diff --git a/.github/swift-file-length-budget.tsv b/.github/swift-file-length-budget.tsv index ea13c6d70701..0539857d23bb 100644 --- a/.github/swift-file-length-budget.tsv +++ b/.github/swift-file-length-budget.tsv @@ -3,7 +3,7 @@ # Reduce counts as files shrink. CI fails if tracked files exceed this budget. 33857 CLI/cmux.swift 17914 Sources/AppDelegate.swift -16740 Sources/ContentView.swift +16709 Sources/ContentView.swift 14612 Sources/TerminalController.swift 13595 Sources/Panels/BrowserPanel.swift 12088 Sources/GhosttyTerminalView.swift @@ -13,15 +13,15 @@ 7911 Sources/Panels/BrowserPanelView.swift 7350 cmuxTests/WorkspaceUnitTests.swift 6944 cmuxTests/WorkspaceRemoteConnectionTests.swift +6363 cmuxTests/GhosttyConfigTests.swift 6317 cmuxTests/SessionPersistenceTests.swift -6299 cmuxTests/GhosttyConfigTests.swift 6153 CLI/cmux_open.swift 6074 Sources/TabManager.swift 6074 Sources/TextBoxInput.swift 5925 cmuxTests/TerminalAndGhosttyTests.swift 5522 cmuxTests/BrowserConfigTests.swift -4921 Sources/cmuxApp.swift 5113 Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift +4921 Sources/cmuxApp.swift 4460 Sources/Panels/FilePreviewPanel.swift 4400 cmuxTests/BrowserPanelTests.swift 4227 Sources/BrowserWindowPortal.swift @@ -32,8 +32,8 @@ 3664 Packages/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttySurfaceView.swift 3397 Sources/CmuxConfig.swift 3331 cmuxTests/TabManagerSessionSnapshotTests.swift -3204 Sources/Update/UpdateTitlebarAccessory.swift -2878 Sources/SessionIndexView.swift +3200 Sources/Update/UpdateTitlebarAccessory.swift +2892 Sources/SessionIndexView.swift 2871 cmuxTests/CMUXOpenCommandTests.swift 2573 Sources/KeyboardShortcutSettings.swift 2565 Sources/Panels/CmuxWebView.swift @@ -48,9 +48,9 @@ 2095 cmuxTests/ShortcutAndCommandPaletteTests.swift 2059 Sources/SessionPersistence.swift 2036 Sources/KeyboardShortcutSettingsFileStore.swift +1960 Sources/SessionIndexStore.swift 1949 Sources/Panels/BrowserWebAuthnSupport.swift 1860 cmuxTests/NotificationAndMenuBarTests.swift -1794 Sources/SessionIndexStore.swift 1777 Sources/RestorableAgentSession.swift 1748 Sources/WindowDragHandleView.swift 1724 cmuxTests/TerminalControllerSocketSecurityTests.swift @@ -59,11 +59,12 @@ 1652 cmuxTests/CMUXCLIErrorOutputRegressionTests.swift 1574 cmuxTests/MarkdownPanelTests.swift 1560 cmuxTests/TextBoxMentionCompletionTests.swift -1498 cmuxTests/OmnibarAndToolsTests.swift +1497 cmuxTests/OmnibarAndToolsTests.swift 1496 cmuxUITests/MultiWindowNotificationsUITests.swift 1446 Sources/FileExplorerStore.swift -1380 cmuxUITests/MenuKeyEquivalentRoutingUITests.swift +1387 cmuxTests/PiVaultAgentPersistenceTests.swift 1382 cmuxTests/KeyboardShortcutSettingsFileStoreStartupTests.swift +1380 cmuxUITests/MenuKeyEquivalentRoutingUITests.swift 1373 cmuxTests/AppDelegateIssue2907RoutingTests.swift 1366 Sources/Feed/FeedButtonStyleDebugWindowController.swift 1362 Sources/CMUXInstalledExtensionSidebarHostView.swift @@ -77,24 +78,23 @@ 1197 cmuxTests/CodexAppServerSessionTests.swift 1166 Sources/VaultAgentProcessScanner.swift 1161 cmuxTests/SidebarOrderingTests.swift -1144 cmuxTests/PiVaultAgentPersistenceTests.swift 1126 cmuxTests/FileExplorerStoreTests.swift 1120 cmuxTests/AgentHibernationTests.swift 1107 Sources/AppDelegate+CmuxSSHURL.swift 1093 cmuxUITests/BonsplitTabDragUITests.swift 1048 Packages/CmuxCommandPalette/Sources/CmuxCommandPalette/Search/CommandPaletteFuzzyMatcher.swift +1035 Sources/SessionIndexRegisteredAgents.swift 1021 cmuxUITests/TerminalCmdClickUITests.swift 1006 cmuxTests/CmuxSSHURLRequestTests.swift 1000 cmuxTests/CmuxTopSnapshotScopeTests.swift 951 Sources/App/TerminalDirectoryOpenSupport.swift 947 Sources/TerminalNotificationPolicy.swift -945 Sources/SessionIndexRegisteredAgents.swift 937 Sources/TextBoxMentionIndexStore.swift 934 Sources/App/ShortcutRoutingSupport.swift 926 Sources/DockPanelView.swift +920 Sources/CommandPalette/CommandPaletteSettingsToggle.swift 919 Packages/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface+RuntimeLifecycle.swift 918 cmuxTests/WorkspaceGroupTests.swift -920 Sources/CommandPalette/CommandPaletteSettingsToggle.swift 905 Sources/CmuxSSHURLRequest.swift 901 Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/AppSection.swift 893 Sources/WorkspaceContentView.swift @@ -117,13 +117,14 @@ 752 cmuxUITests/CloseWorkspaceCmdDUITests.swift 746 Sources/App/MenuBarExtraController.swift 738 Packages/CMUXProjectModel/Sources/CMUXProjectModel/XcodeProjectAdapter.swift -738 Packages/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Coordinator/AuthCoordinator.swift +736 Packages/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Coordinator/AuthCoordinator.swift +726 cmuxTests/CLICodexHookTimeoutRegressionTests.swift 716 Sources/TaskManagerSnapshot.swift 715 Packages/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface+Input.swift 715 Sources/AppleScriptSupport.swift 710 Sources/TerminalSSHSessionDetector.swift -706 CLI/CMUXCLI+Config.swift 707 CLI/CMUXCLI+AgentHookDefinitions.swift +706 CLI/CMUXCLI+Config.swift 699 Sources/RightSidebarPanelView.swift 699 cmuxTests/TerminalNotificationClearAllTests.swift 698 cmuxTests/RestorableAgentHookProviderResumeTests.swift @@ -187,7 +188,6 @@ 528 cmuxTests/CLINotifyProcessTestSupport.swift 528 cmuxUITests/AutomationSocketUITests.swift 527 CLI/CLISocketPathResolver.swift -726 cmuxTests/CLICodexHookTimeoutRegressionTests.swift 523 Packages/CmuxRemoteSession/Sources/CmuxRemoteSession/Session/RemoteSessionCoordinator+PortScan.swift 520 CLI/CMUXCLI+AmpExtension.swift 520 cmuxTests/MainWindowVisibilityControllerTests.swift diff --git a/CLAUDE.md b/CLAUDE.md index a920858e22b7..61c6196d98ec 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -38,17 +38,17 @@ App path: **Claude Code** outputs: ```markdown -======================================================= +------------------------------------------------------- [cmux DEV my-tag.app](file:///Users/someone/Library/Developer/Xcode/DerivedData/cmux-my-tag/Build/Products/Debug/cmux%20DEV%20my-tag.app) -======================================================= +------------------------------------------------------- ``` **Codex** outputs: ```markdown -======================================================= +------------------------------------------------------- [my-tag: file:///Users/someone/Library/Developer/Xcode/DerivedData/cmux-my-tag/Build/Products/Debug/cmux%20DEV%20my-tag.app](file:///Users/someone/Library/Developer/Xcode/DerivedData/cmux-my-tag/Build/Products/Debug/cmux%20DEV%20my-tag.app) -======================================================= +------------------------------------------------------- ``` Never use `/tmp/cmux-/...` app links in chat output. diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index 87ec6d72f219..bbb41970c3a0 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -153971,17 +153971,125 @@ "sessionIndex.preview.truncated": { "extractionState": "manual", "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "تم اقتطاع المعاينة" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Pregled je skraćen" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Forhåndsvisning afkortet" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Vorschau gekürzt" + } + }, "en": { "stringUnit": { "state": "translated", "value": "Preview truncated" } }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Vista previa truncada" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Aperçu tronqué" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Anteprima troncata" + } + }, "ja": { "stringUnit": { "state": "translated", "value": "プレビューを省略しました" } + }, + "km": { + "stringUnit": { + "state": "translated", + "value": "បានកាត់ខ្លីការមើលជាមុន" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "미리보기가 잘렸습니다" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Forhåndsvisningen er avkortet" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Podgląd skrócony" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Prévia truncada" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Предпросмотр усечён" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ตัดทอนตัวอย่างแล้ว" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Önizleme kısaltıldı" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Попередній перегляд усічено" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "预览已截断" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "預覽已截斷" + } } } }, diff --git a/Sources/SessionIndexJSONLStreamDirection.swift b/Sources/SessionIndexJSONLStreamDirection.swift new file mode 100644 index 000000000000..ba35b1d82b03 --- /dev/null +++ b/Sources/SessionIndexJSONLStreamDirection.swift @@ -0,0 +1,4 @@ +enum SessionIndexJSONLStreamDirection: Sendable { + case forward + case reverse +} diff --git a/Sources/SessionIndexJSONLStreamStopReason.swift b/Sources/SessionIndexJSONLStreamStopReason.swift new file mode 100644 index 000000000000..2d788cc0a111 --- /dev/null +++ b/Sources/SessionIndexJSONLStreamStopReason.swift @@ -0,0 +1,7 @@ +enum SessionIndexJSONLStreamStopReason: Sendable, Equatable { + case completed + case missingFile + case maxBytes + case maxLines + case stoppedByBody +} diff --git a/Sources/SessionIndexJSONLStreamSummary.swift b/Sources/SessionIndexJSONLStreamSummary.swift new file mode 100644 index 000000000000..3ccdffb0a20e --- /dev/null +++ b/Sources/SessionIndexJSONLStreamSummary.swift @@ -0,0 +1,5 @@ +struct SessionIndexJSONLStreamSummary: Sendable, Equatable { + var bytesRead: Int = 0 + var linesVisited: Int = 0 + var stopReason: SessionIndexJSONLStreamStopReason = .completed +} diff --git a/Sources/SessionIndexRegisteredAgents.swift b/Sources/SessionIndexRegisteredAgents.swift index 1dbd6888dbd2..651dfa427c00 100644 --- a/Sources/SessionIndexRegisteredAgents.swift +++ b/Sources/SessionIndexRegisteredAgents.swift @@ -237,6 +237,20 @@ extension SessionIndexStore { let cwd: String? let modified: Date let fileURL: URL + + var hasStableListMetadata: Bool { + !title.isEmpty && cwd != nil + } + + func mergingMissingFields(from older: AntigravityHistoryMetadata) -> AntigravityHistoryMetadata { + AntigravityHistoryMetadata( + sessionId: sessionId, + title: title.isEmpty ? older.title : title, + cwd: cwd ?? older.cwd, + modified: modified, + fileURL: fileURL + ) + } } private struct GrokSessionMetadata { @@ -247,6 +261,11 @@ extension SessionIndexStore { var branch: String? } + nonisolated static let antigravityHistoryMinimumScanBytes = 2 * 1024 * 1024 + nonisolated static let antigravityHistoryMaximumScanBytes = 24 * 1024 * 1024 + nonisolated private static let antigravityHistoryBytesPerRequestedEntry = 256 * 1024 + nonisolated static let antigravityHistoryMaximumScanLines = 12_000 + nonisolated static func loadGrokEntries( registration: CmuxVaultAgentRegistration, needle: String, @@ -475,6 +494,15 @@ extension SessionIndexStore { let fm = FileManager.default var latestBySessionID: [String: AntigravityHistoryMetadata] = [:] + var sessionIDsInReverseHistoryOrder: [String] = [] + var targetSessionIDs = Set() + var stableTargetMetadataCount = 0 + let maxScanBytes = antigravityHistoryScanBytes( + offset: offset, + limit: limit, + usesSparseFilter: !needle.isEmpty || cwdFilter != nil + ) + let target = antigravityHistoryTarget(offset: offset, limit: limit) for root in roots { if Task.isCancelled { break } @@ -488,7 +516,12 @@ extension SessionIndexStore { let fallbackModified = ((try? fm.attributesOfItem(atPath: historyURL.path))?[.modificationDate] as? Date) ?? Date.distantPast - forEachJSONLine(url: historyURL, maxBytes: Int.max) { object in + forEachJSONLine( + url: historyURL, + maxBytes: maxScanBytes, + maxLines: antigravityHistoryMaximumScanLines, + direction: .reverse + ) { object in if Task.isCancelled { return true } guard let sessionId = firstString(in: object, keys: antigravitySessionIDKeys()) else { return false @@ -515,22 +548,52 @@ extension SessionIndexStore { fileURL: historyURL ) if let existing = latestBySessionID[sessionId] { - if metadata.modified >= existing.modified { - latestBySessionID[sessionId] = metadata + let wasStable = existing.hasStableListMetadata + let merged = existing.mergingMissingFields(from: metadata) + latestBySessionID[sessionId] = merged + if targetSessionIDs.contains(sessionId), + !wasStable, + merged.hasStableListMetadata { + stableTargetMetadataCount += 1 + } + if sessionIDsInReverseHistoryOrder.count >= target, + stableTargetMetadataCount >= target { + return true + } + return false + } + latestBySessionID[sessionId] = metadata + sessionIDsInReverseHistoryOrder.append(sessionId) + if sessionIDsInReverseHistoryOrder.count <= target { + _ = targetSessionIDs.insert(sessionId) + if metadata.hasStableListMetadata { + stableTargetMetadataCount += 1 } - } else { - latestBySessionID[sessionId] = metadata + } + if sessionIDsInReverseHistoryOrder.count >= target, + stableTargetMetadataCount >= target { + return true } return false } + if sessionIDsInReverseHistoryOrder.count >= target, + stableTargetMetadataCount >= target { + break + } } + let reverseOrderIndexBySessionID = Dictionary( + uniqueKeysWithValues: sessionIDsInReverseHistoryOrder + .enumerated() + .map { ($0.element, $0.offset) } + ) let entries = latestBySessionID.values - .sorted { - if $0.modified == $1.modified { - return $0.sessionId < $1.sessionId + .sorted { lhs, rhs in + if lhs.modified != rhs.modified { + return lhs.modified > rhs.modified } - return $0.modified > $1.modified + return (reverseOrderIndexBySessionID[lhs.sessionId] ?? Int.max) + < (reverseOrderIndexBySessionID[rhs.sessionId] ?? Int.max) } .map { metadata in SessionEntry( @@ -549,6 +612,33 @@ extension SessionIndexStore { return Array(entries.dropFirst(offset).prefix(limit)) } + nonisolated private static func antigravityHistoryScanBytes( + offset: Int, + limit: Int, + usesSparseFilter: Bool + ) -> Int { + if usesSparseFilter { + return antigravityHistoryMaximumScanBytes + } + let target = antigravityHistoryTarget(offset: offset, limit: limit) + return scaledBound( + target: target, + unit: antigravityHistoryBytesPerRequestedEntry, + minimum: antigravityHistoryMinimumScanBytes, + maximum: antigravityHistoryMaximumScanBytes + ) + } + + nonisolated private static func antigravityHistoryTarget(offset: Int, limit: Int) -> Int { + let requested = offset > Int.max - limit ? Int.max : offset + limit + return max(1, requested) + } + + nonisolated private static func scaledBound(target: Int, unit: Int, minimum: Int, maximum: Int) -> Int { + guard target < maximum / unit else { return maximum } + return min(max(target * unit, minimum), maximum) + } + nonisolated private static func registeredSessionRoots( registration: CmuxVaultAgentRegistration, cwdFilter: String? diff --git a/Sources/SessionIndexStore.swift b/Sources/SessionIndexStore.swift index b472a9603011..6d46cdf48937 100644 --- a/Sources/SessionIndexStore.swift +++ b/Sources/SessionIndexStore.swift @@ -1024,42 +1024,208 @@ final class SessionIndexStore: ObservableObject { return out } - /// Stream JSON-lines from the start of `url`. `body` returns true to stop early. - /// Caps total bytes read at `maxBytes`. + /// Stream JSON-lines from `url`. `body` returns true to stop early. + /// Caps total bytes read at `maxBytes` and non-empty line decode attempts at `maxLines`. + @discardableResult nonisolated static func forEachJSONLine( url: URL, maxBytes: Int, + maxLines: Int? = nil, + direction: SessionIndexJSONLStreamDirection = .forward, body: ([String: Any]) -> Bool - ) { - guard let handle = try? FileHandle(forReadingFrom: url) else { return } + ) -> SessionIndexJSONLStreamSummary { + guard maxBytes > 0, maxLines != 0 else { + return SessionIndexJSONLStreamSummary( + stopReason: maxLines == 0 ? .maxLines : .maxBytes + ) + } + guard let handle = try? FileHandle(forReadingFrom: url) else { + return SessionIndexJSONLStreamSummary(stopReason: .missingFile) + } defer { try? handle.close() } + let fileSize = jsonLineFileSize(url: url) + switch direction { + case .forward: + return streamJSONLinesForward( + handle: handle, + fileSize: fileSize, + maxBytes: maxBytes, + maxLines: maxLines, + body: body + ) + case .reverse: + return streamJSONLinesReverse( + handle: handle, + fileSize: fileSize, + maxBytes: maxBytes, + maxLines: maxLines, + body: body + ) + } + } + + nonisolated private static func streamJSONLinesForward( + handle: FileHandle, + fileSize: UInt64, + maxBytes: Int, + maxLines: Int?, + body: ([String: Any]) -> Bool + ) -> SessionIndexJSONLStreamSummary { + var summary = SessionIndexJSONLStreamSummary() var leftover = Data() - var totalRead = 0 let chunkSize = 64 * 1024 - while totalRead < maxBytes { + var reachedEOF = false + while summary.bytesRead < maxBytes { + let bytesRemaining = maxBytes - summary.bytesRead let chunk: Data if #available(macOS 10.15.4, *) { - chunk = (try? handle.read(upToCount: chunkSize)) ?? Data() + chunk = (try? handle.read(upToCount: min(chunkSize, bytesRemaining))) ?? Data() } else { - chunk = handle.readData(ofLength: chunkSize) + chunk = handle.readData(ofLength: min(chunkSize, bytesRemaining)) } - if chunk.isEmpty { break } - totalRead += chunk.count + if chunk.isEmpty { + reachedEOF = true + break + } + summary.bytesRead += chunk.count leftover.append(chunk) while let nl = leftover.firstIndex(of: 0x0a) { - let lineData = leftover.subdata(in: 0.. 0 && UInt64(summary.bytesRead) >= fileSize + if (reachedEOF || didReadEntireFile), !leftover.isEmpty, + let stopReason = processJSONLineData( + in: leftover, + range: leftover.startIndex.. Bool + ) -> SessionIndexJSONLStreamSummary { + var summary = SessionIndexJSONLStreamSummary() + guard fileSize > 0 else { + return summary + } + + let chunkSize: UInt64 = 64 * 1024 + let byteBudget = min(UInt64(maxBytes), fileSize) + var position = fileSize + var carry = Data() + while position > 0, UInt64(summary.bytesRead) < byteBudget { + let bytesRemaining = byteBudget - UInt64(summary.bytesRead) + let bytesToRead = min(chunkSize, bytesRemaining, position) + position -= bytesToRead + handle.seek(toFileOffset: position) + let chunk = handle.readData(ofLength: Int(bytesToRead)) + if chunk.isEmpty { break } + summary.bytesRead += chunk.count + + var buffer = chunk + if !carry.isEmpty { + buffer.append(carry) + } + + var searchEnd = buffer.endIndex + while let newline = buffer[.. buffer.startIndex { + carry = buffer.subdata(in: buffer.startIndex.. UInt64 { + ((try? FileManager.default.attributesOfItem(atPath: url.path))?[.size] as? NSNumber)? + .uint64Value ?? 0 + } + + nonisolated private static func shouldProcessReverseCarry( + _ carry: Data, + position: UInt64, + handle: FileHandle + ) -> Bool { + guard !carry.isEmpty else { return false } + guard position > 0 else { return true } + handle.seek(toFileOffset: position - 1) + return handle.readData(ofLength: 1).first == 0x0a + } + + nonisolated private static func processJSONLineData( + in data: Data, + range: Range, + maxLines: Int?, + summary: inout SessionIndexJSONLStreamSummary, + body: ([String: Any]) -> Bool + ) -> SessionIndexJSONLStreamStopReason? { + guard !range.isEmpty else { return nil } + if let maxLines, summary.linesVisited >= maxLines { + return .maxLines + } + summary.linesVisited += 1 + var shouldStop = false + autoreleasepool { + let lineData = data.subdata(in: range) + if let obj = try? JSONSerialization.jsonObject(with: lineData) as? [String: Any] { + shouldStop = body(obj) + } } + return shouldStop ? .stoppedByBody : nil } // MARK: OpenCode diff --git a/Sources/SessionIndexView.swift b/Sources/SessionIndexView.swift index a0f2f19f4bf4..345dd58c248e 100644 --- a/Sources/SessionIndexView.swift +++ b/Sources/SessionIndexView.swift @@ -1064,29 +1064,31 @@ private extension SessionEntry { } private enum SessionTranscriptLoader { - private static let streamChunkSize = 256 * 1024 - private static let maxPreviewRecordBytes = 2 * 1024 * 1024 - private static let maxPreviewTurns = 500 - private static let maxTurnTextCharacters = 40_000 - private static let newlineByte: UInt8 = 10 + nonisolated private static let streamChunkSize = 256 * 1024 + nonisolated private static let maxPreviewRecordBytes = 2 * 1024 * 1024 + nonisolated private static let maxPreviewTurns = 500 + nonisolated private static let maxTurnTextCharacters = 40_000 + nonisolated private static let antigravityPreviewMaxScanBytes = SessionIndexStore.antigravityHistoryMaximumScanBytes + nonisolated private static let antigravityPreviewMaxScanLines = SessionIndexStore.antigravityHistoryMaximumScanLines + nonisolated private static let newlineByte: UInt8 = 10 // Wrapping `Data(string.utf8)` in a helper keeps large needle array literals // cheap to type-check. The Xcode 27 / Swift 6.4 expression solver otherwise // times out on the bigger literals below ("unable to type-check this // expression in reasonable time"), which Xcode 26 tolerated. - private static func needle(_ string: String) -> Data { Data(string.utf8) } + nonisolated private static func needle(_ string: String) -> Data { Data(string.utf8) } - private static let claudeUserNeedles = [ + nonisolated private static let claudeUserNeedles = [ Data(#""type":"user""#.utf8), Data(#""type": "user""#.utf8), Data(#""type":"assistant""#.utf8), Data(#""type": "assistant""#.utf8) ] - private static let codexResponseItemNeedles = [ + nonisolated private static let codexResponseItemNeedles = [ Data(#""type":"response_item""#.utf8), Data(#""type": "response_item""#.utf8) ] - private static let codexPreviewNeedles = [ + nonisolated private static let codexPreviewNeedles = [ Data(#""role":"user""#.utf8), Data(#""role": "user""#.utf8), Data(#""role":"assistant""#.utf8), @@ -1096,7 +1098,7 @@ private enum SessionTranscriptLoader { Data(#""type":"function_call_output""#.utf8), Data(#""type": "function_call_output""#.utf8) ] - private static let genericRoleNeedles = [ + nonisolated private static let genericRoleNeedles = [ Data(#""role":"#.utf8), Data(#""role": "#.utf8) ] @@ -1310,15 +1312,20 @@ private enum SessionTranscriptLoader { throw SessionTranscriptLoadError.missingFile } - var turns: [SessionTranscriptTurn] = [] - var lineIndex = 0 + var reversedTurns: [SessionTranscriptTurn] = [] + var reverseLineIndex = 0 var didHitTurnLimit = false let agent = SessionAgent.registered(RegisteredSessionAgent(id: "antigravity")) - SessionIndexStore.forEachJSONLine(url: url, maxBytes: Int.max) { object in - defer { lineIndex += 1 } + let streamSummary = SessionIndexStore.forEachJSONLine( + url: url, + maxBytes: antigravityPreviewMaxScanBytes, + maxLines: antigravityPreviewMaxScanLines, + direction: .reverse + ) { object in + defer { reverseLineIndex += 1 } if Task.isCancelled { return true } - guard turns.count < maxPreviewTurns else { + guard reversedTurns.count < maxPreviewTurns else { didHitTurnLimit = true return true } @@ -1329,11 +1336,16 @@ private enum SessionTranscriptLoader { guard let text = normalizedText(from: content, role: .user, agent: agent) else { return false } - turns.append(SessionTranscriptTurn(id: lineIndex, role: .user, text: text)) + reversedTurns.append(SessionTranscriptTurn(id: reverseLineIndex, role: .user, text: text)) return false } - if didHitTurnLimit { - appendTurnLimitMarker(to: &turns, id: lineIndex) + var turns = reversedTurns.reversed().enumerated().map { offset, turn in + SessionTranscriptTurn(id: offset, role: turn.role, text: turn.text) + } + if didHitTurnLimit + || streamSummary.stopReason == .maxBytes + || streamSummary.stopReason == .maxLines { + turns.insert(turnLimitMarker(id: -1), at: 0) } return coalesce(turns) } @@ -1913,12 +1925,14 @@ private enum SessionTranscriptLoader { } private static func appendTurnLimitMarker(to turns: inout [SessionTranscriptTurn], id: Int) { - turns.append( - SessionTranscriptTurn( - id: id, - role: .event, - text: String(localized: "sessionIndex.preview.truncated", defaultValue: "Preview truncated") - ) + turns.append(turnLimitMarker(id: id)) + } + + private static func turnLimitMarker(id: Int) -> SessionTranscriptTurn { + SessionTranscriptTurn( + id: id, + role: .event, + text: String(localized: "sessionIndex.preview.truncated", defaultValue: "Preview truncated") ) } } diff --git a/cmux.xcodeproj/project.pbxproj b/cmux.xcodeproj/project.pbxproj index ac5daf2eb73d..27132422b88c 100644 --- a/cmux.xcodeproj/project.pbxproj +++ b/cmux.xcodeproj/project.pbxproj @@ -605,9 +605,13 @@ 5E2701030000000000000001 /* SentryEventScrubberTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5E2701030000000000000002 /* SentryEventScrubberTests.swift */; }; A5001601 /* SentryHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001600 /* SentryHelper.swift */; }; FE003106 /* SessionAgentPresentation.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE003006 /* SessionAgentPresentation.swift */; }; - A9F200000000000000000008 /* SessionAgentSessionPanelSnapshot.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9F100000000000000000008 /* SessionAgentSessionPanelSnapshot.swift */; }; - B3575000000000000000000C /* SessionIndexModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = B3575000000000000000000B /* SessionIndexModels.swift */; }; - B35750000000000000000008 /* SessionIndexRegisteredAgents.swift in Sources */ = {isa = PBXBuildFile; fileRef = B35750000000000000000007 /* SessionIndexRegisteredAgents.swift */; }; + A9F200000000000000000008 /* SessionAgentSessionPanelSnapshot.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9F100000000000000000008 /* SessionAgentSessionPanelSnapshot.swift */; }; + C4535011A1B2C3D4E5F60718 /* SessionIndexJSONLStreamDirection.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4535010A1B2C3D4E5F60718 /* SessionIndexJSONLStreamDirection.swift */; }; + C4535021A1B2C3D4E5F60718 /* SessionIndexJSONLStreamStopReason.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4535020A1B2C3D4E5F60718 /* SessionIndexJSONLStreamStopReason.swift */; }; + C4535031A1B2C3D4E5F60718 /* SessionIndexJSONLStreamSummary.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4535030A1B2C3D4E5F60718 /* SessionIndexJSONLStreamSummary.swift */; }; + C4535002A1B2C3D4E5F60718 /* SessionIndexJSONLStreamTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4535001A1B2C3D4E5F60718 /* SessionIndexJSONLStreamTests.swift */; }; + B3575000000000000000000C /* SessionIndexModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = B3575000000000000000000B /* SessionIndexModels.swift */; }; + B35750000000000000000008 /* SessionIndexRegisteredAgents.swift in Sources */ = {isa = PBXBuildFile; fileRef = B35750000000000000000007 /* SessionIndexRegisteredAgents.swift */; }; FE003104 /* SessionIndexStore+CodexSQL.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE003004 /* SessionIndexStore+CodexSQL.swift */; }; FE003101 /* SessionIndexStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE003001 /* SessionIndexStore.swift */; }; FE003102 /* SessionIndexView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE003002 /* SessionIndexView.swift */; }; @@ -1456,9 +1460,13 @@ 5E2701030000000000000002 /* SentryEventScrubberTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SentryEventScrubberTests.swift; sourceTree = ""; }; A5001600 /* SentryHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SentryHelper.swift; sourceTree = ""; }; FE003006 /* SessionAgentPresentation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionAgentPresentation.swift; sourceTree = ""; }; - A9F100000000000000000008 /* SessionAgentSessionPanelSnapshot.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionAgentSessionPanelSnapshot.swift; sourceTree = ""; }; - B3575000000000000000000B /* SessionIndexModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionIndexModels.swift; sourceTree = ""; }; - B35750000000000000000007 /* SessionIndexRegisteredAgents.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionIndexRegisteredAgents.swift; sourceTree = ""; }; + A9F100000000000000000008 /* SessionAgentSessionPanelSnapshot.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionAgentSessionPanelSnapshot.swift; sourceTree = ""; }; + C4535010A1B2C3D4E5F60718 /* SessionIndexJSONLStreamDirection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionIndexJSONLStreamDirection.swift; sourceTree = ""; }; + C4535020A1B2C3D4E5F60718 /* SessionIndexJSONLStreamStopReason.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionIndexJSONLStreamStopReason.swift; sourceTree = ""; }; + C4535030A1B2C3D4E5F60718 /* SessionIndexJSONLStreamSummary.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionIndexJSONLStreamSummary.swift; sourceTree = ""; }; + C4535001A1B2C3D4E5F60718 /* SessionIndexJSONLStreamTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionIndexJSONLStreamTests.swift; sourceTree = ""; }; + B3575000000000000000000B /* SessionIndexModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionIndexModels.swift; sourceTree = ""; }; + B35750000000000000000007 /* SessionIndexRegisteredAgents.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionIndexRegisteredAgents.swift; sourceTree = ""; }; FE003004 /* SessionIndexStore+CodexSQL.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SessionIndexStore+CodexSQL.swift"; sourceTree = ""; }; FE003001 /* SessionIndexStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionIndexStore.swift; sourceTree = ""; }; FE003002 /* SessionIndexView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionIndexView.swift; sourceTree = ""; }; @@ -2399,11 +2407,14 @@ A5001655 /* CmuxActionTrust.swift */, FE001001 /* FileExplorerStore.swift */, B37A0000000000000000000A /* FileExplorerState.swift */, - FE001003 /* FileExplorerSearchController.swift */, - FE001004 /* FileExplorerTerminalPathInsertion.swift */, - FE001002 /* FileExplorerView.swift */, - FE003001 /* SessionIndexStore.swift */, - B35750000000000000000007 /* SessionIndexRegisteredAgents.swift */, + FE001003 /* FileExplorerSearchController.swift */, + FE001004 /* FileExplorerTerminalPathInsertion.swift */, + FE001002 /* FileExplorerView.swift */, + C4535010A1B2C3D4E5F60718 /* SessionIndexJSONLStreamDirection.swift */, + C4535020A1B2C3D4E5F60718 /* SessionIndexJSONLStreamStopReason.swift */, + C4535030A1B2C3D4E5F60718 /* SessionIndexJSONLStreamSummary.swift */, + FE003001 /* SessionIndexStore.swift */, + B35750000000000000000007 /* SessionIndexRegisteredAgents.swift */, B35750000000000000000005 /* VaultAgentProcessScanner.swift */, FE003004 /* SessionIndexStore+CodexSQL.swift */, FE003005 /* RovoDevIndex.swift */, @@ -2519,6 +2530,7 @@ F4100001A1B2C3D4E5F60718 /* PortScannerTests.swift */, F5000001A1B2C3D4E5F60718 /* SessionPersistenceTests.swift */, B35750000000000000000009 /* PiVaultAgentPersistenceTests.swift */, + C4535001A1B2C3D4E5F60718 /* SessionIndexJSONLStreamTests.swift */, D3610B010000000000000002 /* AgentSessionAutoResumeSettingsTests.swift */, A9E010000000000000000005 /* AgentExecutableResolverTests.swift */, A9E030000000000000000001 /* AgentSessionSocketSurfaceTests.swift */, @@ -3479,11 +3491,14 @@ 5E2701020000000000000001 /* SentryEventScrubber.swift in Sources */, A5001601 /* SentryHelper.swift in Sources */, FE003106 /* SessionAgentPresentation.swift in Sources */, - A9F200000000000000000008 /* SessionAgentSessionPanelSnapshot.swift in Sources */, - B3575000000000000000000C /* SessionIndexModels.swift in Sources */, - B35750000000000000000008 /* SessionIndexRegisteredAgents.swift in Sources */, - FE003104 /* SessionIndexStore+CodexSQL.swift in Sources */, - FE003101 /* SessionIndexStore.swift in Sources */, + A9F200000000000000000008 /* SessionAgentSessionPanelSnapshot.swift in Sources */, + C4535011A1B2C3D4E5F60718 /* SessionIndexJSONLStreamDirection.swift in Sources */, + C4535021A1B2C3D4E5F60718 /* SessionIndexJSONLStreamStopReason.swift in Sources */, + C4535031A1B2C3D4E5F60718 /* SessionIndexJSONLStreamSummary.swift in Sources */, + B3575000000000000000000C /* SessionIndexModels.swift in Sources */, + B35750000000000000000008 /* SessionIndexRegisteredAgents.swift in Sources */, + FE003104 /* SessionIndexStore+CodexSQL.swift in Sources */, + FE003101 /* SessionIndexStore.swift in Sources */, FE003102 /* SessionIndexView.swift in Sources */, A5001610 /* SessionPersistence.swift in Sources */, E30780000000000000000014 /* SessionRemoteWorkspaceSnapshot+Restore.swift in Sources */, @@ -3893,6 +3908,7 @@ F5300002A1B2C3D4E5F60718 /* RovoDevTranscriptPreviewTests.swift in Sources */, 3865A0043865A0043865A004 /* SearchIndexTests.swift in Sources */, 5E2701030000000000000001 /* SentryEventScrubberTests.swift in Sources */, + C4535002A1B2C3D4E5F60718 /* SessionIndexJSONLStreamTests.swift in Sources */, 8A3392FE64E0605D942213D1 /* SessionIndexViewTests.swift in Sources */, F5000000A1B2C3D4E5F60718 /* SessionPersistenceTests.swift in Sources */, A50019B2 /* SettingsSearchIndexTests.swift in Sources */, diff --git a/cmuxTests/PiVaultAgentPersistenceTests.swift b/cmuxTests/PiVaultAgentPersistenceTests.swift index 152f9ec8f5cf..811bf78bca9b 100644 --- a/cmuxTests/PiVaultAgentPersistenceTests.swift +++ b/cmuxTests/PiVaultAgentPersistenceTests.swift @@ -1,5 +1,6 @@ import CmuxSession -import XCTest +import Foundation +import Testing #if canImport(cmux_DEV) @testable import cmux_DEV @@ -7,7 +8,38 @@ import XCTest @testable import cmux #endif -final class PiVaultAgentPersistenceTests: XCTestCase { + +@Suite +struct PiVaultAgentPersistenceTests { + private func expectEqual(_ lhs: T, _ rhs: T) { + #expect(lhs == rhs) + } + + private func expectEqual(_ lhs: T?, _ rhs: T) { + #expect(lhs == rhs) + } + + private func expectEqual(_ lhs: T, _ rhs: T?) { + #expect(lhs == rhs) + } + + private func expectNotEqual(_ lhs: T, _ rhs: T) { + #expect(lhs != rhs) + } + + private func expectTrue(_ condition: Bool) { + #expect(condition) + } + + private func expectNil(_ value: T?) { + #expect(value == nil) + } + + private func requireUnwrapped(_ value: T?) throws -> T { + try #require(value) + } + + @Test func testRegisteredSessionAgentCodablePreservesPresentation() throws { let encoded = try JSONEncoder().encode( SessionAgent.registered(RegisteredSessionAgent( @@ -20,40 +52,48 @@ final class PiVaultAgentPersistenceTests: XCTestCase { let decoded = try JSONDecoder().decode(SessionAgent.self, from: encoded) guard case .registered(let agent) = decoded else { - return XCTFail("Expected registered agent") + Issue.record("Expected registered agent") + return } - XCTAssertEqual(agent.id, "acme-agent") - XCTAssertEqual(agent.name, "Acme Agent") - XCTAssertEqual(agent.iconAssetName, "AgentIcons/Acme") + expectEqual(agent.id, "acme-agent") + expectEqual(agent.name, "Acme Agent") + expectEqual(agent.iconAssetName, "AgentIcons/Acme") } + + @Test func testBuiltInIDWithRegisteredMetadataDecodesAsRegisteredAgent() throws { let encoded = Data(#"{"id":"grok","name":"Custom Grok","iconAssetName":"AgentIcons/CustomGrok"}"#.utf8) let decoded = try JSONDecoder().decode(SessionAgent.self, from: encoded) guard case .registered(let agent) = decoded else { - return XCTFail("Expected legacy registered Grok metadata to be preserved") + Issue.record("Expected legacy registered Grok metadata to be preserved") + return } - XCTAssertEqual(agent.id, "grok") - XCTAssertEqual(agent.name, "Custom Grok") - XCTAssertEqual(agent.iconAssetName, "AgentIcons/CustomGrok") + expectEqual(agent.id, "grok") + expectEqual(agent.name, "Custom Grok") + expectEqual(agent.iconAssetName, "AgentIcons/CustomGrok") } + + @Test func testBuiltInIDWithoutRegisteredMetadataDecodesAsBuiltInAgent() throws { let encoded = Data(#"{"id":"grok"}"#.utf8) let decoded = try JSONDecoder().decode(SessionAgent.self, from: encoded) - XCTAssertEqual(decoded, .grok) + expectEqual(decoded, .grok) } + + @Test func testRegisteredSessionAgentEqualityIncludesPresentation() { - XCTAssertNotEqual( + expectNotEqual( SessionAgent.registered(RegisteredSessionAgent(id: "acme-agent", name: "Acme Agent")), SessionAgent.registered(RegisteredSessionAgent(id: "acme-agent", name: "Renamed Agent")) ) - XCTAssertEqual( + expectEqual( Set([ SessionAgent.registered(RegisteredSessionAgent(id: "acme-agent", iconAssetName: "AgentIcons/Acme")), SessionAgent.registered(RegisteredSessionAgent(id: "acme-agent", iconAssetName: "AgentIcons/Renamed")), @@ -62,22 +102,28 @@ final class PiVaultAgentPersistenceTests: XCTestCase { ) } + + @Test func testBuiltInPiRegistrationUsesBrandedIconAsset() { let agent = RegisteredSessionAgent(registration: CmuxVaultAgentRegistration.builtInPi) - XCTAssertEqual(agent.iconAssetName, "AgentIcons/Pi") - XCTAssertEqual(SessionAgent.registered(agent).assetName, "AgentIcons/Pi") + expectEqual(agent.iconAssetName, "AgentIcons/Pi") + expectEqual(SessionAgent.registered(agent).assetName, "AgentIcons/Pi") } + + @Test func testBuiltInAntigravityRegistrationUsesBrandedIconAsset() { let agent = RegisteredSessionAgent(registration: CmuxVaultAgentRegistration.builtInAntigravity) - XCTAssertEqual(agent.iconAssetName, "AgentIcons/Antigravity") - XCTAssertEqual(SessionAgent.registered(agent).assetName, "AgentIcons/Antigravity") - XCTAssertEqual(CmuxVaultAgentRegistration.builtInAntigravity.detect.processNames, ["agy", "antigravity"]) + expectEqual(agent.iconAssetName, "AgentIcons/Antigravity") + expectEqual(SessionAgent.registered(agent).assetName, "AgentIcons/Antigravity") + expectEqual(CmuxVaultAgentRegistration.builtInAntigravity.detect.processNames, ["agy", "antigravity"]) } + + @Test func testBuiltInAntigravityRegistrationLoadsHistoryDisplayAndWorkspace() async throws { let tempDir = FileManager.default.temporaryDirectory .appendingPathComponent("cmux-antigravity-vault-history-\(UUID().uuidString)", isDirectory: true) @@ -99,17 +145,19 @@ final class PiVaultAgentPersistenceTests: XCTestCase { limit: 10 ) - let entry = try XCTUnwrap(entries.first) - XCTAssertEqual(entry.agent, .registered(RegisteredSessionAgent(registration: registration))) - XCTAssertEqual(entry.sessionId, "antigravity-conversation-123") - XCTAssertEqual(entry.title, "Implement Antigravity notifications") - XCTAssertEqual(entry.cwd, "/tmp/antigravity repo") - XCTAssertEqual( + let entry = try requireUnwrapped(entries.first) + expectEqual(entry.agent, .registered(RegisteredSessionAgent(registration: registration))) + expectEqual(entry.sessionId, "antigravity-conversation-123") + expectEqual(entry.title, "Implement Antigravity notifications") + expectEqual(entry.cwd, "/tmp/antigravity repo") + expectEqual( entry.resumeCommand, "{ cd -- '/tmp/antigravity repo' 2>/dev/null || [ ! -d '/tmp/antigravity repo' ]; } && 'agy' '--conversation' 'antigravity-conversation-123'" ) } + + @Test func testBuiltInAntigravityRegistrationIndexesEachHistoryConversation() async throws { let tempDir = FileManager.default.temporaryDirectory .appendingPathComponent("cmux-antigravity-vault-conversations-\(UUID().uuidString)", isDirectory: true) @@ -134,9 +182,9 @@ final class PiVaultAgentPersistenceTests: XCTestCase { limit: 10 ) - XCTAssertEqual(entries.map(\.sessionId), ["conversation-a", "conversation-b"]) - XCTAssertEqual(entries.map(\.title), ["latest prompt", "newer prompt"]) - XCTAssertEqual(entries.map(\.cwd), ["/tmp/antigravity repo", "/tmp/antigravity repo"]) + expectEqual(entries.map(\.sessionId), ["conversation-a", "conversation-b"]) + expectEqual(entries.map(\.title), ["latest prompt", "newer prompt"]) + expectEqual(entries.map(\.cwd), ["/tmp/antigravity repo", "/tmp/antigravity repo"]) let filtered = await SessionIndexStore.loadRegisteredAgentEntries( registration: registration, @@ -145,13 +193,160 @@ final class PiVaultAgentPersistenceTests: XCTestCase { offset: 0, limit: 10 ) - XCTAssertEqual(filtered.map(\.sessionId), ["conversation-b"]) - XCTAssertEqual( + expectEqual(filtered.map(\.sessionId), ["conversation-b"]) + expectEqual( filtered.first?.resumeCommand, "{ cd -- '/tmp/antigravity repo' 2>/dev/null || [ ! -d '/tmp/antigravity repo' ]; } && 'agy' '--conversation' 'conversation-b'" ) } + + @Test + func testBuiltInAntigravityRegistrationPaginatesStableBoundedHistory() async throws { + let tempDir = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-antigravity-vault-pagination-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: tempDir) } + + let historyURL = tempDir.appendingPathComponent("history.jsonl", isDirectory: false) + let lines = (0..<80).map { index in + """ + {"display":"prompt \(index)","timestamp":\(1_779_263_000_000 + index),"workspace":"/tmp/antigravity repo","conversationId":"conversation-\(index)"} + """ + }.joined(separator: "\n") + "\n" + try lines.write(to: historyURL, atomically: true, encoding: .utf8) + + var registration = CmuxVaultAgentRegistration.builtInAntigravity + registration.sessionDirectory = tempDir.path + let firstPage = await SessionIndexStore.loadRegisteredAgentEntries( + registration: registration, + needle: "", + cwdFilter: nil, + offset: 0, + limit: 10 + ) + let secondPage = await SessionIndexStore.loadRegisteredAgentEntries( + registration: registration, + needle: "", + cwdFilter: nil, + offset: 10, + limit: 10 + ) + + expectEqual(firstPage.map(\.sessionId), (70..<80).reversed().map { "conversation-\($0)" }) + expectEqual(secondPage.map(\.sessionId), (60..<70).reversed().map { "conversation-\($0)" }) + expectTrue(Set(firstPage.map(\.sessionId)).isDisjoint(with: Set(secondPage.map(\.sessionId)))) + } + + + @Test + func testBuiltInAntigravityRegistrationBackfillsSparseLatestHistoryMetadata() async throws { + let tempDir = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-antigravity-vault-backfill-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: tempDir) } + + let historyURL = tempDir.appendingPathComponent("history.jsonl", isDirectory: false) + try """ + {"display":"original prompt","timestamp":1779263000000,"workspace":"/tmp/antigravity repo","conversationId":"conversation-a"} + {"timestamp":1779263001000,"conversationId":"conversation-a"} + """.write(to: historyURL, atomically: true, encoding: .utf8) + + var registration = CmuxVaultAgentRegistration.builtInAntigravity + registration.sessionDirectory = tempDir.path + let entries = await SessionIndexStore.loadRegisteredAgentEntries( + registration: registration, + needle: "", + cwdFilter: nil, + offset: 0, + limit: 10 + ) + + let entry = try requireUnwrapped(entries.first) + expectEqual(entries.count, 1) + expectEqual(entry.sessionId, "conversation-a") + expectEqual(entry.title, "original prompt") + expectEqual(entry.cwd, "/tmp/antigravity repo") + expectEqual(entry.modified, Date(timeIntervalSince1970: 1_779_263_001)) + } + + + @Test + func testBuiltInAntigravityRegistrationUsesMaximumWindowForSparseSearch() async throws { + let tempDir = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-antigravity-vault-sparse-search-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: tempDir) } + + let historyURL = tempDir.appendingPathComponent("history.jsonl", isDirectory: false) + var lines = [ + """ + {"display":"needle prompt","timestamp":1779263000000,"workspace":"/tmp/antigravity repo","conversationId":"conversation-needle"} + """ + ] + lines.append( + contentsOf: (0..<700).map { index in + """ + {"display":"filler prompt \(index)","timestamp":\(1_779_263_001_000 + index),"workspace":"/tmp/antigravity repo","conversationId":"conversation-\(index)"} + """ + } + ) + try (lines.joined(separator: "\n") + "\n").write(to: historyURL, atomically: true, encoding: .utf8) + + var registration = CmuxVaultAgentRegistration.builtInAntigravity + registration.sessionDirectory = tempDir.path + let entries = await SessionIndexStore.loadRegisteredAgentEntries( + registration: registration, + needle: "needle", + cwdFilter: nil, + offset: 0, + limit: 1 + ) + + let entry = try requireUnwrapped(entries.first) + expectEqual(entries.count, 1) + expectEqual(entry.sessionId, "conversation-needle") + expectEqual(entry.title, "needle prompt") + } + + + @Test + func testBuiltInAntigravityRegistrationDoesNotSpendListBudgetOnDuplicateRows() async throws { + let tempDir = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-antigravity-vault-duplicate-rows-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: tempDir) } + + let historyURL = tempDir.appendingPathComponent("history.jsonl", isDirectory: false) + var lines = [ + """ + {"display":"older prompt","timestamp":1779263000000,"workspace":"/tmp/antigravity repo","conversationId":"conversation-older"} + """ + ] + lines.append( + contentsOf: (0..<700).map { index in + """ + {"display":"latest prompt \(index)","timestamp":\(1_779_263_001_000 + index),"workspace":"/tmp/antigravity repo","conversationId":"conversation-latest"} + """ + } + ) + try (lines.joined(separator: "\n") + "\n").write(to: historyURL, atomically: true, encoding: .utf8) + + var registration = CmuxVaultAgentRegistration.builtInAntigravity + registration.sessionDirectory = tempDir.path + let entries = await SessionIndexStore.loadRegisteredAgentEntries( + registration: registration, + needle: "", + cwdFilter: nil, + offset: 0, + limit: 2 + ) + + expectEqual(entries.map(\.sessionId), ["conversation-latest", "conversation-older"]) + } + + + @Test func testRegisteredAgentJSONLWorkspaceKeyIsSharedCWDMetadata() async throws { let tempDir = FileManager.default.temporaryDirectory .appendingPathComponent("cmux-registered-workspace-cwd-\(UUID().uuidString)", isDirectory: true) @@ -181,12 +376,14 @@ final class PiVaultAgentPersistenceTests: XCTestCase { limit: 10 ) - let entry = try XCTUnwrap(entries.first) - XCTAssertEqual(entry.sessionId, "native-session-123") - XCTAssertEqual(entry.title, "Resume Acme") - XCTAssertEqual(entry.cwd, "/tmp/acme-workspace") + let entry = try requireUnwrapped(entries.first) + expectEqual(entry.sessionId, "native-session-123") + expectEqual(entry.title, "Resume Acme") + expectEqual(entry.cwd, "/tmp/acme-workspace") } + + @Test func testRegisteredAgentJSONLDisplayFieldIsNotSharedTitleMetadata() async throws { let tempDir = FileManager.default.temporaryDirectory .appendingPathComponent("cmux-registered-display-title-\(UUID().uuidString)", isDirectory: true) @@ -216,15 +413,17 @@ final class PiVaultAgentPersistenceTests: XCTestCase { limit: 10 ) - let entry = try XCTUnwrap(entries.first) - XCTAssertEqual(entry.sessionId, "native-session-123") - XCTAssertEqual(entry.title, "") - XCTAssertEqual( + let entry = try requireUnwrapped(entries.first) + expectEqual(entry.sessionId, "native-session-123") + expectEqual(entry.title, "") + expectEqual( entry.displayTitle, String(localized: "sessionIndex.untitled", defaultValue: "Untitled chat") ) } + + @Test func testRegisteredAgentJSONLSessionIDDoesNotUseAntigravityConversationID() async throws { let tempDir = FileManager.default.temporaryDirectory .appendingPathComponent("cmux-registered-session-id-\(UUID().uuidString)", isDirectory: true) @@ -254,22 +453,26 @@ final class PiVaultAgentPersistenceTests: XCTestCase { limit: 10 ) - let entry = try XCTUnwrap(entries.first) - XCTAssertEqual(entry.sessionId, "native-session-123") - XCTAssertEqual(entry.resumeCommand, "{ cd -- '/tmp/acme' 2>/dev/null || [ ! -d '/tmp/acme' ]; } && 'acme-agent' '--session' 'native-session-123'") + let entry = try requireUnwrapped(entries.first) + expectEqual(entry.sessionId, "native-session-123") + expectEqual(entry.resumeCommand, "{ cd -- '/tmp/acme' 2>/dev/null || [ ! -d '/tmp/acme' ]; } && 'acme-agent' '--session' 'native-session-123'") } + + @Test func testBuiltInGrokRegistrationUsesNativeSessionDirectory() { let registration = CmuxVaultAgentRegistration.builtInGrok - XCTAssertEqual(registration.id, "grok") - XCTAssertEqual(registration.sessionIdSource, .grokSessionDirectory) - XCTAssertEqual(registration.sessionDirectory, "~/.grok/sessions") - XCTAssertEqual(registration.detect.processNames, ["grok", "grok-macos-aarch64", "grok-macos-aarch"]) - XCTAssertTrue(registration.detect.argvContains.isEmpty) - XCTAssertEqual(SessionAgent.grok.assetName, "AgentIcons/Grok") + expectEqual(registration.id, "grok") + expectEqual(registration.sessionIdSource, .grokSessionDirectory) + expectEqual(registration.sessionDirectory, "~/.grok/sessions") + expectEqual(registration.detect.processNames, ["grok", "grok-macos-aarch64", "grok-macos-aarch"]) + expectTrue(registration.detect.argvContains.isEmpty) + expectEqual(SessionAgent.grok.assetName, "AgentIcons/Grok") } + + @Test func testRegisteredAgentTemplateFailsClosedWhenPlaceholderIsUnavailable() { let registration = CmuxVaultAgentRegistration( id: "acme-agent", @@ -296,9 +499,11 @@ final class PiVaultAgentPersistenceTests: XCTestCase { registrationOverride: registration ) - XCTAssertNil(command) + expectNil(command) } + + @Test func testRegisteredAgentTemplateUsesExplicitWorkingDirectoryForCWDPlaceholder() { let registration = CmuxVaultAgentRegistration( id: "acme-agent", @@ -326,9 +531,11 @@ final class PiVaultAgentPersistenceTests: XCTestCase { includeWorkingDirectoryPrefix: false ) - XCTAssertEqual(command, "'acme-agent' '--cwd' '/tmp/acme' '--session' 'session-123'") + expectEqual(command, "'acme-agent' '--cwd' '/tmp/acme' '--session' 'session-123'") } + + @Test func testRegisteredAgentTemplatePreservesCWDArgumentWithWorkingDirectoryPrefix() { let registration = CmuxVaultAgentRegistration( id: "acme-agent", @@ -355,12 +562,14 @@ final class PiVaultAgentPersistenceTests: XCTestCase { registrationOverride: registration ) - XCTAssertEqual( + expectEqual( command, "{ cd -- '/tmp/acme' 2>/dev/null || [ ! -d '/tmp/acme' ]; } && 'acme-agent' '--cwd' '/tmp/acme' '--session' 'session-123'" ) } + + @Test func testRegisteredAgentTemplateDoesNotExpandPlaceholdersInsideReplacementValues() { let registration = CmuxVaultAgentRegistration( id: "acme-agent", @@ -388,9 +597,11 @@ final class PiVaultAgentPersistenceTests: XCTestCase { includeWorkingDirectoryPrefix: false ) - XCTAssertEqual(command, "'acme-agent' '--session' 'session-{{cwd}}' '--cwd' '/tmp/acme'") + expectEqual(command, "'acme-agent' '--session' 'session-{{cwd}}' '--cwd' '/tmp/acme'") } + + @Test func testRegisteredAgentCWDIgnoreSuppressesResumeWorkingDirectory() { let registration = CmuxVaultAgentRegistration( id: "acme-agent", @@ -413,10 +624,12 @@ final class PiVaultAgentPersistenceTests: XCTestCase { specifics: .registered(registration) ) - XCTAssertNil(entry.resumeWorkingDirectory) - XCTAssertEqual(entry.resumeCommand, "'acme-agent' '--session' 'session-123'") + expectNil(entry.resumeWorkingDirectory) + expectEqual(entry.resumeCommand, "'acme-agent' '--session' 'session-123'") } + + @Test func testRegisteredAgentJSONLNativeSessionIDOverridesPathFallback() async throws { let tempDir = FileManager.default.temporaryDirectory .appendingPathComponent("cmux-registered-native-id-\(UUID().uuidString)", isDirectory: true) @@ -447,13 +660,15 @@ final class PiVaultAgentPersistenceTests: XCTestCase { limit: 10 ) - let entry = try XCTUnwrap(entries.first) - XCTAssertEqual(entry.id, "acme-agent:native-session-123") - XCTAssertEqual(entry.sessionId, "native-session-123") - XCTAssertEqual(entry.title, "Resume Acme") - XCTAssertEqual(entry.gitBranch, "issue-3575-vault-pi-agent-support") + let entry = try requireUnwrapped(entries.first) + expectEqual(entry.id, "acme-agent:native-session-123") + expectEqual(entry.sessionId, "native-session-123") + expectEqual(entry.title, "Resume Acme") + expectEqual(entry.gitBranch, "issue-3575-vault-pi-agent-support") } + + @Test func testRegisteredAgentCWDFilterUsesJSONLMetadataNotFallback() async throws { let tempDir = FileManager.default.temporaryDirectory .appendingPathComponent("cmux-registered-cwd-filter-\(UUID().uuidString)", isDirectory: true) @@ -483,9 +698,11 @@ final class PiVaultAgentPersistenceTests: XCTestCase { limit: 10 ) - XCTAssertTrue(entries.isEmpty) + expectTrue(entries.isEmpty) } + + @Test func testRegisteredAgentMetadataKeepsScanningForBranchWhenFallbackCWDSet() async throws { let tempDir = FileManager.default.temporaryDirectory .appendingPathComponent("cmux-pi-vault-branch-\(UUID().uuidString)", isDirectory: true) @@ -493,7 +710,7 @@ final class PiVaultAgentPersistenceTests: XCTestCase { defer { try? FileManager.default.removeItem(at: tempDir) } let cwd = "/tmp/pi repo" - let projectDirectory = try XCTUnwrap(PiSessionLocator.projectDirectoryName(for: cwd)) + let projectDirectory = try requireUnwrapped(PiSessionLocator.projectDirectoryName(for: cwd)) let sessionDir = tempDir.appendingPathComponent(projectDirectory, isDirectory: true) try FileManager.default.createDirectory(at: sessionDir, withIntermediateDirectories: true) let sessionFile = sessionDir.appendingPathComponent("018f2b35-7c75-7e1a-a6ff-cc1d5f9f0000.jsonl") @@ -512,12 +729,14 @@ final class PiVaultAgentPersistenceTests: XCTestCase { limit: 10 ) - let entry = try XCTUnwrap(entries.first) - XCTAssertEqual(entry.title, "Implement Pi restore") - XCTAssertEqual(entry.cwd, cwd) - XCTAssertEqual(entry.gitBranch, "issue-3575-vault-pi-agent-support") + let entry = try requireUnwrapped(entries.first) + expectEqual(entry.title, "Implement Pi restore") + expectEqual(entry.cwd, cwd) + expectEqual(entry.gitBranch, "issue-3575-vault-pi-agent-support") } + + @Test func testPiJSONLTypedContentBlocksUseFirstUserTextAsTitle() async throws { let tempDir = FileManager.default.temporaryDirectory .appendingPathComponent("cmux-pi-vault-title-blocks-\(UUID().uuidString)", isDirectory: true) @@ -525,7 +744,7 @@ final class PiVaultAgentPersistenceTests: XCTestCase { defer { try? FileManager.default.removeItem(at: tempDir) } let cwd = "/tmp/pi typed blocks" - let projectDirectory = try XCTUnwrap(PiSessionLocator.projectDirectoryName(for: cwd)) + let projectDirectory = try requireUnwrapped(PiSessionLocator.projectDirectoryName(for: cwd)) let sessionDir = tempDir.appendingPathComponent(projectDirectory, isDirectory: true) try FileManager.default.createDirectory(at: sessionDir, withIntermediateDirectories: true) let sessionFile = sessionDir.appendingPathComponent("019e1c86-def0-72c9-90d4-8543db20f981.jsonl") @@ -544,11 +763,13 @@ final class PiVaultAgentPersistenceTests: XCTestCase { limit: 10 ) - let entry = try XCTUnwrap(entries.first) - XCTAssertEqual(entry.title, "ping") - XCTAssertEqual(entry.cwd, cwd) + let entry = try requireUnwrapped(entries.first) + expectEqual(entry.title, "ping") + expectEqual(entry.cwd, cwd) } + + @Test func testPiJSONLTopLevelAssistantTypedContentDoesNotBecomeTitle() async throws { let tempDir = FileManager.default.temporaryDirectory .appendingPathComponent("cmux-pi-vault-top-level-role-\(UUID().uuidString)", isDirectory: true) @@ -556,7 +777,7 @@ final class PiVaultAgentPersistenceTests: XCTestCase { defer { try? FileManager.default.removeItem(at: tempDir) } let cwd = "/tmp/pi top level role" - let projectDirectory = try XCTUnwrap(PiSessionLocator.projectDirectoryName(for: cwd)) + let projectDirectory = try requireUnwrapped(PiSessionLocator.projectDirectoryName(for: cwd)) let sessionDir = tempDir.appendingPathComponent(projectDirectory, isDirectory: true) try FileManager.default.createDirectory(at: sessionDir, withIntermediateDirectories: true) let sessionFile = sessionDir.appendingPathComponent("019e1c86-def0-72c9-90d4-8543db20f982.jsonl") @@ -575,11 +796,13 @@ final class PiVaultAgentPersistenceTests: XCTestCase { limit: 10 ) - let entry = try XCTUnwrap(entries.first) - XCTAssertEqual(entry.title, "implement the vault view") - XCTAssertEqual(entry.cwd, cwd) + let entry = try requireUnwrapped(entries.first) + expectEqual(entry.title, "implement the vault view") + expectEqual(entry.cwd, cwd) } + + @Test func testPiJSONLMessagesArrayUsesNilRoleTextAsTitle() async throws { let tempDir = FileManager.default.temporaryDirectory .appendingPathComponent("cmux-pi-vault-messages-nil-role-\(UUID().uuidString)", isDirectory: true) @@ -587,7 +810,7 @@ final class PiVaultAgentPersistenceTests: XCTestCase { defer { try? FileManager.default.removeItem(at: tempDir) } let cwd = "/tmp/pi nil role" - let projectDirectory = try XCTUnwrap(PiSessionLocator.projectDirectoryName(for: cwd)) + let projectDirectory = try requireUnwrapped(PiSessionLocator.projectDirectoryName(for: cwd)) let sessionDir = tempDir.appendingPathComponent(projectDirectory, isDirectory: true) try FileManager.default.createDirectory(at: sessionDir, withIntermediateDirectories: true) let sessionFile = sessionDir.appendingPathComponent("019e1c86-def0-72c9-90d4-8543db20f983.jsonl") @@ -605,11 +828,13 @@ final class PiVaultAgentPersistenceTests: XCTestCase { limit: 10 ) - let entry = try XCTUnwrap(entries.first) - XCTAssertEqual(entry.title, "restore without role") - XCTAssertEqual(entry.cwd, cwd) + let entry = try requireUnwrapped(entries.first) + expectEqual(entry.title, "restore without role") + expectEqual(entry.cwd, cwd) } + + @Test func testPiJSONLTypedContentBlocksRequireTextType() async throws { let tempDir = FileManager.default.temporaryDirectory .appendingPathComponent("cmux-pi-vault-typed-content-\(UUID().uuidString)", isDirectory: true) @@ -617,7 +842,7 @@ final class PiVaultAgentPersistenceTests: XCTestCase { defer { try? FileManager.default.removeItem(at: tempDir) } let cwd = "/tmp/pi typed content" - let projectDirectory = try XCTUnwrap(PiSessionLocator.projectDirectoryName(for: cwd)) + let projectDirectory = try requireUnwrapped(PiSessionLocator.projectDirectoryName(for: cwd)) let sessionDir = tempDir.appendingPathComponent(projectDirectory, isDirectory: true) try FileManager.default.createDirectory(at: sessionDir, withIntermediateDirectories: true) let sessionFile = sessionDir.appendingPathComponent("019e1c86-def0-72c9-90d4-8543db20f984.jsonl") @@ -635,11 +860,13 @@ final class PiVaultAgentPersistenceTests: XCTestCase { limit: 10 ) - let entry = try XCTUnwrap(entries.first) - XCTAssertEqual(entry.title, "typed text title") - XCTAssertEqual(entry.cwd, cwd) + let entry = try requireUnwrapped(entries.first) + expectEqual(entry.title, "typed text title") + expectEqual(entry.cwd, cwd) } + + @Test func testGrokVaultLoadsNativeChatHistoryFromEncodedDirectory() async throws { let tempDir = FileManager.default.temporaryDirectory .appendingPathComponent("cmux-grok-vault-\(UUID().uuidString)", isDirectory: true) @@ -674,19 +901,21 @@ final class PiVaultAgentPersistenceTests: XCTestCase { limit: 10 ) - let entry = try XCTUnwrap(entries.first) - XCTAssertEqual(entry.agent, .grok) - XCTAssertEqual(entry.sessionId, sessionId) - XCTAssertEqual(entry.title, "Implement Grok Vault") - XCTAssertEqual(entry.cwd, cwd) - XCTAssertEqual(entry.gitBranch, "issue-4394-grok-vault-resume") - XCTAssertEqual(entry.fileURL, historyURL) - XCTAssertEqual( + let entry = try requireUnwrapped(entries.first) + expectEqual(entry.agent, .grok) + expectEqual(entry.sessionId, sessionId) + expectEqual(entry.title, "Implement Grok Vault") + expectEqual(entry.cwd, cwd) + expectEqual(entry.gitBranch, "issue-4394-grok-vault-resume") + expectEqual(entry.fileURL, historyURL) + expectEqual( entry.resumeCommand, "{ cd -- '/tmp/grok repo' 2>/dev/null || [ ! -d '/tmp/grok repo' ]; } && 'env' 'GROK_HOME=\(grokHome.path)' 'grok' '-r' 'grok-session-123' '-m' 'grok-4' '--permission-mode' 'auto' '--sandbox' 'danger-full-access'" ) } + + @Test func testGrokVaultTitlePrefersUserQueryOverInjectedMetadata() async throws { let tempDir = FileManager.default.temporaryDirectory .appendingPathComponent("cmux-grok-vault-metadata-title-\(UUID().uuidString)", isDirectory: true) @@ -736,10 +965,12 @@ final class PiVaultAgentPersistenceTests: XCTestCase { limit: 10 ) - let entry = try XCTUnwrap(entries.first) - XCTAssertEqual(entry.title, "Implement native Vault metadata") + let entry = try requireUnwrapped(entries.first) + expectEqual(entry.title, "Implement native Vault metadata") } + + @Test func testGrokVaultFindsBranchAfterStableMetadata() async throws { let tempDir = FileManager.default.temporaryDirectory .appendingPathComponent("cmux-grok-vault-late-branch-\(UUID().uuidString)", isDirectory: true) @@ -772,10 +1003,12 @@ final class PiVaultAgentPersistenceTests: XCTestCase { limit: 10 ) - let entry = try XCTUnwrap(entries.first) - XCTAssertEqual(entry.gitBranch, "late-branch") + let entry = try requireUnwrapped(entries.first) + expectEqual(entry.gitBranch, "late-branch") } + + @Test func testGrokVaultLoadsHookObservedShellGrokHome() async throws { let tempDir = FileManager.default.temporaryDirectory .appendingPathComponent("cmux-grok-vault-observed-home-\(UUID().uuidString)", isDirectory: true) @@ -829,16 +1062,18 @@ final class PiVaultAgentPersistenceTests: XCTestCase { homeDirectory: homeDirectory.path ) - let entry = try XCTUnwrap(entries.first) - XCTAssertEqual(entry.sessionId, sessionId) - XCTAssertEqual(entry.title, "Find sessions under shell GROK_HOME") - XCTAssertEqual(entry.cwd, cwd) - XCTAssertEqual( + let entry = try requireUnwrapped(entries.first) + expectEqual(entry.sessionId, sessionId) + expectEqual(entry.title, "Find sessions under shell GROK_HOME") + expectEqual(entry.cwd, cwd) + expectEqual( entry.resumeCommand, "{ cd -- '/tmp/grok observed home' 2>/dev/null || [ ! -d '/tmp/grok observed home' ]; } && 'env' 'GROK_HOME=\(grokHome.path)' 'grok' '-r' '\(sessionId)' '-m' 'grok-4' '--permission-mode' 'auto' '--sandbox' 'danger-full-access'" ) } + + @Test func testGrokVaultLoadsHookObservedShellGrokHomeFromCustomStateDir() async throws { let tempDir = FileManager.default.temporaryDirectory .appendingPathComponent("cmux-grok-vault-custom-state-\(UUID().uuidString)", isDirectory: true) @@ -891,15 +1126,17 @@ final class PiVaultAgentPersistenceTests: XCTestCase { homeDirectory: homeDirectory.path ) - let entry = try XCTUnwrap(entries.first) - XCTAssertEqual(entry.sessionId, sessionId) - XCTAssertEqual(entry.title, "Find sessions under custom hook state") - XCTAssertEqual( + let entry = try requireUnwrapped(entries.first) + expectEqual(entry.sessionId, sessionId) + expectEqual(entry.title, "Find sessions under custom hook state") + expectEqual( entry.resumeCommand, "{ cd -- '/tmp/grok custom state' 2>/dev/null || [ ! -d '/tmp/grok custom state' ]; } && 'env' 'GROK_HOME=\(grokHome.path)' 'grok' '-r' '\(sessionId)' '-m' 'grok-4'" ) } + + @Test func testRegisteredGrokSessionDirectoryUsesNativeDirectoryLayout() async throws { let tempDir = FileManager.default.temporaryDirectory .appendingPathComponent("cmux-registered-grok-vault-\(UUID().uuidString)", isDirectory: true) @@ -938,19 +1175,21 @@ final class PiVaultAgentPersistenceTests: XCTestCase { limit: 10 ) - let entry = try XCTUnwrap(entries.first) - XCTAssertEqual(entry.id, "custom-grok:\(sessionId)") - XCTAssertEqual(entry.agent, .registered(RegisteredSessionAgent(registration: registration))) - XCTAssertEqual(entry.sessionId, sessionId) - XCTAssertEqual(entry.title, "Resume a custom Grok-compatible agent") - XCTAssertEqual(entry.cwd, cwd) - XCTAssertEqual(entry.gitBranch, "issue-4394-grok-vault-resume") - XCTAssertEqual( + let entry = try requireUnwrapped(entries.first) + expectEqual(entry.id, "custom-grok:\(sessionId)") + expectEqual(entry.agent, .registered(RegisteredSessionAgent(registration: registration))) + expectEqual(entry.sessionId, sessionId) + expectEqual(entry.title, "Resume a custom Grok-compatible agent") + expectEqual(entry.cwd, cwd) + expectEqual(entry.gitBranch, "issue-4394-grok-vault-resume") + expectEqual( entry.resumeCommand, "{ cd -- '/tmp/custom grok repo' 2>/dev/null || [ ! -d '/tmp/custom grok repo' ]; } && 'env' 'GROK_HOME=\(tempDir.path)' 'custom-grok' '-r' '\(sessionId)'" ) } + + @Test func testGrokVaultCWDFilterUsesEncodedProjectDirectory() async throws { let tempDir = FileManager.default.temporaryDirectory .appendingPathComponent("cmux-grok-vault-filter-\(UUID().uuidString)", isDirectory: true) @@ -985,11 +1224,13 @@ final class PiVaultAgentPersistenceTests: XCTestCase { limit: 10 ) - XCTAssertEqual(entries.map(\.sessionId), ["current-session"]) - XCTAssertEqual(entries.first?.cwd, "/tmp/current grok repo") + expectEqual(entries.map(\.sessionId), ["current-session"]) + expectEqual(entries.first?.cwd, "/tmp/current grok repo") } @MainActor + + @Test func testGrokAgentSearchScopeUsesCurrentDirectoryCWDFilter() async throws { let tempDir = FileManager.default.temporaryDirectory .appendingPathComponent("cmux-grok-agent-scope-filter-\(UUID().uuidString)", isDirectory: true) @@ -1033,10 +1274,12 @@ final class PiVaultAgentPersistenceTests: XCTestCase { limit: 10 ) - XCTAssertEqual(outcome.entries.map(\.sessionId), ["current-session"]) - XCTAssertEqual(outcome.entries.first?.cwd, "/tmp/current grok search") + expectEqual(outcome.entries.map(\.sessionId), ["current-session"]) + expectEqual(outcome.entries.first?.cwd, "/tmp/current grok search") } + + @Test func testPiVaultAgentSnapshotRoundTripBuildsTargetedSessionCommand() throws { let tempDir = FileManager.default.temporaryDirectory .appendingPathComponent("cmux-pi-vault-agent-\(UUID().uuidString)", isDirectory: true) @@ -1048,7 +1291,7 @@ final class PiVaultAgentPersistenceTests: XCTestCase { .appendingPathComponent("2026-05-05T12-00-00-000Z_018f2b35-7c75-7e1a-a6ff-cc1d5f9f0000.jsonl") .path let panelId = UUID(uuidString: "3D4D5F4B-CA09-4E5C-A65E-8423D7F4BEA0")! - let piKind = try XCTUnwrap(RestorableAgentKind(rawValue: "pi")) + let piKind = try requireUnwrapped(RestorableAgentKind(rawValue: "pi")) var snapshot = makeSnapshot() snapshot.windows[0].tabManager.workspaces[0].focusedPanelId = panelId @@ -1099,15 +1342,15 @@ final class PiVaultAgentPersistenceTests: XCTestCase { schemaVersion: SessionSnapshotSchema.currentVersion, bundleIdentifier: "com.cmuxterm.tests" ) - XCTAssertTrue(store.save(snapshot, fileURL: snapshotURL)) - let loadedAgent = try XCTUnwrap( + expectTrue(store.save(snapshot, fileURL: snapshotURL)) + let loadedAgent = try requireUnwrapped( store.load(fileURL: snapshotURL)?.windows.first? .tabManager.workspaces.first?.panels.first?.terminal?.agent ) - XCTAssertEqual(loadedAgent.kind.rawValue, "pi") - XCTAssertEqual(loadedAgent.sessionId, sessionPath) - XCTAssertEqual( + expectEqual(loadedAgent.kind.rawValue, "pi") + expectEqual(loadedAgent.sessionId, sessionPath) + expectEqual( loadedAgent.resumeCommand, "{ cd -- '/tmp/pi repo' 2>/dev/null || [ ! -d '/tmp/pi repo' ]; } && '/opt/homebrew/bin/pi' '--session' '\(sessionPath)'" ) diff --git a/cmuxTests/SessionIndexJSONLStreamTests.swift b/cmuxTests/SessionIndexJSONLStreamTests.swift new file mode 100644 index 000000000000..560e6e903377 --- /dev/null +++ b/cmuxTests/SessionIndexJSONLStreamTests.swift @@ -0,0 +1,154 @@ +import Foundation +import Testing + +#if canImport(cmux_DEV) +@testable import cmux_DEV +#elseif canImport(cmux) +@testable import cmux +#endif + +@Suite +struct SessionIndexJSONLStreamTests { + @Test + func testJSONLStreamHonorsMaxLines() throws { + let tempDir = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-jsonl-stream-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: tempDir) } + + let historyURL = tempDir.appendingPathComponent("history.jsonl", isDirectory: false) + let lines = (0..<10).map { #"{"index":\#($0)}"# }.joined(separator: "\n") + "\n" + try lines.write(to: historyURL, atomically: true, encoding: .utf8) + + var visited: [Int] = [] + let summary = SessionIndexStore.forEachJSONLine(url: historyURL, maxBytes: Int.max, maxLines: 3) { object in + if let index = object["index"] as? Int { + visited.append(index) + } + return false + } + + #expect(visited == [0, 1, 2]) + #expect(summary.linesVisited == 3) + #expect(summary.stopReason == .maxLines) + } + + @Test + func testReverseJSONLStreamHonorsMaxLines() throws { + let tempDir = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-jsonl-reverse-stream-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: tempDir) } + + let historyURL = tempDir.appendingPathComponent("history.jsonl", isDirectory: false) + let lines = (0..<10).map { #"{"index":\#($0)}"# }.joined(separator: "\n") + "\n" + try lines.write(to: historyURL, atomically: true, encoding: .utf8) + + var visited: [Int] = [] + let summary = SessionIndexStore.forEachJSONLine( + url: historyURL, + maxBytes: Int.max, + maxLines: 3, + direction: .reverse + ) { object in + if let index = object["index"] as? Int { + visited.append(index) + } + return false + } + + #expect(visited == [9, 8, 7]) + #expect(summary.linesVisited == 3) + #expect(summary.stopReason == .maxLines) + } + + @Test + func testReverseJSONLStreamHonorsMaxBytes() throws { + let tempDir = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-jsonl-byte-stream-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: tempDir) } + + let historyURL = tempDir.appendingPathComponent("history.jsonl", isDirectory: false) + let lines = (0..<30).map { index in + #"{"index":\#(index),"padding":"\#(String(repeating: "x", count: 80))"}"# + }.joined(separator: "\n") + "\n" + try lines.write(to: historyURL, atomically: true, encoding: .utf8) + + var visited: [Int] = [] + let summary = SessionIndexStore.forEachJSONLine( + url: historyURL, + maxBytes: 512, + direction: .reverse + ) { object in + if let index = object["index"] as? Int { + visited.append(index) + } + return false + } + + #expect(summary.bytesRead <= 512) + #expect(summary.stopReason == .maxBytes) + #expect(visited.first == 29) + #expect(visited.elementsEqual(visited.sorted(by: >))) + #expect(visited.count < 30) + } + + @Test + func testReverseJSONLStreamProcessesLineStartingAtByteBoundary() throws { + let tempDir = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-jsonl-byte-boundary-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: tempDir) } + + let records = (0..<5).map { #"{"index":\#($0)}"# } + let historyURL = tempDir.appendingPathComponent("history.jsonl", isDirectory: false) + try (records.joined(separator: "\n") + "\n").write(to: historyURL, atomically: true, encoding: .utf8) + + let byteWindow = Data((records[3] + "\n" + records[4] + "\n").utf8).count + var visited: [Int] = [] + let summary = SessionIndexStore.forEachJSONLine( + url: historyURL, + maxBytes: byteWindow, + direction: .reverse + ) { object in + if let index = object["index"] as? Int { + visited.append(index) + } + return false + } + + #expect(visited == [4, 3]) + #expect(summary.stopReason == .maxBytes) + } + + @Test + func testForwardJSONLStreamFlushesTrailingLineAtExactByteBudget() throws { + let tempDir = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-jsonl-forward-exact-budget-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: tempDir) } + + let records = [ + #"{"index":0}"#, + #"{"index":1}"# + ] + let payload = records.joined(separator: "\n") + let historyURL = tempDir.appendingPathComponent("history.jsonl", isDirectory: false) + try payload.write(to: historyURL, atomically: true, encoding: .utf8) + + var visited: [Int] = [] + let summary = SessionIndexStore.forEachJSONLine( + url: historyURL, + maxBytes: Data(payload.utf8).count + ) { object in + if let index = object["index"] as? Int { + visited.append(index) + } + return false + } + + #expect(visited == [0, 1]) + #expect(summary.stopReason == .completed) + } +}