import Foundation import HapiClient import HapiProtocol import Testing /// Targeted coverage for what the pagination fixtures cannot reach: /// tail-sync single-flight/trailing behavior, concurrent-ingest preservation /// across a reset replace (identity baseline), the cursor-no-advance guard, /// snapshot round-trips + LRU, and seed-on-resume — the essentials of the /// Android `MessageWindowStoreTest`. @Suite("MessageWindowController") struct MessageWindowControllerTests { // MARK: - Response builders private func agentRow(id: String, seq: Int, at: Int) -> DecryptedMessage { DecryptedMessage( id: id, seq: seq, content: [ "role": "agent", "content": [ "type": "codex", "data": ["type": "message", "message": .string(id)], ], ], createdAt: at, invokedAt: at ) } private func latestPage( _ messages: [DecryptedMessage], epoch: Int, reset: Bool = false, hasMore: Bool = false ) -> MessagesResponse { let newest = messages.last let oldest = messages.first return MessagesResponse( messages: messages, page: MessagesPage( direction: .latest, limit: 200, epoch: epoch, reset: reset, nextBeforeSeq: oldest?.seq, nextBeforeAt: oldest.map { $0.invokedAt ?? $0.createdAt }, nextAfterSeq: nil, nextAfterAt: nil, snapshotHeadSeq: newest?.seq, snapshotHeadAt: newest.map { $0.invokedAt ?? $0.createdAt }, hasMore: hasMore ) ) } private func afterPage( _ messages: [DecryptedMessage], epoch: Int, nextAfter: (at: Int, seq: Int)?, hasMore: Bool ) -> MessagesResponse { MessagesResponse( messages: messages, page: MessagesPage( direction: .after, limit: 200, epoch: epoch, reset: false, nextBeforeSeq: nil, nextBeforeAt: nil, nextAfterSeq: nextAfter?.seq, nextAfterAt: nextAfter?.at, snapshotHeadSeq: nextAfter?.seq, snapshotHeadAt: nextAfter?.at, hasMore: hasMore ) ) } // MARK: - Tail sync @Test func cancellingHistoryDoesNotPublishAnOfflineWarning() async { let provider = GatedMessagesProvider() var initial = MessageWindowState(sessionId: "history") initial.hasMore = true initial.epoch = 1 initial.oldestPosition = MessagePosition(at: 1000, seq: 1) let controller = MessageWindowController(sessionId: "history", provider: provider, initialState: initial) let task = Task { await controller.fetchOlder() } await provider.waitForRequests(1) task.cancel() await provider.failPending(CancellationError()) guard case .stopped(.invalidated) = await task.value else { Issue.record("Cancellation must not appear as a failed network request") return } let state = await controller.state #expect(!state.isLoadingMore) #expect(state.warning == nil) #expect(state.hasMore) } @Test func nativeHistoryRetainsThePrependBudgetAcrossSSEAndReset() async throws { var initial = MessageWindowState(sessionId: "history") initial.messages = (1...800).map { WindowMessage(wire: agentRow(id: "a-\($0)", seq: $0, at: $0 * 1000)) } initial.epoch = 1 initial.viewMode = .history let provider = GatedMessagesProvider() let controller = MessageWindowController(sessionId: "history", provider: provider, initialState: initial, historyRetentionLimit: 800) await controller.ingestSSEMessages([WindowMessage(wire: agentRow(id: "new", seq: 801, at: 801000))]) let history = await controller.state #expect(history.messages.count == 800) #expect(history.messages.first?.id == "a-1") #expect(history.requiresLatestReset) await provider.release(latestPage([agentRow(id: "new", seq: 801, at: 801000)], epoch: 1)) try await controller.reconcileQueuedState() let afterReconnect = await controller.state #expect(await provider.requests == [.latest(limit: 200)]) #expect(afterReconnect.messages == history.messages) #expect(afterReconnect.oldestPosition == history.oldestPosition) #expect(afterReconnect.newestPosition == history.newestPosition) #expect(afterReconnect.hasMore == history.hasMore) #expect(afterReconnect.historyVersion == history.historyVersion) #expect(afterReconnect.syncGeneration == history.syncGeneration + 1) #expect(afterReconnect.requiresLatestReset) #expect(!afterReconnect.isSyncingTail) await controller.setViewMode(.tail) let tail = await controller.state #expect(tail.messages.count == 400) #expect(tail.epoch == nil) await controller.clear() let cleared = await controller.state #expect(cleared.historyRetentionLimit == 800) } @Test(arguments: [false, true]) func gapRecoveryReplacesTruncatedHistoryOnEpochChangeOrExplicitReset(reset: Bool) async throws { let provider = GatedMessagesProvider() var initial = MessageWindowState(sessionId: "history") initial.messages = [WindowMessage(wire: agentRow(id: "obsolete", seq: 1, at: 1000))] initial.epoch = 1 initial.viewMode = .history initial.requiresLatestReset = true initial.hasMore = true initial.oldestPosition = MessagePosition(at: 1000, seq: 1) initial.newestPosition = initial.oldestPosition let controller = MessageWindowController(sessionId: "history", provider: provider, initialState: initial) let epoch = reset ? 1 : 2 // Empty authoritative latest also covers deleted history. await provider.release(latestPage([], epoch: epoch, reset: reset)) try await controller.reconcileQueuedState() #expect(await provider.requests.count == 1) let state = await controller.state #expect(state.epoch == epoch) #expect(state.messages.isEmpty) #expect(!state.requiresLatestReset) #expect(!state.hasMore) } @Test func gapRecoveryDrainsAFreshEpochCheckDuringTruncatedHistorySync() async throws { let provider = GatedMessagesProvider() var initial = MessageWindowState(sessionId: "history") initial.messages = [WindowMessage(wire: agentRow(id: "obsolete", seq: 1, at: 1000))] initial.epoch = 1 initial.viewMode = .history initial.requiresLatestReset = true let controller = MessageWindowController(sessionId: "history", provider: provider, initialState: initial) let first = Task { await controller.syncTail() } await provider.waitForRequests(1) let recovery = Task { try await controller.reconcileQueuedState() } // Let recovery join the parked run before releasing its old snapshot. try await Task.sleep(for: .milliseconds(20)) await provider.release(latestPage([], epoch: 1)) await first.value await provider.waitForRequests(2) #expect(await provider.requests == [.latest(limit: 200), .latest(limit: 200)]) #expect(await controller.state.messages.map(\.id) == ["obsolete"]) await provider.release(latestPage([agentRow(id: "replacement", seq: 2, at: 2000)], epoch: 2)) try await recovery.value #expect(await controller.state.epoch == 2) #expect(await controller.state.messages.map(\.id) == ["replacement"]) } @Test func stalledOlderCursorDoesNotApplyOrPretendHistoryIsExhausted() async { let provider = GatedMessagesProvider() var initial = MessageWindowState(sessionId: "history") initial.hasMore = true initial.epoch = 1 initial.oldestPosition = MessagePosition(at: 1000, seq: 1) await provider.release(MessagesResponse(messages: [], page: MessagesPage( direction: .before, limit: 200, epoch: 1, reset: false, nextBeforeSeq: 1, nextBeforeAt: 1000, nextAfterSeq: nil, nextAfterAt: nil, snapshotHeadSeq: nil, snapshotHeadAt: nil, hasMore: true ))) let controller = MessageWindowController(sessionId: "history", provider: provider, initialState: initial) let outcome = await controller.fetchOlder() guard case .stopped(.cursorDidNotAdvance) = outcome else { Issue.record("Expected a stalled cursor"); return } let state = await controller.state #expect(state.hasMore) #expect(!state.isLoadingMore) #expect(state.historyVersion == 0) } @Test func readerReversalVetoesAnInflightPrepend() async { let provider = GatedMessagesProvider() var initial = MessageWindowState(sessionId: "history") initial.hasMore = true initial.epoch = 1 initial.oldestPosition = MessagePosition(at: 1000, seq: 1) let controller = MessageWindowController(sessionId: "history", provider: provider, initialState: initial) let gate = ChatHistoryRequestGate() let request = Task { await controller.fetchOlder(onBeforeApply: { _ in gate.allowsApply }) } await provider.waitForRequests(1) gate.invalidate() await provider.release(MessagesResponse(messages: [agentRow(id: "older", seq: 0, at: 0)], page: MessagesPage( direction: .before, limit: 200, epoch: 1, reset: false, nextBeforeSeq: 0, nextBeforeAt: 0, nextAfterSeq: nil, nextAfterAt: nil, snapshotHeadSeq: nil, snapshotHeadAt: nil, hasMore: false ))) let outcome = await request.value guard case .stopped(.invalidated) = outcome else { Issue.record("Stale page was applied"); return } let state = await controller.state #expect(state.messages.isEmpty) #expect(state.historyVersion == 0) } @Test func concurrentSyncTailCallsCoalesceIntoASingleRun() async { let provider = GatedMessagesProvider() let controller = MessageWindowController(sessionId: "s", provider: provider) let first = Task { await controller.syncTail() } await provider.waitForRequests(1) let second = Task { await controller.syncTail() } // Settle: same-priority actor jobs run FIFO in practice, so two // round-trips put `second` past its join decision before we release. _ = await controller.state _ = await controller.state // Safety net so a mis-scheduled `second` fails fast instead of // hanging: an after-page is buffered for the run it would start. await provider.release(latestPage([agentRow(id: "a-1", seq: 1, at: 1000)], epoch: 0)) await provider.release(afterPage([], epoch: 0, nextAfter: (at: 1000, seq: 1), hasMore: false)) await first.value await second.value let requests = await provider.requests #expect(requests.count == 1, "second caller should have joined the in-flight run") let state = await controller.state #expect(state.messages.map(\.id) == ["a-1"]) } @Test func ensureAfterCurrentRequestsATrailingRunAndDrainsIt() async { let provider = GatedMessagesProvider() let controller = MessageWindowController(sessionId: "s", provider: provider) let first = Task { await controller.syncTail() } await provider.waitForRequests(1) let drain = Task { await controller.syncTail(ensureAfterCurrent: true) } await provider.release(latestPage([agentRow(id: "a-1", seq: 1, at: 1000)], epoch: 0)) await first.value // Whether `drain` joined the first run (trailing) or started after it // (fresh run), the guaranteed observable is a SECOND, after-cursor // request before `drain` returns. await provider.waitForRequests(2) let requestsMidway = await provider.requests guard case .after = requestsMidway[1] else { Issue.record("second request should be an after-cursor catch-up, got \(requestsMidway[1])") return } await provider.release(afterPage([], epoch: 0, nextAfter: (at: 1000, seq: 1), hasMore: false)) await drain.value let requests = await provider.requests #expect(requests.count == 2) let warning = await controller.state.warning #expect(warning == nil) } @Test func resetReplacePreservesRowsThatArrivedWhileTheRequestWasInFlight() async { let provider = GatedMessagesProvider() let controller = MessageWindowController(sessionId: "s", provider: provider) // Seed epoch + cursor with a completed latest sync. let seed = Task { await controller.syncTail() } await provider.waitForRequests(1) await provider.release(latestPage([agentRow(id: "a-1", seq: 1, at: 1000)], epoch: 0)) await seed.value // Second sync goes down the after-cursor path and parks on the provider. let sync = Task { await controller.syncTail() } await provider.waitForRequests(2) let requests = await provider.requests guard case .after = requests[1] else { Issue.record("expected an after-cursor request, got \(requests[1])") return } // A live SSE row lands while the request is in flight. await controller.ingestSSEMessages([WindowMessage(wire: agentRow(id: "b-2", seq: 2, at: 2000))]) // The server answers with a reset page that does not contain b-2. await provider.release(latestPage([agentRow(id: "c-3", seq: 3, at: 3000)], epoch: 1, reset: true)) await sync.value let state = await controller.state // a-1 (captured in the request baseline) was replaced; the concurrent // b-2 and the authoritative c-3 both survive. #expect(state.messages.map(\.id) == ["b-2", "c-3"]) #expect(state.epoch == 1) #expect(state.newestPosition == MessagePosition(at: 3000, seq: 3)) } @Test func aTailCursorThatDoesNotAdvanceAbortsWithAWarningInsteadOfSpinning() async { let provider = GatedMessagesProvider() let controller = MessageWindowController(sessionId: "s", provider: provider) let seed = Task { await controller.syncTail() } await provider.waitForRequests(1) await provider.release(latestPage([agentRow(id: "a-1", seq: 1, at: 1000)], epoch: 0)) await seed.value let sync = Task { await controller.syncTail() } await provider.waitForRequests(2) // hasMore with a non-advancing nextAfter is a protocol violation. await provider.release(afterPage( [agentRow(id: "b-2", seq: 2, at: 2000)], epoch: 0, nextAfter: (at: 1000, seq: 1), hasMore: true )) await sync.value let state = await controller.state #expect(state.warning == "Message tail cursor did not advance") #expect(!state.isSyncingTail) let requestCount = await provider.requests.count #expect(requestCount == 2) } // MARK: - Snapshots @Test func snapshotRoundTripRestoresInterruptedSendsAndFlagsStaleSnapshots() throws { let directory = FileManager.default.temporaryDirectory .appendingPathComponent("hapi-window-tests-\(UUID().uuidString)") defer { try? FileManager.default.removeItem(at: directory) } let snapshots = WindowSnapshotStore(directory: directory) let queuedSending = buildOptimisticMessage( localId: "local-1", text: "hello", createdAt: 1000, status: .sending ) let fresh = PersistedMessageWindow( messages: [queuedSending, WindowMessage(wire: agentRow(id: "a-1", seq: 1, at: 500))], hasMore: true, oldestPositionAt: 500, oldestPositionSeq: 1, newestPositionAt: 500, newestPositionSeq: 1, epoch: 3 ) snapshots.save(sessionId: "session-a", snapshot: fresh) let loaded = try #require(snapshots.load(sessionId: "session-a")) let hydrated = MessageWindowLogic.hydrate(sessionId: "session-a", persisted: loaded) // Interrupted `sending` on a queued row restores to `queued`. let restored = try #require(hydrated.messages.first(where: { $0.id == "local-1" })) #expect(restored.status == .queued) #expect(restored.hasExplicitNullInvokedAt, "tri-state null must survive the disk round-trip") #expect(hydrated.epoch == 3) #expect(!hydrated.requiresLatestReset) #expect(hydrated.newestPosition == MessagePosition(at: 500, seq: 1)) // A snapshot without a usable epoch hydrates flagged for a latest reset. var stale = fresh stale.epoch = nil snapshots.save(sessionId: "session-b", snapshot: stale) let staleLoaded = try #require(snapshots.load(sessionId: "session-b")) let staleHydrated = MessageWindowLogic.hydrate(sessionId: "session-b", persisted: staleLoaded) #expect(staleHydrated.requiresLatestReset) #expect(staleHydrated.epoch == nil) } @Test func snapshotsAreLRUCappedAtTenSessions() throws { let directory = FileManager.default.temporaryDirectory .appendingPathComponent("hapi-window-tests-\(UUID().uuidString)") defer { try? FileManager.default.removeItem(at: directory) } let snapshots = WindowSnapshotStore(directory: directory) for index in 0..<12 { snapshots.save( sessionId: "session-\(index)", snapshot: PersistedMessageWindow( messages: [WindowMessage(wire: agentRow(id: "a-\(index)", seq: 1, at: 1000))] ) ) } let files = try FileManager.default.contentsOfDirectory(atPath: directory.path) .filter { $0.hasSuffix(".window.json") } #expect(files.count == 10) } // MARK: - Seed @Test func seedFromCarriesRowsAndOlderCursorButForcesALatestReset() async { let provider = GatedMessagesProvider() let source = MessageWindowController(sessionId: "old-session", provider: provider) let seed = Task { await source.syncTail() } await provider.waitForRequests(1) await provider.release(latestPage( [agentRow(id: "a-1", seq: 1, at: 1000)], epoch: 5, hasMore: true )) await seed.value let target = MessageWindowController(sessionId: "new-session", provider: provider) await target.seedFrom(source) let state = await target.state #expect(state.sessionId == "new-session") #expect(state.messages.map(\.id) == ["a-1"]) #expect(state.hasMore) #expect(state.requiresLatestReset) #expect(state.epoch == nil) #expect(state.newestPosition == nil) #expect(state.oldestPosition == MessagePosition(at: 1000, seq: 1)) } } // MARK: - Gated transport /// Provider whose responses are released manually, so tests control the /// interleaving between transport round-trips and controller mutations. actor GatedMessagesProvider: MessagesProviding { private(set) var requests: [MessagesPageQuery] = [] private var buffered: [MessagesResponse] = [] private var pending: [CheckedContinuation] = [] private var requestWaiters: [(threshold: Int, continuation: CheckedContinuation)] = [] func messages(sessionId: String, query: MessagesPageQuery) async throws -> MessagesResponse { requests.append(query) let reached = requests.count var remaining: [(threshold: Int, continuation: CheckedContinuation)] = [] for waiter in requestWaiters { if waiter.threshold <= reached { waiter.continuation.resume() } else { remaining.append(waiter) } } requestWaiters = remaining if !buffered.isEmpty { return buffered.removeFirst() } return try await withCheckedThrowingContinuation { continuation in pending.append(continuation) } } func queuedState(sessionId: String, localIds: [String]) async throws -> QueuedStateResponse { QueuedStateResponse(queuedLocalIds: [], invokedLocalMessages: []) } /// Answer the oldest parked request, or buffer for the next one. func release(_ response: MessagesResponse) { if pending.isEmpty { buffered.append(response) } else { pending.removeFirst().resume(returning: response) } } func failPending(_ error: any Error) { pending.removeFirst().resume(throwing: error) } /// Suspends until at least `threshold` requests have been issued. func waitForRequests(_ threshold: Int) async { if requests.count >= threshold { return } await withCheckedContinuation { continuation in requestWaiters.append((threshold, continuation)) } } }