diff --git a/supacode/Features/Terminal/BusinessLogic/TerminalEventCoalescer.swift b/supacode/Features/Terminal/BusinessLogic/TerminalEventCoalescer.swift new file mode 100644 index 00000000..c029f0a6 --- /dev/null +++ b/supacode/Features/Terminal/BusinessLogic/TerminalEventCoalescer.swift @@ -0,0 +1,67 @@ +import Foundation + +/// Drops a state-notification event that is identical to the last one emitted +/// for its slot, so an agent re-emitting the same status / focus / run-script / +/// font size doesn't flood the terminal event stream during a tool storm. +/// +/// Only "latest value wins" events are coalesced. Lifecycle and one-shot events +/// — notifications, custom-command completions, tab open/close, agent removal, +/// setup-script consumption, layout restore — are never coalesced, because each +/// occurrence is meaningful (e.g. two identical notifications are two distinct +/// user-facing events). +struct TerminalEventCoalescer { + enum CoalesceKey: Hashable { + case focusChanged + case fontSize + case taskStatus(Worktree.ID) + case runScriptStatus(Worktree.ID) + case agentEntry(UUID) + } + + private var lastEmitted: [CoalesceKey: TerminalClient.Event] = [:] + + /// The coalesce slot for an event, or `nil` when the event must never be + /// coalesced. + static func coalesceKey(for event: TerminalClient.Event) -> CoalesceKey? { + switch event { + case .focusChanged: + return .focusChanged + case .fontSizeChanged: + return .fontSize + case .taskStatusChanged(let worktreeID, _): + return .taskStatus(worktreeID) + case .runScriptStatusChanged(let worktreeID, _): + return .runScriptStatus(worktreeID) + case .agentEntryChanged(let entry): + return .agentEntry(entry.id) + case .customCommandSucceeded, .notificationReceived, .notificationIndicatorChanged, + .tabCreated, .tabClosed, .agentEntryRemoved, .commandPaletteToggleRequested, + .setupScriptConsumed, .layoutRestored, .layoutRestoreFailed: + return nil + } + } + + /// Returns `true` when the event should be forwarded, `false` when it is an + /// exact repeat of the last value emitted for its slot. + mutating func shouldEmit(_ event: TerminalClient.Event) -> Bool { + guard let key = Self.coalesceKey(for: event) else { return true } + if lastEmitted[key] == event { return false } + lastEmitted[key] = event + return true + } + + /// Clears the cache so a freshly subscribed stream can be re-seeded with the + /// current state instead of having it suppressed as a duplicate. + mutating func reset() { + lastEmitted.removeAll() + } + + /// Drops per-worktree slots for torn-down worktrees so a worktree ID that + /// returns doesn't inherit a stale cached value. + mutating func forget(worktreeIDs: Set) { + for worktreeID in worktreeIDs { + lastEmitted[.taskStatus(worktreeID)] = nil + lastEmitted[.runScriptStatus(worktreeID)] = nil + } + } +} diff --git a/supacode/Features/Terminal/BusinessLogic/WorktreeTerminalManager.swift b/supacode/Features/Terminal/BusinessLogic/WorktreeTerminalManager.swift index 6e5f2880..0a6c0683 100644 --- a/supacode/Features/Terminal/BusinessLogic/WorktreeTerminalManager.swift +++ b/supacode/Features/Terminal/BusinessLogic/WorktreeTerminalManager.swift @@ -21,6 +21,11 @@ final class WorktreeTerminalManager { private var lastNotificationIndicatorCount: Int? private var eventContinuation: AsyncStream.Continuation? private var pendingEvents: [TerminalClient.Event] = [] + private var eventCoalescer = TerminalEventCoalescer() + /// Caps the live stream and the pre-subscription backlog so a producer that + /// outruns the single main-actor consumer can't grow memory without bound. + private static let eventBufferCap = 2048 + private static let pendingEventCap = 1024 var selectedWorktreeID: Worktree.ID? /// The worktree+tab focused in Canvas, updated by CanvasView on card tap. /// Used by toggleCanvas to know which worktree to return to. @@ -189,9 +194,15 @@ final class WorktreeTerminalManager { func eventStream() -> AsyncStream { eventContinuation?.finish() - let (stream, continuation) = AsyncStream.makeStream(of: TerminalClient.Event.self) + let (stream, continuation) = AsyncStream.makeStream( + of: TerminalClient.Event.self, + bufferingPolicy: .bufferingNewest(Self.eventBufferCap) + ) eventContinuation = continuation lastNotificationIndicatorCount = nil + // A new subscriber must be re-seeded with current state, so the dedup cache + // can't suppress the next emit as a duplicate of one the old stream saw. + eventCoalescer.reset() if !pendingEvents.isEmpty { let bufferedEvents = pendingEvents pendingEvents.removeAll() @@ -359,8 +370,10 @@ final class WorktreeTerminalManager { func prune(keeping worktreeIDs: Set) { var removed: [WorktreeTerminalState] = [] + var removedIDs: Set = [] for (id, state) in states where !worktreeIDs.contains(id) { removed.append(state) + removedIDs.insert(id) } for state in removed { state.closeAllSurfaces() @@ -369,6 +382,7 @@ final class WorktreeTerminalManager { terminalLogger.info("Pruned \(removed.count) terminal state(s)") } states = states.filter { worktreeIDs.contains($0.key) } + eventCoalescer.forget(worktreeIDs: removedIDs) emitNotificationIndicatorCountIfNeeded() } @@ -527,7 +541,12 @@ final class WorktreeTerminalManager { } private func emit(_ event: TerminalClient.Event) { + guard eventCoalescer.shouldEmit(event) else { return } guard let eventContinuation else { + if pendingEvents.count >= Self.pendingEventCap { + pendingEvents.removeFirst() + terminalLogger.debug("Dropped oldest pending terminal event (backlog cap reached)") + } pendingEvents.append(event) return } diff --git a/supacodeTests/TerminalEventCoalescerTests.swift b/supacodeTests/TerminalEventCoalescerTests.swift new file mode 100644 index 00000000..2f21422b --- /dev/null +++ b/supacodeTests/TerminalEventCoalescerTests.swift @@ -0,0 +1,112 @@ +import Foundation +import Testing + +@testable import supacode + +struct TerminalEventCoalescerTests { + @Test func dropsConsecutiveIdenticalStateEvents() { + var coalescer = TerminalEventCoalescer() + let event = TerminalClient.Event.taskStatusChanged(worktreeID: "w1", status: .running) + let first = coalescer.shouldEmit(event) + // A re-emitted identical status is redundant: the slot already holds it. + let second = coalescer.shouldEmit(event) + #expect(first) + #expect(!second) + } + + @Test func passesDistinctValuesForSameSlot() { + var coalescer = TerminalEventCoalescer() + let running = coalescer.shouldEmit(.taskStatusChanged(worktreeID: "w1", status: .running)) + let idle = coalescer.shouldEmit(.taskStatusChanged(worktreeID: "w1", status: .idle)) + // Flipping back is still a real change. + let runningAgain = coalescer.shouldEmit(.taskStatusChanged(worktreeID: "w1", status: .running)) + #expect(running) + #expect(idle) + #expect(runningAgain) + } + + @Test func keysStateEventsPerWorktree() { + var coalescer = TerminalEventCoalescer() + let firstWorktree = coalescer.shouldEmit(.taskStatusChanged(worktreeID: "w1", status: .running)) + // A different worktree has its own slot, so an identical status still passes. + let secondWorktree = coalescer.shouldEmit(.taskStatusChanged(worktreeID: "w2", status: .running)) + #expect(firstWorktree) + #expect(secondWorktree) + } + + @Test func neverCoalescesNotifications() { + var coalescer = TerminalEventCoalescer() + let event = TerminalClient.Event.notificationReceived( + worktreeID: "w1", + surfaceID: UUID(), + title: "Build", + body: "done" + ) + let first = coalescer.shouldEmit(event) + // Two identical notifications are two distinct user-facing events. + let second = coalescer.shouldEmit(event) + #expect(first) + #expect(second) + } + + @Test func neverCoalescesLifecycleEvents() { + var coalescer = TerminalEventCoalescer() + let tab1 = coalescer.shouldEmit(.tabCreated(worktreeID: "w1")) + let tab2 = coalescer.shouldEmit(.tabCreated(worktreeID: "w1")) + let cmd1 = coalescer.shouldEmit(.customCommandSucceeded(worktreeID: "w1", name: "test", durationMs: 5)) + let cmd2 = coalescer.shouldEmit(.customCommandSucceeded(worktreeID: "w1", name: "test", durationMs: 5)) + let id = UUID() + let removed1 = coalescer.shouldEmit(.agentEntryRemoved(id)) + let removed2 = coalescer.shouldEmit(.agentEntryRemoved(id)) + #expect(tab1) + #expect(tab2) + #expect(cmd1) + #expect(cmd2) + #expect(removed1) + #expect(removed2) + } + + @Test func coalescesFocusRunScriptAndFontSize() { + var coalescer = TerminalEventCoalescer() + let surfaceID = UUID() + let focus1 = coalescer.shouldEmit(.focusChanged(worktreeID: "w1", surfaceID: surfaceID)) + let focus2 = coalescer.shouldEmit(.focusChanged(worktreeID: "w1", surfaceID: surfaceID)) + let run1 = coalescer.shouldEmit(.runScriptStatusChanged(worktreeID: "w1", isRunning: true)) + let run2 = coalescer.shouldEmit(.runScriptStatusChanged(worktreeID: "w1", isRunning: true)) + let font1 = coalescer.shouldEmit(.fontSizeChanged(14)) + let font2 = coalescer.shouldEmit(.fontSizeChanged(14)) + let font3 = coalescer.shouldEmit(.fontSizeChanged(16)) + #expect(focus1) + #expect(!focus2) + #expect(run1) + #expect(!run2) + #expect(font1) + #expect(!font2) + #expect(font3) + } + + @Test func resetClearsCacheSoAFreshSubscriberIsNotStarved() { + var coalescer = TerminalEventCoalescer() + let event = TerminalClient.Event.taskStatusChanged(worktreeID: "w1", status: .running) + let first = coalescer.shouldEmit(event) + let second = coalescer.shouldEmit(event) + // After a resubscribe the cache is cleared, so the current value can be + // re-delivered to the new stream. + coalescer.reset() + let afterReset = coalescer.shouldEmit(event) + #expect(first) + #expect(!second) + #expect(afterReset) + } + + @Test func forgetDropsKeysForRemovedWorktrees() { + var coalescer = TerminalEventCoalescer() + let event = TerminalClient.Event.taskStatusChanged(worktreeID: "w1", status: .running) + let first = coalescer.shouldEmit(event) + coalescer.forget(worktreeIDs: ["w1"]) + // The slot is gone, so a returning worktree starts clean. + let afterForget = coalescer.shouldEmit(event) + #expect(first) + #expect(afterForget) + } +}