feat(android): decode the two server frames the client was throwing away

Both had shipped on the server for a while and Android silently discarded
them, which is worse than not supporting them — the UI looked correct while
missing information the user needed.

W2 `queue`: `ServerMessageType` had no member for it, so `decodeServer`
returned null and `SessionEngine.onFrame` counted it as undecodable and
dropped it. The "N queued" badge could therefore never appear on Android,
on any device, ever. Adding the branch made the compiler flag the
non-exhaustive `when` in the engine, which is the same gap seen from the
other side. A negative depth drops the frame rather than feeding nonsense
to a badge; the next broadcast carries the real value.

W1 `status.preview`: not modelled at all, so a remote approval was BLIND —
the gate could show that `Bash` wanted to run, but not what it wanted to
run, and the same for an Edit's diff. Approving blind defeats the point of
the remote gate, which exists so whoever is holding the phone can make an
informed call. `ApprovalPreview` now models both variants and
`GateState.preview` carries it to the UI.

Two decode rules are load-bearing and pinned by tests:

  - A broken preview must never cost the FRAME. `pending` is what makes the
    gate appear at all, so trading a missing preview for a missing gate
    would be a bad bargain. Every malformed shape — null, non-object, no
    kind, unknown kind, command without text, diff without a file — degrades
    the preview to null and still yields the Status.
  - Unknown values are kept, not coerced. A diff line kind or file status
    this client has never seen renders as itself; dropping a line we cannot
    classify would hide part of what is being approved. Only a diff file
    missing its paths is dropped, because then the preview cannot be trusted
    to describe the right file.

A sustained pending refreshes the preview without minting a new epoch: the
server's bounded render can arrive a frame after the gate rises, and a
decision tapped in between has to stay valid.

The preview's diff subset lives in :wire-protocol rather than reusing the
:app diff model — :session-core may not depend on :app, and new wire types
belong in the frozen contract module (CLAUDE.md).

Verified: :wire-protocol:test :session-core:test + both koverVerify green;
16 new tests (10 codec, 6 engine-level).
This commit is contained in:
Yaojia Wang
2026-07-30 09:39:50 +02:00
parent 3e5cfdc1cf
commit 3fe719f874
9 changed files with 528 additions and 6 deletions

View File

