mirror of
https://github.com/wu736139669/hapi.git
synced 2026-10-09 19:29:41 +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).
174 lines
7.4 KiB
Swift
174 lines
7.4 KiB
Swift
import Foundation
|
|
|
|
/// State machine model for one session's message window — the faithful port
|
|
/// of the web reference `web/src/lib/message-window-store.ts` (its
|
|
/// `InternalState`), per `docs/api/client-contract/pagination.md`. The pure
|
|
/// transitions over this state live in ``MessageWindowLogic``; the async
|
|
/// orchestration (`HapiClient` `MessageWindowController`) drives them.
|
|
/// Structure and semantics mirror the Android reference port
|
|
/// (`android/core/protocol/.../window/MessageWindowState.kt`) one-to-one.
|
|
|
|
/// Windowing constants — normative, from the web reference.
|
|
public enum MessageWindowConstants {
|
|
/// Max regular rows kept in tail mode (following the live bottom).
|
|
public static let visibleWindowSize = 400
|
|
/// Max regular rows kept in history mode (user scrolled back).
|
|
public static let historyWindowSize = 600
|
|
/// Temporary cap while an older page is being merged (prepend).
|
|
public static let olderLoadWindowSize = 800
|
|
/// Separate trim bucket for codex `agent-run-*` rows so background-agent
|
|
/// traces don't evict chat.
|
|
public static let agentRunWindowSize = 800
|
|
/// Request size for every page fetch.
|
|
public static let pageSize = 200
|
|
}
|
|
|
|
public enum MessageViewMode: String, Sendable {
|
|
/// Following the live bottom; trims from the top.
|
|
case tail
|
|
/// Scrolled back; trims from the bottom and may force a latest reset.
|
|
case history
|
|
}
|
|
|
|
/// Compound paging position `(at, seq)` where `at = invokedAt ?? createdAt` —
|
|
/// both halves always travel together (`pagination.md` "Position key").
|
|
public struct MessagePosition: Equatable, Comparable, Sendable {
|
|
public let at: Int
|
|
public let seq: Int
|
|
|
|
public init(at: Int, seq: Int) {
|
|
self.at = at
|
|
self.seq = seq
|
|
}
|
|
|
|
public static func < (lhs: MessagePosition, rhs: MessagePosition) -> Bool {
|
|
lhs.at != rhs.at ? lhs.at < rhs.at : lhs.seq < rhs.seq
|
|
}
|
|
}
|
|
|
|
/// Outcome of one older-page load (`fetchOlderMessages` in the web).
|
|
public enum OlderLoadOutcome: Sendable {
|
|
case applied(historyVersion: Int, hasMore: Bool, addedRenderableCount: Int)
|
|
case stopped(StopReason)
|
|
case failed(any Error)
|
|
|
|
public enum StopReason: String, Sendable {
|
|
case unavailable
|
|
case busy
|
|
case invalidated
|
|
case epochReset = "epoch-reset"
|
|
case exhausted
|
|
}
|
|
}
|
|
|
|
/// The full window state. Field-for-field port of the web `InternalState`
|
|
/// (public `MessageWindowState` + internal cursor/generation fields), with
|
|
/// the two `(at, seq)` half-pairs folded into nullable ``MessagePosition``s —
|
|
/// the web only ever reads/writes them pairwise (`readPosition`).
|
|
///
|
|
/// `Equatable` compares ``WindowMessage`` rows by **identity** (see that
|
|
/// type) — deliberately mirroring the web's instance-based change detection,
|
|
/// not deep equality.
|
|
public struct MessageWindowState: Equatable, Sendable {
|
|
public let sessionId: String
|
|
/// Window rows in position order (queued rows re-merged after each trim).
|
|
public var messages: [WindowMessage] = []
|
|
/// Older history exists (server flag, or rows were trimmed away).
|
|
public var hasMore: Bool = false
|
|
/// min/max `seq` over ``messages`` — derived, kept for UI parity.
|
|
public var oldestSeq: Int?
|
|
public var newestSeq: Int?
|
|
/// Cached server epoch; nil until the first page (or after invalidation).
|
|
public var epoch: Int?
|
|
public var isSyncingTail: Bool = false
|
|
public var isLoadingMore: Bool = false
|
|
public var warning: String?
|
|
public var viewMode: MessageViewMode = .tail
|
|
/// Bumped whenever the ``messages`` list instance changes.
|
|
public var messagesVersion: Int = 0
|
|
/// Bumped per applied older page (scroll-anchoring handle).
|
|
public var historyVersion: Int = 0
|
|
/// Bumped on tail-side content changes (auto-scroll handle).
|
|
public var tailRevision: Int = 0
|
|
/// Next `before` request position (web `oldestPositionAt/Seq`).
|
|
public var oldestPosition: MessagePosition?
|
|
/// Next `after` request position (web `newestPositionAt/Seq`).
|
|
public var newestPosition: MessagePosition?
|
|
/// Cursors are unusable — the next tail sync must fetch a fresh latest page.
|
|
public var requiresLatestReset: Bool = false
|
|
/// Re-activation with persisted state: fetch the current tail first.
|
|
public var preferLatestOnActivation: Bool = false
|
|
/// Invalidates in-flight tail syncs (compare-and-ignore).
|
|
public var syncGeneration: Int = 0
|
|
/// Invalidates in-flight older-page loads.
|
|
public var olderGeneration: Int = 0
|
|
|
|
public init(sessionId: String) {
|
|
self.sessionId = sessionId
|
|
}
|
|
}
|
|
|
|
/// Snapshot shape persisted per session (web `PersistedMessageWindowState`,
|
|
/// storage key `hapi:message-window:v2:`): messages + cursors + epoch only —
|
|
/// transient flags and counters never survive a restart.
|
|
///
|
|
/// Encoding writes every key (`null` for empty cursor halves), matching the
|
|
/// web's v2 JSON shape; decoding is lenient (absent keys fall back to the
|
|
/// defaults) so an older snapshot never throws.
|
|
public struct PersistedMessageWindow: Codable, Sendable {
|
|
public var messages: [WindowMessage] = []
|
|
public var hasMore: Bool = false
|
|
public var oldestPositionAt: Int?
|
|
public var oldestPositionSeq: Int?
|
|
public var newestPositionAt: Int?
|
|
public var newestPositionSeq: Int?
|
|
public var epoch: Int?
|
|
|
|
public init(
|
|
messages: [WindowMessage] = [],
|
|
hasMore: Bool = false,
|
|
oldestPositionAt: Int? = nil,
|
|
oldestPositionSeq: Int? = nil,
|
|
newestPositionAt: Int? = nil,
|
|
newestPositionSeq: Int? = nil,
|
|
epoch: Int? = nil
|
|
) {
|
|
self.messages = messages
|
|
self.hasMore = hasMore
|
|
self.oldestPositionAt = oldestPositionAt
|
|
self.oldestPositionSeq = oldestPositionSeq
|
|
self.newestPositionAt = newestPositionAt
|
|
self.newestPositionSeq = newestPositionSeq
|
|
self.epoch = epoch
|
|
}
|
|
|
|
private enum CodingKeys: String, CodingKey {
|
|
case messages, hasMore
|
|
case oldestPositionAt, oldestPositionSeq
|
|
case newestPositionAt, newestPositionSeq
|
|
case epoch
|
|
}
|
|
|
|
public init(from decoder: Decoder) throws {
|
|
let container = try decoder.container(keyedBy: CodingKeys.self)
|
|
messages = try container.decodeIfPresent([WindowMessage].self, forKey: .messages) ?? []
|
|
hasMore = try container.decodeIfPresent(Bool.self, forKey: .hasMore) ?? false
|
|
oldestPositionAt = try container.decodeIfPresent(Int.self, forKey: .oldestPositionAt)
|
|
oldestPositionSeq = try container.decodeIfPresent(Int.self, forKey: .oldestPositionSeq)
|
|
newestPositionAt = try container.decodeIfPresent(Int.self, forKey: .newestPositionAt)
|
|
newestPositionSeq = try container.decodeIfPresent(Int.self, forKey: .newestPositionSeq)
|
|
epoch = try container.decodeIfPresent(Int.self, forKey: .epoch)
|
|
}
|
|
|
|
public func encode(to encoder: Encoder) throws {
|
|
var container = encoder.container(keyedBy: CodingKeys.self)
|
|
try container.encode(messages, forKey: .messages)
|
|
try container.encode(hasMore, forKey: .hasMore)
|
|
try container.encode(oldestPositionAt, forKey: .oldestPositionAt)
|
|
try container.encode(oldestPositionSeq, forKey: .oldestPositionSeq)
|
|
try container.encode(newestPositionAt, forKey: .newestPositionAt)
|
|
try container.encode(newestPositionSeq, forKey: .newestPositionSeq)
|
|
try container.encode(epoch, forKey: .epoch)
|
|
}
|
|
}
|