merge: B-M2c Android message window store, pagination fixtures green

This commit is contained in:
weishu
2026-08-17 15:51:34 +08:00
15 changed files with 2880 additions and 2 deletions
@@ -93,7 +93,7 @@ class HapiApi(
private val client: OkHttpClient,
private val imageClient: OkHttpClient = client,
private val authClient: OkHttpClient = client,
) {
) : MessagesApi {
/** Normalized hub origin this instance talks to. */
val hubUrl: String = HubUrls.normalize(hubUrl)
?: throw IllegalArgumentException("Invalid hub URL: $hubUrl")
@@ -193,6 +193,26 @@ class HapiApi(
return request("GET", target.build())
}
/** [MessagesApi] seam over [getMessages] — the window store's transport. */
override suspend fun getMessages(sessionId: String, query: MessagesQuery): MessagesResponse = when (query) {
is MessagesQuery.Latest -> getMessages(sessionId, limit = query.limit)
is MessagesQuery.Before -> getMessages(
sessionId,
limit = query.limit,
beforeSeq = query.beforeSeq,
beforeAt = query.beforeAt,
)
is MessagesQuery.After -> getMessages(
sessionId,
limit = query.limit,
afterSeq = query.afterSeq,
afterAt = query.afterAt,
untilSeq = query.untilSeq,
untilAt = query.untilAt,
epoch = query.epoch,
)
}
/**
* `POST /api/sessions/:id/messages` — responds `{ok: true}` only; the
* message itself arrives via SSE (`message-received`), reconciled by
@@ -211,7 +231,7 @@ class HapiApi(
request("POST", url("api", "sessions", sessionId, "messages", messageId, "steer").build(), EMPTY_JSON)
/** `POST /api/sessions/:id/messages/queued-state` — resync optimistic sends after reconnect. */
suspend fun getQueuedState(sessionId: String, localIds: List<String>): QueuedStateResponse =
override suspend fun getQueuedState(sessionId: String, localIds: List<String>): QueuedStateResponse =
request(
"POST",
url("api", "sessions", sessionId, "messages", "queued-state").build(),
@@ -0,0 +1,51 @@
package app.hapi.data.api
import app.hapi.protocol.wire.MessagesResponse
import app.hapi.protocol.wire.QueuedStateResponse
/**
* One `GET /api/sessions/:id/messages` request. The three variants are the
* only shapes the reference client ever issues
* (`docs/api/client-contract/pagination.md`): the store cannot produce an
* invalid parameter combination by construction, and the pagination fixture
* harness asserts these against the recorded `expectedRequests` — `After`
* always carries the `until` keys (explicitly null on the first catch-up
* request), the other variants never do.
*/
sealed interface MessagesQuery {
val limit: Int
/** Newest page, no cursor. */
data class Latest(override val limit: Int) : MessagesQuery
/** Rows strictly older than the compound cursor. */
data class Before(
val beforeAt: Long,
val beforeSeq: Long,
override val limit: Int,
) : MessagesQuery
/** Tail catch-up: rows strictly newer than the cursor, snapshot-bounded. */
data class After(
val afterAt: Long,
val afterSeq: Long,
val untilAt: Long?,
val untilSeq: Long?,
val epoch: Long,
override val limit: Int,
) : MessagesQuery
}
/**
* Minimal seam over the two message endpoints the window store drives —
* implemented by [HapiApi] in production and by a scripted fake in the
* pagination fixture harness. Extracted (instead of depending on the concrete
* [HapiApi]) so the REAL orchestration is what the fixtures replay.
*/
interface MessagesApi {
/** `GET /api/sessions/:id/messages`. */
suspend fun getMessages(sessionId: String, query: MessagesQuery): MessagesResponse
/** `POST /api/sessions/:id/messages/queued-state`. */
suspend fun getQueuedState(sessionId: String, localIds: List<String>): QueuedStateResponse
}
@@ -0,0 +1,582 @@
package app.hapi.data.store
import app.hapi.data.api.MessagesApi
import app.hapi.data.api.MessagesQuery
import app.hapi.protocol.window.MessagePosition
import app.hapi.protocol.window.MessageStatus
import app.hapi.protocol.window.MessageViewMode
import app.hapi.protocol.window.MessageWindowLogic
import app.hapi.protocol.window.MessageWindowState
import app.hapi.protocol.window.OlderLoadOutcome
import app.hapi.protocol.window.PAGE_SIZE
import app.hapi.protocol.window.WindowMessage
import app.hapi.protocol.window.asWindowMessage
import app.hapi.protocol.window.buildOptimisticMessage
import app.hapi.protocol.wire.AttachmentMetadata
import app.hapi.protocol.wire.MessagesResponse
import app.hapi.protocol.wire.SyncEvent
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.CoroutineStart
import kotlinx.coroutines.Deferred
import kotlinx.coroutines.async
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
/** What the chat screen renders — mirror of the web `useMessages` return shape. */
data class MessageWindowUiState(
val messages: List<WindowMessage>,
val hasMore: Boolean,
val isSyncingTail: Boolean,
val isLoadingMore: Boolean,
val warning: String?,
val viewMode: MessageViewMode,
val messagesVersion: Long,
val historyVersion: Long,
val tailRevision: Long,
)
/**
* Per-session message window orchestration — the async half of the web
* reference `web/src/lib/message-window-store.ts`, driving the pure
* transitions in `MessageWindowLogic` over a [StateFlow].
*
* Concurrency model: the web store is single-threaded JS whose interleaving
* points are its `await`s; here every synchronous read-modify-write segment
* between transport calls runs under [stateMutex], and the generation
* counters + request-baseline identity checks (ported as-is) handle whatever
* interleaves across the api calls — which are deliberately made **outside**
* the lock. Tail syncs are single-flight per session with an optional
* trailing re-run, exactly like the web `TailSyncController`.
*/
class MessageWindowStore(
val sessionId: String,
private val api: MessagesApi,
private val scope: CoroutineScope,
private val snapshots: WindowSnapshots? = null,
initialState: MessageWindowState? = null,
) {
private val stateMutex = Mutex()
private val _state = MutableStateFlow(initialState ?: MessageWindowLogic.createState(sessionId))
/** The full window state (UI projects what it needs; see [uiState]). */
val state: StateFlow<MessageWindowState> = _state.asStateFlow()
/**
* UI-facing projection — the fields the web `useMessages` hook returns;
* cursor/generation internals stay off the render path.
*/
val uiState: Flow<MessageWindowUiState> = state
.map { current ->
MessageWindowUiState(
messages = current.messages,
hasMore = current.hasMore,
isSyncingTail = current.isSyncingTail,
isLoadingMore = current.isLoadingMore,
warning = current.warning,
viewMode = current.viewMode,
messagesVersion = current.messagesVersion,
historyVersion = current.historyVersion,
tailRevision = current.tailRevision,
)
}
.distinctUntilChanged()
// ------------------------------------------------------- tail controller --
private val controllerLock = Any()
private var running: TailRun? = null
private var trailingRequested = false
/** Bumped by [clear]/[seedFrom] so a finished run stops chaining trailing runs. */
private var controllerEpoch = 0
private class TailRun(val prefersLatest: Boolean) {
lateinit var job: Deferred<Unit>
}
private sealed interface SyncDecision {
data class Await(val job: Deferred<Unit>) : SyncDecision
data class Drain(val observed: Deferred<Unit>) : SyncDecision
}
/**
* Run (or join) a tail sync (web `syncTailMessages`). With
* [ensureAfterCurrent] the call drains: if a run is already in flight, a
* trailing run is requested and awaited, so the caller returns only after
* a sync that STARTED at or after this call.
*/
suspend fun syncTail(ensureAfterCurrent: Boolean = false) {
val decision: SyncDecision = synchronized(controllerLock) {
val current = running
when {
current == null -> SyncDecision.Await(startTailSyncLocked())
_state.value.preferLatestOnActivation ->
if (current.prefersLatest) {
SyncDecision.Await(current.job)
} else {
trailingRequested = false
SyncDecision.Await(startTailSyncLocked())
}
!ensureAfterCurrent -> SyncDecision.Await(current.job)
else -> {
trailingRequested = true
SyncDecision.Drain(current.job)
}
}
}
when (decision) {
is SyncDecision.Await -> decision.job.join()
is SyncDecision.Drain -> drainTailSync(decision.observed)
}
}
private fun startTailSyncLocked(): Deferred<Unit> {
val epoch = controllerEpoch
val run = TailRun(prefersLatest = _state.value.preferLatestOnActivation)
// UNDISPATCHED mirrors the web: `runTailSync` executes to its first true
// suspension (normally the api call) before `startTailSync` returns, so
// `beginTailSync`'s generation bump lands synchronously and an in-flight
// run it replaces cannot commit another page in between.
run.job = scope.async(start = CoroutineStart.UNDISPATCHED) { runTailSync() }
running = run
run.job.invokeOnCompletion {
synchronized(controllerLock) {
if (controllerEpoch != epoch || running !== run) return@invokeOnCompletion
running = null
if (!trailingRequested) return@invokeOnCompletion
trailingRequested = false
startTailSyncLocked()
}
}
return run.job
}
/** Web `waitForTailSyncDrain`: follow the chain until no newer run exists. */
private suspend fun drainTailSync(observed: Deferred<Unit>) {
val epoch = synchronized(controllerLock) { controllerEpoch }
var current: Deferred<Unit>? = observed
while (current != null) {
current.join()
current = synchronized(controllerLock) {
if (controllerEpoch != epoch) return
val next = running?.job
if (next != null && next !== current) next else null
}
}
}
private fun isCurrentTailSync(generation: Long): Boolean =
_state.value.syncGeneration == generation
private suspend fun runTailSync() {
val generation = update { MessageWindowLogic.beginTailSync(it) }.syncGeneration
try {
val initial = _state.value
val initialCursor = initial.newestPosition
val preferLatestOnActivation = initial.preferLatestOnActivation
val canIncrement = initialCursor != null
&& initial.epoch != null
&& !initial.requiresLatestReset
&& !preferLatestOnActivation
if (!canIncrement) {
val requestBaseline = baseline()
val response = api.getMessages(sessionId, MessagesQuery.Latest(limit = PAGE_SIZE))
if (!isCurrentTailSync(generation)) return
update { previous ->
if (previous.syncGeneration != generation) return@update previous
MessageWindowLogic.applyLatestResponse(
previous,
response.windowMessages(),
response.page,
replaceServerRows = initial.requiresLatestReset
|| preferLatestOnActivation
|| response.page.reset,
requestBaseline = requestBaseline,
).copy(preferLatestOnActivation = false)
}
finishTailSync(generation, warning = null)
return
}
var after: MessagePosition = initialCursor!!
var until: MessagePosition? = null
while (true) {
val requestBaseline = baseline()
val response = api.getMessages(
sessionId,
MessagesQuery.After(
afterAt = after.at,
afterSeq = after.seq,
untilAt = until?.at,
untilSeq = until?.seq,
epoch = initial.epoch!!,
limit = PAGE_SIZE,
),
)
if (!isCurrentTailSync(generation)) return
if (response.page.reset || response.page.direction == "latest") {
update { previous ->
if (previous.syncGeneration != generation) return@update previous
MessageWindowLogic.applyLatestResponse(
previous,
response.windowMessages(),
response.page,
replaceServerRows = true,
requestBaseline = requestBaseline,
)
}
break
}
val nextAfter = MessageWindowLogic.pagePosition(response.page.nextAfterAt, response.page.nextAfterSeq)
val snapshotHead =
MessageWindowLogic.pagePosition(response.page.snapshotHeadAt, response.page.snapshotHeadSeq)
if (until == null) {
until = snapshotHead
}
update { previous ->
if (previous.syncGeneration != generation) return@update previous
MessageWindowLogic.applyAfterPage(previous, response.windowMessages(), response.page, nextAfter)
}
val current = _state.value
if (
current.requiresLatestReset
|| current.preferLatestOnActivation
|| !response.page.hasMore
|| nextAfter == null
) {
break
}
if (nextAfter <= after) {
throw IllegalStateException("Message tail cursor did not advance")
}
after = nextAfter
}
finishTailSync(generation, warning = null)
} catch (cancellation: CancellationException) {
throw cancellation
} catch (error: Throwable) {
if (!isCurrentTailSync(generation)) return
finishTailSync(generation, error.message ?: "Failed to synchronize messages")
}
}
private suspend fun finishTailSync(generation: Long, warning: String?) {
update { MessageWindowLogic.finishTailSync(it, generation, warning) }
persist()
}
// ---------------------------------------------------------- older pages --
/**
* Load one older page (web `fetchOlderMessages`). On an epoch mismatch
* the window is invalidated and a full tail sync (`ensureAfterCurrent`)
* runs before the `stopped/epoch-reset` outcome is returned.
* [onBeforeApply] runs while the state lock is held (mirror of the web's
* synchronous updater) — it must not call back into this store.
*/
suspend fun fetchOlder(onBeforeApply: ((Long) -> Boolean)? = null): OlderLoadOutcome {
val initial = _state.value
when (val precheck = MessageWindowLogic.olderLoadPrecheck(initial)) {
is MessageWindowLogic.OlderPrecheck.Stop -> return OlderLoadOutcome.Stopped(precheck.reason)
is MessageWindowLogic.OlderPrecheck.Proceed -> {
val before = precheck.before
val generation = initial.olderGeneration + 1
update { MessageWindowLogic.beginOlderLoad(it, generation) }
return try {
val response = api.getMessages(
sessionId,
MessagesQuery.Before(beforeAt = before.at, beforeSeq = before.seq, limit = PAGE_SIZE),
)
if (_state.value.olderGeneration != generation) {
return OlderLoadOutcome.Stopped(OlderLoadOutcome.StopReason.Invalidated)
}
if (initial.epoch != null && response.page.epoch != initial.epoch) {
update { MessageWindowLogic.applyOlderEpochMismatch(it, generation) }
syncTail(ensureAfterCurrent = true)
return OlderLoadOutcome.Stopped(OlderLoadOutcome.StopReason.EpochReset)
}
var historyVersion = 0L
var addedRenderableCount = 0
var applyRejected = false
update { previous ->
if (previous.olderGeneration != generation) return@update previous
val incoming = response.windowMessages()
addedRenderableCount = MessageWindowLogic.countNewRenderableMessages(previous, incoming)
val nextHistoryVersion = previous.historyVersion + 1
if (onBeforeApply != null && !onBeforeApply(nextHistoryVersion)) {
applyRejected = true
return@update MessageWindowLogic.rejectOlderApply(previous)
}
historyVersion = nextHistoryVersion
MessageWindowLogic.applyOlderResponse(previous, incoming, response.page, nextHistoryVersion)
}
if (applyRejected || historyVersion == 0L) {
return OlderLoadOutcome.Stopped(OlderLoadOutcome.StopReason.Invalidated)
}
persist()
OlderLoadOutcome.Applied(
historyVersion = historyVersion,
hasMore = response.page.hasMore,
addedRenderableCount = addedRenderableCount,
)
} catch (cancellation: CancellationException) {
throw cancellation
} catch (error: Throwable) {
if (_state.value.olderGeneration != generation) {
return OlderLoadOutcome.Stopped(OlderLoadOutcome.StopReason.Invalidated)
}
update {
MessageWindowLogic.failOlderLoad(
it,
generation,
error.message ?: "Failed to load older messages",
)
}
OlderLoadOutcome.Failed(error)
}
}
}
}
suspend fun cancelOlderLoad() {
update { MessageWindowLogic.cancelOlderLoad(it) }
}
// ----------------------------------------------------------- SSE ingest --
/**
* Route one message-stream SSE event into this window (the
* `SyncTargets.onMessageEvent` hook). `messages-invalidated` clears the
* window and starts a fresh tail sync; `scheduled-matured` re-syncs so
* the released row (and its consumption) lands even if the
* `message-received` frame was missed.
*/
suspend fun onMessageEvent(event: SyncEvent) {
when (event) {
is SyncEvent.MessageReceived -> ingestSseMessages(listOf(event.message.asWindowMessage()))
is SyncEvent.MessagesConsumed -> markConsumed(event.localIds, event.invokedAt)
is SyncEvent.MessageCancelled -> removeMessage(event.messageId)
is SyncEvent.MessagesInvalidated -> {
clear()
scope.launch { syncTail() }
}
is SyncEvent.ScheduledMatured -> scope.launch { syncTail(ensureAfterCurrent = true) }
else -> Unit
}
}
/** SSE `message-received` ingest (web `ingestIncomingMessages`). */
suspend fun ingestSseMessages(messages: List<WindowMessage>) {
if (messages.isEmpty()) return
update { MessageWindowLogic.ingestIncoming(it, messages) }
persist()
}
/** SSE `messages-consumed` (web `markMessagesConsumed`). */
suspend fun markConsumed(localIds: List<String>, invokedAt: Long) {
if (localIds.isEmpty()) return
update { MessageWindowLogic.markConsumed(it, localIds, invokedAt) }
persist()
}
/** SSE `message-cancelled` / optimistic DELETE removal (web `removeOptimisticMessage`). */
suspend fun removeMessage(localIdOrId: String) {
update { MessageWindowLogic.removeByLocalIdOrId(it, localIdOrId) }
persist()
}
// ------------------------------------------------------ optimistic sends --
/** Append a pre-built optimistic row (web `appendOptimisticMessage`). */
suspend fun appendOptimistic(message: WindowMessage) {
update { MessageWindowLogic.appendOptimistic(it, message) }
persist()
}
/**
* Append the standard optimistic row for a send
* (`useSendMessage.createOptimisticMessage`): status `sending` until the
* POST settles, then [updateStatus] to `queued`/`sent`/`failed`.
*/
suspend fun appendOptimistic(
localId: String,
text: String,
attachments: List<AttachmentMetadata>? = null,
scheduledAt: Long? = null,
deliveryMode: String = "queue",
createdAt: Long = System.currentTimeMillis(),
) {
appendOptimistic(
buildOptimisticMessage(
localId = localId,
text = text,
createdAt = createdAt,
attachments = attachments,
scheduledAt = scheduledAt,
deliveryMode = deliveryMode,
status = MessageStatus.Sending,
),
)
}
suspend fun updateStatus(localId: String, status: MessageStatus) {
update { MessageWindowLogic.updateStatus(it, localId, status) }
persist()
}
/**
* A cancel DELETE answered `{"status":"invoked"}` — too late, the agent
* consumed the row. Remove the queued snapshot and ingest the returned
* authoritative row as `sent` (web `useCancelQueuedMessage`).
*/
suspend fun applyCancelInvoked(localId: String, message: WindowMessage) {
removeMessage(localId)
appendOptimistic(message.copy(status = MessageStatus.Sent))
}
// ------------------------------------------------- queued reconciliation --
fun queuedReconcileCandidateLocalIds(): List<String> =
MessageWindowLogic.queuedReconcileCandidateLocalIds(_state.value)
suspend fun reconcileQueuedLocalIds(candidateLocalIds: List<String>, queuedLocalIds: List<String>) {
update { MessageWindowLogic.reconcileQueuedLocalIds(it, candidateLocalIds, queuedLocalIds) }
persist()
}
/**
* Queued-state recovery after a `resume: 'gap'` reconnect (port of
* `web/src/lib/queued-state-reconciliation.ts`): tail-sync to the drain,
* collect candidates, ask the hub for the verdict in ≤1000-id batches,
* stamp invoked rows like `messages-consumed`, drop deleted candidates.
*/
suspend fun reconcileQueuedState() {
syncTail(ensureAfterCurrent = true)
val candidateLocalIds = queuedReconcileCandidateLocalIds()
if (candidateLocalIds.isEmpty()) return
val queuedLocalIds = mutableListOf<String>()
val invokedLocalMessages = mutableListOf<Pair<String, Long>>()
candidateLocalIds.chunked(QUEUED_STATE_BATCH_SIZE).forEach { batch ->
val response = api.getQueuedState(sessionId, batch)
queuedLocalIds += response.queuedLocalIds
invokedLocalMessages += response.invokedLocalMessages.map { it.localId to it.invokedAt }
}
val invokedByTimestamp = LinkedHashMap<Long, MutableList<String>>()
for ((localId, invokedAt) in invokedLocalMessages) {
invokedByTimestamp.getOrPut(invokedAt) { mutableListOf() }.add(localId)
}
for ((invokedAt, localIds) in invokedByTimestamp) {
markConsumed(localIds, invokedAt)
}
reconcileQueuedLocalIds(candidateLocalIds, queuedLocalIds)
}
// ------------------------------------------------------------ lifecycle --
/** Switch view mode (web `setMessageViewMode`); tail re-entry trims. */
suspend fun setViewMode(mode: MessageViewMode) {
update { MessageWindowLogic.setViewMode(it, mode) }
persist()
}
/**
* Session (re-)activation (web `activateMessageWindow`): trims back to
* the visible window and, with usable persisted state, requests a
* fresh-latest trailing run from an in-flight sync.
*/
suspend fun activate() {
var requestedLatest = false
update { previous ->
val activation = MessageWindowLogic.activate(previous)
requestedLatest = activation.requestedLatest
activation.state
}
if (requestedLatest) {
synchronized(controllerLock) {
if (running != null) {
trailingRequested = true
}
}
}
}
/** Web `clearMessageWindow`: forget everything but keep generations poisoned. */
suspend fun clear() {
synchronized(controllerLock) {
controllerEpoch += 1
running = null
trailingRequested = false
}
snapshots?.delete(sessionId)
update { previous ->
MessageWindowLogic.createState(sessionId).copy(
syncGeneration = previous.syncGeneration + 1,
olderGeneration = previous.olderGeneration + 1,
)
}
}
/**
* Seed this window from another session's (web
* `seedMessageWindowFromSession`) — resume/reopen can hand back a new
* session id; the old rows render instantly and `requiresLatestReset`
* forces a fresh latest page underneath.
*/
suspend fun seedFrom(source: MessageWindowStore) {
if (source.sessionId == sessionId) return
synchronized(controllerLock) {
controllerEpoch += 1
running = null
trailingRequested = false
}
val sourceState = source.state.value
update { target -> MessageWindowLogic.seededState(sourceState, target) }
persist()
}
// -------------------------------------------------------------- internals --
private suspend fun update(transform: (MessageWindowState) -> MessageWindowState): MessageWindowState =
stateMutex.withLock {
val next = transform(_state.value)
_state.value = next
next
}
/** Identity baseline of the current rows, keyed by id (web `requestBaseline`). */
private fun baseline(): Map<String, WindowMessage> =
_state.value.messages.associateBy { it.id }
private fun MessagesResponse.windowMessages(): List<WindowMessage> =
messages.map { it.asWindowMessage() }
private suspend fun persist() {
val snapshotStore = snapshots ?: return
val state = _state.value
if (MessageWindowLogic.shouldPersist(state)) {
snapshotStore.save(sessionId, MessageWindowLogic.toPersisted(state))
} else {
snapshotStore.delete(sessionId)
}
}
private companion object {
const val QUEUED_STATE_BATCH_SIZE = 1000
}
}
@@ -0,0 +1,59 @@
package app.hapi.data.store
import app.hapi.data.api.MessagesApi
import app.hapi.protocol.window.MessageWindowLogic
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
/**
* Per-hub registry of per-session [MessageWindowStore]s — the analogue of the
* web module's session-keyed maps. [open] hydrates a cold session from its
* disk snapshot (interrupted `sending` rows restore, stale snapshots start
* flagged for a latest reset); [seed] carries a window across a
* resume/reopen id change. Snapshot files themselves are LRU-capped by
* [WindowSnapshots].
*/
class MessageWindowStores(
private val api: MessagesApi,
private val scope: CoroutineScope,
private val snapshots: WindowSnapshots? = null,
) {
private val mutex = Mutex()
private val stores = HashMap<String, MessageWindowStore>()
/** The session's store, hydrating from its snapshot on first open. */
suspend fun open(sessionId: String): MessageWindowStore {
mutex.withLock { stores[sessionId] }?.let { return it }
// Hydrate outside the lock (disk I/O), then race-tolerantly install.
val hydrated = snapshots?.load(sessionId)?.let { MessageWindowLogic.hydrate(sessionId, it) }
return mutex.withLock {
stores.getOrPut(sessionId) {
MessageWindowStore(
sessionId = sessionId,
api = api,
scope = scope,
snapshots = snapshots,
initialState = hydrated,
)
}
}
}
/** Already-open store, if any (no hydration). */
suspend fun peek(sessionId: String): MessageWindowStore? =
mutex.withLock { stores[sessionId] }
/** Web `seedMessageWindowFromSession` for resume/reopen id migration. */
suspend fun seed(fromSessionId: String, toSessionId: String) {
if (fromSessionId.isEmpty() || toSessionId.isEmpty() || fromSessionId == toSessionId) return
val source = open(fromSessionId)
val target = open(toSessionId)
target.seedFrom(source)
}
/** Web `clearMessageWindow` (e.g. on `session-removed`). */
suspend fun clear(sessionId: String) {
mutex.withLock { stores[sessionId] }?.clear()
}
}
@@ -0,0 +1,90 @@
package app.hapi.data.store
import app.hapi.protocol.window.PersistedMessageWindow
import app.hapi.protocol.wire.HapiJson
import java.io.File
import java.io.FileOutputStream
import java.security.MessageDigest
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.serialization.json.Json
/**
* Per-session message-window snapshots on disk: one JSON file per session in
* [dir], written atomically (temp file + fsync + rename — the same guarantee
* `android.util.AtomicFile` provides, hand-rolled here so plain JVM unit
* tests can exercise it), pruned LRU to [maxSessions] files.
*
* Mirrors the web's `sessionStorage` persistence of
* `PersistedMessageWindowState` (key `hapi:message-window:v2:`): messages +
* cursors + epoch for instant cold-start rendering; `hydrate` in
* `MessageWindowLogic` restores interrupted send states and flags stale
* snapshots for a latest reset.
*
* TODO(B-M2b dedup): fold into the shared `JsonSnapshotStore` once that
* lands — this class predates it in a parallel work package.
*/
class WindowSnapshots(
private val dir: File,
private val maxSessions: Int = DEFAULT_MAX_SESSIONS,
private val json: Json = HapiJson,
private val io: CoroutineDispatcher = Dispatchers.IO,
) {
suspend fun save(sessionId: String, snapshot: PersistedMessageWindow): Unit = withContext(io) {
dir.mkdirs()
val target = fileFor(sessionId)
val temp = File(dir, target.name + ".tmp")
FileOutputStream(temp).use { stream ->
stream.write(json.encodeToString(PersistedMessageWindow.serializer(), snapshot).toByteArray())
stream.fd.sync()
}
if (!temp.renameTo(target)) {
// Windows-style rename-over-existing failure; best effort swap.
target.delete()
temp.renameTo(target)
}
prune()
}
suspend fun load(sessionId: String): PersistedMessageWindow? = withContext(io) {
val file = fileFor(sessionId)
if (!file.isFile) return@withContext null
try {
val snapshot = json.decodeFromString(PersistedMessageWindow.serializer(), file.readText())
// Touch for LRU recency: reading a snapshot marks the session live.
file.setLastModified(System.currentTimeMillis())
snapshot
} catch (_: Exception) {
file.delete()
null
}
}
suspend fun delete(sessionId: String): Unit = withContext(io) {
fileFor(sessionId).delete()
Unit
}
/** Drop the least-recently-written files beyond [maxSessions]. */
private fun prune() {
val files = dir.listFiles { file -> file.isFile && file.name.endsWith(SUFFIX) } ?: return
if (files.size <= maxSessions) return
files.sortedByDescending { it.lastModified() }
.drop(maxSessions)
.forEach { it.delete() }
}
/** Session ids are arbitrary strings — file names come from a digest. */
private fun fileFor(sessionId: String): File {
val digest = MessageDigest.getInstance("SHA-256").digest(sessionId.toByteArray())
val name = digest.joinToString("") { "%02x".format(it) }.take(32)
return File(dir, name + SUFFIX)
}
private companion object {
const val DEFAULT_MAX_SESSIONS = 10
const val SUFFIX = ".window.json"
}
}
@@ -0,0 +1,332 @@
package app.hapi.data.store
import app.hapi.data.api.MessagesApi
import app.hapi.data.api.MessagesQuery
import app.hapi.protocol.window.MessageStatus
import app.hapi.protocol.window.MessageWindowLogic
import app.hapi.protocol.window.PersistedMessageWindow
import app.hapi.protocol.window.asWindowMessage
import app.hapi.protocol.window.buildOptimisticMessage
import app.hapi.protocol.wire.DecryptedMessage
import app.hapi.protocol.wire.MessagesPage
import app.hapi.protocol.wire.MessagesResponse
import app.hapi.protocol.wire.OptionalField
import app.hapi.protocol.wire.QueuedStateResponse
import java.io.File
import kotlin.test.assertEquals
import kotlin.test.assertNull
import kotlin.test.assertTrue
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.test.runCurrent
import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.withContext
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put
import org.junit.Rule
import org.junit.Test
import org.junit.rules.TemporaryFolder
/**
* 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, seed-on-resume, and Mutex discipline under parallelism.
*/
class MessageWindowStoreTest {
@get:Rule
val temp = TemporaryFolder()
// ------------------------------------------------------------- fixtures --
private fun agentRow(id: String, seq: Long, at: Long): DecryptedMessage = DecryptedMessage(
id = id,
seq = seq,
createdAt = at,
invokedAt = OptionalField.Present(at),
content = buildJsonObject {
put("role", "agent")
put("content", buildJsonObject {
put("type", "codex")
put("data", buildJsonObject {
put("type", "message")
put("message", id)
})
})
},
)
private fun latestPage(
messages: List<DecryptedMessage>,
epoch: Long,
reset: Boolean = false,
hasMore: Boolean = false,
): MessagesResponse {
val newest = messages.lastOrNull()
val oldest = messages.firstOrNull()
return MessagesResponse(
messages = messages,
page = MessagesPage(
direction = "latest",
limit = 200,
epoch = epoch,
reset = reset,
nextBeforeSeq = oldest?.seq,
nextBeforeAt = oldest?.positionAt,
nextAfterSeq = null,
nextAfterAt = null,
snapshotHeadSeq = newest?.seq,
snapshotHeadAt = newest?.positionAt,
hasMore = hasMore,
),
)
}
private fun afterPage(
messages: List<DecryptedMessage>,
epoch: Long,
nextAfter: Pair<Long, Long>?,
hasMore: Boolean,
): MessagesResponse = MessagesResponse(
messages = messages,
page = MessagesPage(
direction = "after",
limit = 200,
epoch = epoch,
reset = false,
nextBeforeSeq = null,
nextBeforeAt = null,
nextAfterSeq = nextAfter?.second,
nextAfterAt = nextAfter?.first,
snapshotHeadSeq = nextAfter?.second,
snapshotHeadAt = nextAfter?.first,
hasMore = hasMore,
),
)
/** Api whose responses are released manually, so tests control interleaving. */
private class GatedMessagesApi : MessagesApi {
val requests = mutableListOf<MessagesQuery>()
private val responses = Channel<MessagesResponse>(Channel.UNLIMITED)
fun release(response: MessagesResponse) {
check(responses.trySend(response).isSuccess)
}
override suspend fun getMessages(sessionId: String, query: MessagesQuery): MessagesResponse {
requests += query
return responses.receive()
}
override suspend fun getQueuedState(sessionId: String, localIds: List<String>): QueuedStateResponse =
QueuedStateResponse(emptyList(), emptyList())
}
// ------------------------------------------------------------ tail sync --
@Test
fun `concurrent syncTail calls coalesce into a single run`() = runTest {
val api = GatedMessagesApi()
val store = MessageWindowStore("s", api, backgroundScope)
val first = launch { store.syncTail() }
runCurrent()
assertEquals(1, api.requests.size)
val second = launch { store.syncTail() }
runCurrent()
// The second caller joined the in-flight run instead of issuing its own.
assertEquals(1, api.requests.size)
api.release(latestPage(listOf(agentRow("a-1", 1, 1000)), epoch = 0))
first.join()
second.join()
assertEquals(1, api.requests.size)
assertEquals(listOf("a-1"), store.state.value.messages.map { it.id })
}
@Test
fun `ensureAfterCurrent requests a trailing run and drains it`() = runTest {
val api = GatedMessagesApi()
val store = MessageWindowStore("s", api, backgroundScope)
val first = launch { store.syncTail() }
runCurrent()
assertEquals(1, api.requests.size)
val drain = launch { store.syncTail(ensureAfterCurrent = true) }
runCurrent()
assertEquals(1, api.requests.size)
api.release(latestPage(listOf(agentRow("a-1", 1, 1000)), epoch = 0))
first.join()
runCurrent()
// The trailing run started and issued its own (after-cursor) request.
assertEquals(2, api.requests.size)
assertTrue(api.requests[1] is MessagesQuery.After)
assertTrue(drain.isActive)
api.release(afterPage(emptyList(), epoch = 0, nextAfter = 1000L to 1L, hasMore = false))
drain.join()
assertEquals(2, api.requests.size)
}
@Test
fun `reset replace preserves rows that arrived while the request was in flight`() = runTest {
val api = GatedMessagesApi()
val store = MessageWindowStore("s", api, backgroundScope)
// Seed epoch + cursor with a completed latest sync.
val seed = launch { store.syncTail() }
runCurrent()
api.release(latestPage(listOf(agentRow("a-1", 1, 1000)), epoch = 0))
seed.join()
// Second sync goes down the after-cursor path and hangs on the api.
val sync = launch { store.syncTail() }
runCurrent()
assertTrue(api.requests.last() is MessagesQuery.After)
// A live SSE row lands while the request is in flight.
store.ingestSseMessages(listOf(agentRow("b-2", 2, 2000).asWindowMessage()))
// The server answers with a reset page that does not contain b-2.
api.release(latestPage(listOf(agentRow("c-3", 3, 3000)), epoch = 1, reset = true))
sync.join()
val state = store.state.value
// a-1 (captured in the request baseline) was replaced; the concurrent
// b-2 and the authoritative c-3 both survive.
assertEquals(listOf("b-2", "c-3"), state.messages.map { it.id })
assertEquals(1L, state.epoch)
assertEquals(3000L to 3L, state.newestPosition!!.let { it.at to it.seq })
}
@Test
fun `a tail cursor that does not advance aborts with a warning instead of spinning`() = runTest {
val api = GatedMessagesApi()
val store = MessageWindowStore("s", api, backgroundScope)
val seed = launch { store.syncTail() }
runCurrent()
api.release(latestPage(listOf(agentRow("a-1", 1, 1000)), epoch = 0))
seed.join()
val sync = launch { store.syncTail() }
runCurrent()
// hasMore with a non-advancing nextAfter is a protocol violation.
api.release(afterPage(listOf(agentRow("b-2", 2, 2000)), epoch = 0, nextAfter = 1000L to 1L, hasMore = true))
sync.join()
val state = store.state.value
assertEquals("Message tail cursor did not advance", state.warning)
assertTrue(!state.isSyncingTail)
assertEquals(2, api.requests.size)
}
// ------------------------------------------------------------ snapshots --
@Test
fun `snapshot round-trip restores interrupted sends and flags stale snapshots`() = runBlocking {
val snapshots = WindowSnapshots(temp.newFolder(), io = Dispatchers.Unconfined)
val queuedSending = buildOptimisticMessage(
localId = "local-1",
text = "hello",
createdAt = 1000,
status = MessageStatus.Sending,
)
val fresh = PersistedMessageWindow(
messages = listOf(queuedSending, agentRow("a-1", 1, 500).asWindowMessage()),
hasMore = true,
oldestPositionAt = 500,
oldestPositionSeq = 1,
newestPositionAt = 500,
newestPositionSeq = 1,
epoch = 3,
)
snapshots.save("session-a", fresh)
val loaded = snapshots.load("session-a")!!
val hydrated = MessageWindowLogic.hydrate("session-a", loaded)
// Interrupted `sending` on a queued row restores to `queued`.
assertEquals(
MessageStatus.Queued,
hydrated.messages.first { it.id == "local-1" }.status,
)
assertEquals(3L, hydrated.epoch)
assertTrue(!hydrated.requiresLatestReset)
assertEquals(500L to 1L, hydrated.newestPosition!!.let { it.at to it.seq })
// A snapshot without a usable epoch hydrates flagged for a latest reset.
val stale = fresh.copy(epoch = null)
snapshots.save("session-b", stale)
val staleHydrated = MessageWindowLogic.hydrate("session-b", snapshots.load("session-b")!!)
assertTrue(staleHydrated.requiresLatestReset)
assertNull(staleHydrated.epoch)
}
@Test
fun `snapshots are LRU-capped at ten sessions`() = runBlocking {
val dir = temp.newFolder()
val snapshots = WindowSnapshots(dir, io = Dispatchers.Unconfined)
repeat(12) { index ->
snapshots.save(
"session-$index",
PersistedMessageWindow(messages = listOf(agentRow("a-$index", 1, 1000).asWindowMessage())),
)
}
val files = dir.listFiles { file: File -> file.name.endsWith(".window.json") }.orEmpty()
assertEquals(10, files.size)
}
// ----------------------------------------------------------------- seed --
@Test
fun `seedFrom carries rows and older cursor but forces a latest reset`() = runTest {
val api = GatedMessagesApi()
val source = MessageWindowStore("old-session", api, backgroundScope)
val seed = launch { source.syncTail() }
runCurrent()
api.release(latestPage(listOf(agentRow("a-1", 1, 1000)), epoch = 5, hasMore = true))
seed.join()
val target = MessageWindowStore("new-session", api, backgroundScope)
target.seedFrom(source)
val state = target.state.value
assertEquals("new-session", state.sessionId)
assertEquals(listOf("a-1"), state.messages.map { it.id })
assertTrue(state.hasMore)
assertTrue(state.requiresLatestReset)
assertNull(state.epoch)
assertNull(state.newestPosition)
assertEquals(1000L to 1L, state.oldestPosition!!.let { it.at to it.seq })
}
// ------------------------------------------------------ mutex discipline --
@Test
fun `parallel mutations from real threads serialize through the state mutex`() = runBlocking {
val api = GatedMessagesApi()
val store = MessageWindowStore("s", api, this)
withContext(Dispatchers.Default) {
(0 until 64).map { index ->
launch {
store.appendOptimistic(
localId = "local-$index",
text = "message $index",
createdAt = 1000L + index,
)
store.updateStatus("local-$index", MessageStatus.Queued)
}
}.forEach { it.join() }
}
val state = store.state.value
assertEquals(64, state.messages.size)
assertTrue(state.messages.all { it.status == MessageStatus.Queued && it.isQueuedForInvocation })
}
}
@@ -0,0 +1,314 @@
package app.hapi.data.store
import app.hapi.data.api.MessagesApi
import app.hapi.data.api.MessagesQuery
import app.hapi.protocol.window.MessageStatus
import app.hapi.protocol.window.MessageViewMode
import app.hapi.protocol.window.MessageWindowState
import app.hapi.protocol.window.OlderLoadOutcome
import app.hapi.protocol.window.WindowMessage
import app.hapi.protocol.wire.HapiJson
import app.hapi.protocol.wire.MessagesResponse
import app.hapi.protocol.wire.OptionalField
import app.hapi.protocol.wire.QueuedStateResponse
import app.hapi.protocol.wire.longOrNull
import app.hapi.protocol.wire.stringOrNull
import java.io.File
import kotlin.test.assertEquals
import kotlin.test.assertNull
import kotlin.test.assertTrue
import kotlin.test.fail
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.runBlocking
import kotlinx.serialization.builtins.ListSerializer
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonNull
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.jsonArray
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.put
import org.junit.Test
import org.junit.runner.RunWith
import org.junit.runners.Parameterized
/**
* Pagination conformance suite: replays every op script in
* `shared/fixtures/pagination/` against the REAL [MessageWindowStore]
* (driven by a scripted [MessagesApi]) and requires
*
* - the exact same `GET /messages` requests, in order (`expectedRequests`),
* - the same older-load outcomes (`expectedOutcome`) and queued-state
* reconcile candidates (`expectedCandidates`),
* - the exact final window projection (`expectedState`).
*
* The twin of `web/src/lib/message-window-store.fixtures.test.ts` /
* `web/scripts/fixtures/pagination/runner.ts`. A failure here means this port
* drifted from the web reference (or the fixtures were regenerated after a
* web behavior change and the port must catch up).
*
* Determinism: ops run strictly sequentially; the store's scope uses
* [Dispatchers.Unconfined], so internal tail-sync jobs run to completion
* within each op (the scripted api never suspends) — mirroring the web
* harness awaiting every op.
*/
@RunWith(Parameterized::class)
class PaginationFixtureTest(private val fixtureName: String) {
companion object {
private val fixturesDir: File by lazy {
val path = checkNotNull(System.getProperty("hapi.fixtures.dir")) {
"hapi.fixtures.dir system property not set (see core/data/build.gradle.kts)"
}
File(path).also {
check(it.isDirectory) { "fixtures dir does not exist: $it" }
}
}
@JvmStatic
@Parameterized.Parameters(name = "{0}")
fun fixtures(): List<String> {
val files = File(fixturesDir, "pagination")
.listFiles { file -> file.name.endsWith(".json") }
?.map { it.name }
?.sorted()
.orEmpty()
check(files.isNotEmpty()) {
"no pagination fixtures found under $fixturesDir/pagination — refusing to pass on zero files"
}
return files
}
}
@Test
fun `replaying the ops against the real store matches the stored expectations`() {
val document = HapiJson.parseToJsonElement(
File(File(fixturesDir, "pagination"), fixtureName).readText()
).jsonObject
val version = File(fixturesDir, "VERSION").readText().trim().toInt()
assertEquals(version, document.getValue("fixtureVersion").longOrNull?.toInt(), "fixtureVersion")
assertEquals(fixtureName, "${document.getValue("name").stringOrNull}.json", "name matches file")
val scripted = ScriptedMessagesApi()
val store = MessageWindowStore(
sessionId = "fixture-pagination-${document.getValue("name").stringOrNull}",
api = scripted,
scope = CoroutineScope(SupervisorJob() + Dispatchers.Unconfined),
)
runBlocking {
for ((opIndex, opElement) in document.getValue("ops").jsonArray.withIndex()) {
executeOp(store, scripted, opElement.jsonObject, opIndex)
}
}
assertEquals(
document.getValue("expectedState"),
projectWindowState(store.state.value),
"final window projection ($fixtureName)",
)
}
// ------------------------------------------------------------ op replay --
private suspend fun executeOp(
store: MessageWindowStore,
scripted: ScriptedMessagesApi,
op: JsonObject,
opIndex: Int,
) {
val kind = op.getValue("op").stringOrNull
when (kind) {
"sync-tail" -> {
scripted.begin(decodeResponses(op))
store.syncTail()
assertSettled(store, scripted, opIndex)
assertRequests(op, scripted, opIndex)
}
"fetch-older" -> {
scripted.begin(decodeResponses(op))
val outcome = store.fetchOlder()
assertSettled(store, scripted, opIndex)
assertRequests(op, scripted, opIndex)
op["expectedOutcome"]?.let { expected ->
assertEquals(expected, projectOutcome(outcome), "ops[$opIndex] outcome")
}
}
"sse-messages" -> store.ingestSseMessages(
op.getValue("messages").jsonArray.map(::decodeWindowMessage)
)
"append-optimistic" -> store.appendOptimistic(decodeWindowMessage(op.getValue("message")))
"update-status" -> store.updateStatus(
localId = op.getValue("localId").stringOrNull!!,
status = MessageStatus.fromWire(op.getValue("status").stringOrNull)!!,
)
"messages-consumed" -> store.markConsumed(
localIds = op.getValue("localIds").jsonArray.map { it.stringOrNull!! },
invokedAt = op.getValue("invokedAt").longOrNull!!,
)
"message-cancelled" -> store.removeMessage(op.getValue("localId").stringOrNull!!)
"cancel-invoked" -> store.applyCancelInvoked(
localId = op.getValue("localId").stringOrNull!!,
message = decodeWindowMessage(op.getValue("message")),
)
"set-view-mode" -> store.setViewMode(
when (op.getValue("mode").stringOrNull) {
"history" -> MessageViewMode.History
else -> MessageViewMode.Tail
}
)
"queued-state" -> {
// Mirrors the web runner (reconcileQueuedStateAfterConnect's
// post tail-sync half): collect candidates, apply invoked
// verdicts grouped by timestamp, drop deleted candidates.
val candidates = store.queuedReconcileCandidateLocalIds()
op["expectedCandidates"]?.let { expected ->
assertEquals(
expected,
JsonArray(candidates.map(::JsonPrimitive)),
"ops[$opIndex] candidates",
)
}
val invokedByTimestamp = LinkedHashMap<Long, MutableList<String>>()
for (entry in op.getValue("invoked").jsonArray) {
val invoked = entry.jsonObject
invokedByTimestamp
.getOrPut(invoked.getValue("invokedAt").longOrNull!!) { mutableListOf() }
.add(invoked.getValue("localId").stringOrNull!!)
}
for ((invokedAt, localIds) in invokedByTimestamp) {
store.markConsumed(localIds, invokedAt)
}
store.reconcileQueuedLocalIds(
candidateLocalIds = candidates,
queuedLocalIds = op.getValue("queuedLocalIds").jsonArray.map { it.stringOrNull!! },
)
}
else -> fail("ops[$opIndex]: unknown op '$kind'")
}
}
private fun decodeResponses(op: JsonObject): List<MessagesResponse> =
HapiJson.decodeFromJsonElement(
ListSerializer(MessagesResponse.serializer()),
op.getValue("responses"),
)
private fun decodeWindowMessage(element: JsonElement): WindowMessage =
HapiJson.decodeFromJsonElement(WindowMessage.serializer(), element)
private fun assertSettled(store: MessageWindowStore, scripted: ScriptedMessagesApi, opIndex: Int) {
assertEquals(0, scripted.remaining, "ops[$opIndex]: scripted response(s) left unconsumed")
val state = store.state.value
assertNull(state.warning, "ops[$opIndex]: store reported a warning")
assertTrue(!state.isSyncingTail && !state.isLoadingMore, "ops[$opIndex]: store still busy after the op settled")
}
private fun assertRequests(op: JsonObject, scripted: ScriptedMessagesApi, opIndex: Int) {
val expected = op["expectedRequests"] ?: return
assertEquals(expected, JsonArray(scripted.requests.toList()), "ops[$opIndex] requests")
}
// ----------------------------------------------------------- projection --
private fun projectOutcome(outcome: OlderLoadOutcome): JsonObject = when (outcome) {
is OlderLoadOutcome.Applied -> buildJsonObject {
put("kind", "applied")
put("hasMore", outcome.hasMore)
put("addedRenderableCount", outcome.addedRenderableCount)
}
is OlderLoadOutcome.Stopped -> buildJsonObject {
put("kind", "stopped")
put("reason", outcome.reason.wire)
}
is OlderLoadOutcome.Failed -> fail("older-page load failed: ${outcome.error.message}")
}
private fun projectWindowState(state: MessageWindowState): JsonObject = buildJsonObject {
put("messages", JsonArray(state.messages.map(::projectMessage)))
put("hasMore", state.hasMore)
put("epoch", state.epoch?.let(::JsonPrimitive) ?: JsonNull)
put("viewMode", state.viewMode.wire)
put("olderCursor", projectCursor(state.oldestPosition?.at, state.oldestPosition?.seq))
put("newestCursor", projectCursor(state.newestPosition?.at, state.newestPosition?.seq))
}
private fun projectMessage(message: WindowMessage): JsonObject = buildJsonObject {
put("id", message.id)
put("localId", message.localId?.let(::JsonPrimitive) ?: JsonNull)
put("seq", message.seq?.let(::JsonPrimitive) ?: JsonNull)
put("createdAt", message.createdAt)
// Wire tri-state: the key appears only when the wire carried it.
when (val invokedAt = message.wire.invokedAt) {
is OptionalField.Present -> put("invokedAt", invokedAt.value?.let(::JsonPrimitive) ?: JsonNull)
OptionalField.Absent -> Unit
}
message.wire.scheduledAt?.let { put("scheduledAt", it) }
message.status?.let { put("status", it.wire) }
put("queued", message.isQueuedForInvocation)
put("optimistic", message.isOptimistic)
}
private fun projectCursor(at: Long?, seq: Long?): JsonElement =
if (at != null && seq != null) {
buildJsonObject {
put("at", at)
put("seq", seq)
}
} else {
JsonNull
}
}
/**
* The scripted transport: serves queued [MessagesResponse]s FIFO and records
* each request in the canonical shape the web harness pinned — `limit` only
* for a latest page; `beforeAt`+`beforeSeq`+`limit` for older pages;
* `afterAt`+`afterSeq`+`untilAt`+`untilSeq`+`epoch`+`limit` (untils
* explicitly null on the first loop request) for tail catch-up.
*/
internal class ScriptedMessagesApi : MessagesApi {
private val queue = ArrayDeque<MessagesResponse>()
val requests = mutableListOf<JsonObject>()
val remaining: Int get() = queue.size
fun begin(responses: List<MessagesResponse>) {
queue.clear()
queue.addAll(responses)
requests.clear()
}
override suspend fun getMessages(sessionId: String, query: MessagesQuery): MessagesResponse {
requests += query.toRequestJson()
return queue.removeFirstOrNull()
?: throw IllegalStateException("scripted MessagesApi exhausted: unexpected getMessages request")
}
override suspend fun getQueuedState(sessionId: String, localIds: List<String>): QueuedStateResponse =
throw UnsupportedOperationException("queued-state ops are replayed via store primitives, like the web runner")
private fun MessagesQuery.toRequestJson(): JsonObject = when (this) {
is MessagesQuery.Latest -> buildJsonObject {
put("limit", limit)
}
is MessagesQuery.Before -> buildJsonObject {
put("beforeAt", beforeAt)
put("beforeSeq", beforeSeq)
put("limit", limit)
}
is MessagesQuery.After -> buildJsonObject {
put("afterAt", afterAt)
put("afterSeq", afterSeq)
put("epoch", epoch)
put("limit", limit)
put("untilAt", untilAt?.let(::JsonPrimitive) ?: JsonNull)
put("untilSeq", untilSeq?.let(::JsonPrimitive) ?: JsonNull)
}
}
}