@@ -1,5 +1,6 @@
package wang.yaojia.webterm.session
import wang.yaojia.webterm.wire.ApprovalPreview
import wang.yaojia.webterm.wire.ApproveMode
import wang.yaojia.webterm.wire.ClientMessage
import wang.yaojia.webterm.wire.GateKind
@@ -22,6 +23,15 @@ public data class GateState(
val detail: String?,
/** Rising-edge counter; see type doc. */
val epoch: Int,
/**
* W1 — the command or diff this gate is asking about, so the user is not approving blind.
*
* `null` is a NORMAL state, not an error: the server only sends a preview for a held Bash/Edit-family
* tool, and a malformed one decodes to null rather than costing us the gate. UI must therefore render
* a previewless gate as an ordinary gate, never as a failure. Every string inside is UNTRUSTED and
* must render inert (plan §8).
*/
val preview: ApprovalPreview? = null,
) {
/** The action set for this gate: plan → three-way, tool → two-way. */
public val affordances: List<Affordance>

View File

@@ -1,5 +1,7 @@
package wang.yaojia.webterm.session
import wang.yaojia.webterm.wire.ApprovalPreview
import wang.yaojia.webterm.wire.GateKind
/**
@@ -29,7 +31,12 @@ public class GateTracker private constructor(
private val lastEpoch: Int,
) {
/** Feeds one `status` frame's gate-relevant fields; returns the next snapshot. */
public fun reduce(pending: Boolean, gate: GateKind?, detail: String?): GateTracker {
public fun reduce(
pending: Boolean,
gate: GateKind?,
detail: String?,
preview: ApprovalPreview? = null,
): GateTracker {
if (!pending) {
// Falling edge (or still idle): gate lifted, epoch counter retained.
return GateTracker(current = null, lastEpoch = lastEpoch)
@@ -38,12 +45,14 @@ public class GateTracker private constructor(
val held = current
if (held != null) {
// Sustained pending: same epoch, refresh kind/detail from the frame.
val refreshed = GateState(kind = kind, detail = detail, epoch = held.epoch)
// Refresh the preview too: the server may send the held status again with a preview it did
// not have on the rising edge (the bounded render can arrive a frame late).
val refreshed = GateState(kind = kind, detail = detail, epoch = held.epoch, preview = preview)
return GateTracker(current = refreshed, lastEpoch = lastEpoch)
}
// Rising edge: mint the next epoch (the ONLY place it increments).
val nextEpoch = lastEpoch + 1
val risen = GateState(kind = kind, detail = detail, epoch = nextEpoch)
val risen = GateState(kind = kind, detail = detail, epoch = nextEpoch, preview = preview)
return GateTracker(current = risen, lastEpoch = nextEpoch)
}

View File

@@ -281,6 +281,7 @@ public class SessionEngine(
is ServerMessage.Output -> { emit(Output(msg.data)); null }
is ServerMessage.Status -> { onStatus(msg); null }
is ServerMessage.Telemetry -> { emit(Telemetry(msg.telemetry)); null }
is ServerMessage.Queue -> { emit(Queued(msg.length)); null }
is ServerMessage.Exit -> {
emit(Exited(msg.code, msg.reason))
EndCause.Terminal
@@ -299,7 +300,7 @@ public class SessionEngine(
private fun onStatus(status: ServerMessage.Status) {
val previous = gateTracker.current
gateTracker = gateTracker.reduce(status.pending, status.gate, status.detail)
gateTracker = gateTracker.reduce(status.pending, status.gate, status.detail, status.preview)
val next = gateTracker.current
if (next != previous) emit(Gate(next))
}

View File

@@ -27,3 +27,13 @@ public data class Digest(val digest: AwayDigest) : SessionEvent
/** Latest statusLine telemetry broadcast (mirrors the server `telemetry` frame, B2). */
public data class Telemetry(val telemetry: StatusTelemetry) : SessionEvent
/**
* W2 — pending-inject queue depth for this session, broadcast to every attached device so all mirrors
* agree on the "N queued" badge.
*
* The server has always sent this frame; Android had no `ServerMessageType` member for it, so
* `decodeServer` returned null and `SessionEngine` discarded it as undecodable. [length] is
* non-negative — the codec drops a negative depth rather than passing nonsense to a badge.
*/
public data class Queued(val length: Int) : SessionEvent

View File

@@ -0,0 +1,168 @@
package wang.yaojia.webterm.session
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertNotNull
import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test
import wang.yaojia.webterm.testsupport.FakeTermTransport
import wang.yaojia.webterm.wire.ApprovalPreview
import wang.yaojia.webterm.wire.HostEndpoint
import wang.yaojia.webterm.wire.TermTransport
/**
* Engine-level proof for the two frames Android used to lose (W1 `status.preview`, W2 `queue`).
*
* `MessageCodec` decoding them is necessary but not sufficient: the reason they were invisible is that
* `SessionEngine.onFrame` is where a decoded frame becomes a `SessionEvent`, and a frame with no branch
* there is discarded just as silently as one that fails to decode. These tests drive the real engine
* through the fake transport so a future refactor that drops the branch fails here.
*/
@DisplayName("SessionEngine — queue depth and approval preview reach the UI")
class SessionEngineQueueAndPreviewTest {
private fun endpoint(): HostEndpoint = HostEndpoint.fromBaseUrl("http://localhost:3000")!!
private fun TestScope.newEngine(transport: TermTransport): SessionEngine = SessionEngine(
transport = transport,
endpoint = endpoint(),
scope = backgroundScope,
nowMs = { 0L },
)
private fun TestScope.collectEvents(engine: SessionEngine): List<SessionEvent> {
val seen = mutableListOf<SessionEvent>()
backgroundScope.launch { engine.events.collect { seen += it } }
return seen
}
private fun TestScope.started(transport: FakeTermTransport): Pair<SessionEngine, List<SessionEvent>> {
val engine = newEngine(transport)
val events = collectEvents(engine)
engine.start()
testScheduler.runCurrent()
return engine to events
}
@Test
@DisplayName("a queue frame becomes a Queued event instead of being dropped")
fun queueFrameSurfaces() = runTest {
val transport = FakeTermTransport()
val (_, events) = started(transport)
transport.emit("""{"type":"queue","length":4}""")
testScheduler.runCurrent()
assertEquals(
listOf(4),
events.filterIsInstance<Queued>().map { it.length },
"before this fix `queue` had no ServerMessageType member, so decodeServer returned null " +
"and onFrame discarded it as undecodable — the badge could never appear",
)
}
@Test
@DisplayName("a malformed queue frame is dropped without disturbing the stream")
fun malformedQueueIsInert() = runTest {
val transport = FakeTermTransport()
val (_, events) = started(transport)
transport.emit("""{"type":"queue","length":-3}""")
transport.emit("""{"type":"queue"}""")
transport.emit("""{"type":"queue","length":1}""")
testScheduler.runCurrent()
assertEquals(listOf(1), events.filterIsInstance<Queued>().map { it.length })
}
@Test
@DisplayName("a held gate carries the command preview, so the user is not approving blind")
fun gateCarriesCommandPreview() = runTest {
val transport = FakeTermTransport()
val (_, events) = started(transport)
transport.emit(
"""{"type":"status","status":"waiting","pending":true,"gate":"tool","detail":"Bash",
"preview":{"kind":"command","text":"git push --force","truncated":false}}""",
)
testScheduler.runCurrent()
val gate = events.filterIsInstance<Gate>().last().gate
assertNotNull(gate, "the gate itself must still be emitted")
assertEquals(
ApprovalPreview.Command("git push --force", truncated = false),
gate!!.preview,
"the whole point of W1: show WHAT is being approved, not just the tool name",
)
}
@Test
@DisplayName("a preview that arrives a frame late is picked up on the sustained status")
fun previewCanArriveLate() = runTest {
val transport = FakeTermTransport()
val (_, events) = started(transport)
// Rising edge with no preview yet — the server's bounded render is not always ready in time.
transport.emit("""{"type":"status","status":"waiting","pending":true,"gate":"tool"}""")
testScheduler.runCurrent()
val risen = events.filterIsInstance<Gate>().last().gate!!
assertNull(risen.preview)
transport.emit(
"""{"type":"status","status":"waiting","pending":true,"gate":"tool",
"preview":{"kind":"command","text":"ls -la"}}""",
)
testScheduler.runCurrent()
val refreshed = events.filterIsInstance<Gate>().last().gate!!
assertEquals(ApprovalPreview.Command("ls -la"), refreshed.preview)
assertEquals(
risen.epoch,
refreshed.epoch,
"a sustained pending must NOT mint a new epoch — a decision tapped before the preview " +
"landed has to stay valid, or the preview arriving would silently invalidate the tap",
)
}
@Test
@DisplayName("a broken preview still yields the gate")
fun brokenPreviewStillGates() = runTest {
val transport = FakeTermTransport()
val (_, events) = started(transport)
transport.emit(
"""{"type":"status","status":"waiting","pending":true,"gate":"tool","preview":{"kind":"nope"}}""",
)
testScheduler.runCurrent()
val gate = events.filterIsInstance<Gate>().lastOrNull()?.gate
assertNotNull(gate, "losing the gate because its preview was malformed would be the worse bug")
assertNull(gate!!.preview)
}
@Test
@DisplayName("lifting the gate clears the preview with it")
fun liftingClearsPreview() = runTest {
val transport = FakeTermTransport()
val (_, events) = started(transport)
transport.emit(
"""{"type":"status","status":"waiting","pending":true,"gate":"tool",
"preview":{"kind":"command","text":"rm -rf /"}}""",
)
testScheduler.runCurrent()
transport.emit("""{"type":"status","status":"working","pending":false}""")
testScheduler.runCurrent()
assertNull(
events.filterIsInstance<Gate>().last().gate,
"a resolved gate must not leave a stale command on screen",
)
assertTrue(events.filterIsInstance<Gate>().size >= 2)
}
}

View File

@@ -0,0 +1,81 @@
package wang.yaojia.webterm.wire
/**
* W1 — a bounded preview of the tool a held approval is asking about
* (`src/types.ts` `ApprovalPreview`, carried on `status.preview`).
*
* ### Why this type has to exist
* Without it a remote approval is **blind**: the gate surfaces could show the tool's *name* and nothing
* else, so the user was being asked to approve a `Bash` command without seeing the command, or an `Edit`
* without seeing the diff. Approving blind is worse than not being able to approve at all — the whole
* point of the remote gate is that the person holding the phone can make an informed call. The server has
* been sending this since W1; Android simply dropped it on the floor.
*
* ### Untrusted, and rendered inert
* Both variants carry attacker-influenced text: a command line and a diff body come from whatever Claude
* Code decided to run against whatever the repo contains. Renderers MUST treat every string here as inert
* (plan §8) — plain text only, no linkification, no Markdown, no autolink. The server already strips
* control/ANSI characters from [Command.text] except newlines, but this client does not depend on that.
*
* @see [ServerMessage.Status.preview]
*/
public sealed interface ApprovalPreview {
/** The source exceeded the server's line/byte cap and was clipped, so what is shown is incomplete. */
public val truncated: Boolean
/**
* A shell command (the `Bash` tool family). Newlines are preserved by the server; every other
* control/ANSI character is stripped before it reaches us.
*/
public data class Command(
val text: String,
override val truncated: Boolean = false,
) : ApprovalPreview
/**
* ONE synthetic file diff (the `Edit` / `Write` / `MultiEdit` / `NotebookEdit` family).
*
* The shape mirrors the server's `DiffFile`, but note the deliberate boundary: `:app` owns the full
* diff *viewer* (its own richer model lives with `DiffViewModel`, which is fed by `GET /projects/diff`
* and needs staging/base-mode state this type has no business carrying). This is the read-only subset
* a gate needs in order to answer "what am I approving?", and it lives here because `:wire-protocol`
* is the single place new wire types may be added (CLAUDE.md) and `:session-core` may not depend on
* `:app`.
*/
public data class Diff(
val file: PreviewDiffFile,
override val truncated: Boolean = false,
) : ApprovalPreview
}
/** One file's worth of diff inside an [ApprovalPreview.Diff] (`src/types.ts` `DiffFile`). */
public data class PreviewDiffFile(
val oldPath: String,
val newPath: String,
/** Raw wire value; unknown values are preserved verbatim rather than coerced, so a new server
* status renders as itself instead of silently becoming "modified". */
val status: String,
val added: Int,
val removed: Int,
val binary: Boolean,
val hunks: List<PreviewDiffHunk>,
)
/** One hunk (`src/types.ts` `DiffHunk`). */
public data class PreviewDiffHunk(
val header: String,
val lines: List<PreviewDiffLine>,
)
/**
* One diff line (`src/types.ts` `DiffLine`).
*
* [kind] stays a raw wire string for the same reason as [PreviewDiffFile.status]: a gate that mis-colours
* an unknown line kind is a cosmetic bug, but a gate that DROPS a line it cannot classify would hide part
* of what the user is approving.
*/
public data class PreviewDiffLine(
val kind: String,
val text: String,
)

View File

@@ -112,6 +112,7 @@ public object MessageCodec {
ServerMessageType.EXIT -> decodeExit(root)
ServerMessageType.STATUS -> decodeStatus(root)
ServerMessageType.TELEMETRY -> decodeTelemetry(root)
ServerMessageType.QUEUE -> decodeQueue(root)
null -> null
}
}
@@ -132,7 +133,71 @@ public object MessageCodec {
val detail = obj.stringField("detail")
val pending = obj.boolField("pending") ?: false
val gate = obj.stringField("gate")?.let { GateKind.fromWire(it) }
return ServerMessage.Status(status, detail, pending, gate)
// A malformed preview degrades to null and NEVER fails the frame: the `pending` signal is what
// makes the gate appear at all, so trading a missing preview for a missing gate would be a bad bargain.
val preview = decodePreview(obj["preview"])
return ServerMessage.Status(status, detail, pending, gate, preview)
}
private fun decodeQueue(obj: JsonObject): ServerMessage? {
val length = obj.intField("length") ?: return null
// A negative depth is nonsense the UI would render as a badge; drop the frame rather than
// propagate it. The next broadcast carries the real depth, so nothing is lost for long.
if (length < 0) return null
return ServerMessage.Queue(length)
}
/**
* `status.preview` (W1). Returns null for absent, non-object, unknown-`kind`, or structurally broken
* input — every one of which is a preview we cannot show, not a frame we should discard.
*/
private fun decodePreview(element: kotlinx.serialization.json.JsonElement?): ApprovalPreview? {
val obj = element as? JsonObject ?: return null
val truncated = obj.boolField("truncated") ?: false
return when (obj.stringField("kind")) {
PREVIEW_KIND_COMMAND -> obj.stringField("text")?.let {
ApprovalPreview.Command(text = it, truncated = truncated)
}
PREVIEW_KIND_DIFF -> decodePreviewFile(obj["file"])?.let {
ApprovalPreview.Diff(file = it, truncated = truncated)
}
else -> null
}
}
private fun decodePreviewFile(element: kotlinx.serialization.json.JsonElement?): PreviewDiffFile? {
val obj = element as? JsonObject ?: return null
// Paths identify WHAT is being changed; without them the preview cannot be trusted to describe
// the right file, so this is the one place the diff variant is dropped rather than degraded.
val oldPath = obj.stringField("oldPath") ?: return null
val newPath = obj.stringField("newPath") ?: return null
return PreviewDiffFile(
oldPath = oldPath,
newPath = newPath,
status = obj.stringField("status") ?: "",
added = obj.intField("added") ?: 0,
removed = obj.intField("removed") ?: 0,
binary = obj.boolField("binary") ?: false,
hunks = (obj["hunks"] as? kotlinx.serialization.json.JsonArray)
?.mapNotNull { decodePreviewHunk(it) }
.orEmpty(),
)
}
private fun decodePreviewHunk(element: kotlinx.serialization.json.JsonElement?): PreviewDiffHunk? {
val obj = element as? JsonObject ?: return null
return PreviewDiffHunk(
header = obj.stringField("header") ?: "",
// Drop-one-keep-rest: a single unparseable line must not cost the user the rest of the diff
// they are being asked to approve.
lines = (obj["lines"] as? kotlinx.serialization.json.JsonArray)
?.mapNotNull { line ->
val l = line as? JsonObject ?: return@mapNotNull null
val text = l.stringField("text") ?: return@mapNotNull null
PreviewDiffLine(kind = l.stringField("kind") ?: "", text = text)
}
.orEmpty(),
)
}
private fun decodeTelemetry(obj: JsonObject): ServerMessage? {
@@ -229,4 +294,8 @@ public object MessageCodec {
key: String,
deserializer: kotlinx.serialization.DeserializationStrategy<T>,
): T? = this[key]?.let { runCatching { json.decodeFromJsonElement(deserializer, it) }.getOrNull() }
/** `ApprovalPreview` discriminators (`src/types.ts` — a string union, not an enum). */
private const val PREVIEW_KIND_COMMAND = "command"
private const val PREVIEW_KIND_DIFF = "diff"
}

View File

@@ -36,10 +36,27 @@ public sealed interface ServerMessage {
val detail: String? = null,
val pending: Boolean = false,
val gate: GateKind? = null,
/**
* W1 — what the held approval is actually asking about. Present only on a held ([pending])
* waiting status for a Bash/Edit-family tool; null otherwise, which is a NORMAL state and not an
* error (plenty of gates have nothing previewable). A malformed preview also decodes to null
* rather than dropping the whole frame: losing the preview degrades the UI, but losing the frame
* would lose the `pending` signal itself and the gate would never appear.
*/
val preview: ApprovalPreview? = null,
) : ServerMessage
/** Latest statusLine telemetry broadcast (B2). */
public data class Telemetry(val telemetry: StatusTelemetry) : ServerMessage
/**
* W2 — current pending-inject queue depth, broadcast to EVERY attached device so all mirrors show the
* same "N queued" badge (`src/types.ts` `{ type: 'queue'; length: number }`).
*
* Previously this frame had no member here at all, so `decodeServer` returned null for it and
* `SessionEngine` silently discarded every one — the badge could never appear on Android.
*/
public data class Queue(val length: Int) : ServerMessage
}
/**
@@ -77,7 +94,8 @@ public enum class ServerMessageType(public val wire: String) {
OUTPUT("output"),
EXIT("exit"),
STATUS("status"),
TELEMETRY("telemetry");
TELEMETRY("telemetry"),
QUEUE("queue");
public companion object {
public fun fromWire(value: String): ServerMessageType? = entries.firstOrNull { it.wire == value }

View File

@@ -0,0 +1,156 @@
package wang.yaojia.webterm.wire
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertNotNull
import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test
/**
* W1 `status.preview` + W2 `queue` decode.
*
* Both of these were silently lost before: `queue` had no `ServerMessageType` member so the whole frame
* was dropped as undecodable, and `preview` was not modelled at all, which made every remote approval
* BLIND — the user was asked to approve a `Bash` call without seeing the command.
*
* The governing rule these tests pin: a broken PREVIEW must never cost us the FRAME. The `pending` flag on
* a status frame is what makes the gate appear; trading a missing preview for a missing gate would be a
* bad bargain, so every malformed-preview case below still yields a `Status`.
*/
@DisplayName("MessageCodec — status.preview (W1) and queue (W2)")
class ApprovalPreviewDecodeTest {
private fun status(previewJson: String): ServerMessage.Status? =
MessageCodec.decodeServer(
"""{"type":"status","status":"working","pending":true,"gate":"tool","detail":"Bash","preview":$previewJson}""",
) as? ServerMessage.Status
// ── queue (W2) ───────────────────────────────────────────────────────────────────────────────────
@Test
@DisplayName("a queue frame decodes instead of being dropped as unknown")
fun queueDecodes() {
val msg = MessageCodec.decodeServer("""{"type":"queue","length":3}""")
assertEquals(ServerMessage.Queue(3), msg)
}
@Test
@DisplayName("queue depth zero is a real value, not an absent one")
fun queueZero() {
assertEquals(ServerMessage.Queue(0), MessageCodec.decodeServer("""{"type":"queue","length":0}"""))
}
@Test
@DisplayName("a negative or missing depth drops the frame rather than feeding nonsense to a badge")
fun queueRejectsNonsense() {
assertNull(MessageCodec.decodeServer("""{"type":"queue","length":-1}"""))
assertNull(MessageCodec.decodeServer("""{"type":"queue"}"""))
assertNull(MessageCodec.decodeServer("""{"type":"queue","length":"3"}"""), "a quoted number is not a number")
}
// ── preview: command variant ─────────────────────────────────────────────────────────────────────
@Test
@DisplayName("a command preview decodes, newlines preserved")
fun commandPreview() {
val s = status("""{"kind":"command","text":"rm -rf build\ncd ..","truncated":true}""")
assertEquals(
ApprovalPreview.Command(text = "rm -rf build\ncd ..", truncated = true),
s?.preview,
)
}
@Test
@DisplayName("truncated defaults to false when the server omits it")
fun truncatedDefaults() {
val p = status("""{"kind":"command","text":"ls"}""")?.preview
assertEquals(ApprovalPreview.Command("ls", truncated = false), p)
}
// ── preview: diff variant ────────────────────────────────────────────────────────────────────────
@Test
@DisplayName("a diff preview decodes file, hunks and lines")
fun diffPreview() {
val s = status(
"""{"kind":"diff","file":{"oldPath":"a.kt","newPath":"a.kt","status":"modified",
"added":2,"removed":1,"binary":false,
"hunks":[{"header":"@@ -1 +1,2 @@","lines":[
{"kind":"add","text":"new"},{"kind":"del","text":"old"}]}]}}""",
)
val diff = s?.preview as? ApprovalPreview.Diff
assertNotNull(diff, "the diff variant must decode")
assertEquals("a.kt", diff!!.file.newPath)
assertEquals(2, diff.file.added)
assertEquals(1, diff.file.hunks.size)
assertEquals(
listOf(PreviewDiffLine("add", "new"), PreviewDiffLine("del", "old")),
diff.file.hunks.single().lines,
)
}
@Test
@DisplayName("an unknown line kind is kept, not dropped — hiding a line hides what is being approved")
fun unknownLineKindKept() {
val s = status(
"""{"kind":"diff","file":{"oldPath":"a","newPath":"a","status":"totally-new-status",
"added":0,"removed":0,"binary":false,
"hunks":[{"header":"h","lines":[{"kind":"future-kind","text":"keep me"}]}]}}""",
)
val diff = s?.preview as ApprovalPreview.Diff
assertEquals("totally-new-status", diff.file.status, "an unknown status renders as itself")
assertEquals(
listOf(PreviewDiffLine("future-kind", "keep me")),
diff.file.hunks.single().lines,
)
}
@Test
@DisplayName("one unparseable line is dropped but its siblings survive")
fun lossyLines() {
val s = status(
"""{"kind":"diff","file":{"oldPath":"a","newPath":"a","status":"modified",
"added":0,"removed":0,"binary":false,
"hunks":[{"header":"h","lines":[
{"kind":"add","text":"first"},{"kind":"add"},{"kind":"add","text":"third"}]}]}}""",
)
val diff = s?.preview as ApprovalPreview.Diff
assertEquals(
listOf("first", "third"),
diff.file.hunks.single().lines.map { it.text },
"drop-one-keep-rest: a malformed line must not cost the user the rest of the diff",
)
}
// ── degradation: the load-bearing rule ───────────────────────────────────────────────────────────
@Test
@DisplayName("every malformed preview degrades to null AND still yields the Status frame")
fun malformedPreviewNeverCostsTheFrame() {
val broken = listOf(
"""null""" to "explicit null",
""""a string"""" to "not an object",
"""{}""" to "no kind",
"""{"kind":"command"}""" to "command without text",
"""{"kind":"diff"}""" to "diff without file",
"""{"kind":"diff","file":{"newPath":"a"}}""" to "diff file without oldPath",
"""{"kind":"video"}""" to "unknown kind",
)
broken.forEach { (json, why) ->
val s = status(json)
assertNotNull(s, "the frame must survive a bad preview ($why)")
assertNull(s!!.preview, "the preview must degrade to null ($why)")
assertTrue(s.pending, "the pending flag — the reason the gate appears at all — must survive ($why)")
}
}
@Test
@DisplayName("a status frame with no preview at all is a normal frame")
fun previewAbsentIsNormal() {
val s = MessageCodec.decodeServer("""{"type":"status","status":"waiting","pending":true}""")
as? ServerMessage.Status
assertNotNull(s)
assertNull(s!!.preview)
}
}