diff --git a/.github/swift-file-length-budget.tsv b/.github/swift-file-length-budget.tsv index fa65d6dcf094..58dd164ff9dc 100644 --- a/.github/swift-file-length-budget.tsv +++ b/.github/swift-file-length-budget.tsv @@ -1,7 +1,7 @@ # cmux-owned Swift file length budget. # Format: max_linesrelative path # Reduce counts as files shrink. CI fails if tracked files exceed this budget. -34499 CLI/cmux.swift +34428 CLI/cmux.swift 17954 Sources/AppDelegate.swift 16427 Sources/ContentView.swift 14270 Sources/TerminalController.swift @@ -12,7 +12,7 @@ 9497 cmuxTests/CLINotifyProcessIntegrationRegressionTests.swift 8032 Sources/Panels/BrowserPanelView.swift 8016 CLI/cmux_open.swift -7743 Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift +7761 Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift 7405 cmuxTests/WorkspaceUnitTests.swift 7218 cmuxTests/WorkspaceRemoteConnectionTests.swift 6359 cmuxTests/SessionPersistenceTests.swift @@ -24,8 +24,8 @@ 4483 Sources/cmuxApp.swift 4482 Sources/Panels/FilePreviewPanel.swift 4367 cmuxTests/BrowserPanelTests.swift -4283 Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttySurfaceView.swift 4121 Sources/BrowserWindowPortal.swift +3964 Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttySurfaceView.swift 3934 Sources/Feed/FeedPanelView.swift 3926 cmuxTests/TabManagerUnitTests.swift 3896 cmuxTests/WindowAndDragTests.swift @@ -58,7 +58,7 @@ 1656 Sources/FileExplorerView.swift 1652 cmuxTests/CMUXCLIErrorOutputRegressionTests.swift 1649 cmuxTests/TerminalControllerSocketSecurityTests.swift -1604 Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/TerminalInputTextView.swift +1603 Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/TerminalInputTextView.swift 1560 cmuxTests/TextBoxMentionCompletionTests.swift 1500 cmuxUITests/MultiWindowNotificationsUITests.swift 1499 cmuxTests/OmnibarAndToolsTests.swift diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6494759e3e0f..a46c71d3b8fc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -524,6 +524,29 @@ jobs: -only-testing:cmuxTests/GhosttyOptionAsAltModsTests \ test + - name: Run Codex transcript fallback session-binding regression + if: ${{ matrix.shard == fromJSON(env.CMUX_APP_HOST_FOCUSED_REGRESSION_SHARD) }} + run: | + # Focused gate for the Codex transcript fallback race + # (https://github.com/manaflow-ai/cmux/issues/5550). The full "Run unit + # tests" step below tolerates failures by parsing only the XCTest + # "Executed ... (0 unexpected)" summary, which does NOT count Swift + # Testing (@Test/#expect) issues. This suite is Swift Testing, so a + # regression here would otherwise be silently swallowed. Keep this + # targeted only-testing invocation as a non-tolerant guard. + set -euo pipefail + SOURCE_PACKAGES_DIR="$PWD/.ci-source-packages" + scripts/ci/run-in-console-session.sh \ + scripts/ci/run-app-host-xcodebuild.sh \ + -project cmux.xcodeproj -scheme cmux-unit -configuration Debug \ + -derivedDataPath "$CMUX_DERIVED_DATA_PATH" \ + -clonedSourcePackagesDirPath "$SOURCE_PACKAGES_DIR" \ + -disableAutomaticPackageResolution \ + -destination "platform=macOS" \ + CMUX_SKIP_ZIG_BUILD=1 \ + -only-testing:cmuxTests/AgentChatTranscriptResolverTests \ + test + - name: Run unit tests run: | set -euo pipefail diff --git a/.github/workflows/test-ios.yml b/.github/workflows/test-ios.yml index 8d7e474265bf..953bd90f2a89 100644 --- a/.github/workflows/test-ios.yml +++ b/.github/workflows/test-ios.yml @@ -157,11 +157,8 @@ jobs: ios-simulator: needs: detect-ios-changes if: ${{ needs.detect-ios-changes.outputs.should_run == 'true' }} - # Blacksmith, not the bare `macos-26` label (which our self-hosted fleet - # carries). iOS simulator XCTest runs inside the Simulator, not as a - # foregrounded Mac app, so the Blacksmith macOS foreground limitation that - # blocks macOS app-host XCTest does not apply here; this lane is verified - # green on blacksmith-6vcpu-macos-26. + # Keep iOS CI runner selection behind the repo variable so overflow can + # switch between paid macOS runner providers without a workflow edit. runs-on: ${{ vars.MACOS_RUNNER_IOS || 'blacksmith-6vcpu-macos-26' }} timeout-minutes: 35 strategy: diff --git a/CLI/cmux.swift b/CLI/cmux.swift index 0cd3659c8de9..f73bc679fb2c 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -25304,78 +25304,7 @@ struct CMUXCLI { } func findCodexTranscriptPath(sessionId: String, env: [String: String]) -> String? { - let normalizedSessionId = sessionId.trimmingCharacters(in: .whitespacesAndNewlines) - guard !normalizedSessionId.isEmpty else { return nil } - - let codexHome = normalizedHookValue(env["CODEX_HOME"]) ?? "~/.codex" - let sessionsURL = URL(fileURLWithPath: NSString(string: codexHome).expandingTildeInPath, isDirectory: true) - .appendingPathComponent("sessions", isDirectory: true) - let fileManager = FileManager.default - var newest: URL? - var newestModificationDate: Date? - for directoryURL in recentCodexSessionDirectories(sessionsURL: sessionsURL, fileManager: fileManager) { - guard let urls = try? fileManager.contentsOfDirectory( - at: directoryURL, - includingPropertiesForKeys: [.contentModificationDateKey], - options: [.skipsHiddenFiles] - ) else { - continue - } - - for url in urls { - guard url.pathExtension == "jsonl", - url.lastPathComponent.contains(normalizedSessionId) else { - continue - } - let modificationDate = (try? url.resourceValues(forKeys: [.contentModificationDateKey]).contentModificationDate) ?? .distantPast - if newest == nil || modificationDate > (newestModificationDate ?? .distantPast) { - newest = url - newestModificationDate = modificationDate - } - } - } - return newest?.path - } - - private func recentCodexSessionDirectories(sessionsURL: URL, fileManager: FileManager) -> [URL] { - var directories: [URL] = [] - var seenPaths = Set() - - func appendIfDirectory(_ url: URL) { - guard seenPaths.insert(url.path).inserted else { return } - var isDirectory: ObjCBool = false - guard fileManager.fileExists(atPath: url.path, isDirectory: &isDirectory), - isDirectory.boolValue else { - return - } - directories.append(url) - } - - appendIfDirectory(sessionsURL) - - var utcCalendar = Calendar(identifier: .gregorian) - utcCalendar.timeZone = TimeZone(secondsFromGMT: 0) ?? .current - for calendar in [Calendar.current, utcCalendar] { - for dayOffset in -14...1 { - guard let date = calendar.date(byAdding: .day, value: dayOffset, to: Date()) else { - continue - } - let components = calendar.dateComponents([.year, .month, .day], from: date) - guard let year = components.year, - let month = components.month, - let day = components.day else { - continue - } - appendIfDirectory( - sessionsURL - .appendingPathComponent(String(format: "%04d", year), isDirectory: true) - .appendingPathComponent(String(format: "%02d", month), isDirectory: true) - .appendingPathComponent(String(format: "%02d", day), isDirectory: true) - ) - } - } - - return directories + nil } private static let codexMonitorLeaseDirectoryName = "codex-monitor-leases" diff --git a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Composer/ChatComposerView.swift b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Composer/ChatComposerView.swift index a4484ab89856..cae6e64cd582 100644 --- a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Composer/ChatComposerView.swift +++ b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Composer/ChatComposerView.swift @@ -2,12 +2,10 @@ import CmuxAgentChat import CmuxMobileSupport import Foundation import SwiftUI - #if os(iOS) import PhotosUI import UIKit #endif - public struct ChatComposerView: View { private let agentState: ChatAgentState private let agentKind: ChatAgentKind @@ -39,7 +37,6 @@ public struct ChatComposerView: View { private static let maxAttachmentDimension: CGFloat = 2048 private static let jpegQuality: CGFloat = 0.85 private static let hardStopWindow: TimeInterval = 2 - public init( agentState: ChatAgentState, agentKind: ChatAgentKind, @@ -97,8 +94,9 @@ public struct ChatComposerView: View { } #if os(iOS) - @ViewBuilder + @MainActor @ViewBuilder private var composerSurface: some View { + #if compiler(>=6.2) if #available(iOS 26.0, *) { GlassEffectContainer { composerStack @@ -106,10 +104,13 @@ public struct ChatComposerView: View { } else { composerStack } + #else + composerStack + #endif } #endif - private var composerStack: some View { + @MainActor private var composerStack: some View { VStack(spacing: 8) { if isEnded { endedRow @@ -166,7 +167,7 @@ public struct ChatComposerView: View { // MARK: - Field row - private var fieldRow: some View { + @MainActor private var fieldRow: some View { HStack(alignment: .bottom, spacing: 8) { #if os(iOS) attachButton @@ -225,7 +226,7 @@ public struct ChatComposerView: View { agentState == .ended } - private var endedRow: some View { + @MainActor private var endedRow: some View { HStack(spacing: 12) { Text( String( @@ -254,7 +255,7 @@ public struct ChatComposerView: View { // MARK: - Send / stop button - @ViewBuilder + @MainActor @ViewBuilder private var sendButton: some View { if hasContent { Button(action: performSend) { @@ -375,14 +376,13 @@ public struct ChatComposerView: View { isDraftFocused = true } - private var attachButton: some View { - PhotosPicker(selection: $pickedItems, maxSelectionCount: 4, matching: .images) { - MobileComposerIconLabel( - systemImage: "paperclip", - foregroundStyle: AnyShapeStyle(Color.secondary.opacity(0.8)), - size: controlHeight - ) - } + @MainActor private var attachButton: some View { + let label = MobileComposerIconLabel( + systemImage: "paperclip", + foregroundStyle: AnyShapeStyle(Color.secondary.opacity(0.8)), + size: controlHeight + ) + return PhotosPicker(selection: $pickedItems, maxSelectionCount: 4, matching: .images) { label } .buttonStyle(.plain) .accessibilityIdentifier("ChatComposerAttach") .accessibilityLabel( @@ -398,7 +398,7 @@ public struct ChatComposerView: View { } } - private var micButton: some View { + @MainActor private var micButton: some View { let listening = dictation.state.isListening return MobileComposerIconButton( systemImage: "mic", @@ -423,7 +423,7 @@ public struct ChatComposerView: View { } } - private var attachmentStrip: some View { + @MainActor private var attachmentStrip: some View { ScrollView(.horizontal, showsIndicators: false) { HStack(spacing: 8) { ForEach(Array(attachments.enumerated()), id: \.element.id) { index, attachment in @@ -449,7 +449,7 @@ public struct ChatComposerView: View { } } - private func removeButton(id: String, index: Int) -> some View { + @MainActor private func removeButton(id: String, index: Int) -> some View { Button { removeAttachment(id: id) } label: { diff --git a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Screen/KeyboardDismissTap.swift b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Screen/KeyboardDismissTap.swift index 6958a062dad2..9552e71a89f6 100644 --- a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Screen/KeyboardDismissTap.swift +++ b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Screen/KeyboardDismissTap.swift @@ -69,16 +69,20 @@ struct KeyboardDismissTap: UIViewRepresentable { // and dismisses the keyboard on every tap across other screens // that share the window. if window !== installedWindow { - installedWindow?.removeGestureRecognizer(recognizer) - installedWindow = nil + uninstallRecognizer() } - guard let window else { return } + guard installedWindow == nil, let window else { return } window.addGestureRecognizer(recognizer) installedWindow = window } deinit { + uninstallRecognizer() + } + + func uninstallRecognizer() { installedWindow?.removeGestureRecognizer(recognizer) + installedWindow = nil } @objc private func handleTap() { diff --git a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Transcript/Rows/ChatFileEditCardView.swift b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Transcript/Rows/ChatFileEditCardView.swift index 7505c043f3ba..e1f47c2ac6c8 100644 --- a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Transcript/Rows/ChatFileEditCardView.swift +++ b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Transcript/Rows/ChatFileEditCardView.swift @@ -166,12 +166,14 @@ public struct ChatFileEditCardView: View { private func diffLineAccessibilityLabel(_ line: String) -> String { if line.hasPrefix("+") { + let addedLine = String(line.dropFirst()) return String(localized: "chat.diff.added.accessibility", - defaultValue: "Added: \(line.dropFirst())", bundle: .module) + defaultValue: "Added: \(addedLine)", bundle: .module) } if line.hasPrefix("-") { + let removedLine = String(line.dropFirst()) return String(localized: "chat.diff.removed.accessibility", - defaultValue: "Removed: \(line.dropFirst())", bundle: .module) + defaultValue: "Removed: \(removedLine)", bundle: .module) } if line.hasPrefix("@@") { return String(localized: "chat.diff.hunk.accessibility", diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift index c92982af59e0..47aaa15b0247 100644 --- a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift +++ b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift @@ -998,7 +998,25 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { self.pairingAttemptID = UUID() } + // This non-UIView @MainActor store can have its last reference released off + // the main actor (a background continuation/callback/detached task may hold + // it), so teardown must HOP to the main actor via `isolated deinit`, not + // assert it — `MainActor.assumeIsolated` would trap on an off-main release. + // cmux ships on Swift 6.2 (the isolated-deinit path); the #else only keeps + // the older compat toolchain, which lacks `isolated deinit`, compiling. + #if compiler(>=6.2) isolated deinit { + cancelInFlightWorkForTeardown() + } + #else + deinit { + MainActor.assumeIsolated { + cancelInFlightWorkForTeardown() + } + } + #endif + + private func cancelInFlightWorkForTeardown() { presenceTask?.cancel() networkPathObservationTask?.cancel() terminalEventListenerTask?.cancel() diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePushCoordinator.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePushCoordinator.swift index 27fb8019e5b0..5a7993a25893 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePushCoordinator.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePushCoordinator.swift @@ -140,8 +140,7 @@ public final class MobilePushCoordinator { /// and persist the flag. Returns whether authorization was granted. @discardableResult public func enable() async -> Bool { - let priorStatus = await UNUserNotificationCenter.current() - .notificationSettings().authorizationStatus + let priorStatus = await currentNotificationAuthorizationStatus() // Only an undetermined status produces a real OS prompt; gate the // "shown" event on it so a re-toggle of an already-decided status does // not log a phantom prompt. @@ -172,6 +171,15 @@ public final class MobilePushCoordinator { UIApplication.shared.unregisterForRemoteNotifications() } + private func currentNotificationAuthorizationStatus() async -> UNAuthorizationStatus { + let rawValue = await withCheckedContinuation { continuation in + UNUserNotificationCenter.current().getNotificationSettings { settings in + continuation.resume(returning: settings.authorizationStatus.rawValue) + } + } + return UNAuthorizationStatus(rawValue: rawValue) ?? .notDetermined + } + /// Hand a freshly-registered APNs token to the network layer. public func handleDeviceToken(_ token: Data) async { await registration.register(deviceToken: token) diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/SignInView.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/SignInView.swift index 5039e6322f6e..6b5ed73735d5 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/SignInView.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/SignInView.swift @@ -47,7 +47,7 @@ struct SignInView: View { @ViewBuilder private var signInEntrySwitcher: some View { - #if os(iOS) + #if os(iOS) && compiler(>=6.2) if #available(iOS 26.0, *) { GlassEffectContainer { signInEntryContent diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalComposerView.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalComposerView.swift index 6a2c5d117607..fb750d1f66f2 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalComposerView.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalComposerView.swift @@ -69,31 +69,26 @@ struct TerminalComposerView: View { /// `@Observable` reference type is held with `@State`; SwiftUI tracks the /// `state` it reads (mic button enabled/listening) automatically. @State private var dictation = ComposerDictationController() - init(store: CMUXMobileShellStore, terminalID: String, requestHeightRemeasure: @escaping () -> Void) { self.store = store self.terminalID = terminalID self.requestHeightRemeasure = requestHeightRemeasure } - /// Single-line height of the round attach button beside the field. It stays /// pinned to the bottom edge of the (taller) field via the outer `HStack`'s /// `.bottom` alignment. private let controlHeight: CGFloat = 40 - /// Diameter of the iMessage-style send button INSIDE the field's rounded /// container. With the container's 6pt vertical padding it exactly fills the /// 40pt single-line field height (6 + 28 + 6), centering the circle on a /// one-line message; the inner `HStack`'s `.bottom` alignment keeps it riding /// the last line as the field grows. private let inlineSendDiameter: CGFloat = 28 - /// Line range for the growing compose field. Opens at a SINGLE line (`1...`) so it /// starts as a compact one-line message box and grows as the user types, up to 14 /// lines before scrolling. Each added line grows this view's height, which the host /// reserves above the toolbar, pushing only the terminal up. private let composerLineLimit = 1...14 - /// Minimum height of the compose field, matching the one-line baseline. private let composerFieldMinHeight: CGFloat = 40 @@ -261,8 +256,9 @@ struct TerminalComposerView: View { /// On iOS 26 the glass controls float in a `GlassEffectContainer` over the /// terminal (no opaque bar — that would be glass-on-glass). Earlier OSes get /// a `.bar` material backing behind the material controls. - @ViewBuilder + @MainActor @ViewBuilder private var composerSurface: some View { + #if compiler(>=6.2) if #available(iOS 26.0, *) { GlassEffectContainer { composerBar @@ -271,9 +267,13 @@ struct TerminalComposerView: View { composerBar .background(.bar) } + #else + composerBar + .background(.bar) + #endif } - private var composerBar: some View { + @MainActor private var composerBar: some View { VStack(alignment: .leading, spacing: 6) { // iMessage-style chip row of staged image attachments, ABOVE the // field. Shown only when something is staged so the empty composer @@ -377,7 +377,7 @@ struct TerminalComposerView: View { /// leading side. Tapping toggles dictation; while listening it shows a filled, /// tinted mic. Disabled when the recognizer is unavailable or permission was /// denied so the user is never left tapping a dead control. - private var micButton: some View { + @MainActor private var micButton: some View { let listening = dictation.state.isListening return MobileComposerIconButton( systemImage: "mic", @@ -407,7 +407,7 @@ struct TerminalComposerView: View { /// Horizontal, removable thumbnail chips for the staged attachments. Each /// chip shows the picked image with an x to remove it. - private var attachmentChipRow: some View { + @MainActor private var attachmentChipRow: some View { ScrollView(.horizontal, showsIndicators: false) { HStack(spacing: 8) { ForEach(pendingAttachments) { attachment in @@ -806,7 +806,7 @@ private struct AttachmentChip: View { } } - @ViewBuilder + @MainActor @ViewBuilder private var thumbnailView: some View { if let thumbnail { Image(uiImage: thumbnail) diff --git a/Packages/iOS/CmuxMobileSupport/Sources/CmuxMobileSupport/View+MobileGlass.swift b/Packages/iOS/CmuxMobileSupport/Sources/CmuxMobileSupport/View+MobileGlass.swift index 8ddb5cd4eb78..6e814a840206 100644 --- a/Packages/iOS/CmuxMobileSupport/Sources/CmuxMobileSupport/View+MobileGlass.swift +++ b/Packages/iOS/CmuxMobileSupport/Sources/CmuxMobileSupport/View+MobileGlass.swift @@ -5,7 +5,7 @@ public extension View { /// Glass (iOS 26+) or bordered button styling for secondary sign-in actions. @ViewBuilder func mobileGlassButton() -> some View { - #if os(iOS) + #if os(iOS) && compiler(>=6.2) if #available(iOS 26.0, *) { self .buttonStyle(.glass) @@ -26,7 +26,7 @@ public extension View { /// Prominent glass (iOS 26+) or bordered-prominent primary button styling. @ViewBuilder func mobileGlassProminentButton() -> some View { - #if os(iOS) + #if os(iOS) && compiler(>=6.2) if #available(iOS 26.0, *) { self .buttonStyle(.glassProminent) @@ -55,7 +55,7 @@ public extension View { /// is already backed and this is a no-op. @ViewBuilder func mobileGlassNavigationTitle() -> some View { - #if os(iOS) + #if os(iOS) && compiler(>=6.2) if #available(iOS 26.0, *) { Button {} label: { self @@ -115,7 +115,7 @@ public extension View { /// Glass (iOS 26+) or thin-material capsule pill background for input fields. @ViewBuilder func mobileGlassPill() -> some View { - #if os(iOS) + #if os(iOS) && compiler(>=6.2) if #available(iOS 26.0, *) { self.glassEffect(.regular.interactive(), in: .capsule) } else { @@ -136,7 +136,7 @@ public extension View { @ViewBuilder func mobileGlassField(cornerRadius: CGFloat = 20) -> some View { let shape = RoundedRectangle(cornerRadius: cornerRadius, style: .continuous) - #if os(iOS) + #if os(iOS) && compiler(>=6.2) if #available(iOS 26.0, *) { self.glassEffect(.regular.interactive(), in: shape) } else { @@ -155,7 +155,7 @@ public extension View { /// button (send / dismiss). Pair with a fixed-size icon label. @ViewBuilder func mobileGlassCircle() -> some View { - #if os(iOS) + #if os(iOS) && compiler(>=6.2) if #available(iOS 26.0, *) { self.glassEffect(.regular.interactive(), in: .circle) } else { diff --git a/Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/TerminalHardwareKeyResolver.swift b/Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/TerminalHardwareKeyResolver.swift index 52f7b27a215f..bca42155dec2 100644 --- a/Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/TerminalHardwareKeyResolver.swift +++ b/Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/TerminalHardwareKeyResolver.swift @@ -5,8 +5,6 @@ import Foundation import UIKit struct TerminalHardwareKeyResolver { - private init() {} - private static let supportedModifierFlags: UIKeyModifierFlags = [.shift, .control, .alternate] private static let keyCommands: [TerminalHardwareKeyCommand] = { let navigation = [ @@ -33,8 +31,8 @@ struct TerminalHardwareKeyResolver { return navigation + controlInputs + shiftedControlInputs }() - static func makeKeyCommands(target: Any, action: Selector) -> [UIKeyCommand] { - keyCommands.map { command in + @MainActor func makeKeyCommands(target: Any, action: Selector) -> [UIKeyCommand] { + Self.keyCommands.map { command in UIKeyCommand( input: command.input, modifierFlags: command.modifierFlags, @@ -45,7 +43,7 @@ struct TerminalHardwareKeyResolver { /// Maps a `UIKeyCommand.input*` string to a platform-neutral special key. /// Returns `nil` for ordinary character inputs. - private static func specialKey(for input: String) -> TerminalSpecialKey? { + private func specialKey(for input: String) -> TerminalSpecialKey? { switch input { case UIKeyCommand.inputUpArrow: return .upArrow case UIKeyCommand.inputDownArrow: return .downArrow @@ -63,7 +61,7 @@ struct TerminalHardwareKeyResolver { } /// Translates `UIKeyModifierFlags` into the kit's platform-neutral set. - private static func kitModifiers(_ flags: UIKeyModifierFlags) -> TerminalKeyModifier { + private func kitModifiers(_ flags: UIKeyModifierFlags) -> TerminalKeyModifier { var result: TerminalKeyModifier = [] if flags.contains(.shift) { result.insert(.shift) } if flags.contains(.control) { result.insert(.control) } @@ -71,7 +69,7 @@ struct TerminalHardwareKeyResolver { return result } - static func data(input: String, modifierFlags: UIKeyModifierFlags) -> Data? { + func data(input: String, modifierFlags: UIKeyModifierFlags) -> Data? { let modifiers = kitModifiers(modifierFlags) if let key = specialKey(for: input) { return TerminalKeyEncoder.encode(specialKey: key, modifiers: modifiers) diff --git a/Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/TerminalInputTextView.swift b/Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/TerminalInputTextView.swift index 64864b7aa138..823b912f7bf3 100644 --- a/Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/TerminalInputTextView.swift +++ b/Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/TerminalInputTextView.swift @@ -75,6 +75,7 @@ final class TerminalInputTextView: UIView, UIKeyInput, UITextInput { private var alternateAccessorySticky: Bool { modifierState.isStickyOn(.alternate) } private var commandAccessorySticky: Bool { modifierState.isStickyOn(.command) } private var shiftAccessorySticky: Bool { modifierState.isStickyOn(.shift) } + private let hardwareKeyResolver = TerminalHardwareKeyResolver() /// The in-progress IME composition string, or `nil` when not composing. /// @@ -127,7 +128,7 @@ final class TerminalInputTextView: UIView, UIKeyInput, UITextInput { override var keyCommands: [UIKeyCommand]? { guard markedText == nil else { return nil } - var commands = TerminalHardwareKeyResolver.makeKeyCommands( + var commands = hardwareKeyResolver.makeKeyCommands( target: self, action: #selector(handleHardwareKeyCommand(_:)) ) @@ -719,7 +720,7 @@ final class TerminalInputTextView: UIView, UIKeyInput, UITextInput { if !alternateAccessorySticky { setAlternateAccessoryArmed(false) } - if let output = TerminalHardwareKeyResolver.data( + if let output = hardwareKeyResolver.data( input: UIKeyCommand.inputDelete, modifierFlags: [.alternate] ) { @@ -871,7 +872,7 @@ final class TerminalInputTextView: UIView, UIKeyInput, UITextInput { @discardableResult private func handleHardwareKeyInput(input: String, modifierFlags: UIKeyModifierFlags) -> Bool { - guard let data = TerminalHardwareKeyResolver.data(input: input, modifierFlags: modifierFlags) else { + guard let data = hardwareKeyResolver.data(input: input, modifierFlags: modifierFlags) else { return false } onEscapeSequence?(data) @@ -984,7 +985,7 @@ final class TerminalInputTextView: UIView, UIKeyInput, UITextInput { armed: Bool, sticky: Bool ) { - var config = Self.accessoryButtonConfiguration(armed: armed, sticky: sticky) + var config = accessoryButtonConfiguration(armed: armed, sticky: sticky) let symbolName: String? let title: String switch item { @@ -1019,13 +1020,9 @@ final class TerminalInputTextView: UIView, UIKeyInput, UITextInput { config.contentInsets = Self.accessoryButtonContentInsets button.configuration = config if let actionButton = button as? AccessoryActionButton { - // On iOS 26 the armed and sticky states share the same - // prominent-glass blue fill, so the double-tap *lock* is - // distinguished by a white capsule border drawn over the glass (see - // ``AccessoryActionButton/isStickyLocked``). On earlier OSes the - // flat style already renders the locked white stroke through the - // background configuration, so the layer border stays off to avoid - // a doubled stroke. + // On iOS 26 the double-tap lock uses the prominent-glass fill plus + // the white capsule border from ``AccessoryActionButton/isStickyLocked``. + // Earlier OSes already render the locked stroke through the background style. if #available(iOS 26.0, *) { actionButton.isStickyLocked = sticky } else { @@ -1034,7 +1031,8 @@ final class TerminalInputTextView: UIView, UIKeyInput, UITextInput { } } - private static func accessoryButtonConfiguration(armed: Bool, sticky: Bool) -> UIButton.Configuration { + private func accessoryButtonConfiguration(armed: Bool, sticky: Bool) -> UIButton.Configuration { + #if compiler(>=6.2) if #available(iOS 26.0, *) { var config: UIButton.Configuration = (armed || sticky) ? .prominentGlass() : .glass() config.baseForegroundColor = .white @@ -1043,6 +1041,7 @@ final class TerminalInputTextView: UIView, UIKeyInput, UITextInput { } return config } + #endif var config = UIButton.Configuration.plain() var background = UIBackgroundConfiguration.clear() if sticky { @@ -1276,7 +1275,7 @@ final class TerminalInputTextView: UIView, UIKeyInput, UITextInput { private func controlSequence(for text: String) -> Data? { guard text.count == 1 else { return nil } - return TerminalHardwareKeyResolver.data(input: text, modifierFlags: [.control]) + return hardwareKeyResolver.data(input: text, modifierFlags: [.control]) } private func alternateSequence(for text: String) -> Data? { @@ -1289,12 +1288,12 @@ final class TerminalInputTextView: UIView, UIKeyInput, UITextInput { private func alternateAccessoryOutput(for action: TerminalInputAccessoryAction) -> Data? { switch action { case .leftArrow: - return TerminalHardwareKeyResolver.data( + return hardwareKeyResolver.data( input: UIKeyCommand.inputLeftArrow, modifierFlags: [.alternate] ) case .rightArrow: - return TerminalHardwareKeyResolver.data( + return hardwareKeyResolver.data( input: UIKeyCommand.inputRightArrow, modifierFlags: [.alternate] ) @@ -1319,12 +1318,12 @@ final class TerminalInputTextView: UIView, UIKeyInput, UITextInput { return Data([0x05]) // Ctrl+E - end of line case .upArrow: // Cmd+Up on Mac often scrolls; just send the raw arrow - return TerminalHardwareKeyResolver.data( + return hardwareKeyResolver.data( input: UIKeyCommand.inputUpArrow, modifierFlags: [] ) case .downArrow: - return TerminalHardwareKeyResolver.data( + return hardwareKeyResolver.data( input: UIKeyCommand.inputDownArrow, modifierFlags: [] ) @@ -1345,7 +1344,7 @@ final class TerminalInputTextView: UIView, UIKeyInput, UITextInput { private func shiftAccessoryOutput(for action: TerminalInputAccessoryAction) -> Data? { switch action { case .tab: - return TerminalHardwareKeyResolver.data(input: "\t", modifierFlags: [.shift]) + return hardwareKeyResolver.data(input: "\t", modifierFlags: [.shift]) default: return action.output } diff --git a/Sources/Mobile/AgentChat/AgentChatTranscriptResolver.swift b/Sources/Mobile/AgentChat/AgentChatTranscriptResolver.swift index e667be6aba3c..1401256e57ae 100644 --- a/Sources/Mobile/AgentChat/AgentChatTranscriptResolver.swift +++ b/Sources/Mobile/AgentChat/AgentChatTranscriptResolver.swift @@ -3,25 +3,21 @@ import Foundation /// Resolves the transcript JSONL path for an agent session. /// -/// Preference order: the hook store's recorded `transcriptPath`, then the -/// agent-specific conventional location (claude: encoded-cwd project dir; -/// codex: rollout filename containing the session id). +/// Preference order: the hook store's recorded `transcriptPath`, then Claude's +/// conventional encoded-cwd project location. Codex transcript binding stays on +/// the hook-recorded path because scanning rollout directories can bind a pane +/// to the wrong conversation. struct AgentChatTranscriptResolver: Sendable { private let homeDirectory: URL /// Config-dir root for Claude (`$CLAUDE_CONFIG_DIR` or `~/.claude`). private let claudeConfigRoot: URL - /// Config-dir root for Codex (`$CODEX_HOME` or `~/.codex`). - private let codexConfigRoot: URL /// Creates a resolver. /// - /// The derived-path fallbacks honor the agents' own config-dir env - /// overrides so a user who relocates their config (e.g. `CLAUDE_CONFIG_DIR` - /// or `CODEX_HOME`, including via a launcher/subrouter) still has transcripts - /// resolved. The PRIMARY source remains the hook-recorded absolute - /// `transcriptPath`, which already encodes any custom dir; this only fixes - /// the fallback used when no path was recorded (e.g. a codex session resumed - /// out-of-band, resolved by scanning the sessions dir). + /// The Claude derived-path fallback honors `CLAUDE_CONFIG_DIR` so a user who + /// relocates their config still has Claude transcripts resolved. Codex uses + /// only the hook-recorded absolute `transcriptPath`, which already encodes + /// any custom `CODEX_HOME`. /// /// - Parameters: /// - homeDirectory: Injectable home directory for tests. @@ -36,10 +32,6 @@ struct AgentChatTranscriptResolver: Sendable { override: environment["CLAUDE_CONFIG_DIR"], default: homeDirectory.appendingPathComponent(".claude", isDirectory: true) ) - self.codexConfigRoot = Self.configRoot( - override: environment["CODEX_HOME"], - default: homeDirectory.appendingPathComponent(".codex", isDirectory: true) - ) } /// Resolves a config-dir root from an env override, expanding a leading `~`, @@ -65,16 +57,25 @@ struct AgentChatTranscriptResolver: Sendable { case .claude: return claudeFallbackPath(record: record) case .codex: - return codexFallbackPath(sessionID: record.sessionID) + return nil case .other: return nil } } + /// Returns the hook-recorded transcript path when it still exists. + /// + /// This is intentionally cheap enough for main-actor call sites; it does not + /// run any agent-specific fallback scan. + func recordedTranscriptPath(for record: AgentChatSessionRecord) -> String? { + guard let recorded = record.transcriptPath else { return nil } + let expanded = (recorded as NSString).expandingTildeInPath + return FileManager.default.fileExists(atPath: expanded) ? expanded : nil + } + /// Resolves only paths that are cheap to check from the main-actor mobile - /// session list path. Codex's fallback scans the full sessions tree, so it is - /// intentionally excluded here and remains available only when opening a - /// transcript. + /// session list path. Codex stays limited to recorded paths so cmux never + /// binds a pane by scanning rollout directories. func boundedTranscriptPath(for record: AgentChatSessionRecord) -> String? { if let recorded = recordedTranscriptPath(for: record) { return recorded @@ -87,12 +88,6 @@ struct AgentChatTranscriptResolver: Sendable { } } - private func recordedTranscriptPath(for record: AgentChatSessionRecord) -> String? { - guard let recorded = record.transcriptPath else { return nil } - let expanded = (recorded as NSString).expandingTildeInPath - return FileManager.default.fileExists(atPath: expanded) ? expanded : nil - } - private func claudeFallbackPath(record: AgentChatSessionRecord) -> String? { let fileManager = FileManager.default guard let cwd = record.workingDirectory else { return nil } @@ -104,26 +99,4 @@ struct AgentChatTranscriptResolver: Sendable { .path return fileManager.fileExists(atPath: path) ? path : nil } - - /// Codex rollout files are named `rollout--.jsonl` - /// under `~/.codex/sessions/YYYY/MM/DD/`; scan recent day directories for - /// the session id. - private func codexFallbackPath(sessionID: String) -> String? { - let fileManager = FileManager.default - let root = codexConfigRoot - .appendingPathComponent("sessions", isDirectory: true) - guard let enumerator = fileManager.enumerator( - at: root, - includingPropertiesForKeys: nil, - options: [.skipsHiddenFiles] - ) else { return nil } - let needle = sessionID.lowercased() - for case let url as URL in enumerator { - guard url.pathExtension == "jsonl" else { continue } - if url.lastPathComponent.lowercased().contains(needle) { - return url.path - } - } - return nil - } } diff --git a/cmux.xcodeproj/project.pbxproj b/cmux.xcodeproj/project.pbxproj index 1a0f2bf013e5..f9dd0cb0b658 100644 --- a/cmux.xcodeproj/project.pbxproj +++ b/cmux.xcodeproj/project.pbxproj @@ -28,6 +28,7 @@ C7A51D100000000000000002 /* AgentChatSessionRegistryObservationReviewRegressionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C7A51D100000000000000001 /* AgentChatSessionRegistryObservationReviewRegressionTests.swift */; }; C7A51D000000000000000002 /* AgentChatSessionRegistryObservationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C7A51D000000000000000001 /* AgentChatSessionRegistryObservationTests.swift */; }; ACA7C4A70000000000000005 /* AgentChatTranscriptResolver.swift in Sources */ = {isa = PBXBuildFile; fileRef = ACA7C4A70000000000000006 /* AgentChatTranscriptResolver.swift */; }; + A9E020000000000000000010 /* AgentChatTranscriptResolverTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9E010000000000000000010 /* AgentChatTranscriptResolverTests.swift */; }; C7A52F000000000000000002 /* AgentChatTranscriptService+Wire.swift in Sources */ = {isa = PBXBuildFile; fileRef = C7A52F000000000000000001 /* AgentChatTranscriptService+Wire.swift */; }; ACA7C4A7000000000000000B /* AgentChatTranscriptService.swift in Sources */ = {isa = PBXBuildFile; fileRef = ACA7C4A7000000000000000C /* AgentChatTranscriptService.swift */; }; ACA7C4A70000000000000009 /* AgentChatTranscriptTailer.swift in Sources */ = {isa = PBXBuildFile; fileRef = ACA7C4A7000000000000000A /* AgentChatTranscriptTailer.swift */; }; @@ -1398,6 +1399,7 @@ C7A51D100000000000000001 /* AgentChatSessionRegistryObservationReviewRegressionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AgentChatSessionRegistryObservationReviewRegressionTests.swift; sourceTree = ""; }; C7A51D000000000000000001 /* AgentChatSessionRegistryObservationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AgentChatSessionRegistryObservationTests.swift; sourceTree = ""; }; ACA7C4A70000000000000006 /* AgentChatTranscriptResolver.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = "AgentChatTranscriptResolver.swift"; sourceTree = ""; }; + A9E010000000000000000010 /* AgentChatTranscriptResolverTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AgentChatTranscriptResolverTests.swift; sourceTree = ""; }; C7A52F000000000000000001 /* AgentChatTranscriptService+Wire.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = "AgentChatTranscriptService+Wire.swift"; sourceTree = ""; }; ACA7C4A7000000000000000C /* AgentChatTranscriptService.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = "AgentChatTranscriptService.swift"; sourceTree = ""; }; ACA7C4A7000000000000000A /* AgentChatTranscriptTailer.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = "AgentChatTranscriptTailer.swift"; sourceTree = ""; }; @@ -3735,6 +3737,7 @@ D3610B010000000000000002 /* AgentSessionAutoResumeSettingsTests.swift */, D3610B020000000000000002 /* AgentSessionAutoResumeSwiftTests.swift */, A9E010000000000000000005 /* AgentExecutableResolverTests.swift */, + A9E010000000000000000010 /* AgentChatTranscriptResolverTests.swift */, A9E030000000000000000001 /* AgentSessionSocketSurfaceTests.swift */, A9E050000000000000000001 /* AgentSessionWebRendererTests.swift */, A9E040000000000000000001 /* CodexAppServerSessionTests.swift */, @@ -5368,6 +5371,7 @@ C7A51E000000000000000002 /* AgentChatSessionRegistryLifecycleTests.swift in Sources */, C7A51D100000000000000002 /* AgentChatSessionRegistryObservationReviewRegressionTests.swift in Sources */, C7A51D000000000000000002 /* AgentChatSessionRegistryObservationTests.swift in Sources */, + A9E020000000000000000010 /* AgentChatTranscriptResolverTests.swift in Sources */, A9E020000000000000000005 /* AgentExecutableResolverTests.swift in Sources */, D72260010000000000000001 /* AgentHibernationPortalVisibilityTests.swift in Sources */, D36A00020000000000000001 /* AgentHibernationTests.swift in Sources */, diff --git a/cmuxTests/AgentChatTranscriptResolverTests.swift b/cmuxTests/AgentChatTranscriptResolverTests.swift new file mode 100644 index 000000000000..3793699a43e3 --- /dev/null +++ b/cmuxTests/AgentChatTranscriptResolverTests.swift @@ -0,0 +1,131 @@ +import Foundation +import CMUXAgentLaunch +import Testing + +#if canImport(cmux_DEV) + @testable import cmux_DEV +#elseif canImport(cmux) + @testable import cmux +#endif + +@Suite struct AgentChatTranscriptResolverTests { + @Test("Codex sessions without hook-recorded transcript paths fail closed") + func codexWithoutRecordedTranscriptPathFailsClosed() throws { + let fm = FileManager.default + let home = Self.makeTemporaryHome("agentchat-resolver-codex-no-recorded-path") + defer { try? fm.removeItem(at: home) } + + let sessionID = "codex-session-with-rollout-on-disk" + _ = try Self.writeCodexRollout( + home: home, + sessionID: sessionID + ) + let resolver = Self.codexResolver(home: home) + + #expect(resolver.transcriptPath(for: Self.codexRecord(sessionID: sessionID)) == nil) + } + + @Test("Codex uses the hook-recorded transcript path when it exists") + func codexUsesHookRecordedTranscriptPath() throws { + let fm = FileManager.default + let home = Self.makeTemporaryHome("agentchat-resolver-codex-recorded-path") + defer { try? fm.removeItem(at: home) } + + let sessionID = "codex-session-with-recorded-path" + let rollout = try Self.writeCodexRollout( + home: home, + sessionID: "different-session-in-file" + ) + let resolver = Self.codexResolver(home: home) + let record = Self.codexRecord( + sessionID: sessionID, + transcriptPath: rollout.path + ) + + #expect(Self.resolvedPathsMatch(resolver.transcriptPath(for: record), rollout)) + } + + private static func codexRecord( + sessionID: String, + transcriptPath: String? = nil + ) -> AgentChatSessionRecord { + AgentChatSessionRecord( + sessionID: sessionID, + agentKind: .codex, + workspaceID: nil, + surfaceID: nil, + workingDirectory: nil, + transcriptPath: transcriptPath, + state: .idle, + lastActivityAt: Date(timeIntervalSince1970: 0), + title: nil, + pid: nil + ) + } + + private static func codexResolver(home: URL) -> AgentChatTranscriptResolver { + AgentChatTranscriptResolver( + homeDirectory: home, + environment: [:] + ) + } + + /// Unique temp home for a Codex resolver test. Depending on the runner, + /// `temporaryDirectory` reports the path under `/var` or `/private/var`; + /// tests compare resolved real paths (`resolvedPathsMatch`) so the surface + /// representation does not matter. + private static func makeTemporaryHome(_ label: String) -> URL { + FileManager.default.temporaryDirectory + .appendingPathComponent("\(label)-\(UUID().uuidString)", isDirectory: true) + } + + /// Canonicalizes an existing path with `realpath(3)`. `URL`'s + /// `resolvingSymlinksInPath()` does NOT resolve `/var` -> `/private/var` on + /// some macOS versions, but `realpath` reliably resolves both spellings, so + /// a resolver-returned `/private/var` output and a test's `/var` rollout path + /// compare equal regardless of which form `temporaryDirectory` reports. + private static func canonicalExistingPath(_ path: String) -> String { + guard let resolved = realpath(path, nil) else { return path } + defer { free(resolved) } + return String(cString: resolved) + } + + /// True when a resolver-returned path and the expected rollout URL refer to + /// the same on-disk file, compared by fully symlink-resolved real paths. + private static func resolvedPathsMatch(_ actual: String?, _ expected: URL) -> Bool { + guard let actual else { return false } + return canonicalExistingPath(actual) == canonicalExistingPath(expected.path) + } + + private static func writeCodexRollout( + home: URL, + sessionID: String, + includeSessionMeta: Bool = true, + filenameSessionID: String? = nil, + year: Int = 2026, + month: Int = 6, + day: Int = 26 + ) throws -> URL { + let dir = home + .appendingPathComponent(".codex", isDirectory: true) + .appendingPathComponent("sessions", isDirectory: true) + .appendingPathComponent(String(format: "%04d", year), isDirectory: true) + .appendingPathComponent(String(format: "%02d", month), isDirectory: true) + .appendingPathComponent(String(format: "%02d", day), isDirectory: true) + try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + let dateStamp = String(format: "%04d-%02d-%02d", year, month, day) + let rolloutURL = dir.appendingPathComponent( + "rollout-\(dateStamp)T00-00-00-\(filenameSessionID ?? sessionID).jsonl", + isDirectory: false + ) + var lines: [String] = [] + if includeSessionMeta { + lines.append(#"{"timestamp":"\#(dateStamp)T00:00:00.000Z","type":"session_meta","payload":{"id":"\#(sessionID)","cwd":"/tmp/project"}}"#) + } + lines.append(#"{"timestamp":"\#(dateStamp)T00:00:01.000Z","type":"response_item","payload":{"type":"message","role":"assistant","content":[{"type":"output_text","text":"hello"}]}}"#) + let contents = lines.joined(separator: "\n") + try (contents + "\n").write(to: rolloutURL, atomically: true, encoding: .utf8) + return rolloutURL + } + +} diff --git a/scripts/ci/cmux_unit_test_shard.py b/scripts/ci/cmux_unit_test_shard.py index cbf73c071a6b..f398df1f0412 100755 --- a/scripts/ci/cmux_unit_test_shard.py +++ b/scripts/ci/cmux_unit_test_shard.py @@ -24,6 +24,7 @@ ) LARGE_SUITE_METHOD_THRESHOLD = 40 FOCUSED_GATE_SELECTORS = { + "cmuxTests/AgentChatTranscriptResolverTests", "cmuxTests/BrowserSystemProxyMirrorTests", "cmuxTests/GhosttyOptionAsAltModsTests", } diff --git a/tests/test_ci_cmux_unit_test_shard.py b/tests/test_ci_cmux_unit_test_shard.py index 6b694066e8d0..ddbe410358ff 100644 --- a/tests/test_ci_cmux_unit_test_shard.py +++ b/tests/test_ci_cmux_unit_test_shard.py @@ -107,6 +107,7 @@ def main() -> int: return 1 shard_selectors = output.read_text(encoding="utf-8").splitlines() for focused_selector in ( + "-only-testing:cmuxTests/AgentChatTranscriptResolverTests", "-only-testing:cmuxTests/BrowserSystemProxyMirrorTests", "-only-testing:cmuxTests/GhosttyOptionAsAltModsTests", ):