package com.sptmobile.endpoint import kotlinx.serialization.Serializable import kotlinx.serialization.json.Json import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.booleanOrNull import kotlinx.serialization.json.contentOrNull import kotlinx.serialization.json.longOrNull import kotlinx.serialization.json.jsonArray import kotlinx.serialization.json.jsonObject import kotlinx.serialization.json.jsonPrimitive import java.time.Instant import java.time.format.DateTimeParseException /** * One history-fetch wire row (`rust/link-proto` `HistoryEntry`, verbatim). * `dir` is relative to the endpoint: `in` = the endpoint spoke, `out` = the * phone/host sent to it. */ @Serializable data class HistoryRow( val msg_id: String? = null, val ts_ms: Long, val dir: String, val from: String, val body: String, val json: String? = null, ) /** * One row of the interlaced endpoint view (DESIGN.md §Interlaced view * mechanics): conversation history rows and digest rows merged by timestamp. */ sealed interface TimelineRow { /** Effective timestamp used for the merge; null only when unknowable. */ val tsMs: Long? /** * Stable per-row identity for LazyColumn keying and hoisted UI state * (REQ-HAZARD-DIGEST-CARD-COLLAPSE). The SAME logical row must yield the * SAME key across every idle-tick republish — otherwise Compose re-binds a * shifted row and item-scoped state (an expanded card) resets. Derived from * the exact dedup anchors: msg-id for messages, seq / turn-anchor for * digest rows (never fuzzy). */ val stableKey: String /** A conversation-history message (or a locally-appended own send). */ data class Message( val msgId: String?, override val tsMs: Long, val outbound: Boolean, val from: String, val body: String, ) : TimelineRow { // msg-id when present (the exact axis); else a content-stable fallback // for the rare msg-id-less row — stable across republishes of the same // fetched row. override val stableKey: String get() = "msg:" + (msgId ?: "$tsMs@$from#${body.hashCode()}") } /** The user input that opened a digest turn. [key] is the turn's stable * anchor (input_seq / first entry seq / open-turn ordinal), assigned by * [Timeline.flattenDigest]. */ data class DigestInput( override val tsMs: Long?, val text: String, val partial: Boolean, val key: String, ) : TimelineRow { override val stableKey: String get() = "din:$key" } /** One digest entry (Agent text, ToolSprint, Boundary, Context). [key] is * the entry's stable anchor (its seq, or turn-anchor/ordinal for seqless * rows), assigned by [Timeline.flattenDigest]. */ data class DigestEntry( override val tsMs: Long?, val kind: String, val text: String, val seq: Long?, val key: String, ) : TimelineRow { override val stableKey: String get() = "den:$key" } } // [impl->REQ-ENDPOINT-VIEW-INTERLACE] /** * Pure interlace of history + digest into one timeline (DESIGN.md ruling 8, * §Interlaced view mechanics; KNOWN-HAZARDS 3.1). Free of Android/JNI so the * merge semantics are JVM-unit-testable. * * Rules, all EXACT and never fuzzy (REQ-HAZARD-DUP-ROWS): * - History rows dedup by msg-id (a locally-appended own send collapses with * the same row arriving off a later history fetch). Rows without a msg-id * are kept as-is. * - A digest `Context{kind: owl_message}` row whose `` json-attr * msg-id matches an own send (the session's [DigestViewState.ownSends] or * any outbound history row) is the echo of that send — dropped in favor of * the message row. * - Digest rows keep digest order (seq order is the truth); each row's merge * timestamp is its own `ts` (RFC3339-UTC, public digest contract) when * present, else carried forward from the previous digest row, else backfilled * from the first following row that has one. A digest with no `ts` anywhere * sorts after history (it is the live now-window). * - The merge is stable: neither source's internal order is ever reshuffled; * on equal timestamps history rows land first. */ object Timeline { private val json = Json { ignoreUnknownKeys = true } fun parseHistory(reply: String): List = json.decodeFromString(reply) fun interlace( history: List, localSends: List, digestTurns: List, ownMsgIds: Set, ): List { // History axis: fetched rows + local optimistic sends, msg-id dedup // (first occurrence wins — fetched rows come first, so a local send // collapses into its fetched twin once the host log catches up). val seenMsgIds = mutableSetOf() val messages = (history + localSends).mapNotNull { row -> if (row.msg_id != null && !seenMsgIds.add(row.msg_id)) return@mapNotNull null TimelineRow.Message( msgId = row.msg_id, tsMs = row.ts_ms, outbound = row.dir == "out", from = row.from, body = row.body, ) }.sortedBy { it.tsMs } // Own-send set: session sends + everything we already show as an // outbound message row (exact msg-id axis only). val own = ownMsgIds + (history + localSends).filter { it.dir == "out" }.mapNotNull { it.msg_id } val digestRows = flattenDigest(digestTurns, own) // Stable two-list merge on effective ts; ties → history first. val out = ArrayList(messages.size + digestRows.size) var i = 0 var j = 0 while (i < messages.size || j < digestRows.size) { val takeMessage = when { i == messages.size -> false j == digestRows.size -> true else -> { val dts = digestRows[j].tsMs dts == null || messages[i].tsMs <= dts } } if (takeMessage) out += messages[i++] else out += digestRows[j++] } return out } /** * Digest turns → rows in digest order, own-send echoes dropped, effective * timestamps assigned by carry-forward then head-backfill. */ private fun flattenDigest( turns: List, ownMsgIds: Set, ): List { data class Raw(val row: TimelineRow, val ownTs: Long?) val raw = mutableListOf() for ((turnIdx, turn) in turns.withIndex()) { val partial = turn["partial"]?.jsonPrimitive?.booleanOrNull ?: false val input = turn["input"]?.jsonPrimitive?.contentOrNull // Stable turn anchor. The LIVE turn (partial) has NO input_seq and // all-seqless entries that get seqs assigned as they finalize — so // it must NOT key off any seq, or every heartbeat re-keys its rows // and the list loses its scroll anchor (REQ-HAZARD-DIGEST-CARD- // COLLAPSE). Key it "open" (there is one live turn); closed turns key // off their absolute input_seq (stable even as the window slides). val turnKey = when { partial -> "open" else -> (DigestViewState.inputSeq(turn) ?: DigestViewState.seqsOfTurn(turn).firstOrNull()) ?.toString() ?: "t$turnIdx" } if (input != null) { raw += Raw(TimelineRow.DigestInput(null, input, partial, turnKey), null) } var entryIdx = -1 turn["entries"]?.jsonArray?.forEach { e -> entryIdx++ val entry = e.jsonObject val echo = DigestViewState.echoMsgId(entry) if (echo != null && echo in ownMsgIds) return@forEach val (kind, inner) = entry.entries.firstOrNull() ?: return@forEach val obj = inner.jsonObject val ts = obj["ts"]?.jsonPrimitive?.contentOrNull?.let(::parseTs) val seq = obj["seq"]?.jsonPrimitive?.longOrNull // Identity is (turn, ordinal) — NEVER the seq, which is absent on // a live entry and appears later, which would churn the key. val entryKey = "$turnKey/$entryIdx" val text = when (kind) { "Agent" -> obj["text"]?.jsonPrimitive?.contentOrNull ?: "" "ToolSprint" -> "tools: " + (obj["tools"]?.jsonArray?.joinToString { t -> t.jsonObject["name"]?.jsonPrimitive?.contentOrNull ?: "?" } ?: "") "Boundary" -> obj["kind"]?.jsonPrimitive?.contentOrNull ?: "boundary" "Context" -> obj["body"]?.jsonPrimitive?.contentOrNull ?: "" else -> obj.toString() } raw += Raw(TimelineRow.DigestEntry(ts, kind, text, seq, entryKey), ts) } } // Carry-forward, then backfill the ts-less head from the first known. var last: Long? = null val forward = raw.map { r -> last = r.ownTs ?: last r.row to last } val firstKnown = forward.firstNotNullOfOrNull { it.second } return forward.map { (row, ts) -> val eff = ts ?: firstKnown when (row) { is TimelineRow.DigestInput -> row.copy(tsMs = eff) is TimelineRow.DigestEntry -> row.copy(tsMs = eff ?: row.tsMs) is TimelineRow.Message -> row } } } /** RFC3339-UTC `ts` (public digest contract) → epoch ms; null if unparseable. */ fun parseTs(ts: String): Long? = try { Instant.parse(ts).toEpochMilli() } catch (_: DateTimeParseException) { null } /** * `LinkNative.send` reply, `{"outcome":"...","success":bool}`. Any * success class (SENT/QUEUED) is FINAL — never re-send * (REQ-HAZARD-QUEUED-RETRY). */ @Serializable data class SendResult(val outcome: String, val success: Boolean) fun parseSendResult(reply: String): SendResult = json.decodeFromString(reply) }