diff --git a/android/session-core/src/main/kotlin/wang/yaojia/webterm/session/GateState.kt b/android/session-core/src/main/kotlin/wang/yaojia/webterm/session/GateState.kt index bab53df..5243d0b 100644 --- a/android/session-core/src/main/kotlin/wang/yaojia/webterm/session/GateState.kt +++ b/android/session-core/src/main/kotlin/wang/yaojia/webterm/session/GateState.kt @@ -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 diff --git a/android/session-core/src/main/kotlin/wang/yaojia/webterm/session/GateTracker.kt b/android/session-core/src/main/kotlin/wang/yaojia/webterm/session/GateTracker.kt index 277ba59..6e6b78b 100644 --- a/android/session-core/src/main/kotlin/wang/yaojia/webterm/session/GateTracker.kt +++ b/android/session-core/src/main/kotlin/wang/yaojia/webterm/session/GateTracker.kt @@ -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) } diff --git a/android/session-core/src/main/kotlin/wang/yaojia/webterm/session/SessionEngine.kt b/android/session-core/src/main/kotlin/wang/yaojia/webterm/session/SessionEngine.kt index 4857a73..ee68997 100644 --- a/android/session-core/src/main/kotlin/wang/yaojia/webterm/session/SessionEngine.kt +++ b/android/session-core/src/main/kotlin/wang/yaojia/webterm/session/SessionEngine.kt @@ -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)) } diff --git a/android/session-core/src/main/kotlin/wang/yaojia/webterm/session/SessionEvent-cockpit.kt b/android/session-core/src/main/kotlin/wang/yaojia/webterm/session/SessionEvent-cockpit.kt index d2d465d..f228032 100644 --- a/android/session-core/src/main/kotlin/wang/yaojia/webterm/session/SessionEvent-cockpit.kt +++ b/android/session-core/src/main/kotlin/wang/yaojia/webterm/session/SessionEvent-cockpit.kt @@ -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 diff --git a/android/session-core/src/test/kotlin/wang/yaojia/webterm/session/SessionEngineQueueAndPreviewTest.kt b/android/session-core/src/test/kotlin/wang/yaojia/webterm/session/SessionEngineQueueAndPreviewTest.kt new file mode 100644 index 0000000..a52b8a7 --- /dev/null +++ b/android/session-core/src/test/kotlin/wang/yaojia/webterm/session/SessionEngineQueueAndPreviewTest.kt @@ -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 { + val seen = mutableListOf() + backgroundScope.launch { engine.events.collect { seen += it } } + return seen + } + + private fun TestScope.started(transport: FakeTermTransport): Pair> { + 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().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().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().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().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().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().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().last().gate, + "a resolved gate must not leave a stale command on screen", + ) + assertTrue(events.filterIsInstance().size >= 2) + } +} diff --git a/android/wire-protocol/src/main/kotlin/wang/yaojia/webterm/wire/ApprovalPreview.kt b/android/wire-protocol/src/main/kotlin/wang/yaojia/webterm/wire/ApprovalPreview.kt new file mode 100644 index 0000000..93317f3 --- /dev/null +++ b/android/wire-protocol/src/main/kotlin/wang/yaojia/webterm/wire/ApprovalPreview.kt @@ -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, +) + +/** One hunk (`src/types.ts` `DiffHunk`). */ +public data class PreviewDiffHunk( + val header: String, + val lines: List, +) + +/** + * 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, +) diff --git a/android/wire-protocol/src/main/kotlin/wang/yaojia/webterm/wire/MessageCodec.kt b/android/wire-protocol/src/main/kotlin/wang/yaojia/webterm/wire/MessageCodec.kt index c6ee40b..09d435f 100644 --- a/android/wire-protocol/src/main/kotlin/wang/yaojia/webterm/wire/MessageCodec.kt +++ b/android/wire-protocol/src/main/kotlin/wang/yaojia/webterm/wire/MessageCodec.kt @@ -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? = 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" } diff --git a/android/wire-protocol/src/main/kotlin/wang/yaojia/webterm/wire/ServerMessage.kt b/android/wire-protocol/src/main/kotlin/wang/yaojia/webterm/wire/ServerMessage.kt index 689efc4..fdb7504 100644 --- a/android/wire-protocol/src/main/kotlin/wang/yaojia/webterm/wire/ServerMessage.kt +++ b/android/wire-protocol/src/main/kotlin/wang/yaojia/webterm/wire/ServerMessage.kt @@ -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 } diff --git a/android/wire-protocol/src/test/kotlin/wang/yaojia/webterm/wire/ApprovalPreviewDecodeTest.kt b/android/wire-protocol/src/test/kotlin/wang/yaojia/webterm/wire/ApprovalPreviewDecodeTest.kt new file mode 100644 index 0000000..6c5da63 --- /dev/null +++ b/android/wire-protocol/src/test/kotlin/wang/yaojia/webterm/wire/ApprovalPreviewDecodeTest.kt @@ -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) + } +}