package com.sptmobile.endpoint import kotlinx.serialization.json.Json import kotlinx.serialization.json.jsonObject import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertNull import org.junit.Assert.assertTrue import org.junit.Test /** * Kotlin twin of `rust/link-client/src/digest.rs` tests — the two sides * implement the same view semantics, so the same scenarios must pass. */ class DigestViewStateTest { /** A closed turn — `input_seq` present (wire truth: assigned at close). */ private fun turn(inputSeq: Long, entrySeqs: List): String { val entries = entrySeqs.joinToString(",") { """{"Agent":{"text":"t$it","seq":$it}}""" } return """{"input":"input-$inputSeq","input_seq":$inputSeq,"entries":[$entries]}""" } /** The open turn — `partial: true`, NO `input_seq`, entries carry NO * `seq` (seq is assigned at close; probed against the released binary * 2026-07-07). So `seqsOfTurn` is empty — it is the unstable live tail. */ private fun partialTurn(label: String): String = """{"input":"input-$label","entries":[{"Agent":{"text":"t$label"}}],"partial":true}""" /** A CLOSED turn with `input: null` (agent-initiated: a boundary crossing, * a psyche download, or agent output with no user input). It carries NO * `input_seq` — yet it is a real, closed, stable row because its entries * carry `seq`s. Live wire, probed 2026-07-07: null-input turns exist and * are common; "no input_seq" must NOT be read as "open tail". */ private fun nullInputClosedTurn(entrySeq: Long): String = """{"input":null,"entries":[{"Agent":{"text":"ctx$entrySeq","seq":$entrySeq}}]}""" private fun entryOf(json: String) = Json.parseToJsonElement(json).jsonObject // [unit->REQ-ENDPOINT-VIEW-INTERLACE] // Deltas ACCUMULATE into the append-only log: each contributes its turns; // closed turns are logged by seq, the partial turn is the live tail. (The // old positional `from`-truncate is gone — it desynced against the snapshot // merge and flip-flopped the length; the accumulator only grows.) @Test fun deltasTruncateToFromAndAppend() { val v = DigestViewState() val first = """{"version":1,"from":0,"turns":[${turn(1, listOf(2))},${partialTurn("open")}]}""" assertEquals(DigestViewState.DeltaOutcome.Applied, v.applyDelta(first)) assertEquals(2, v.turns.size) assertEquals(1, v.version) // Turn at index 1 closes (replaced, gaining its input_seq) and a new // partial appears. val second = """{"version":2,"from":1,"turns":[${turn(3, listOf(4))},${partialTurn("next")}]}""" assertEquals(DigestViewState.DeltaOutcome.Applied, v.applyDelta(second)) assertEquals(3, v.turns.size) assertEquals(2, v.version) assertEquals(listOf(1L, 2L, 3L, 4L), v.seqs()) } // [unit->REQ-ENDPOINT-VIEW-INTERLACE] // A delta that skips ahead no longer "gaps" — it simply accumulates its // turns alongside what is already logged (the log self-heals by growing; // nothing is dropped or left stale). @Test fun aheadDeltaAccumulatesInsteadOfGapping() { val v = DigestViewState() v.applyDelta("""{"version":1,"from":0,"turns":[${turn(1, listOf(2))}]}""") val ahead = """{"version":5,"from":3,"turns":[${turn(9, listOf(10))}]}""" assertEquals(DigestViewState.DeltaOutcome.Applied, v.applyDelta(ahead)) assertEquals(2, v.turns.size) assertEquals(5, v.version) assertEquals(listOf(1L, 2L, 9L, 10L), v.seqs()) } // [unit->REQ-ENDPOINT-VIEW-INTERLACE] // The cursor is the max seq across turn input_seqs and closed-entry seqs — // Boundary/Context rows and the (unseqed) open turn never carry one and // never move it. @Test fun cursorIsMaxSeqIgnoringSeqlessRows() { val v = DigestViewState() assertNull(v.cursor()) val snap = """{"turns":[ {"input":"i","input_seq":3,"entries":[ {"Agent":{"text":"a","seq":4}}, {"Boundary":{"kind":"clear"}}, {"Context":{"kind":"owl_message","body":"x"}}, {"ToolSprint":{"calls":2,"seq":6}} ]}, ${partialTurn("open")} ]}""" v.applySnapshot(snap) assertEquals(6L, v.cursor()) assertEquals(listOf(3L, 4L, 6L), v.seqs()) } // [unit->REQ-ENDPOINT-VIEW-INTERLACE] // The log only GROWS: even when the host's retained window has moved on // (`after_predates_window`), turns the phone already logged are KEPT — the // phone remembers more than the host's window still holds, and the new // turns are added on top. (The old behaviour wiped the view here.) @Test fun predatedResyncKeepsTheLogAndAddsNew() { val v = DigestViewState() v.applySnapshot("""{"turns":[${turn(1, listOf(2))}]}""") val resync = """{"after_predates_window":true,"turns":[${turn(50, listOf(51))},${partialTurn("open")}]}""" assertEquals(DigestViewState.SnapshotOutcome.Merged, v.applySnapshot(resync)) assertEquals(listOf(1L, 2L, 50L, 51L), v.seqs()) assertEquals(3, v.turns.size) } // [unit->REQ-HAZARD-DUP-ROWS] // Reconnect re-sync overlap: the turn that was open (unseqed) at // disconnect comes back CLOSED in the `--after` snapshot. The stale // unseqed copy is dropped, the closed version lands once — every seq // appears exactly once, no fuzzy matching. @Test fun resyncOverlapNeverDuplicatesRows() { val v = DigestViewState() v.applySnapshot("""{"turns":[${turn(1, listOf(2))},${partialTurn("open")}]}""") assertEquals(2L, v.cursor()) // Re-sync with --after 2: the previously-open turn is closed now // (input_seq 3, entry seq 4), plus a genuinely new turn 5. val resync = """{"turns":[${turn(3, listOf(4))},${turn(5, listOf(6))}]}""" assertEquals(DigestViewState.SnapshotOutcome.Merged, v.applySnapshot(resync)) val seqs = v.seqs() assertEquals(listOf(1L, 2L, 3L, 4L, 5L, 6L), seqs) assertEquals("duplicate seq = duplicate row", seqs.toSet().size, seqs.size) assertEquals(3, v.turns.size) } // [unit->REQ-HAZARD-DUP-ROWS] // THE 2026-07-07 field bug (operator screenshot: perri's digest, the open // doyle turn doubled): the liveness belt re-syncs `--after ` // every idle tick, and the reply re-includes the cursor turn AND the open // (unseqed) turn. The open turn must not accumulate — same snapshot // applied N times leaves exactly one copy. @Test fun idleBeltResyncNeverAccumulatesTheOpenTurn() { val v = DigestViewState() v.applySnapshot("""{"turns":[${turn(1, listOf(2))},${partialTurn("open")}]}""") // Probed --after reply shape: cursor turn + open turn, unchanged. val beltTick = """{"turns":[${turn(1, listOf(2))},${partialTurn("open")}]}""" repeat(3) { assertEquals(DigestViewState.SnapshotOutcome.Merged, v.applySnapshot(beltTick)) assertEquals("open turn must never accumulate", 2, v.turns.size) } // The open turn closes: its seqed version replaces the tail once and // no unseqed ghost remains. v.applySnapshot("""{"turns":[${turn(3, listOf(4))}]}""") assertEquals(2, v.turns.size) assertEquals(listOf(1L, 2L, 3L, 4L), v.seqs()) assertTrue(v.turns.all { DigestViewState.inputSeq(it) != null }) } // [unit->REQ-HAZARD-DUP-ROWS] // THE 2026-07-07 diagnosis: "no input_seq" is NOT "open tail". A CLOSED // null-input turn (boundary / psyche-download / agent-context) carries no // input_seq but IS a stable row via its entry seqs. A liveness-belt resync // returns only strictly-after content (probed: `--after ` returned // ONLY the open turn), so the null-input closed turn is NOT re-delivered — // it must SURVIVE the merge, not be dropped. The drop axis is "no stable // seq anchor" (open/partial + pure-seqless rows), never "no input_seq". @Test fun nullInputClosedTurnSurvivesResync() { val v = DigestViewState() // Initial full window: a seqed turn, a null-input CLOSED agent-context // turn (entry seq 3), and the open turn. v.applySnapshot( """{"turns":[${turn(1, listOf(2))},${nullInputClosedTurn(3)},${partialTurn("open")}]}""" ) assertEquals(3, v.turns.size) assertEquals(3L, v.cursor()) // Belt resync `--after 3`: only the open turn comes back (strictly // after cursor). The null-input closed turn (seq 3) must NOT vanish. assertEquals( DigestViewState.SnapshotOutcome.Merged, v.applySnapshot("""{"turns":[${partialTurn("open")}]}"""), ) assertTrue("null-input closed turn (seq 3) dropped on resync", v.seqs().contains(3L)) assertEquals(listOf(1L, 2L, 3L), v.seqs()) // One seqed turn + one null-input closed turn + one open turn. assertEquals(3, v.turns.size) } // [unit->REQ-HAZARD-DUP-ROWS] // Own-send echo collapses by EXACT msg-id: the Context{owl_message} row's // json attr carries the --json-payload blob verbatim. @Test fun ownSendEchoCollapsesByMsgId() { val v = DigestViewState() v.noteOwnSend("u-1") val echo = entryOf( """{"Context":{ "kind":"owl_message", "body":"hello" }}""" ) assertEquals("u-1", DigestViewState.echoMsgId(echo)) assertTrue(v.ownSends().contains(DigestViewState.echoMsgId(echo)!!)) // Someone ELSE's message with a different msg-id must NOT collapse. val other = entryOf( """{"Context":{ "kind":"owl_message", "body":"hi" }}""" ) assertFalse(v.ownSends().contains(DigestViewState.echoMsgId(other)!!)) // Non-Context rows and non-owl_message Context rows are never echoes. assertNull(DigestViewState.echoMsgId(entryOf("""{"Agent":{"text":"hello","seq":9}}"""))) assertNull(DigestViewState.echoMsgId(entryOf("""{"Context":{"kind":"file","body":"x"}}"""))) } }