mirror of
https://github.com/wu736139669/hapi.git
synced 2026-10-08 19:19:42 +00:00
* fix(acp): carry the live reasoning marker on the wire payload ACP agents stream thoughts a token at a time, so the handler coalesces them into a buffer and re-sends the whole buffer under a stable stream id every 250ms. The converter dropped the marker that says a payload is one of those throttled snapshots, leaving the hub unable to tell a replaceable snapshot from the settled message that closes the stream. Mirrors how the text variant already forwards streamSnapshot. * fix(hub): keep one stored message per reasoning stream OpenCode reasoning arrives as a series of growing snapshots sharing one stream id, and every snapshot was persisted as its own message. A 26h session reached 48,844 rows and 63MB, and because the web budgets a fixed number of messages, its 400-message window covered barely three minutes of conversation — scrolling up walked through duplicate snapshots instead of history. Retire a stream's earlier live snapshots once their replacement is stored. Sweeping only after the insert matters: the two statements are separate transactions, so clearing first would leave a window where a crash takes the whole stream. Only rows marked live are eligible and the replacement is spared, so a stream always keeps at least one row and the settled message that closes it is never removed. Live rendering is unchanged: the web still receives every snapshot and already folds them by stream id. * fix(web): spend the message window on conversation, not repeated snapshots The window budgets raw messages, but a reasoning stream renders as a single folded block no matter how many snapshots it arrived in. On sessions recorded before the hub started retiring them, those snapshots fill the window on their own: in one 26h session the newest 400 messages covered 202 seconds, so scrolling up paged through duplicates instead of history. Collapse each stream to its newest snapshot before trimming. Rendering is unchanged — the timeline already folds them by stream id — and rows without a stream id are never touched. * fix(ios,android): port reasoning-snapshot compaction to the native windows The window logic in HapiProtocol and :core:protocol is a one-to-one port of the web store, so collapsing superseded reasoning snapshots only on the web left the native windows budgeting raw snapshot rows. The hub stores one row per stream now, but a client that already holds the older snapshots still spends its window on them. Add the same stream-id reader and compaction to both ports, in the shape each already uses for agent-run rows, and pin the behaviour with a pagination fixture. Both fixture suites enumerate shared/fixtures/pagination from disk, so the ports cannot drift from the web again without CI saying so.
815 lines
33 KiB
Swift
815 lines
33 KiB
Swift
import Foundation
|
|
|
|
/// Pure state transitions of the message window — a function-for-function
|
|
/// port of `web/src/lib/message-window-store.ts` (with the async
|
|
/// orchestration stripped out; `HapiClient`'s `MessageWindowController`
|
|
/// re-adds it), matching the Android reference port
|
|
/// (`window/MessageWindowLogic.kt`) one-to-one. Every function takes the
|
|
/// previous ``MessageWindowState`` and returns the next one; nothing here
|
|
/// touches clocks, I/O, or tasks, so the whole surface is fixture-comparable.
|
|
public enum MessageWindowLogic {
|
|
|
|
public enum TrimMode: Sendable {
|
|
case append
|
|
case prepend
|
|
}
|
|
|
|
public static func createState(sessionId: String) -> MessageWindowState {
|
|
MessageWindowState(sessionId: sessionId)
|
|
}
|
|
|
|
// MARK: - Helpers
|
|
|
|
/// Web `buildState`: swap the messages list, re-derive seq bounds, bump
|
|
/// the version. Every call site passes a freshly-built array (the web
|
|
/// counterpart always builds a new array instance too), so the version
|
|
/// bumps unconditionally.
|
|
private static func settingMessages(
|
|
_ previous: MessageWindowState,
|
|
_ messages: [WindowMessage]
|
|
) -> MessageWindowState {
|
|
var oldestSeq: Int?
|
|
var newestSeq: Int?
|
|
for message in messages {
|
|
guard let seq = message.seq else { continue }
|
|
oldestSeq = oldestSeq.map { min($0, seq) } ?? seq
|
|
newestSeq = newestSeq.map { max($0, seq) } ?? seq
|
|
}
|
|
var next = previous
|
|
next.messages = messages
|
|
next.oldestSeq = oldestSeq
|
|
next.newestSeq = newestSeq
|
|
next.messagesVersion = previous.messagesVersion + 1
|
|
return next
|
|
}
|
|
|
|
/// A row's compound position; rows without a server `seq` have none.
|
|
public static func messagePosition(_ message: WindowMessage) -> MessagePosition? {
|
|
guard let seq = message.seq else { return nil }
|
|
return MessagePosition(at: message.positionAt, seq: seq)
|
|
}
|
|
|
|
public enum PositionEnd: Sendable {
|
|
case oldest
|
|
case newest
|
|
}
|
|
|
|
public static func derivePosition(
|
|
_ messages: [WindowMessage],
|
|
_ end: PositionEnd
|
|
) -> MessagePosition? {
|
|
var selected: MessagePosition?
|
|
for message in messages {
|
|
guard let candidate = messagePosition(message) else { continue }
|
|
guard let current = selected else {
|
|
selected = candidate
|
|
continue
|
|
}
|
|
if (end == .oldest && candidate < current) || (end == .newest && candidate > current) {
|
|
selected = candidate
|
|
}
|
|
}
|
|
return selected
|
|
}
|
|
|
|
/// Pairwise page cursor (`pagePosition` in the web): both halves or nothing.
|
|
public static func pagePosition(at: Int?, seq: Int?) -> MessagePosition? {
|
|
guard let at, let seq else { return nil }
|
|
return MessagePosition(at: at, seq: seq)
|
|
}
|
|
|
|
private static func maxPosition(_ a: MessagePosition?, _ b: MessagePosition?) -> MessagePosition? {
|
|
switch (a, b) {
|
|
case (nil, let b): return b
|
|
case (let a, nil): return a
|
|
case (let a?, let b?): return a >= b ? a : b
|
|
}
|
|
}
|
|
|
|
// MARK: - Trims
|
|
|
|
private struct Trim {
|
|
let kept: [WindowMessage]
|
|
let dropped: [WindowMessage]
|
|
}
|
|
|
|
private static func sliceForTrim(
|
|
_ items: [WindowMessage],
|
|
limit: Int,
|
|
mode: TrimMode
|
|
) -> Trim {
|
|
if items.count <= limit { return Trim(kept: items, dropped: []) }
|
|
if limit <= 0 { return Trim(kept: [], dropped: items) }
|
|
switch mode {
|
|
case .prepend:
|
|
return Trim(
|
|
kept: Array(items[..<limit]),
|
|
dropped: Array(items[limit...])
|
|
)
|
|
case .append:
|
|
return Trim(
|
|
kept: Array(items[(items.count - limit)...]),
|
|
dropped: Array(items[..<(items.count - limit)])
|
|
)
|
|
}
|
|
}
|
|
|
|
/// Codex background-agent trace rows (`agent-run-*`) get their own trim
|
|
/// bucket so long traces do not evict chat (web `isCodexAgentRunMessage`).
|
|
public static func isCodexAgentRunMessage(_ message: WindowMessage) -> Bool {
|
|
guard let outer = message.content.objectValue,
|
|
outer["role"]?.stringValue == "agent",
|
|
let payload = outer["content"]?.objectValue,
|
|
payload["type"]?.stringValue == "codex",
|
|
let data = payload["data"]?.objectValue
|
|
else { return false }
|
|
let type = data["type"]?.stringValue
|
|
return type == "agent-run-start" || type == "agent-run-update" || type == "agent-run-trace"
|
|
}
|
|
|
|
/// Web `getReasoningStreamId`: the stream a reasoning row belongs to, or
|
|
/// `nil` for anything else. Unrecognised shapes read as `nil`, which means
|
|
/// "keep it".
|
|
private static func reasoningStreamId(_ message: WindowMessage) -> String? {
|
|
guard let outer = message.content.objectValue,
|
|
outer["role"]?.stringValue == "agent",
|
|
let payload = outer["content"]?.objectValue,
|
|
payload["type"]?.stringValue == "codex",
|
|
let data = payload["data"]?.objectValue,
|
|
data["type"]?.stringValue == "reasoning",
|
|
let id = data["id"]?.stringValue,
|
|
!id.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
|
else { return nil }
|
|
return id
|
|
}
|
|
|
|
/// Web `dropSupersededReasoningSnapshots`: collapse a reasoning stream to
|
|
/// the one snapshot that still says something. The CLI re-sends a growing
|
|
/// buffer under a stable stream id every few hundred milliseconds and the
|
|
/// timeline folds those rows into a single block, so spending window
|
|
/// budget on the older ones is what pushes the surrounding conversation
|
|
/// out of reach. Rows with no stream id are left alone.
|
|
private static func dropSupersededReasoningSnapshots(
|
|
_ messages: [WindowMessage]
|
|
) -> [WindowMessage] {
|
|
var newestByStream: [String: WindowMessage] = [:]
|
|
for message in messages {
|
|
guard let streamId = reasoningStreamId(message) else { continue }
|
|
guard let incumbent = newestByStream[streamId] else {
|
|
newestByStream[streamId] = message
|
|
continue
|
|
}
|
|
// Fall back to arrival order when either row predates seq
|
|
// numbering: `messages` is kept in display order, so later still
|
|
// means newer.
|
|
let newer: Bool
|
|
if let challengerAt = messagePosition(message),
|
|
let incumbentAt = messagePosition(incumbent) {
|
|
newer = !(challengerAt < incumbentAt)
|
|
} else {
|
|
newer = true
|
|
}
|
|
if newer { newestByStream[streamId] = message }
|
|
}
|
|
if newestByStream.isEmpty { return messages }
|
|
let survivors = Set(newestByStream.values.map(\.id))
|
|
return messages.filter { reasoningStreamId($0) == nil || survivors.contains($0.id) }
|
|
}
|
|
|
|
/// Trim to `regularLimit` while never dropping queued rows: superseded
|
|
/// reasoning snapshots are collapsed first, the regular budget shrinks by
|
|
/// the queued count, `agent-run-*` rows trim against their own bucket
|
|
/// (`agentRunWindowSize`), and queued rows are re-merged afterwards (web
|
|
/// `trimPreservingQueued`).
|
|
private static func trimPreservingQueued(
|
|
_ incoming: [WindowMessage],
|
|
regularLimit: Int,
|
|
mode: TrimMode
|
|
) -> Trim {
|
|
let messages = dropSupersededReasoningSnapshots(incoming)
|
|
let queued = messages.filter(\.isQueuedForInvocation)
|
|
let queuedIds = Set(queued.map(\.id))
|
|
let nonQueued = messages.filter { !queuedIds.contains($0.id) }
|
|
let agentRuns = nonQueued.filter { isCodexAgentRunMessage($0) }
|
|
let regular = nonQueued.filter { !isCodexAgentRunMessage($0) }
|
|
let regularTrim = sliceForTrim(regular, limit: max(0, regularLimit - queued.count), mode: mode)
|
|
let agentRunTrim = sliceForTrim(
|
|
agentRuns,
|
|
limit: MessageWindowConstants.agentRunWindowSize,
|
|
mode: mode
|
|
)
|
|
return Trim(
|
|
kept: MessageMerge.mergeMessages(regularTrim.kept + agentRunTrim.kept, queued),
|
|
dropped: regularTrim.dropped + agentRunTrim.dropped
|
|
)
|
|
}
|
|
|
|
// MARK: - Retention
|
|
|
|
/// Web `shouldRetainWindowMessage`: queued, or renderable by the chat
|
|
/// pipeline.
|
|
public static func shouldRetainWindowMessage(_ message: WindowMessage) -> Bool {
|
|
message.isQueuedForInvocation || MessageRetention.isRenderable(message.content)
|
|
}
|
|
|
|
/// How many of `incoming` would add a NEW renderable row (not already
|
|
/// represented by id or localId) — feeds the `applied` older-load outcome.
|
|
public static func countNewRenderableMessages(
|
|
_ previous: MessageWindowState,
|
|
incoming: [WindowMessage]
|
|
) -> Int {
|
|
var representedIds = Set(previous.messages.map(\.id))
|
|
var representedLocalIds = Set(previous.messages.compactMap(\.localId))
|
|
var count = 0
|
|
for message in incoming {
|
|
guard shouldRetainWindowMessage(message) else { continue }
|
|
if representedIds.contains(message.id) { continue }
|
|
if let localId = message.localId, representedLocalIds.contains(localId) { continue }
|
|
count += 1
|
|
representedIds.insert(message.id)
|
|
if let localId = message.localId { representedLocalIds.insert(localId) }
|
|
}
|
|
return count
|
|
}
|
|
|
|
// MARK: - Merge
|
|
|
|
/// Merge `incoming` into the window, trim per mode/limit, and repair
|
|
/// cursors/flags on overflow (web `mergeIntoWindow`): an append-side trim
|
|
/// flips `hasMore` and recomputes the older cursor from the oldest kept
|
|
/// row; a prepend-side trim drops tail rows, so the window no longer
|
|
/// reaches the live bottom — flag `requiresLatestReset` and pull the
|
|
/// newest cursor back to the newest kept row.
|
|
public static func mergeIntoWindow(
|
|
_ previous: MessageWindowState,
|
|
incoming: [WindowMessage],
|
|
mode: TrimMode? = nil,
|
|
regularLimit: Int? = nil,
|
|
advanceTailRevision: Bool = false
|
|
) -> MessageWindowState {
|
|
let retainedIncoming = incoming.filter { shouldRetainWindowMessage($0) }
|
|
if retainedIncoming.isEmpty {
|
|
return previous
|
|
}
|
|
let effectiveMode = mode
|
|
?? (previous.viewMode == .history ? .prepend : .append)
|
|
let effectiveLimit = regularLimit
|
|
?? (previous.viewMode == .history
|
|
? MessageWindowConstants.historyWindowSize
|
|
: MessageWindowConstants.visibleWindowSize)
|
|
let merged = MessageMerge.mergeMessages(previous.messages, retainedIncoming)
|
|
let trim = trimPreservingQueued(merged, regularLimit: effectiveLimit, mode: effectiveMode)
|
|
var next = settingMessages(previous, trim.kept)
|
|
if advanceTailRevision {
|
|
next.tailRevision = previous.tailRevision + 1
|
|
}
|
|
if trim.dropped.isEmpty {
|
|
return next
|
|
}
|
|
if effectiveMode == .append {
|
|
let oldest = derivePosition(trim.kept, .oldest)
|
|
next.hasMore = true
|
|
next.oldestPosition = oldest ?? next.oldestPosition
|
|
return next
|
|
}
|
|
next.requiresLatestReset = true
|
|
next.newestPosition = derivePosition(trim.kept, .newest)
|
|
return next
|
|
}
|
|
|
|
// MARK: - Latest replace
|
|
|
|
/// Apply a `latest` page (cold start, activation refresh, or a reset
|
|
/// response). With `replaceServerRows` the page is authoritative: every
|
|
/// server row captured in `requestBaseline` (by **instance identity**) is
|
|
/// discarded, while optimistic rows and rows that changed since the
|
|
/// request left (concurrent SSE) survive the swap (web
|
|
/// `applyLatestResponse`).
|
|
public static func applyLatestResponse(
|
|
_ previous: MessageWindowState,
|
|
responseMessages: [WindowMessage],
|
|
page: MessagesPage,
|
|
replaceServerRows: Bool,
|
|
requestBaseline: [String: WindowMessage]
|
|
) -> MessageWindowState {
|
|
let retainedResponseMessages = responseMessages.filter { shouldRetainWindowMessage($0) }
|
|
let concurrentServerRows = previous.messages.filter { message in
|
|
!message.isOptimistic && requestBaseline[message.id] !== message
|
|
}
|
|
let preserved = replaceServerRows
|
|
? previous.messages.filter { message in
|
|
message.isOptimistic || requestBaseline[message.id] !== message
|
|
}
|
|
: previous.messages
|
|
let authoritative = MessageMerge.mergeMessages(preserved, retainedResponseMessages)
|
|
let incoming = MessageMerge.mergeMessages(authoritative, concurrentServerRows)
|
|
let trim = trimPreservingQueued(
|
|
incoming,
|
|
regularLimit: MessageWindowConstants.visibleWindowSize,
|
|
mode: .append
|
|
)
|
|
let snapshotHead = pagePosition(at: page.snapshotHeadAt, seq: page.snapshotHeadSeq)
|
|
?? derivePosition(responseMessages, .newest)
|
|
let newestKept = derivePosition(trim.kept, .newest)
|
|
let newest = maxPosition(snapshotHead, newestKept)
|
|
let responseOldest = pagePosition(at: page.nextBeforeAt, seq: page.nextBeforeSeq)
|
|
let oldest: MessagePosition?
|
|
if !trim.dropped.isEmpty {
|
|
oldest = derivePosition(trim.kept, .oldest)
|
|
} else if replaceServerRows {
|
|
oldest = responseOldest
|
|
} else {
|
|
oldest = responseOldest ?? previous.oldestPosition
|
|
}
|
|
var next = settingMessages(previous, trim.kept)
|
|
next.hasMore = page.hasMore || (!replaceServerRows && previous.hasMore) || !trim.dropped.isEmpty
|
|
next.epoch = page.epoch
|
|
next.oldestPosition = oldest
|
|
next.newestPosition = newest
|
|
next.tailRevision = previous.tailRevision + 1
|
|
next.requiresLatestReset = false
|
|
next.isLoadingMore = replaceServerRows ? false : previous.isLoadingMore
|
|
next.olderGeneration = replaceServerRows ? previous.olderGeneration + 1 : previous.olderGeneration
|
|
next.warning = nil
|
|
return next
|
|
}
|
|
|
|
// MARK: - Tail sync
|
|
|
|
/// Web `beginTailSync`: claim a new sync generation. The older generation
|
|
/// bumps too — an older-page response captured before this point must not
|
|
/// commit while the tail request is in flight, or a reset can mistake it
|
|
/// for concurrent SSE.
|
|
public static func beginTailSync(_ previous: MessageWindowState) -> MessageWindowState {
|
|
var next = previous
|
|
next.syncGeneration = previous.syncGeneration + 1
|
|
next.olderGeneration = previous.olderGeneration + 1
|
|
next.isSyncingTail = true
|
|
next.isLoadingMore = false
|
|
next.warning = nil
|
|
return next
|
|
}
|
|
|
|
public static func finishTailSync(
|
|
_ previous: MessageWindowState,
|
|
generation: Int,
|
|
warning: String?
|
|
) -> MessageWindowState {
|
|
guard previous.syncGeneration == generation else { return previous }
|
|
var next = previous
|
|
next.isSyncingTail = false
|
|
next.warning = warning
|
|
return next
|
|
}
|
|
|
|
/// Loop body of the after-cursor catch-up (web `runTailSync` inner
|
|
/// updater): merge the page, adopt its epoch, and advance the newest
|
|
/// cursor to `max(current, nextAfter)` — unless a prepend-side trim just
|
|
/// flagged a latest reset, in which case the cursor stays pulled back.
|
|
public static func applyAfterPage(
|
|
_ previous: MessageWindowState,
|
|
responseMessages: [WindowMessage],
|
|
page: MessagesPage,
|
|
nextAfter: MessagePosition?
|
|
) -> MessageWindowState {
|
|
var merged = mergeIntoWindow(previous, incoming: responseMessages, advanceTailRevision: true)
|
|
if merged.requiresLatestReset {
|
|
merged.epoch = page.epoch
|
|
merged.warning = nil
|
|
return merged
|
|
}
|
|
merged.epoch = page.epoch
|
|
merged.newestPosition = maxPosition(nextAfter, merged.newestPosition)
|
|
merged.warning = nil
|
|
return merged
|
|
}
|
|
|
|
// MARK: - View mode
|
|
|
|
/// Re-enter tail mode (web `enterTailMode`): trim to the visible window;
|
|
/// when a history overflow flagged `requiresLatestReset`, drop epoch and
|
|
/// newest cursor so the next tail sync fetches a fresh latest page.
|
|
/// The flag itself is deliberately NOT cleared here (web quirk, pinned) —
|
|
/// `applyLatestResponse` clears it when the fresh page lands.
|
|
public static func enterTailMode(_ previous: MessageWindowState) -> MessageWindowState {
|
|
let trim = trimPreservingQueued(
|
|
previous.messages,
|
|
regularLimit: MessageWindowConstants.visibleWindowSize,
|
|
mode: .append
|
|
)
|
|
let forceLatest = previous.requiresLatestReset
|
|
let oldest = !trim.dropped.isEmpty
|
|
? derivePosition(trim.kept, .oldest)
|
|
: previous.oldestPosition
|
|
var next = settingMessages(previous, trim.kept)
|
|
next.hasMore = previous.hasMore || !trim.dropped.isEmpty
|
|
next.viewMode = .tail
|
|
next.epoch = forceLatest ? nil : previous.epoch
|
|
next.oldestPosition = oldest
|
|
next.newestPosition = forceLatest ? nil : previous.newestPosition
|
|
return next
|
|
}
|
|
|
|
public static func setViewMode(
|
|
_ previous: MessageWindowState,
|
|
mode: MessageViewMode
|
|
) -> MessageWindowState {
|
|
if previous.viewMode == mode { return previous }
|
|
if mode == .history {
|
|
var next = previous
|
|
next.viewMode = .history
|
|
return next
|
|
}
|
|
return enterTailMode(previous)
|
|
}
|
|
|
|
public struct Activation {
|
|
public let state: MessageWindowState
|
|
public let requestedLatest: Bool
|
|
}
|
|
|
|
/// Session (re-)activation (web `activateMessageWindow`): trim back to
|
|
/// the visible window, and when a usable persisted cursor exists, flag
|
|
/// `preferLatestOnActivation` — another client may have advanced the
|
|
/// session by many pages, so the next tail sync fetches the current tail
|
|
/// first and reconciles it through the reset-preservation path.
|
|
public static func activate(_ previous: MessageWindowState) -> Activation {
|
|
let trim = trimPreservingQueued(
|
|
previous.messages,
|
|
regularLimit: MessageWindowConstants.visibleWindowSize,
|
|
mode: .append
|
|
)
|
|
let forceLatest = previous.requiresLatestReset
|
|
let hasUsableCursor = previous.newestPosition != nil && previous.epoch != nil && !forceLatest
|
|
let preferLatestOnActivation = hasUsableCursor && !trim.kept.isEmpty
|
|
let requestedLatest = preferLatestOnActivation && !previous.preferLatestOnActivation
|
|
let invalidateRunningSync = requestedLatest && previous.isSyncingTail
|
|
|
|
func withActivationUpdates(_ state: MessageWindowState) -> MessageWindowState {
|
|
guard preferLatestOnActivation else { return state }
|
|
var flagged = state
|
|
flagged.preferLatestOnActivation = true
|
|
if invalidateRunningSync {
|
|
flagged.syncGeneration += 1
|
|
flagged.olderGeneration += 1
|
|
}
|
|
return flagged
|
|
}
|
|
|
|
if previous.viewMode == .tail
|
|
&& trim.kept.count == previous.messages.count
|
|
&& !forceLatest {
|
|
let state = preferLatestOnActivation ? withActivationUpdates(previous) : previous
|
|
return Activation(state: state, requestedLatest: requestedLatest)
|
|
}
|
|
let next = enterTailMode(previous)
|
|
let state = preferLatestOnActivation ? withActivationUpdates(next) : next
|
|
return Activation(state: state, requestedLatest: requestedLatest)
|
|
}
|
|
|
|
// MARK: - Older pages
|
|
|
|
public enum OlderPrecheck {
|
|
case proceed(before: MessagePosition)
|
|
case stop(OlderLoadOutcome.StopReason)
|
|
}
|
|
|
|
public static func olderLoadPrecheck(_ state: MessageWindowState) -> OlderPrecheck {
|
|
let before = state.oldestPosition
|
|
if state.isSyncingTail || state.isLoadingMore {
|
|
return .stop(.busy)
|
|
}
|
|
if !state.hasMore {
|
|
return .stop(.exhausted)
|
|
}
|
|
guard let before else {
|
|
return .stop(.unavailable)
|
|
}
|
|
return .proceed(before: before)
|
|
}
|
|
|
|
public static func beginOlderLoad(
|
|
_ previous: MessageWindowState,
|
|
generation: Int
|
|
) -> MessageWindowState {
|
|
var next = previous
|
|
next.olderGeneration = generation
|
|
next.isLoadingMore = true
|
|
next.warning = nil
|
|
return next
|
|
}
|
|
|
|
/// An older page answered with a different epoch: every cursor is
|
|
/// meaningless. Drop epoch + newest cursor and flag the latest reset;
|
|
/// the caller then runs a tail sync and reports `stopped/epoch-reset`.
|
|
public static func applyOlderEpochMismatch(
|
|
_ previous: MessageWindowState,
|
|
generation: Int
|
|
) -> MessageWindowState {
|
|
guard previous.olderGeneration == generation else { return previous }
|
|
var next = previous
|
|
next.isLoadingMore = false
|
|
next.epoch = nil
|
|
next.newestPosition = nil
|
|
next.requiresLatestReset = true
|
|
return next
|
|
}
|
|
|
|
/// The `onBeforeApply` veto path: invalidate this load, keep the window.
|
|
public static func rejectOlderApply(_ previous: MessageWindowState) -> MessageWindowState {
|
|
var next = previous
|
|
next.olderGeneration = previous.olderGeneration + 1
|
|
next.isLoadingMore = false
|
|
next.warning = nil
|
|
return next
|
|
}
|
|
|
|
public static func applyOlderResponse(
|
|
_ previous: MessageWindowState,
|
|
responseMessages: [WindowMessage],
|
|
page: MessagesPage,
|
|
historyVersion: Int
|
|
) -> MessageWindowState {
|
|
var merged = mergeIntoWindow(
|
|
previous,
|
|
incoming: responseMessages,
|
|
mode: .prepend,
|
|
regularLimit: MessageWindowConstants.olderLoadWindowSize
|
|
)
|
|
merged.hasMore = page.hasMore
|
|
merged.epoch = page.epoch
|
|
merged.oldestPosition = pagePosition(at: page.nextBeforeAt, seq: page.nextBeforeSeq)
|
|
merged.isLoadingMore = false
|
|
merged.historyVersion = historyVersion
|
|
merged.warning = nil
|
|
return merged
|
|
}
|
|
|
|
public static func failOlderLoad(
|
|
_ previous: MessageWindowState,
|
|
generation: Int,
|
|
warning: String
|
|
) -> MessageWindowState {
|
|
guard previous.olderGeneration == generation else { return previous }
|
|
var next = previous
|
|
next.isLoadingMore = false
|
|
next.warning = warning
|
|
return next
|
|
}
|
|
|
|
public static func cancelOlderLoad(_ previous: MessageWindowState) -> MessageWindowState {
|
|
guard previous.isLoadingMore else { return previous }
|
|
var next = previous
|
|
next.olderGeneration = previous.olderGeneration + 1
|
|
next.isLoadingMore = false
|
|
next.warning = nil
|
|
return next
|
|
}
|
|
|
|
// MARK: - Live rows
|
|
|
|
/// SSE `message-received` ingest (web `ingestIncomingMessages`): merge,
|
|
/// then — when an epoch is cached and no reset is pending — advance the
|
|
/// newest cursor past the incoming rows' positions, **including rows the
|
|
/// pipeline hides**, so a later tail sync does not refetch them.
|
|
public static func ingestIncoming(
|
|
_ previous: MessageWindowState,
|
|
incoming: [WindowMessage]
|
|
) -> MessageWindowState {
|
|
if incoming.isEmpty { return previous }
|
|
var merged = mergeIntoWindow(previous, incoming: incoming, advanceTailRevision: true)
|
|
if merged.epoch == nil || merged.requiresLatestReset {
|
|
return merged
|
|
}
|
|
let incomingNewest = derivePosition(incoming, .newest)
|
|
let currentNewest = merged.newestPosition
|
|
if let incomingNewest, currentNewest == nil || incomingNewest > currentNewest! {
|
|
merged.newestPosition = incomingNewest
|
|
}
|
|
return merged
|
|
}
|
|
|
|
/// Optimistic append (web `appendOptimisticMessage`): never advances the
|
|
/// newest cursor.
|
|
public static func appendOptimistic(
|
|
_ previous: MessageWindowState,
|
|
message: WindowMessage
|
|
) -> MessageWindowState {
|
|
mergeIntoWindow(
|
|
previous,
|
|
incoming: [message],
|
|
mode: previous.viewMode == .history ? .prepend : .append,
|
|
advanceTailRevision: true
|
|
)
|
|
}
|
|
|
|
public static func updateStatus(
|
|
_ previous: MessageWindowState,
|
|
localId: String,
|
|
status: MessageStatus
|
|
) -> MessageWindowState {
|
|
if localId.isEmpty { return previous }
|
|
var changed = false
|
|
let messages = previous.messages.map { message -> WindowMessage in
|
|
if message.localId != localId || message.status == status {
|
|
return message
|
|
}
|
|
changed = true
|
|
return message.withStatus(status)
|
|
}
|
|
return changed ? settingMessages(previous, messages) : previous
|
|
}
|
|
|
|
public static func markIndeterminate(
|
|
_ previous: MessageWindowState,
|
|
localIds: [String]
|
|
) -> MessageWindowState {
|
|
if localIds.isEmpty { return previous }
|
|
let idSet = Set(localIds)
|
|
var changed = false
|
|
let messages = previous.messages.map { message -> WindowMessage in
|
|
guard let localId = message.localId, idSet.contains(localId), message.status != .indeterminate else {
|
|
return message
|
|
}
|
|
changed = true
|
|
return message.withStatus(.indeterminate)
|
|
}
|
|
return changed ? settingMessages(previous, messages) : previous
|
|
}
|
|
|
|
public static func markRequeued(
|
|
_ previous: MessageWindowState,
|
|
localIds: [String]
|
|
) -> MessageWindowState {
|
|
if localIds.isEmpty { return previous }
|
|
let idSet = Set(localIds)
|
|
var changed = false
|
|
let messages = previous.messages.map { message -> WindowMessage in
|
|
guard let localId = message.localId, idSet.contains(localId), message.status == .indeterminate else {
|
|
return message
|
|
}
|
|
changed = true
|
|
return message.withStatus(.queued)
|
|
}
|
|
return changed ? settingMessages(previous, messages) : previous
|
|
}
|
|
|
|
/// `message-cancelled` / optimistic DELETE removal: matches localId OR
|
|
/// id; idempotent.
|
|
public static func removeByLocalIdOrId(
|
|
_ previous: MessageWindowState,
|
|
localId: String
|
|
) -> MessageWindowState {
|
|
if localId.isEmpty { return previous }
|
|
let messages = previous.messages.filter { $0.localId != localId && $0.id != localId }
|
|
return messages.count == previous.messages.count
|
|
? previous
|
|
: settingMessages(previous, messages)
|
|
}
|
|
|
|
/// SSE `messages-consumed` (web `markMessagesConsumed`): stamp
|
|
/// `invokedAt` and flip status to `sent` on matching rows — including
|
|
/// server rows without a client status (web quirk, pinned) — skip
|
|
/// `failed`, then re-sort: the row moves from its enqueue position to its
|
|
/// invocation position. Never advances the newest cursor.
|
|
public static func markConsumed(
|
|
_ previous: MessageWindowState,
|
|
localIds: [String],
|
|
invokedAt: Int
|
|
) -> MessageWindowState {
|
|
if localIds.isEmpty { return previous }
|
|
let idSet = Set(localIds)
|
|
var changed = false
|
|
let updated = previous.messages.map { message -> WindowMessage in
|
|
guard let localId = message.localId, idSet.contains(localId), message.status != .failed else {
|
|
return message
|
|
}
|
|
let needsStatus = message.status != .sent
|
|
let needsInvokedAt = message.hasExplicitNullInvokedAt
|
|
if !needsStatus && !needsInvokedAt { return message }
|
|
changed = true
|
|
var next = message
|
|
if needsStatus { next = next.withStatus(.sent) }
|
|
if needsInvokedAt { next = next.withInvokedAt(invokedAt) }
|
|
return next
|
|
}
|
|
if !changed { return previous }
|
|
var next = settingMessages(previous, MessageMerge.mergeMessages([], updated))
|
|
next.tailRevision = previous.tailRevision + 1
|
|
return next
|
|
}
|
|
|
|
// MARK: - Queued reconciliation
|
|
|
|
/// Web `isQueuedReconcileCandidate`.
|
|
private static func isQueuedReconcileCandidate(_ message: WindowMessage) -> Bool {
|
|
guard message.localId != nil, message.isQueuedForInvocation else { return false }
|
|
if !message.isOptimistic { return true }
|
|
return message.status == .queued || message.status == .sent
|
|
}
|
|
|
|
/// Candidate localIds for the queued-state round trip, in window order.
|
|
public static func queuedReconcileCandidateLocalIds(_ state: MessageWindowState) -> [String] {
|
|
var seen = Set<String>()
|
|
var localIds: [String] = []
|
|
for message in state.messages where isQueuedReconcileCandidate(message) {
|
|
let localId = message.localId!
|
|
if seen.insert(localId).inserted {
|
|
localIds.append(localId)
|
|
}
|
|
}
|
|
return localIds
|
|
}
|
|
|
|
/// Apply the queued-state verdict (web `reconcileQueuedLocalIds`): drop
|
|
/// candidates that are in neither the still-queued list nor (post
|
|
/// `markConsumed`) invoked — they were deleted server-side.
|
|
public static func reconcileQueuedLocalIds(
|
|
_ previous: MessageWindowState,
|
|
candidateLocalIds: [String],
|
|
queuedLocalIds: [String]
|
|
) -> MessageWindowState {
|
|
if candidateLocalIds.isEmpty { return previous }
|
|
let candidates = Set(candidateLocalIds)
|
|
let queued = Set(queuedLocalIds)
|
|
let messages = previous.messages.filter { message in
|
|
guard let localId = message.localId, candidates.contains(localId) else { return true }
|
|
return queued.contains(localId) || !isQueuedReconcileCandidate(message)
|
|
}
|
|
return messages.count == previous.messages.count
|
|
? previous
|
|
: settingMessages(previous, messages)
|
|
}
|
|
|
|
// MARK: - Persistence
|
|
|
|
/// Web `shouldPersistState`.
|
|
public static func shouldPersist(_ state: MessageWindowState) -> Bool {
|
|
!state.messages.isEmpty
|
|
|| state.hasMore
|
|
|| state.epoch != nil
|
|
|| state.oldestPosition != nil
|
|
|| state.newestPosition != nil
|
|
}
|
|
|
|
public static func toPersisted(_ state: MessageWindowState) -> PersistedMessageWindow {
|
|
PersistedMessageWindow(
|
|
messages: state.messages,
|
|
hasMore: state.hasMore,
|
|
oldestPositionAt: state.oldestPosition?.at,
|
|
oldestPositionSeq: state.oldestPosition?.seq,
|
|
newestPositionAt: state.newestPosition?.at,
|
|
newestPositionSeq: state.newestPosition?.seq,
|
|
epoch: state.epoch
|
|
)
|
|
}
|
|
|
|
/// Rebuild a state from a persisted snapshot (web `hydrateState`):
|
|
/// interrupted `sending` rows restore to `queued`/`sent`, and a snapshot
|
|
/// with rows but no usable cursor/epoch starts flagged for a latest
|
|
/// reset.
|
|
public static func hydrate(
|
|
sessionId: String,
|
|
persisted: PersistedMessageWindow
|
|
) -> MessageWindowState {
|
|
let restored = persisted.messages.map { message -> WindowMessage in
|
|
guard message.status == .sending else { return message }
|
|
return message.withStatus(message.hasExplicitNullInvokedAt ? .queued : .sent)
|
|
}
|
|
let oldest = pagePosition(at: persisted.oldestPositionAt, seq: persisted.oldestPositionSeq)
|
|
let newest = pagePosition(at: persisted.newestPositionAt, seq: persisted.newestPositionSeq)
|
|
let epoch = persisted.epoch.flatMap { $0 >= 0 ? $0 : nil }
|
|
var next = settingMessages(
|
|
createState(sessionId: sessionId),
|
|
MessageMerge.mergeMessages([], restored)
|
|
)
|
|
next.hasMore = persisted.hasMore
|
|
next.oldestPosition = oldest
|
|
next.newestPosition = newest
|
|
next.epoch = epoch
|
|
next.requiresLatestReset = !persisted.messages.isEmpty && (newest == nil || epoch == nil)
|
|
return next
|
|
}
|
|
|
|
// MARK: - Seed
|
|
|
|
/// Seed a fresh window from another session's (web
|
|
/// `seedMessageWindowFromSession`) — resume/reopen may hand back a
|
|
/// different session id; the old rows render instantly while
|
|
/// `requiresLatestReset` forces a fresh latest page underneath. Cursor
|
|
/// epoch and newest position are NOT carried over.
|
|
public static func seededState(
|
|
source: MessageWindowState,
|
|
target: MessageWindowState
|
|
) -> MessageWindowState {
|
|
var next = settingMessages(createState(sessionId: target.sessionId), source.messages)
|
|
next.hasMore = source.hasMore
|
|
next.tailRevision = source.tailRevision
|
|
next.oldestPosition = source.oldestPosition
|
|
next.requiresLatestReset = true
|
|
next.syncGeneration = target.syncGeneration + 1
|
|
next.olderGeneration = target.olderGeneration + 1
|
|
return next
|
|
}
|
|
}
|