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
+12
View File
@@ -19,6 +19,18 @@ android {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
testOptions {
unitTests.all { test ->
// Golden fixtures live at the repo root (outside the Gradle root
// dir); the pagination conformance suite replays them against the
// real MessageWindowStore (same wiring as :core:protocol).
test.systemProperty(
"hapi.fixtures.dir",
rootDir.parentFile.resolve("shared/fixtures").absolutePath,
)
}
}
}
kotlin {
@@ -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)
}
}
}
@@ -0,0 +1,129 @@
package app.hapi.protocol.window
import app.hapi.protocol.wire.OptionalField
import app.hapi.protocol.wire.isUserMessage
import kotlin.math.abs
/**
* Merge/order semantics for window rows — a faithful port of
* `web/src/lib/messages.ts` (`compareMessages`, `mergeMessages`).
*/
object MessageMerge {
/**
* Position comparator: ascending `at = invokedAt ?? createdAt`, ties by
* `seq`, full ties by ASCII id comparison. The web uses `localeCompare`
* for the final tie-break, but the fixtures keep every position pair
* distinct and the fixtures README pins ASCII comparison for native
* ports (`shared/fixtures/README.md`, pagination determinism notes).
*/
val comparator: Comparator<WindowMessage> = Comparator { a, b ->
val aTime = a.invokedAtOrNull ?: a.createdAt
val bTime = b.invokedAtOrNull ?: b.createdAt
if (aTime != bTime) return@Comparator aTime.compareTo(bTime)
val aSeq = a.seq
val bSeq = b.seq
if (aSeq != null && bSeq != null && aSeq != bSeq) return@Comparator aSeq.compareTo(bSeq)
a.id.compareTo(b.id)
}
/**
* Merge [incoming] into [existing]:
* - by-id upsert (incoming wins; a numeric `invokedAt` already known
* locally is preserved when the incoming copy lacks one);
* - stored rows carrying a `localId` evict the optimistic bubble with the
* same `localId`, inheriting its client `status` (when the server row
* has none) and any locally-known numeric `invokedAt`;
* - fallback: an optimistic row already `sent` is dropped when a server
* user message sits within 10 s of the same position;
* - stable sort by [comparator].
*/
fun mergeMessages(existing: List<WindowMessage>, incoming: List<WindowMessage>): List<WindowMessage> {
if (existing.isEmpty()) {
return incoming.sortedWith(comparator)
}
if (incoming.isEmpty()) {
return existing.sortedWith(comparator)
}
val byId = LinkedHashMap<String, WindowMessage>()
for (message in existing) {
byId[message.id] = message
}
for (message in incoming) {
val current = byId[message.id]
// JS `existing.invokedAt != null && msg.invokedAt == null` — keep a
// locally-known invocation stamp when the incoming copy has none
// (explicit null or absent).
byId[message.id] = if (current != null && current.invokedAtOrNull != null && message.invokedAtOrNull == null) {
message.withInvokedAt(current.invokedAtOrNull!!)
} else {
message
}
}
var merged: List<WindowMessage> = byId.values.toList()
val incomingStoredLocalIds = buildSet {
for (message in incoming) {
val localId = message.localId
if (localId != null && !message.isOptimistic) add(localId)
}
}
if (incomingStoredLocalIds.isNotEmpty()) {
val optimisticStatusByLocalId = HashMap<String, MessageStatus>()
val optimisticInvokedAtByLocalId = HashMap<String, Long?>()
for (message in merged) {
val localId = message.localId ?: continue
if (!message.isOptimistic || localId !in incomingStoredLocalIds) continue
message.status?.let { optimisticStatusByLocalId[localId] = it }
if (message.wire.invokedAt is OptionalField.Present) {
optimisticInvokedAtByLocalId[localId] = message.invokedAtOrNull
}
}
merged = merged.filter { message ->
val localId = message.localId
localId == null || localId !in incomingStoredLocalIds || !message.isOptimistic
}
if (optimisticStatusByLocalId.isNotEmpty() || optimisticInvokedAtByLocalId.isNotEmpty()) {
merged = merged.map { message ->
val localId = message.localId ?: return@map message
var updated = message
val preservedStatus = optimisticStatusByLocalId[localId]
if (preservedStatus != null && message.status == null) {
updated = updated.copy(status = preservedStatus)
}
if (optimisticInvokedAtByLocalId.containsKey(localId) && message.invokedAtOrNull == null) {
val optimisticInvokedAt = optimisticInvokedAtByLocalId[localId]
if (optimisticInvokedAt != null) {
updated = updated.withInvokedAt(optimisticInvokedAt)
}
}
updated
}
}
}
val optimisticMessages = merged.filter { it.isOptimistic }
val nonOptimisticMessages = merged.filter { !it.isOptimistic }
val result = nonOptimisticMessages.toMutableList()
for (optimistic in optimisticMessages) {
if (optimistic.status == MessageStatus.Sent) {
// Compare by the position key (invokedAt ?? createdAt): a late
// ack can attach invokedAt long after createdAt, and matching
// on createdAt alone would render duplicates.
val optimisticTime = optimistic.invokedAtOrNull ?: optimistic.createdAt
val hasServerUserMessage = nonOptimisticMessages.any { message ->
message.wire.isUserMessage
&& abs((message.invokedAtOrNull ?: message.createdAt) - optimisticTime) < 10_000
}
if (hasServerUserMessage) continue
}
result.add(optimistic)
}
return result.sortedWith(comparator)
}
}
@@ -0,0 +1,213 @@
package app.hapi.protocol.window
import app.hapi.protocol.wire.stringOrNull
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
/**
* Renderability predicate for window retention — a faithful port of the
* **null-decision tree** of `normalizeDecryptedMessage`
* (`web/src/chat/normalize.ts` + `web/src/chat/normalizeAgent.ts` +
* `shared/src/messages.ts`), used by the web store's
* `shouldRetainWindowMessage`: rows the chat pipeline would hide never enter
* the window (but still advance cursors).
*
* The web returns `null` (⇒ hidden) in exactly two situations:
* 1. agent rows whose content `isSkippableAgentContent` (meta/compact-summary
* Claude output, empty recaps/agy steps, non-chat-visible system rows);
* 2. codex-envelope agent rows (`content.type === 'codex'`) whose payload
* the codex switch of `normalizeAgentRecord` cannot render.
* Everything else — user rows, unknown roles, unparseable envelopes — falls
* back to a raw-stringify bubble and is renderable.
*
* TODO(B-M2a dedup): the full chat-pipeline port owns the complete
* `normalizeAgentRecord`; once it lands, this predicate should be re-expressed
* as `normalize(...) != null` so the two cannot drift.
*/
object MessageRetention {
fun isRenderable(content: JsonElement): Boolean {
val record = unwrapRoleWrappedRecordEnvelope(content) ?: return true
if (record.role != "agent") return true
if (isSkippableAgentContent(record.content)) return false
if (isCodexContent(record.content) && !isRenderableCodexContent(record.content)) return false
return true
}
// ------------------------------------------------------------ envelope --
internal data class RoleWrappedRecord(val role: String, val content: JsonElement)
/** `shared/src/messages.ts` `isRoleWrappedRecord`: object with a string `role` and a `content` key. */
private fun asRoleWrappedRecord(value: JsonElement?): RoleWrappedRecord? {
val obj = value as? JsonObject ?: return null
val role = obj["role"].stringOrNull ?: return null
if (!obj.containsKey("content")) return null
return RoleWrappedRecord(role, obj["content"] ?: JsonNull)
}
/** `shared/src/messages.ts` `unwrapRoleWrappedRecordEnvelope`. */
internal fun unwrapRoleWrappedRecordEnvelope(value: JsonElement?): RoleWrappedRecord? {
asRoleWrappedRecord(value)?.let { return it }
val obj = value as? JsonObject ?: return null
asRoleWrappedRecord(obj["message"])?.let { return it }
(obj["data"] as? JsonObject)?.let { data -> asRoleWrappedRecord(data["message"])?.let { return it } }
(obj["payload"] as? JsonObject)?.let { payload -> asRoleWrappedRecord(payload["message"])?.let { return it } }
return null
}
// ------------------------------------------------- JS-semantics helpers --
/** JS `isObject` (`shared/src/utils.ts`): non-null `typeof 'object'` — arrays included. */
private fun jsIsObject(value: JsonElement?): Boolean =
value is JsonObject || value is JsonArray
/** JS property access: arrays and primitives have no string keys (→ undefined). */
private fun prop(value: JsonElement?, key: String): JsonElement? =
(value as? JsonObject)?.get(key)
/** JS `asString`: the value iff it is a string. */
private fun asString(value: JsonElement?): String? = value.stringOrNull
/** JS `asNumber`: the value iff it is a finite number. */
private fun asNumber(value: JsonElement?): Double? =
(value as? JsonPrimitive)?.takeUnless { it.isString }?.content?.toDoubleOrNull()
?.takeIf { it.isFinite() }
/** JS `Boolean(x)` truthiness over a JSON value. */
private fun jsTruthy(value: JsonElement?): Boolean = when (value) {
null, is JsonNull -> false
is JsonObject, is JsonArray -> true
is JsonPrimitive -> when {
value.isString -> value.content.isNotEmpty()
value.content == "true" -> true
value.content == "false" -> false
else -> value.content.toDoubleOrNull()?.let { it != 0.0 && !it.isNaN() } ?: false
}
}
/** JS `a ?? b` over JSON lookups (absent and `null` fall through). */
private fun coalesce(vararg values: JsonElement?): JsonElement? =
values.firstOrNull { it != null && it !is JsonNull }
// --------------------------------------------------- agent skippability --
private val VISIBLE_CLAUDE_SYSTEM_SUBTYPES = setOf(
"api_error",
"turn_duration",
"microcompact_boundary",
"compact_boundary",
"away_summary",
)
/** `shared/src/messages.ts` `isClaudeChatVisibleMessage`. */
private fun isClaudeChatVisibleMessage(type: JsonElement?, subtype: JsonElement?): Boolean {
val typeString = type.stringOrNull
if (typeString == "rate_limit_event") return false
if (typeString == "tool_progress") return false
if (typeString != "system") return true
return subtype.stringOrNull in VISIBLE_CLAUDE_SYSTEM_SUBTYPES
}
/** `web/src/chat/normalizeAgent.ts` `isSkippableAgentContent`. */
internal fun isSkippableAgentContent(content: JsonElement?): Boolean {
if (prop(content, "type").stringOrNull != "output") return false
val data = prop(content, "data")?.takeIf { jsIsObject(it) } ?: return false
if (jsTruthy(prop(data, "isMeta")) || jsTruthy(prop(data, "isCompactSummary"))) return true
val dataType = prop(data, "type")
val subtype = prop(data, "subtype")
if (
dataType.stringOrNull == "system"
&& subtype.stringOrNull == "away_summary"
&& asString(prop(data, "content"))?.trim().isNullOrEmpty()
) {
return true
}
if (
dataType.stringOrNull == "agy_message"
&& (asString(prop(data, "content")) ?: "").trim().isEmpty()
) {
return true
}
return !isClaudeChatVisibleMessage(dataType, subtype)
}
/** `content.type === AGENT_MESSAGE_PAYLOAD_TYPE` (`'codex'`, `shared/src/modes.ts`). */
internal fun isCodexContent(content: JsonElement?): Boolean =
prop(content, "type").stringOrNull == "codex"
// ------------------------------------------------- codex renderability --
/**
* Whether the codex branch of `normalizeAgentRecord` yields a non-null
* message for this content (`web/src/chat/normalizeAgent.ts:923-1241`).
*/
internal fun isRenderableCodexContent(content: JsonElement?): Boolean {
val data = prop(content, "data")?.takeIf { jsIsObject(it) } ?: return false
val type = prop(data, "type").stringOrNull ?: return false
return when (type) {
"agent-run-start", "agent-run-update", "agent-run-trace" -> true
"generated-image" ->
!asString(coalesce(prop(data, "imageId"), prop(data, "image_id"))).isNullOrEmpty()
"error" -> prop(data, "message").stringOrNull != null
"message" -> prop(data, "message").stringOrNull != null
"reasoning" -> prop(data, "message").stringOrNull != null
"context_compacted" -> true
"compact-summary" -> prop(data, "summary").stringOrNull != null
"token_count" -> hasCodexTokenUsage(data)
"thread_goal_updated" -> hasValidThreadGoal(prop(data, "goal"))
"thread_goal_cleared" -> true
"tool-call" -> prop(data, "callId").stringOrNull != null
"tool-call-result" -> prop(data, "callId").stringOrNull != null
"plan" -> hasPlanEntries(coalesce(prop(data, "entries"), prop(data, "items")) ?: data)
"plan_update" -> hasPlanEntries(
coalesce(prop(data, "plan"), prop(data, "update"), prop(data, "items"), prop(data, "steps")) ?: data
)
else -> false
}
}
/** Null-condition of `normalizeCodexTokenUsage`: `info` object with numeric input+output tokens. */
private fun hasCodexTokenUsage(data: JsonElement): Boolean {
val info = prop(data, "info")?.takeIf { jsIsObject(it) } ?: return false
val usageSource = sequenceOf("last", "lastTokenUsage", "last_token_usage", "total", "totalTokenUsage", "total_token_usage")
.map { prop(info, it) }
.firstOrNull { jsIsObject(it) }
?: info
val input = asNumber(coalesce(prop(usageSource, "inputTokens"), prop(usageSource, "input_tokens")))
val output = asNumber(coalesce(prop(usageSource, "outputTokens"), prop(usageSource, "output_tokens")))
return input != null && output != null
}
/** Null-condition of `normalizeThreadGoal`. */
private fun hasValidThreadGoal(goal: JsonElement?): Boolean {
if (!jsIsObject(goal)) return false
val threadId = asString(coalesce(prop(goal, "threadId"), prop(goal, "thread_id")))
val objective = asString(prop(goal, "objective"))
val status = asString(prop(goal, "status"))
if (threadId.isNullOrEmpty() || objective.isNullOrEmpty() || status.isNullOrEmpty()) return false
return status in setOf("active", "paused", "budgetLimited", "usageLimited", "blocked", "complete")
}
/** Non-empty condition of `normalizePlanEntries`. */
private fun hasPlanEntries(value: JsonElement?): Boolean {
val entries: List<JsonElement> = when {
value is JsonArray -> value
jsIsObject(value) -> sequenceOf("plan", "items", "steps")
.map { prop(value, it) }
.firstOrNull { it is JsonArray }
?.let { (it as JsonArray).toList() }
?: emptyList()
else -> emptyList()
}
return entries.any { entry ->
entry.stringOrNull != null || (
entry is JsonObject && sequenceOf("step", "content", "text", "title", "description")
.any { key -> !asString(prop(entry, key)).isNullOrEmpty() }
)
}
}
}
@@ -0,0 +1,659 @@
package app.hapi.protocol.window
import app.hapi.protocol.wire.MessagesPage
import app.hapi.protocol.wire.hasExplicitNullInvokedAt
import app.hapi.protocol.wire.stringOrNull
import kotlinx.serialization.json.JsonObject
/**
* 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; `:core:data`'s `MessageWindowStore` re-adds it). Every
* function takes the previous [MessageWindowState] and returns the next one;
* nothing here touches clocks, I/O, or coroutines, so the whole surface is
* fixture-comparable.
*/
object MessageWindowLogic {
enum class TrimMode { Append, Prepend }
fun createState(sessionId: String): MessageWindowState = MessageWindowState(sessionId = sessionId)
// -------------------------------------------------------------- helpers --
/** Web `buildState`: swap the messages list, re-derive seq bounds, bump the version on identity change. */
private fun MessageWindowState.withMessages(messages: List<WindowMessage>): MessageWindowState {
if (messages === this.messages) return this
var oldestSeq: Long? = null
var newestSeq: Long? = null
for (message in messages) {
val seq = message.seq ?: continue
oldestSeq = if (oldestSeq == null) seq else minOf(oldestSeq, seq)
newestSeq = if (newestSeq == null) seq else maxOf(newestSeq, seq)
}
return copy(
messages = messages,
oldestSeq = oldestSeq,
newestSeq = newestSeq,
messagesVersion = messagesVersion + 1,
)
}
/** A row's compound position; rows without a server `seq` have none. */
fun messagePosition(message: WindowMessage): MessagePosition? {
val seq = message.seq ?: return null
return MessagePosition(at = message.positionAt, seq = seq)
}
enum class PositionEnd { Oldest, Newest }
fun derivePosition(messages: List<WindowMessage>, end: PositionEnd): MessagePosition? {
var selected: MessagePosition? = null
for (message in messages) {
val candidate = messagePosition(message) ?: continue
val current = selected
if (current == null) {
selected = candidate
continue
}
val comparison = candidate.compareTo(current)
if ((end == PositionEnd.Oldest && comparison < 0) || (end == PositionEnd.Newest && comparison > 0)) {
selected = candidate
}
}
return selected
}
/** Pairwise page cursor (`pagePosition` in the web): both halves or nothing. */
fun pagePosition(at: Long?, seq: Long?): MessagePosition? =
if (at != null && seq != null) MessagePosition(at, seq) else null
private fun maxPosition(a: MessagePosition?, b: MessagePosition?): MessagePosition? = when {
a == null -> b
b == null -> a
a >= b -> a
else -> b
}
// ---------------------------------------------------------------- trims --
private data class Trim(val kept: List<WindowMessage>, val dropped: List<WindowMessage>)
private fun sliceForTrim(items: List<WindowMessage>, limit: Int, mode: TrimMode): Trim {
if (items.size <= limit) return Trim(items, emptyList())
if (limit <= 0) return Trim(emptyList(), items)
return when (mode) {
TrimMode.Prepend -> Trim(items.subList(0, limit).toList(), items.subList(limit, items.size).toList())
TrimMode.Append -> Trim(
items.subList(items.size - limit, items.size).toList(),
items.subList(0, items.size - limit).toList(),
)
}
}
/**
* Codex background-agent trace rows (`agent-run-*`) get their own trim
* bucket so long traces do not evict chat (web `isCodexAgentRunMessage`).
*/
fun isCodexAgentRunMessage(message: WindowMessage): Boolean {
val outer = message.wire.content as? JsonObject ?: return false
if (outer["role"].stringOrNull != "agent") return false
val content = outer["content"] as? JsonObject ?: return false
if (content["type"].stringOrNull != "codex") return false
val data = content["data"] as? JsonObject ?: return false
val type = data["type"].stringOrNull
return type == "agent-run-start" || type == "agent-run-update" || type == "agent-run-trace"
}
/**
* Trim to [regularLimit] while never dropping queued rows: the regular
* budget shrinks by the queued count, `agent-run-*` rows trim against
* their own [AGENT_RUN_WINDOW_SIZE] bucket, and queued rows are re-merged
* afterwards (web `trimPreservingQueued`).
*/
private fun trimPreservingQueued(messages: List<WindowMessage>, regularLimit: Int, mode: TrimMode): Trim {
val queued = messages.filter { it.isQueuedForInvocation }
val queuedIds = queued.mapTo(HashSet()) { it.id }
val nonQueued = messages.filter { it.id !in queuedIds }
val agentRuns = nonQueued.filter { isCodexAgentRunMessage(it) }
val regular = nonQueued.filter { !isCodexAgentRunMessage(it) }
val regularTrim = sliceForTrim(regular, maxOf(0, regularLimit - queued.size), mode)
val agentRunTrim = sliceForTrim(agentRuns, AGENT_RUN_WINDOW_SIZE, mode)
return Trim(
kept = MessageMerge.mergeMessages(regularTrim.kept + agentRunTrim.kept, queued),
dropped = regularTrim.dropped + agentRunTrim.dropped,
)
}
// ------------------------------------------------------------ retention --
/** Web `shouldRetainWindowMessage`: queued, or renderable by the chat pipeline. */
fun shouldRetainWindowMessage(message: WindowMessage): Boolean =
message.isQueuedForInvocation || MessageRetention.isRenderable(message.wire.content)
/**
* How many of [incoming] would add a NEW renderable row (not already
* represented by id or localId) — feeds the `applied` older-load outcome.
*/
fun countNewRenderableMessages(previous: MessageWindowState, incoming: List<WindowMessage>): Int {
val representedIds = previous.messages.mapTo(HashSet()) { it.id }
val representedLocalIds = previous.messages.mapNotNullTo(HashSet()) { it.localId }
var count = 0
for (message in incoming) {
if (!shouldRetainWindowMessage(message)) continue
if (message.id in representedIds) continue
val localId = message.localId
if (localId != null && localId in representedLocalIds) continue
count += 1
representedIds.add(message.id)
if (localId != null) representedLocalIds.add(localId)
}
return count
}
// ---------------------------------------------------------------- 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.
*/
fun mergeIntoWindow(
previous: MessageWindowState,
incoming: List<WindowMessage>,
mode: TrimMode? = null,
regularLimit: Int? = null,
advanceTailRevision: Boolean = false,
): MessageWindowState {
val retainedIncoming = incoming.filter { shouldRetainWindowMessage(it) }
if (retainedIncoming.isEmpty()) {
return previous
}
val effectiveMode = mode
?: if (previous.viewMode == MessageViewMode.History) TrimMode.Prepend else TrimMode.Append
val effectiveLimit = regularLimit
?: if (previous.viewMode == MessageViewMode.History) HISTORY_WINDOW_SIZE else VISIBLE_WINDOW_SIZE
val merged = MessageMerge.mergeMessages(previous.messages, retainedIncoming)
val (kept, dropped) = trimPreservingQueued(merged, effectiveLimit, effectiveMode).let { it.kept to it.dropped }
var next = previous.withMessages(kept)
if (advanceTailRevision) {
next = next.copy(tailRevision = previous.tailRevision + 1)
}
if (dropped.isEmpty()) {
return next
}
if (effectiveMode == TrimMode.Append) {
val oldest = derivePosition(kept, PositionEnd.Oldest)
return next.copy(
hasMore = true,
oldestPosition = oldest ?: next.oldestPosition,
)
}
val newest = derivePosition(kept, PositionEnd.Newest)
return next.copy(
requiresLatestReset = true,
newestPosition = newest,
)
}
// -------------------------------------------------------- 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 **reference**) is
* discarded, while optimistic rows and rows that changed since the
* request left (concurrent SSE) survive the swap (web
* `applyLatestResponse`).
*/
fun applyLatestResponse(
previous: MessageWindowState,
responseMessages: List<WindowMessage>,
page: MessagesPage,
replaceServerRows: Boolean,
requestBaseline: Map<String, WindowMessage>,
): MessageWindowState {
val retainedResponseMessages = responseMessages.filter { shouldRetainWindowMessage(it) }
val concurrentServerRows = previous.messages.filter { message ->
!message.isOptimistic && requestBaseline[message.id] !== message
}
val preserved = if (replaceServerRows) {
previous.messages.filter { message ->
message.isOptimistic || requestBaseline[message.id] !== message
}
} else {
previous.messages
}
val authoritative = MessageMerge.mergeMessages(preserved, retainedResponseMessages)
val incoming = MessageMerge.mergeMessages(authoritative, concurrentServerRows)
val (kept, dropped) = trimPreservingQueued(incoming, VISIBLE_WINDOW_SIZE, TrimMode.Append)
.let { it.kept to it.dropped }
val snapshotHead = pagePosition(page.snapshotHeadAt, page.snapshotHeadSeq)
?: derivePosition(responseMessages, PositionEnd.Newest)
val newestKept = derivePosition(kept, PositionEnd.Newest)
val newest = maxPosition(snapshotHead, newestKept)
val responseOldest = pagePosition(page.nextBeforeAt, page.nextBeforeSeq)
val oldest = when {
dropped.isNotEmpty() -> derivePosition(kept, PositionEnd.Oldest)
replaceServerRows -> responseOldest
else -> responseOldest ?: previous.oldestPosition
}
return previous.withMessages(kept).copy(
hasMore = page.hasMore || (!replaceServerRows && previous.hasMore) || dropped.isNotEmpty(),
epoch = page.epoch,
oldestPosition = oldest,
newestPosition = newest,
tailRevision = previous.tailRevision + 1,
requiresLatestReset = false,
isLoadingMore = if (replaceServerRows) false else previous.isLoadingMore,
olderGeneration = if (replaceServerRows) previous.olderGeneration + 1 else previous.olderGeneration,
warning = null,
)
}
// ------------------------------------------------------------ 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.
*/
fun beginTailSync(previous: MessageWindowState): MessageWindowState = previous.copy(
syncGeneration = previous.syncGeneration + 1,
olderGeneration = previous.olderGeneration + 1,
isSyncingTail = true,
isLoadingMore = false,
warning = null,
)
fun finishTailSync(previous: MessageWindowState, generation: Long, warning: String?): MessageWindowState =
if (previous.syncGeneration != generation) {
previous
} else {
previous.copy(isSyncingTail = false, warning = warning)
}
/**
* 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.
*/
fun applyAfterPage(
previous: MessageWindowState,
responseMessages: List<WindowMessage>,
page: MessagesPage,
nextAfter: MessagePosition?,
): MessageWindowState {
val merged = mergeIntoWindow(previous, responseMessages, advanceTailRevision = true)
if (merged.requiresLatestReset) {
return merged.copy(epoch = page.epoch, warning = null)
}
val newest = maxPosition(nextAfter, merged.newestPosition)
return merged.copy(
epoch = page.epoch,
newestPosition = newest,
warning = null,
)
}
// ------------------------------------------------------------ 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.
*/
fun enterTailMode(previous: MessageWindowState): MessageWindowState {
val trim = trimPreservingQueued(previous.messages, VISIBLE_WINDOW_SIZE, TrimMode.Append)
val forceLatest = previous.requiresLatestReset
val oldest = if (trim.dropped.isNotEmpty()) {
derivePosition(trim.kept, PositionEnd.Oldest)
} else {
previous.oldestPosition
}
return previous.withMessages(trim.kept).copy(
hasMore = previous.hasMore || trim.dropped.isNotEmpty(),
viewMode = MessageViewMode.Tail,
epoch = if (forceLatest) null else previous.epoch,
oldestPosition = oldest,
newestPosition = if (forceLatest) null else previous.newestPosition,
)
}
fun setViewMode(previous: MessageWindowState, mode: MessageViewMode): MessageWindowState = when {
previous.viewMode == mode -> previous
mode == MessageViewMode.History -> previous.copy(viewMode = MessageViewMode.History)
else -> enterTailMode(previous)
}
data class Activation(val state: MessageWindowState, val requestedLatest: Boolean)
/**
* 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.
*/
fun activate(previous: MessageWindowState): Activation {
val trim = trimPreservingQueued(previous.messages, VISIBLE_WINDOW_SIZE, TrimMode.Append)
val forceLatest = previous.requiresLatestReset
val hasUsableCursor = previous.newestPosition != null && previous.epoch != null && !forceLatest
val preferLatestOnActivation = hasUsableCursor && trim.kept.isNotEmpty()
val requestedLatest = preferLatestOnActivation && !previous.preferLatestOnActivation
val invalidateRunningSync = requestedLatest && previous.isSyncingTail
fun MessageWindowState.withActivationUpdates(): MessageWindowState {
if (!preferLatestOnActivation) return this
val flagged = copy(preferLatestOnActivation = true)
return if (invalidateRunningSync) {
flagged.copy(
syncGeneration = flagged.syncGeneration + 1,
olderGeneration = flagged.olderGeneration + 1,
)
} else {
flagged
}
}
if (
previous.viewMode == MessageViewMode.Tail
&& trim.kept.size == previous.messages.size
&& !forceLatest
) {
val state = if (preferLatestOnActivation) previous.withActivationUpdates() else previous
return Activation(state, requestedLatest)
}
val next = enterTailMode(previous)
val state = if (preferLatestOnActivation) next.withActivationUpdates() else next
return Activation(state, requestedLatest)
}
// ---------------------------------------------------------- older pages --
sealed interface OlderPrecheck {
data class Proceed(val before: MessagePosition) : OlderPrecheck
data class Stop(val reason: OlderLoadOutcome.StopReason) : OlderPrecheck
}
fun olderLoadPrecheck(state: MessageWindowState): OlderPrecheck {
val before = state.oldestPosition
if (state.isSyncingTail || state.isLoadingMore) {
return OlderPrecheck.Stop(OlderLoadOutcome.StopReason.Busy)
}
if (!state.hasMore) {
return OlderPrecheck.Stop(OlderLoadOutcome.StopReason.Exhausted)
}
if (before == null) {
return OlderPrecheck.Stop(OlderLoadOutcome.StopReason.Unavailable)
}
return OlderPrecheck.Proceed(before)
}
fun beginOlderLoad(previous: MessageWindowState, generation: Long): MessageWindowState =
previous.copy(olderGeneration = generation, isLoadingMore = true, warning = null)
/**
* 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`.
*/
fun applyOlderEpochMismatch(previous: MessageWindowState, generation: Long): MessageWindowState =
if (previous.olderGeneration != generation) {
previous
} else {
previous.copy(
isLoadingMore = false,
epoch = null,
newestPosition = null,
requiresLatestReset = true,
)
}
/** The `onBeforeApply` veto path: invalidate this load, keep the window. */
fun rejectOlderApply(previous: MessageWindowState): MessageWindowState = previous.copy(
olderGeneration = previous.olderGeneration + 1,
isLoadingMore = false,
warning = null,
)
fun applyOlderResponse(
previous: MessageWindowState,
responseMessages: List<WindowMessage>,
page: MessagesPage,
historyVersion: Long,
): MessageWindowState {
val merged = mergeIntoWindow(
previous,
responseMessages,
mode = TrimMode.Prepend,
regularLimit = OLDER_LOAD_WINDOW_SIZE,
)
return merged.copy(
hasMore = page.hasMore,
epoch = page.epoch,
oldestPosition = pagePosition(page.nextBeforeAt, page.nextBeforeSeq),
isLoadingMore = false,
historyVersion = historyVersion,
warning = null,
)
}
fun failOlderLoad(previous: MessageWindowState, generation: Long, warning: String): MessageWindowState =
if (previous.olderGeneration != generation) {
previous
} else {
previous.copy(isLoadingMore = false, warning = warning)
}
fun cancelOlderLoad(previous: MessageWindowState): MessageWindowState =
if (!previous.isLoadingMore) {
previous
} else {
previous.copy(
olderGeneration = previous.olderGeneration + 1,
isLoadingMore = false,
warning = null,
)
}
// ------------------------------------------------------------ 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.
*/
fun ingestIncoming(previous: MessageWindowState, incoming: List<WindowMessage>): MessageWindowState {
if (incoming.isEmpty()) return previous
val merged = mergeIntoWindow(previous, incoming, advanceTailRevision = true)
if (merged.epoch == null || merged.requiresLatestReset) {
return merged
}
val incomingNewest = derivePosition(incoming, PositionEnd.Newest)
val currentNewest = merged.newestPosition
val newest = if (incomingNewest != null && (currentNewest == null || incomingNewest > currentNewest)) {
incomingNewest
} else {
currentNewest
}
return merged.copy(newestPosition = newest)
}
/** Optimistic append (web `appendOptimisticMessage`): never advances the newest cursor. */
fun appendOptimistic(previous: MessageWindowState, message: WindowMessage): MessageWindowState =
mergeIntoWindow(
previous,
listOf(message),
mode = if (previous.viewMode == MessageViewMode.History) TrimMode.Prepend else TrimMode.Append,
advanceTailRevision = true,
)
fun updateStatus(previous: MessageWindowState, localId: String, status: MessageStatus): MessageWindowState {
if (localId.isEmpty()) return previous
var changed = false
val messages = previous.messages.map { message ->
if (message.localId != localId || message.status == status) {
message
} else {
changed = true
message.copy(status = status)
}
}
return if (changed) previous.withMessages(messages) else previous
}
/** `message-cancelled` / optimistic DELETE removal: matches localId OR id; idempotent. */
fun removeByLocalIdOrId(previous: MessageWindowState, localId: String): MessageWindowState {
if (localId.isEmpty()) return previous
val messages = previous.messages.filter { it.localId != localId && it.id != localId }
return if (messages.size == previous.messages.size) previous else previous.withMessages(messages)
}
/**
* SSE `messages-consumed` (web `markMessagesConsumed`): stamp `invokedAt`
* and flip status to `sent` on matching rows (skip `failed`), then
* re-sort — the row moves from its enqueue position to its invocation
* position. Never advances the newest cursor.
*/
fun markConsumed(previous: MessageWindowState, localIds: List<String>, invokedAt: Long): MessageWindowState {
if (localIds.isEmpty()) return previous
val idSet = localIds.toSet()
var changed = false
val updated = previous.messages.map { message ->
val localId = message.localId
if (localId == null || localId !in idSet || message.status == MessageStatus.Failed) {
return@map message
}
val needsStatus = message.status != MessageStatus.Sent
val needsInvokedAt = message.wire.hasExplicitNullInvokedAt
if (!needsStatus && !needsInvokedAt) return@map message
changed = true
var next = message
if (needsStatus) next = next.copy(status = MessageStatus.Sent)
if (needsInvokedAt) next = next.withInvokedAt(invokedAt)
next
}
if (!changed) return previous
return previous
.withMessages(MessageMerge.mergeMessages(emptyList(), updated))
.copy(tailRevision = previous.tailRevision + 1)
}
// -------------------------------------------------- queued reconciliation --
/** Web `isQueuedReconcileCandidate`. */
private fun isQueuedReconcileCandidate(message: WindowMessage): Boolean {
if (message.localId == null || !message.isQueuedForInvocation) return false
if (!message.isOptimistic) return true
return message.status == MessageStatus.Queued || message.status == MessageStatus.Sent
}
/** Candidate localIds for the queued-state round trip, in window order. */
fun queuedReconcileCandidateLocalIds(state: MessageWindowState): List<String> {
val localIds = LinkedHashSet<String>()
for (message in state.messages) {
if (isQueuedReconcileCandidate(message)) {
localIds.add(message.localId!!)
}
}
return localIds.toList()
}
/**
* 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.
*/
fun reconcileQueuedLocalIds(
previous: MessageWindowState,
candidateLocalIds: List<String>,
queuedLocalIds: List<String>,
): MessageWindowState {
if (candidateLocalIds.isEmpty()) return previous
val candidates = candidateLocalIds.toSet()
val queued = queuedLocalIds.toSet()
val messages = previous.messages.filter { message ->
val localId = message.localId
if (localId == null || localId !in candidates) return@filter true
localId in queued || !isQueuedReconcileCandidate(message)
}
return if (messages.size == previous.messages.size) previous else previous.withMessages(messages)
}
// ---------------------------------------------------------- persistence --
/** Web `shouldPersistState`. */
fun shouldPersist(state: MessageWindowState): Boolean =
state.messages.isNotEmpty()
|| state.hasMore
|| state.epoch != null
|| state.oldestPosition != null
|| state.newestPosition != null
fun 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.
*/
fun hydrate(sessionId: String, persisted: PersistedMessageWindow): MessageWindowState {
val restored = persisted.messages.map { message ->
if (message.status != MessageStatus.Sending) {
message
} else {
message.copy(
status = if (message.wire.hasExplicitNullInvokedAt) MessageStatus.Queued else MessageStatus.Sent,
)
}
}
val oldest = pagePosition(persisted.oldestPositionAt, persisted.oldestPositionSeq)
val newest = pagePosition(persisted.newestPositionAt, persisted.newestPositionSeq)
val epoch = persisted.epoch?.takeIf { it >= 0 }
return createState(sessionId)
.withMessages(MessageMerge.mergeMessages(emptyList(), restored))
.copy(
hasMore = persisted.hasMore,
oldestPosition = oldest,
newestPosition = newest,
epoch = epoch,
requiresLatestReset = persisted.messages.isNotEmpty() && (newest == null || epoch == null),
)
}
// ---------------------------------------------------------------- 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.
*/
fun seededState(source: MessageWindowState, target: MessageWindowState): MessageWindowState =
createState(target.sessionId)
.withMessages(source.messages.toList())
.copy(
hasMore = source.hasMore,
tailRevision = source.tailRevision,
oldestPosition = source.oldestPosition,
requiresLatestReset = true,
syncGeneration = target.syncGeneration + 1,
olderGeneration = target.olderGeneration + 1,
)
}
@@ -0,0 +1,113 @@
package app.hapi.protocol.window
import kotlinx.serialization.Serializable
/**
* 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 (`:core:data` `MessageWindowStore`) drives them.
*/
/** Windowing constants — normative, from the web reference. */
const val VISIBLE_WINDOW_SIZE: Int = 400
const val HISTORY_WINDOW_SIZE: Int = 600
const val OLDER_LOAD_WINDOW_SIZE: Int = 800
const val AGENT_RUN_WINDOW_SIZE: Int = 800
const val PAGE_SIZE: Int = 200
enum class MessageViewMode(val wire: String) {
/** Following the live bottom; trims from the top. */
Tail("tail"),
/** Scrolled back; trims from the bottom and may force a latest reset. */
History("history"),
}
/**
* Compound paging position `(at, seq)` where `at = invokedAt ?? createdAt` —
* both halves always travel together (`pagination.md` "Position key").
*/
data class MessagePosition(val at: Long, val seq: Long) : Comparable<MessagePosition> {
override fun compareTo(other: MessagePosition): Int =
if (at != other.at) at.compareTo(other.at) else seq.compareTo(other.seq)
}
/** Outcome of one older-page load (`fetchOlderMessages` in the web). */
sealed interface OlderLoadOutcome {
data class Applied(
val historyVersion: Long,
val hasMore: Boolean,
val addedRenderableCount: Int,
) : OlderLoadOutcome
data class Stopped(val reason: StopReason) : OlderLoadOutcome
data class Failed(val error: Throwable) : OlderLoadOutcome
enum class StopReason(val wire: String) {
Unavailable("unavailable"),
Busy("busy"),
Invalidated("invalidated"),
EpochReset("epoch-reset"),
Exhausted("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`).
*/
data class MessageWindowState(
val sessionId: String,
/** Window rows in position order (queued rows re-merged after each trim). */
val messages: List<WindowMessage> = emptyList(),
/** Older history exists (server flag, or rows were trimmed away). */
val hasMore: Boolean = false,
/** min/max `seq` over [messages] — derived, kept for UI parity. */
val oldestSeq: Long? = null,
val newestSeq: Long? = null,
/** Cached server epoch; null until the first page (or after invalidation). */
val epoch: Long? = null,
val isSyncingTail: Boolean = false,
val isLoadingMore: Boolean = false,
val warning: String? = null,
val viewMode: MessageViewMode = MessageViewMode.Tail,
/** Bumped whenever the [messages] list instance changes. */
val messagesVersion: Long = 0,
/** Bumped per applied older page (scroll-anchoring handle). */
val historyVersion: Long = 0,
/** Bumped on tail-side content changes (auto-scroll handle). */
val tailRevision: Long = 0,
/** Next `before` request position (web `oldestPositionAt/Seq`). */
val oldestPosition: MessagePosition? = null,
/** Next `after` request position (web `newestPositionAt/Seq`). */
val newestPosition: MessagePosition? = null,
/** Cursors are unusable — the next tail sync must fetch a fresh latest page. */
val requiresLatestReset: Boolean = false,
/** Re-activation with persisted state: fetch the current tail first. */
val preferLatestOnActivation: Boolean = false,
/** Invalidates in-flight tail syncs (compare-and-ignore). */
val syncGeneration: Long = 0,
/** Invalidates in-flight older-page loads. */
val olderGeneration: Long = 0,
)
/**
* 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.
*/
@Serializable
data class PersistedMessageWindow(
val messages: List<WindowMessage> = emptyList(),
val hasMore: Boolean = false,
val oldestPositionAt: Long? = null,
val oldestPositionSeq: Long? = null,
val newestPositionAt: Long? = null,
val newestPositionSeq: Long? = null,
val epoch: Long? = null,
)
@@ -0,0 +1,160 @@
package app.hapi.protocol.window
import app.hapi.protocol.wire.AttachmentMetadata
import app.hapi.protocol.wire.DecryptedMessage
import app.hapi.protocol.wire.OptionalField
import app.hapi.protocol.wire.hasExplicitNullInvokedAt
import app.hapi.protocol.wire.isUserMessage
import app.hapi.protocol.wire.stringOrNull
import kotlinx.serialization.KSerializer
import kotlinx.serialization.Serializable
import kotlinx.serialization.SerializationException
import kotlinx.serialization.builtins.ListSerializer
import kotlinx.serialization.descriptors.SerialDescriptor
import kotlinx.serialization.encoding.Decoder
import kotlinx.serialization.encoding.Encoder
import kotlinx.serialization.json.JsonDecoder
import kotlinx.serialization.json.JsonEncoder
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put
/**
* Client-side send state of an optimistic user message. The web reference
* extends the wire `DecryptedMessage` with `status?: MessageStatus`
* (`web/src/types/api.ts`); the wire never carries it — servers echo rows
* without a status and the client re-attaches it during merge.
*/
enum class MessageStatus(val wire: String) {
Queued("queued"),
Sending("sending"),
Sent("sent"),
Failed("failed"),
;
companion object {
fun fromWire(value: String?): MessageStatus? = entries.firstOrNull { it.wire == value }
}
}
/**
* One row of the message window: the wire [DecryptedMessage] plus the
* client-side [status]. Mirrors the web's `DecryptedMessage & {status?}`.
*
* Identity matters: `applyLatestResponse`'s request-baseline comparison is by
* **reference** (web `!==`), so transitions must only create new instances for
* rows they actually change — which every function in this package does.
*/
@Serializable(with = WindowMessageSerializer::class)
data class WindowMessage(
val wire: DecryptedMessage,
val status: MessageStatus? = null,
) {
val id: String get() = wire.id
val seq: Long? get() = wire.seq
val localId: String? get() = wire.localId
val createdAt: Long get() = wire.createdAt
/** Position time `invokedAt ?? createdAt` (collapsed tri-state). */
val positionAt: Long get() = wire.positionAt
val invokedAtOrNull: Long? get() = wire.invokedAtOrNull
/** A row is optimistic iff it has a localId and `id === localId`. */
val isOptimistic: Boolean
get() = wire.localId != null && wire.id == wire.localId
/**
* Web `isQueuedForInvocation`: a user message whose `invokedAt` is an
* explicit `null` and whose send did not fail. Only these rows sit in the
* queued bar and survive window trims.
*/
val isQueuedForInvocation: Boolean
get() = wire.isUserMessage && wire.hasExplicitNullInvokedAt && status != MessageStatus.Failed
/** Copy with `invokedAt` stamped to an explicit number. */
fun withInvokedAt(invokedAt: Long): WindowMessage =
copy(wire = wire.copy(invokedAt = OptionalField.Present(invokedAt)))
}
fun DecryptedMessage.asWindowMessage(status: MessageStatus? = null): WindowMessage =
WindowMessage(this, status)
/**
* Serializes as the wire object plus an optional `status` key — the same
* shape the web persists (and the pagination fixtures use for op inputs).
*/
internal object WindowMessageSerializer : KSerializer<WindowMessage> {
override val descriptor: SerialDescriptor =
SerialDescriptor("app.hapi.protocol.window.WindowMessage", JsonObject.serializer().descriptor)
override fun deserialize(decoder: Decoder): WindowMessage {
val input = decoder as? JsonDecoder
?: throw SerializationException("WindowMessage supports JSON only")
val obj = input.decodeJsonElement() as? JsonObject
?: throw SerializationException("WindowMessage must be a JSON object")
return WindowMessage(
wire = input.json.decodeFromJsonElement(DecryptedMessage.serializer(), obj),
status = MessageStatus.fromWire(obj["status"].stringOrNull),
)
}
override fun serialize(encoder: Encoder, value: WindowMessage) {
val output = encoder as? JsonEncoder
?: throw SerializationException("WindowMessage supports JSON only")
val wireObject = output.json.encodeToJsonElement(DecryptedMessage.serializer(), value.wire) as JsonObject
output.encodeJsonElement(buildJsonObject {
wireObject.forEach { (key, element) -> put(key, element) }
value.status?.let { put("status", JsonPrimitive(it.wire)) }
})
}
}
/**
* Builds the optimistic row appended on send, mirroring
* `createOptimisticMessage` in `web/src/hooks/mutations/useSendMessage.ts`
* and the contract's "Optimistic sends" lifecycle: `id = localId`,
* `seq = null`, explicit `invokedAt: null` (so the strict-null queued check
* matches), content `{role:'user', content:{type:'text', text, attachments?},
* meta:{deliveryMode}}`.
*/
fun buildOptimisticMessage(
localId: String,
text: String,
createdAt: Long,
attachments: List<AttachmentMetadata>? = null,
scheduledAt: Long? = null,
deliveryMode: String = "queue",
status: MessageStatus = MessageStatus.Sending,
): WindowMessage {
val content = buildJsonObject {
put("role", "user")
put("content", buildJsonObject {
put("type", "text")
put("text", text)
if (attachments != null) {
put(
"attachments",
app.hapi.protocol.wire.HapiJson.encodeToJsonElement(
ListSerializer(AttachmentMetadata.serializer()),
attachments,
),
)
}
})
put("meta", buildJsonObject { put("deliveryMode", deliveryMode) })
}
return WindowMessage(
wire = DecryptedMessage(
id = localId,
seq = null,
localId = localId,
content = content,
createdAt = createdAt,
invokedAt = OptionalField.Present(null),
scheduledAt = scheduledAt,
),
status = status,
)
}
@@ -0,0 +1,23 @@
package app.hapi.protocol.wire
/**
* Wire-level message predicates shared by the window state machine and (later)
* the chat pipeline. Ported from `web/src/lib/messages.ts`.
*/
/**
* `true` when [DecryptedMessage.content] is a role-wrapped envelope with
* `role: "user"` (web `isUserMessage`). Arrays and primitives have no `role`
* key and are not user messages.
*/
val DecryptedMessage.isUserMessage: Boolean
get() = content.objOrNull?.get("role").stringOrNull == "user"
/**
* `true` when `invokedAt` is an **explicit** wire `null` — the strict-null
* half of the queued predicate (`docs/api/client-contract/pagination.md`
* "Queued semantics"). An absent field (pre-V8 hubs) means already-invoked
* and returns `false`.
*/
val DecryptedMessage.hasExplicitNullInvokedAt: Boolean
get() = invokedAt is OptionalField.Present && invokedAt.value == null
@@ -0,0 +1,121 @@
package app.hapi.protocol.window
import app.hapi.protocol.wire.DecryptedMessage
import app.hapi.protocol.wire.OptionalField
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put
/**
* Merge/retention edges the pagination fixtures deliberately avoid: full
* position ties (the fixtures keep every `(at, seq)` pair distinct so the
* web's `localeCompare` never decides an order — natives pin ASCII), the 10s
* no-echo dedup fallback, invokedAt preservation across a stale refetch, and
* the skippable/codex retention predicate.
*/
class MessageMergeTest {
private fun userRow(
id: String,
createdAt: Long,
localId: String? = null,
seq: Long? = null,
invokedAt: OptionalField<Long?> = OptionalField.Present(null),
status: MessageStatus? = null,
): WindowMessage = WindowMessage(
wire = DecryptedMessage(
id = id,
seq = seq,
localId = localId,
createdAt = createdAt,
invokedAt = invokedAt,
content = buildJsonObject {
put("role", "user")
put("content", buildJsonObject {
put("type", "text")
put("text", id)
})
},
),
status = status,
)
@Test
fun `full position ties break by ASCII id comparison`() {
// 'B' (0x42) sorts before 'a' (0x61) in ASCII; locale-aware collation
// (the web's localeCompare) would typically order them the other way.
val rows = listOf(
userRow("row-a", createdAt = 1000),
userRow("row-B", createdAt = 1000),
)
val merged = MessageMerge.mergeMessages(emptyList(), rows)
assertEquals(listOf("row-B", "row-a"), merged.map { it.id })
}
@Test
fun `an optimistic sent row is dropped when a server user row lands within ten seconds`() {
val optimistic = userRow(
"local-1",
createdAt = 10_000,
localId = "local-1",
status = MessageStatus.Sent,
)
// Server row WITHOUT a localId echo, 9 999 ms away by position.
val server = userRow("srv-9", createdAt = 19_999, seq = 9, invokedAt = OptionalField.Absent)
val merged = MessageMerge.mergeMessages(listOf(optimistic), listOf(server))
assertEquals(listOf("srv-9"), merged.map { it.id })
// At exactly 10 000 ms the fallback does not fire.
val far = userRow("srv-far", createdAt = 20_000, seq = 10, invokedAt = OptionalField.Absent)
val kept = MessageMerge.mergeMessages(listOf(optimistic), listOf(far))
assertEquals(listOf("local-1", "srv-far"), kept.map { it.id })
}
@Test
fun `a locally-known invokedAt survives a stale refetch of the same row`() {
val stamped = userRow("srv-1", createdAt = 1000, seq = 1, invokedAt = OptionalField.Present(5000))
val stale = userRow("srv-1", createdAt = 1000, seq = 1, invokedAt = OptionalField.Present(null))
val merged = MessageMerge.mergeMessages(listOf(stamped), listOf(stale))
assertEquals(5000L, merged.single().invokedAtOrNull)
}
@Test
fun `retention hides skippable output and unrenderable codex payloads only`() {
fun agentContent(inner: kotlinx.serialization.json.JsonObject) = buildJsonObject {
put("role", "agent")
put("content", inner)
}
// Meta system output — hidden.
assertTrue(
!MessageRetention.isRenderable(agentContent(buildJsonObject {
put("type", "output")
put("data", buildJsonObject {
put("type", "system")
put("isMeta", true)
})
}))
)
// codex row with no message payload — hidden.
assertTrue(
!MessageRetention.isRenderable(agentContent(buildJsonObject {
put("type", "codex")
put("data", buildJsonObject { put("type", "unknown-kind") })
}))
)
// codex text message — renderable.
assertTrue(
MessageRetention.isRenderable(agentContent(buildJsonObject {
put("type", "codex")
put("data", buildJsonObject {
put("type", "message")
put("message", "hi")
})
}))
)
// Unparseable envelope falls back to a stringify bubble — renderable.
assertTrue(MessageRetention.isRenderable(buildJsonObject { put("whatever", 1) }))
}
}