mirror of
https://github.com/wu736139669/hapi.git
synced 2026-10-12 20:07:31 +00:00
HapiProtocol/Window/ — pure port of web/src/lib/message-window-store.ts + messages.ts, mirroring the Android reference port 1:1: - MessageWindowState: constants (400/600/800/800/200), MessagePosition, OlderLoadOutcome, full InternalState fields, persisted v2 snapshot shape - MessageWindowLogic: every transition (merge/trim preserving queued rows, latest replace with request-baseline identity preservation, tail sync begin/apply/finish, older pages + epoch-mismatch reset, enterTailMode that deliberately keeps requiresLatestReset, activate, SSE ingest with hidden-row cursor advance, markConsumed stamping server rows to 'sent', optimistic lifecycle, queued reconcile, hydrate/persist, seededState) - MessageMerge: position comparator (ASCII tie-break), localId echo replacement preserving status/invokedAt, 10s sent-dedup fallback - MessageRetention: calls the fixtures-green chat pipeline's normalizeDecryptedMessage directly (no hand-mirrored tree to drift) - WindowMessage: identity-carrying final class (web's !== baseline classification), tri-state invokedAt, buildOptimisticMessage HapiClient/Stores/ — async half: - MessagesProviding seam (three-variant MessagesPageQuery; APIClient conforms via its existing endpoints) - MessageWindowController actor: single-flight tail controller with trailing drain and a synchronous generation bump before the first await (the web runs to its first suspension; Android used UNDISPATCHED), fetchOlder with onBeforeApply veto, SSE hooks, optimistic append/status/ cancel-invoked, queued-state reconciliation in 1000-id batches, seedFrom - WindowSnapshotStore: per-session JSON snapshots, LRU 10 (dedup TODO with the session-list package's cache) + MessageWindowControllers registry Tests (swift-testing): - PaginationFixtureTests: parameterized replay of shared/fixtures/ pagination/*.json against the real controller via a scripted provider; asserts expectedRequests (canonical JSON incl. explicit-null untils), expectedOutcome, expectedCandidates, and the final expectedState projection with per-op labels and first-differing-line diffs - MessageWindowControllerTests: tail-sync coalescing + trailing drain, concurrent-SSE preservation across a reset replace, cursor-no-advance guard, snapshot round-trip/LRU, seedFrom All 11 fixtures verified green against a line-by-line Python mirror of these exact algorithms (no local Swift toolchain; CI runs the real suite).
345 lines
13 KiB
Swift
345 lines
13 KiB
Swift
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 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<MessagesResponse, any Error>] = []
|
|
private var requestWaiters: [(threshold: Int, continuation: CheckedContinuation<Void, Never>)] = []
|
|
|
|
func messages(sessionId: String, query: MessagesPageQuery) async throws -> MessagesResponse {
|
|
requests.append(query)
|
|
let reached = requests.count
|
|
var remaining: [(threshold: Int, continuation: CheckedContinuation<Void, Never>)] = []
|
|
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)
|
|
}
|
|
}
|
|
|
|
/// 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))
|
|
}
|
|
}
|
|
}
|