diff --git a/app/modules/AgentHubCore/Sources/AgentHub/Services/DevServerReadinessProbe.swift b/app/modules/AgentHubCore/Sources/AgentHub/Services/DevServerReadinessProbe.swift new file mode 100644 index 00000000..adf000b2 --- /dev/null +++ b/app/modules/AgentHubCore/Sources/AgentHub/Services/DevServerReadinessProbe.swift @@ -0,0 +1,77 @@ +// +// DevServerReadinessProbe.swift +// AgentHub +// +// Polls loopback candidates until a dev server becomes reachable. +// + +import Foundation + +enum DevServerReadinessProbeResult: Equatable, Sendable { + case ready(URL) + case timedOut + case stale +} + +actor DevServerReadinessProbe { + private var candidateURLs: [URL] + + init(expectedURL: URL) { + self.candidateURLs = [expectedURL] + } + + init(expectedURLs: [URL]) { + self.candidateURLs = Array(expectedURLs) + } + + func registerCandidate(_ url: URL) { + guard WebPreviewNavigationPolicy.isAllowedLoopbackURL(url), + !candidateURLs.contains(url) else { + return + } + + candidateURLs.append(url) + } + + func waitUntilReady( + timeout: Duration = .seconds(30), + pollInterval: Duration = .milliseconds(250), + probe: @escaping @Sendable (URL) async -> Bool, + isCurrent: @escaping @Sendable () async -> Bool + ) async -> DevServerReadinessProbeResult { + let clock = ContinuousClock() + let deadline = clock.now.advanced(by: timeout) + + while clock.now < deadline { + if await !isCurrent() { + return .stale + } + + let urls = candidateURLs + let readyURL: URL? = await withTaskGroup(of: URL?.self, returning: URL?.self) { group in + for url in urls { + group.addTask { await probe(url) ? url : nil } + } + for await result in group { + if let url = result { + group.cancelAll() + return url + } + } + return nil + } + + if let url = readyURL { + return await isCurrent() ? .ready(url) : .stale + } + + do { + try await Task.sleep(for: pollInterval) + } catch { + return .stale + } + } + + return await isCurrent() ? .timedOut : .stale + } +} diff --git a/app/modules/AgentHubCore/Sources/AgentHub/Services/SessionFileWatcher.swift b/app/modules/AgentHubCore/Sources/AgentHub/Services/SessionFileWatcher.swift index 368eced5..8ab57086 100644 --- a/app/modules/AgentHubCore/Sources/AgentHub/Services/SessionFileWatcher.swift +++ b/app/modules/AgentHubCore/Sources/AgentHub/Services/SessionFileWatcher.swift @@ -20,6 +20,14 @@ public actor SessionFileWatcher { public struct StateUpdate: Sendable { public let sessionId: String public let state: SessionMonitorState + /// When non-nil, indicates this session has been continued by a new session (e.g., "clear context and accept plan") + public let continuationSessionId: String? + + public init(sessionId: String, state: SessionMonitorState, continuationSessionId: String? = nil) { + self.sessionId = sessionId + self.state = state + self.continuationSessionId = continuationSessionId + } } // MARK: - Properties @@ -34,6 +42,9 @@ public actor SessionFileWatcher { /// Seconds to wait before considering a tool as awaiting approval private var approvalTimeoutSeconds: Int = 0 + /// Tracks continuation session IDs already claimed to prevent double-claiming + private var claimedContinuationIds: Set = [] + /// Publisher for state updates public nonisolated var statePublisher: AnyPublisher { stateSubject.eraseToAnyPublisher() @@ -155,7 +166,7 @@ public actor SessionFileWatcher { guard let self = self else { return } self.processingQueue.async { - AppLogger.watcher.debug("[Polling] TICK (1.5s) for session: \(sessionId.prefix(8), privacy: .public)") + //AppLogger.watcher.debug("[Polling] TICK (1.5s) for session: \(sessionId.prefix(8), privacy: .public)") // Health check: detect stale file watcher let timeSinceLastEvent = Date().timeIntervalSince(lastFileEventTime) @@ -215,19 +226,82 @@ public actor SessionFileWatcher { self.stateSubject.send(StateUpdate(sessionId: sessionId, state: updatedState)) } } + } } statusTimer.resume() + // Set up directory watch for continuation detection (e.g., "clear context and accept plan"). + // Runs alongside the file watcher and shares lastFileEventTime to detect when + // the old session goes quiet and a new session file appears. + let encodedPath = projectPath.claudeProjectPathEncoded + let projectDir = "\(claudePath)/projects/\(encodedPath)" + let existingJsonlFiles = Set( + (try? FileManager.default.contentsOfDirectory(atPath: projectDir)) ?? [] + ).filter { $0.hasSuffix(".jsonl") } + + var directorySource: DispatchSourceFileSystemObject? + var directoryFd: Int32? + var continuationFired = false + + let dirFd = open(projectDir, O_EVTONLY) + if dirFd >= 0 { + let dirSource = DispatchSource.makeFileSystemObjectSource( + fileDescriptor: dirFd, + eventMask: [.write, .link], + queue: DispatchQueue.global(qos: .utility) + ) + + dirSource.setEventHandler { [weak self] in + guard let self else { return } + self.processingQueue.async { + // Only fire once per monitored session + guard !continuationFired else { return } + // Only trigger if old session has been quiet for 3+ seconds and had activity + let timeSinceLastEvent = Date().timeIntervalSince(lastFileEventTime) + guard timeSinceLastEvent >= 3, parseResult.messageCount > 0 else { return } + + guard let currentFiles = try? FileManager.default.contentsOfDirectory(atPath: projectDir) else { return } + let newJsonlFiles = Set(currentFiles) + .subtracting(existingJsonlFiles) + .filter { $0.hasSuffix(".jsonl") } + + guard let newFile = newJsonlFiles.first else { return } + let newSessionId = String(newFile.dropLast(6)) // Remove ".jsonl" + continuationFired = true + + Task { + await self.emitContinuation(oldSessionId: sessionId, newSessionId: newSessionId) + } + } + } + + dirSource.setCancelHandler { + close(dirFd) + } + + dirSource.resume() + directorySource = dirSource + directoryFd = dirFd + + AppLogger.watcher.info( + "[Continuation] Directory watch started for session: \(sessionId.prefix(8), privacy: .public)" + ) + } + // Store watcher info watchedSessions[sessionId] = FileWatcherInfo( filePath: filePath, source: source, statusTimer: statusTimer, parseResult: parseResult, + projectPath: projectPath, lastFileEventTime: lastFileEventTime, - lastKnownFileSize: lastKnownFileSize + lastKnownFileSize: lastKnownFileSize, + directorySource: directorySource, + directoryFd: directoryFd, + existingJsonlFiles: existingJsonlFiles ) } @@ -242,6 +316,10 @@ public actor SessionFileWatcher { info.source.cancel() info.statusTimer.cancel() + // Clean up directory watcher for continuation detection + if let dirSource = info.directorySource { + dirSource.cancel() + } AppLogger.watcher.info("[Polling] Cancelled file watcher and timer for: \(sessionId.prefix(8), privacy: .public)") } @@ -385,6 +463,33 @@ public actor SessionFileWatcher { if case .awaitingApproval = status { return true } return false } + + // MARK: - Continuation Detection + + /// Emit a continuation event and clean up the old session's watchers + private func emitContinuation(oldSessionId: String, newSessionId: String) { + // Prevent double-claiming the same continuation + guard !claimedContinuationIds.contains(newSessionId) else { return } + claimedContinuationIds.insert(newSessionId) + + guard let info = watchedSessions[oldSessionId] else { return } + let state = buildMonitorState(from: info.parseResult) + + AppLogger.watcher.info( + "[Continuation] Detected: \(oldSessionId.prefix(8), privacy: .public) -> \(newSessionId.prefix(8), privacy: .public)" + ) + + stateSubject.send(StateUpdate( + sessionId: oldSessionId, + state: state, + continuationSessionId: newSessionId + )) + + // Stop monitoring the old session (cleans up file + directory watchers) + Task { + await self.stopMonitoring(sessionId: oldSessionId) + } + } } // MARK: - FileWatcherInfo @@ -394,10 +499,16 @@ private struct FileWatcherInfo { let source: DispatchSourceFileSystemObject let statusTimer: DispatchSourceTimer var parseResult: SessionJSONLParser.ParseResult + let projectPath: String // Health check tracking var lastFileEventTime: Date var lastKnownFileSize: UInt64 + + // Continuation detection (directory watching for new session files) + var directorySource: DispatchSourceFileSystemObject? + var directoryFd: Int32? + var existingJsonlFiles: Set } // MARK: - Protocol Conformance diff --git a/app/modules/AgentHubCore/Sources/AgentHub/UI/MultiProviderSessionsListView.swift b/app/modules/AgentHubCore/Sources/AgentHub/UI/MultiProviderSessionsListView.swift index bfd6e30e..69855303 100644 --- a/app/modules/AgentHubCore/Sources/AgentHub/UI/MultiProviderSessionsListView.swift +++ b/app/modules/AgentHubCore/Sources/AgentHub/UI/MultiProviderSessionsListView.swift @@ -128,6 +128,12 @@ public struct MultiProviderSessionsListView: View { .onChange(of: codexViewModel.resolvedPendingSessions) { _, newResolutions in handleResolvedSessions(newResolutions, provider: .codex, viewModel: codexViewModel) } + .onChange(of: claudeViewModel.resolvedContinuations) { _, newContinuations in + handleContinuationResolutions(newContinuations, provider: .claude, viewModel: claudeViewModel) + } + .onChange(of: codexViewModel.resolvedContinuations) { _, newContinuations in + handleContinuationResolutions(newContinuations, provider: .codex, viewModel: codexViewModel) + } .onChange(of: claudeViewModel.lastCreatedPendingId) { _, newId in guard let newId else { return } primarySessionId = "pending-claude-\(newId.uuidString)" @@ -956,6 +962,25 @@ public struct MultiProviderSessionsListView: View { viewModel.resolvedPendingSessions.removeValue(forKey: pendingUUID) } + /// Handles session continuation resolutions (e.g., "clear context and accept plan"). + /// Updates primarySessionId when the currently selected session has been continued by a new one. + private func handleContinuationResolutions( + _ continuations: [String: String], + provider: SessionProviderKind, + viewModel: CLISessionsViewModel + ) { + let providerPrefix = "\(provider.rawValue.lowercased())-" + for (oldSessionId, newSessionId) in continuations { + let oldPrimaryId = "\(providerPrefix)\(oldSessionId)" + if primarySessionId == oldPrimaryId { + let newPrimaryId = "\(providerPrefix)\(newSessionId)" + AppLogger.session.info("[PrimarySelection] Continuation: \(oldPrimaryId.prefix(20), privacy: .public) -> \(newPrimaryId.prefix(20), privacy: .public)") + primarySessionId = newPrimaryId + } + viewModel.resolvedContinuations.removeValue(forKey: oldSessionId) + } + } + private func ensurePrimarySelection() { let items = filteredSelectedSessionItems guard !items.isEmpty else { diff --git a/app/modules/AgentHubCore/Sources/AgentHub/UI/WebPreviewNavigationPolicy.swift b/app/modules/AgentHubCore/Sources/AgentHub/UI/WebPreviewNavigationPolicy.swift new file mode 100644 index 00000000..7e5260b4 --- /dev/null +++ b/app/modules/AgentHubCore/Sources/AgentHub/UI/WebPreviewNavigationPolicy.swift @@ -0,0 +1,68 @@ +// +// WebPreviewNavigationPolicy.swift +// AgentHub +// +// Centralized allowlist for embedded web preview navigation. +// + +import Foundation + +enum WebPreviewNavigationDecision: Equatable { + case allow + case openExternally(URL) + case deny(String) +} + +enum WebPreviewNavigationPolicy { + + static func decision( + for navigationURL: URL?, + allowedProjectRoot: URL?, + isMainFrameNavigation: Bool, + opensInNewWindow: Bool + ) -> WebPreviewNavigationDecision { + guard let navigationURL else { return .allow } + + guard isMainFrameNavigation || opensInNewWindow else { + return .allow + } + + if navigationURL.isFileURL { + guard let allowedProjectRoot else { + return .deny("Blocked file navigation because the preview has no allowed project root.") + } + + return isURL(navigationURL, withinAllowedRoot: allowedProjectRoot) + ? .allow + : .deny("Blocked navigation outside the allowed project directory.") + } + + if isAllowedLoopbackURL(navigationURL) { + return .allow + } + + return .openExternally(navigationURL) + } + + static func isAllowedLoopbackURL(_ url: URL) -> Bool { + guard let scheme = url.scheme?.lowercased(), + ["http", "https"].contains(scheme), + let host = url.host?.lowercased() else { + return false + } + + return host == "localhost" || host == "127.0.0.1" || host == "::1" + } + + static func isURL(_ url: URL, withinAllowedRoot rootURL: URL) -> Bool { + let normalizedURL = url.standardizedFileURL.resolvingSymlinksInPath() + let normalizedRoot = rootURL.standardizedFileURL.resolvingSymlinksInPath() + + if normalizedURL.path == normalizedRoot.path { + return true + } + + let rootPath = normalizedRoot.path.hasSuffix("/") ? normalizedRoot.path : normalizedRoot.path + "/" + return normalizedURL.path.hasPrefix(rootPath) + } +} diff --git a/app/modules/AgentHubCore/Sources/AgentHub/ViewModels/CLISessionsViewModel.swift b/app/modules/AgentHubCore/Sources/AgentHub/ViewModels/CLISessionsViewModel.swift index 82842dc1..dc8ab3c8 100644 --- a/app/modules/AgentHubCore/Sources/AgentHub/ViewModels/CLISessionsViewModel.swift +++ b/app/modules/AgentHubCore/Sources/AgentHub/ViewModels/CLISessionsViewModel.swift @@ -105,7 +105,7 @@ public final class CLISessionsViewModel { public var pendingHubSessionWithTerminal: Bool = false /// Session IDs awaiting progressive restoration on app launch - private var pendingRestorationSessionIds: Set = [] + var pendingRestorationSessionIds: Set = [] /// Terminal view IDs loaded from persistence, used during progressive restoration private var restoringTerminalViewIds: Set = [] @@ -161,7 +161,19 @@ public final class CLISessionsViewModel { .filter { $0.sessionId == session.id } .receive(on: DispatchQueue.main) .sink { [weak self] update in - self?.monitorStates[session.id] = update.state + guard let self else { return } + if let continuationId = update.continuationSessionId { + // Session has been continued (e.g., "clear context and accept plan") + // Refresh to discover the new session, then transfer monitoring + Task { @MainActor [weak self] in + guard let self else { return } + await self.monitorService.refreshSessions() + try? await Task.sleep(for: .milliseconds(300)) + self.transferMonitoring(fromSessionId: session.id, toSessionId: continuationId) + } + } else { + self.monitorStates[session.id] = update.state + } } monitoringCancellables[session.id] = cancellable @@ -348,6 +360,11 @@ public final class CLISessionsViewModel { /// Preserves terminal PTY across pending → real session transition public var activeTerminals: [String: TerminalContainerView] = [:] + /// Maps old session IDs to their continuation session IDs. + /// Used by the sidebar to update `primarySessionId` when a session continues + /// (e.g., "clear context and accept plan" in Claude Code). + public var resolvedContinuations: [String: String] = [:] + // MARK: - Monitoring State /// Set of session IDs currently being monitored @@ -361,7 +378,7 @@ public final class CLISessionsViewModel { /// Backup store for monitored session objects. /// Ensures sessions persist across refreshes even if not yet in history.jsonl. - private var monitoredSessionBackup: [String: CLISession] = [:] + var monitoredSessionBackup: [String: CLISession] = [:] /// Whether to show the last message instead of the first message in session rows public var showLastMessage: Bool { @@ -399,6 +416,9 @@ public final class CLISessionsViewModel { private var terminalViewKey: String { AgentHubDefaults.sessionsWithTerminalView + "." + providerDefaultsSuffix } + private var monitoredSessionBackupKey: String { + AgentHubDefaults.monitoredSessionIds + ".backup." + providerDefaultsSuffix + } // MARK: - Initialization @@ -584,6 +604,8 @@ public final class CLISessionsViewModel { let persistedSessionIds = loadPersistedSessionIds() if !persistedSessionIds.isEmpty { pendingRestorationSessionIds = persistedSessionIds + // Load session backup for continuation sessions not yet in history.jsonl + monitoredSessionBackup = loadPersistedSessionBackup() // Load terminal view state for restoration decisions if let tvData = UserDefaults.standard.data(forKey: terminalViewKey), let tvIds = try? JSONDecoder().decode([String].self, from: tvData) { @@ -623,8 +645,19 @@ public final class CLISessionsViewModel { var restoredIds: Set = [] for sessionId in pendingRestorationSessionIds { - if let session = findSession(byId: sessionId), - sessionFileExists(session: session) { + // Try authoritative source first + var session = findSession(byId: sessionId) + + // Fallback: use persisted backup (handles continuation sessions not yet in history.jsonl) + if session == nil, let backupSession = monitoredSessionBackup[sessionId] { + session = backupSession + } + + if let session, sessionFileExists(session: session) { + // Insert backup session into sidebar only after confirming file exists + if findSession(byId: session.id) == nil { + insertSessionIntoRepository(session) + } // Populate monitoring state directly (skip persistence — persisted state is already correct) monitoredSessionIds.insert(session.id) monitoredSessionBackup[session.id] = session @@ -703,10 +736,49 @@ public final class CLISessionsViewModel { } } + /// Persists the monitored session backup to UserDefaults. + /// Ensures continuation sessions survive app relaunch even before history.jsonl is updated. + private func persistMonitoredSessionBackup() { + if let data = try? JSONEncoder().encode(monitoredSessionBackup) { + UserDefaults.standard.set(data, forKey: monitoredSessionBackupKey) + } + } + + /// Loads the persisted session backup from UserDefaults. + private func loadPersistedSessionBackup() -> [String: CLISession] { + guard let data = UserDefaults.standard.data(forKey: monitoredSessionBackupKey), + let backup = try? JSONDecoder().decode([String: CLISession].self, from: data) else { + return [:] + } + return backup + } + + /// Inserts a session into the appropriate worktree in selectedRepositories. + /// Used for continuation sessions that aren't yet discovered by refreshSessions(). + func insertSessionIntoRepository(_ session: CLISession) { + for repoIndex in selectedRepositories.indices { + if let wtIndex = selectedRepositories[repoIndex].worktrees.firstIndex(where: { + $0.path == session.projectPath + }) { + // Avoid duplicates + if !selectedRepositories[repoIndex].worktrees[wtIndex].sessions.contains(where: { $0.id == session.id }) { + selectedRepositories[repoIndex].worktrees[wtIndex].sessions.insert(session, at: 0) + } + return + } + } + } + /// Syncs the backup store with latest session data from allSessions. /// Called after repositories are updated to keep backup fresh for monitored sessions. /// Also cleans up any orphaned entries not in monitoredSessionIds. - private func syncMonitoredSessionBackup() { + func syncMonitoredSessionBackup() { + // Don't clean backup while restorations are pending — processPendingSessionRestorations + // needs the backup as a fallback for continuation sessions not yet in history.jsonl. + // On first repositoriesPublisher emission after relaunch, monitoredSessionIds is still empty, + // so cleaning would destroy the backup before it can be used. + guard pendingRestorationSessionIds.isEmpty else { return } + // Update backup with latest data for monitored sessions for sessionId in monitoredSessionIds { if let session = allSessions.first(where: { $0.id == sessionId }) { @@ -718,6 +790,7 @@ public final class CLISessionsViewModel { for sessionId in orphanedIds { monitoredSessionBackup.removeValue(forKey: sessionId) } + persistMonitoredSessionBackup() } /// Restores monitored sessions from a provided set of session IDs. @@ -1880,6 +1953,7 @@ public final class CLISessionsViewModel { persistMonitoredSessions() persistSessionsWithTerminalView() + persistMonitoredSessionBackup() // Start polling for Preview/Plan buttons (works in both terminal and monitor modes) startPolling(session: session) @@ -1903,6 +1977,7 @@ public final class CLISessionsViewModel { persistMonitoredSessions() persistSessionsWithTerminalView() + persistMonitoredSessionBackup() Task { await fileWatcher.stopMonitoring(sessionId: sessionId) @@ -1914,6 +1989,82 @@ public final class CLISessionsViewModel { monitoredSessionIds.contains(sessionId) } + /// Transfers monitoring from an old session to its continuation session. + /// Preserves the terminal PTY (same Claude process) and all UI state. + /// Called when Claude Code creates a new session (e.g., "clear context and accept plan"). + public func transferMonitoring(fromSessionId oldSessionId: String, toSessionId newSessionId: String) { + guard monitoredSessionIds.contains(oldSessionId) else { return } + + // Find or create the new session object + let newSession: CLISession + if let found = allSessions.first(where: { $0.id == newSessionId }) { + newSession = found + } else if let oldSession = monitoredSessionBackup[oldSessionId] { + // Session may not be in history.jsonl yet — create from old session metadata + newSession = CLISession( + id: newSessionId, + projectPath: oldSession.projectPath, + branchName: oldSession.branchName, + isWorktree: oldSession.isWorktree, + lastActivityAt: Date(), + messageCount: 0, + isActive: true + ) + // Insert into the worktree's session list + insertSessionIntoRepository(newSession) + } else { + AppLogger.session.warning("[Continuation] Cannot transfer: old session \(oldSessionId.prefix(8), privacy: .public) not found in backup") + return + } + + // Transfer terminal PTY (re-key from old session ID to new) + if let terminal = activeTerminals.removeValue(forKey: oldSessionId) { + activeTerminals[newSessionId] = terminal + } + + // Transfer monitoring IDs + monitoredSessionIds.remove(oldSessionId) + monitoredSessionIds.insert(newSessionId) + + // Transfer backup + monitoredSessionBackup.removeValue(forKey: oldSessionId) + monitoredSessionBackup[newSessionId] = newSession + + // Transfer terminal view state + if sessionsWithTerminalView.remove(oldSessionId) != nil { + sessionsWithTerminalView.insert(newSessionId) + } + + // Transfer custom name + if let customName = sessionCustomNames.removeValue(forKey: oldSessionId) { + sessionCustomNames[newSessionId] = customName + } + + // Transfer pending prompts + if let prompt = pendingTerminalPrompts.removeValue(forKey: oldSessionId) { + pendingTerminalPrompts[newSessionId] = prompt + } + + // Clean up old monitoring subscription and state + monitoringCancellables.removeValue(forKey: oldSessionId) + monitorStates.removeValue(forKey: oldSessionId) + + // Start polling the new session + startPolling(session: newSession) + + // Notify sidebar of the ID change + resolvedContinuations[oldSessionId] = newSessionId + + // Persist + persistMonitoredSessions() + persistSessionsWithTerminalView() + persistMonitoredSessionBackup() + + AppLogger.session.info( + "[Continuation] Transferred monitoring: \(oldSessionId.prefix(8), privacy: .public) -> \(newSessionId.prefix(8), privacy: .public)" + ) + } + /// Get all currently monitored sessions with their states. /// Uses a backup store to preserve sessions that may not yet be in history.jsonl. public var monitoredSessions: [(session: CLISession, state: SessionMonitorState?)] { diff --git a/app/modules/AgentHubCore/Tests/AgentHubTests/SessionContinuationTests.swift b/app/modules/AgentHubCore/Tests/AgentHubTests/SessionContinuationTests.swift new file mode 100644 index 00000000..e23518ba --- /dev/null +++ b/app/modules/AgentHubCore/Tests/AgentHubTests/SessionContinuationTests.swift @@ -0,0 +1,423 @@ +import Foundation +import Combine +import Testing + +@testable import AgentHubCore + +// MARK: - Mocks + +private final class MockMonitorService: SessionMonitorServiceProtocol, @unchecked Sendable { + let subject = PassthroughSubject<[SelectedRepository], Never>() + var repositoriesPublisher: AnyPublisher<[SelectedRepository], Never> { + subject.eraseToAnyPublisher() + } + + func addRepository(_ path: String) async -> SelectedRepository? { nil } + func addRepositories(_ paths: [String]) async {} + func removeRepository(_ path: String) async {} + func getSelectedRepositories() async -> [SelectedRepository] { [] } + func setSelectedRepositories(_ repositories: [SelectedRepository]) async {} + func refreshSessions(skipWorktreeRedetection: Bool) async {} +} + +private final class MockFileWatcher: SessionFileWatcherProtocol, @unchecked Sendable { + let subject = PassthroughSubject() + var statePublisher: AnyPublisher { + subject.eraseToAnyPublisher() + } + + func startMonitoring(sessionId: String, projectPath: String, sessionFilePath: String?) async {} + func stopMonitoring(sessionId: String) async {} + func getState(sessionId: String) async -> SessionMonitorState? { nil } + func refreshState(sessionId: String) async {} + func setApprovalTimeout(_ seconds: Int) async {} +} + +// MARK: - Helpers + +private let testProjectPath = "/tmp/test-project" + +private func makeSession(id: String = UUID().uuidString, projectPath: String = testProjectPath) -> CLISession { + CLISession( + id: id, + projectPath: projectPath, + branchName: "main", + isWorktree: false, + lastActivityAt: Date(), + messageCount: 1, + isActive: true + ) +} + +private func makeWorktree( + path: String = testProjectPath, + sessions: [CLISession] = [] +) -> WorktreeBranch { + WorktreeBranch( + name: "main", + path: path, + isWorktree: false, + sessions: sessions, + isExpanded: true + ) +} + +private func makeRepo( + path: String = testProjectPath, + worktrees: [WorktreeBranch] = [] +) -> SelectedRepository { + SelectedRepository( + path: path, + worktrees: worktrees, + isExpanded: true + ) +} + +@MainActor +private func makeViewModel( + sessions: [CLISession] = [], + projectPath: String = testProjectPath +) -> (CLISessionsViewModel, MockFileWatcher) { + let monitorService = MockMonitorService() + let fileWatcher = MockFileWatcher() + + let vm = CLISessionsViewModel( + monitorService: monitorService, + fileWatcher: fileWatcher, + searchService: nil, + cliConfiguration: .claudeDefault, + providerKind: .claude + ) + + // Pre-populate selectedRepositories with a worktree containing the test sessions + let worktree = makeWorktree(path: projectPath, sessions: sessions) + let repo = makeRepo(path: projectPath, worktrees: [worktree]) + vm.selectedRepositories = [repo] + + return (vm, fileWatcher) +} + +// MARK: - Suite 1: TransferMonitoringTests + +@Suite("transferMonitoring — core state re-keying") +struct TransferMonitoringTests { + + @Test("Transfers monitoring IDs from old to new session") + @MainActor + func transfersMonitoringIds() { + let oldSession = makeSession(id: "old-session") + let newSession = makeSession(id: "new-session") + let (vm, _) = makeViewModel(sessions: [oldSession, newSession]) + + vm.startMonitoring(session: oldSession) + #expect(vm.monitoredSessionIds.contains("old-session")) + + vm.transferMonitoring(fromSessionId: "old-session", toSessionId: "new-session") + + #expect(!vm.monitoredSessionIds.contains("old-session")) + #expect(vm.monitoredSessionIds.contains("new-session")) + } + + @Test("Transfers terminal view state from old to new session") + @MainActor + func transfersTerminalViewState() { + let oldSession = makeSession(id: "old-session") + let newSession = makeSession(id: "new-session") + let (vm, _) = makeViewModel(sessions: [oldSession, newSession]) + + vm.startMonitoring(session: oldSession) + // startMonitoring defaults to terminal view + #expect(vm.sessionsWithTerminalView.contains("old-session")) + + vm.transferMonitoring(fromSessionId: "old-session", toSessionId: "new-session") + + #expect(!vm.sessionsWithTerminalView.contains("old-session")) + #expect(vm.sessionsWithTerminalView.contains("new-session")) + } + + @Test("Transfers custom name from old to new session") + @MainActor + func transfersCustomName() { + let oldSession = makeSession(id: "old-session") + let newSession = makeSession(id: "new-session") + let (vm, _) = makeViewModel(sessions: [oldSession, newSession]) + + vm.startMonitoring(session: oldSession) + vm.sessionCustomNames["old-session"] = "My Task" + + vm.transferMonitoring(fromSessionId: "old-session", toSessionId: "new-session") + + #expect(vm.sessionCustomNames["old-session"] == nil) + #expect(vm.sessionCustomNames["new-session"] == "My Task") + } + + @Test("Transfers pending prompt from old to new session") + @MainActor + func transfersPendingPrompt() { + let oldSession = makeSession(id: "old-session") + let newSession = makeSession(id: "new-session") + let (vm, _) = makeViewModel(sessions: [oldSession, newSession]) + + vm.startMonitoring(session: oldSession) + vm.pendingTerminalPrompts["old-session"] = "fix the bug" + + vm.transferMonitoring(fromSessionId: "old-session", toSessionId: "new-session") + + #expect(vm.pendingTerminalPrompts["old-session"] == nil) + #expect(vm.pendingTerminalPrompts["new-session"] == "fix the bug") + } + + @Test("Populates resolvedContinuations mapping") + @MainActor + func populatesResolvedContinuations() { + let oldSession = makeSession(id: "old-session") + let newSession = makeSession(id: "new-session") + let (vm, _) = makeViewModel(sessions: [oldSession, newSession]) + + vm.startMonitoring(session: oldSession) + vm.transferMonitoring(fromSessionId: "old-session", toSessionId: "new-session") + + #expect(vm.resolvedContinuations["old-session"] == "new-session") + } + + @Test("Clears old monitor state after transfer") + @MainActor + func clearsOldMonitorState() { + let oldSession = makeSession(id: "old-session") + let newSession = makeSession(id: "new-session") + let (vm, _) = makeViewModel(sessions: [oldSession, newSession]) + + vm.startMonitoring(session: oldSession) + vm.monitorStates["old-session"] = SessionMonitorState(status: .thinking) + + vm.transferMonitoring(fromSessionId: "old-session", toSessionId: "new-session") + + #expect(vm.monitorStates["old-session"] == nil) + } + + @Test("No-op when old session is not monitored") + @MainActor + func noOpWhenOldSessionNotMonitored() { + let session = makeSession(id: "some-session") + let (vm, _) = makeViewModel(sessions: [session]) + + // Should not crash or mutate state + vm.transferMonitoring(fromSessionId: "unknown-id", toSessionId: "new-id") + + #expect(vm.monitoredSessionIds.isEmpty) + #expect(vm.resolvedContinuations.isEmpty) + } + + @Test("No-op when old session is monitored but not in backup and new session not in allSessions") + @MainActor + func noOpWhenOldSessionNotInBackup() { + let (vm, _) = makeViewModel() + + // Manually insert into monitoredSessionIds without going through startMonitoring + // (which would also populate backup) + vm.monitoredSessionIds.insert("orphan-id") + + // The old session is monitored but has no backup entry and "new-id" is not in allSessions + vm.transferMonitoring(fromSessionId: "orphan-id", toSessionId: "new-id") + + // Should have returned early — orphan-id still in monitoredSessionIds because + // the guard passes but the else branch returns early + #expect(vm.resolvedContinuations.isEmpty) + } + + @Test("Creates session from backup when not in allSessions and inserts into repository") + @MainActor + func createsSessionFromBackupWhenNotInAllSessions() { + let oldSession = makeSession(id: "old-session") + let (vm, _) = makeViewModel(sessions: [oldSession]) + + vm.startMonitoring(session: oldSession) + + // "new-session" is NOT in allSessions — should be created from backup metadata + vm.transferMonitoring(fromSessionId: "old-session", toSessionId: "new-session") + + #expect(vm.monitoredSessionIds.contains("new-session")) + #expect(!vm.monitoredSessionIds.contains("old-session")) + + // The new session should have been inserted into the repository + let repoSessions = vm.selectedRepositories.flatMap { $0.worktrees.flatMap { $0.sessions } } + #expect(repoSessions.contains(where: { $0.id == "new-session" })) + + // Verify the created session inherits properties from the old session + let created = repoSessions.first(where: { $0.id == "new-session" }) + #expect(created?.projectPath == testProjectPath) + #expect(created?.isActive == true) + } +} + +// MARK: - Suite 2: InsertSessionIntoRepositoryTests + +@Suite("insertSessionIntoRepository — sidebar tree insertion") +struct InsertSessionIntoRepositoryTests { + + @Test("Inserts session into matching worktree") + @MainActor + func insertsIntoMatchingWorktree() { + let (vm, _) = makeViewModel() + + let session = makeSession(id: "inserted-session") + vm.insertSessionIntoRepository(session) + + let worktreeSessions = vm.selectedRepositories[0].worktrees[0].sessions + #expect(worktreeSessions.contains(where: { $0.id == "inserted-session" })) + } + + @Test("Does not create duplicates when called twice") + @MainActor + func avoidsDuplicates() { + let (vm, _) = makeViewModel() + + let session = makeSession(id: "dup-session") + vm.insertSessionIntoRepository(session) + vm.insertSessionIntoRepository(session) + + let matchCount = vm.selectedRepositories[0].worktrees[0].sessions.filter { $0.id == "dup-session" }.count + #expect(matchCount == 1) + } + + @Test("No-op when no matching worktree exists") + @MainActor + func noOpWhenNoMatchingWorktree() { + let (vm, _) = makeViewModel() + + let session = makeSession(id: "orphan", projectPath: "/tmp/no-such-project") + let countBefore = vm.selectedRepositories[0].worktrees[0].sessions.count + vm.insertSessionIntoRepository(session) + let countAfter = vm.selectedRepositories[0].worktrees[0].sessions.count + + #expect(countBefore == countAfter) + } +} + +// MARK: - Suite 3: SyncMonitoredSessionBackupTests + +@Suite("syncMonitoredSessionBackup — timing guard and cleanup") +struct SyncMonitoredSessionBackupTests { + + @Test("Does not clear backup while pending restorations exist") + @MainActor + func doesNotClearBackupDuringPendingRestorations() { + let session = makeSession(id: "persisted-session") + let (vm, _) = makeViewModel(sessions: [session]) + + // Populate backup via startMonitoring + vm.startMonitoring(session: session) + #expect(vm.monitoredSessionBackup["persisted-session"] != nil) + + // Simulate pending restoration (as if app just relaunched) + vm.pendingRestorationSessionIds = ["some-other-session"] + + // syncMonitoredSessionBackup should bail out early + vm.syncMonitoredSessionBackup() + + // Backup should be untouched + #expect(vm.monitoredSessionBackup["persisted-session"] != nil) + } + + @Test("Cleans orphaned backup entries after restorations complete") + @MainActor + func cleansOrphanedBackupAfterRestorationsComplete() { + let session = makeSession(id: "monitored-session") + let (vm, _) = makeViewModel(sessions: [session]) + + // Start monitoring to populate backup + vm.startMonitoring(session: session) + + // Stop monitoring — removes from monitoredSessionIds but backup persists until sync + vm.stopMonitoring(sessionId: "monitored-session") + // Re-add to backup manually to simulate a stale entry + vm.monitoredSessionBackup["monitored-session"] = session + + // Ensure no pending restorations + vm.pendingRestorationSessionIds = [] + + // Now sync should clean the orphaned entry + vm.syncMonitoredSessionBackup() + + #expect(vm.monitoredSessionBackup["monitored-session"] == nil) + } + + @Test("Updates backup with latest session data for monitored sessions") + @MainActor + func updatesBackupWithLatestSessionData() { + let session = makeSession(id: "active-session") + let (vm, _) = makeViewModel(sessions: [session]) + + vm.startMonitoring(session: session) + + // Update the session in selectedRepositories with new data + var updatedSession = session + updatedSession.messageCount = 42 + vm.selectedRepositories[0].worktrees[0].sessions = [updatedSession] + + vm.pendingRestorationSessionIds = [] + vm.syncMonitoredSessionBackup() + + #expect(vm.monitoredSessionBackup["active-session"]?.messageCount == 42) + } +} + +// MARK: - Suite 4: ContinuationViaPublisherTests + +@Suite("Continuation via statePublisher — end-to-end flow") +struct ContinuationViaPublisherTests { + + @Test("Continuation event triggers transfer via publisher subscription") + @MainActor + func continuationEventTriggersTransfer() async throws { + let oldSession = makeSession(id: "old-pub") + let newSession = makeSession(id: "new-pub") + let (vm, mockWatcher) = makeViewModel(sessions: [oldSession, newSession]) + + vm.startMonitoring(session: oldSession) + #expect(vm.monitoredSessionIds.contains("old-pub")) + + // Emit a continuation event from the mock file watcher + let state = SessionMonitorState(status: .idle) + mockWatcher.subject.send( + SessionFileWatcher.StateUpdate( + sessionId: "old-pub", + state: state, + continuationSessionId: "new-pub" + ) + ) + + // Allow the Combine pipeline + Task to settle + try await Task.sleep(for: .milliseconds(500)) + + #expect(!vm.monitoredSessionIds.contains("old-pub")) + #expect(vm.monitoredSessionIds.contains("new-pub")) + #expect(vm.resolvedContinuations["old-pub"] == "new-pub") + } + + @Test("Normal update does not trigger transfer") + @MainActor + func normalUpdateDoesNotTriggerTransfer() async throws { + let session = makeSession(id: "normal-session") + let (vm, mockWatcher) = makeViewModel(sessions: [session]) + + vm.startMonitoring(session: session) + + let state = SessionMonitorState(status: .thinking, messageCount: 5) + mockWatcher.subject.send( + SessionFileWatcher.StateUpdate( + sessionId: "normal-session", + state: state, + continuationSessionId: nil + ) + ) + + // Allow Combine pipeline to settle + try await Task.sleep(for: .milliseconds(100)) + + #expect(vm.monitoredSessionIds.contains("normal-session")) + #expect(vm.monitorStates["normal-session"]?.status == .thinking) + #expect(vm.monitorStates["normal-session"]?.messageCount == 5) + #expect(vm.resolvedContinuations.isEmpty) + } +} diff --git a/app/modules/AgentHubCore/Tests/AgentHubTests/WebPreviewTests.swift b/app/modules/AgentHubCore/Tests/AgentHubTests/WebPreviewTests.swift new file mode 100644 index 00000000..2592f3a0 --- /dev/null +++ b/app/modules/AgentHubCore/Tests/AgentHubTests/WebPreviewTests.swift @@ -0,0 +1,154 @@ +import Foundation +import Testing + +@testable import AgentHubCore + +private actor ProbeAttemptCounter { + private var attempts = 0 + + func nextAttempt() -> Int { + attempts += 1 + return attempts + } + + func current() -> Int { + attempts + } +} + +private actor GenerationGate { + private var remainingCurrentChecks: Int + + init(remainingCurrentChecks: Int) { + self.remainingCurrentChecks = remainingCurrentChecks + } + + func isCurrent() -> Bool { + guard remainingCurrentChecks > 0 else { return false } + remainingCurrentChecks -= 1 + return true + } +} + +@Suite("WebPreviewNavigationPolicy") +struct WebPreviewNavigationPolicyTests { + + @Test("Allows file URLs inside the project root") + func allowsProjectFiles() { + let root = URL(fileURLWithPath: "/tmp/project") + let child = root.appendingPathComponent("pages/index.html") + + let decision = WebPreviewNavigationPolicy.decision( + for: child, + allowedProjectRoot: root, + isMainFrameNavigation: true, + opensInNewWindow: false + ) + + #expect(decision == .allow) + } + + @Test("Rejects file URLs outside the project root") + func rejectsFilesOutsideRoot() { + let root = URL(fileURLWithPath: "/tmp/project") + let outside = URL(fileURLWithPath: "/tmp/other/index.html") + + let decision = WebPreviewNavigationPolicy.decision( + for: outside, + allowedProjectRoot: root, + isMainFrameNavigation: true, + opensInNewWindow: false + ) + + if case .deny = decision { + return + } + + Issue.record("Expected navigation outside the project root to be denied.") + } + + @Test("Allows localhost and loopback origins") + func allowsLoopbackOrigins() { + let urls = [ + URL(string: "http://localhost:3000")!, + URL(string: "http://127.0.0.1:3000")!, + URL(string: "http://[::1]:3000")! + ] + + for url in urls { + let decision = WebPreviewNavigationPolicy.decision( + for: url, + allowedProjectRoot: nil, + isMainFrameNavigation: true, + opensInNewWindow: false + ) + #expect(decision == .allow) + } + } + + @Test("Opens non-local top-level navigations in the external browser") + func opensExternalSitesOutsideTheApp() { + let url = URL(string: "https://example.com/docs")! + + let decision = WebPreviewNavigationPolicy.decision( + for: url, + allowedProjectRoot: nil, + isMainFrameNavigation: true, + opensInNewWindow: false + ) + + #expect(decision == .openExternally(url)) + } +} + +@Suite("DevServerReadinessProbe") +struct DevServerReadinessProbeTests { + + @Test("Waits for a successful probe before becoming ready") + func waitsForReachability() async { + let url = URL(string: "http://localhost:3000")! + let attempts = ProbeAttemptCounter() + let probe = DevServerReadinessProbe(expectedURL: url) + + let result = await probe.waitUntilReady( + timeout: .milliseconds(50), + pollInterval: .milliseconds(1), + probe: { _ in + await attempts.nextAttempt() >= 3 + }, + isCurrent: { true } + ) + + #expect(result == .ready(url)) + #expect(await attempts.current() >= 3) + } + + @Test("Times out when the server never becomes reachable") + func timesOutWhenNoCandidateResponds() async { + let probe = DevServerReadinessProbe(expectedURL: URL(string: "http://localhost:3000")!) + + let result = await probe.waitUntilReady( + timeout: .milliseconds(20), + pollInterval: .milliseconds(2), + probe: { _ in false }, + isCurrent: { true } + ) + + #expect(result == .timedOut) + } + + @Test("Returns stale when a restart invalidates the generation") + func returnsStaleWhenGenerationChanges() async { + let gate = GenerationGate(remainingCurrentChecks: 1) + let probe = DevServerReadinessProbe(expectedURL: URL(string: "http://localhost:3000")!) + + let result = await probe.waitUntilReady( + timeout: .milliseconds(40), + pollInterval: .milliseconds(1), + probe: { _ in false }, + isCurrent: { await gate.isCurrent() } + ) + + #expect(result == .stale) + } +}