From b533c0121f8224f5a2c09e1a9f397746a5ad2741 Mon Sep 17 00:00:00 2001 From: Abdulaziz Albahar <67667005+azooz2003-bit@users.noreply.github.com> Date: Mon, 10 Aug 2026 20:07:36 -0700 Subject: [PATCH 01/16] test: reject false artifact reachability errors --- .../ChatArtifactViewerModelTests.swift | 14 +++++++++ .../MobileArtifactChunkFetchLoopTests.swift | 4 +-- ios/cmuxUITests/cmuxUITests.swift | 30 +++++++++++++++++-- 3 files changed, 43 insertions(+), 5 deletions(-) diff --git a/Packages/iOS/CmuxAgentChatUI/Tests/CmuxAgentChatUITests/ChatArtifactViewerModelTests.swift b/Packages/iOS/CmuxAgentChatUI/Tests/CmuxAgentChatUITests/ChatArtifactViewerModelTests.swift index d7bab973243c..e4ad9eb7f55f 100644 --- a/Packages/iOS/CmuxAgentChatUI/Tests/CmuxAgentChatUITests/ChatArtifactViewerModelTests.swift +++ b/Packages/iOS/CmuxAgentChatUI/Tests/CmuxAgentChatUITests/ChatArtifactViewerModelTests.swift @@ -6,6 +6,20 @@ import Testing @Suite struct ChatArtifactViewerModelTests { + @Test("missing attachment session is not reported as an unreachable Mac") + @MainActor + func missingAttachmentSessionDoesNotClaimMacUnreachable() async { + let loader = ChatArtifactLoader( + supportsArtifacts: true, + stat: { _ in throw ChatArtifactError.sessionNotFound } + ) + let model = ChatArtifactViewerModel() + + await model.load(path: "/tmp/cmux-attachments/ci-failure.png", loader: loader) + + #expect(model.state != .macUnreachable) + } + @Test("large transport chunks are published in bounded UI batches") @MainActor func batchesLargeTransportChunks() async { diff --git a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileArtifactChunkFetchLoopTests.swift b/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileArtifactChunkFetchLoopTests.swift index d78b2c06ccd0..a3e26075af48 100644 --- a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileArtifactChunkFetchLoopTests.swift +++ b/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileArtifactChunkFetchLoopTests.swift @@ -61,7 +61,7 @@ struct MobileArtifactChunkFetchLoopTests { } @Test - func rejectsEmptyNonEOFChunkAsMacUnreachable() async { + func rejectsEmptyNonEOFChunkWithoutClaimingMacUnreachable() async { let stalled = ChatArtifactChunk( data: Data(), offset: 0, @@ -81,7 +81,7 @@ struct MobileArtifactChunkFetchLoopTests { } Issue.record("empty non-EOF chunk should fail") } catch let error as ChatArtifactError { - #expect(error == .macUnreachable) + #expect(error != .macUnreachable) } catch { Issue.record("unexpected error: \(error)") } diff --git a/ios/cmuxUITests/cmuxUITests.swift b/ios/cmuxUITests/cmuxUITests.swift index 30e36258e760..3f1386ece9a9 100644 --- a/ios/cmuxUITests/cmuxUITests.swift +++ b/ios/cmuxUITests/cmuxUITests.swift @@ -4809,6 +4809,26 @@ final class cmuxUITests: XCTestCase { XCTAssertFalse(app.buttons["MobileNewTerminalMenuItem"].exists) } + /// A typed missing-session response from a live Mac must not be flattened + /// into a reachability diagnosis when an attachment opens from chat. + @MainActor + func testAgentChatMissingAttachmentSessionDoesNotClaimMacUnreachable() throws { + let app = launchAgentChatPreviewApp(environment: [ + "CMUX_UITEST_AGENT_CHAT_ARTIFACT_FAILURE": "session_not_found", + ]) + let table = app.tables["ChatTranscriptTableView"] + _ = try scrollToRichAgentChatFixtureRegion(table: table, app: app) + let attachment = app.descendants(matching: .any) + .matching(NSPredicate(format: "label CONTAINS %@", "ci-failure.png")) + .firstMatch + XCTAssertTrue(attachment.waitForExistence(timeout: 4)) + + tap(attachment, in: app) + + XCTAssertTrue(app.staticTexts["Session not found"].waitForExistence(timeout: 5)) + XCTAssertFalse(app.staticTexts["Mac unreachable"].exists) + } + /// Regression for WhatsApp-style chat keyboard tracking: focusing the chat /// composer must translate the actual transcript table frame upward with the /// composer while preserving the table's own bottom-visible content. The table @@ -5917,10 +5937,14 @@ final class cmuxUITests: XCTestCase { } @MainActor - private func launchAgentChatPreviewApp() -> XCUIApplication { - let app = launchApp(mockData: false, environment: [ + private func launchAgentChatPreviewApp(environment: [String: String] = [:]) -> XCUIApplication { + var launchEnvironment = [ "CMUX_UITEST_AGENT_CHAT_PREVIEW": "1", - ]) + ] + for (key, value) in environment { + launchEnvironment[key] = value + } + let app = launchApp(mockData: false, environment: launchEnvironment) XCTAssertTrue(app.tables["ChatTranscriptTableView"].waitForExistence(timeout: 8)) return app } From eadad94321bd7ffc244d3bcc5b544268639f4f90 Mon Sep 17 00:00:00 2001 From: Abdulaziz Albahar <67667005+azooz2003-bit@users.noreply.github.com> Date: Mon, 10 Aug 2026 20:54:29 -0700 Subject: [PATCH 02/16] fix: preserve artifact failure meaning --- .../Artifacts/ChatArtifactError.swift | 4 +- .../ChatArtifactActionVisibilityPolicy.swift | 3 +- .../ChatArtifactContentCacheWriter.swift | 6 +- .../ChatArtifactTemporaryFileWriter.swift | 4 +- .../Artifacts/ChatArtifactViewerModel.swift | 8 ++- .../ChatArtifactViewerRouteView.swift | 12 ++++ .../Artifacts/ChatArtifactViewerState.swift | 2 + .../Resources/Localizable.xcstrings | 68 +++++++++++++++++++ .../ChatArtifactViewerModelTests.swift | 26 ++++++- .../MobileArtifactChunkFetchLoop.swift | 2 +- .../MobileChatArtifactFailureClassifier.swift | 37 ++++++++++ .../MobileChatEventSource.swift | 35 +--------- .../MobileArtifactChunkFetchLoopTests.swift | 2 +- ...leChatArtifactFailureClassifierTests.swift | 31 +++++++++ .../AgentChatDemoScreen.swift | 26 +++++++ .../Resources/Localizable.xcstrings | 28 ++++++++ .../TerminalArtifactFilesSheet+Content.swift | 64 ++++++++++++----- .../TerminalArtifactFilesSheet.swift | 39 ++++++++--- .../TerminalArtifactGalleryFailureTests.swift | 18 +++++ 19 files changed, 345 insertions(+), 70 deletions(-) create mode 100644 Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileChatArtifactFailureClassifier.swift create mode 100644 Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileChatArtifactFailureClassifierTests.swift create mode 100644 Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/TerminalArtifactGalleryFailureTests.swift diff --git a/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Artifacts/ChatArtifactError.swift b/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Artifacts/ChatArtifactError.swift index d70d6d10ade6..f52679aa7965 100644 --- a/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Artifacts/ChatArtifactError.swift +++ b/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Artifacts/ChatArtifactError.swift @@ -14,7 +14,9 @@ public enum ChatArtifactError: Error, Sendable, Equatable { case unsupportedMedia /// The Mac-side artifact service is not wired. case unavailable - /// The Mac could not be reached or did not answer. + /// The source answered, but the artifact response could not be loaded safely. + case loadFailed + /// The control transport to the Mac closed before the request completed. case macUnreachable /// The file exceeds the inline preview size limit. case tooLarge(limitBytes: Int64) diff --git a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactActionVisibilityPolicy.swift b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactActionVisibilityPolicy.swift index a9709f0f589c..8cdce4396307 100644 --- a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactActionVisibilityPolicy.swift +++ b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactActionVisibilityPolicy.swift @@ -21,7 +21,8 @@ struct ChatArtifactActionVisibilityPolicy: Equatable { actions = [.share, .save] inlineStateIdentity = "quick-look" case .loading, .folder, .text, .markdown, .binary, .tooLarge, - .unsupportedMedia, .fileMissing, .macUnreachable, .forbidden: + .unsupportedMedia, .fileMissing, .sessionMissing, .loadFailed, + .macUnreachable, .forbidden: actions = [] inlineStateIdentity = nil } diff --git a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactContentCacheWriter.swift b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactContentCacheWriter.swift index 10ce5f1aa29b..c6365111b4af 100644 --- a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactContentCacheWriter.swift +++ b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactContentCacheWriter.swift @@ -46,20 +46,20 @@ actor ChatArtifactContentCacheWriter { chunk.totalSize == expectedSize, nextOffset <= expectedSize - Int64(chunk.data.count), let fileHandle else { - throw ChatArtifactError.macUnreachable + throw ChatArtifactError.loadFailed } try fileHandle.write(contentsOf: chunk.data) memoryData?.append(chunk.data) nextOffset += Int64(chunk.data.count) reachedEOF = chunk.eof if reachedEOF, nextOffset != expectedSize { - throw ChatArtifactError.macUnreachable + throw ChatArtifactError.loadFailed } } func finish() throws -> Data? { guard reachedEOF, nextOffset == expectedSize, let fileHandle else { - throw ChatArtifactError.macUnreachable + throw ChatArtifactError.loadFailed } try fileHandle.close() self.fileHandle = nil diff --git a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactTemporaryFileWriter.swift b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactTemporaryFileWriter.swift index b10aaf2c4bc5..7051a3f9a35b 100644 --- a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactTemporaryFileWriter.swift +++ b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactTemporaryFileWriter.swift @@ -52,7 +52,7 @@ actor ChatArtifactTemporaryFileWriter { chunk.totalSize <= limit, nextOffset <= limit - Int64(chunk.data.count), let fileHandle else { - throw ChatArtifactError.macUnreachable + throw ChatArtifactError.loadFailed } try fileHandle.write(contentsOf: chunk.data) nextOffset += Int64(chunk.data.count) @@ -60,7 +60,7 @@ actor ChatArtifactTemporaryFileWriter { func finish() throws -> URL { guard let fileHandle else { - throw ChatArtifactError.macUnreachable + throw ChatArtifactError.loadFailed } try fileHandle.close() self.fileHandle = nil diff --git a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactViewerModel.swift b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactViewerModel.swift index 5eb652cc1af6..2f2014b3f61d 100644 --- a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactViewerModel.swift +++ b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactViewerModel.swift @@ -319,15 +319,19 @@ final class ChatArtifactViewerModel { stat: ChatArtifactStat? ) -> ChatArtifactViewerState { guard let artifactError = error as? ChatArtifactError else { - return .macUnreachable + return .loadFailed } switch artifactError { case .fileNotFound: return .fileMissing + case .sessionNotFound: + return .sessionMissing case .forbidden: return .forbidden - case .macUnreachable, .unavailable, .unsupported, .sessionNotFound, .invalidParams: + case .macUnreachable: return .macUnreachable + case .loadFailed, .unavailable, .unsupported, .invalidParams: + return .loadFailed case .unsupportedMedia: return .unsupportedMedia case .tooLarge(let limitBytes): diff --git a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactViewerRouteView.swift b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactViewerRouteView.swift index eb13e96d0423..2bb82991d46f 100644 --- a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactViewerRouteView.swift +++ b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactViewerRouteView.swift @@ -195,6 +195,18 @@ struct ChatArtifactViewerRouteView: View { message: String(localized: "chat.artifact.file_missing.message", defaultValue: "The file is no longer available on your Mac.", bundle: .module), retry: false ) + case .sessionMissing: + unavailableView( + title: String(localized: "chat.artifact.session_missing.title", defaultValue: "Session not found", bundle: .module), + message: String(localized: "chat.artifact.session_missing.message", defaultValue: "The chat session for this file is no longer available.", bundle: .module), + retry: false + ) + case .loadFailed: + unavailableView( + title: String(localized: "chat.artifact.load_failed.title", defaultValue: "Couldn’t load file", bundle: .module), + message: String(localized: "chat.artifact.load_failed.message", defaultValue: "The file couldn’t be loaded. Try again.", bundle: .module), + retry: true + ) case .macUnreachable: unavailableView( title: String(localized: "chat.artifact.mac_unreachable.title", defaultValue: "Mac unreachable", bundle: .module), diff --git a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactViewerState.swift b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactViewerState.swift index 57c7c8b0c7a0..0da3eb15e2bf 100644 --- a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactViewerState.swift +++ b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactViewerState.swift @@ -15,6 +15,8 @@ enum ChatArtifactViewerState: Equatable, Sendable { case tooLarge(actualSize: Int64?, limit: Int64) case unsupportedMedia case fileMissing + case sessionMissing + case loadFailed case macUnreachable case forbidden } diff --git a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Resources/Localizable.xcstrings b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Resources/Localizable.xcstrings index 0242e66e0517..66a03ad8d8e1 100644 --- a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Resources/Localizable.xcstrings +++ b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Resources/Localizable.xcstrings @@ -494,6 +494,40 @@ } } }, + "chat.artifact.load_failed.message": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "The file couldn’t be loaded. Try again." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ファイルを読み込めませんでした。もう一度お試しください。" + } + } + } + }, + "chat.artifact.load_failed.title": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Couldn’t load file" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ファイルを読み込めません" + } + } + } + }, "chat.artifact.loading": { "extractionState": "manual", "localizations": { @@ -545,6 +579,40 @@ } } }, + "chat.artifact.session_missing.message": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "The chat session for this file is no longer available." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "このファイルのチャットセッションは利用できなくなりました。" + } + } + } + }, + "chat.artifact.session_missing.title": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Session not found" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "セッションが見つかりません" + } + } + } + }, "chat.artifact.markdown.raw": { "extractionState": "manual", "localizations": { diff --git a/Packages/iOS/CmuxAgentChatUI/Tests/CmuxAgentChatUITests/ChatArtifactViewerModelTests.swift b/Packages/iOS/CmuxAgentChatUI/Tests/CmuxAgentChatUITests/ChatArtifactViewerModelTests.swift index e4ad9eb7f55f..6ff0e3d8ea1d 100644 --- a/Packages/iOS/CmuxAgentChatUI/Tests/CmuxAgentChatUITests/ChatArtifactViewerModelTests.swift +++ b/Packages/iOS/CmuxAgentChatUI/Tests/CmuxAgentChatUITests/ChatArtifactViewerModelTests.swift @@ -17,7 +17,31 @@ struct ChatArtifactViewerModelTests { await model.load(path: "/tmp/cmux-attachments/ci-failure.png", loader: loader) - #expect(model.state != .macUnreachable) + #expect(model.state == .sessionMissing) + } + + @Test("only a reachability error reports an unreachable Mac") + @MainActor + func distinguishesLoadFailureFromMacReachability() async { + let loadFailureModel = ChatArtifactViewerModel() + await loadFailureModel.load( + path: "/tmp/broken.txt", + loader: ChatArtifactLoader( + supportsArtifacts: true, + stat: { _ in throw ChatArtifactError.loadFailed } + ) + ) + let unreachableModel = ChatArtifactViewerModel() + await unreachableModel.load( + path: "/tmp/unreachable.txt", + loader: ChatArtifactLoader( + supportsArtifacts: true, + stat: { _ in throw ChatArtifactError.macUnreachable } + ) + ) + + #expect(loadFailureModel.state == .loadFailed) + #expect(unreachableModel.state == .macUnreachable) } @Test("large transport chunks are published in bounded UI batches") diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileArtifactChunkFetchLoop.swift b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileArtifactChunkFetchLoop.swift index baadfaa14b84..5b759b5513fc 100644 --- a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileArtifactChunkFetchLoop.swift +++ b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileArtifactChunkFetchLoop.swift @@ -37,7 +37,7 @@ struct MobileArtifactChunkFetchLoop: Sendable { return result } guard !chunk.data.isEmpty else { - throw ChatArtifactError.macUnreachable + throw ChatArtifactError.loadFailed } } } diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileChatArtifactFailureClassifier.swift b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileChatArtifactFailureClassifier.swift new file mode 100644 index 000000000000..6f921169a432 --- /dev/null +++ b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileChatArtifactFailureClassifier.swift @@ -0,0 +1,37 @@ +internal import CmuxAgentChat +internal import CmuxMobileRPC +internal import Foundation + +/// Keeps artifact availability separate from control-transport reachability. +struct MobileChatArtifactFailureClassifier: Sendable { + func classify(_ error: any Error) -> ChatArtifactError { + guard let connectionError = error as? MobileShellConnectionError else { + return .loadFailed + } + switch connectionError { + case .connectionClosed: + return .macUnreachable + case .invalidResponse, .requestTimedOut, .transportWriteTimedOut, + .connectAttemptGated, .insecureManualRoute, .attachTicketExpired, + .authorizationFailed, .accountMismatch, .routeCleanupBlocked: + return .loadFailed + case .rpcError(let code, _): + switch code?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() { + case "invalid_params": + return .invalidParams + case "not_found": + return .sessionNotFound + case "forbidden": + return .forbidden + case "file_not_found": + return .fileNotFound + case "unsupported_media": + return .unsupportedMedia + case "unavailable": + return .unavailable + default: + return .loadFailed + } + } + } +} diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileChatEventSource.swift b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileChatEventSource.swift index 5ebd994dcebb..22aea1904800 100644 --- a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileChatEventSource.swift +++ b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileChatEventSource.swift @@ -362,7 +362,7 @@ public actor MobileChatEventSource: ChatEventSource { } catch is CancellationError { throw CancellationError() } catch { - throw Self.artifactError(from: error) + throw MobileChatArtifactFailureClassifier().classify(error) } } @@ -419,9 +419,9 @@ public actor MobileChatEventSource: ChatEventSource { } catch MobileArtifactLaneFetchError.failedAfterFirstByte { // Once the lane exposed bytes, mixing in an RPC restart could // splice two file versions into one preview. - throw ChatArtifactError.macUnreachable + throw ChatArtifactError.loadFailed } catch MobileArtifactLaneFetchError.invalidDescriptor { - throw ChatArtifactError.macUnreachable + throw ChatArtifactError.loadFailed } } return try await fetchArtifactChunksOverRPC( @@ -454,33 +454,4 @@ public actor MobileChatEventSource: ChatEventSource { } } - private nonisolated static func artifactError(from error: any Error) -> ChatArtifactError { - guard let connectionError = error as? MobileShellConnectionError else { - return .macUnreachable - } - switch connectionError { - case .invalidResponse, .connectionClosed, .requestTimedOut, - .transportWriteTimedOut, .connectAttemptGated, - .insecureManualRoute, .attachTicketExpired, - .authorizationFailed, .accountMismatch, .routeCleanupBlocked: - return .macUnreachable - case .rpcError(let code, _): - switch code?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() { - case "invalid_params": - return .invalidParams - case "not_found": - return .sessionNotFound - case "forbidden": - return .forbidden - case "file_not_found": - return .fileNotFound - case "unsupported_media": - return .unsupportedMedia - case "unavailable": - return .unavailable - default: - return .macUnreachable - } - } - } } diff --git a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileArtifactChunkFetchLoopTests.swift b/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileArtifactChunkFetchLoopTests.swift index a3e26075af48..c62def81d4c8 100644 --- a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileArtifactChunkFetchLoopTests.swift +++ b/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileArtifactChunkFetchLoopTests.swift @@ -81,7 +81,7 @@ struct MobileArtifactChunkFetchLoopTests { } Issue.record("empty non-EOF chunk should fail") } catch let error as ChatArtifactError { - #expect(error != .macUnreachable) + #expect(error == .loadFailed) } catch { Issue.record("unexpected error: \(error)") } diff --git a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileChatArtifactFailureClassifierTests.swift b/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileChatArtifactFailureClassifierTests.swift new file mode 100644 index 000000000000..24842cabd35e --- /dev/null +++ b/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileChatArtifactFailureClassifierTests.swift @@ -0,0 +1,31 @@ +import CmuxAgentChat +import CmuxMobileRPC +import Foundation +import Testing + +@testable import CmuxMobileShell + +@Suite +struct MobileChatArtifactFailureClassifierTests { + @Test + func reservesMacUnreachableForControlTransportLoss() { + let classifier = MobileChatArtifactFailureClassifier() + + #expect(classifier.classify(MobileShellConnectionError.connectionClosed) == .macUnreachable) + #expect(classifier.classify(MobileShellConnectionError.requestTimedOut) == .loadFailed) + #expect(classifier.classify(MobileShellConnectionError.transportWriteTimedOut) == .loadFailed) + #expect(classifier.classify(MobileShellConnectionError.invalidResponse) == .loadFailed) + #expect(classifier.classify(MobileShellConnectionError.connectAttemptGated) == .loadFailed) + #expect(classifier.classify(CocoaError(.fileReadUnknown)) == .loadFailed) + } + + @Test + func preservesServerArtifactFailures() { + let classifier = MobileChatArtifactFailureClassifier() + + #expect(classifier.classify(MobileShellConnectionError.rpcError("not_found", "gone")) == .sessionNotFound) + #expect(classifier.classify(MobileShellConnectionError.rpcError("file_not_found", "gone")) == .fileNotFound) + #expect(classifier.classify(MobileShellConnectionError.rpcError("forbidden", "denied")) == .forbidden) + #expect(classifier.classify(MobileShellConnectionError.rpcError("unexpected", "bad")) == .loadFailed) + } +} diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/AgentChatDemoScreen.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/AgentChatDemoScreen.swift index ea09af438ca6..b0d30d728ba4 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/AgentChatDemoScreen.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/AgentChatDemoScreen.swift @@ -12,6 +12,7 @@ import SwiftUI /// host serves real transcripts. struct AgentChatDemoScreen: View { let style: AgentChatDemoScreenStyle + private let artifactLoader: ChatArtifactLoader @Environment(\.dismiss) private var dismiss @State private var stack: DemoStack? @@ -19,6 +20,7 @@ struct AgentChatDemoScreen: View { init(style: AgentChatDemoScreenStyle = .standalone) { self.style = style + artifactLoader = Self.makeArtifactLoader() } var body: some View { @@ -148,6 +150,7 @@ struct AgentChatDemoScreen: View { providesOwnChrome: false, onOpenTerminal: {} ) + .environment(\.chatArtifactLoader, artifactLoader) case .inlineWorkspace: ChatScreen( store: stack.store, @@ -156,9 +159,32 @@ struct AgentChatDemoScreen: View { providesOwnChrome: false, onOpenTerminal: {} ) + .environment(\.chatArtifactLoader, artifactLoader) } } + private static func makeArtifactLoader() -> ChatArtifactLoader { + let failure = UITestConfig.value( + for: "CMUX_UITEST_AGENT_CHAT_ARTIFACT_FAILURE", + env: ProcessInfo.processInfo.environment + ) + let error: ChatArtifactError + switch failure { + case "session_not_found": + error = .sessionNotFound + case "load_failed": + error = .loadFailed + case "mac_unreachable": + error = .macUnreachable + default: + return .unsupported() + } + return ChatArtifactLoader( + supportsArtifacts: true, + stat: { _ in throw error } + ) + } + private func header(for stack: DemoStack) -> some View { ChatSessionHeaderView( descriptor: stack.store.descriptor, diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/Resources/Localizable.xcstrings b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/Resources/Localizable.xcstrings index f7e0b93263d1..2c0e845c9d2a 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/Resources/Localizable.xcstrings +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/Resources/Localizable.xcstrings @@ -310,6 +310,20 @@ } } }, + "terminal.artifact.gallery.load_failed.message" : { + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "The file list couldn’t be loaded. Try again." } }, + "ja" : { "stringUnit" : { "state" : "translated", "value" : "ファイル一覧を読み込めませんでした。もう一度お試しください。" } } + } + }, + "terminal.artifact.gallery.load_failed.title" : { + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "Couldn’t load files" } }, + "ja" : { "stringUnit" : { "state" : "translated", "value" : "ファイルを読み込めません" } } + } + }, "terminal.artifact.gallery.loading" : { "extractionState" : "manual", "localizations" : { @@ -458,6 +472,20 @@ } } }, + "terminal.artifact.gallery.session_missing.message" : { + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "The chat session for these files is no longer available." } }, + "ja" : { "stringUnit" : { "state" : "translated", "value" : "これらのファイルのチャットセッションは利用できなくなりました。" } } + } + }, + "terminal.artifact.gallery.session_missing.title" : { + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "Session not found" } }, + "ja" : { "stringUnit" : { "state" : "translated", "value" : "セッションが見つかりません" } } + } + }, "terminal.artifact.gallery.showing_first" : { "extractionState" : "manual", "localizations" : { diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalArtifactFilesSheet+Content.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalArtifactFilesSheet+Content.swift index 9589bd03752e..27faac4be764 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalArtifactFilesSheet+Content.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalArtifactFilesSheet+Content.swift @@ -107,8 +107,8 @@ extension TerminalArtifactFilesSheet { ) .refreshable { await refreshInView() } } - case .failed: - failureView { await refreshInView() } + case .failed(let failure): + failureView(failure: failure) { await refreshInView() } } } @@ -143,8 +143,8 @@ extension TerminalArtifactFilesSheet { switch state { case .idle, .loading: loadingView - case .failed: - failureView { await loadFirstSessionPage(query: nil) } + case .failed(let failure): + failureView(failure: failure) { await loadFirstSessionPage(query: nil) } case .loaded(let snapshot): let visibleSnapshotIsEmpty = displaySettings.showMissingFiles ? snapshot.isEmpty @@ -285,8 +285,8 @@ extension TerminalArtifactFilesSheet { switch state { case .idle, .loading: loadingView - case .failed: - failureView { await loadFirstSessionPage(query: query) } + case .failed(let failure): + failureView(failure: failure) { await loadFirstSessionPage(query: query) } case .loaded(let snapshot): let presentation = ChatArtifactGalleryPresentation( snapshot: snapshot, @@ -532,22 +532,17 @@ extension TerminalArtifactFilesSheet { .frame(maxWidth: .infinity, maxHeight: .infinity) } - private func failureView(retry: @escaping @MainActor () async -> Void) -> some View { + private func failureView( + failure: TerminalArtifactGalleryFailure, + retry: @escaping @MainActor () async -> Void + ) -> some View { ContentUnavailableView { Label( - String( - localized: "terminal.artifact.gallery.unreachable.title", - defaultValue: "Mac unreachable", - bundle: .module - ), - systemImage: "wifi.exclamationmark" + failureTitle(failure), + systemImage: failureSystemImage(failure) ) } description: { - Text(String( - localized: "terminal.artifact.gallery.unreachable.message", - defaultValue: "Check the connection to your Mac and try again.", - bundle: .module - )) + Text(failureMessage(failure)) } actions: { Button { Task { await retry() } @@ -565,6 +560,39 @@ extension TerminalArtifactFilesSheet { } } + private func failureTitle(_ failure: TerminalArtifactGalleryFailure) -> String { + switch failure { + case .macUnreachable: + String(localized: "terminal.artifact.gallery.unreachable.title", defaultValue: "Mac unreachable", bundle: .module) + case .sessionMissing: + String(localized: "terminal.artifact.gallery.session_missing.title", defaultValue: "Session not found", bundle: .module) + case .loadFailed: + String(localized: "terminal.artifact.gallery.load_failed.title", defaultValue: "Couldn’t load files", bundle: .module) + } + } + + private func failureMessage(_ failure: TerminalArtifactGalleryFailure) -> String { + switch failure { + case .macUnreachable: + String(localized: "terminal.artifact.gallery.unreachable.message", defaultValue: "Check the connection to your Mac and try again.", bundle: .module) + case .sessionMissing: + String(localized: "terminal.artifact.gallery.session_missing.message", defaultValue: "The chat session for these files is no longer available.", bundle: .module) + case .loadFailed: + String(localized: "terminal.artifact.gallery.load_failed.message", defaultValue: "The file list couldn’t be loaded. Try again.", bundle: .module) + } + } + + private func failureSystemImage(_ failure: TerminalArtifactGalleryFailure) -> String { + switch failure { + case .macUnreachable: + "wifi.exclamationmark" + case .sessionMissing: + "exclamationmark.bubble" + case .loadFailed: + "exclamationmark.triangle" + } + } + private var gridColumns: [GridItem] { Array( repeating: GridItem(.flexible(minimum: 0), spacing: 12, alignment: .top), diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalArtifactFilesSheet.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalArtifactFilesSheet.swift index 71a75ea9505f..1c65c1971177 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalArtifactFilesSheet.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalArtifactFilesSheet.swift @@ -50,6 +50,28 @@ struct TerminalArtifactSelection: Identifiable, Equatable { var id: String { "\(workspaceID)#\(surfaceID)#\(sessionID ?? "terminal")#\(path)" } } +enum TerminalArtifactGalleryFailure: Equatable { + case macUnreachable + case sessionMissing + case loadFailed + + init(error: any Error) { + guard let artifactError = error as? ChatArtifactError else { + self = .loadFailed + return + } + switch artifactError { + case .macUnreachable: + self = .macUnreachable + case .sessionNotFound: + self = .sessionMissing + case .unsupported, .invalidParams, .forbidden, .fileNotFound, + .unsupportedMedia, .unavailable, .loadFailed, .tooLarge: + self = .loadFailed + } + } +} + struct TerminalArtifactFilesSheet: View { let workspaceID: String let surfaceID: String @@ -173,7 +195,7 @@ struct TerminalArtifactFilesSheet: View { private func loadInitial() async { guard let source else { - inViewState = .failed + inViewState = .failed(.loadFailed) scope = .inView return } @@ -203,14 +225,14 @@ struct TerminalArtifactFilesSheet: View { } catch is CancellationError { return } catch { - inViewState = .failed + inViewState = .failed(TerminalArtifactGalleryFailure(error: error)) scope = .inView } } func refreshInView() async { guard let source else { - inViewState = .failed + inViewState = .failed(.loadFailed) return } do { @@ -227,7 +249,7 @@ struct TerminalArtifactFilesSheet: View { } catch is CancellationError { return } catch { - inViewState = .failed + inViewState = .failed(TerminalArtifactGalleryFailure(error: error)) } } @@ -265,10 +287,11 @@ struct TerminalArtifactFilesSheet: View { return } catch { if !preservingContent { + let failure = TerminalArtifactGalleryFailure(error: error) if query == nil { - sessionState = .failed + sessionState = .failed(failure) } else { - searchState = .failed + searchState = .failed(failure) } } } @@ -541,14 +564,14 @@ struct TerminalArtifactFilesSheet: View { enum InViewLoadState: Equatable { case loading case loaded([TerminalArtifactReference]) - case failed + case failed(TerminalArtifactGalleryFailure) } enum SessionLoadState: Equatable { case idle case loading case loaded(SessionGallerySnapshot) - case failed + case failed(TerminalArtifactGalleryFailure) } typealias SessionGallerySnapshot = ChatArtifactGallerySnapshot diff --git a/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/TerminalArtifactGalleryFailureTests.swift b/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/TerminalArtifactGalleryFailureTests.swift new file mode 100644 index 000000000000..34b2e3bf2065 --- /dev/null +++ b/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/TerminalArtifactGalleryFailureTests.swift @@ -0,0 +1,18 @@ +#if os(iOS) +import CmuxAgentChat +import Foundation +import Testing + +@testable import CmuxMobileShellUI + +@Suite +struct TerminalArtifactGalleryFailureTests { + @Test + func preservesSpecificFailureMeaning() { + #expect(TerminalArtifactGalleryFailure(error: ChatArtifactError.macUnreachable) == .macUnreachable) + #expect(TerminalArtifactGalleryFailure(error: ChatArtifactError.sessionNotFound) == .sessionMissing) + #expect(TerminalArtifactGalleryFailure(error: ChatArtifactError.fileNotFound) == .loadFailed) + #expect(TerminalArtifactGalleryFailure(error: CocoaError(.fileReadUnknown)) == .loadFailed) + } +} +#endif From 63cb7152e1366c94a9eaba25f148bd2ba6afd22e Mon Sep 17 00:00:00 2001 From: Abdulaziz Albahar <67667005+azooz2003-bit@users.noreply.github.com> Date: Mon, 10 Aug 2026 21:36:38 -0700 Subject: [PATCH 03/16] docs: clarify artifact load failure contract --- .../Sources/CmuxAgentChat/Artifacts/ChatArtifactError.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Artifacts/ChatArtifactError.swift b/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Artifacts/ChatArtifactError.swift index f52679aa7965..3795a96584ff 100644 --- a/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Artifacts/ChatArtifactError.swift +++ b/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Artifacts/ChatArtifactError.swift @@ -14,7 +14,7 @@ public enum ChatArtifactError: Error, Sendable, Equatable { case unsupportedMedia /// The Mac-side artifact service is not wired. case unavailable - /// The source answered, but the artifact response could not be loaded safely. + /// The artifact operation failed without confirmed closure of the control transport. case loadFailed /// The control transport to the Mac closed before the request completed. case macUnreachable From a2a44e083dec4947f17231af24cd12a66f67f139 Mon Sep 17 00:00:00 2001 From: Abdulaziz Albahar <67667005+azooz2003-bit@users.noreply.github.com> Date: Mon, 10 Aug 2026 21:44:08 -0700 Subject: [PATCH 04/16] test: reveal missing chat attachment before tapping --- ios/cmuxUITests/cmuxUITests.swift | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/ios/cmuxUITests/cmuxUITests.swift b/ios/cmuxUITests/cmuxUITests.swift index 3f1386ece9a9..253e7f910bfc 100644 --- a/ios/cmuxUITests/cmuxUITests.swift +++ b/ios/cmuxUITests/cmuxUITests.swift @@ -4822,6 +4822,10 @@ final class cmuxUITests: XCTestCase { .matching(NSPredicate(format: "label CONTAINS %@", "ci-failure.png")) .firstMatch XCTAssertTrue(attachment.waitForExistence(timeout: 4)) + guard scrollTranscript(table, toReveal: attachment, timeout: 10) else { + XCTFail("Missing attachment never became visible") + return + } tap(attachment, in: app) @@ -7406,6 +7410,23 @@ final class cmuxUITests: XCTestCase { return frame } + @MainActor + private func scrollTranscript( + _ table: XCUIElement, + toReveal element: XCUIElement, + timeout: TimeInterval + ) -> Bool { + let deadline = Date().addingTimeInterval(timeout) + while Date() < deadline { + if element.isHittable { + return true + } + table.swipeDown(velocity: .slow) + RunLoop.current.run(until: Date().addingTimeInterval(0.12)) + } + return element.isHittable + } + @MainActor private func scrollToRichAgentChatFixtureRegion( table: XCUIElement, From 27203cb3065a1a28bc6d3c67ae596f82a33cb193 Mon Sep 17 00:00:00 2001 From: Abdulaziz Albahar <67667005+azooz2003-bit@users.noreply.github.com> Date: Mon, 10 Aug 2026 22:06:30 -0700 Subject: [PATCH 05/16] fix: preserve attachment button semantics --- .../Transcript/Rows/ChatAttachmentBubbleView.swift | 6 +++++- ios/cmuxUITests/cmuxUITests.swift | 9 +++------ 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Transcript/Rows/ChatAttachmentBubbleView.swift b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Transcript/Rows/ChatAttachmentBubbleView.swift index 7d03df8cba80..9e6d080d789f 100644 --- a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Transcript/Rows/ChatAttachmentBubbleView.swift +++ b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Transcript/Rows/ChatAttachmentBubbleView.swift @@ -62,7 +62,6 @@ public struct ChatAttachmentBubbleView: View { .padding(.horizontal, 4) } } - .accessibilityElement(children: .combine) } .frame(maxWidth: .infinity, alignment: .trailing) .sheet(item: $fallbackSelection) { selection in @@ -87,11 +86,16 @@ public struct ChatAttachmentBubbleView: View { } } .buttonStyle(.plain) + .accessibilityElement(children: .ignore) + .accessibilityLabel(Text(verbatim: displayName)) + .accessibilityValue(Text(verbatim: hostPath)) + .accessibilityIdentifier("ChatAttachmentButton") .task(id: hostPath) { await loadThumbnail(path: hostPath) } } else { bubble + .accessibilityElement(children: .combine) } } diff --git a/ios/cmuxUITests/cmuxUITests.swift b/ios/cmuxUITests/cmuxUITests.swift index 253e7f910bfc..eaadfcffd668 100644 --- a/ios/cmuxUITests/cmuxUITests.swift +++ b/ios/cmuxUITests/cmuxUITests.swift @@ -4818,10 +4818,9 @@ final class cmuxUITests: XCTestCase { ]) let table = app.tables["ChatTranscriptTableView"] _ = try scrollToRichAgentChatFixtureRegion(table: table, app: app) - let attachment = app.descendants(matching: .any) - .matching(NSPredicate(format: "label CONTAINS %@", "ci-failure.png")) - .firstMatch + let attachment = app.buttons["ChatAttachmentButton"] XCTAssertTrue(attachment.waitForExistence(timeout: 4)) + XCTAssertEqual(attachment.label, "ci-failure.png") guard scrollTranscript(table, toReveal: attachment, timeout: 10) else { XCTFail("Missing attachment never became visible") return @@ -7434,9 +7433,7 @@ final class cmuxUITests: XCTestCase { file: StaticString = #filePath, line: UInt = #line ) throws -> ChatTranscriptMetrics { - let imageAttachment = app.descendants(matching: .any) - .matching(NSPredicate(format: "label CONTAINS %@", "ci-failure.png")) - .firstMatch + let imageAttachment = app.buttons["ChatAttachmentButton"] let cardElements = [ app.buttons["ChatQuestionOption0"], app.buttons["ChatPermissionApprove"], From 609fa9d7510b5d3a810fec0555ee55681216676b Mon Sep 17 00:00:00 2001 From: Abdulaziz Albahar <67667005+azooz2003-bit@users.noreply.github.com> Date: Mon, 10 Aug 2026 22:27:20 -0700 Subject: [PATCH 06/16] fix: keep native attachment button trait --- .../Transcript/Rows/ChatAttachmentBubbleView.swift | 1 - 1 file changed, 1 deletion(-) diff --git a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Transcript/Rows/ChatAttachmentBubbleView.swift b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Transcript/Rows/ChatAttachmentBubbleView.swift index 9e6d080d789f..c5c5f9ecf6cd 100644 --- a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Transcript/Rows/ChatAttachmentBubbleView.swift +++ b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Transcript/Rows/ChatAttachmentBubbleView.swift @@ -86,7 +86,6 @@ public struct ChatAttachmentBubbleView: View { } } .buttonStyle(.plain) - .accessibilityElement(children: .ignore) .accessibilityLabel(Text(verbatim: displayName)) .accessibilityValue(Text(verbatim: hostPath)) .accessibilityIdentifier("ChatAttachmentButton") From f69b30040f1ab32d4c0bde74699ff2b6a27ba057 Mon Sep 17 00:00:00 2001 From: Abdulaziz Albahar <67667005+azooz2003-bit@users.noreply.github.com> Date: Mon, 10 Aug 2026 22:30:19 -0700 Subject: [PATCH 07/16] test: keep rich fixture lookup role agnostic --- ios/cmuxUITests/cmuxUITests.swift | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/ios/cmuxUITests/cmuxUITests.swift b/ios/cmuxUITests/cmuxUITests.swift index eaadfcffd668..953a8f939d5a 100644 --- a/ios/cmuxUITests/cmuxUITests.swift +++ b/ios/cmuxUITests/cmuxUITests.swift @@ -7433,7 +7433,9 @@ final class cmuxUITests: XCTestCase { file: StaticString = #filePath, line: UInt = #line ) throws -> ChatTranscriptMetrics { - let imageAttachment = app.buttons["ChatAttachmentButton"] + let imageAttachment = app.descendants(matching: .any) + .matching(NSPredicate(format: "label CONTAINS %@", "ci-failure.png")) + .firstMatch let cardElements = [ app.buttons["ChatQuestionOption0"], app.buttons["ChatPermissionApprove"], From 6e0b9ab20e7791b90777f0c42d7dbfe770ba390e Mon Sep 17 00:00:00 2001 From: Abdulaziz Albahar <67667005+azooz2003-bit@users.noreply.github.com> Date: Mon, 10 Aug 2026 23:31:56 -0700 Subject: [PATCH 08/16] test: tap attachment by visible identity --- ios/cmuxUITests/cmuxUITests.swift | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/ios/cmuxUITests/cmuxUITests.swift b/ios/cmuxUITests/cmuxUITests.swift index 953a8f939d5a..1bdcbf053c23 100644 --- a/ios/cmuxUITests/cmuxUITests.swift +++ b/ios/cmuxUITests/cmuxUITests.swift @@ -4818,9 +4818,10 @@ final class cmuxUITests: XCTestCase { ]) let table = app.tables["ChatTranscriptTableView"] _ = try scrollToRichAgentChatFixtureRegion(table: table, app: app) - let attachment = app.buttons["ChatAttachmentButton"] + let attachment = app.descendants(matching: .any) + .matching(NSPredicate(format: "label == %@", "ci-failure.png")) + .firstMatch XCTAssertTrue(attachment.waitForExistence(timeout: 4)) - XCTAssertEqual(attachment.label, "ci-failure.png") guard scrollTranscript(table, toReveal: attachment, timeout: 10) else { XCTFail("Missing attachment never became visible") return From 5847420158becb430fe467c086fb56169a433392 Mon Sep 17 00:00:00 2001 From: Abdulaziz Albahar <67667005+azooz2003-bit@users.noreply.github.com> Date: Mon, 10 Aug 2026 23:58:41 -0700 Subject: [PATCH 09/16] fix: preserve loader across artifact navigation --- .../Sources/CmuxAgentChatUI/Screen/ChatScreen.swift | 2 ++ .../Transcript/ChatBlockDetailSheetView.swift | 1 + ios/cmuxUITests/cmuxUITests.swift | 6 +++--- 3 files changed, 6 insertions(+), 3 deletions(-) diff --git a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Screen/ChatScreen.swift b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Screen/ChatScreen.swift index 667ad17960ef..b4575c10565d 100644 --- a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Screen/ChatScreen.swift +++ b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Screen/ChatScreen.swift @@ -15,6 +15,7 @@ import UIKit /// (drafts, attachments). public struct ChatScreen: View { @Environment(ToastCenter.self) private var toasts + @Environment(\.chatArtifactLoader) private var artifactLoader @State private var store: ChatConversationStore @State private var renderer = ChatMarkdownRenderer() @State private var contentCache = ChatContentCache() @@ -98,6 +99,7 @@ public struct ChatScreen: View { ChatArtifactViewerDestination(path: selectedArtifact.path) { self.selectedArtifact = nil } + .environment(\.chatArtifactLoader, artifactLoader) } } .task { diff --git a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Transcript/ChatBlockDetailSheetView.swift b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Transcript/ChatBlockDetailSheetView.swift index 4aeed0647442..de2a88d5b705 100644 --- a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Transcript/ChatBlockDetailSheetView.swift +++ b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Transcript/ChatBlockDetailSheetView.swift @@ -76,6 +76,7 @@ struct ChatBlockDetailSheetView: View { ChatArtifactViewerDestination(path: selectedArtifact.path) { dismiss() } + .environment(\.chatArtifactLoader, artifactLoader) } } } diff --git a/ios/cmuxUITests/cmuxUITests.swift b/ios/cmuxUITests/cmuxUITests.swift index 1bdcbf053c23..b8a5b9a8c675 100644 --- a/ios/cmuxUITests/cmuxUITests.swift +++ b/ios/cmuxUITests/cmuxUITests.swift @@ -4814,14 +4814,14 @@ final class cmuxUITests: XCTestCase { @MainActor func testAgentChatMissingAttachmentSessionDoesNotClaimMacUnreachable() throws { let app = launchAgentChatPreviewApp(environment: [ + "CMUX_UITEST_MOCK_DATA": "1", "CMUX_UITEST_AGENT_CHAT_ARTIFACT_FAILURE": "session_not_found", ]) let table = app.tables["ChatTranscriptTableView"] _ = try scrollToRichAgentChatFixtureRegion(table: table, app: app) - let attachment = app.descendants(matching: .any) - .matching(NSPredicate(format: "label == %@", "ci-failure.png")) - .firstMatch + let attachment = app.buttons["ChatAttachmentButton"] XCTAssertTrue(attachment.waitForExistence(timeout: 4)) + XCTAssertEqual(attachment.label, "ci-failure.png") guard scrollTranscript(table, toReveal: attachment, timeout: 10) else { XCTFail("Missing attachment never became visible") return From 77a9cbf5c4002495d046deb0dfe3e1358ddc165d Mon Sep 17 00:00:00 2001 From: Abdulaziz Albahar <67667005+azooz2003-bit@users.noreply.github.com> Date: Tue, 11 Aug 2026 11:17:27 -0700 Subject: [PATCH 10/16] test: expose lossy artifact failure handling --- .../ArtifactByteReaderTests.swift | 43 +++++++++++++++++-- .../ChatArtifactTemporaryFileStoreTests.swift | 35 +++++++++++++++ .../ChatArtifactViewerModelTests.swift | 34 +++++++++++++++ .../MobileArtifactChunkFetchLoopTests.swift | 27 +++++++++++- ...leChatArtifactFailureClassifierTests.swift | 12 ++++-- ...ChangesContentFingerprintPolicyTests.swift | 29 +++++++++---- .../TerminalArtifactGalleryFailureTests.swift | 2 +- 7 files changed, 165 insertions(+), 17 deletions(-) diff --git a/Packages/Shared/CmuxAgentChat/Tests/CmuxAgentChatTests/ArtifactByteReaderTests.swift b/Packages/Shared/CmuxAgentChat/Tests/CmuxAgentChatTests/ArtifactByteReaderTests.swift index 4f4e68f6c137..e5819d6d60d1 100644 --- a/Packages/Shared/CmuxAgentChat/Tests/CmuxAgentChatTests/ArtifactByteReaderTests.swift +++ b/Packages/Shared/CmuxAgentChat/Tests/CmuxAgentChatTests/ArtifactByteReaderTests.swift @@ -23,7 +23,7 @@ struct ArtifactByteReaderTests { } } - @Test("listing a file keeps the existing file-not-found semantic") + @Test("listing a file is not reported as a missing file") func listingFile() throws { try withTemporaryDirectory { directory in let file = directory.appendingPathComponent("artifact.txt") @@ -33,9 +33,46 @@ struct ArtifactByteReaderTests { _ = try ArtifactByteReader().list(path: file.path) Issue.record("listing a file should fail") } catch ArtifactByteReader.Error.fileNotFound { - // Expected wire semantic. + Issue.record("an existing non-directory must not be reported as missing") } catch { - Issue.record("unexpected error: \(error)") + // Expected: the path exists, but directory listing does not apply. + } + } + } + + @Test("permission denial is not reported as a missing file") + func permissionDenied() throws { + try withTemporaryDirectory { directory in + guard Darwin.geteuid() != 0 else { return } + let file = directory.appendingPathComponent("private.txt") + try Data("secret".utf8).write(to: file) + try #require(Darwin.chmod(file.path, 0o000) == 0) + defer { _ = Darwin.chmod(file.path, 0o600) } + + do { + _ = try ArtifactByteReader().fetch(path: file.path, offset: 0, length: 16) + Issue.record("an unreadable file should fail") + } catch ArtifactByteReader.Error.fileNotFound { + Issue.record("permission denial must not be reported as a missing file") + } catch { + // Expected: a distinct permission/read failure. + } + } + } + + @Test("damaged image data is not reported as an unsupported file type") + func damagedImage() throws { + try withTemporaryDirectory { directory in + let file = directory.appendingPathComponent("damaged.png") + try Data("not a png".utf8).write(to: file) + + do { + _ = try ArtifactByteReader().thumbnail(path: file.path, maxDimension: 128) + Issue.record("damaged image data should fail") + } catch ArtifactByteReader.Error.unsupportedMedia { + Issue.record("a recognized but damaged image must not be reported as an unsupported type") + } catch { + // Expected: a distinct decode failure. } } } diff --git a/Packages/iOS/CmuxAgentChatUI/Tests/CmuxAgentChatUITests/ChatArtifactTemporaryFileStoreTests.swift b/Packages/iOS/CmuxAgentChatUI/Tests/CmuxAgentChatUITests/ChatArtifactTemporaryFileStoreTests.swift index 07ea27dd5f29..e8550d3f7e6a 100644 --- a/Packages/iOS/CmuxAgentChatUI/Tests/CmuxAgentChatUITests/ChatArtifactTemporaryFileStoreTests.swift +++ b/Packages/iOS/CmuxAgentChatUI/Tests/CmuxAgentChatUITests/ChatArtifactTemporaryFileStoreTests.swift @@ -96,4 +96,39 @@ struct ChatArtifactTemporaryFileStoreTests { } #expect(try FileManager.default.contentsOfDirectory(atPath: directory.path).isEmpty) } + + @Test("rejects a stream that ends before the stat size") + func rejectsTruncatedStream() async throws { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-artifact-truncated-test-\(UUID().uuidString)", isDirectory: true) + defer { try? FileManager.default.removeItem(at: directory) } + let partial = Data("partial".utf8) + let expectedSize = Int64(partial.count + 4) + let loader = ChatArtifactLoader( + supportsArtifacts: true, + stream: { _, onChunk in + try await onChunk(ChatArtifactChunk( + data: partial, + offset: 0, + totalSize: expectedSize, + eof: false + )) + } + ) + + do { + _ = try await ChatArtifactTemporaryFileStore(directory: directory).fetch( + path: "/remote/truncated.pdf", + expectedSize: expectedSize, + limit: 1_024, + loader: loader, + progress: { _ in } + ) + Issue.record("a truncated stream must not produce a preview file") + } catch is ChatArtifactError { + // Expected: the incomplete transfer remains typed end to end. + } catch { + Issue.record("unexpected error: \(error)") + } + } } diff --git a/Packages/iOS/CmuxAgentChatUI/Tests/CmuxAgentChatUITests/ChatArtifactViewerModelTests.swift b/Packages/iOS/CmuxAgentChatUI/Tests/CmuxAgentChatUITests/ChatArtifactViewerModelTests.swift index 6ff0e3d8ea1d..45300f83b0d7 100644 --- a/Packages/iOS/CmuxAgentChatUI/Tests/CmuxAgentChatUITests/ChatArtifactViewerModelTests.swift +++ b/Packages/iOS/CmuxAgentChatUI/Tests/CmuxAgentChatUITests/ChatArtifactViewerModelTests.swift @@ -44,6 +44,40 @@ struct ChatArtifactViewerModelTests { #expect(unreachableModel.state == .macUnreachable) } + @Test("damaged image bytes become a visible failure") + @MainActor + func damagedImageDoesNotBecomeABlankPreview() async { + let data = Data("not an image".utf8) + let loader = ChatArtifactLoader( + supportsArtifacts: true, + stat: { _ in + ChatArtifactStat( + exists: true, + isDirectory: false, + size: Int64(data.count), + modifiedAt: Date(timeIntervalSince1970: 0), + kind: .image, + mimeType: "image/png" + ) + }, + stream: { _, onChunk in + try await onChunk(ChatArtifactChunk( + data: data, + offset: 0, + totalSize: Int64(data.count), + eof: true + )) + } + ) + let model = ChatArtifactViewerModel() + + await model.load(path: "/tmp/damaged.png", loader: loader) + + if case .image = model.state { + Issue.record("damaged image bytes must render an explicit failure instead of a blank image") + } + } + @Test("large transport chunks are published in bounded UI batches") @MainActor func batchesLargeTransportChunks() async { diff --git a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileArtifactChunkFetchLoopTests.swift b/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileArtifactChunkFetchLoopTests.swift index c62def81d4c8..b79710d1d5cd 100644 --- a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileArtifactChunkFetchLoopTests.swift +++ b/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileArtifactChunkFetchLoopTests.swift @@ -81,7 +81,7 @@ struct MobileArtifactChunkFetchLoopTests { } Issue.record("empty non-EOF chunk should fail") } catch let error as ChatArtifactError { - #expect(error == .loadFailed) + #expect(error != .loadFailed) } catch { Issue.record("unexpected error: \(error)") } @@ -90,4 +90,29 @@ struct MobileArtifactChunkFetchLoopTests { #expect(snapshot.requestedOffsets == [0]) #expect(snapshot.deliveredChunks == [stalled]) } + + @Test + func rejectsOutOfOrderFinalChunk() async { + let malformed = ChatArtifactChunk( + data: Data("abc".utf8), + offset: 4, + totalSize: 7, + eof: true + ) + let script = MobileArtifactChunkScript(chunks: [malformed]) + + do { + _ = try await MobileArtifactChunkFetchLoop().run( + collectsData: true, + progress: nil + ) { offset in + try await script.fetch(offset: offset) + } onChunk: { _ in } + Issue.record("an out-of-order chunk should fail") + } catch is ChatArtifactError { + // Expected: malformed transfer metadata is a typed protocol failure. + } catch { + Issue.record("unexpected error: \(error)") + } + } } diff --git a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileChatArtifactFailureClassifierTests.swift b/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileChatArtifactFailureClassifierTests.swift index 24842cabd35e..0503b3c596d8 100644 --- a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileChatArtifactFailureClassifierTests.swift +++ b/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileChatArtifactFailureClassifierTests.swift @@ -12,10 +12,14 @@ struct MobileChatArtifactFailureClassifierTests { let classifier = MobileChatArtifactFailureClassifier() #expect(classifier.classify(MobileShellConnectionError.connectionClosed) == .macUnreachable) - #expect(classifier.classify(MobileShellConnectionError.requestTimedOut) == .loadFailed) - #expect(classifier.classify(MobileShellConnectionError.transportWriteTimedOut) == .loadFailed) - #expect(classifier.classify(MobileShellConnectionError.invalidResponse) == .loadFailed) - #expect(classifier.classify(MobileShellConnectionError.connectAttemptGated) == .loadFailed) + #expect(classifier.classify(MobileShellConnectionError.requestTimedOut) != .loadFailed) + #expect(classifier.classify(MobileShellConnectionError.transportWriteTimedOut) != .loadFailed) + #expect(classifier.classify(MobileShellConnectionError.invalidResponse) != .loadFailed) + #expect(classifier.classify(MobileShellConnectionError.connectAttemptGated) != .loadFailed) + #expect(classifier.classify(MobileShellConnectionError.routeCleanupBlocked) != .loadFailed) + #expect(classifier.classify(MobileShellConnectionError.attachTicketExpired) != .loadFailed) + #expect(classifier.classify(MobileShellConnectionError.authorizationFailed("denied")) != .loadFailed) + #expect(classifier.classify(MobileShellConnectionError.accountMismatch("wrong account")) != .loadFailed) #expect(classifier.classify(CocoaError(.fileReadUnknown)) == .loadFailed) } diff --git a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/WorkspaceChangesContentFingerprintPolicyTests.swift b/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/WorkspaceChangesContentFingerprintPolicyTests.swift index 639a04db128d..0ff3df96aa4d 100644 --- a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/WorkspaceChangesContentFingerprintPolicyTests.swift +++ b/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/WorkspaceChangesContentFingerprintPolicyTests.swift @@ -17,8 +17,8 @@ import Testing ) } - @Test func mismatchFailsWithRetryableArtifactError() { - #expect(throws: ChatArtifactError.macUnreachable) { + @Test func mismatchDoesNotClaimMacUnreachable() { + expectFailureOtherThanMacUnreachable { try WorkspaceChangesContentFingerprintPolicy().validate( expected: "stat:10:100:2:300:101", observed: "stat:10:100:2:301:101" @@ -26,8 +26,8 @@ import Testing } } - @Test func missingFingerprintAfterEstablishmentFailsWithRetryableError() { - #expect(throws: ChatArtifactError.macUnreachable) { + @Test func missingFingerprintAfterEstablishmentDoesNotClaimMacUnreachable() { + expectFailureOtherThanMacUnreachable { try WorkspaceChangesContentFingerprintPolicy().validate( expected: "stat:10:100:2:300:101", observed: nil @@ -35,14 +35,14 @@ import Testing } } - @Test func missingExpectedFingerprintFailsWithRetryableError() { - #expect(throws: ChatArtifactError.macUnreachable) { + @Test func missingExpectedFingerprintDoesNotClaimMacUnreachable() { + expectFailureOtherThanMacUnreachable { try WorkspaceChangesContentFingerprintPolicy().validate( expected: nil, observed: "stat:10:100:2:300:101" ) } - #expect(throws: ChatArtifactError.macUnreachable) { + expectFailureOtherThanMacUnreachable { try WorkspaceChangesContentFingerprintPolicy().validate( expected: nil, observed: nil @@ -51,11 +51,24 @@ import Testing } @Test func presentLegacyFingerprintShapeIsRejected() { - #expect(throws: ChatArtifactError.macUnreachable) { + expectFailureOtherThanMacUnreachable { try WorkspaceChangesContentFingerprintPolicy().validate( expected: "stat:10:100", observed: "stat:10:100" ) } } + + private func expectFailureOtherThanMacUnreachable( + _ operation: () throws -> Void + ) { + do { + try operation() + Issue.record("invalid fingerprint state should fail") + } catch let error as ChatArtifactError { + #expect(error != .macUnreachable) + } catch { + Issue.record("unexpected error: \(error)") + } + } } diff --git a/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/TerminalArtifactGalleryFailureTests.swift b/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/TerminalArtifactGalleryFailureTests.swift index 34b2e3bf2065..11e3a36e4aea 100644 --- a/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/TerminalArtifactGalleryFailureTests.swift +++ b/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/TerminalArtifactGalleryFailureTests.swift @@ -11,7 +11,7 @@ struct TerminalArtifactGalleryFailureTests { func preservesSpecificFailureMeaning() { #expect(TerminalArtifactGalleryFailure(error: ChatArtifactError.macUnreachable) == .macUnreachable) #expect(TerminalArtifactGalleryFailure(error: ChatArtifactError.sessionNotFound) == .sessionMissing) - #expect(TerminalArtifactGalleryFailure(error: ChatArtifactError.fileNotFound) == .loadFailed) + #expect(TerminalArtifactGalleryFailure(error: ChatArtifactError.fileNotFound) != .loadFailed) #expect(TerminalArtifactGalleryFailure(error: CocoaError(.fileReadUnknown)) == .loadFailed) } } From 7ef631bec36c8e23ff6005b96072877c8a47ad08 Mon Sep 17 00:00:00 2001 From: Abdulaziz Albahar <67667005+azooz2003-bit@users.noreply.github.com> Date: Tue, 11 Aug 2026 12:22:55 -0700 Subject: [PATCH 11/16] fix: preserve artifact fetch failure reasons --- .../Artifacts/ArtifactByteReader.swift | 122 ++++-- .../ChatArtifactChunkValidator.swift | 60 +++ .../Artifacts/ChatArtifactError.swift | 46 ++- .../ArtifactByteReaderTests.swift | 48 +-- .../ChatArtifactChunkValidatorTests.swift | 89 +++++ .../ChatArtifactActionVisibilityPolicy.swift | 4 +- .../Artifacts/ChatArtifactContentCache.swift | 55 ++- .../ChatArtifactContentCacheWriter.swift | 53 +-- .../ChatArtifactFailurePresentation.swift | 311 +++++++++++++++ .../Artifacts/ChatArtifactFolderView.swift | 37 +- .../Artifacts/ChatArtifactInlineViewer.swift | 34 +- .../Artifacts/ChatArtifactLoader.swift | 11 +- .../ChatArtifactLocalFailureClassifier.swift | 52 +++ .../ChatArtifactTemporaryFileStore.swift | 6 +- .../ChatArtifactTemporaryFileWriter.swift | 58 ++- .../ChatArtifactViewerFileActionState.swift | 6 +- .../Artifacts/ChatArtifactViewerModel.swift | 40 +- .../ChatArtifactViewerPageModel.swift | 8 +- .../Artifacts/ChatArtifactViewerPager.swift | 19 +- .../ChatArtifactViewerRouteView.swift | 63 +--- .../Artifacts/ChatArtifactViewerScope.swift | 5 +- .../Artifacts/ChatArtifactViewerState.swift | 8 +- .../Resources/Localizable.xcstrings | 357 ++++++++++++++++++ ...tArtifactActionVisibilityPolicyTests.swift | 18 +- ...ChatArtifactFailurePresentationTests.swift | 76 ++++ .../ChatArtifactTemporaryFileStoreTests.swift | 4 +- .../ChatArtifactViewerModelTests.swift | 108 ++++-- .../MobileArtifactChunkFetchLoop.swift | 6 +- .../MobileChatArtifactFailureClassifier.swift | 79 +++- .../MobileChatEventSource.swift | 6 +- ...ellComposite+WorkspaceChangesContent.swift | 33 +- ...spaceChangesContentFingerprintPolicy.swift | 14 +- .../MobileArtifactChunkFetchLoopTests.swift | 14 +- ...leChatArtifactFailureClassifierTests.swift | 82 +++- ...ChangesContentFingerprintPolicyTests.swift | 22 +- .../AgentChatDemoScreen.swift | 58 +++ .../TerminalArtifactFilesSheet+Content.swift | 69 ++-- .../TerminalArtifactFilesSheet.swift | 22 +- .../TerminalArtifactGalleryItemView.swift | 34 +- .../TerminalArtifactGalleryFailureTests.swift | 8 +- Resources/Localizable.xcstrings | 66 ++++ .../MobileHostIrohApplicationLaneRouter.swift | 56 ++- ...rminalController+MobileChatArtifacts.swift | 214 ++++++++--- ...alController+MobileTerminalArtifacts.swift | 40 +- ...nalController+MobileWorkspaceChanges.swift | 2 +- cmuxTests/MobileHostIrohAdmissionTests.swift | 30 ++ ios/cmuxUITests/cmuxUITests.swift | 25 ++ 47 files changed, 2071 insertions(+), 507 deletions(-) create mode 100644 Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Artifacts/ChatArtifactChunkValidator.swift create mode 100644 Packages/Shared/CmuxAgentChat/Tests/CmuxAgentChatTests/ChatArtifactChunkValidatorTests.swift create mode 100644 Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactFailurePresentation.swift create mode 100644 Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactLocalFailureClassifier.swift create mode 100644 Packages/iOS/CmuxAgentChatUI/Tests/CmuxAgentChatUITests/ChatArtifactFailurePresentationTests.swift diff --git a/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Artifacts/ArtifactByteReader.swift b/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Artifacts/ArtifactByteReader.swift index aeb06002a95e..a110ddafc9ea 100644 --- a/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Artifacts/ArtifactByteReader.swift +++ b/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Artifacts/ArtifactByteReader.swift @@ -13,11 +13,23 @@ public struct ArtifactByteReader: Sendable { private static let utf8SniffByteCount = 8 * 1024 /// Filesystem/decoder failures surfaced by artifact RPC handlers. - public enum Error: Swift.Error, Sendable { + public enum Error: Swift.Error, Sendable, Equatable { /// The scoped path no longer exists or cannot be statted. case fileNotFound + /// The scoped path exists but cannot be read by cmux. + case permissionDenied + /// The operation requires a directory, but the path is not one. + case notDirectory + /// The operation requires a regular file, but the path is another filesystem type. + case notRegularFile /// The operation does not apply to this media type. case unsupportedMedia + /// The path has a supported media type, but its bytes cannot be decoded. + case corruptMedia + /// A decoded image could not be encoded as a thumbnail. + case previewFailed + /// The path exists, but a filesystem operation failed for another reason. + case readFailed } /// Creates a byte reader. @@ -31,17 +43,18 @@ public struct ArtifactByteReader: Sendable { /// Reads one clamped byte chunk for an already-authorized file path. public func fetch(path: String, offset: Int64, length: Int) throws -> ChatArtifactChunk { - let attributes = try attributes(path: path) - guard (attributes[.type] as? FileAttributeType) == .typeRegular else { - throw Error.unsupportedMedia - } let opened = try openVerifiedRegularFile(path: path) let handle = opened.handle defer { try? handle.close() } let totalSize = opened.size let clampedOffset = min(max(offset, 0), totalSize) - try handle.seek(toOffset: UInt64(clampedOffset)) - let data = try handle.read(upToCount: max(0, length)) ?? Data() + let data: Data + do { + try handle.seek(toOffset: UInt64(clampedOffset)) + data = try handle.read(upToCount: max(0, length)) ?? Data() + } catch { + throw filesystemError(error) + } let endOffset = clampedOffset + Int64(data.count) return ChatArtifactChunk( data: data, @@ -83,28 +96,31 @@ public struct ArtifactByteReader: Sendable { } let url = URL(fileURLWithPath: path) guard let source = CGImageSourceCreateWithURL(url as CFURL, nil) else { - throw Error.unsupportedMedia + _ = try attributes(path: path) + throw Error.corruptMedia } let options: [CFString: Any] = [ kCGImageSourceCreateThumbnailFromImageAlways: true, kCGImageSourceThumbnailMaxPixelSize: maxDimension, kCGImageSourceCreateThumbnailWithTransform: true, ] - guard let image = CGImageSourceCreateThumbnailAtIndex(source, 0, options as CFDictionary), - let destinationData = CFDataCreateMutable(nil, 0), + guard let image = CGImageSourceCreateThumbnailAtIndex(source, 0, options as CFDictionary) else { + throw Error.corruptMedia + } + guard let destinationData = CFDataCreateMutable(nil, 0), let destination = CGImageDestinationCreateWithData( destinationData, UTType.jpeg.identifier as CFString, 1, nil ) else { - throw Error.unsupportedMedia + throw Error.previewFailed } CGImageDestinationAddImage(destination, image, [ kCGImageDestinationLossyCompressionQuality: 0.82, ] as CFDictionary) guard CGImageDestinationFinalize(destination) else { - throw Error.unsupportedMedia + throw Error.previewFailed } return ChatArtifactThumbnail( data: destinationData as Data, @@ -120,25 +136,38 @@ public struct ArtifactByteReader: Sendable { /// read only for the capped entries that the listing actually returns. public func list(path: String) throws -> ChatArtifactDirectoryListing { let stat = try stat(path: path) - guard stat.isDirectory else { throw Error.fileNotFound } - let names = try FileManager.default.contentsOfDirectory(atPath: path) + guard stat.isDirectory else { throw Error.notDirectory } + let names: [String] + do { + names = try FileManager.default.contentsOfDirectory(atPath: path) + } catch { + throw filesystemError(error) + } let sortedNames = names.sorted { $0.localizedStandardCompare($1) == .orderedAscending } let directoryURL = URL(fileURLWithPath: path, isDirectory: true) - let listed = try sortedNames - .prefix(Self.maximumDirectoryEntryCount) - .map { name -> ChatArtifactDirectoryEntry in + var listed: [ChatArtifactDirectoryEntry] = [] + listed.reserveCapacity(min(sortedNames.count, Self.maximumDirectoryEntryCount)) + for name in sortedNames.prefix(Self.maximumDirectoryEntryCount) { + do { let entry = directoryURL.appendingPathComponent(name) let values = try entry.resourceValues(forKeys: [.isDirectoryKey, .fileSizeKey]) let isDirectory = values.isDirectory ?? false - return ChatArtifactDirectoryEntry( + listed.append(ChatArtifactDirectoryEntry( name: name, isDirectory: isDirectory, size: Int64(values.fileSize ?? 0), kind: kind(path: entry.path, isDirectory: isDirectory) - ) + )) + } catch { + let failure = filesystemError(error) + if failure == .fileNotFound { + continue + } + throw failure } + } return ChatArtifactDirectoryListing( entries: listed, isTruncated: names.count > Self.maximumDirectoryEntryCount @@ -226,23 +255,25 @@ public struct ArtifactByteReader: Sendable { func openVerifiedRegularFile(path: String) throws -> (handle: FileHandle, size: Int64) { // Set close-on-exec atomically at open; fcntl afterward cannot close the fork race. let descriptor = Darwin.open(path, O_RDONLY | O_NONBLOCK | O_CLOEXEC) - guard descriptor >= 0 else { throw Error.fileNotFound } + guard descriptor >= 0 else { throw filesystemError(errno: Darwin.errno) } var metadata = Darwin.stat() guard Darwin.fstat(descriptor, &metadata) == 0 else { + let errorCode = Darwin.errno Darwin.close(descriptor) - throw Error.fileNotFound + throw filesystemError(errno: errorCode) } guard (metadata.st_mode & S_IFMT) == S_IFREG else { Darwin.close(descriptor) - throw Error.unsupportedMedia + throw Error.notRegularFile } let flags = Darwin.fcntl(descriptor, F_GETFL, 0) guard flags >= 0, Darwin.fcntl(descriptor, F_SETFL, flags & ~O_NONBLOCK) >= 0 else { + let errorCode = Darwin.errno Darwin.close(descriptor) - throw Error.fileNotFound + throw filesystemError(errno: errorCode) } return ( @@ -289,7 +320,50 @@ public struct ArtifactByteReader: Sendable { do { return try FileManager.default.attributesOfItem(atPath: path) } catch { - throw Error.fileNotFound + throw filesystemError(error) + } + } + + private func filesystemError(_ error: any Swift.Error) -> Error { + if let readerError = error as? Error { + return readerError + } + if let posixError = error as? POSIXError { + return filesystemError(errno: posixError.code.rawValue) + } + let nsError = error as NSError + if nsError.domain == NSPOSIXErrorDomain { + return filesystemError(errno: Int32(nsError.code)) + } + if let underlying = nsError.userInfo[NSUnderlyingErrorKey] as? NSError, + underlying !== nsError { + return filesystemError(underlying) + } + if let cocoaError = error as? CocoaError { + switch cocoaError.code { + case .fileReadNoSuchFile: + return .fileNotFound + case .fileReadNoPermission: + return .permissionDenied + default: + break + } + } + return .readFailed + } + + private func filesystemError(errno errorCode: Int32) -> Error { + switch POSIXErrorCode(rawValue: errorCode) { + case .ENOENT, .ESTALE: + return .fileNotFound + case .EACCES, .EPERM: + return .permissionDenied + case .ENOTDIR: + return .notDirectory + case .EISDIR: + return .notRegularFile + default: + return .readFailed } } diff --git a/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Artifacts/ChatArtifactChunkValidator.swift b/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Artifacts/ChatArtifactChunkValidator.swift new file mode 100644 index 000000000000..d7d6d128507e --- /dev/null +++ b/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Artifacts/ChatArtifactChunkValidator.swift @@ -0,0 +1,60 @@ +/// Validates the ordered chunk protocol shared by artifact transports and stores. +public struct ChatArtifactChunkValidator: Sendable { + private let expectedSize: Int64? + private var observedSize: Int64? + private var nextOffset: Int64 = 0 + private var reachedEOF = false + private var receivedChunk = false + + /// Creates a validator, optionally pinned to metadata fetched before the stream. + /// + /// - Parameter expectedSize: The stat-reported size, or `nil` when the first + /// chunk establishes the transfer size. + public init(expectedSize: Int64? = nil) { + self.expectedSize = expectedSize + } + + /// Accepts one chunk only when its offset, size, and EOF metadata are coherent. + public mutating func receive(_ chunk: ChatArtifactChunk) throws { + guard expectedSize.map({ $0 >= 0 }) ?? true, + !reachedEOF, + chunk.offset >= 0, + chunk.totalSize >= 0, + chunk.offset == nextOffset else { + throw ChatArtifactError.invalidResponse + } + if let expectedSize, chunk.totalSize != expectedSize { + throw ChatArtifactError.fileChanged + } + if let observedSize, chunk.totalSize != observedSize { + throw ChatArtifactError.fileChanged + } + observedSize = chunk.totalSize + + let byteCount = Int64(chunk.data.count) + guard byteCount <= chunk.totalSize - chunk.offset else { + throw ChatArtifactError.invalidResponse + } + let endOffset = chunk.offset + byteCount + if chunk.eof { + guard endOffset == chunk.totalSize else { + throw ChatArtifactError.invalidResponse + } + } else { + guard byteCount > 0, endOffset < chunk.totalSize else { + throw ChatArtifactError.invalidResponse + } + } + + receivedChunk = true + nextOffset = endOffset + reachedEOF = chunk.eof + } + + /// Confirms that the producer returned an exact-size EOF chunk before ending. + public func finish() throws { + guard receivedChunk, reachedEOF else { + throw ChatArtifactError.transferInterrupted + } + } +} diff --git a/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Artifacts/ChatArtifactError.swift b/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Artifacts/ChatArtifactError.swift index 3795a96584ff..a65004ab52a6 100644 --- a/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Artifacts/ChatArtifactError.swift +++ b/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Artifacts/ChatArtifactError.swift @@ -6,14 +6,58 @@ public enum ChatArtifactError: Error, Sendable, Equatable { case invalidParams /// The chat session no longer exists on the Mac. case sessionNotFound + /// The chat session exists, but its transcript or artifact index cannot be read. + case sessionUnavailable + /// The terminal authorizing the requested path no longer exists on the Mac. + case terminalNotFound + /// The workspace authorizing the requested path no longer exists on the Mac. + case workspaceNotFound + /// The workspace directory is no longer a Git repository. + case notRepository /// The requested path was not referenced by the session. case forbidden /// The path was in scope but no longer exists on the Mac. case fileNotFound - /// The path is not an image or cannot be decoded as one. + /// The Mac found the path but does not have permission to read it. + case permissionDenied + /// A directory listing was requested for a path that is not a directory. + case notDirectory + /// File bytes were requested for a path that is not a regular file. + case notRegularFile + /// The Mac found the file but could not read its metadata or bytes. + case fileReadFailed + /// The file changed after the transfer began. + case fileChanged + /// The operation does not apply to this media type. case unsupportedMedia + /// The media type is supported, but its data is damaged or invalid. + case corruptMedia + /// Valid media data could not be converted into a preview. + case previewFailed /// The Mac-side artifact service is not wired. case unavailable + /// The Mac or transport returned malformed or inconsistent transfer data. + case invalidResponse + /// A transfer ended after starting but before all bytes arrived. + case transferInterrupted + /// The file request did not complete before its deadline. + case requestTimedOut + /// An equivalent connection attempt is already in progress. + case connectionRecovering + /// Stuck connection cleanup prevents another attempt until restart. + case connectionNeedsRestart + /// The selected route cannot securely transfer host files. + case secureConnectionRequired + /// The attach credential expired before the file request completed. + case authenticationExpired + /// The paired Mac rejected this device's authorization. + case authorizationFailed + /// The phone and Mac are signed in to different accounts. + case accountMismatch + /// The local device has no remaining space for a temporary file. + case localStorageFull + /// The local device could not create or update a temporary file. + case localStorageUnavailable /// The artifact operation failed without confirmed closure of the control transport. case loadFailed /// The control transport to the Mac closed before the request completed. diff --git a/Packages/Shared/CmuxAgentChat/Tests/CmuxAgentChatTests/ArtifactByteReaderTests.swift b/Packages/Shared/CmuxAgentChat/Tests/CmuxAgentChatTests/ArtifactByteReaderTests.swift index e5819d6d60d1..6196388406bc 100644 --- a/Packages/Shared/CmuxAgentChat/Tests/CmuxAgentChatTests/ArtifactByteReaderTests.swift +++ b/Packages/Shared/CmuxAgentChat/Tests/CmuxAgentChatTests/ArtifactByteReaderTests.swift @@ -29,13 +29,23 @@ struct ArtifactByteReaderTests { let file = directory.appendingPathComponent("artifact.txt") #expect(FileManager.default.createFile(atPath: file.path, contents: Data("hello".utf8))) - do { - _ = try ArtifactByteReader().list(path: file.path) - Issue.record("listing a file should fail") - } catch ArtifactByteReader.Error.fileNotFound { - Issue.record("an existing non-directory must not be reported as missing") - } catch { - // Expected: the path exists, but directory listing does not apply. + #expect(throws: ArtifactByteReader.Error.notDirectory) { + try ArtifactByteReader().list(path: file.path) + } + } + } + + @Test("a path removed from the Mac is reported as missing") + func missingPath() throws { + try withTemporaryDirectory { directory in + let missing = directory.appendingPathComponent("removed.txt") + let reader = ArtifactByteReader() + + #expect(throws: ArtifactByteReader.Error.fileNotFound) { + try reader.stat(path: missing.path) + } + #expect(throws: ArtifactByteReader.Error.fileNotFound) { + try reader.fetch(path: missing.path, offset: 0, length: 16) } } } @@ -49,13 +59,8 @@ struct ArtifactByteReaderTests { try #require(Darwin.chmod(file.path, 0o000) == 0) defer { _ = Darwin.chmod(file.path, 0o600) } - do { - _ = try ArtifactByteReader().fetch(path: file.path, offset: 0, length: 16) - Issue.record("an unreadable file should fail") - } catch ArtifactByteReader.Error.fileNotFound { - Issue.record("permission denial must not be reported as a missing file") - } catch { - // Expected: a distinct permission/read failure. + #expect(throws: ArtifactByteReader.Error.permissionDenied) { + try ArtifactByteReader().fetch(path: file.path, offset: 0, length: 16) } } } @@ -66,13 +71,8 @@ struct ArtifactByteReaderTests { let file = directory.appendingPathComponent("damaged.png") try Data("not a png".utf8).write(to: file) - do { - _ = try ArtifactByteReader().thumbnail(path: file.path, maxDimension: 128) - Issue.record("damaged image data should fail") - } catch ArtifactByteReader.Error.unsupportedMedia { - Issue.record("a recognized but damaged image must not be reported as an unsupported type") - } catch { - // Expected: a distinct decode failure. + #expect(throws: ArtifactByteReader.Error.corruptMedia) { + try ArtifactByteReader().thumbnail(path: file.path, maxDimension: 128) } } } @@ -182,7 +182,7 @@ struct ArtifactByteReaderTests { do { _ = try ArtifactByteReader().fetch(path: fifo.path, offset: 0, length: 1) Issue.record("fetching a FIFO should fail") - } catch ArtifactByteReader.Error.unsupportedMedia { + } catch ArtifactByteReader.Error.notRegularFile { // Expected: opening a FIFO for reading could block indefinitely. } catch { Issue.record("unexpected error: \(error)") @@ -203,7 +203,7 @@ struct ArtifactByteReaderTests { do { _ = try ArtifactByteReader().thumbnail(path: fifo.path, maxDimension: 128) Issue.record("thumbnailing a FIFO should fail") - } catch ArtifactByteReader.Error.unsupportedMedia { + } catch ArtifactByteReader.Error.notRegularFile { // Expected: ImageIO must never open an unverified FIFO path. } catch { Issue.record("unexpected error: \(error)") @@ -225,7 +225,7 @@ struct ArtifactByteReaderTests { let opened = try ArtifactByteReader().openVerifiedRegularFile(path: fifo.path) try? opened.handle.close() Issue.record("descriptor validation should reject a FIFO") - } catch ArtifactByteReader.Error.unsupportedMedia { + } catch ArtifactByteReader.Error.notRegularFile { // Expected: the nonblocking descriptor is identified as a FIFO. } catch { Issue.record("unexpected error: \(error)") diff --git a/Packages/Shared/CmuxAgentChat/Tests/CmuxAgentChatTests/ChatArtifactChunkValidatorTests.swift b/Packages/Shared/CmuxAgentChat/Tests/CmuxAgentChatTests/ChatArtifactChunkValidatorTests.swift new file mode 100644 index 000000000000..aeb1fcdbf460 --- /dev/null +++ b/Packages/Shared/CmuxAgentChat/Tests/CmuxAgentChatTests/ChatArtifactChunkValidatorTests.swift @@ -0,0 +1,89 @@ +import Foundation +import Testing + +@testable import CmuxAgentChat + +@Suite +struct ChatArtifactChunkValidatorTests { + @Test + func acceptsOrderedExactSizeTransfer() throws { + var validator = ChatArtifactChunkValidator(expectedSize: 3) + + try validator.receive(ChatArtifactChunk( + data: Data("ab".utf8), + offset: 0, + totalSize: 3, + eof: false + )) + try validator.receive(ChatArtifactChunk( + data: Data("c".utf8), + offset: 2, + totalSize: 3, + eof: true + )) + + try validator.finish() + } + + @Test + func changedTotalDoesNotMasqueradeAsConnectionLoss() throws { + var validator = ChatArtifactChunkValidator(expectedSize: 3) + + #expect(throws: ChatArtifactError.fileChanged) { + try validator.receive(ChatArtifactChunk( + data: Data("abcd".utf8), + offset: 0, + totalSize: 4, + eof: true + )) + } + } + + @Test + func rejectsMalformedOffsetsAndEOF() throws { + var negativeExpectedSize = ChatArtifactChunkValidator(expectedSize: -1) + #expect(throws: ChatArtifactError.invalidResponse) { + try negativeExpectedSize.receive(ChatArtifactChunk( + data: Data(), + offset: 0, + totalSize: 0, + eof: true + )) + } + + var offsetValidator = ChatArtifactChunkValidator() + #expect(throws: ChatArtifactError.invalidResponse) { + try offsetValidator.receive(ChatArtifactChunk( + data: Data("a".utf8), + offset: 1, + totalSize: 2, + eof: false + )) + } + + var eofValidator = ChatArtifactChunkValidator() + #expect(throws: ChatArtifactError.invalidResponse) { + try eofValidator.receive(ChatArtifactChunk( + data: Data("a".utf8), + offset: 0, + totalSize: 2, + eof: true + )) + } + } + + @Test + func producerEndingWithoutEOFIsInterrupted() throws { + var validator = ChatArtifactChunkValidator(expectedSize: 2) + try validator.receive(ChatArtifactChunk( + data: Data("a".utf8), + offset: 0, + totalSize: 2, + eof: false + )) + + #expect(throws: ChatArtifactError.transferInterrupted) { + try validator.finish() + } + } +} diff --git a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactActionVisibilityPolicy.swift b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactActionVisibilityPolicy.swift index 8cdce4396307..7333c0501655 100644 --- a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactActionVisibilityPolicy.swift +++ b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactActionVisibilityPolicy.swift @@ -20,9 +20,7 @@ struct ChatArtifactActionVisibilityPolicy: Equatable { case .quickLook: actions = [.share, .save] inlineStateIdentity = "quick-look" - case .loading, .folder, .text, .markdown, .binary, .tooLarge, - .unsupportedMedia, .fileMissing, .sessionMissing, .loadFailed, - .macUnreachable, .forbidden: + case .loading, .folder, .text, .markdown, .binary, .failure: actions = [] inlineStateIdentity = nil } diff --git a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactContentCache.swift b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactContentCache.swift index daa5f33105ee..2810dde8161c 100644 --- a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactContentCache.swift +++ b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactContentCache.swift @@ -79,9 +79,12 @@ public actor ChatArtifactContentCache { ) async throws -> Bool { if let data = memoryCache.object(forKey: key as NSString) { let value = Data(referencing: data) - try? touch(fileURL(for: key), at: accessedAt) - try await receive(Self.chunk(data: value, totalSize: expectedSize)) - return true + if Int64(value.count) == expectedSize { + try? touch(fileURL(for: key), at: accessedAt) + try await receive(Self.chunk(data: value, totalSize: expectedSize)) + return true + } + memoryCache.removeObject(forKey: key as NSString) } if try await replayDiskEntry( @@ -93,12 +96,18 @@ public actor ChatArtifactContentCache { return true } - let writer = try ChatArtifactContentCacheWriter( - directory: directory, - key: key, - expectedSize: expectedSize, - retainsMemoryCopy: expectedSize <= Int64(maxMemoryBytes) - ) + let writer: ChatArtifactContentCacheWriter + do { + writer = try ChatArtifactContentCacheWriter( + directory: directory, + key: key, + expectedSize: expectedSize, + retainsMemoryCopy: expectedSize <= Int64(maxMemoryBytes) + ) + } catch { + try await fetch(receive) + return false + } do { try await fetch { chunk in try Task.checkCancellation() @@ -110,8 +119,8 @@ public actor ChatArtifactContentCache { if let data, maxMemoryBytes > 0 { memoryCache.setObject(data as NSData, forKey: key as NSString, cost: data.count) } - try touch(fileURL(for: key), at: accessedAt) - try enforceDiskBudget() + try? touch(fileURL(for: key), at: accessedAt) + try? enforceDiskBudget() return false } catch { await writer.discard() @@ -131,19 +140,35 @@ public actor ChatArtifactContentCache { try? fileManager.removeItem(at: url) return false } - try touch(url, at: accessedAt) + try? touch(url, at: accessedAt) if expectedSize == 0 { try await receive(Self.chunk(data: Data(), totalSize: 0)) return true } - let handle = try FileHandle(forReadingFrom: url) + let handle: FileHandle + do { + handle = try FileHandle(forReadingFrom: url) + } catch { + try? fileManager.removeItem(at: url) + return false + } defer { try? handle.close() } var offset: Int64 = 0 while offset < expectedSize { try Task.checkCancellation() let requested = Int(min(Int64(Self.diskReadChunkBytes), expectedSize - offset)) - guard let data = try handle.read(upToCount: requested), !data.isEmpty else { - throw CocoaError(.fileReadCorruptFile) + let data: Data + do { + guard let bytes = try handle.read(upToCount: requested), !bytes.isEmpty else { + throw CocoaError(.fileReadCorruptFile) + } + data = bytes + } catch { + try? fileManager.removeItem(at: url) + if offset == 0 { + return false + } + throw ChatArtifactError.localStorageUnavailable } let chunkOffset = offset offset += Int64(data.count) diff --git a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactContentCacheWriter.swift b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactContentCacheWriter.swift index c6365111b4af..19bb7a147341 100644 --- a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactContentCacheWriter.swift +++ b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactContentCacheWriter.swift @@ -3,13 +3,11 @@ import Foundation /// Serializes one artifact stream into an atomic content-cache entry. actor ChatArtifactContentCacheWriter { - private let expectedSize: Int64 private let temporaryURL: URL private let destinationURL: URL private var fileHandle: FileHandle? private var memoryData: Data? - private var nextOffset: Int64 = 0 - private var reachedEOF = false + private var validator: ChatArtifactChunkValidator init( directory: URL, @@ -17,9 +15,9 @@ actor ChatArtifactContentCacheWriter { expectedSize: Int64, retainsMemoryCopy: Bool ) throws { + validator = ChatArtifactChunkValidator(expectedSize: expectedSize) let fileManager = FileManager() try fileManager.createDirectory(at: directory, withIntermediateDirectories: true) - self.expectedSize = expectedSize destinationURL = directory.appendingPathComponent(key, isDirectory: false) temporaryURL = directory.appendingPathComponent( ".\(key).\(UUID().uuidString).partial", @@ -41,33 +39,38 @@ actor ChatArtifactContentCacheWriter { } func append(_ chunk: ChatArtifactChunk) throws { - guard !reachedEOF, - chunk.offset == nextOffset, - chunk.totalSize == expectedSize, - nextOffset <= expectedSize - Int64(chunk.data.count), - let fileHandle else { - throw ChatArtifactError.loadFailed + try validator.receive(chunk) + if let fileHandle { + do { + try fileHandle.write(contentsOf: chunk.data) + } catch { + disablePersistence() + } } - try fileHandle.write(contentsOf: chunk.data) memoryData?.append(chunk.data) - nextOffset += Int64(chunk.data.count) - reachedEOF = chunk.eof - if reachedEOF, nextOffset != expectedSize { - throw ChatArtifactError.loadFailed - } } func finish() throws -> Data? { - guard reachedEOF, nextOffset == expectedSize, let fileHandle else { - throw ChatArtifactError.loadFailed + try validator.finish() + guard let fileHandle else { + return memoryData + } + do { + try fileHandle.close() + } catch { + disablePersistence() + return memoryData } - try fileHandle.close() self.fileHandle = nil let fileManager = FileManager() - if fileManager.fileExists(atPath: destinationURL.path) { - try fileManager.removeItem(at: destinationURL) + do { + if fileManager.fileExists(atPath: destinationURL.path) { + try fileManager.removeItem(at: destinationURL) + } + try fileManager.moveItem(at: temporaryURL, to: destinationURL) + } catch { + try? fileManager.removeItem(at: temporaryURL) } - try fileManager.moveItem(at: temporaryURL, to: destinationURL) return memoryData } @@ -76,4 +79,10 @@ actor ChatArtifactContentCacheWriter { fileHandle = nil try? FileManager().removeItem(at: temporaryURL) } + + private func disablePersistence() { + try? fileHandle?.close() + fileHandle = nil + try? FileManager().removeItem(at: temporaryURL) + } } diff --git a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactFailurePresentation.swift b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactFailurePresentation.swift new file mode 100644 index 000000000000..60abf602628f --- /dev/null +++ b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactFailurePresentation.swift @@ -0,0 +1,311 @@ +import CmuxAgentChat +import Foundation + +/// Localized copy and retry guidance for one typed artifact failure. +public struct ChatArtifactFailurePresentation: Equatable, Sendable { + /// Short failure title suitable for an unavailable view or alert. + public let title: String + /// Specific explanation and remediation for the failure. + public let message: String + /// SF Symbol describing the failure category. + public let systemImage: String + /// Whether repeating the same operation can reasonably recover. + public let allowsRetry: Bool + + /// Creates presentation copy without discarding the underlying failure reason. + /// + /// - Parameters: + /// - error: Typed failure from the host, transport, transfer, or local store. + /// - scope: Authorization context used to explain forbidden paths accurately. + /// - actualSize: Stat-reported size used by the too-large message. + public init( + error: ChatArtifactError, + scope: ChatArtifactViewerScope, + actualSize: Int64? = nil + ) { + switch error { + case .unsupported: + self = Self.failure( + title: ("chat.artifact.failure.unsupported.title", "File previews unavailable"), + message: ("chat.artifact.failure.unsupported.message", "This Mac doesn't support file previews. Update cmux on the Mac."), + systemImage: "arrow.up.circle", + allowsRetry: false + ) + case .invalidParams: + self = Self.failure( + title: ("chat.artifact.failure.invalid_request.title", "Invalid file request"), + message: ("chat.artifact.failure.invalid_request.message", "The file request was invalid. Update cmux on both devices."), + systemImage: "exclamationmark.triangle", + allowsRetry: false + ) + case .sessionNotFound: + self = Self.failure( + title: ("chat.artifact.session_missing.title", "Session not found"), + message: ("chat.artifact.session_missing.message", "The chat session for this file is no longer available."), + systemImage: "exclamationmark.bubble", + allowsRetry: false + ) + case .sessionUnavailable: + self = Self.failure( + title: ("chat.artifact.failure.session_unavailable.title", "Session unavailable"), + message: ("chat.artifact.failure.session_unavailable.message", "The session exists, but its file history couldn't be read on the Mac."), + systemImage: "bubble.left.and.exclamationmark.bubble.right", + allowsRetry: true + ) + case .terminalNotFound: + self = Self.failure( + title: ("chat.artifact.failure.terminal_missing.title", "Terminal not found"), + message: ("chat.artifact.failure.terminal_missing.message", "The terminal that authorized this file is no longer available."), + systemImage: "terminal", + allowsRetry: false + ) + case .workspaceNotFound: + self = Self.failure( + title: ("chat.artifact.failure.workspace_missing.title", "Workspace not found"), + message: ("chat.artifact.failure.workspace_missing.message", "The workspace for this file is no longer available."), + systemImage: "rectangle.stack.badge.minus", + allowsRetry: false + ) + case .notRepository: + self = Self.failure( + title: ("chat.artifact.failure.not_repository.title", "Repository unavailable"), + message: ("chat.artifact.failure.not_repository.message", "The workspace folder is no longer a Git repository."), + systemImage: "arrow.triangle.branch", + allowsRetry: false + ) + case .forbidden: + let message: Copy = switch scope { + case .chat: + ("chat.artifact.forbidden.message", "This file was not referenced by the conversation.") + case .terminal: + ("chat.artifact.forbidden.terminal_message", "This file isn't visible in the current terminal view.") + case .workspaceChanges: + ("chat.artifact.failure.forbidden.workspace_message", "This file is no longer part of the workspace changes.") + } + self = Self.failure( + title: ("chat.artifact.forbidden.title", "Preview unavailable"), + message: message, + systemImage: "lock", + allowsRetry: false + ) + case .fileNotFound: + self = Self.failure( + title: ("chat.artifact.file_missing.title", "File not found"), + message: ("chat.artifact.file_missing.message", "The file is no longer available on your Mac."), + systemImage: "doc.badge.minus", + allowsRetry: false + ) + case .permissionDenied: + self = Self.failure( + title: ("chat.artifact.failure.permission.title", "Permission denied"), + message: ("chat.artifact.failure.permission.message", "cmux found the file, but the Mac doesn't allow cmux to read it. Check the file's permissions."), + systemImage: "lock.trianglebadge.exclamationmark", + allowsRetry: false + ) + case .notDirectory: + self = Self.failure( + title: ("chat.artifact.failure.not_directory.title", "Not a folder"), + message: ("chat.artifact.failure.not_directory.message", "This path is a file, not a folder."), + systemImage: "folder.badge.questionmark", + allowsRetry: false + ) + case .notRegularFile: + self = Self.failure( + title: ("chat.artifact.failure.not_regular_file.title", "Not a regular file"), + message: ("chat.artifact.failure.not_regular_file.message", "This path is a folder or special filesystem item, so its bytes can't be previewed."), + systemImage: "doc.badge.ellipsis", + allowsRetry: false + ) + case .fileReadFailed: + self = Self.failure( + title: ("chat.artifact.failure.read_failed.title", "Couldn't read file"), + message: ("chat.artifact.failure.read_failed.message", "The Mac found the file but couldn't read its metadata or contents. Try again."), + systemImage: "doc.badge.exclamationmark", + allowsRetry: true + ) + case .fileChanged: + self = Self.failure( + title: ("chat.artifact.failure.file_changed.title", "File changed"), + message: ("chat.artifact.failure.file_changed.message", "The file changed while it was loading. Try again to load the latest version."), + systemImage: "arrow.triangle.2.circlepath.doc.on.clipboard", + allowsRetry: true + ) + case .unsupportedMedia: + self = Self.failure( + title: ("chat.artifact.preview_unavailable.title", "Preview unavailable"), + message: ("chat.artifact.preview_unavailable.message", "This file can't be previewed."), + systemImage: "doc", + allowsRetry: false + ) + case .corruptMedia: + self = Self.failure( + title: ("chat.artifact.failure.corrupt_media.title", "File is damaged"), + message: ("chat.artifact.failure.corrupt_media.message", "The file type is supported, but its media data couldn't be decoded."), + systemImage: "doc.badge.exclamationmark", + allowsRetry: false + ) + case .previewFailed: + self = Self.failure( + title: ("chat.artifact.failure.preview_failed.title", "Couldn't create preview"), + message: ("chat.artifact.failure.preview_failed.message", "The file was read, but cmux couldn't create its preview. Try again."), + systemImage: "eye.slash", + allowsRetry: true + ) + case .unavailable: + self = Self.failure( + title: ("chat.artifact.failure.service_unavailable.title", "File service unavailable"), + message: ("chat.artifact.failure.service_unavailable.message", "File transfer is temporarily unavailable on the Mac. Try again."), + systemImage: "wrench.and.screwdriver", + allowsRetry: true + ) + case .invalidResponse: + self = Self.failure( + title: ("chat.artifact.failure.invalid_response.title", "Invalid file response"), + message: ("chat.artifact.failure.invalid_response.message", "The Mac sent inconsistent file data. Try again, then update cmux if it continues."), + systemImage: "exclamationmark.arrow.trianglehead.2.clockwise.rotate.90", + allowsRetry: true + ) + case .transferInterrupted: + self = Self.failure( + title: ("chat.artifact.failure.transfer_interrupted.title", "Transfer interrupted"), + message: ("chat.artifact.failure.transfer_interrupted.message", "The file transfer stopped before all bytes arrived. Try again."), + systemImage: "arrow.down.circle.dotted", + allowsRetry: true + ) + case .requestTimedOut: + self = Self.failure( + title: ("chat.artifact.failure.timeout.title", "Request timed out"), + message: ("chat.artifact.failure.timeout.message", "The file request didn't complete in time. Try again."), + systemImage: "clock.badge.exclamationmark", + allowsRetry: true + ) + case .connectionRecovering: + self = Self.failure( + title: ("chat.artifact.failure.reconnecting.title", "Reconnecting to Mac"), + message: ("chat.artifact.failure.reconnecting.message", "A connection attempt is already in progress. Try again in a moment."), + systemImage: "arrow.triangle.2.circlepath", + allowsRetry: true + ) + case .connectionNeedsRestart: + self = Self.failure( + title: ("chat.artifact.failure.restart.title", "Restart required"), + message: ("chat.artifact.failure.restart.message", "Connection cleanup is stuck. Restart cmux on this device, reconnect, and try again."), + systemImage: "arrow.clockwise.circle", + allowsRetry: false + ) + case .secureConnectionRequired: + self = Self.failure( + title: ("chat.artifact.failure.secure_connection.title", "Secure connection required"), + message: ("chat.artifact.failure.secure_connection.message", "This route can't securely transfer Mac files. Reconnect using a paired secure route."), + systemImage: "lock.shield", + allowsRetry: false + ) + case .authenticationExpired: + self = Self.failure( + title: ("chat.artifact.failure.authentication_expired.title", "Pairing expired"), + message: ("chat.artifact.failure.authentication_expired.message", "Reconnect to the Mac to refresh authentication, then try again."), + systemImage: "key.slash", + allowsRetry: false + ) + case .authorizationFailed: + self = Self.failure( + title: ("chat.artifact.failure.authorization.title", "Connection not authorized"), + message: ("chat.artifact.failure.authorization.message", "This device isn't authorized to read files from the Mac. Pair it again."), + systemImage: "person.badge.shield.checkmark", + allowsRetry: false + ) + case .accountMismatch: + self = Self.failure( + title: ("chat.artifact.failure.account_mismatch.title", "Account mismatch"), + message: ("chat.artifact.failure.account_mismatch.message", "This device and the Mac are signed in to different cmux accounts."), + systemImage: "person.2.badge.minus", + allowsRetry: false + ) + case .localStorageFull: + self = Self.failure( + title: ("chat.artifact.failure.storage_full.title", "iPhone storage full"), + message: ("chat.artifact.failure.storage_full.message", "Free some storage on this device, then try again."), + systemImage: "internaldrive.badge.exclamationmark", + allowsRetry: false + ) + case .localStorageUnavailable: + self = Self.failure( + title: ("chat.artifact.failure.local_storage.title", "Local storage unavailable"), + message: ("chat.artifact.failure.local_storage.message", "The file arrived, but cmux couldn't create or read its temporary file on this device. Try again."), + systemImage: "internaldrive.badge.questionmark", + allowsRetry: true + ) + case .loadFailed: + self = Self.failure( + title: ("chat.artifact.load_failed.title", "Couldn't load file"), + message: ("chat.artifact.load_failed.message", "The file couldn't be loaded. Try again."), + systemImage: "exclamationmark.triangle", + allowsRetry: true + ) + case .macUnreachable: + self = Self.failure( + title: ("chat.artifact.mac_unreachable.title", "Mac unreachable"), + message: ("chat.artifact.mac_unreachable.message", "Check the connection to your Mac and try again."), + systemImage: "wifi.exclamationmark", + allowsRetry: true + ) + case .tooLarge(let limitBytes): + self = Self( + title: Self.localized("chat.artifact.too_large.title", defaultValue: "File too large to preview"), + message: Self.tooLargeMessage(actualSize: actualSize, limit: limitBytes), + systemImage: "doc.badge.ellipsis", + allowsRetry: false + ) + } + } + + private typealias Copy = (key: StaticString, value: String.LocalizationValue) + + private init(title: String, message: String, systemImage: String, allowsRetry: Bool) { + self.title = title + self.message = message + self.systemImage = systemImage + self.allowsRetry = allowsRetry + } + + private static func failure( + title: Copy, + message: Copy, + systemImage: String, + allowsRetry: Bool + ) -> Self { + Self( + title: localized(title.key, defaultValue: title.value), + message: localized(message.key, defaultValue: message.value), + systemImage: systemImage, + allowsRetry: allowsRetry + ) + } + + private static func localized( + _ key: StaticString, + defaultValue: String.LocalizationValue + ) -> String { + String(localized: key, defaultValue: defaultValue, bundle: .module) + } + + private static func tooLargeMessage(actualSize: Int64?, limit: Int64) -> String { + let limitText = ByteCountFormatter.string(fromByteCount: limit, countStyle: .file) + guard let actualSize else { + let format = localized( + "chat.artifact.too_large.limit_message", + defaultValue: "This preview is limited to %@." + ) + return String.localizedStringWithFormat(format, limitText) + } + let format = localized( + "chat.artifact.too_large.message", + defaultValue: "This file is %@; previews are limited to %@." + ) + return String.localizedStringWithFormat( + format, + ByteCountFormatter.string(fromByteCount: actualSize, countStyle: .file), + limitText + ) + } +} diff --git a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactFolderView.swift b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactFolderView.swift index 3045fc84301c..6582761fbc09 100644 --- a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactFolderView.swift +++ b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactFolderView.swift @@ -65,19 +65,29 @@ struct ChatArtifactFolderView: View { } } } - case .failed: + case .failed(let error): + let failure = ChatArtifactFailurePresentation(error: error, scope: scope) VStack(spacing: 10) { - Text(String(localized: "chat.artifact.folder.load_failed", defaultValue: "Couldn't load this folder", bundle: .module)) + Image(systemName: failure.systemImage) + .font(.title2) + .foregroundStyle(.secondary) + Text(failure.title) .font(.headline) - Button { - Task { await load() } - } label: { - Label( - String(localized: "chat.artifact.retry", defaultValue: "Retry", bundle: .module), - systemImage: "arrow.clockwise" - ) + Text(failure.message) + .font(.subheadline) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + if failure.allowsRetry { + Button { + Task { await load() } + } label: { + Label( + String(localized: "chat.artifact.retry", defaultValue: "Retry", bundle: .module), + systemImage: "arrow.clockwise" + ) + } + .buttonStyle(.bordered) } - .buttonStyle(.bordered) } .frame(maxWidth: .infinity, maxHeight: .infinity) .padding() @@ -119,8 +129,11 @@ struct ChatArtifactFolderView: View { let listing = try await loader.list(path: path) guard !Task.isCancelled else { return } await MainActor.run { state = .listing(listing) } + } catch is CancellationError { + return } catch { - await MainActor.run { state = .failed } + let failure = (error as? ChatArtifactError) ?? .loadFailed + await MainActor.run { state = .failed(failure) } } } @@ -146,7 +159,7 @@ struct ChatArtifactFolderView: View { private enum LoadState: Equatable { case loading case listing(ChatArtifactDirectoryListing) - case failed + case failed(ChatArtifactError) } } diff --git a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactInlineViewer.swift b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactInlineViewer.swift index 78e732ee17ea..472d6b738443 100644 --- a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactInlineViewer.swift +++ b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactInlineViewer.swift @@ -37,7 +37,7 @@ public struct ChatArtifactInlineViewer: View { let actionDescriptor = inlineActionDescriptor(snapshot: snapshot) ChatArtifactViewerRouteView( snapshot: snapshot, - scope: .terminal, + scope: viewerScope, actions: pageModel.actions( loader: loader, quickLookCanPreview: { fileURL in @@ -70,20 +70,12 @@ public struct ChatArtifactInlineViewer: View { } .chatArtifactFileActionPresentation(fileActionPresentationBinding) .alert( - String( - localized: "chat.artifact.action_failed.title", - defaultValue: "Couldn't complete action", - bundle: .module - ), + fileActionFailurePresentation.title, isPresented: fileActionErrorBinding ) { Button(String(localized: "chat.artifact.ok", defaultValue: "OK", bundle: .module)) {} } message: { - Text(String( - localized: "chat.artifact.action_failed.message", - defaultValue: "Check the connection to your Mac and try again.", - bundle: .module - )) + Text(fileActionFailurePresentation.message) } #endif .task(id: LoadIdentity(path: path, retryGeneration: snapshot.retryGeneration)) { @@ -152,8 +144,28 @@ public struct ChatArtifactInlineViewer: View { set: { pageModel.setShowsFileActionError($0) } ) } + + private var fileActionFailurePresentation: ChatArtifactFailurePresentation { + ChatArtifactFailurePresentation( + error: pageModel.fileActionState.failure ?? .loadFailed, + scope: viewerScope + ) + } #endif + private var viewerScope: ChatArtifactViewerScope { + switch loader.scope { + case .chat: + .chat + case .terminal: + .terminal + case .workspaceChanges: + .workspaceChanges + case .unsupported: + .terminal + } + } + private var imageActionPerformer: (@MainActor (ChatArtifactAction) -> Void)? { #if os(iOS) performInlineAction diff --git a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactLoader.swift b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactLoader.swift index fe80487181d7..d007684bdedd 100644 --- a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactLoader.swift +++ b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactLoader.swift @@ -277,6 +277,11 @@ public struct ChatArtifactLoader: Sendable { size: Int64? = nil, onChunk: @escaping @Sendable (ChatArtifactChunk) async throws -> Void ) async throws { + let validation = ChatArtifactStreamValidation(expectedSize: size) + let validatedReceive: @Sendable (ChatArtifactChunk) async throws -> Void = { chunk in + try await validation.receive(chunk) + try await onChunk(chunk) + } guard scope != .unsupported, let key = ChatArtifactContentCache.key( scopeKey: scope.cacheNamespace, @@ -284,7 +289,8 @@ public struct ChatArtifactLoader: Sendable { modifiedAt: modifiedAt, size: size ), let size else { - try await streamHandler(path, onChunk) + try await streamHandler(path, validatedReceive) + try await validation.finish() return } let handler = streamHandler @@ -294,8 +300,9 @@ public struct ChatArtifactLoader: Sendable { fetch: { receive in try await handler(path, receive) }, - receive: onChunk + receive: validatedReceive ) + try await validation.finish() } public func thumbnail( diff --git a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactLocalFailureClassifier.swift b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactLocalFailureClassifier.swift new file mode 100644 index 000000000000..6604fe3e911e --- /dev/null +++ b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactLocalFailureClassifier.swift @@ -0,0 +1,52 @@ +import CmuxAgentChat +import Foundation + +enum ChatArtifactLocalFailureClassifier { + static func classify(_ error: any Error) -> ChatArtifactError { + if let artifactError = error as? ChatArtifactError { + return artifactError + } + if let posixError = error as? POSIXError { + return classify(posixCode: posixError.code) + } + let nsError = error as NSError + if nsError.domain == NSPOSIXErrorDomain, + let code = POSIXErrorCode(rawValue: Int32(nsError.code)) { + return classify(posixCode: code) + } + if let underlying = nsError.userInfo[NSUnderlyingErrorKey] as? NSError, + underlying !== nsError { + return classify(underlying) + } + if let cocoaError = error as? CocoaError, + cocoaError.code == .fileWriteOutOfSpace { + return .localStorageFull + } + return .localStorageUnavailable + } + + private static func classify(posixCode: POSIXErrorCode) -> ChatArtifactError { + switch posixCode { + case .ENOSPC, .EDQUOT: + return .localStorageFull + default: + return .localStorageUnavailable + } + } +} + +actor ChatArtifactStreamValidation { + private var validator: ChatArtifactChunkValidator + + init(expectedSize: Int64?) { + validator = ChatArtifactChunkValidator(expectedSize: expectedSize) + } + + func receive(_ chunk: ChatArtifactChunk) throws { + try validator.receive(chunk) + } + + func finish() throws { + try validator.finish() + } +} diff --git a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactTemporaryFileStore.swift b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactTemporaryFileStore.swift index 8766de102e5f..e6eceb8492aa 100644 --- a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactTemporaryFileStore.swift +++ b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactTemporaryFileStore.swift @@ -31,6 +31,9 @@ actor ChatArtifactTemporaryFileStore { loader: ChatArtifactLoader, progress: @escaping @Sendable (ChatArtifactChunk) async -> Void ) async throws -> URL { + guard expectedSize >= 0 else { + throw ChatArtifactError.invalidResponse + } guard expectedSize <= limit else { throw ChatArtifactError.tooLarge(limitBytes: limit) } @@ -41,7 +44,8 @@ actor ChatArtifactTemporaryFileStore { let writer = try ChatArtifactTemporaryFileWriter( directory: directory, fileExtension: fileExtension, - preferredFilename: preferredFilename + preferredFilename: preferredFilename, + expectedSize: expectedSize ) do { try await loader.stream( diff --git a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactTemporaryFileWriter.swift b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactTemporaryFileWriter.swift index 7051a3f9a35b..f974ce8f0274 100644 --- a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactTemporaryFileWriter.swift +++ b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactTemporaryFileWriter.swift @@ -6,27 +6,37 @@ actor ChatArtifactTemporaryFileWriter { let fileURL: URL private var fileHandle: FileHandle? - private var nextOffset: Int64 = 0 + private var validator: ChatArtifactChunkValidator init( directory: URL, fileExtension: String, - preferredFilename: String? = nil + preferredFilename: String? = nil, + expectedSize: Int64 ) throws { - try FileManager.default.createDirectory( - at: directory, - withIntermediateDirectories: true - ) + validator = ChatArtifactChunkValidator(expectedSize: expectedSize) + do { + try FileManager.default.createDirectory( + at: directory, + withIntermediateDirectories: true + ) + } catch { + throw ChatArtifactLocalFailureClassifier.classify(error) + } let fileURL: URL if let preferredFilename { let itemDirectory = directory.appendingPathComponent( UUID().uuidString, isDirectory: true ) - try FileManager.default.createDirectory( - at: itemDirectory, - withIntermediateDirectories: true - ) + do { + try FileManager.default.createDirectory( + at: itemDirectory, + withIntermediateDirectories: true + ) + } catch { + throw ChatArtifactLocalFailureClassifier.classify(error) + } fileURL = itemDirectory.appendingPathComponent(preferredFilename) } else { var generatedURL = directory.appendingPathComponent(UUID().uuidString) @@ -36,33 +46,41 @@ actor ChatArtifactTemporaryFileWriter { fileURL = generatedURL } guard FileManager.default.createFile(atPath: fileURL.path, contents: nil) else { - throw CocoaError(.fileWriteUnknown) + throw ChatArtifactError.localStorageUnavailable } self.fileURL = fileURL do { fileHandle = try FileHandle(forWritingTo: fileURL) } catch { try? FileManager.default.removeItem(at: fileURL) - throw error + throw ChatArtifactLocalFailureClassifier.classify(error) } } func append(_ chunk: ChatArtifactChunk, limit: Int64) throws { - guard chunk.offset == nextOffset, - chunk.totalSize <= limit, - nextOffset <= limit - Int64(chunk.data.count), + try validator.receive(chunk) + guard chunk.totalSize <= limit, + chunk.offset <= limit - Int64(chunk.data.count), let fileHandle else { - throw ChatArtifactError.loadFailed + throw ChatArtifactError.invalidResponse + } + do { + try fileHandle.write(contentsOf: chunk.data) + } catch { + throw ChatArtifactLocalFailureClassifier.classify(error) } - try fileHandle.write(contentsOf: chunk.data) - nextOffset += Int64(chunk.data.count) } func finish() throws -> URL { + try validator.finish() guard let fileHandle else { - throw ChatArtifactError.loadFailed + throw ChatArtifactError.localStorageUnavailable + } + do { + try fileHandle.close() + } catch { + throw ChatArtifactLocalFailureClassifier.classify(error) } - try fileHandle.close() self.fileHandle = nil return fileURL } diff --git a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactViewerFileActionState.swift b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactViewerFileActionState.swift index ab9e0af733a7..d515c246ef9d 100644 --- a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactViewerFileActionState.swift +++ b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactViewerFileActionState.swift @@ -1,8 +1,12 @@ +import CmuxAgentChat + /// Immutable system-presentation state owned by one artifact page. struct ChatArtifactViewerFileActionState: Equatable, Sendable { #if os(iOS) var presentation: ChatArtifactFileActionPresentation? = nil #endif var isRunning = false - var showsError = false + var failure: ChatArtifactError? + + var showsError: Bool { failure != nil } } diff --git a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactViewerModel.swift b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactViewerModel.swift index 2f2014b3f61d..2c3168e19c20 100644 --- a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactViewerModel.swift +++ b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactViewerModel.swift @@ -1,5 +1,6 @@ import CmuxAgentChat import Foundation +import ImageIO import Observation /// Main-actor state machine for one progressively loaded artifact path. @@ -56,6 +57,12 @@ final class ChatArtifactViewerModel { let loadedStat = try await loader.stat(path: path) try Task.checkCancellation() guard path == activePath else { return } + guard loadedStat.exists else { + throw ChatArtifactError.fileNotFound + } + guard loadedStat.size >= 0 else { + throw ChatArtifactError.invalidResponse + } stat = loadedStat activeStat = loadedStat totalBytes = loadedStat.size @@ -77,7 +84,10 @@ final class ChatArtifactViewerModel { ? policy.maxMediaPreviewBytes : policy.maxPreviewBytes guard loadedStat.size <= limit else { - state = .tooLarge(actualSize: loadedStat.size, limit: limit) + state = .failure( + error: .tooLarge(limitBytes: limit), + actualSize: loadedStat.size + ) return } @@ -267,6 +277,9 @@ final class ChatArtifactViewerModel { try Task.checkCancellation() guard path == activePath else { return } let data = await accumulator.value() + guard Self.canDecodeImage(data) else { + throw ChatArtifactError.corruptMedia + } state = .image(data: data) } @@ -319,24 +332,17 @@ final class ChatArtifactViewerModel { stat: ChatArtifactStat? ) -> ChatArtifactViewerState { guard let artifactError = error as? ChatArtifactError else { - return .loadFailed + return .failure(error: .loadFailed, actualSize: stat?.size) } - switch artifactError { - case .fileNotFound: - return .fileMissing - case .sessionNotFound: - return .sessionMissing - case .forbidden: - return .forbidden - case .macUnreachable: - return .macUnreachable - case .loadFailed, .unavailable, .unsupported, .invalidParams: - return .loadFailed - case .unsupportedMedia: - return .unsupportedMedia - case .tooLarge(let limitBytes): - return .tooLarge(actualSize: stat?.size, limit: limitBytes) + return .failure(error: artifactError, actualSize: stat?.size) + } + + private static func canDecodeImage(_ data: Data) -> Bool { + guard let source = CGImageSourceCreateWithData(data as CFData, nil), + CGImageSourceGetCount(source) > 0 else { + return false } + return CGImageSourceCreateImageAtIndex(source, 0, nil) != nil } private static let maximumCopyContentsBytes: Int64 = 4 * 1024 * 1024 diff --git a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactViewerPageModel.swift b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactViewerPageModel.swift index 12414285efd5..3b746bcfb240 100644 --- a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactViewerPageModel.swift +++ b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactViewerPageModel.swift @@ -218,7 +218,11 @@ final class ChatArtifactViewerPageModel { } func setShowsFileActionError(_ isPresented: Bool) { - fileActionState.showsError = isPresented + if isPresented { + fileActionState.failure = fileActionState.failure ?? .loadFailed + } else { + fileActionState.failure = nil + } } private func prepareFileAction( @@ -238,7 +242,7 @@ final class ChatArtifactViewerPageModel { } catch is CancellationError { return } catch { - fileActionState.showsError = true + fileActionState.failure = (error as? ChatArtifactError) ?? .loadFailed } } #endif diff --git a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactViewerPager.swift b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactViewerPager.swift index 959d2e9285c2..5c93ac4552e7 100644 --- a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactViewerPager.swift +++ b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactViewerPager.swift @@ -70,20 +70,12 @@ struct ChatArtifactViewerPager: View { #if os(iOS) .chatArtifactFileActionPresentation(fileActionPresentationBinding) .alert( - String( - localized: "chat.artifact.action_failed.title", - defaultValue: "Couldn't complete action", - bundle: .module - ), + fileActionFailurePresentation.title, isPresented: fileActionErrorBinding ) { Button(String(localized: "chat.artifact.ok", defaultValue: "OK", bundle: .module)) {} } message: { - Text(String( - localized: "chat.artifact.action_failed.message", - defaultValue: "Check the connection to your Mac and try again.", - bundle: .module - )) + Text(fileActionFailurePresentation.message) } #endif .onChange(of: initialPath) { _, newPath in @@ -260,5 +252,12 @@ struct ChatArtifactViewerPager: View { ) } + private var fileActionFailurePresentation: ChatArtifactFailurePresentation { + ChatArtifactFailurePresentation( + error: model.toolbarSnapshot.fileActionState.failure ?? .loadFailed, + scope: scope + ) + } + #endif } diff --git a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactViewerRouteView.swift b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactViewerRouteView.swift index 2bb82991d46f..229f9c41f2d9 100644 --- a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactViewerRouteView.swift +++ b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactViewerRouteView.swift @@ -178,46 +178,16 @@ struct ChatArtifactViewerRouteView: View { message: String(localized: "chat.artifact.preview_unavailable.message", defaultValue: "This file can't be previewed.", bundle: .module), detail: formattedSize(stat.size) ) - case .tooLarge(let actualSize, let limit): - unavailableView( - title: String(localized: "chat.artifact.too_large.title", defaultValue: "File too large to preview", bundle: .module), - message: tooLargeMessage(actualSize: actualSize, limit: limit) - ) - case .unsupportedMedia: - unavailableView( - title: String(localized: "chat.artifact.preview_unavailable.title", defaultValue: "Preview unavailable", bundle: .module), - message: String(localized: "chat.artifact.preview_unavailable.message", defaultValue: "This file can't be previewed.", bundle: .module), - detail: nil - ) - case .fileMissing: - unavailableView( - title: String(localized: "chat.artifact.file_missing.title", defaultValue: "File not found", bundle: .module), - message: String(localized: "chat.artifact.file_missing.message", defaultValue: "The file is no longer available on your Mac.", bundle: .module), - retry: false - ) - case .sessionMissing: - unavailableView( - title: String(localized: "chat.artifact.session_missing.title", defaultValue: "Session not found", bundle: .module), - message: String(localized: "chat.artifact.session_missing.message", defaultValue: "The chat session for this file is no longer available.", bundle: .module), - retry: false - ) - case .loadFailed: - unavailableView( - title: String(localized: "chat.artifact.load_failed.title", defaultValue: "Couldn’t load file", bundle: .module), - message: String(localized: "chat.artifact.load_failed.message", defaultValue: "The file couldn’t be loaded. Try again.", bundle: .module), - retry: true - ) - case .macUnreachable: - unavailableView( - title: String(localized: "chat.artifact.mac_unreachable.title", defaultValue: "Mac unreachable", bundle: .module), - message: String(localized: "chat.artifact.mac_unreachable.message", defaultValue: "Check the connection to your Mac and try again.", bundle: .module), - retry: true + case .failure(let error, let actualSize): + let failure = ChatArtifactFailurePresentation( + error: error, + scope: scope, + actualSize: actualSize ) - case .forbidden: unavailableView( - title: String(localized: "chat.artifact.forbidden.title", defaultValue: "Preview unavailable", bundle: .module), - message: forbiddenMessage, - retry: false + title: failure.title, + message: failure.message, + retry: failure.allowsRetry ) } } @@ -375,21 +345,4 @@ struct ChatArtifactViewerRouteView: View { ) } - private var forbiddenMessage: String { - switch scope { - case .chat: - String( - localized: "chat.artifact.forbidden.message", - defaultValue: "This file was not referenced by the conversation.", - bundle: .module - ) - case .terminal: - String( - localized: "chat.artifact.forbidden.terminal_message", - defaultValue: "This file isn't visible in the current terminal view.", - bundle: .module - ) - } - } - } diff --git a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactViewerScope.swift b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactViewerScope.swift index 444ef5e019ec..521a942aa669 100644 --- a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactViewerScope.swift +++ b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactViewerScope.swift @@ -1,8 +1,11 @@ /// The user-facing context in which an artifact preview is presented. -public enum ChatArtifactViewerScope: Sendable { +public enum ChatArtifactViewerScope: Sendable, Equatable { /// An artifact referenced by an agent chat transcript. case chat /// An artifact opened from a terminal surface. case terminal + + /// A base or working-tree file opened from Workspace Changes. + case workspaceChanges } diff --git a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactViewerState.swift b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactViewerState.swift index 0da3eb15e2bf..7518671bb332 100644 --- a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactViewerState.swift +++ b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactViewerState.swift @@ -12,11 +12,5 @@ enum ChatArtifactViewerState: Equatable, Sendable { case text case markdown case binary(stat: ChatArtifactStat) - case tooLarge(actualSize: Int64?, limit: Int64) - case unsupportedMedia - case fileMissing - case sessionMissing - case loadFailed - case macUnreachable - case forbidden + case failure(error: ChatArtifactError, actualSize: Int64?) } diff --git a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Resources/Localizable.xcstrings b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Resources/Localizable.xcstrings index 66a03ad8d8e1..7364dd0c3723 100644 --- a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Resources/Localizable.xcstrings +++ b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Resources/Localizable.xcstrings @@ -1,6 +1,363 @@ { "sourceLanguage": "en", "strings": { + "chat.artifact.failure.account_mismatch.message": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "This device and the Mac are signed in to different cmux accounts." } }, + "ja": { "stringUnit": { "state": "translated", "value": "このデバイスとMacは異なるcmuxアカウントにサインインしています。" } } + } + }, + "chat.artifact.failure.account_mismatch.title": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "Account mismatch" } }, + "ja": { "stringUnit": { "state": "translated", "value": "アカウントが一致しません" } } + } + }, + "chat.artifact.failure.authentication_expired.message": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "Reconnect to the Mac to refresh authentication, then try again." } }, + "ja": { "stringUnit": { "state": "translated", "value": "Macに再接続して認証を更新してから、もう一度お試しください。" } } + } + }, + "chat.artifact.failure.authentication_expired.title": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "Pairing expired" } }, + "ja": { "stringUnit": { "state": "translated", "value": "ペアリングの有効期限が切れました" } } + } + }, + "chat.artifact.failure.authorization.message": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "This device isn't authorized to read files from the Mac. Pair it again." } }, + "ja": { "stringUnit": { "state": "translated", "value": "このデバイスにはMac上のファイルを読み取る権限がありません。もう一度ペアリングしてください。" } } + } + }, + "chat.artifact.failure.authorization.title": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "Connection not authorized" } }, + "ja": { "stringUnit": { "state": "translated", "value": "接続が承認されていません" } } + } + }, + "chat.artifact.failure.corrupt_media.message": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "The file type is supported, but its media data couldn't be decoded." } }, + "ja": { "stringUnit": { "state": "translated", "value": "対応しているファイル形式ですが、メディアデータをデコードできませんでした。" } } + } + }, + "chat.artifact.failure.corrupt_media.title": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "File is damaged" } }, + "ja": { "stringUnit": { "state": "translated", "value": "ファイルが破損しています" } } + } + }, + "chat.artifact.failure.file_changed.message": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "The file changed while it was loading. Try again to load the latest version." } }, + "ja": { "stringUnit": { "state": "translated", "value": "読み込み中にファイルが変更されました。最新の内容を読み込むには、もう一度お試しください。" } } + } + }, + "chat.artifact.failure.file_changed.title": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "File changed" } }, + "ja": { "stringUnit": { "state": "translated", "value": "ファイルが変更されました" } } + } + }, + "chat.artifact.failure.forbidden.workspace_message": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "This file is no longer part of the workspace changes." } }, + "ja": { "stringUnit": { "state": "translated", "value": "このファイルはワークスペースの変更に含まれなくなりました。" } } + } + }, + "chat.artifact.failure.invalid_request.message": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "The file request was invalid. Update cmux on both devices." } }, + "ja": { "stringUnit": { "state": "translated", "value": "ファイルリクエストが無効です。両方のデバイスでcmuxをアップデートしてください。" } } + } + }, + "chat.artifact.failure.invalid_request.title": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "Invalid file request" } }, + "ja": { "stringUnit": { "state": "translated", "value": "無効なファイルリクエスト" } } + } + }, + "chat.artifact.failure.invalid_response.message": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "The Mac sent inconsistent file data. Try again, then update cmux if it continues." } }, + "ja": { "stringUnit": { "state": "translated", "value": "Macから整合性のないファイルデータが送信されました。もう一度試し、続く場合はcmuxをアップデートしてください。" } } + } + }, + "chat.artifact.failure.invalid_response.title": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "Invalid file response" } }, + "ja": { "stringUnit": { "state": "translated", "value": "無効なファイル応答" } } + } + }, + "chat.artifact.failure.local_storage.message": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "The file arrived, but cmux couldn't create or read its temporary file on this device. Try again." } }, + "ja": { "stringUnit": { "state": "translated", "value": "ファイルは届きましたが、このデバイスで一時ファイルを作成または読み取りできませんでした。もう一度お試しください。" } } + } + }, + "chat.artifact.failure.local_storage.title": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "Local storage unavailable" } }, + "ja": { "stringUnit": { "state": "translated", "value": "ローカルストレージを利用できません" } } + } + }, + "chat.artifact.failure.not_directory.message": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "This path is a file, not a folder." } }, + "ja": { "stringUnit": { "state": "translated", "value": "このパスはフォルダではなくファイルです。" } } + } + }, + "chat.artifact.failure.not_directory.title": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "Not a folder" } }, + "ja": { "stringUnit": { "state": "translated", "value": "フォルダではありません" } } + } + }, + "chat.artifact.failure.not_regular_file.message": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "This path is a folder or special filesystem item, so its bytes can't be previewed." } }, + "ja": { "stringUnit": { "state": "translated", "value": "このパスはフォルダまたは特殊なファイルシステム項目のため、内容をプレビューできません。" } } + } + }, + "chat.artifact.failure.not_regular_file.title": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "Not a regular file" } }, + "ja": { "stringUnit": { "state": "translated", "value": "通常のファイルではありません" } } + } + }, + "chat.artifact.failure.not_repository.message": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "The workspace folder is no longer a Git repository." } }, + "ja": { "stringUnit": { "state": "translated", "value": "ワークスペースのフォルダはGitリポジトリではなくなりました。" } } + } + }, + "chat.artifact.failure.not_repository.title": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "Repository unavailable" } }, + "ja": { "stringUnit": { "state": "translated", "value": "リポジトリを利用できません" } } + } + }, + "chat.artifact.failure.permission.message": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "cmux found the file, but the Mac doesn't allow cmux to read it. Check the file's permissions." } }, + "ja": { "stringUnit": { "state": "translated", "value": "ファイルは見つかりましたが、Macでcmuxに読み取りが許可されていません。ファイルのアクセス権を確認してください。" } } + } + }, + "chat.artifact.failure.permission.title": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "Permission denied" } }, + "ja": { "stringUnit": { "state": "translated", "value": "アクセス権がありません" } } + } + }, + "chat.artifact.failure.preview_failed.message": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "The file was read, but cmux couldn't create its preview. Try again." } }, + "ja": { "stringUnit": { "state": "translated", "value": "ファイルは読み取れましたが、プレビューを作成できませんでした。もう一度お試しください。" } } + } + }, + "chat.artifact.failure.preview_failed.title": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "Couldn't create preview" } }, + "ja": { "stringUnit": { "state": "translated", "value": "プレビューを作成できません" } } + } + }, + "chat.artifact.failure.read_failed.message": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "The Mac found the file but couldn't read its metadata or contents. Try again." } }, + "ja": { "stringUnit": { "state": "translated", "value": "Macでファイルは見つかりましたが、メタデータまたは内容を読み取れませんでした。もう一度お試しください。" } } + } + }, + "chat.artifact.failure.read_failed.title": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "Couldn't read file" } }, + "ja": { "stringUnit": { "state": "translated", "value": "ファイルを読み取れません" } } + } + }, + "chat.artifact.failure.reconnecting.message": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "A connection attempt is already in progress. Try again in a moment." } }, + "ja": { "stringUnit": { "state": "translated", "value": "接続を試行中です。しばらくしてからもう一度お試しください。" } } + } + }, + "chat.artifact.failure.reconnecting.title": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "Reconnecting to Mac" } }, + "ja": { "stringUnit": { "state": "translated", "value": "Macに再接続中" } } + } + }, + "chat.artifact.failure.restart.message": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "Connection cleanup is stuck. Restart cmux on this device, reconnect, and try again." } }, + "ja": { "stringUnit": { "state": "translated", "value": "接続のクリーンアップが停止しています。このデバイスでcmuxを再起動し、再接続してからもう一度お試しください。" } } + } + }, + "chat.artifact.failure.restart.title": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "Restart required" } }, + "ja": { "stringUnit": { "state": "translated", "value": "再起動が必要です" } } + } + }, + "chat.artifact.failure.secure_connection.message": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "This route can't securely transfer Mac files. Reconnect using a paired secure route." } }, + "ja": { "stringUnit": { "state": "translated", "value": "この経路ではMacのファイルを安全に転送できません。ペアリング済みの安全な経路で再接続してください。" } } + } + }, + "chat.artifact.failure.secure_connection.title": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "Secure connection required" } }, + "ja": { "stringUnit": { "state": "translated", "value": "安全な接続が必要です" } } + } + }, + "chat.artifact.failure.service_unavailable.message": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "File transfer is temporarily unavailable on the Mac. Try again." } }, + "ja": { "stringUnit": { "state": "translated", "value": "Macでファイル転送を一時的に利用できません。もう一度お試しください。" } } + } + }, + "chat.artifact.failure.service_unavailable.title": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "File service unavailable" } }, + "ja": { "stringUnit": { "state": "translated", "value": "ファイルサービスを利用できません" } } + } + }, + "chat.artifact.failure.session_unavailable.message": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "The session exists, but its file history couldn't be read on the Mac." } }, + "ja": { "stringUnit": { "state": "translated", "value": "セッションは存在しますが、Macでファイル履歴を読み取れませんでした。" } } + } + }, + "chat.artifact.failure.session_unavailable.title": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "Session unavailable" } }, + "ja": { "stringUnit": { "state": "translated", "value": "セッションを利用できません" } } + } + }, + "chat.artifact.failure.storage_full.message": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "Free some storage on this device, then try again." } }, + "ja": { "stringUnit": { "state": "translated", "value": "このデバイスの空き容量を増やしてから、もう一度お試しください。" } } + } + }, + "chat.artifact.failure.storage_full.title": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "iPhone storage full" } }, + "ja": { "stringUnit": { "state": "translated", "value": "iPhoneのストレージがいっぱいです" } } + } + }, + "chat.artifact.failure.terminal_missing.message": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "The terminal that authorized this file is no longer available." } }, + "ja": { "stringUnit": { "state": "translated", "value": "このファイルを承認したターミナルは利用できなくなりました。" } } + } + }, + "chat.artifact.failure.terminal_missing.title": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "Terminal not found" } }, + "ja": { "stringUnit": { "state": "translated", "value": "ターミナルが見つかりません" } } + } + }, + "chat.artifact.failure.timeout.message": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "The file request didn't complete in time. Try again." } }, + "ja": { "stringUnit": { "state": "translated", "value": "ファイルリクエストが時間内に完了しませんでした。もう一度お試しください。" } } + } + }, + "chat.artifact.failure.timeout.title": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "Request timed out" } }, + "ja": { "stringUnit": { "state": "translated", "value": "リクエストがタイムアウトしました" } } + } + }, + "chat.artifact.failure.transfer_interrupted.message": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "The file transfer stopped before all bytes arrived. Try again." } }, + "ja": { "stringUnit": { "state": "translated", "value": "すべてのデータが届く前にファイル転送が停止しました。もう一度お試しください。" } } + } + }, + "chat.artifact.failure.transfer_interrupted.title": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "Transfer interrupted" } }, + "ja": { "stringUnit": { "state": "translated", "value": "転送が中断されました" } } + } + }, + "chat.artifact.failure.unsupported.message": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "This Mac doesn't support file previews. Update cmux on the Mac." } }, + "ja": { "stringUnit": { "state": "translated", "value": "このMacはファイルプレビューに対応していません。Macのcmuxをアップデートしてください。" } } + } + }, + "chat.artifact.failure.unsupported.title": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "File previews unavailable" } }, + "ja": { "stringUnit": { "state": "translated", "value": "ファイルプレビューを利用できません" } } + } + }, + "chat.artifact.failure.workspace_missing.message": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "The workspace for this file is no longer available." } }, + "ja": { "stringUnit": { "state": "translated", "value": "このファイルのワークスペースは利用できなくなりました。" } } + } + }, + "chat.artifact.failure.workspace_missing.title": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "Workspace not found" } }, + "ja": { "stringUnit": { "state": "translated", "value": "ワークスペースが見つかりません" } } + } + }, "chat.artifact.accessibility.large_text_truncated": { "extractionState": "manual", "localizations": { diff --git a/Packages/iOS/CmuxAgentChatUI/Tests/CmuxAgentChatUITests/ChatArtifactActionVisibilityPolicyTests.swift b/Packages/iOS/CmuxAgentChatUI/Tests/CmuxAgentChatUITests/ChatArtifactActionVisibilityPolicyTests.swift index dac05908ba08..716a9ecde7fc 100644 --- a/Packages/iOS/CmuxAgentChatUI/Tests/CmuxAgentChatUITests/ChatArtifactActionVisibilityPolicyTests.swift +++ b/Packages/iOS/CmuxAgentChatUI/Tests/CmuxAgentChatUITests/ChatArtifactActionVisibilityPolicyTests.swift @@ -32,10 +32,18 @@ struct ChatArtifactActionVisibilityPolicyTests { @Test func loadingAndErrorStatesOfferNoActions() { #expect(ChatArtifactActionVisibilityPolicy(inlineState: .loading).actions.isEmpty) - #expect(ChatArtifactActionVisibilityPolicy(inlineState: .fileMissing).actions.isEmpty) - #expect(ChatArtifactActionVisibilityPolicy(inlineState: .macUnreachable).actions.isEmpty) - #expect(ChatArtifactActionVisibilityPolicy(inlineState: .forbidden).actions.isEmpty) - #expect(ChatArtifactActionVisibilityPolicy(inlineState: .unsupportedMedia).actions.isEmpty) + #expect(ChatArtifactActionVisibilityPolicy( + inlineState: .failure(error: .fileNotFound, actualSize: nil) + ).actions.isEmpty) + #expect(ChatArtifactActionVisibilityPolicy( + inlineState: .failure(error: .macUnreachable, actualSize: nil) + ).actions.isEmpty) + #expect(ChatArtifactActionVisibilityPolicy( + inlineState: .failure(error: .forbidden, actualSize: nil) + ).actions.isEmpty) + #expect(ChatArtifactActionVisibilityPolicy( + inlineState: .failure(error: .unsupportedMedia, actualSize: nil) + ).actions.isEmpty) } @Test @@ -44,7 +52,7 @@ struct ChatArtifactActionVisibilityPolicyTests { #expect(ChatArtifactActionVisibilityPolicy(inlineState: .text).actions.isEmpty) #expect(ChatArtifactActionVisibilityPolicy(inlineState: .markdown).actions.isEmpty) #expect(ChatArtifactActionVisibilityPolicy( - inlineState: .tooLarge(actualSize: 10, limit: 5) + inlineState: .failure(error: .tooLarge(limitBytes: 5), actualSize: 10) ).actions.isEmpty) } diff --git a/Packages/iOS/CmuxAgentChatUI/Tests/CmuxAgentChatUITests/ChatArtifactFailurePresentationTests.swift b/Packages/iOS/CmuxAgentChatUI/Tests/CmuxAgentChatUITests/ChatArtifactFailurePresentationTests.swift new file mode 100644 index 000000000000..03f57c2fb10a --- /dev/null +++ b/Packages/iOS/CmuxAgentChatUI/Tests/CmuxAgentChatUITests/ChatArtifactFailurePresentationTests.swift @@ -0,0 +1,76 @@ +import CmuxAgentChat +import Testing + +@testable import CmuxAgentChatUI + +@Suite +struct ChatArtifactFailurePresentationTests { + @Test + func everyFailureHasSpecificCopyAndRetryGuidance() { + let unreachable = ChatArtifactFailurePresentation( + error: .macUnreachable, + scope: .chat + ) + let cases: [(error: ChatArtifactError, title: String, allowsRetry: Bool)] = [ + (.unsupported, "File previews unavailable", false), + (.invalidParams, "Invalid file request", false), + (.sessionNotFound, "Session not found", false), + (.sessionUnavailable, "Session unavailable", true), + (.terminalNotFound, "Terminal not found", false), + (.workspaceNotFound, "Workspace not found", false), + (.notRepository, "Repository unavailable", false), + (.forbidden, "Preview unavailable", false), + (.fileNotFound, "File not found", false), + (.permissionDenied, "Permission denied", false), + (.notDirectory, "Not a folder", false), + (.notRegularFile, "Not a regular file", false), + (.fileReadFailed, "Couldn't read file", true), + (.fileChanged, "File changed", true), + (.unsupportedMedia, "Preview unavailable", false), + (.corruptMedia, "File is damaged", false), + (.previewFailed, "Couldn't create preview", true), + (.unavailable, "File service unavailable", true), + (.invalidResponse, "Invalid file response", true), + (.transferInterrupted, "Transfer interrupted", true), + (.requestTimedOut, "Request timed out", true), + (.connectionRecovering, "Reconnecting to Mac", true), + (.connectionNeedsRestart, "Restart required", false), + (.secureConnectionRequired, "Secure connection required", false), + (.authenticationExpired, "Pairing expired", false), + (.authorizationFailed, "Connection not authorized", false), + (.accountMismatch, "Account mismatch", false), + (.localStorageFull, "iPhone storage full", false), + (.localStorageUnavailable, "Local storage unavailable", true), + (.loadFailed, "Couldn't load file", true), + (.tooLarge(limitBytes: 1_024), "File too large to preview", false), + ] + + #expect(unreachable.title == "Mac unreachable") + #expect(unreachable.allowsRetry) + for item in cases { + let presentation = ChatArtifactFailurePresentation( + error: item.error, + scope: .chat, + actualSize: 2_048 + ) + #expect(presentation.title == item.title) + #expect(presentation.allowsRetry == item.allowsRetry) + #expect(!presentation.message.isEmpty) + #expect(!presentation.systemImage.isEmpty) + #expect(presentation.title != unreachable.title) + #expect(presentation.message != unreachable.message) + } + } + + @Test + func forbiddenCopyMatchesAuthorizationScope() { + let chat = ChatArtifactFailurePresentation(error: .forbidden, scope: .chat) + let terminal = ChatArtifactFailurePresentation(error: .forbidden, scope: .terminal) + let changes = ChatArtifactFailurePresentation(error: .forbidden, scope: .workspaceChanges) + + #expect(chat.message != terminal.message) + #expect(terminal.message != changes.message) + #expect(changes.message != chat.message) + } + +} diff --git a/Packages/iOS/CmuxAgentChatUI/Tests/CmuxAgentChatUITests/ChatArtifactTemporaryFileStoreTests.swift b/Packages/iOS/CmuxAgentChatUI/Tests/CmuxAgentChatUITests/ChatArtifactTemporaryFileStoreTests.swift index e8550d3f7e6a..1ab1f4773251 100644 --- a/Packages/iOS/CmuxAgentChatUI/Tests/CmuxAgentChatUITests/ChatArtifactTemporaryFileStoreTests.swift +++ b/Packages/iOS/CmuxAgentChatUI/Tests/CmuxAgentChatUITests/ChatArtifactTemporaryFileStoreTests.swift @@ -125,8 +125,8 @@ struct ChatArtifactTemporaryFileStoreTests { progress: { _ in } ) Issue.record("a truncated stream must not produce a preview file") - } catch is ChatArtifactError { - // Expected: the incomplete transfer remains typed end to end. + } catch let error as ChatArtifactError { + #expect(error == .transferInterrupted) } catch { Issue.record("unexpected error: \(error)") } diff --git a/Packages/iOS/CmuxAgentChatUI/Tests/CmuxAgentChatUITests/ChatArtifactViewerModelTests.swift b/Packages/iOS/CmuxAgentChatUI/Tests/CmuxAgentChatUITests/ChatArtifactViewerModelTests.swift index 45300f83b0d7..e3454ff31cd8 100644 --- a/Packages/iOS/CmuxAgentChatUI/Tests/CmuxAgentChatUITests/ChatArtifactViewerModelTests.swift +++ b/Packages/iOS/CmuxAgentChatUI/Tests/CmuxAgentChatUITests/ChatArtifactViewerModelTests.swift @@ -17,7 +17,39 @@ struct ChatArtifactViewerModelTests { await model.load(path: "/tmp/cmux-attachments/ci-failure.png", loader: loader) - #expect(model.state == .sessionMissing) + #expect(model.state == .failure(error: .sessionNotFound, actualSize: nil)) + } + + @Test("missing and malformed stat responses remain distinct") + @MainActor + func validatesStatResponseBeforeFetching() async { + let missing = ChatArtifactViewerModel() + await missing.load( + path: "/tmp/gone.txt", + loader: Self.loader(stat: ChatArtifactStat( + exists: false, + isDirectory: false, + size: 0, + modifiedAt: Date(timeIntervalSince1970: 0), + kind: .text, + mimeType: "text/plain" + )) + ) + let malformed = ChatArtifactViewerModel() + await malformed.load( + path: "/tmp/malformed.txt", + loader: Self.loader(stat: ChatArtifactStat( + exists: true, + isDirectory: false, + size: -1, + modifiedAt: Date(timeIntervalSince1970: 0), + kind: .text, + mimeType: "text/plain" + )) + ) + + #expect(missing.state == .failure(error: .fileNotFound, actualSize: nil)) + #expect(malformed.state == .failure(error: .invalidResponse, actualSize: nil)) } @Test("only a reachability error reports an unreachable Mac") @@ -40,8 +72,8 @@ struct ChatArtifactViewerModelTests { ) ) - #expect(loadFailureModel.state == .loadFailed) - #expect(unreachableModel.state == .macUnreachable) + #expect(loadFailureModel.state == .failure(error: .loadFailed, actualSize: nil)) + #expect(unreachableModel.state == .failure(error: .macUnreachable, actualSize: nil)) } @Test("damaged image bytes become a visible failure") @@ -73,9 +105,7 @@ struct ChatArtifactViewerModelTests { await model.load(path: "/tmp/damaged.png", loader: loader) - if case .image = model.state { - Issue.record("damaged image bytes must render an explicit failure instead of a blank image") - } + #expect(model.state == .failure(error: .corruptMedia, actualSize: Int64(data.count))) } @Test("large transport chunks are published in bounded UI batches") @@ -168,20 +198,33 @@ struct ChatArtifactViewerModelTests { ), ]) let newData = Data("new path".utf8) - let loader = Self.loader(totalSize: Int64(newData.count)) { path, onChunk in - if path == "/tmp/old.txt" { - try await blockedStream.fetch(onChunk: onChunk) - return - } - try await onChunk( - ChatArtifactChunk( - data: newData, - offset: 0, - totalSize: Int64(newData.count), - eof: true + let loader = ChatArtifactLoader( + supportsArtifacts: true, + stat: { path in + ChatArtifactStat( + exists: true, + isDirectory: false, + size: path == "/tmp/old.txt" ? staleTotalSize : Int64(newData.count), + modifiedAt: Date(timeIntervalSince1970: 0), + kind: .text, + mimeType: "text/plain" ) - ) - } + }, + stream: { path, onChunk in + if path == "/tmp/old.txt" { + try await blockedStream.fetch(onChunk: onChunk) + return + } + try await onChunk( + ChatArtifactChunk( + data: newData, + offset: 0, + totalSize: Int64(newData.count), + eof: true + ) + ) + } + ) let model = ChatArtifactViewerModel() let oldTask = Task { @@ -189,6 +232,7 @@ struct ChatArtifactViewerModelTests { } await blockedStream.waitUntilFirstChunkDelivered() oldTask.cancel() + await blockedStream.resume() let newTask = Task { await model.load(path: "/tmp/new.txt", loader: loader) } @@ -224,7 +268,10 @@ struct ChatArtifactViewerModelTests { await model.load(path: "/tmp/too-large.txt", loader: loader) - #expect(model.state == .tooLarge(actualSize: actualSize, limit: limit)) + #expect(model.state == .failure( + error: .tooLarge(limitBytes: limit), + actualSize: actualSize + )) } @Test @@ -296,7 +343,10 @@ struct ChatArtifactViewerModelTests { await model.load(path: "/remote/movie.mp4", loader: loader) - #expect(model.state == .tooLarge(actualSize: limit + 1, limit: limit)) + #expect(model.state == .failure( + error: .tooLarge(limitBytes: limit), + actualSize: limit + 1 + )) #expect(await streamCalls.callCount() == 0) } @@ -393,14 +443,17 @@ struct ChatArtifactViewerModelTests { @Test @MainActor func markdownStreamsAsTextAndAppliesRawOnlyThreshold() async { - let markdown = Data("# Heading\n\nBody".utf8) + let markdown = Data( + repeating: 0x61, + count: Int(ChatArtifactMarkdownPresentation.maximumRenderedByteCount + 1) + ) let loader = ChatArtifactLoader( supportsArtifacts: true, stat: { _ in ChatArtifactStat( exists: true, isDirectory: false, - size: ChatArtifactMarkdownPresentation.maximumRenderedByteCount + 1, + size: Int64(markdown.count), modifiedAt: Date(timeIntervalSince1970: 0), kind: .text, mimeType: "text/markdown" @@ -420,7 +473,7 @@ struct ChatArtifactViewerModelTests { await model.load(path: "/remote/README.md", loader: loader) #expect(model.state == .markdown) - #expect(model.renderedText == "# Heading\n\nBody") + #expect(model.renderedText.utf8.count == markdown.count) #expect(model.markdownPresentation.mode == .raw) #expect(!model.markdownPresentation.isRenderedAvailable) } @@ -448,6 +501,13 @@ struct ChatArtifactViewerModelTests { ) } + private static func loader(stat: ChatArtifactStat) -> ChatArtifactLoader { + ChatArtifactLoader( + supportsArtifacts: true, + stat: { _ in stat } + ) + } + private static func quickLookLoader(data: Data) -> ChatArtifactLoader { ChatArtifactLoader( supportsArtifacts: true, diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileArtifactChunkFetchLoop.swift b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileArtifactChunkFetchLoop.swift index 5b759b5513fc..17cde88052f2 100644 --- a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileArtifactChunkFetchLoop.swift +++ b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileArtifactChunkFetchLoop.swift @@ -19,10 +19,12 @@ struct MobileArtifactChunkFetchLoop: Sendable { ) async throws -> Data { var offset: Int64 = 0 var result = Data() + var validator = ChatArtifactChunkValidator() while true { try Task.checkCancellation() let chunk = try await fetchChunk(offset) try Task.checkCancellation() + try validator.receive(chunk) if collectsData { if result.isEmpty, chunk.totalSize > 0, chunk.totalSize <= Int64(Int.max) { @@ -34,11 +36,9 @@ struct MobileArtifactChunkFetchLoop: Sendable { progress?(offset, chunk.totalSize) try await onChunk(chunk) if chunk.eof { + try validator.finish() return result } - guard !chunk.data.isEmpty else { - throw ChatArtifactError.loadFailed - } } } } diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileChatArtifactFailureClassifier.swift b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileChatArtifactFailureClassifier.swift index 6f921169a432..e837dda54524 100644 --- a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileChatArtifactFailureClassifier.swift +++ b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileChatArtifactFailureClassifier.swift @@ -2,36 +2,99 @@ internal import CmuxAgentChat internal import CmuxMobileRPC internal import Foundation -/// Keeps artifact availability separate from control-transport reachability. -struct MobileChatArtifactFailureClassifier: Sendable { - func classify(_ error: any Error) -> ChatArtifactError { +/// Preserves file, request, authorization, and transport failures across RPC scopes. +struct MobileArtifactFailureClassifier: Sendable { + func classify(_ error: any Error, method: String? = nil) -> ChatArtifactError { + if let artifactError = error as? ChatArtifactError { + return artifactError + } + if error is DecodingError { + return .invalidResponse + } guard let connectionError = error as? MobileShellConnectionError else { return .loadFailed } switch connectionError { case .connectionClosed: return .macUnreachable - case .invalidResponse, .requestTimedOut, .transportWriteTimedOut, - .connectAttemptGated, .insecureManualRoute, .attachTicketExpired, - .authorizationFailed, .accountMismatch, .routeCleanupBlocked: - return .loadFailed + case .invalidResponse: + return .invalidResponse + case .requestTimedOut, .transportWriteTimedOut: + return .requestTimedOut + case .connectAttemptGated: + return .connectionRecovering + case .routeCleanupBlocked: + return .connectionNeedsRestart + case .insecureManualRoute: + return .secureConnectionRequired + case .attachTicketExpired: + return .authenticationExpired + case .authorizationFailed: + return .authorizationFailed + case .accountMismatch: + return .accountMismatch case .rpcError(let code, _): switch code?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() { case "invalid_params": return .invalidParams - case "not_found": + case "session_not_found": return .sessionNotFound + case "session_unavailable": + return .sessionUnavailable + case "terminal_not_found": + return .terminalNotFound + case "workspace_not_found": + return .workspaceNotFound + case "not_found": + return legacyNotFoundFailure(method: method) + case "not_a_repo": + return .notRepository case "forbidden": return .forbidden case "file_not_found": return .fileNotFound + case "permission_denied": + return .permissionDenied + case "not_directory": + return .notDirectory + case "not_regular_file": + return .notRegularFile + case "read_failed": + return .fileReadFailed + case "file_changed": + return .fileChanged case "unsupported_media": return .unsupportedMedia + case "corrupt_media": + return .corruptMedia + case "preview_failed": + return .previewFailed case "unavailable": return .unavailable + case "invalid_response": + return .invalidResponse + case "transfer_interrupted": + return .transferInterrupted + case "request_timed_out": + return .requestTimedOut + case "unsupported_transport": + return .secureConnectionRequired + case "method_not_found", "capability_disabled": + return .unsupported default: return .loadFailed } } } + + private func legacyNotFoundFailure(method: String?) -> ChatArtifactError { + guard let method else { return .sessionNotFound } + if method.hasPrefix("mobile.terminal.artifact.") { + return .terminalNotFound + } + if method.hasPrefix("mobile.workspace.changes.") { + return .workspaceNotFound + } + return .sessionNotFound + } } diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileChatEventSource.swift b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileChatEventSource.swift index 22aea1904800..cb041069efed 100644 --- a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileChatEventSource.swift +++ b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileChatEventSource.swift @@ -362,7 +362,7 @@ public actor MobileChatEventSource: ChatEventSource { } catch is CancellationError { throw CancellationError() } catch { - throw MobileChatArtifactFailureClassifier().classify(error) + throw MobileArtifactFailureClassifier().classify(error, method: method) } } @@ -419,9 +419,9 @@ public actor MobileChatEventSource: ChatEventSource { } catch MobileArtifactLaneFetchError.failedAfterFirstByte { // Once the lane exposed bytes, mixing in an RPC restart could // splice two file versions into one preview. - throw ChatArtifactError.loadFailed + throw ChatArtifactError.transferInterrupted } catch MobileArtifactLaneFetchError.invalidDescriptor { - throw ChatArtifactError.loadFailed + throw ChatArtifactError.invalidResponse } } return try await fetchArtifactChunksOverRPC( diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+WorkspaceChangesContent.swift b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+WorkspaceChangesContent.swift index 4b42a03bd062..7ac37ffcf597 100644 --- a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+WorkspaceChangesContent.swift +++ b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+WorkspaceChangesContent.swift @@ -179,6 +179,7 @@ extension MobileShellComposite { var result = Data() var fingerprints: [String?] = [] var receivedChunkCount = 0 + var chunkValidator = ChatArtifactChunkValidator() while true { try Task.checkCancellation() let response = try await workspaceChangesFileFetchResponse( @@ -189,6 +190,7 @@ extension MobileShellComposite { length: chunkLength ) let chunk = response.value + try chunkValidator.receive(chunk) try WorkspaceChangesContentFingerprintPolicy().validate( expected: expectedFingerprint, observed: response.contentFingerprint @@ -206,9 +208,9 @@ extension MobileShellComposite { } result.append(chunk.data) offset = chunk.offset + Int64(chunk.data.count) - if chunk.eof { return (result, fingerprints) } - guard !chunk.data.isEmpty else { - throw ChatArtifactError.macUnreachable + if chunk.eof { + try chunkValidator.finish() + return (result, fingerprints) } } } @@ -263,7 +265,7 @@ extension MobileShellComposite { MobileDebugLog.anchormux( "changes.content error method=\(request.method) params=\(request.params) error=\(error)" ) - throw Self.workspaceChangesArtifactError(from: error) + throw MobileArtifactFailureClassifier().classify(error, method: request.method) } } @@ -297,29 +299,6 @@ extension MobileShellComposite { } } - private nonisolated static func workspaceChangesArtifactError( - from error: any Error - ) -> ChatArtifactError { - guard let connectionError = error as? MobileShellConnectionError else { - return .macUnreachable - } - guard case .rpcError(let code, _) = connectionError else { - return .macUnreachable - } - switch code?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() { - case "invalid_params": - return .invalidParams - case "forbidden": - return .forbidden - case "file_not_found", "not_found": - return .fileNotFound - case "unsupported_media": - return .unsupportedMedia - default: - return .macUnreachable - } - } - nonisolated static func workspaceChangesLines(from data: Data) async throws -> [String] { guard !data.isEmpty else { return [] } diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/WorkspaceChangesContentFingerprintPolicy.swift b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/WorkspaceChangesContentFingerprintPolicy.swift index f7fb1ebb2613..1fe8e775313e 100644 --- a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/WorkspaceChangesContentFingerprintPolicy.swift +++ b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/WorkspaceChangesContentFingerprintPolicy.swift @@ -3,12 +3,14 @@ internal import CmuxAgentChat /// Rejects content chunks that no longer match the stat that began their transfer. struct WorkspaceChangesContentFingerprintPolicy: Sendable { func validate(expected: String?, observed: String?) throws { - guard let expected, - let observed, - isIdentityBearing(expected), - isIdentityBearing(observed), - observed == expected else { - throw ChatArtifactError.macUnreachable + guard let expected, let observed else { + throw ChatArtifactError.invalidResponse + } + guard isIdentityBearing(expected), isIdentityBearing(observed) else { + throw ChatArtifactError.invalidResponse + } + guard observed == expected else { + throw ChatArtifactError.fileChanged } } diff --git a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileArtifactChunkFetchLoopTests.swift b/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileArtifactChunkFetchLoopTests.swift index b79710d1d5cd..a140783d1bf7 100644 --- a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileArtifactChunkFetchLoopTests.swift +++ b/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileArtifactChunkFetchLoopTests.swift @@ -81,14 +81,14 @@ struct MobileArtifactChunkFetchLoopTests { } Issue.record("empty non-EOF chunk should fail") } catch let error as ChatArtifactError { - #expect(error != .loadFailed) + #expect(error == .invalidResponse) } catch { Issue.record("unexpected error: \(error)") } let snapshot = await script.snapshot() #expect(snapshot.requestedOffsets == [0]) - #expect(snapshot.deliveredChunks == [stalled]) + #expect(snapshot.deliveredChunks.isEmpty) } @Test @@ -99,18 +99,16 @@ struct MobileArtifactChunkFetchLoopTests { totalSize: 7, eof: true ) - let script = MobileArtifactChunkScript(chunks: [malformed]) - do { _ = try await MobileArtifactChunkFetchLoop().run( collectsData: true, progress: nil - ) { offset in - try await script.fetch(offset: offset) + ) { _ in + malformed } onChunk: { _ in } Issue.record("an out-of-order chunk should fail") - } catch is ChatArtifactError { - // Expected: malformed transfer metadata is a typed protocol failure. + } catch let error as ChatArtifactError { + #expect(error == .invalidResponse) } catch { Issue.record("unexpected error: \(error)") } diff --git a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileChatArtifactFailureClassifierTests.swift b/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileChatArtifactFailureClassifierTests.swift index 0503b3c596d8..c27454b46ff6 100644 --- a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileChatArtifactFailureClassifierTests.swift +++ b/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileChatArtifactFailureClassifierTests.swift @@ -9,27 +9,83 @@ import Testing struct MobileChatArtifactFailureClassifierTests { @Test func reservesMacUnreachableForControlTransportLoss() { - let classifier = MobileChatArtifactFailureClassifier() + let classifier = MobileArtifactFailureClassifier() #expect(classifier.classify(MobileShellConnectionError.connectionClosed) == .macUnreachable) - #expect(classifier.classify(MobileShellConnectionError.requestTimedOut) != .loadFailed) - #expect(classifier.classify(MobileShellConnectionError.transportWriteTimedOut) != .loadFailed) - #expect(classifier.classify(MobileShellConnectionError.invalidResponse) != .loadFailed) - #expect(classifier.classify(MobileShellConnectionError.connectAttemptGated) != .loadFailed) - #expect(classifier.classify(MobileShellConnectionError.routeCleanupBlocked) != .loadFailed) - #expect(classifier.classify(MobileShellConnectionError.attachTicketExpired) != .loadFailed) - #expect(classifier.classify(MobileShellConnectionError.authorizationFailed("denied")) != .loadFailed) - #expect(classifier.classify(MobileShellConnectionError.accountMismatch("wrong account")) != .loadFailed) + #expect(classifier.classify(MobileShellConnectionError.requestTimedOut) == .requestTimedOut) + #expect(classifier.classify(MobileShellConnectionError.transportWriteTimedOut) == .requestTimedOut) + #expect(classifier.classify(MobileShellConnectionError.invalidResponse) == .invalidResponse) + #expect(classifier.classify(MobileShellConnectionError.connectAttemptGated) == .connectionRecovering) + #expect(classifier.classify(MobileShellConnectionError.routeCleanupBlocked) == .connectionNeedsRestart) + #expect(classifier.classify(MobileShellConnectionError.insecureManualRoute) == .secureConnectionRequired) + #expect(classifier.classify(MobileShellConnectionError.attachTicketExpired) == .authenticationExpired) + #expect(classifier.classify(MobileShellConnectionError.authorizationFailed("denied")) == .authorizationFailed) + #expect(classifier.classify(MobileShellConnectionError.accountMismatch("wrong account")) == .accountMismatch) #expect(classifier.classify(CocoaError(.fileReadUnknown)) == .loadFailed) } @Test func preservesServerArtifactFailures() { - let classifier = MobileChatArtifactFailureClassifier() + let classifier = MobileArtifactFailureClassifier() - #expect(classifier.classify(MobileShellConnectionError.rpcError("not_found", "gone")) == .sessionNotFound) - #expect(classifier.classify(MobileShellConnectionError.rpcError("file_not_found", "gone")) == .fileNotFound) - #expect(classifier.classify(MobileShellConnectionError.rpcError("forbidden", "denied")) == .forbidden) + let exactCodes: [(String, ChatArtifactError)] = [ + ("invalid_params", .invalidParams), + ("session_not_found", .sessionNotFound), + ("session_unavailable", .sessionUnavailable), + ("terminal_not_found", .terminalNotFound), + ("workspace_not_found", .workspaceNotFound), + ("not_a_repo", .notRepository), + ("forbidden", .forbidden), + ("file_not_found", .fileNotFound), + ("permission_denied", .permissionDenied), + ("not_directory", .notDirectory), + ("not_regular_file", .notRegularFile), + ("read_failed", .fileReadFailed), + ("file_changed", .fileChanged), + ("unsupported_media", .unsupportedMedia), + ("corrupt_media", .corruptMedia), + ("preview_failed", .previewFailed), + ("unavailable", .unavailable), + ("invalid_response", .invalidResponse), + ("transfer_interrupted", .transferInterrupted), + ("request_timed_out", .requestTimedOut), + ("unsupported_transport", .secureConnectionRequired), + ("method_not_found", .unsupported), + ("capability_disabled", .unsupported), + ] + for (code, expected) in exactCodes { + #expect(classifier.classify( + MobileShellConnectionError.rpcError(code, "fixture") + ) == expected) + } + + #expect(classifier.classify( + MobileShellConnectionError.rpcError("not_found", "gone"), + method: "mobile.chat.artifact.stat" + ) == .sessionNotFound) + #expect(classifier.classify( + MobileShellConnectionError.rpcError("not_found", "gone"), + method: "mobile.terminal.artifact.stat" + ) == .terminalNotFound) + #expect(classifier.classify( + MobileShellConnectionError.rpcError("not_found", "gone"), + method: "mobile.workspace.changes.file_stat" + ) == .workspaceNotFound) + #expect(classifier.classify( + MobileShellConnectionError.rpcError("not_found", "gone") + ) == .sessionNotFound) #expect(classifier.classify(MobileShellConnectionError.rpcError("unexpected", "bad")) == .loadFailed) } + + @Test + func preservesTypedAndDecodeFailures() { + let classifier = MobileArtifactFailureClassifier() + let decodeFailure = DecodingError.dataCorrupted(.init( + codingPath: [], + debugDescription: "fixture" + )) + + #expect(classifier.classify(ChatArtifactError.fileChanged) == .fileChanged) + #expect(classifier.classify(decodeFailure) == .invalidResponse) + } } diff --git a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/WorkspaceChangesContentFingerprintPolicyTests.swift b/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/WorkspaceChangesContentFingerprintPolicyTests.swift index 0ff3df96aa4d..351338abbb1f 100644 --- a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/WorkspaceChangesContentFingerprintPolicyTests.swift +++ b/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/WorkspaceChangesContentFingerprintPolicyTests.swift @@ -18,7 +18,7 @@ import Testing } @Test func mismatchDoesNotClaimMacUnreachable() { - expectFailureOtherThanMacUnreachable { + #expect(throws: ChatArtifactError.fileChanged) { try WorkspaceChangesContentFingerprintPolicy().validate( expected: "stat:10:100:2:300:101", observed: "stat:10:100:2:301:101" @@ -27,7 +27,7 @@ import Testing } @Test func missingFingerprintAfterEstablishmentDoesNotClaimMacUnreachable() { - expectFailureOtherThanMacUnreachable { + #expect(throws: ChatArtifactError.invalidResponse) { try WorkspaceChangesContentFingerprintPolicy().validate( expected: "stat:10:100:2:300:101", observed: nil @@ -36,13 +36,13 @@ import Testing } @Test func missingExpectedFingerprintDoesNotClaimMacUnreachable() { - expectFailureOtherThanMacUnreachable { + #expect(throws: ChatArtifactError.invalidResponse) { try WorkspaceChangesContentFingerprintPolicy().validate( expected: nil, observed: "stat:10:100:2:300:101" ) } - expectFailureOtherThanMacUnreachable { + #expect(throws: ChatArtifactError.invalidResponse) { try WorkspaceChangesContentFingerprintPolicy().validate( expected: nil, observed: nil @@ -51,7 +51,7 @@ import Testing } @Test func presentLegacyFingerprintShapeIsRejected() { - expectFailureOtherThanMacUnreachable { + #expect(throws: ChatArtifactError.invalidResponse) { try WorkspaceChangesContentFingerprintPolicy().validate( expected: "stat:10:100", observed: "stat:10:100" @@ -59,16 +59,4 @@ import Testing } } - private func expectFailureOtherThanMacUnreachable( - _ operation: () throws -> Void - ) { - do { - try operation() - Issue.record("invalid fingerprint state should fail") - } catch let error as ChatArtifactError { - #expect(error != .macUnreachable) - } catch { - Issue.record("unexpected error: \(error)") - } - } } diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/AgentChatDemoScreen.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/AgentChatDemoScreen.swift index b0d30d728ba4..d28af7b41de3 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/AgentChatDemoScreen.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/AgentChatDemoScreen.swift @@ -170,12 +170,70 @@ struct AgentChatDemoScreen: View { ) let error: ChatArtifactError switch failure { + case "unsupported": + error = .unsupported + case "invalid_params": + error = .invalidParams case "session_not_found": error = .sessionNotFound + case "session_unavailable": + error = .sessionUnavailable + case "terminal_not_found": + error = .terminalNotFound + case "workspace_not_found": + error = .workspaceNotFound + case "not_a_repo": + error = .notRepository + case "forbidden": + error = .forbidden + case "file_not_found": + error = .fileNotFound + case "permission_denied": + error = .permissionDenied + case "not_directory": + error = .notDirectory + case "not_regular_file": + error = .notRegularFile + case "read_failed": + error = .fileReadFailed + case "file_changed": + error = .fileChanged + case "unsupported_media": + error = .unsupportedMedia + case "corrupt_media": + error = .corruptMedia + case "preview_failed": + error = .previewFailed + case "unavailable": + error = .unavailable + case "invalid_response": + error = .invalidResponse + case "transfer_interrupted": + error = .transferInterrupted + case "request_timed_out": + error = .requestTimedOut + case "connection_recovering": + error = .connectionRecovering + case "connection_needs_restart": + error = .connectionNeedsRestart + case "secure_connection_required": + error = .secureConnectionRequired + case "authentication_expired": + error = .authenticationExpired + case "authorization_failed": + error = .authorizationFailed + case "account_mismatch": + error = .accountMismatch + case "local_storage_full": + error = .localStorageFull + case "local_storage_unavailable": + error = .localStorageUnavailable case "load_failed": error = .loadFailed case "mac_unreachable": error = .macUnreachable + case "too_large": + error = .tooLarge(limitBytes: 1_024) default: return .unsupported() } diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalArtifactFilesSheet+Content.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalArtifactFilesSheet+Content.swift index 27faac4be764..49945937b12f 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalArtifactFilesSheet+Content.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalArtifactFilesSheet+Content.swift @@ -536,60 +536,33 @@ extension TerminalArtifactFilesSheet { failure: TerminalArtifactGalleryFailure, retry: @escaping @MainActor () async -> Void ) -> some View { + let presentation = ChatArtifactFailurePresentation( + error: failure.error, + scope: scope == .session ? .chat : .terminal + ) ContentUnavailableView { Label( - failureTitle(failure), - systemImage: failureSystemImage(failure) + presentation.title, + systemImage: presentation.systemImage ) } description: { - Text(failureMessage(failure)) + Text(presentation.message) } actions: { - Button { - Task { await retry() } - } label: { - Label( - String( - localized: "terminal.artifact.gallery.retry", - defaultValue: "Retry", - bundle: .module - ), - systemImage: "arrow.clockwise" - ) + if presentation.allowsRetry { + Button { + Task { await retry() } + } label: { + Label( + String( + localized: "terminal.artifact.gallery.retry", + defaultValue: "Retry", + bundle: .module + ), + systemImage: "arrow.clockwise" + ) + } + .buttonStyle(.borderedProminent) } - .buttonStyle(.borderedProminent) - } - } - - private func failureTitle(_ failure: TerminalArtifactGalleryFailure) -> String { - switch failure { - case .macUnreachable: - String(localized: "terminal.artifact.gallery.unreachable.title", defaultValue: "Mac unreachable", bundle: .module) - case .sessionMissing: - String(localized: "terminal.artifact.gallery.session_missing.title", defaultValue: "Session not found", bundle: .module) - case .loadFailed: - String(localized: "terminal.artifact.gallery.load_failed.title", defaultValue: "Couldn’t load files", bundle: .module) - } - } - - private func failureMessage(_ failure: TerminalArtifactGalleryFailure) -> String { - switch failure { - case .macUnreachable: - String(localized: "terminal.artifact.gallery.unreachable.message", defaultValue: "Check the connection to your Mac and try again.", bundle: .module) - case .sessionMissing: - String(localized: "terminal.artifact.gallery.session_missing.message", defaultValue: "The chat session for these files is no longer available.", bundle: .module) - case .loadFailed: - String(localized: "terminal.artifact.gallery.load_failed.message", defaultValue: "The file list couldn’t be loaded. Try again.", bundle: .module) - } - } - - private func failureSystemImage(_ failure: TerminalArtifactGalleryFailure) -> String { - switch failure { - case .macUnreachable: - "wifi.exclamationmark" - case .sessionMissing: - "exclamationmark.bubble" - case .loadFailed: - "exclamationmark.triangle" } } diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalArtifactFilesSheet.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalArtifactFilesSheet.swift index 1c65c1971177..a70f5267a9fe 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalArtifactFilesSheet.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalArtifactFilesSheet.swift @@ -50,25 +50,13 @@ struct TerminalArtifactSelection: Identifiable, Equatable { var id: String { "\(workspaceID)#\(surfaceID)#\(sessionID ?? "terminal")#\(path)" } } -enum TerminalArtifactGalleryFailure: Equatable { - case macUnreachable - case sessionMissing - case loadFailed +struct TerminalArtifactGalleryFailure: Equatable { + let error: ChatArtifactError + + static let loadFailed = Self(error: ChatArtifactError.loadFailed) init(error: any Error) { - guard let artifactError = error as? ChatArtifactError else { - self = .loadFailed - return - } - switch artifactError { - case .macUnreachable: - self = .macUnreachable - case .sessionNotFound: - self = .sessionMissing - case .unsupported, .invalidParams, .forbidden, .fileNotFound, - .unsupportedMedia, .unavailable, .loadFailed, .tooLarge: - self = .loadFailed - } + self.error = (error as? ChatArtifactError) ?? .loadFailed } } diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalArtifactGalleryItemView.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalArtifactGalleryItemView.swift index 10d963d1c010..c8300ae6c992 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalArtifactGalleryItemView.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalArtifactGalleryItemView.swift @@ -54,7 +54,7 @@ struct TerminalArtifactGalleryItemView: View, Equatable { @State private var thumbnail: ChatArtifactThumbnail? @State private var fileActionPresentation: ChatArtifactFileActionPresentation? @State private var isFileActionRunning = false - @State private var showsFileActionError = false + @State private var fileActionFailure: ChatArtifactError? @ScaledMetric(relativeTo: .subheadline) private var gridNameMinHeight: CGFloat = 38 @ScaledMetric(relativeTo: .caption2) private var gridMetadataMinHeight: CGFloat = 28 @ScaledMetric(relativeTo: .body) private var gridSymbolSize: CGFloat = 48 @@ -119,20 +119,12 @@ struct TerminalArtifactGalleryItemView: View, Equatable { } .chatArtifactFileActionPresentation($fileActionPresentation) .alert( - String( - localized: "terminal.artifact.gallery.action_failed.title", - defaultValue: "Couldn't complete action", - bundle: .module - ), - isPresented: $showsFileActionError + fileActionFailurePresentation.title, + isPresented: fileActionFailureBinding ) { Button(String(localized: "terminal.artifact.gallery.ok", defaultValue: "OK", bundle: .module)) {} } message: { - Text(String( - localized: "terminal.artifact.gallery.action_failed.message", - defaultValue: "Check the connection to your Mac and try again.", - bundle: .module - )) + Text(fileActionFailurePresentation.message) } .task(id: "\(artifact.path)#\(Self.thumbnailDimension)") { guard artifact.kind == .image, artifact.exists else { return } @@ -159,12 +151,28 @@ struct TerminalArtifactGalleryItemView: View, Equatable { } catch is CancellationError { // The row disappeared while its file was being prepared. } catch { - showsFileActionError = true + fileActionFailure = (error as? ChatArtifactError) ?? .loadFailed } isFileActionRunning = false } } + private var fileActionFailureBinding: Binding { + Binding( + get: { fileActionFailure != nil }, + set: { isPresented in + if !isPresented { fileActionFailure = nil } + } + ) + } + + private var fileActionFailurePresentation: ChatArtifactFailurePresentation { + ChatArtifactFailurePresentation( + error: fileActionFailure ?? .loadFailed, + scope: value.openScope == .session ? .chat : .terminal + ) + } + private func open() { actions.open(artifact.path, value.openScope, value.swipeOrder) } diff --git a/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/TerminalArtifactGalleryFailureTests.swift b/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/TerminalArtifactGalleryFailureTests.swift index 11e3a36e4aea..3535b8f55cd3 100644 --- a/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/TerminalArtifactGalleryFailureTests.swift +++ b/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/TerminalArtifactGalleryFailureTests.swift @@ -9,10 +9,10 @@ import Testing struct TerminalArtifactGalleryFailureTests { @Test func preservesSpecificFailureMeaning() { - #expect(TerminalArtifactGalleryFailure(error: ChatArtifactError.macUnreachable) == .macUnreachable) - #expect(TerminalArtifactGalleryFailure(error: ChatArtifactError.sessionNotFound) == .sessionMissing) - #expect(TerminalArtifactGalleryFailure(error: ChatArtifactError.fileNotFound) != .loadFailed) - #expect(TerminalArtifactGalleryFailure(error: CocoaError(.fileReadUnknown)) == .loadFailed) + #expect(TerminalArtifactGalleryFailure(error: ChatArtifactError.macUnreachable).error == .macUnreachable) + #expect(TerminalArtifactGalleryFailure(error: ChatArtifactError.sessionNotFound).error == .sessionNotFound) + #expect(TerminalArtifactGalleryFailure(error: ChatArtifactError.fileNotFound).error == .fileNotFound) + #expect(TerminalArtifactGalleryFailure(error: CocoaError(.fileReadUnknown)).error == .loadFailed) } } #endif diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index f20fab9228e6..bc22f5f2c05a 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -137098,6 +137098,13 @@ } } }, + "mobile.chat.artifact.error.corruptMedia": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "The file contains invalid or damaged media data." } }, + "ja": { "stringUnit": { "state": "translated", "value": "ファイルに無効または破損したメディアデータが含まれています。" } } + } + }, "mobile.chat.artifact.error.fileNotFound": { "extractionState": "manual", "localizations": { @@ -137183,6 +137190,23 @@ } } }, + "mobile.chat.artifact.error.invalidPath": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "The file path is invalid." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ファイルパスが無効です。" + } + } + } + }, "mobile.chat.artifact.error.irohTransportUnavailable": { "extractionState": "manual", "localizations": { @@ -137200,6 +137224,41 @@ } } }, + "mobile.chat.artifact.error.notDirectory": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "That path is not a folder." } }, + "ja": { "stringUnit": { "state": "translated", "value": "そのパスはフォルダではありません。" } } + } + }, + "mobile.chat.artifact.error.notRegularFile": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "That path is not a regular file." } }, + "ja": { "stringUnit": { "state": "translated", "value": "そのパスは通常のファイルではありません。" } } + } + }, + "mobile.chat.artifact.error.permissionDenied": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "cmux does not have permission to read that file." } }, + "ja": { "stringUnit": { "state": "translated", "value": "cmuxにはそのファイルを読み取る権限がありません。" } } + } + }, + "mobile.chat.artifact.error.previewFailed": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "The Mac could not create a preview for that file." } }, + "ja": { "stringUnit": { "state": "translated", "value": "Macでそのファイルのプレビューを作成できませんでした。" } } + } + }, + "mobile.chat.artifact.error.readFailed": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "The Mac found that file but could not read it." } }, + "ja": { "stringUnit": { "state": "translated", "value": "Macでファイルは見つかりましたが、読み取れませんでした。" } } + } + }, "mobile.chat.artifact.error.sessionNotFound": { "extractionState": "manual", "localizations": { @@ -137217,6 +137276,13 @@ } } }, + "mobile.chat.artifact.error.sessionUnavailable": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "That session exists, but its file history could not be read." } }, + "ja": { "stringUnit": { "state": "translated", "value": "そのセッションは存在しますが、ファイル履歴を読み取れませんでした。" } } + } + }, "mobile.chat.artifact.error.transferUnavailable": { "extractionState": "manual", "localizations": { diff --git a/Sources/Mobile/MobileHostIrohApplicationLaneRouter.swift b/Sources/Mobile/MobileHostIrohApplicationLaneRouter.swift index 101f6d6a1c22..49818f9f9d9b 100644 --- a/Sources/Mobile/MobileHostIrohApplicationLaneRouter.swift +++ b/Sources/Mobile/MobileHostIrohApplicationLaneRouter.swift @@ -39,6 +39,9 @@ struct MobileHostIrohRejectingArtifactLaneHandler: MobileHostIrohArtifactLaneHan enum MobileHostIrohArtifactTransferIssueFailure: Equatable, Sendable { case fileNotFound + case permissionDenied + case notRegularFile + case readFailed case unavailable } @@ -46,6 +49,10 @@ enum MobileHostIrohArtifactTransferIssueFailure: Equatable, Sendable { actor MobileHostIrohArtifactTransferRegistry { enum Error: Swift.Error, Equatable { case unavailable + case fileNotFound + case permissionDenied + case notRegularFile + case readFailed case invalidFile case capacityExceeded case unknownResource @@ -57,8 +64,14 @@ actor MobileHostIrohArtifactTransferRegistry { var issueFailure: MobileHostIrohArtifactTransferIssueFailure { switch self { - case .invalidFile: + case .fileNotFound: .fileNotFound + case .permissionDenied: + .permissionDenied + case .notRegularFile: + .notRegularFile + case .readFailed, .invalidFile: + .readFailed case .unavailable, .capacityExceeded, .unknownResource, .expired, .peerMismatch, .invalidOffset, .alreadyInUse, .resumeLimitExceeded: .unavailable @@ -195,9 +208,26 @@ struct MobileHostIrohArtifactFileIdentity: Equatable, Sendable { let modifiedNanoseconds: Int64 static func snapshot(path: String) throws -> Self { - let handle = try FileHandle(forReadingFrom: URL(fileURLWithPath: path)) - defer { try? handle.close() } - return try snapshot(fileDescriptor: handle.fileDescriptor) + let descriptor = Darwin.open(path, O_RDONLY | O_NONBLOCK | O_CLOEXEC) + guard descriptor >= 0 else { + switch POSIXErrorCode(rawValue: Darwin.errno) { + case .ENOENT, .ESTALE: + throw MobileHostIrohArtifactTransferRegistry.Error.fileNotFound + case .EACCES, .EPERM: + throw MobileHostIrohArtifactTransferRegistry.Error.permissionDenied + default: + throw MobileHostIrohArtifactTransferRegistry.Error.readFailed + } + } + defer { _ = Darwin.close(descriptor) } + var value = stat() + guard fstat(descriptor, &value) == 0 else { + throw MobileHostIrohArtifactTransferRegistry.Error.readFailed + } + guard (value.st_mode & S_IFMT) == S_IFREG else { + throw MobileHostIrohArtifactTransferRegistry.Error.notRegularFile + } + return identity(from: value) } static func snapshot(fileDescriptor: Int32) throws -> Self { @@ -206,6 +236,10 @@ struct MobileHostIrohArtifactFileIdentity: Equatable, Sendable { (value.st_mode & S_IFMT) == S_IFREG else { throw MobileHostIrohArtifactTransferRegistry.Error.invalidFile } + return identity(from: value) + } + + private static func identity(from value: stat) -> Self { return Self( device: UInt64(value.st_dev), inode: UInt64(value.st_ino), @@ -228,10 +262,22 @@ private final class MobileHostIrohArtifactDispatchReader: @unchecked Sendable { private let channel: DispatchIO init(path: String) throws { - let fileDescriptor = Darwin.open(path, O_RDONLY | O_CLOEXEC) + let fileDescriptor = Darwin.open(path, O_RDONLY | O_NONBLOCK | O_CLOEXEC) guard fileDescriptor >= 0 else { throw MobileHostIrohArtifactTransferRegistry.Error.invalidFile } + var metadata = stat() + guard fstat(fileDescriptor, &metadata) == 0, + (metadata.st_mode & S_IFMT) == S_IFREG else { + Darwin.close(fileDescriptor) + throw MobileHostIrohArtifactTransferRegistry.Error.invalidFile + } + let flags = Darwin.fcntl(fileDescriptor, F_GETFL, 0) + guard flags >= 0, + Darwin.fcntl(fileDescriptor, F_SETFL, flags & ~O_NONBLOCK) >= 0 else { + Darwin.close(fileDescriptor) + throw MobileHostIrohArtifactTransferRegistry.Error.invalidFile + } self.fileDescriptor = fileDescriptor self.channel = DispatchIO( type: .random, diff --git a/Sources/TerminalController+MobileChatArtifacts.swift b/Sources/TerminalController+MobileChatArtifacts.swift index 658597779cd7..1d76d2934b3a 100644 --- a/Sources/TerminalController+MobileChatArtifacts.swift +++ b/Sources/TerminalController+MobileChatArtifacts.swift @@ -8,6 +8,12 @@ private enum TerminalControllerChatArtifactIndexProvider { static let rowCounts = ChatArtifactGalleryRowCountCache(maximumAge: 2) } +private enum MobileChatArtifactIndexError: Error { + case unavailable + case sessionNotFound + case sessionUnavailable +} + extension TerminalController { func v2MobileChatArtifactGallery(params: [String: Any]) async -> V2CallResult { guard let sessionID = v2RawString(params, "session_id")?.trimmingCharacters(in: .whitespacesAndNewlines), @@ -39,9 +45,7 @@ extension TerminalController { cursor = nil } do { - guard let indexedSession = try await mobileChatArtifactIndexedSession(sessionID: sessionID) else { - return mobileChatArtifactError(.notFound, path: "") - } + let indexedSession = try await mobileChatArtifactIndexedSession(sessionID: sessionID) let pageSize = min(max(v2Int(params, "page_size") ?? 60, 1), 100) let query = v2RawString(params, "query") let includeDirectories = v2Bool(params, "include_directories") ?? false @@ -62,9 +66,13 @@ extension TerminalController { includeDirectories: includeDirectories ) }.value - return .ok(ChatArtifactWire.payload(page) ?? [:]) - } catch { + return ChatArtifactWire.result(page) + } catch MobileChatArtifactIndexError.unavailable { + return mobileChatArtifactError(.unavailable, path: "") + } catch MobileChatArtifactIndexError.sessionNotFound { return mobileChatArtifactError(.notFound, path: "") + } catch { + return mobileChatArtifactError(.sessionUnavailable, path: "") } } @@ -72,11 +80,15 @@ extension TerminalController { /// both gallery pages and terminal-bound count-only scans. func mobileChatArtifactIndexedSession( sessionID: String - ) async throws -> (sessionID: String, snapshot: AgentChatArtifactIndex.Snapshot)? { - guard let service = agentChatTranscriptService, - let record = service.sessionRecord(sessionID: sessionID), - let transcriptPath = service.resolver.transcriptPath(for: record) else { - return nil + ) async throws -> (sessionID: String, snapshot: AgentChatArtifactIndex.Snapshot) { + guard let service = agentChatTranscriptService else { + throw MobileChatArtifactIndexError.unavailable + } + guard let record = service.sessionRecord(sessionID: sessionID) else { + throw MobileChatArtifactIndexError.sessionNotFound + } + guard let transcriptPath = service.resolver.transcriptPath(for: record) else { + throw MobileChatArtifactIndexError.sessionUnavailable } let snapshot = try await TerminalControllerChatArtifactIndexProvider.shared.snapshot( sessionID: record.sessionID, @@ -123,19 +135,14 @@ extension TerminalController { let stat = try await Task.detached { try ArtifactByteReader().stat(path: resolved.canonicalPath) }.value - return .ok(ChatArtifactWire.payload(stat) ?? [:]) - } catch ArtifactByteReader.Error.fileNotFound { - debugLogMobileChatArtifactDenial( - code: "file_not_found", reason: "stat-failed", path: resolved.requestedPath - ) - return mobileChatArtifactError(.fileNotFound, path: resolved.requestedPath) - } catch ArtifactByteReader.Error.unsupportedMedia { - return mobileChatArtifactError(.unsupportedMedia, path: resolved.requestedPath) + return ChatArtifactWire.result(stat) + } catch let error as ArtifactByteReader.Error { + return mobileArtifactReadFailure(error, path: resolved.requestedPath) } catch { debugLogMobileChatArtifactDenial( - code: "file_not_found", reason: "stat-failed", path: resolved.requestedPath + code: "read_failed", reason: "stat-failed", path: resolved.requestedPath ) - return mobileChatArtifactError(.fileNotFound, path: resolved.requestedPath) + return mobileArtifactReadFailure(.readFailed, path: resolved.requestedPath) } } @@ -162,16 +169,16 @@ extension TerminalController { data: nil ) } - return .ok(ChatArtifactWire.payload( + return ChatArtifactWire.result( try await executionContext.issueArtifactTransfer( canonicalPath: resolved.canonicalPath ) - ) ?? [:]) + ) } let chunk = try await Task.detached { try ArtifactByteReader().fetch(path: resolved.canonicalPath, offset: offset, length: length) }.value - return .ok(ChatArtifactWire.payload(chunk) ?? [:]) + return ChatArtifactWire.result(chunk) } catch let error as MobileHostIrohArtifactTransferRegistry.Error { switch error.issueFailure { case .fileNotFound: @@ -181,6 +188,12 @@ extension TerminalController { path: resolved.requestedPath ) return mobileChatArtifactError(.fileNotFound, path: resolved.requestedPath) + case .permissionDenied: + return mobileArtifactReadFailure(.permissionDenied, path: resolved.requestedPath) + case .notRegularFile: + return mobileArtifactReadFailure(.notRegularFile, path: resolved.requestedPath) + case .readFailed: + return mobileArtifactReadFailure(.readFailed, path: resolved.requestedPath) case .unavailable: debugLogMobileChatArtifactDenial( code: "unavailable", @@ -189,16 +202,13 @@ extension TerminalController { ) return mobileChatArtifactError(.unavailable, path: resolved.requestedPath) } - } catch ArtifactByteReader.Error.fileNotFound { - debugLogMobileChatArtifactDenial( - code: "file_not_found", reason: "stat-failed", path: resolved.requestedPath - ) - return mobileChatArtifactError(.fileNotFound, path: resolved.requestedPath) + } catch let error as ArtifactByteReader.Error { + return mobileArtifactReadFailure(error, path: resolved.requestedPath) } catch { debugLogMobileChatArtifactDenial( - code: "file_not_found", reason: "stat-failed", path: resolved.requestedPath + code: "read_failed", reason: "fetch-failed", path: resolved.requestedPath ) - return mobileChatArtifactError(.fileNotFound, path: resolved.requestedPath) + return mobileArtifactReadFailure(.readFailed, path: resolved.requestedPath) } } @@ -212,16 +222,11 @@ extension TerminalController { let thumbnail = try await Task.detached { try ArtifactByteReader().thumbnail(path: resolved.canonicalPath, maxDimension: maxDimension) }.value - return .ok(ChatArtifactWire.payload(thumbnail) ?? [:]) - } catch ArtifactByteReader.Error.unsupportedMedia { - return mobileChatArtifactError(.unsupportedMedia, path: resolved.requestedPath) - } catch ArtifactByteReader.Error.fileNotFound { - debugLogMobileChatArtifactDenial( - code: "file_not_found", reason: "stat-failed", path: resolved.requestedPath - ) - return mobileChatArtifactError(.fileNotFound, path: resolved.requestedPath) + return ChatArtifactWire.result(thumbnail) + } catch let error as ArtifactByteReader.Error { + return mobileArtifactReadFailure(error, path: resolved.requestedPath) } catch { - return mobileChatArtifactError(.unsupportedMedia, path: resolved.requestedPath) + return mobileArtifactReadFailure(.previewFailed, path: resolved.requestedPath) } } @@ -234,17 +239,14 @@ extension TerminalController { let listing = try await Task.detached { try ArtifactByteReader().list(path: resolved.canonicalPath) }.value - return .ok(ChatArtifactWire.payload(listing) ?? [:]) - } catch ArtifactByteReader.Error.fileNotFound { - debugLogMobileChatArtifactDenial( - code: "file_not_found", reason: "stat-failed", path: resolved.requestedPath - ) - return mobileChatArtifactError(.fileNotFound, path: resolved.requestedPath) + return ChatArtifactWire.result(listing) + } catch let error as ArtifactByteReader.Error { + return mobileArtifactReadFailure(error, path: resolved.requestedPath) } catch { debugLogMobileChatArtifactDenial( - code: "file_not_found", reason: "stat-failed", path: resolved.requestedPath + code: "read_failed", reason: "list-failed", path: resolved.requestedPath ) - return mobileChatArtifactError(.fileNotFound, path: resolved.requestedPath) + return mobileArtifactReadFailure(.readFailed, path: resolved.requestedPath) } } @@ -305,7 +307,7 @@ extension TerminalController { return .failure(mobileChatArtifactError(.notFound, path: requestedPath)) } guard let transcriptPath = service.resolver.transcriptPath(for: record) else { - return .failure(mobileChatArtifactError(.notFound, path: requestedPath)) + return .failure(mobileChatArtifactError(.sessionUnavailable, path: requestedPath)) } do { let pathResult = try await TerminalControllerChatArtifactIndexProvider.shared.canonicalPath( @@ -325,9 +327,16 @@ extension TerminalController { )) case .canonicalizationFailed: debugLogMobileChatArtifactDenial( - code: "forbidden", reason: "canonicalization-failed", path: requestedPath + code: "invalid_params", reason: "canonicalization-failed", path: requestedPath ) - return .failure(mobileChatArtifactError(.forbidden, path: requestedPath)) + return .failure(.err( + code: "invalid_params", + message: String( + localized: "mobile.chat.artifact.error.invalidPath", + defaultValue: "The file path is invalid." + ), + data: ["path": requestedPath] + )) case .notInSet: debugLogMobileChatArtifactDenial( code: "forbidden", reason: "not-in-set", path: requestedPath @@ -335,7 +344,7 @@ extension TerminalController { return .failure(mobileChatArtifactError(.forbidden, path: requestedPath)) } } catch { - return .failure(mobileChatArtifactError(.notFound, path: requestedPath)) + return .failure(mobileChatArtifactError(.sessionUnavailable, path: requestedPath)) } } @@ -363,6 +372,7 @@ extension TerminalController { private enum MobileChatArtifactErrorKind { case notFound + case sessionUnavailable case forbidden case fileNotFound case unsupportedMedia @@ -376,13 +386,22 @@ extension TerminalController { switch kind { case .notFound: return .err( - code: "not_found", + code: "session_not_found", message: String( localized: "mobile.chat.artifact.error.sessionNotFound", defaultValue: "That agent session is no longer available." ), data: nil ) + case .sessionUnavailable: + return .err( + code: "session_unavailable", + message: String( + localized: "mobile.chat.artifact.error.sessionUnavailable", + defaultValue: "That session exists, but its file history could not be read." + ), + data: nil + ) case .forbidden: return .err( code: "forbidden", @@ -421,15 +440,100 @@ extension TerminalController { ) } } + + func mobileArtifactReadFailure( + _ error: ArtifactByteReader.Error, + path: String? + ) -> V2CallResult { + let data = path.map { ["path": $0] } + switch error { + case .fileNotFound: + return .err( + code: "file_not_found", + message: String( + localized: "mobile.chat.artifact.error.fileNotFound", + defaultValue: "That file is no longer available on the Mac." + ), + data: data + ) + case .permissionDenied: + return .err( + code: "permission_denied", + message: String( + localized: "mobile.chat.artifact.error.permissionDenied", + defaultValue: "cmux does not have permission to read that file." + ), + data: data + ) + case .notDirectory: + return .err( + code: "not_directory", + message: String( + localized: "mobile.chat.artifact.error.notDirectory", + defaultValue: "That path is not a folder." + ), + data: data + ) + case .notRegularFile: + return .err( + code: "not_regular_file", + message: String( + localized: "mobile.chat.artifact.error.notRegularFile", + defaultValue: "That path is not a regular file." + ), + data: data + ) + case .unsupportedMedia: + return .err( + code: "unsupported_media", + message: String( + localized: "mobile.chat.artifact.error.unsupportedMedia", + defaultValue: "This file type cannot be previewed." + ), + data: data + ) + case .corruptMedia: + return .err( + code: "corrupt_media", + message: String( + localized: "mobile.chat.artifact.error.corruptMedia", + defaultValue: "The file contains invalid or damaged media data." + ), + data: data + ) + case .previewFailed: + return .err( + code: "preview_failed", + message: String( + localized: "mobile.chat.artifact.error.previewFailed", + defaultValue: "The Mac could not create a preview for that file." + ), + data: data + ) + case .readFailed: + return .err( + code: "read_failed", + message: String( + localized: "mobile.chat.artifact.error.readFailed", + defaultValue: "The Mac found that file but could not read it." + ), + data: data + ) + } + } } private struct ChatArtifactWire { - static func payload(_ value: T) -> [String: Any]? { + static func result(_ value: T) -> TerminalController.V2CallResult { let coding = ChatWireCoding() guard let data = try? coding.encode(value), let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { - return nil + return .err( + code: "internal_error", + message: "Failed to encode chat artifact response", + data: nil + ) } - return object + return .ok(object) } } diff --git a/Sources/TerminalController+MobileTerminalArtifacts.swift b/Sources/TerminalController+MobileTerminalArtifacts.swift index 45082cbd8e1c..fd64180c00f0 100644 --- a/Sources/TerminalController+MobileTerminalArtifacts.swift +++ b/Sources/TerminalController+MobileTerminalArtifacts.swift @@ -94,11 +94,7 @@ extension TerminalController { ) } do { - guard let indexedSession = try await mobileChatArtifactIndexedSession(sessionID: sessionID) else { - return TerminalArtifactWire.result( - TerminalArtifactScanResponse(artifacts: [], sessionID: sessionID) - ) - } + let indexedSession = try await mobileChatArtifactIndexedSession(sessionID: sessionID) let galleryRowTotal: Int? if requestsGalleryRowTotal { galleryRowTotal = await mobileChatArtifactGalleryRowTotal( @@ -167,12 +163,10 @@ extension TerminalController { #endif return mobileTerminalArtifactError(.forbidden, path: context.requestedPath) } - } catch ArtifactByteReader.Error.fileNotFound { - return mobileTerminalArtifactError(.fileNotFound, path: context.requestedPath) - } catch ArtifactByteReader.Error.unsupportedMedia { - return mobileTerminalArtifactError(.unsupportedMedia, path: context.requestedPath) + } catch let error as ArtifactByteReader.Error { + return mobileArtifactReadFailure(error, path: context.requestedPath) } catch { - return mobileTerminalArtifactError(.fileNotFound, path: context.requestedPath) + return mobileArtifactReadFailure(.readFailed, path: context.requestedPath) } } @@ -218,16 +212,22 @@ extension TerminalController { switch error.issueFailure { case .fileNotFound: return mobileTerminalArtifactError(.fileNotFound, path: context.requestedPath) + case .permissionDenied: + return mobileArtifactReadFailure(.permissionDenied, path: context.requestedPath) + case .notRegularFile: + return mobileArtifactReadFailure(.notRegularFile, path: context.requestedPath) + case .readFailed: + return mobileArtifactReadFailure(.readFailed, path: context.requestedPath) case .unavailable: return mobileTerminalArtifactError(.unavailable, path: context.requestedPath) } } catch TerminalArtifactReadContext.Error.forbidden { debugLogMobileTerminalArtifactDenial(op: "fetch", path: context.requestedPath) return mobileTerminalArtifactError(.forbidden, path: context.requestedPath) - } catch ArtifactByteReader.Error.fileNotFound { - return mobileTerminalArtifactError(.fileNotFound, path: context.requestedPath) + } catch let error as ArtifactByteReader.Error { + return mobileArtifactReadFailure(error, path: context.requestedPath) } catch { - return mobileTerminalArtifactError(.fileNotFound, path: context.requestedPath) + return mobileArtifactReadFailure(.readFailed, path: context.requestedPath) } } @@ -247,10 +247,10 @@ extension TerminalController { } catch TerminalArtifactReadContext.Error.forbidden { debugLogMobileTerminalArtifactDenial(op: "thumbnail", path: context.requestedPath) return mobileTerminalArtifactError(.forbidden, path: context.requestedPath) - } catch ArtifactByteReader.Error.fileNotFound { - return mobileTerminalArtifactError(.fileNotFound, path: context.requestedPath) + } catch let error as ArtifactByteReader.Error { + return mobileArtifactReadFailure(error, path: context.requestedPath) } catch { - return mobileTerminalArtifactError(.unsupportedMedia, path: context.requestedPath) + return mobileArtifactReadFailure(.previewFailed, path: context.requestedPath) } } @@ -269,10 +269,10 @@ extension TerminalController { } catch TerminalArtifactReadContext.Error.forbidden { debugLogMobileTerminalArtifactDenial(op: "list", path: context.requestedPath) return mobileTerminalArtifactError(.forbidden, path: context.requestedPath) - } catch ArtifactByteReader.Error.fileNotFound { - return mobileTerminalArtifactError(.fileNotFound, path: context.requestedPath) + } catch let error as ArtifactByteReader.Error { + return mobileArtifactReadFailure(error, path: context.requestedPath) } catch { - return mobileTerminalArtifactError(.fileNotFound, path: context.requestedPath) + return mobileArtifactReadFailure(.readFailed, path: context.requestedPath) } } @@ -358,7 +358,7 @@ extension TerminalController { switch kind { case .notFound: return .err( - code: "not_found", + code: "terminal_not_found", message: String( localized: "mobile.terminal.artifact.error.terminalNotFound", defaultValue: "That terminal is no longer available." diff --git a/Sources/TerminalController+MobileWorkspaceChanges.swift b/Sources/TerminalController+MobileWorkspaceChanges.swift index b3a68dd298fd..654501f075cc 100644 --- a/Sources/TerminalController+MobileWorkspaceChanges.swift +++ b/Sources/TerminalController+MobileWorkspaceChanges.swift @@ -284,7 +284,7 @@ extension TerminalController { ) -> V2CallResult { let isRemote = resolution == .remote return .err( - code: isRemote ? "not_a_repo" : "not_found", + code: isRemote ? "not_a_repo" : "workspace_not_found", message: isRemote ? Self.mobileWorkspaceChangesNotARepository : Self.mobileWorkspaceChangesUnavailable, diff --git a/cmuxTests/MobileHostIrohAdmissionTests.swift b/cmuxTests/MobileHostIrohAdmissionTests.swift index fa556c0635ef..4102d45a4db8 100644 --- a/cmuxTests/MobileHostIrohAdmissionTests.swift +++ b/cmuxTests/MobileHostIrohAdmissionTests.swift @@ -2,6 +2,7 @@ import CMUXMobileCore import CmuxAgentChat import CmuxIrohTransport import CmuxMobileRPC +import Darwin import Foundation @preconcurrency import Network import Testing @@ -465,8 +466,20 @@ extension MobileHostAuthorizationTests { @Test func testIrohArtifactDescriptorFailuresPreserveFileAndCapacitySemantics() { #expect( MobileHostIrohArtifactTransferRegistry.Error.invalidFile.issueFailure + == .readFailed + ) + #expect( + MobileHostIrohArtifactTransferRegistry.Error.fileNotFound.issueFailure == .fileNotFound ) + #expect( + MobileHostIrohArtifactTransferRegistry.Error.permissionDenied.issueFailure + == .permissionDenied + ) + #expect( + MobileHostIrohArtifactTransferRegistry.Error.notRegularFile.issueFailure + == .notRegularFile + ) #expect( MobileHostIrohArtifactTransferRegistry.Error.unavailable.issueFailure == .unavailable @@ -477,6 +490,23 @@ extension MobileHostAuthorizationTests { ) } + @Test func testIrohArtifactDescriptorRejectsSpecialFilesWithoutBlocking() async throws { + let directory = FileManager.default.temporaryDirectory.appendingPathComponent( + "cmux-iroh-artifact-fifo-\(UUID().uuidString)", + isDirectory: true + ) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: directory) } + let fifo = directory.appendingPathComponent("preview.png") + try #require(Darwin.mkfifo(fifo.path, 0o600) == 0) + let registry = MobileHostIrohArtifactTransferRegistry() + let peer = try irohPeer(endpointCharacter: "f") + + await #expect(throws: MobileHostIrohArtifactTransferRegistry.Error.notRegularFile) { + try await registry.issue(canonicalPath: fifo.path, peer: peer) + } + } + @Test func testIrohArtifactCapabilityIsOpaquePeerBoundAndSeriallyResumable() async throws { let fixture = try MobileHostIrohArtifactFixture(contents: Data("abcdef".utf8)) defer { fixture.remove() } diff --git a/ios/cmuxUITests/cmuxUITests.swift b/ios/cmuxUITests/cmuxUITests.swift index b8a5b9a8c675..5fbcdd947308 100644 --- a/ios/cmuxUITests/cmuxUITests.swift +++ b/ios/cmuxUITests/cmuxUITests.swift @@ -4833,6 +4833,31 @@ final class cmuxUITests: XCTestCase { XCTAssertFalse(app.staticTexts["Mac unreachable"].exists) } + /// A live Mac that cannot find the referenced path must identify the stale + /// file instead of blaming the connection used by the rest of the app. + @MainActor + func testAgentChatMissingAttachmentFileReportsFileNotFound() throws { + let app = launchAgentChatPreviewApp(environment: [ + "CMUX_UITEST_MOCK_DATA": "1", + "CMUX_UITEST_AGENT_CHAT_ARTIFACT_FAILURE": "file_not_found", + ]) + let table = app.tables["ChatTranscriptTableView"] + _ = try scrollToRichAgentChatFixtureRegion(table: table, app: app) + let attachment = app.buttons["ChatAttachmentButton"] + XCTAssertTrue(attachment.waitForExistence(timeout: 4)) + XCTAssertEqual(attachment.label, "ci-failure.png") + guard scrollTranscript(table, toReveal: attachment, timeout: 10) else { + XCTFail("Missing attachment never became visible") + return + } + + tap(attachment, in: app) + + XCTAssertTrue(app.staticTexts["File not found"].waitForExistence(timeout: 5)) + XCTAssertTrue(app.staticTexts["The file is no longer available on your Mac."].exists) + XCTAssertFalse(app.staticTexts["Mac unreachable"].exists) + } + /// Regression for WhatsApp-style chat keyboard tracking: focusing the chat /// composer must translate the actual transcript table frame upward with the /// composer while preserving the table's own bottom-visible content. The table From 900ae8dc55ac77d21236b6b8796c9bf854d32633 Mon Sep 17 00:00:00 2001 From: Abdulaziz Albahar <67667005+azooz2003-bit@users.noreply.github.com> Date: Tue, 11 Aug 2026 13:12:25 -0700 Subject: [PATCH 12/16] fix: scope artifact storage classification --- .../ChatArtifactLocalFailureClassifier.swift | 12 ++++++------ .../Artifacts/ChatArtifactTemporaryFileWriter.swift | 10 +++++----- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactLocalFailureClassifier.swift b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactLocalFailureClassifier.swift index 6604fe3e911e..a6f0eaf0f0d3 100644 --- a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactLocalFailureClassifier.swift +++ b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactLocalFailureClassifier.swift @@ -1,22 +1,22 @@ import CmuxAgentChat import Foundation -enum ChatArtifactLocalFailureClassifier { - static func classify(_ error: any Error) -> ChatArtifactError { +extension ChatArtifactTemporaryFileWriter { + static func classifyLocalFailure(_ error: any Error) -> ChatArtifactError { if let artifactError = error as? ChatArtifactError { return artifactError } if let posixError = error as? POSIXError { - return classify(posixCode: posixError.code) + return classifyLocalFailure(posixCode: posixError.code) } let nsError = error as NSError if nsError.domain == NSPOSIXErrorDomain, let code = POSIXErrorCode(rawValue: Int32(nsError.code)) { - return classify(posixCode: code) + return classifyLocalFailure(posixCode: code) } if let underlying = nsError.userInfo[NSUnderlyingErrorKey] as? NSError, underlying !== nsError { - return classify(underlying) + return classifyLocalFailure(underlying) } if let cocoaError = error as? CocoaError, cocoaError.code == .fileWriteOutOfSpace { @@ -25,7 +25,7 @@ enum ChatArtifactLocalFailureClassifier { return .localStorageUnavailable } - private static func classify(posixCode: POSIXErrorCode) -> ChatArtifactError { + private static func classifyLocalFailure(posixCode: POSIXErrorCode) -> ChatArtifactError { switch posixCode { case .ENOSPC, .EDQUOT: return .localStorageFull diff --git a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactTemporaryFileWriter.swift b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactTemporaryFileWriter.swift index f974ce8f0274..29d0d7a5a2e0 100644 --- a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactTemporaryFileWriter.swift +++ b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactTemporaryFileWriter.swift @@ -21,7 +21,7 @@ actor ChatArtifactTemporaryFileWriter { withIntermediateDirectories: true ) } catch { - throw ChatArtifactLocalFailureClassifier.classify(error) + throw Self.classifyLocalFailure(error) } let fileURL: URL if let preferredFilename { @@ -35,7 +35,7 @@ actor ChatArtifactTemporaryFileWriter { withIntermediateDirectories: true ) } catch { - throw ChatArtifactLocalFailureClassifier.classify(error) + throw Self.classifyLocalFailure(error) } fileURL = itemDirectory.appendingPathComponent(preferredFilename) } else { @@ -53,7 +53,7 @@ actor ChatArtifactTemporaryFileWriter { fileHandle = try FileHandle(forWritingTo: fileURL) } catch { try? FileManager.default.removeItem(at: fileURL) - throw ChatArtifactLocalFailureClassifier.classify(error) + throw Self.classifyLocalFailure(error) } } @@ -67,7 +67,7 @@ actor ChatArtifactTemporaryFileWriter { do { try fileHandle.write(contentsOf: chunk.data) } catch { - throw ChatArtifactLocalFailureClassifier.classify(error) + throw Self.classifyLocalFailure(error) } } @@ -79,7 +79,7 @@ actor ChatArtifactTemporaryFileWriter { do { try fileHandle.close() } catch { - throw ChatArtifactLocalFailureClassifier.classify(error) + throw Self.classifyLocalFailure(error) } self.fileHandle = nil return fileURL From 785582ad8f96ee42ac7439fe8c2d473603456b92 Mon Sep 17 00:00:00 2001 From: Abdulaziz Albahar <67667005+azooz2003-bit@users.noreply.github.com> Date: Tue, 11 Aug 2026 13:24:48 -0700 Subject: [PATCH 13/16] fix: return terminal artifact failure view --- .../CmuxMobileShellUI/TerminalArtifactFilesSheet+Content.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalArtifactFilesSheet+Content.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalArtifactFilesSheet+Content.swift index 49945937b12f..f6110fe72674 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalArtifactFilesSheet+Content.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalArtifactFilesSheet+Content.swift @@ -540,7 +540,7 @@ extension TerminalArtifactFilesSheet { error: failure.error, scope: scope == .session ? .chat : .terminal ) - ContentUnavailableView { + return ContentUnavailableView { Label( presentation.title, systemImage: presentation.systemImage From 02a8bb1a2709505b5b4019c6198d59659290f557 Mon Sep 17 00:00:00 2001 From: Abdulaziz Albahar <67667005+azooz2003-bit@users.noreply.github.com> Date: Tue, 11 Aug 2026 15:08:35 -0700 Subject: [PATCH 14/16] test: harden artifact failure UI coverage --- .../AgentChatDemoScreen.swift | 87 +----------------- .../CMUXMobileRootView+AgentChatPreview.swift | 92 ++++++++++++++++++- ios/cmuxUITests/cmuxUITests.swift | 25 ++++- 3 files changed, 119 insertions(+), 85 deletions(-) diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/AgentChatDemoScreen.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/AgentChatDemoScreen.swift index d28af7b41de3..0a4459add888 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/AgentChatDemoScreen.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/AgentChatDemoScreen.swift @@ -18,9 +18,12 @@ struct AgentChatDemoScreen: View { @State private var stack: DemoStack? @State private var contentWidth: CGFloat = 0 - init(style: AgentChatDemoScreenStyle = .standalone) { + init( + style: AgentChatDemoScreenStyle = .standalone, + artifactLoader: ChatArtifactLoader = .unsupported() + ) { self.style = style - artifactLoader = Self.makeArtifactLoader() + self.artifactLoader = artifactLoader } var body: some View { @@ -163,86 +166,6 @@ struct AgentChatDemoScreen: View { } } - private static func makeArtifactLoader() -> ChatArtifactLoader { - let failure = UITestConfig.value( - for: "CMUX_UITEST_AGENT_CHAT_ARTIFACT_FAILURE", - env: ProcessInfo.processInfo.environment - ) - let error: ChatArtifactError - switch failure { - case "unsupported": - error = .unsupported - case "invalid_params": - error = .invalidParams - case "session_not_found": - error = .sessionNotFound - case "session_unavailable": - error = .sessionUnavailable - case "terminal_not_found": - error = .terminalNotFound - case "workspace_not_found": - error = .workspaceNotFound - case "not_a_repo": - error = .notRepository - case "forbidden": - error = .forbidden - case "file_not_found": - error = .fileNotFound - case "permission_denied": - error = .permissionDenied - case "not_directory": - error = .notDirectory - case "not_regular_file": - error = .notRegularFile - case "read_failed": - error = .fileReadFailed - case "file_changed": - error = .fileChanged - case "unsupported_media": - error = .unsupportedMedia - case "corrupt_media": - error = .corruptMedia - case "preview_failed": - error = .previewFailed - case "unavailable": - error = .unavailable - case "invalid_response": - error = .invalidResponse - case "transfer_interrupted": - error = .transferInterrupted - case "request_timed_out": - error = .requestTimedOut - case "connection_recovering": - error = .connectionRecovering - case "connection_needs_restart": - error = .connectionNeedsRestart - case "secure_connection_required": - error = .secureConnectionRequired - case "authentication_expired": - error = .authenticationExpired - case "authorization_failed": - error = .authorizationFailed - case "account_mismatch": - error = .accountMismatch - case "local_storage_full": - error = .localStorageFull - case "local_storage_unavailable": - error = .localStorageUnavailable - case "load_failed": - error = .loadFailed - case "mac_unreachable": - error = .macUnreachable - case "too_large": - error = .tooLarge(limitBytes: 1_024) - default: - return .unsupported() - } - return ChatArtifactLoader( - supportsArtifacts: true, - stat: { _ in throw error } - ) - } - private func header(for stack: DemoStack) -> some View { ChatSessionHeaderView( descriptor: stack.store.descriptor, diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/CMUXMobileRootView+AgentChatPreview.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/CMUXMobileRootView+AgentChatPreview.swift index 3987e58af834..3bb8ef8a6800 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/CMUXMobileRootView+AgentChatPreview.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/CMUXMobileRootView+AgentChatPreview.swift @@ -1,4 +1,7 @@ +import CmuxAgentChat +import CmuxAgentChatUI import CmuxMobileSupport +import Foundation import SwiftUI extension CMUXMobileRootView { @@ -13,9 +16,12 @@ extension CMUXMobileRootView { @ViewBuilder var agentChatDemoPreview: some View { #if os(iOS) && DEBUG if UITestConfig.agentChatInlinePreviewEnabled { - AgentChatDemoScreen(style: .inlineWorkspace) + AgentChatDemoScreen( + style: .inlineWorkspace, + artifactLoader: agentChatDemoArtifactLoader + ) } else if UITestConfig.agentChatPreviewEnabled { - AgentChatDemoScreen() + AgentChatDemoScreen(artifactLoader: agentChatDemoArtifactLoader) } else { EmptyView() } @@ -23,4 +29,86 @@ extension CMUXMobileRootView { EmptyView() #endif } + + #if os(iOS) && DEBUG + private var agentChatDemoArtifactLoader: ChatArtifactLoader { + let failure = UITestConfig.value( + for: "CMUX_UITEST_AGENT_CHAT_ARTIFACT_FAILURE", + env: ProcessInfo.processInfo.environment + ) + let error: ChatArtifactError + switch failure { + case "unsupported": + error = .unsupported + case "invalid_params": + error = .invalidParams + case "session_not_found": + error = .sessionNotFound + case "session_unavailable": + error = .sessionUnavailable + case "terminal_not_found": + error = .terminalNotFound + case "workspace_not_found": + error = .workspaceNotFound + case "not_a_repo": + error = .notRepository + case "forbidden": + error = .forbidden + case "file_not_found": + error = .fileNotFound + case "permission_denied": + error = .permissionDenied + case "not_directory": + error = .notDirectory + case "not_regular_file": + error = .notRegularFile + case "read_failed": + error = .fileReadFailed + case "file_changed": + error = .fileChanged + case "unsupported_media": + error = .unsupportedMedia + case "corrupt_media": + error = .corruptMedia + case "preview_failed": + error = .previewFailed + case "unavailable": + error = .unavailable + case "invalid_response": + error = .invalidResponse + case "transfer_interrupted": + error = .transferInterrupted + case "request_timed_out": + error = .requestTimedOut + case "connection_recovering": + error = .connectionRecovering + case "connection_needs_restart": + error = .connectionNeedsRestart + case "secure_connection_required": + error = .secureConnectionRequired + case "authentication_expired": + error = .authenticationExpired + case "authorization_failed": + error = .authorizationFailed + case "account_mismatch": + error = .accountMismatch + case "local_storage_full": + error = .localStorageFull + case "local_storage_unavailable": + error = .localStorageUnavailable + case "load_failed": + error = .loadFailed + case "mac_unreachable": + error = .macUnreachable + case "too_large": + error = .tooLarge(limitBytes: 1_024) + default: + return .unsupported() + } + return ChatArtifactLoader( + supportsArtifacts: true, + stat: { _ in throw error } + ) + } + #endif } diff --git a/ios/cmuxUITests/cmuxUITests.swift b/ios/cmuxUITests/cmuxUITests.swift index 5fbcdd947308..bc632cccc5fd 100644 --- a/ios/cmuxUITests/cmuxUITests.swift +++ b/ios/cmuxUITests/cmuxUITests.swift @@ -7446,8 +7446,31 @@ final class cmuxUITests: XCTestCase { if element.isHittable { return true } + let previousMetrics = transcriptMetrics(from: table) + let previousFrame = usableFrameNow(of: element) table.swipeDown(velocity: .slow) - RunLoop.current.run(until: Date().addingTimeInterval(0.12)) + let progress = XCTNSPredicateExpectation( + predicate: NSPredicate { object, _ in + guard let table = object as? XCUIElement else { return false } + if element.isHittable { + return true + } + if let previousMetrics, + let currentMetrics = self.transcriptMetrics(from: table), + abs(currentMetrics.offsetY - previousMetrics.offsetY) > 1 { + return true + } + if let previousFrame, + let currentFrame = self.usableFrameNow(of: element), + abs(currentFrame.minY - previousFrame.minY) > 1 { + return true + } + return false + }, + object: table + ) + let remaining = max(0, deadline.timeIntervalSinceNow) + _ = XCTWaiter.wait(for: [progress], timeout: min(1, remaining)) } return element.isHittable } From a0fca7886e434fcc4b77702e7830bbc6290a7c34 Mon Sep 17 00:00:00 2001 From: Abdulaziz Albahar <67667005+azooz2003-bit@users.noreply.github.com> Date: Wed, 12 Aug 2026 14:25:13 -0700 Subject: [PATCH 15/16] feat: add mobile artifact failure scenario picker --- .../AgentChatDemoScreen.swift | 45 ++- .../AgentChatFailureScenarioPicker.swift | 290 ++++++++++++++++++ .../AgentChatFailureScenarioTests.swift | 28 ++ ios/cmux/Resources/Localizable.xcstrings | 170 ++++++++++ 4 files changed, 528 insertions(+), 5 deletions(-) create mode 100644 Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/AgentChatFailureScenarioPicker.swift create mode 100644 Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/AgentChatFailureScenarioTests.swift diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/AgentChatDemoScreen.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/AgentChatDemoScreen.swift index 0a4459add888..f7193ea1866d 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/AgentChatDemoScreen.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/AgentChatDemoScreen.swift @@ -12,18 +12,29 @@ import SwiftUI /// host serves real transcripts. struct AgentChatDemoScreen: View { let style: AgentChatDemoScreenStyle - private let artifactLoader: ChatArtifactLoader + private let fixedArtifactLoader: ChatArtifactLoader? @Environment(\.dismiss) private var dismiss @State private var stack: DemoStack? @State private var contentWidth: CGFloat = 0 + @State private var selectedFailureScenario: AgentChatFailureScenario + @State private var showingFailureScenarioPicker = false init( style: AgentChatDemoScreenStyle = .standalone, - artifactLoader: ChatArtifactLoader = .unsupported() + artifactLoader: ChatArtifactLoader? = nil ) { self.style = style - self.artifactLoader = artifactLoader + self.fixedArtifactLoader = artifactLoader + _selectedFailureScenario = State(initialValue: .fileNotFound) + } + + private var usesFailureScenarioPicker: Bool { + style == .standalone && fixedArtifactLoader == nil + } + + private var activeArtifactLoader: ChatArtifactLoader { + fixedArtifactLoader ?? selectedFailureScenario.loader } var body: some View { @@ -48,9 +59,33 @@ struct AgentChatDemoScreen: View { Button(L10n.string("mobile.common.done", defaultValue: "Done")) { dismiss() } .accessibilityIdentifier("AgentChatDemoDone") } + if usesFailureScenarioPicker { + ToolbarItem(placement: .topBarTrailing) { + Button { + showingFailureScenarioPicker = true + } label: { + Label( + L10n.string( + "mobile.settings.agentChat.failureScenario", + defaultValue: "Failure scenario" + ), + systemImage: "list.bullet.rectangle" + ) + } + .accessibilityIdentifier("AgentChatFailureScenarioMenu") + .accessibilityValue(Text(verbatim: selectedFailureScenario.title)) + } + } } } } + .sheet(isPresented: $showingFailureScenarioPicker) { + AgentChatFailureScenarioPicker(selection: $selectedFailureScenario) + } + .onChange(of: selectedFailureScenario) { _, _ in + guard usesFailureScenarioPicker else { return } + stack = nil + } } @ViewBuilder @@ -153,7 +188,7 @@ struct AgentChatDemoScreen: View { providesOwnChrome: false, onOpenTerminal: {} ) - .environment(\.chatArtifactLoader, artifactLoader) + .environment(\.chatArtifactLoader, activeArtifactLoader) case .inlineWorkspace: ChatScreen( store: stack.store, @@ -162,7 +197,7 @@ struct AgentChatDemoScreen: View { providesOwnChrome: false, onOpenTerminal: {} ) - .environment(\.chatArtifactLoader, artifactLoader) + .environment(\.chatArtifactLoader, activeArtifactLoader) } } diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/AgentChatFailureScenarioPicker.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/AgentChatFailureScenarioPicker.swift new file mode 100644 index 000000000000..0a04afe966fd --- /dev/null +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/AgentChatFailureScenarioPicker.swift @@ -0,0 +1,290 @@ +#if DEBUG +import CmuxAgentChat +import CmuxAgentChatUI +import CmuxMobileSupport +import Foundation +import SwiftUI + +/// A deterministic artifact failure that can be selected from the debug chat +/// demo. Each case drives the same viewer entry point as a real Mac response. +enum AgentChatFailureScenario: String, CaseIterable, Hashable, Identifiable, Sendable { + case unsupported + case invalidParams = "invalid_params" + case sessionNotFound = "session_not_found" + case sessionUnavailable = "session_unavailable" + case terminalNotFound = "terminal_not_found" + case workspaceNotFound = "workspace_not_found" + case notRepository = "not_a_repo" + case forbidden + case fileNotFound = "file_not_found" + case permissionDenied = "permission_denied" + case notDirectory = "not_directory" + case notRegularFile = "not_regular_file" + case fileReadFailed = "file_read_failed" + case fileChanged = "file_changed" + case unsupportedMedia = "unsupported_media" + case corruptMedia = "corrupt_media" + case previewFailed = "preview_failed" + case unavailable + case invalidResponse = "invalid_response" + case transferInterrupted = "transfer_interrupted" + case requestTimedOut = "request_timed_out" + case connectionRecovering = "connection_recovering" + case connectionNeedsRestart = "connection_needs_restart" + case secureConnectionRequired = "secure_connection_required" + case authenticationExpired = "authentication_expired" + case authorizationFailed = "authorization_failed" + case accountMismatch = "account_mismatch" + case localStorageFull = "local_storage_full" + case localStorageUnavailable = "local_storage_unavailable" + case loadFailed = "load_failed" + case macUnreachable = "mac_unreachable" + case tooLarge = "too_large" + + var id: String { rawValue } + + /// The typed error the real artifact viewer receives from its loader. + var error: ChatArtifactError { + switch self { + case .unsupported: .unsupported + case .invalidParams: .invalidParams + case .sessionNotFound: .sessionNotFound + case .sessionUnavailable: .sessionUnavailable + case .terminalNotFound: .terminalNotFound + case .workspaceNotFound: .workspaceNotFound + case .notRepository: .notRepository + case .forbidden: .forbidden + case .fileNotFound: .fileNotFound + case .permissionDenied: .permissionDenied + case .notDirectory: .notDirectory + case .notRegularFile: .notRegularFile + case .fileReadFailed: .fileReadFailed + case .fileChanged: .fileChanged + case .unsupportedMedia: .unsupportedMedia + case .corruptMedia: .corruptMedia + case .previewFailed: .previewFailed + case .unavailable: .unavailable + case .invalidResponse: .invalidResponse + case .transferInterrupted: .transferInterrupted + case .requestTimedOut: .requestTimedOut + case .connectionRecovering: .connectionRecovering + case .connectionNeedsRestart: .connectionNeedsRestart + case .secureConnectionRequired: .secureConnectionRequired + case .authenticationExpired: .authenticationExpired + case .authorizationFailed: .authorizationFailed + case .accountMismatch: .accountMismatch + case .localStorageFull: .localStorageFull + case .localStorageUnavailable: .localStorageUnavailable + case .loadFailed: .loadFailed + case .macUnreachable: .macUnreachable + case .tooLarge: .tooLarge(limitBytes: ChatArtifactTransferPolicy.defaultPolicy.maxPreviewBytes) + } + } + + /// Copy rendered by the real unavailable viewer for this failure. + var presentation: ChatArtifactFailurePresentation { + ChatArtifactFailurePresentation( + error: error, + scope: .chat, + actualSize: self == .tooLarge ? ChatArtifactTransferPolicy.defaultPolicy.maxPreviewBytes + 1 : nil + ) + } + + var title: String { presentation.title } + var message: String { presentation.message } + + var category: AgentChatFailureScenarioCategory { + switch self { + case .unsupported, + .invalidParams, + .sessionNotFound, + .sessionUnavailable, + .terminalNotFound, + .workspaceNotFound, + .notRepository, + .forbidden, + .loadFailed: + .request + case .fileNotFound, + .permissionDenied, + .notDirectory, + .notRegularFile, + .fileReadFailed, + .fileChanged: + .filesystem + case .unsupportedMedia, + .corruptMedia, + .previewFailed, + .tooLarge: + .media + case .unavailable, + .invalidResponse, + .transferInterrupted, + .requestTimedOut: + .transfer + case .connectionRecovering, + .connectionNeedsRestart, + .secureConnectionRequired, + .authenticationExpired, + .authorizationFailed, + .accountMismatch, + .macUnreachable: + .connection + case .localStorageFull, + .localStorageUnavailable: + .storage + } + } + + /// A loader that fails at stat time, preserving the selected typed reason. + var loader: ChatArtifactLoader { + let failure = error + return ChatArtifactLoader( + supportsArtifacts: true, + scope: .chat(sessionID: "agent-chat-demo-\(rawValue)"), + stat: { _ in + throw failure + } + ) + } +} + +enum AgentChatFailureScenarioCategory: String, CaseIterable, Identifiable, Sendable { + case request + case filesystem + case media + case transfer + case connection + case storage + + var id: String { rawValue } + + var title: String { + switch self { + case .request: + L10n.string( + "mobile.settings.agentChat.failureScenarios.category.request", + defaultValue: "Request and scope" + ) + case .filesystem: + L10n.string( + "mobile.settings.agentChat.failureScenarios.category.filesystem", + defaultValue: "File system" + ) + case .media: + L10n.string( + "mobile.settings.agentChat.failureScenarios.category.media", + defaultValue: "Media and preview" + ) + case .transfer: + L10n.string( + "mobile.settings.agentChat.failureScenarios.category.transfer", + defaultValue: "Transfer" + ) + case .connection: + L10n.string( + "mobile.settings.agentChat.failureScenarios.category.connection", + defaultValue: "Connection and auth" + ) + case .storage: + L10n.string( + "mobile.settings.agentChat.failureScenarios.category.storage", + defaultValue: "Device storage" + ) + } + } +} + +/// Phone-friendly selector for the complete typed artifact failure matrix. +struct AgentChatFailureScenarioPicker: View { + @Binding private var selection: AgentChatFailureScenario + @Environment(\.dismiss) private var dismiss + + init(selection: Binding) { + _selection = selection + } + + var body: some View { + NavigationStack { + List { + Section { + Text( + L10n.string( + "mobile.settings.agentChat.failureScenarios.instructions", + defaultValue: "Choose a case, then tap the CI attachment in the conversation to see its message." + ) + ) + .font(.subheadline) + .foregroundStyle(.secondary) + .accessibilityIdentifier("AgentChatFailureScenarioInstructions") + } + + ForEach(AgentChatFailureScenarioCategory.allCases) { category in + Section { + ForEach(AgentChatFailureScenario.allCases.filter { $0.category == category }) { scenario in + Button { + selection = scenario + dismiss() + } label: { + scenarioRow(scenario) + } + .buttonStyle(.plain) + .accessibilityIdentifier("AgentChatFailureScenario-\(scenario.rawValue)") + .accessibilityValue(Text(verbatim: scenario.message)) + } + } header: { + Text(verbatim: category.title) + } + } + } + .navigationTitle( + L10n.string( + "mobile.settings.agentChat.failureScenarios.title", + defaultValue: "Artifact failure scenarios" + ) + ) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button( + L10n.string( + "mobile.settings.agentChat.failureScenarios.done", + defaultValue: "Done" + ) + ) { + dismiss() + } + .accessibilityIdentifier("AgentChatFailureScenarioPickerDone") + } + } + } + .accessibilityIdentifier("AgentChatFailureScenarioPicker") + } + + @ViewBuilder + private func scenarioRow(_ scenario: AgentChatFailureScenario) -> some View { + HStack(alignment: .top, spacing: 12) { + Image(systemName: scenario.presentation.systemImage) + .foregroundStyle(.secondary) + .frame(width: 24) + .accessibilityHidden(true) + VStack(alignment: .leading, spacing: 4) { + Text(verbatim: scenario.title) + .foregroundStyle(.primary) + Text(verbatim: scenario.message) + .font(.footnote) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + Spacer(minLength: 8) + if selection == scenario { + Image(systemName: "checkmark") + .foregroundStyle(.tint) + .accessibilityHidden(true) + } + } + .padding(.vertical, 3) + .contentShape(Rectangle()) + } +} +#endif diff --git a/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/AgentChatFailureScenarioTests.swift b/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/AgentChatFailureScenarioTests.swift new file mode 100644 index 000000000000..991230f72e41 --- /dev/null +++ b/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/AgentChatFailureScenarioTests.swift @@ -0,0 +1,28 @@ +#if DEBUG && os(iOS) +import CmuxAgentChat +import Testing + +@testable import CmuxMobileShellUI + +@Suite +struct AgentChatFailureScenarioTests { + @Test + func exposesEveryTypedArtifactFailure() { + let scenarios = AgentChatFailureScenario.allCases + #expect(scenarios.count == 32) + #expect(Set(scenarios.map(\.rawValue)).count == scenarios.count) + #expect(Set(scenarios.map(\.title)).count > 1) + #expect(scenarios.contains(.fileNotFound)) + #expect(scenarios.contains(.macUnreachable)) + #expect(scenarios.contains(.tooLarge)) + } + + @Test + func fixtureLoaderPreservesSelectedTypedError() async { + let loader = AgentChatFailureScenario.fileNotFound.loader + await #expect(throws: ChatArtifactError.fileNotFound) { + try await loader.stat(path: "/tmp/cmux-attachments/ci-failure.png") + } + } +} +#endif diff --git a/ios/cmux/Resources/Localizable.xcstrings b/ios/cmux/Resources/Localizable.xcstrings index 00f8fdb046da..6818d5b20a57 100644 --- a/ios/cmux/Resources/Localizable.xcstrings +++ b/ios/cmux/Resources/Localizable.xcstrings @@ -7175,6 +7175,176 @@ } } }, + "mobile.settings.agentChat.failureScenario": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Failure scenario" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "エラーケース" + } + } + } + }, + "mobile.settings.agentChat.failureScenarios.category.connection": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Connection and auth" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "接続と認証" + } + } + } + }, + "mobile.settings.agentChat.failureScenarios.category.filesystem": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "File system" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ファイルシステム" + } + } + } + }, + "mobile.settings.agentChat.failureScenarios.category.media": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Media and preview" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "メディアとプレビュー" + } + } + } + }, + "mobile.settings.agentChat.failureScenarios.category.request": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Request and scope" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "リクエストと範囲" + } + } + } + }, + "mobile.settings.agentChat.failureScenarios.category.storage": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Device storage" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "デバイスのストレージ" + } + } + } + }, + "mobile.settings.agentChat.failureScenarios.category.transfer": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Transfer" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "転送" + } + } + } + }, + "mobile.settings.agentChat.failureScenarios.done": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Done" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "完了" + } + } + } + }, + "mobile.settings.agentChat.failureScenarios.instructions": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Choose a case, then tap the CI attachment in the conversation to see its message." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ケースを選んでから、会話内のCI添付ファイルをタップするとメッセージを確認できます。" + } + } + } + }, + "mobile.settings.agentChat.failureScenarios.title": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Artifact failure scenarios" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "アーティファクトのエラーケース" + } + } + } + }, "mobile.settings.cmuxLabs": { "extractionState": "manual", "localizations": { From 3c7b2119b21ab6d4062c4984fcb0747fe87909ba Mon Sep 17 00:00:00 2001 From: Abdulaziz Albahar <67667005+azooz2003-bit@users.noreply.github.com> Date: Wed, 12 Aug 2026 19:14:43 -0700 Subject: [PATCH 16/16] docs: clarify artifact load failure --- .../Sources/CmuxAgentChat/Artifacts/ChatArtifactError.swift | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Artifacts/ChatArtifactError.swift b/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Artifacts/ChatArtifactError.swift index a65004ab52a6..59a4c84032f8 100644 --- a/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Artifacts/ChatArtifactError.swift +++ b/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Artifacts/ChatArtifactError.swift @@ -58,7 +58,8 @@ public enum ChatArtifactError: Error, Sendable, Equatable { case localStorageFull /// The local device could not create or update a temporary file. case localStorageUnavailable - /// The artifact operation failed without confirmed closure of the control transport. + /// A request, authorization, route, stream, or file-handle operation failed + /// without confirmed closure of the control transport. case loadFailed /// The control transport to the Mac closed before the request completed. case macUnreachable