native macOS codings agent orchestrator prowl.onev.cat
Something went wrong. Try again.
34 kB · 979 lines
Swift
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980import ConcurrencyExtrasimport DependenciesTestSupportimport Foundationimport GhosttyKitimport ProwlCLISharedimport Testing
@testable import supacode
@MainActorstruct WorktreeTerminalManagerTests { @Test func prunePreservesOnlyFailedRepositorySessionsUntilRecovery() { let manager = WorktreeTerminalManager(runtime: GhosttyRuntime()) let failed = makeWorktree() let original = manager.state(for: failed) let otherRoot = URL(fileURLWithPath: "/tmp/removed-repo") let removed = Worktree( id: otherRoot.path, name: "removed", detail: "", workingDirectory: otherRoot, repositoryRootURL: otherRoot) _ = manager.state(for: removed)
manager.prune(keeping: [], preservingRepositoryIDs: [failed.repositoryRootURL.path]) #expect(manager.stateIfExists(for: failed.id) === original) #expect(manager.stateIfExists(for: removed.id) == nil)
manager.prune(keeping: [failed.id]) #expect(manager.stateIfExists(for: failed.id) === original) manager.prune(keeping: []) #expect(manager.stateIfExists(for: failed.id) == nil) }
@Test func buffersEventsUntilStreamCreated() async { let manager = WorktreeTerminalManager(runtime: GhosttyRuntime()) let worktree = makeWorktree() let state = manager.state(for: worktree)
state.onSetupScriptConsumed?()
let stream = manager.eventStream() let event = await nextEvent(stream) { event in if case .setupScriptConsumed = event { return true } return false }
#expect(event == .setupScriptConsumed(worktreeID: worktree.id)) }
@Test func emitsEventsAfterStreamCreated() async { let manager = WorktreeTerminalManager(runtime: GhosttyRuntime()) let worktree = makeWorktree() let state = manager.state(for: worktree)
let stream = manager.eventStream() let eventTask = Task { await nextEvent(stream) { event in if case .setupScriptConsumed = event { return true } return false } }
state.onSetupScriptConsumed?()
let event = await eventTask.value #expect(event == .setupScriptConsumed(worktreeID: worktree.id)) }
@Test func syncPreferredFontSizeNoOpForMissingState() async { let manager = WorktreeTerminalManager(runtime: GhosttyRuntime()) let stream = manager.eventStream() var iterator = stream.makeAsyncIterator()
manager.syncPreferredFontSize(from: "/nonexistent")
// Should not emit font event; only the notification indicator event let first = await iterator.next() #expect(first == .notificationIndicatorChanged(count: 0)) }
@Test func onFontSizeAdjustedCallbackIsWired() { let manager = WorktreeTerminalManager(runtime: GhosttyRuntime()) let worktree = makeWorktree() let state = manager.state(for: worktree)
#expect(state.onFontSizeAdjusted != nil) }
@Test func closeTargetAvailabilityFollowsTerminalModelState() { let manager = WorktreeTerminalManager(runtime: GhosttyRuntime()) let worktree = makeWorktree() let state = manager.state(for: worktree)
#expect(state.canCloseFocusedTab == false) #expect(state.canCloseFocusedSurface == false)
let tabId = state.createTab()
#expect(tabId != nil) #expect(state.canCloseFocusedTab == true) #expect(state.canCloseFocusedSurface == true)
if let tabId { state.closeTab(tabId) }
#expect(state.canCloseFocusedTab == false) #expect(state.canCloseFocusedSurface == false) }
@Test func newEmptyTabStartsColdAgentDetection() throws { let manager = WorktreeTerminalManager(runtime: GhosttyRuntime()) let worktree = makeWorktree() let state = manager.state(for: worktree)
let tabId = try #require(state.createTab()) let surfaceId = try #require(state.focusedSurfaceId(in: tabId))
#expect(state.agentDetectionSchedules[surfaceId] == nil) #expect(state.agentDetectionTasks[surfaceId] == nil) }
@Test func wakingSurfaceStartsWarmAgentDetection() throws { let manager = WorktreeTerminalManager(runtime: GhosttyRuntime()) let worktree = makeWorktree() let state = manager.state(for: worktree)
let tabId = try #require(state.createTab()) let surfaceId = try #require(state.focusedSurfaceId(in: tabId))
state.wakeAgentDetection(forSurfaceID: surfaceId)
let schedule = try #require(state.agentDetectionSchedules[surfaceId]) #expect(schedule.nextInterval(now: Date()) != nil) #expect(state.agentDetectionTasks[surfaceId] != nil)
state.cleanupAllAgentDetectionState() }
@Test func initialInputStartsWarmAgentDetection() throws { let manager = WorktreeTerminalManager(runtime: GhosttyRuntime()) let worktree = makeWorktree() let state = manager.state(for: worktree)
let tabId = try #require(state.createTab(initialInput: "codex\n")) let surfaceId = try #require(state.focusedSurfaceId(in: tabId))
let schedule = try #require(state.agentDetectionSchedules[surfaceId]) #expect(schedule.nextInterval(now: Date()) != nil) #expect(state.agentDetectionTasks[surfaceId] != nil)
state.cleanupAllAgentDetectionState() }
@Test func backgroundProfileSplitInHiddenWorktreePreservesVisibleSelection() throws { let manager = WorktreeTerminalManager( runtime: GhosttyRuntime(), skipsSurfaceCreationForTesting: true ) let visibleWorktree = makeWorktree(id: "/tmp/repo/visible", name: "visible") let hiddenWorktree = makeWorktree(id: "/tmp/repo/hidden", name: "hidden") let visibleState = manager.state(for: visibleWorktree) let hiddenState = manager.state(for: hiddenWorktree) defer { visibleState.cleanupAllAgentDetectionState() hiddenState.cleanupAllAgentDetectionState() }
let visibleTab = try #require(visibleState.createTab()) let visiblePane = try #require(visibleState.focusedSurfaceId(in: visibleTab)) let hiddenTab = try #require(hiddenState.createTab()) let hiddenAnchor = try #require(hiddenState.focusedSurfaceId(in: hiddenTab)) manager.selectedWorktreeID = visibleWorktree.id let profileID = UUID() let plan = AgentProfileLaunchPlan( profileID: profileID, profileName: "Reviewer", runtime: .claude, invocation: AgentInvocation(executable: ":", arguments: []), commandEnvironmentTokens: [], placement: .split, splitDirection: .right, surfaceEnvironment: [:], dedicatedHome: nil )
let launched = try manager.launchAgentProfile( AgentProfileLaunchRequest( plan: plan, placement: .split(anchor: hiddenAnchor, direction: .right, background: true) ), in: hiddenWorktree ).get()
#expect(manager.selectedWorktreeID == visibleWorktree.id) #expect(visibleState.tabManager.selectedTabId == visibleTab) #expect(visibleState.currentFocusedSurfaceId() == visiblePane) #expect(hiddenState.tabManager.selectedTabId == hiddenTab) #expect(hiddenState.focusedSurfaceId(in: hiddenTab) == hiddenAnchor) #expect(launched.tabID == hiddenTab) }
@Test func startupHookMaintenanceSweepsAgedCrashForwardingRecords() throws { let base = FileManager.default.temporaryDirectory.appending( path: "prowl-tests-forward-startup-\(UUID().uuidString)", directoryHint: .isDirectory ) defer { try? FileManager.default.removeItem(at: base) } var oldStore: CodexForwardingRecordStore? = try CodexForwardingRecordStore( baseDirectory: base, orphanMaximumAge: 60, now: { Date(timeIntervalSince1970: 100) } ) let oldRecord = try #require(oldStore).create(argv: ["/tmp/notifier"]) oldStore = nil let manager = WorktreeTerminalManager( runtime: GhosttyRuntime(), forwardingRecordBaseDirectory: base )
manager.startAgentHookRuntimeMaintenance()
#expect(!FileManager.default.fileExists(atPath: oldRecord.locator.path(percentEncoded: false))) }
@Test func menuSplitProfileFallsBackToATabWhenNoAnchorExists() async throws { let manager = WorktreeTerminalManager( runtime: GhosttyRuntime(), hookResourcesProvider: { nil }, skipsSurfaceCreationForTesting: true ) let worktree = makeWorktree() let profileID = UUID() let plan = AgentProfileLaunchPlan( profileID: profileID, profileName: "Codex", runtime: .codex, invocation: AgentInvocation(executable: "codex", arguments: []), commandEnvironmentTokens: [], placement: .split, splitDirection: .right, surfaceEnvironment: [:], dedicatedHome: nil ) let stream = manager.eventStream()
manager.handleCommand(.launchAgentProfile(worktree, plan: plan)) let event = await nextEvent(stream) { switch $0 { case .agentProfileLaunched, .agentProfileLaunchFailed: true default: false } }
#expect(event == .agentProfileLaunched(worktreeID: worktree.id, profileID: profileID)) #expect(manager.state(for: worktree).tabManager.tabs.count == 1) }
@Test func unavailableHookResourcesWarnOnceAndLaunchTheOriginalInvocation() async throws { let manager = WorktreeTerminalManager( runtime: GhosttyRuntime(), hookResourcesProvider: { nil }, skipsSurfaceCreationForTesting: true ) let worktree = makeWorktree() let original = AgentProfileLaunchPlan( profileID: UUID(), profileName: "Codex", runtime: .codex, invocation: AgentInvocation(executable: "codex", arguments: ["Prompt"]), commandEnvironmentTokens: [], placement: .tab, splitDirection: .right, surfaceEnvironment: [AgentProfileLaunchPlanner.promptCarrierName: "Prompt"], dedicatedHome: nil ) let request = AgentProfileLaunchRequest(plan: original, placement: .tab(background: false))
let preparation = try await manager.prepareAgentProfileLaunch(request, in: worktree).get()
#expect(preparation.warnings.count == 1) #expect(preparation.warnings[0].code == .managedHookDegraded) #expect(preparation.context.request.plan.invocation == original.invocation) #expect(preparation.context.request.plan.hookRegistration == nil) let launched = try manager.launchPreparedAgentProfile(preparation, in: worktree).get() #expect(manager.state(for: worktree).surfaceView(for: launched.surfaceID) != nil) manager.state(for: worktree).cleanupAllAgentDetectionState() }
@Test func promptedManagedHookLaunchBindsDispatchToTheRegistrationEpoch() throws { let manager = WorktreeTerminalManager( runtime: GhosttyRuntime(), skipsSurfaceCreationForTesting: true ) let worktree = makeWorktree() let state = manager.state(for: worktree) let base = AgentProfileLaunchPlan( profileID: UUID(), profileName: "Codex", runtime: .codex, invocation: AgentInvocation(executable: "codex", arguments: ["Prompt"]), commandEnvironmentTokens: [], placement: .tab, splitDirection: .right, surfaceEnvironment: [AgentProfileLaunchPlanner.promptCarrierName: "Prompt"], dedicatedHome: nil ) let plan = base.applyingManagedHook( AgentHookPreparedInvocation( invocation: AgentInvocation(executable: "codex", arguments: ["-c", "notify=[]", "Prompt"]), argumentValues: [1: "notify=[]"] ), resources: AgentHookResources(bundledCLIPath: "/bundle/prowl", socketPath: "/tmp/prowl.sock"), launchCWD: worktree.workingDirectory, token: "token", nativeEvents: ["agent-turn-complete": .turnEnded], coveredEvents: [.turnEnded] ) let launched = try manager.launchAgentProfile( AgentProfileLaunchRequest(plan: plan, placement: .tab(background: false)), in: worktree ).get() let registrationEpoch = try #require(manager.agentEvidenceEpochForTesting(surfaceID: launched.surfaceID)) let dispatch = try manager.issueAgentDispatch() let dispatchID = dispatch.record.id let target = TabResolvedTarget( worktreeID: worktree.id, worktreeName: worktree.name, worktreePath: worktree.workingDirectory.path, worktreeRootPath: worktree.repositoryRootURL.path, worktreeKind: "git", tabID: launched.tabID.rawValue.uuidString, tabTitle: "Codex", tabSelected: true, paneID: launched.surfaceID.uuidString, paneTitle: "codex", paneCWD: worktree.workingDirectory.path, paneFocused: true )
try manager.bindAgentDispatch(dispatchID: dispatchID, target: target)
#expect(manager.agentDispatchSnapshot(dispatchID: dispatchID)?.binding?.evidenceEpoch == registrationEpoch) state.cleanupAllAgentDetectionState() }
@Test func firstTabUsesTabSurfaceContext() throws { let manager = WorktreeTerminalManager(runtime: GhosttyRuntime()) let worktree = makeWorktree() let state = manager.state(for: worktree)
let tabId = try #require(state.createTab()) let surfaceId = try #require(state.focusedSurfaceId(in: tabId)) let surface = try #require(state.surfaceView(for: surfaceId))
#expect(surface.surfaceContextForTesting == GHOSTTY_SURFACE_CONTEXT_TAB) }
@Test func splitTreeDoesNotRecreateSurfaceForClosedTab() throws { let manager = WorktreeTerminalManager(runtime: GhosttyRuntime()) let worktree = makeWorktree() let state = manager.state(for: worktree)
let tabId = try #require(state.createTab()) let surfaceId = try #require(state.focusedSurfaceId(in: tabId))
state.closeTab(tabId) let staleTree = state.splitTree(for: tabId)
#expect(staleTree.isEmpty) #expect(state.surfaceView(for: surfaceId) == nil) #expect(state.surfaceView(for: tabId) == nil) }
@Test func ghosttyCloseRequestDoesNotRecreateSurfaceForClosedTab() throws { let manager = WorktreeTerminalManager(runtime: GhosttyRuntime()) let worktree = makeWorktree() let state = manager.state(for: worktree)
let tabId = try #require(state.createTab()) let surfaceId = try #require(state.focusedSurfaceId(in: tabId)) let surface = try #require(state.surfaceView(for: surfaceId))
surface.bridge.closeSurface(processAlive: false) let staleTree = state.splitTree(for: tabId)
#expect(staleTree.isEmpty) #expect(state.tabManager.tabs.isEmpty) #expect(state.surfaceView(for: surfaceId) == nil) #expect(state.surfaceView(for: tabId) == nil) }
@Test func closeSurfaceReturnsActualRemovalResult() throws { let manager = WorktreeTerminalManager(runtime: GhosttyRuntime()) let worktree = makeWorktree() let state = manager.state(for: worktree)
let tabId = try #require(state.createTab()) let surfaceId = try #require(state.focusedSurfaceId(in: tabId))
#expect(state.closeSurface(id: surfaceId, confirmation: .skip) == true) #expect(state.surfaceView(for: surfaceId) == nil) #expect(state.tabManager.tabs.isEmpty) #expect(state.closeSurface(id: surfaceId, confirmation: .skip) == false) }
@Test func targetHandlesAreGlobalAndReassignedAfterClose() throws { let manager = WorktreeTerminalManager(runtime: GhosttyRuntime()) let firstWorktree = makeWorktree() let secondWorktree = makeWorktree(id: "/tmp/repo/wt-2", name: "wt-2") let firstState = manager.state(for: firstWorktree) let secondState = manager.state(for: secondWorktree)
let firstTabID = try #require(firstState.createTab()) let firstPaneID = try #require(firstState.focusedSurfaceId(in: firstTabID)) let secondTabID = try #require(secondState.createTab()) let secondPaneID = try #require(secondState.focusedSurfaceId(in: secondTabID))
#expect(firstState.tabHandle(for: firstTabID) == 1) #expect(firstState.paneHandle(for: firstPaneID) == 2) #expect(secondState.tabHandle(for: secondTabID) == 3) #expect(secondState.paneHandle(for: secondPaneID) == 4)
#expect(firstState.closeTab(firstTabID, confirmation: .skip)) #expect(firstState.tabHandle(for: firstTabID) == nil) #expect(firstState.paneHandle(for: firstPaneID) == nil)
let replacementTabID = try #require(firstState.createTab()) let replacementPaneID = try #require(firstState.focusedSurfaceId(in: replacementTabID))
#expect(firstState.tabHandle(for: replacementTabID) == 5) #expect(firstState.paneHandle(for: replacementPaneID) == 6) }
@Test func layoutRestoreReassignsHandlesForRestoredTabIDs() throws { let manager = WorktreeTerminalManager(runtime: GhosttyRuntime()) let worktree = makeWorktree() let state = manager.state(for: worktree) let tabID = try #require(state.createTab()) let originalPaneID = try #require(state.focusedSurfaceId(in: tabID)) let originalTabHandle = try #require(state.tabHandle(for: tabID)) let originalPaneHandle = try #require(state.paneHandle(for: originalPaneID)) let snapshot = try #require(state.makeLayoutSnapshotWorktree())
#expect(state.applyLayoutSnapshot(snapshot))
let restoredTabID = try #require(state.tabManager.tabs.first?.id) let restoredPaneID = try #require(state.focusedSurfaceId(in: restoredTabID)) let restoredTabHandle = try #require(state.tabHandle(for: restoredTabID)) let restoredPaneHandle = try #require(state.paneHandle(for: restoredPaneID))
#expect(restoredTabID == tabID) #expect(restoredTabHandle > originalTabHandle) #expect(restoredPaneHandle > originalPaneHandle) #expect(state.paneHandle(for: originalPaneID) == nil) }
@Test func notificationIndicatorUsesCurrentCountOnStreamStart() async { let manager = WorktreeTerminalManager(runtime: GhosttyRuntime()) let worktree = makeWorktree() let state = manager.state(for: worktree)
state.notifications = [ WorktreeTerminalNotification( surfaceId: UUID(), title: "Unread", body: "body", isRead: false ) ] state.onNotificationIndicatorChanged?() state.notifications = [ WorktreeTerminalNotification( surfaceId: UUID(), title: "Read", body: "body", isRead: true ) ]
let stream = manager.eventStream() var iterator = stream.makeAsyncIterator()
let first = await iterator.next() state.onSetupScriptConsumed?() let second = await iterator.next()
#expect(first == .notificationIndicatorChanged(count: 0)) #expect(second == .setupScriptConsumed(worktreeID: worktree.id)) }
@Test func taskStatusReflectsAnyRunningTab() { let manager = WorktreeTerminalManager(runtime: GhosttyRuntime()) let worktree = makeWorktree() let state = manager.state(for: worktree)
#expect(manager.taskStatus(for: worktree.id) == .idle)
let tab1 = TerminalTabID() let tab2 = TerminalTabID() state.tabIsRunningById[tab1] = false state.tabIsRunningById[tab2] = false #expect(manager.taskStatus(for: worktree.id) == .idle)
state.tabIsRunningById[tab2] = true #expect(manager.taskStatus(for: worktree.id) == .running)
state.tabIsRunningById[tab1] = true #expect(manager.taskStatus(for: worktree.id) == .running)
state.tabIsRunningById[tab2] = false #expect(manager.taskStatus(for: worktree.id) == .running)
state.tabIsRunningById[tab1] = false #expect(manager.taskStatus(for: worktree.id) == .idle) }
@Test func hasUnseenNotificationsReflectsUnreadEntries() { let manager = WorktreeTerminalManager(runtime: GhosttyRuntime()) let worktree = makeWorktree() let state = manager.state(for: worktree)
state.notifications = [ makeNotification(isRead: true), makeNotification(isRead: true), ]
#expect(manager.hasUnseenNotifications(for: worktree.id) == false)
state.notifications.append(makeNotification(isRead: false))
#expect(manager.hasUnseenNotifications(for: worktree.id) == true) }
@Test func markAllNotificationsReadEmitsUpdatedIndicatorCount() async { let manager = WorktreeTerminalManager(runtime: GhosttyRuntime()) let worktree = makeWorktree() let state = manager.state(for: worktree)
state.notifications = [ makeNotification(isRead: false), makeNotification(isRead: true), ]
let stream = manager.eventStream() var iterator = stream.makeAsyncIterator()
let first = await iterator.next() state.markAllNotificationsRead() let second = await iterator.next()
#expect(first == .notificationIndicatorChanged(count: 1)) #expect(second == .notificationIndicatorChanged(count: 0)) #expect(state.notifications.map(\.isRead) == [true, true]) }
@Test func markNotificationsReadOnlyAffectsMatchingSurface() { let manager = WorktreeTerminalManager(runtime: GhosttyRuntime()) let worktree = makeWorktree() let state = manager.state(for: worktree) let surfaceA = UUID() let surfaceB = UUID()
state.notifications = [ makeNotification(surfaceId: surfaceA, isRead: false), makeNotification(surfaceId: surfaceB, isRead: false), makeNotification(surfaceId: surfaceB, isRead: true), ]
state.markNotificationsRead(forSurfaceID: surfaceB)
let aNotifications = state.notifications.filter { $0.surfaceId == surfaceA } let bNotifications = state.notifications.filter { $0.surfaceId == surfaceB }
#expect(aNotifications.map(\.isRead) == [false]) #expect(bNotifications.map(\.isRead) == [true, true]) #expect(manager.hasUnseenNotifications(for: worktree.id) == true)
state.markNotificationsRead(forSurfaceID: surfaceA)
#expect(manager.hasUnseenNotifications(for: worktree.id) == false) }
@Test func markNotificationReadOnlyAffectsMatchingID() { let manager = WorktreeTerminalManager(runtime: GhosttyRuntime()) let worktree = makeWorktree() let state = manager.state(for: worktree) let notificationA = UUID() let notificationB = UUID() let surfaceID = UUID()
state.notifications = [ makeNotification(id: notificationA, surfaceId: surfaceID, isRead: false), makeNotification(id: notificationB, surfaceId: surfaceID, isRead: false), ]
state.markNotificationRead(id: notificationB)
#expect(state.notifications.map(\.isRead) == [false, true]) #expect(manager.hasUnseenNotifications(for: worktree.id) == true) }
@Test func latestUnreadNotificationLocationChoosesNewestFocusableAcrossWorktrees() { let manager = WorktreeTerminalManager(runtime: GhosttyRuntime()) let worktreeA = makeWorktree(id: "/tmp/repo/wt-a", name: "wt-a") let worktreeB = makeWorktree(id: "/tmp/repo/wt-b", name: "wt-b") let stateA = manager.state(for: worktreeA) let stateB = manager.state(for: worktreeB) let tabA = stateA.createTab()! let tabB = stateB.createTab()! let surfaceA = stateA.focusedSurfaceId(in: tabA)! let surfaceB = stateB.focusedSurfaceId(in: tabB)! let notificationA = UUID() let notificationB = UUID()
stateA.notifications = [ makeNotification( id: notificationA, surfaceId: surfaceA, createdAt: Date(timeIntervalSince1970: 10), isRead: false ) ] stateB.notifications = [ makeNotification( id: notificationB, surfaceId: surfaceB, createdAt: Date(timeIntervalSince1970: 20), isRead: false ) ]
#expect( manager.latestUnreadNotificationLocation() == NotificationLocation( worktreeID: worktreeB.id, tabID: tabB, surfaceID: surfaceB, notificationID: notificationB ) ) }
@Test func latestUnreadNotificationLocationSkipsClosedSurfaces() { let manager = WorktreeTerminalManager(runtime: GhosttyRuntime()) let worktree = makeWorktree() let state = manager.state(for: worktree) let tabID = state.createTab()! let surfaceID = state.focusedSurfaceId(in: tabID)! let focusableNotification = UUID()
state.notifications = [ makeNotification( surfaceId: UUID(), createdAt: Date(timeIntervalSince1970: 20), isRead: false ), makeNotification( id: focusableNotification, surfaceId: surfaceID, createdAt: Date(timeIntervalSince1970: 10), isRead: false ), ]
#expect( manager.latestUnreadNotificationLocation() == NotificationLocation( worktreeID: worktree.id, tabID: tabID, surfaceID: surfaceID, notificationID: focusableNotification ) ) }
@Test func setNotificationsDisabledMarksAllRead() { let manager = WorktreeTerminalManager(runtime: GhosttyRuntime()) let worktree = makeWorktree() let state = manager.state(for: worktree)
state.notifications = [ makeNotification(isRead: false), makeNotification(isRead: false), ]
state.setNotificationsEnabled(false)
#expect(state.notifications.map(\.isRead) == [true, true]) #expect(manager.hasUnseenNotifications(for: worktree.id) == false) }
@Test func dismissAllNotificationsClearsState() { let manager = WorktreeTerminalManager(runtime: GhosttyRuntime()) let worktree = makeWorktree() let state = manager.state(for: worktree)
state.notifications = [ makeNotification(isRead: false), makeNotification(isRead: true), ]
state.dismissAllNotifications()
#expect(state.notifications.isEmpty) #expect(manager.hasUnseenNotifications(for: worktree.id) == false) }
@Test func makeLayoutSnapshotPersistsCustomTabTitle() throws { let manager = WorktreeTerminalManager(runtime: GhosttyRuntime()) let worktree = makeWorktree() let state = manager.state(for: worktree) let tabID = try #require(state.createTab())
state.tabManager.updateTitle(tabID, title: "npm test") state.tabManager.setCustomTitle(tabID, title: "Build")
let snapshot = try #require(state.makeLayoutSnapshotWorktree())
#expect(snapshot.tabs.first?.title == "npm test") #expect(snapshot.tabs.first?.customTitle == "Build") }
@Test func applyLayoutSnapshotRestoresCustomTabTitle() throws { let tabID = UUID() let manager = WorktreeTerminalManager(runtime: GhosttyRuntime()) let worktree = makeWorktree() let state = manager.state(for: worktree) let snapshot = TerminalLayoutSnapshotPayload.SnapshotWorktree( worktreeID: worktree.id, selectedTabID: tabID.uuidString, tabs: [ TerminalLayoutSnapshotPayload.SnapshotTab( tabID: tabID.uuidString, title: "npm test", customTitle: "Build", icon: nil, splitRoot: .leaf(surfaceID: UUID().uuidString) ) ] )
#expect(state.applyLayoutSnapshot(snapshot)) let restored = try #require(state.tabManager.tabs.first)
#expect(restored.title == "npm test") #expect(restored.customTitle == "Build") #expect(restored.displayTitle == "Build") #expect(restored.isTitleLocked == false) }
@Test func restoreLayoutSnapshotFailClosedClearsSnapshotWhenWorktreeMissing() async { let clearCount = LockIsolated(0) let snapshot = TerminalLayoutSnapshotPayload( worktrees: [ TerminalLayoutSnapshotPayload.SnapshotWorktree( worktreeID: "/tmp/repo/wt-1", selectedTabID: "F96839F5-1371-4841-9E41-49124D918A67", tabs: [ TerminalLayoutSnapshotPayload.SnapshotTab( tabID: "F96839F5-1371-4841-9E41-49124D918A67", title: nil, icon: nil, splitRoot: .leaf(surfaceID: "9B2F6D8C-44A4-42C5-8F9E-962108301901") ) ] ) ] ) let manager = WorktreeTerminalManager( runtime: GhosttyRuntime(), layoutPersistence: TerminalLayoutPersistenceClient( loadSnapshot: { snapshot }, saveSnapshot: { _ in true }, clearSnapshot: { clearCount.withValue { $0 += 1 } return true } ) ) let stream = manager.eventStream()
await manager.restoreLayoutSnapshot(from: [])
let event = await nextEvent(stream) { event in if case .layoutRestoreFailed = event { return true } return false }
#expect(clearCount.value == 1) #expect(event == .layoutRestoreFailed(message: "Saved terminal layout was invalid and has been reset")) }
@Test func restoreLayoutSnapshotEmitsRestoredNilWhenSnapshotMissing() async { let manager = WorktreeTerminalManager( runtime: GhosttyRuntime(), layoutPersistence: TerminalLayoutPersistenceClient( loadSnapshot: { nil }, saveSnapshot: { _ in true }, clearSnapshot: { true } ) ) let stream = manager.eventStream()
await manager.restoreLayoutSnapshot(from: [makeWorktree()])
let event = await nextEvent(stream) { event in event == .layoutRestored(selectedWorktreeID: nil) }
#expect(event == .layoutRestored(selectedWorktreeID: nil)) }
@Test func persistLayoutSnapshotWithoutTabsClearsSnapshot() async { let clearCount = LockIsolated(0) let saveCount = LockIsolated(0) let manager = WorktreeTerminalManager( runtime: GhosttyRuntime(), layoutPersistence: TerminalLayoutPersistenceClient( loadSnapshot: { nil }, saveSnapshot: { _ in saveCount.withValue { $0 += 1 } return true }, clearSnapshot: { clearCount.withValue { $0 += 1 } return true } ) )
await manager.persistLayoutSnapshot()
#expect(saveCount.value == 0) #expect(clearCount.value == 1) }
private func makeWorktree( id: Worktree.ID = "/tmp/repo/wt-1", name: String = "wt-1" ) -> Worktree { Worktree( id: id, name: name, detail: "detail", workingDirectory: URL(fileURLWithPath: id), repositoryRootURL: URL(fileURLWithPath: "/tmp/repo") ) }
@Test func busyAgentFoldsIntoTaskStatusAndEmits() throws { let manager = WorktreeTerminalManager(runtime: GhosttyRuntime()) let worktree = makeWorktree() let state = manager.state(for: worktree)
let tabId = try #require(state.createTab()) let surfaceId = try #require(state.focusedSurfaceId(in: tabId))
#expect(state.taskStatus == .idle)
var emissions: [WorktreeTaskStatus] = [] state.onTaskStatusChanged = { emissions.append($0) }
// A working agent on the tab makes the worktree run, with one emission. state.surfaceAgentStates[surfaceId] = PaneAgentState(detectedAgent: .claude, state: .working) state.updateTabAgentBusyState(for: tabId) #expect(state.taskStatus == .running) #expect(emissions == [.running])
// Idempotent while it stays busy — no duplicate emission. state.updateTabAgentBusyState(for: tabId) #expect(emissions == [.running])
// Returning to idle clears the indicator and emits once more. state.surfaceAgentStates[surfaceId] = PaneAgentState(detectedAgent: .claude, state: .idle) state.updateTabAgentBusyState(for: tabId) #expect(state.taskStatus == .idle) #expect(emissions == [.running, .idle])
state.cleanupAllAgentDetectionState() }
@Test func tabTeardownClearsAgentBusyTaskStatus() throws { let manager = WorktreeTerminalManager(runtime: GhosttyRuntime()) let worktree = makeWorktree() let state = manager.state(for: worktree)
let tabId = try #require(state.createTab()) let surfaceId = try #require(state.focusedSurfaceId(in: tabId))
state.surfaceAgentStates[surfaceId] = PaneAgentState(detectedAgent: .claude, state: .working) state.updateTabAgentBusyState(for: tabId) #expect(state.taskStatus == .running)
state.closeAllSurfaces() #expect(state.tabAgentBusyById.isEmpty) #expect(state.taskStatus == .idle) }
@MainActor @Test func blockedAgentIsTrackedSeparatelyFromBusy() throws { let manager = WorktreeTerminalManager(runtime: GhosttyRuntime()) let worktree = makeWorktree() let state = manager.state(for: worktree)
let tabId = try #require(state.createTab()) let surfaceId = try #require(state.focusedSurfaceId(in: tabId))
// Working: busy, but nothing is waiting on the user. state.surfaceAgentStates[surfaceId] = PaneAgentState(detectedAgent: .claude, state: .working) state.updateTabAgentBusyState(for: tabId) #expect(state.taskStatus == .running) #expect(state.hasBlockedAgent == false)
// working → blocked leaves the busy aggregate unchanged, so the blocked // flag is the only thing that can tell the sidebar to stop spinning. state.surfaceAgentStates[surfaceId] = PaneAgentState(detectedAgent: .claude, state: .blocked) state.updateTabAgentBusyState(for: tabId) #expect(state.taskStatus == .running) #expect(state.hasBlockedAgent) #expect(manager.hasBlockedAgent(for: worktree.id))
// Answered: back to working, attention affordance clears. state.surfaceAgentStates[surfaceId] = PaneAgentState(detectedAgent: .claude, state: .working) state.updateTabAgentBusyState(for: tabId) #expect(state.hasBlockedAgent == false)
state.closeAllSurfaces() #expect(state.tabAgentBlockedById.isEmpty) #expect(state.hasBlockedAgent == false) }
@MainActor @Test func blockedFlagIgnoresPanesWithoutADetectedAgent() throws { let manager = WorktreeTerminalManager(runtime: GhosttyRuntime()) let worktree = makeWorktree() let state = manager.state(for: worktree)
let tabId = try #require(state.createTab()) let surfaceId = try #require(state.focusedSurfaceId(in: tabId))
// A bare shell can carry a stale raw state; without a detected agent it // must not light up the sidebar. state.surfaceAgentStates[surfaceId] = PaneAgentState(detectedAgent: nil, state: .blocked) state.updateTabAgentBusyState(for: tabId) #expect(state.hasBlockedAgent == false) #expect(state.taskStatus == .idle) }
private func nextEvent( _ stream: AsyncStream<TerminalClient.Event>, matching predicate: (TerminalClient.Event) -> Bool ) async -> TerminalClient.Event? { for await event in stream where predicate(event) { return event } return nil }
private func makeNotification( id: UUID = UUID(), surfaceId: UUID = UUID(), createdAt: Date = .distantPast, isRead: Bool ) -> WorktreeTerminalNotification { WorktreeTerminalNotification( id: id, surfaceId: surfaceId, title: "Title", body: "Body", createdAt: createdAt, isRead: isRead ) }
}