diff --git a/android/README.md b/android/README.md index 74b6f442..7157b477 100644 --- a/android/README.md +++ b/android/README.md @@ -11,7 +11,7 @@ independent from the web app; shares only the protocol contract | Module | Type | Responsibility | |---|---|---| -| `:core:protocol` | **pure Kotlin/JVM** (no Android) | Hub wire types (kotlinx.serialization), chat pipeline port (normalize → reduce → tool groups), message-window/pagination logic, versioned patch application, modes catalog, git output parsers, `BindLink` pairing-link parsing. | +| `:core:protocol` | **pure Kotlin/JVM** (no Android) | Hub wire types (kotlinx.serialization), chat pipeline port (normalize → reduce → tool groups), message-window/pagination logic, versioned patch application, modes catalog, git output parsers, `BindLink` pairing-link parsing. **M1a landed**: `wire/` (`HapiJson`, `Session`/`SessionPatch`/`SessionSummary`, `DecryptedMessage`, `AgentState`, `Machine`, 13-type `SyncEvent` union via `SyncEvents.parse`, `MessagesResponse`), `catalog/` (flavors + permission/collaboration modes), `patch/SessionPatching.kt` (exact port of `web/src/lib/sessionPatch.ts`), all fixture-verified. | | `:core:data` | Android library | Transport + persistence: `HapiApi` (OkHttp REST, single-flight 401 re-auth), `SseEngine` (okhttp-sse, resume/backoff state machine), StateFlow stores + AtomicFile JSON snapshots, credential store, FCM registration, WorkManager workers. Placeholder in M0 — see `DataModule.kt`. | | `:app` | Android application | Compose UI, navigation, deep links (`hapicompanion://bind`), FCM service (M4), hand-rolled DI (`AppGraph`, no Hilt). | diff --git a/android/core/protocol/src/main/kotlin/app/hapi/protocol/catalog/Flavors.kt b/android/core/protocol/src/main/kotlin/app/hapi/protocol/catalog/Flavors.kt new file mode 100644 index 00000000..671f3194 --- /dev/null +++ b/android/core/protocol/src/main/kotlin/app/hapi/protocol/catalog/Flavors.kt @@ -0,0 +1,104 @@ +package app.hapi.protocol.catalog + +/** + * Agent flavor catalog — port of `shared/src/modes.ts` (`AGENT_FLAVORS`) and + * `shared/src/flavors.ts` (capabilities/labels). Wire models keep flavor as a + * raw string; resolve it here. Unknown strings map to [AgentFlavor.Other] so + * sessions from a newer hub stay renderable. + */ +sealed interface AgentFlavor { + /** Wire id (`metadata.flavor`). */ + val id: String + + data object Agy : AgentFlavor { override val id = "agy" } + data object Claude : AgentFlavor { override val id = "claude" } + data object Codex : AgentFlavor { override val id = "codex" } + data object Copilot : AgentFlavor { override val id = "copilot" } + data object Cursor : AgentFlavor { override val id = "cursor" } + data object Gemini : AgentFlavor { override val id = "gemini" } + data object Grok : AgentFlavor { override val id = "grok" } + data object Kimi : AgentFlavor { override val id = "kimi" } + data object Opencode : AgentFlavor { override val id = "opencode" } + data object Pi : AgentFlavor { override val id = "pi" } + + /** A flavor this client build does not know. */ + data class Other(val raw: String) : AgentFlavor { + override val id: String get() = raw + } + + companion object { + /** `AGENT_FLAVORS` — declaration order preserved. */ + val KNOWN: List = listOf( + Agy, Claude, Codex, Copilot, Cursor, Gemini, Grok, Kimi, Opencode, Pi, + ) + + /** + * `CREATABLE_AGENT_FLAVORS`: flavors offered when creating a session. + * Gemini is excluded (consumer Gemini CLI sunset 2026-06-18) but stays + * in [KNOWN] so stored sessions remain viewable. + */ + val CREATABLE: List = KNOWN.filter { it != Gemini } + + private val BY_ID: Map = KNOWN.associateBy { it.id } + + /** Resolve a wire flavor string; `null`/blank stays `null`, unknown → [Other]. */ + fun from(raw: String?): AgentFlavor? { + if (raw == null) return null + return BY_ID[raw] ?: Other(raw) + } + + /** `isKnownFlavor` (`shared/src/flavors.ts`). */ + fun isKnown(raw: String?): Boolean = raw != null && raw in BY_ID + } +} + +/** Capability ids (`Capabilities` in `shared/src/flavors.ts`). */ +enum class FlavorCapability { + ModelChange, + Effort, +} + +object Flavors { + private val CAPS: Map> = mapOf( + "agy" to setOf(FlavorCapability.ModelChange), + "claude" to setOf(FlavorCapability.ModelChange, FlavorCapability.Effort), + "gemini" to setOf(FlavorCapability.ModelChange), + "kimi" to setOf(FlavorCapability.ModelChange), + "copilot" to setOf(FlavorCapability.ModelChange), + "grok" to setOf(FlavorCapability.ModelChange, FlavorCapability.Effort), + "codex" to setOf(FlavorCapability.ModelChange), + "cursor" to setOf(FlavorCapability.ModelChange), + "opencode" to setOf(FlavorCapability.ModelChange), + "pi" to setOf(FlavorCapability.ModelChange, FlavorCapability.Effort), + ) + + private val LABELS: Map = mapOf( + "agy" to "Antigravity", + "claude" to "Claude", + "gemini" to "Gemini", + "kimi" to "Kimi", + "copilot" to "Copilot", + "grok" to "Grok Build", + "codex" to "Codex", + "cursor" to "Cursor", + "opencode" to "OpenCode", + "pi" to "Pi", + ) + + fun hasCapability(flavor: String?, capability: FlavorCapability): Boolean = + CAPS[flavor]?.contains(capability) == true + + /** `getFlavorLabel`: unknown → `"Unknown"`. */ + fun label(flavor: String?): String = LABELS[flavor] ?: "Unknown" + + fun supportsModelChange(flavor: String?): Boolean = + hasCapability(flavor, FlavorCapability.ModelChange) + + fun supportsEffort(flavor: String?): Boolean = + hasCapability(flavor, FlavorCapability.Effort) + + /** `isCodexFamilyFlavor`: flavors sharing the codex-style generic envelope UX. */ + fun isCodexFamily(flavor: String?): Boolean = + flavor == "codex" || flavor == "gemini" || flavor == "grok" + || flavor == "kimi" || flavor == "copilot" || flavor == "opencode" +} diff --git a/android/core/protocol/src/main/kotlin/app/hapi/protocol/catalog/Modes.kt b/android/core/protocol/src/main/kotlin/app/hapi/protocol/catalog/Modes.kt new file mode 100644 index 00000000..67629111 --- /dev/null +++ b/android/core/protocol/src/main/kotlin/app/hapi/protocol/catalog/Modes.kt @@ -0,0 +1,133 @@ +package app.hapi.protocol.catalog + +/** + * Permission-mode / collaboration-mode catalogs — data port of + * `shared/src/modes.ts` and `shared/src/copilotModes.ts`. Labels and tones are + * carried verbatim from the TS source (en labels; i18n is a UI concern). + */ +enum class PermissionModeTone { + Neutral, + Info, + Warning, + Danger, +} + +/** + * All permission modes across flavors (`PERMISSION_MODES` — declaration order + * preserved, it is the canonical listing order). + */ +enum class PermissionMode( + val wireId: String, + val label: String, + val tone: PermissionModeTone, +) { + Default("default", "Default", PermissionModeTone.Neutral), + AcceptEdits("acceptEdits", "Accept Edits", PermissionModeTone.Warning), + Auto("auto", "Auto", PermissionModeTone.Warning), + BypassPermissions("bypassPermissions", "Yolo", PermissionModeTone.Danger), + Plan("plan", "Plan Mode", PermissionModeTone.Info), + Ask("ask", "Ask Mode", PermissionModeTone.Info), + Debug("debug", "Debug Mode", PermissionModeTone.Info), + AutoReview("autoReview", "Auto-review", PermissionModeTone.Warning), + ReadOnly("read-only", "Read Only", PermissionModeTone.Warning), + SafeYolo("safe-yolo", "Safe Yolo", PermissionModeTone.Warning), + Yolo("yolo", "Yolo", PermissionModeTone.Danger), + RequestReview("request-review", "Request Review", PermissionModeTone.Neutral), + AlwaysProceed("always-proceed", "Always Proceed", PermissionModeTone.Danger), + ; + + companion object { + private val BY_WIRE_ID: Map = entries.associateBy { it.wireId } + + /** Resolve a wire string; unknown → null (render raw, offer no switch). */ + fun fromWireId(raw: String?): PermissionMode? = raw?.let(BY_WIRE_ID::get) + } +} + +object PermissionModes { + /** `AGY_PERMISSION_MODES`. */ + val AGY = listOf(PermissionMode.RequestReview, PermissionMode.AlwaysProceed) + + /** `CLAUDE_PERMISSION_MODES`. */ + val CLAUDE = listOf( + PermissionMode.Default, PermissionMode.AcceptEdits, PermissionMode.Auto, + PermissionMode.BypassPermissions, PermissionMode.Plan, + ) + + /** `CODEX_PERMISSION_MODES` (gemini/kimi/copilot share the same list). */ + val CODEX = listOf( + PermissionMode.Default, PermissionMode.ReadOnly, + PermissionMode.SafeYolo, PermissionMode.Yolo, + ) + + /** `GROK_PERMISSION_MODES`. */ + val GROK = listOf( + PermissionMode.Default, PermissionMode.Auto, + PermissionMode.Plan, PermissionMode.BypassPermissions, + ) + + /** `OPENCODE_PERMISSION_MODES`. */ + val OPENCODE = listOf(PermissionMode.Default, PermissionMode.Plan, PermissionMode.Yolo) + + /** `CURSOR_PERMISSION_MODES`. */ + val CURSOR = listOf( + PermissionMode.Default, PermissionMode.Plan, PermissionMode.Ask, + PermissionMode.Debug, PermissionMode.AutoReview, PermissionMode.Yolo, + ) + + /** + * `getPermissionModesForFlavor`: branch-for-branch port. Pi has no runtime + * permission switching (always auto-approve) → empty; `null`/unknown + * flavors fall back to the Claude list, exactly like the TS. + */ + fun forFlavor(flavor: String?): List = when (flavor) { + "codex" -> CODEX + "gemini" -> CODEX + "kimi" -> CODEX + "copilot" -> CODEX + "grok" -> GROK + "opencode" -> OPENCODE + "agy" -> AGY + "cursor" -> CURSOR + "pi" -> emptyList() + else -> CLAUDE + } + + /** `isPermissionModeAllowedForFlavor`. */ + fun isAllowedForFlavor(mode: PermissionMode, flavor: String?): Boolean = + mode in forFlavor(flavor) +} + +/** `CODEX_COLLABORATION_MODES` + labels. */ +enum class CodexCollaborationMode(val wireId: String, val label: String) { + Default("default", "Default"), + Plan("plan", "Plan"), + ; + + companion object { + private val BY_WIRE_ID: Map = entries.associateBy { it.wireId } + + fun fromWireId(raw: String?): CodexCollaborationMode? = raw?.let(BY_WIRE_ID::get) + } +} + +/** `COPILOT_AGENT_MODES` + labels (`shared/src/copilotModes.ts`). */ +enum class CopilotAgentMode(val wireId: String, val label: String) { + Interactive("interactive", "Interactive"), + Plan("plan", "Plan"), + Autopilot("autopilot", "Autopilot"), + ; + + companion object { + private val BY_WIRE_ID: Map = entries.associateBy { it.wireId } + + fun fromWireId(raw: String?): CopilotAgentMode? = raw?.let(BY_WIRE_ID::get) + + /** + * `normalizeCopilotAgentMode`: legacy `'fleet'` (briefly a peer mode; + * really the `/fleet` slash command) and any invalid value coerce to + * [Interactive]. + */ + fun normalize(raw: String?): CopilotAgentMode = fromWireId(raw) ?: Interactive + } +} diff --git a/android/core/protocol/src/main/kotlin/app/hapi/protocol/patch/SessionPatching.kt b/android/core/protocol/src/main/kotlin/app/hapi/protocol/patch/SessionPatching.kt new file mode 100644 index 00000000..6d60445a --- /dev/null +++ b/android/core/protocol/src/main/kotlin/app/hapi/protocol/patch/SessionPatching.kt @@ -0,0 +1,158 @@ +package app.hapi.protocol.patch + +import app.hapi.protocol.wire.OptionalField +import app.hapi.protocol.wire.Session +import app.hapi.protocol.wire.SessionPatch +import kotlin.math.abs +import kotlin.math.max + +/** + * Pure session-patch application — line-for-line port of + * `web/src/lib/sessionPatch.ts` (the normative reference; also specified in + * `docs/api/client-contract/sse.md` "Versioned patch algorithm"). + */ + +/** + * Version-monotonicity gate for the versioned `metadata` / `agentState` / + * `todos` / `teamState` sub-patches. **Strictly greater** only: the dual SSE + * connections have no shared ordering, so the same version can arrive twice + * and an older one after a newer one — applying either would regress state + * (resurrected permission requests, rewound resume ids). + */ +fun isNewerVersionedPatch(patchVersion: Long, currentVersion: Long): Boolean { + return patchVersion > currentVersion +} + +/** + * True when applying [patch] to [session] would change nothing that renders. + * + * Keep-alive patches re-send fields about every ~10 s with only `activeAt` + * moving. Sub-minute `activeAt` deltas are ignored (relative-time labels only + * change at minute boundaries); a delta of >= 60 s is still render-relevant. + * + * Port notes: the TS iterates `Object.entries(patch)` with `!==` — here each + * present field is compared explicitly. A present versioned wrapper is always + * relevant in TS (object identity), replicated with the `!= null` checks. A + * present `scratchlistUpdatedAt` is always relevant ([Session] carries no such + * field). Kotlin cannot distinguish an absent `activeTurnStartedAt` on the + * cached session from an explicit `null` the way TS `undefined !== null` does; + * the divergence is unobservable through [applySessionDetailPatch] because + * that field is never assigned anyway. + */ +fun isRenderIrrelevantSessionPatch(session: Session, patch: SessionPatch): Boolean { + patch.active?.let { if (session.active != it) return false } + patch.thinking?.let { if (session.thinking != it) return false } + if (patch.activeTurnStartedAt is OptionalField.Present + && session.activeTurnStartedAt != patch.activeTurnStartedAt.value + ) { + return false + } + patch.activeAt?.let { if (abs(it - session.activeAt) >= 60_000) return false } + patch.updatedAt?.let { if (session.updatedAt != it) return false } + if (patch.metadata != null) return false + if (patch.agentState != null) return false + if (patch.todos != null) return false + if (patch.teamState != null) return false + if (patch.model is OptionalField.Present && session.model != patch.model.value) return false + if (patch.modelReasoningEffort is OptionalField.Present + && session.modelReasoningEffort != patch.modelReasoningEffort.value + ) { + return false + } + if (patch.effort is OptionalField.Present && session.effort != patch.effort.value) return false + if (patch.serviceTier is OptionalField.Present && session.serviceTier != patch.serviceTier.value) return false + patch.permissionMode?.let { if (session.permissionMode != it) return false } + patch.collaborationMode?.let { if (session.collaborationMode != it) return false } + patch.copilotAgentMode?.let { if (session.copilotAgentMode != it) return false } + patch.backgroundTaskCount?.let { if (session.backgroundTaskCount != it) return false } + if (patch.scratchlistUpdatedAt != null) return false + return true +} + +/** + * Apply a validated [SessionPatch] onto a detail [Session]. Returns `null` + * when nothing render-relevant changed (keep the prior object identity). + * Field-by-field only — never wholesale-assign the versioned `{version, + * value}` wrappers. + * + * Replicated exactly from the TS reference: + * - flat fields (`active`, `thinking`, `activeAt`, `model`, + * `modelReasoningEffort`, `effort`, `serviceTier`, `permissionMode`, + * `collaborationMode`, `copilotAgentMode`, `backgroundTaskCount`) are + * last-write-wins when present; a present-`null` `model` / + * `modelReasoningEffort` / `effort` / `serviceTier` clears the field; + * - `updatedAt` is max-monotonic (a rejected stale replay must not rewind it); + * - versioned sub-patches apply only when strictly newer than the cached + * watermark (`todosUpdatedAt` / `teamStateUpdatedAt` treat absent as 0); + * - **`activeTurnStartedAt` and `scratchlistUpdatedAt` are deliberately NOT + * applied** — the TS reference never assigns them (`scratchlistUpdatedAt` + * is a bare refetch trigger; `activeTurnStartedAt` only arrives with full + * session payloads). They still participate in the render-irrelevance + * check, matching `Object.entries` semantics. + */ +fun applySessionDetailPatch(session: Session, patch: SessionPatch): Session? { + if (isRenderIrrelevantSessionPatch(session, patch)) { + return null + } + var next = session + var changed = false + + fun set(value: Session) { + next = value + changed = true + } + + patch.active?.let { if (next.active != it) set(next.copy(active = it)) } + patch.thinking?.let { if (next.thinking != it) set(next.copy(thinking = it)) } + patch.activeAt?.let { if (next.activeAt != it) set(next.copy(activeAt = it)) } + // Monotonic with hub applySessionPatch: a rejected stale + // metadata/agentState replay must not rewind updatedAt. + patch.updatedAt?.let { + val nextUpdatedAt = max(next.updatedAt, it) + if (next.updatedAt != nextUpdatedAt) set(next.copy(updatedAt = nextUpdatedAt)) + } + val model = patch.model + if (model is OptionalField.Present && next.model != model.value) { + set(next.copy(model = model.value)) + } + val modelReasoningEffort = patch.modelReasoningEffort + if (modelReasoningEffort is OptionalField.Present && next.modelReasoningEffort != modelReasoningEffort.value) { + set(next.copy(modelReasoningEffort = modelReasoningEffort.value)) + } + val effort = patch.effort + if (effort is OptionalField.Present && next.effort != effort.value) { + set(next.copy(effort = effort.value)) + } + val serviceTier = patch.serviceTier + if (serviceTier is OptionalField.Present && next.serviceTier != serviceTier.value) { + set(next.copy(serviceTier = serviceTier.value)) + } + patch.permissionMode?.let { if (next.permissionMode != it) set(next.copy(permissionMode = it)) } + patch.collaborationMode?.let { if (next.collaborationMode != it) set(next.copy(collaborationMode = it)) } + patch.copilotAgentMode?.let { if (next.copilotAgentMode != it) set(next.copy(copilotAgentMode = it)) } + patch.backgroundTaskCount?.let { if (next.backgroundTaskCount != it) set(next.copy(backgroundTaskCount = it)) } + // Version gates: dual SSE can deliver duplicates out of order. Only mark + // changed when a strictly newer version lands — otherwise keep the + // previous object identity (no redundant render). + patch.todos?.let { + if (isNewerVersionedPatch(it.version, next.todosUpdatedAt ?: 0)) { + set(next.copy(todos = it.value, todosUpdatedAt = it.version)) + } + } + patch.teamState?.let { + if (isNewerVersionedPatch(it.version, next.teamStateUpdatedAt ?: 0)) { + set(next.copy(teamState = it.value, teamStateUpdatedAt = it.version)) + } + } + patch.metadata?.let { + if (isNewerVersionedPatch(it.version, next.metadataVersion)) { + set(next.copy(metadata = it.value, metadataVersion = it.version)) + } + } + patch.agentState?.let { + if (isNewerVersionedPatch(it.version, next.agentStateVersion)) { + set(next.copy(agentState = it.value, agentStateVersion = it.version)) + } + } + return if (changed) next else null +} diff --git a/android/core/protocol/src/main/kotlin/app/hapi/protocol/wire/AgentState.kt b/android/core/protocol/src/main/kotlin/app/hapi/protocol/wire/AgentState.kt new file mode 100644 index 00000000..3923c67b --- /dev/null +++ b/android/core/protocol/src/main/kotlin/app/hapi/protocol/wire/AgentState.kt @@ -0,0 +1,56 @@ +package app.hapi.protocol.wire + +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonNull + +/** + * Session agent state (`AgentStateSchema`, `shared/src/schemas.ts`). Pending + * permission / input requests live here — they are NOT chat messages + * (`docs/api/client-contract/rest.md`, plan "核心协议事实"). + * + * Closed zod enums (`startingMode`, request `status`/`decision`) are kept as + * raw strings on the wire so a hub that grows a new value can never make an + * entire `Session` undecodable; interpret them via `app.hapi.protocol.catalog`. + */ +@Serializable +data class AgentState( + val controlledByUser: Boolean? = null, + /** `'local' | 'remote' | 'pty'` — mode the session was started in. */ + val startingMode: String? = null, + /** Pending requests keyed by request id (the `:rid` in the approve/deny routes). */ + val requests: Map? = null, + /** Resolved requests keyed by request id. */ + val completedRequests: Map? = null, +) + +/** A pending tool-permission / user-input request (`AgentStateRequestSchema`). */ +@Serializable +data class AgentStateRequest( + val tool: String, + /** Tool arguments, shape depends on [tool] (zod `unknown`). */ + val arguments: JsonElement = JsonNull, + val createdAt: Long? = null, +) + +/** A resolved request (`AgentStateCompletedRequestSchema`). */ +@Serializable +data class AgentStateCompletedRequest( + val tool: String, + val arguments: JsonElement = JsonNull, + val createdAt: Long? = null, + val completedAt: Long? = null, + /** `'canceled' | 'denied' | 'approved'`. */ + val status: String, + val reason: String? = null, + val mode: String? = null, + /** `'approved' | 'approved_for_session' | 'denied' | 'abort'`. */ + val decision: String? = null, + val allowTools: List? = null, + /** + * Answers to AskUserQuestion / request_user_input. Two wire formats: + * flat `{question: string[]}` and nested `{question: {answers: string[]}}` — + * kept raw until the permission-UX milestone needs them. + */ + val answers: JsonElement? = null, +) diff --git a/android/core/protocol/src/main/kotlin/app/hapi/protocol/wire/ApiResponses.kt b/android/core/protocol/src/main/kotlin/app/hapi/protocol/wire/ApiResponses.kt new file mode 100644 index 00000000..652d57be --- /dev/null +++ b/android/core/protocol/src/main/kotlin/app/hapi/protocol/wire/ApiResponses.kt @@ -0,0 +1,52 @@ +package app.hapi.protocol.wire + +import kotlinx.serialization.Serializable + +/** + * Paging envelope of `GET /api/sessions/:id/messages` (`MessagesResponse.page` + * in `shared/src/apiTypes.ts`; semantics in + * `docs/api/client-contract/pagination.md`). Every cursor is a `(seq, at)` + * pair — both halves always travel together. + */ +@Serializable +data class MessagesPage( + /** `'latest' | 'before' | 'after'`. */ + val direction: String, + val limit: Int, + /** Server's current epoch; mismatch on `after` ⇒ [reset] latest page. */ + val epoch: Long, + /** `true` ⇒ discard the local window, this page replaces it. */ + val reset: Boolean, + val nextBeforeSeq: Long? = null, + val nextBeforeAt: Long? = null, + val nextAfterSeq: Long? = null, + val nextAfterAt: Long? = null, + val snapshotHeadSeq: Long? = null, + val snapshotHeadAt: Long? = null, + val hasMore: Boolean, +) + +/** `GET /api/sessions/:id/messages` — messages in ascending display order. */ +@Serializable +data class MessagesResponse( + val messages: List, + val page: MessagesPage, +) + +/** `GET /api/sessions`. */ +@Serializable +data class SessionsResponse( + val sessions: List, +) + +/** `GET /api/sessions/:id`. */ +@Serializable +data class SessionResponse( + val session: Session, +) + +/** `GET /api/machines`. */ +@Serializable +data class MachinesResponse( + val machines: List, +) diff --git a/android/core/protocol/src/main/kotlin/app/hapi/protocol/wire/DecryptedMessage.kt b/android/core/protocol/src/main/kotlin/app/hapi/protocol/wire/DecryptedMessage.kt new file mode 100644 index 00000000..d20962de --- /dev/null +++ b/android/core/protocol/src/main/kotlin/app/hapi/protocol/wire/DecryptedMessage.kt @@ -0,0 +1,105 @@ +package app.hapi.protocol.wire + +import kotlinx.serialization.KSerializer +import kotlinx.serialization.Serializable +import kotlinx.serialization.SerializationException +import kotlinx.serialization.builtins.serializer +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.JsonElement +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.buildJsonObject + +/** + * One stored chat message as returned by `GET /api/sessions/:id/messages` and + * the `message-received` SSE event. Mirrors `DecryptedMessageSchema` + * (`shared/src/schemas.ts`). + * + * [content] is the raw role-wrapped envelope — decoding it into renderable + * blocks is the chat-pipeline milestone (`docs/api/client-contract/messages.md`), + * not a wire concern. + * + * [invokedAt] is tri-state on purpose (`docs/api/client-contract/pagination.md`): + * explicit `null` = the message is still queued; a number = when the agent + * consumed it; **absent** = already invoked (pre-V8 hubs omit the field). A + * plain `Long?` would silently merge "queued" with "legacy invoked", so this + * class carries a hand-written serializer. + */ +@Serializable(with = DecryptedMessageSerializer::class) +data class DecryptedMessage( + val id: String, + val seq: Long? = null, + val localId: String? = null, + val content: JsonElement = JsonNull, + val createdAt: Long, + val invokedAt: OptionalField = OptionalField.Absent, + val scheduledAt: Long? = null, +) { + /** `invokedAt` collapsed to a plain value (both absent and `null` → null). */ + val invokedAtOrNull: Long? get() = invokedAt.valueOrNull() + + /** Display position: `invokedAt ?? createdAt` (see pagination.md "Position key"). */ + val positionAt: Long get() = invokedAtOrNull ?: createdAt +} + +internal object DecryptedMessageSerializer : KSerializer { + override val descriptor: SerialDescriptor = + SerialDescriptor("app.hapi.protocol.wire.DecryptedMessage", JsonObject.serializer().descriptor) + + override fun deserialize(decoder: Decoder): DecryptedMessage { + val input = decoder as? JsonDecoder + ?: throw SerializationException("DecryptedMessage supports JSON only") + val obj = input.decodeJsonElement() as? JsonObject + ?: throw SerializationException("DecryptedMessage must be a JSON object") + return DecryptedMessage( + id = obj["id"].stringOrNull + ?: throw SerializationException("DecryptedMessage.id must be a string"), + seq = obj["seq"].longOrNull, + localId = obj["localId"].stringOrNull, + content = obj["content"] ?: JsonNull, + createdAt = obj["createdAt"].longOrNull + ?: throw SerializationException("DecryptedMessage.createdAt must be a number"), + invokedAt = if (obj.containsKey("invokedAt")) { + OptionalField.Present(obj["invokedAt"].longOrNull) + } else { + OptionalField.Absent + }, + scheduledAt = obj["scheduledAt"].longOrNull, + ) + } + + override fun serialize(encoder: Encoder, value: DecryptedMessage) { + val output = encoder as? JsonEncoder + ?: throw SerializationException("DecryptedMessage supports JSON only") + output.encodeJsonElement(buildJsonObject { + put("id", JsonPrimitive(value.id)) + put("seq", value.seq?.let(::JsonPrimitive) ?: JsonNull) + put("localId", value.localId?.let(::JsonPrimitive) ?: JsonNull) + put("content", value.content) + put("createdAt", JsonPrimitive(value.createdAt)) + if (value.invokedAt is OptionalField.Present) { + put("invokedAt", value.invokedAt.value?.let(::JsonPrimitive) ?: JsonNull) + } + value.scheduledAt?.let { put("scheduledAt", JsonPrimitive(it)) } + }) + } +} + +/** + * User-message attachment descriptor (`AttachmentMetadataSchema`). Also the + * shape sent back in `POST /sessions/:id/messages` bodies. + */ +@Serializable +data class AttachmentMetadata( + val id: String, + val filename: String, + val mimeType: String, + val size: Long, + val path: String, + val previewUrl: String? = null, +) diff --git a/android/core/protocol/src/main/kotlin/app/hapi/protocol/wire/HapiJson.kt b/android/core/protocol/src/main/kotlin/app/hapi/protocol/wire/HapiJson.kt new file mode 100644 index 00000000..274d32eb --- /dev/null +++ b/android/core/protocol/src/main/kotlin/app/hapi/protocol/wire/HapiJson.kt @@ -0,0 +1,26 @@ +package app.hapi.protocol.wire + +import kotlinx.serialization.json.Json + +/** + * The shared [Json] configuration for every hub wire payload. + * + * - `ignoreUnknownKeys` — the hub evolves faster than shipped clients; unknown + * fields must never break decoding (mirrors zod's default key-stripping). + * - `explicitNulls = false` — hub payloads use `nullish` fields liberally; + * absent and `null` both decode to Kotlin `null`, and locally-encoded JSON + * omits `null`s instead of writing them. + * - `coerceInputValues` — JSON `null` on a non-nullable property with a + * default coerces to the default (e.g. legacy `activeAt: null` → `0`, + * matching the zod `.transform((v) => v ?? 0)`). + * + * Where absent-vs-explicit-`null` is semantically different on the wire + * (queued messages' `invokedAt`, `SessionPatch` clear-fields), the models use + * [OptionalField] with custom decoding instead of relying on this instance's + * defaults. + */ +val HapiJson: Json = Json { + ignoreUnknownKeys = true + explicitNulls = false + coerceInputValues = true +} diff --git a/android/core/protocol/src/main/kotlin/app/hapi/protocol/wire/Machine.kt b/android/core/protocol/src/main/kotlin/app/hapi/protocol/wire/Machine.kt new file mode 100644 index 00000000..44a32d94 --- /dev/null +++ b/android/core/protocol/src/main/kotlin/app/hapi/protocol/wire/Machine.kt @@ -0,0 +1,102 @@ +package app.hapi.protocol.wire + +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject + +/** + * Machine (runner host) row (`MachineSchema`, `shared/src/schemas.ts`). + * Runner internals that v1 does not branch on stay raw [JsonElement]. + */ +@Serializable +data class Machine( + val id: String, + val namespace: String, + val seq: Long, + val createdAt: Long, + val updatedAt: Long, + val active: Boolean, + val activeAt: Long, + val metadata: MachineMetadata? = null, + val metadataVersion: Long, + val runnerState: RunnerState? = null, + val runnerStateVersion: Long, + val health: MachineHealth? = null, +) + +/** `MachineMetadataSchema`. */ +@Serializable +data class MachineMetadata( + val host: String, + val platform: String, + val happyCliVersion: String, + val displayName: String? = null, + val homeDir: String? = null, + val happyHomeDir: String? = null, + val happyLibDir: String? = null, + val workspaceRoots: List? = null, + /** Machine-scoped RPC capability ids this runner registers. */ + val capabilities: List? = null, + val startedCliMtimeMs: Long? = null, + val installedCliMtimeMs: Long? = null, + val supervisedRestart: Boolean? = null, +) + +/** + * `RunnerStateSchema` — cheaply-typed scalars only; `capabilities` (agent + * config descriptors) and `lastSpawnError` stay raw until a feature needs them. + */ +@Serializable +data class RunnerState( + /** `'running' | 'shutting-down'` or any future string (zod union with string). */ + val status: String, + val pid: Int? = null, + val httpPort: Int? = null, + val startedAt: Long? = null, + val capabilities: JsonElement? = null, + val shutdownRequestedAt: Long? = null, + val shutdownSource: String? = null, + val lastSpawnError: JsonElement? = null, +) + +/** `MachineHealthSchema`. */ +@Serializable +data class MachineHealth( + val collectedAt: Long, + val cpuCount: Int? = null, + val load1m: Double? = null, + val cpuPercent: Double? = null, + val memoryPercent: Double? = null, + val uptimeSeconds: Double? = null, +) + +/** + * Flat patch variant of the `machine-updated` SSE payload + * (`MachinePatchSchema` — strict in zod). + */ +@Serializable +data class MachinePatch( + val active: Boolean? = null, + val activeAt: Long? = null, + val updatedAt: Long? = null, +) + +object MachinePatches { + private val KNOWN_KEYS = setOf("active", "activeAt", "updatedAt") + + /** + * Strict + non-empty parse mirroring the web's `getMachinePatch` + * (`MachinePatchSchema.safeParse` + key-count check): any unknown key or + * mistyped value → `null` (caller falls back to refetching machines), and + * an empty `{}` is also `null`. Strictness is what separates a patch from + * a full [Machine] payload. + */ + fun parse(element: JsonElement?): MachinePatch? { + val obj = element as? JsonObject ?: return null + if (obj.isEmpty() || obj.keys.any { it !in KNOWN_KEYS }) return null + val active = obj["active"]?.let { it.boolOrNull ?: return null } + val activeAt = obj["activeAt"]?.let { it.longOrNull ?: return null } + val updatedAt = obj["updatedAt"]?.let { it.longOrNull ?: return null } + return MachinePatch(active = active, activeAt = activeAt, updatedAt = updatedAt) + } +} diff --git a/android/core/protocol/src/main/kotlin/app/hapi/protocol/wire/OptionalField.kt b/android/core/protocol/src/main/kotlin/app/hapi/protocol/wire/OptionalField.kt new file mode 100644 index 00000000..c1cf60a5 --- /dev/null +++ b/android/core/protocol/src/main/kotlin/app/hapi/protocol/wire/OptionalField.kt @@ -0,0 +1,24 @@ +package app.hapi.protocol.wire + +/** + * Tri-state wire field: distinguishes an absent key from an explicit JSON + * `null` (use `OptionalField` with `Present(null)`). + * + * JSON cannot be round-tripped through plain nullable Kotlin properties when + * the contract gives `null` its own meaning: + * - `DecryptedMessage.invokedAt` — explicit `null` = still queued; absent = + * already invoked (pre-V8 hub rows omit the field). See + * `docs/api/client-contract/pagination.md`. + * - `SessionPatch.model/effort/...` — present `null` clears the field, absent + * leaves it untouched (TS `patch.model !== undefined`). + */ +sealed interface OptionalField { + data object Absent : OptionalField + data class Present(val value: T) : OptionalField +} + +val OptionalField<*>.isPresent: Boolean + get() = this is OptionalField.Present + +/** The present value, or null when absent (collapses the tri-state). */ +fun OptionalField.valueOrNull(): T? = (this as? OptionalField.Present)?.value diff --git a/android/core/protocol/src/main/kotlin/app/hapi/protocol/wire/Session.kt b/android/core/protocol/src/main/kotlin/app/hapi/protocol/wire/Session.kt new file mode 100644 index 00000000..bb112d37 --- /dev/null +++ b/android/core/protocol/src/main/kotlin/app/hapi/protocol/wire/Session.kt @@ -0,0 +1,73 @@ +package app.hapi.protocol.wire + +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.JsonElement + +/** + * Full session row (`SessionSchema`, `shared/src/schemas.ts` — verified + * field-by-field). Sent by `GET /api/sessions/:id` and as the "full session" + * variant of the `session-updated` SSE event. + * + * All timestamps are epoch milliseconds ([Long]). `metadataVersion` / + * `agentStateVersion` are hub-side write counters; `todosUpdatedAt` / + * `teamStateUpdatedAt` are epoch-ms watermarks — all four gate versioned + * patches (see `app.hapi.protocol.patch.SessionPatching`). + * + * zod-exactness notes: + * - `activeAt` is `nullish` with a `?? 0` transform in zod → default 0 here + * ([HapiJson] coerces an explicit `null` to the default). + * - `model`/`modelReasoningEffort`/`effort`/`serviceTier` default to `null` + * in zod → nullable with `null` default. + * - `permissionMode`/`collaborationMode`/`copilotAgentMode` are strict zod + * enums; kept as raw strings for forward compatibility (a new hub-side mode + * must not make the session undecodable) — interpret via + * `app.hapi.protocol.catalog`. Legacy `copilotAgentMode: 'fleet'` is + * likewise normalized there, not at decode time. + * - `teamState` stays a raw [JsonElement]: nothing in v1 renders its inner + * structure, only replaces it wholesale via versioned patches. + */ +@Serializable +data class Session( + val id: String, + val namespace: String, + val seq: Long, + val createdAt: Long, + val updatedAt: Long, + val pinned: Boolean? = null, + val globalPinned: Boolean? = null, + val active: Boolean, + val activeAt: Long = 0, + val metadata: SessionMetadata? = null, + val metadataVersion: Long, + val agentState: AgentState? = null, + val agentStateVersion: Long, + val thinking: Boolean, + val thinkingAt: Long, + val activeTurnStartedAt: Long? = null, + val backgroundTaskCount: Int? = null, + val todos: List? = null, + val teamState: JsonElement? = null, + val todosUpdatedAt: Long? = null, + val teamStateUpdatedAt: Long? = null, + val model: String? = null, + val modelReasoningEffort: String? = null, + val effort: String? = null, + val serviceTier: String? = null, + val permissionMode: String? = null, + val collaborationMode: String? = null, + val copilotAgentMode: String? = null, +) + +/** + * One TodoWrite item (`TodoItemSchema`). zod defaults `priority` to + * `'medium'` and `id` to `''`; replicated as Kotlin defaults. + */ +@Serializable +data class TodoItem( + val content: String, + /** `'pending' | 'in_progress' | 'completed'`. */ + val status: String, + val priority: String = "medium", + val id: String = "", + val activeForm: String? = null, +) diff --git a/android/core/protocol/src/main/kotlin/app/hapi/protocol/wire/SessionMetadata.kt b/android/core/protocol/src/main/kotlin/app/hapi/protocol/wire/SessionMetadata.kt new file mode 100644 index 00000000..451cd2b0 --- /dev/null +++ b/android/core/protocol/src/main/kotlin/app/hapi/protocol/wire/SessionMetadata.kt @@ -0,0 +1,67 @@ +package app.hapi.protocol.wire + +import kotlinx.serialization.Serializable + +/** + * Typed subset of the session `metadata` blob (`MetadataSchema`, + * `shared/src/schemas.ts` — ~60 fields, most of them CLI/runner internals). + * Only the fields a native client renders or branches on are modeled; + * [HapiJson]'s `ignoreUnknownKeys` drops the rest on decode. This is safe + * because clients never write metadata wholesale — mutations go through + * dedicated REST endpoints (rename, summary, …). + * + * `path` and `host` are required, matching zod: the hub always writes them, + * and the web client also fails the whole parse without them. + */ +@Serializable +data class SessionMetadata( + val path: String, + val host: String, + val name: String? = null, + val os: String? = null, + val summary: MetadataSummary? = null, + val machineId: String? = null, + /** Agent flavor id (`claude`, `codex`, …) — resolve via `catalog.AgentFlavor`. */ + val flavor: String? = null, + /** `'local' | 'remote' | 'pty'`. */ + val startingMode: String? = null, + val lifecycleState: String? = null, + val worktree: WorktreeMetadata? = null, + val capabilities: SessionCapabilities? = null, + /** Loopback MCP URL when the session's CLI happy server is running. */ + val hapiMcpUrl: String? = null, + val slashCommands: List? = null, + val tools: List? = null, +) + +/** `metadata.summary` on the detail session (`{text, updatedAt}`). */ +@Serializable +data class MetadataSummary( + val text: String, + val updatedAt: Long, +) + +/** `WorktreeMetadataSchema`. */ +@Serializable +data class WorktreeMetadata( + val basePath: String, + val branch: String, + val name: String, + val worktreePath: String? = null, + val createdAt: Long? = null, +) + +/** `SessionCapabilitiesSchema`. */ +@Serializable +data class SessionCapabilities( + val terminal: Boolean? = null, + val conversationHistory: ConversationHistoryCapabilities? = null, +) + +/** `ConversationHistoryCapabilitiesSchema`. */ +@Serializable +data class ConversationHistoryCapabilities( + val forkCurrent: Boolean? = null, + val forkAtMessage: Boolean? = null, + val rewindToMessage: Boolean? = null, +) diff --git a/android/core/protocol/src/main/kotlin/app/hapi/protocol/wire/SessionPatch.kt b/android/core/protocol/src/main/kotlin/app/hapi/protocol/wire/SessionPatch.kt new file mode 100644 index 00000000..77267d0c --- /dev/null +++ b/android/core/protocol/src/main/kotlin/app/hapi/protocol/wire/SessionPatch.kt @@ -0,0 +1,172 @@ +package app.hapi.protocol.wire + +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.JsonObject + +/** + * Atomic `{version, value}` pair used by structured `session-updated` patches + * for `metadata` / `agentState` / `todos` / `teamState`. The version is the + * only safe way to reject stale patches — the dual SSE connections have no + * shared ordering. Never assign the wrapper itself into a [Session]; apply + * `value` and store `version` (see `docs/api/client-contract/sse.md`). + */ +data class VersionedValue( + /** + * Write counter (metadata/agentState) or epoch-ms watermark + * (todos/teamState) — compared strictly-greater against the cached + * watermark on [Session]. + */ + val version: Long, + val value: T?, +) + +/** + * Decoded `SessionPatch` (`SessionPatchSchema`, `shared/src/schemas.ts` + * — strict). Build via [SessionPatches.parse]; a data-class instance is also + * fine for tests/local use. + * + * Field encoding of zod optionality: + * - optional non-nullable fields → plain `T?` where `null` means absent; + * - `nullable().optional()` fields ([activeTurnStartedAt], [model], + * [modelReasoningEffort], [effort], [serviceTier]) → [OptionalField], because + * an explicit wire `null` clears the target field while absence leaves it + * untouched. + * + * Note `activeTurnStartedAt` and `scratchlistUpdatedAt` are carried but never + * assigned by `applySessionDetailPatch` — replicating the reference client + * (see `app.hapi.protocol.patch.SessionPatching`). + */ +data class SessionPatch( + val active: Boolean? = null, + val thinking: Boolean? = null, + val activeTurnStartedAt: OptionalField = OptionalField.Absent, + val activeAt: Long? = null, + val updatedAt: Long? = null, + val metadata: VersionedValue? = null, + val agentState: VersionedValue? = null, + val todos: VersionedValue>? = null, + val teamState: VersionedValue? = null, + val model: OptionalField = OptionalField.Absent, + val modelReasoningEffort: OptionalField = OptionalField.Absent, + val effort: OptionalField = OptionalField.Absent, + val serviceTier: OptionalField = OptionalField.Absent, + val permissionMode: String? = null, + val collaborationMode: String? = null, + val copilotAgentMode: String? = null, + val backgroundTaskCount: Int? = null, + /** Bare refetch trigger for `GET /sessions/:id/scratchlist`; carries no data. */ + val scratchlistUpdatedAt: Long? = null, +) + +object SessionPatches { + private val KNOWN_KEYS = setOf( + "active", "thinking", "activeTurnStartedAt", "activeAt", "updatedAt", + "metadata", "agentState", "todos", "teamState", + "model", "modelReasoningEffort", "effort", "serviceTier", + "permissionMode", "collaborationMode", "copilotAgentMode", + "backgroundTaskCount", "scratchlistUpdatedAt", + ) + + /** + * Parse a `session-updated` data payload as a [SessionPatch], mirroring + * the web reference `getSessionPatch` (`SessionPatchSchema.safeParse` + + * non-empty check): returns `null` for anything that is not a valid, + * non-empty patch — the caller then treats the payload as a full + * [Session] or falls back to a REST refetch. + * + * Strictness is load-bearing: `SessionPatchSchema` is `.strict()`, so an + * unknown key (e.g. `id` on a full session object) must fail. Total — + * never throws. + * + * Deliberate leniencies vs zod (noted, not accidental): enum-typed + * strings (`permissionMode` etc.) accept any string so a future hub mode + * cannot force a refetch loop, and `teamState.value` is only shallowly + * checked (object with string `teamName`, or null). + */ + fun parse(element: JsonElement?): SessionPatch? { + val obj = element as? JsonObject ?: return null + if (obj.isEmpty() || obj.keys.any { it !in KNOWN_KEYS }) return null + return try { + SessionPatch( + active = obj["active"]?.let { it.boolOrNull ?: return null }, + thinking = obj["thinking"]?.let { it.boolOrNull ?: return null }, + activeTurnStartedAt = optionalNullableLong(obj, "activeTurnStartedAt") ?: return null, + activeAt = obj["activeAt"]?.let { it.longOrNull ?: return null }, + updatedAt = obj["updatedAt"]?.let { it.longOrNull ?: return null }, + metadata = obj["metadata"]?.let { wrapper -> + versioned(wrapper) { value -> + if (value is JsonNull) null + else HapiJson.decodeFromJsonElement(SessionMetadata.serializer(), value) + } ?: return null + }, + agentState = obj["agentState"]?.let { wrapper -> + versioned(wrapper) { value -> + if (value is JsonNull) null + else HapiJson.decodeFromJsonElement(AgentState.serializer(), value) + } ?: return null + }, + todos = obj["todos"]?.let { wrapper -> + versioned(wrapper) { value -> + if (value !is JsonArray) throw IllegalArgumentException("todos.value must be an array") + value.map { HapiJson.decodeFromJsonElement(TodoItem.serializer(), it) } + } ?: return null + }, + teamState = obj["teamState"]?.let { wrapper -> + versioned(wrapper) { value -> + when { + value is JsonNull -> null // TeamDelete clear + value is JsonObject && value["teamName"].stringOrNull != null -> value + else -> throw IllegalArgumentException("teamState.value must be null or a team object") + } + } ?: return null + }, + model = optionalNullableString(obj, "model") ?: return null, + modelReasoningEffort = optionalNullableString(obj, "modelReasoningEffort") ?: return null, + effort = optionalNullableString(obj, "effort") ?: return null, + serviceTier = optionalNullableString(obj, "serviceTier") ?: return null, + permissionMode = obj["permissionMode"]?.let { it.stringOrNull ?: return null }, + collaborationMode = obj["collaborationMode"]?.let { it.stringOrNull ?: return null }, + copilotAgentMode = obj["copilotAgentMode"]?.let { raw -> + val mode = raw.stringOrNull ?: return null + // zod coerces the legacy `'fleet'` literal to 'interactive'. + if (mode == "fleet") "interactive" else mode + }, + backgroundTaskCount = obj["backgroundTaskCount"]?.let { it.intOrNull ?: return null }, + scratchlistUpdatedAt = obj["scratchlistUpdatedAt"]?.let { it.longOrNull ?: return null }, + ) + } catch (_: Exception) { + null + } + } + + /** `{version, value}` wrapper: `version` must be a number, `value` key must exist. */ + private fun versioned(wrapper: JsonElement, decodeValue: (JsonElement) -> T?): VersionedValue? { + val obj = wrapper as? JsonObject ?: return null + val version = obj["version"].longOrNull ?: return null + if (!obj.containsKey("value")) return null + return try { + VersionedValue(version, decodeValue(obj.getValue("value"))) + } catch (_: Exception) { + null + } + } + + /** + * Tri-state for `z.number().nullable().optional()`: absent → Absent, + * `null` → Present(null), number → Present(n); anything else → null (invalid). + */ + private fun optionalNullableLong(obj: JsonObject, key: String): OptionalField? { + val raw = obj[key] ?: return OptionalField.Absent + if (raw is JsonNull) return OptionalField.Present(null) + return raw.longOrNull?.let { OptionalField.Present(it) } + } + + /** Tri-state for `z.string().nullable().optional()`. */ + private fun optionalNullableString(obj: JsonObject, key: String): OptionalField? { + val raw = obj[key] ?: return OptionalField.Absent + if (raw is JsonNull) return OptionalField.Present(null) + return raw.stringOrNull?.let { OptionalField.Present(it) } + } +} diff --git a/android/core/protocol/src/main/kotlin/app/hapi/protocol/wire/SessionSummary.kt b/android/core/protocol/src/main/kotlin/app/hapi/protocol/wire/SessionSummary.kt new file mode 100644 index 00000000..33e622c4 --- /dev/null +++ b/android/core/protocol/src/main/kotlin/app/hapi/protocol/wire/SessionSummary.kt @@ -0,0 +1,78 @@ +package app.hapi.protocol.wire + +import kotlinx.serialization.Serializable + +/** + * Session-list row returned by `GET /api/sessions` (`SessionSummary`, + * `shared/src/sessionSummary.ts`). Hub-computed projection of [Session]: + * carries the version watermarks so list caches can gate structured SSE + * patches without a detail query. + */ +@Serializable +data class SessionSummary( + val id: String, + val active: Boolean, + val thinking: Boolean = false, + val activeAt: Long = 0, + val updatedAt: Long = 0, + val pinned: Boolean? = null, + val globalPinned: Boolean? = null, + val metadata: SessionSummaryMetadata? = null, + val metadataVersion: Long = 0, + val agentStateVersion: Long = 0, + val todosUpdatedAt: Long = 0, + val todoProgress: TodoProgress? = null, + /** Authoritative total — may exceed `pendingRequests.size` (capped at 5). */ + val pendingRequestsCount: Int = 0, + /** `'permission'` / `'input'`, deduplicated. */ + val pendingRequestKinds: List = emptyList(), + /** Capped (≤ 5), oldest-first slice for per-row hover copy. */ + val pendingRequests: List = emptyList(), + val backgroundTaskCount: Int = 0, + val futureScheduledMessageCount: Int = 0, + /** Epoch ms of the soonest uninvoked future scheduled message, or null. */ + val nextScheduledAt: Long? = null, + val model: String? = null, + val modelReasoningEffort: String? = null, + val effort: String? = null, +) + +/** `SessionSummaryMetadata` — list-sized metadata projection. */ +@Serializable +data class SessionSummaryMetadata( + val name: String? = null, + val path: String, + val machineId: String? = null, + /** Summary text only — no `updatedAt` in the list projection. */ + val summary: SummaryText? = null, + val flavor: String? = null, + val worktree: WorktreeMetadata? = null, + val agentSessionId: String? = null, + val lifecycleState: String? = null, + val hapiMcpUrl: String? = null, +) + +@Serializable +data class SummaryText( + val text: String, +) + +@Serializable +data class TodoProgress( + val completed: Int, + val total: Int, +) + +/** + * One pending request in the capped summary slice (`PendingRequest`, + * `shared/src/sessionSummary.ts`). + */ +@Serializable +data class PendingRequest( + val id: String, + /** `'permission' | 'input'`. */ + val kind: String, + val tool: String, + /** Epoch ms when raised (hub falls back to `session.updatedAt`). */ + val since: Long, +) diff --git a/android/core/protocol/src/main/kotlin/app/hapi/protocol/wire/SyncEvent.kt b/android/core/protocol/src/main/kotlin/app/hapi/protocol/wire/SyncEvent.kt new file mode 100644 index 00000000..e008317b --- /dev/null +++ b/android/core/protocol/src/main/kotlin/app/hapi/protocol/wire/SyncEvent.kt @@ -0,0 +1,211 @@ +package app.hapi.protocol.wire + +import kotlinx.serialization.DeserializationStrategy +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject + +/** + * The SSE event union (`SyncEventSchema`, `shared/src/schemas.ts`; + * `docs/api/client-contract/sse.md` — 13 types, discriminated on `type`). + * Decode with [SyncEvents.parse]; there is no polymorphic serializer because + * frames arrive one JSON document per SSE `data:` line and unknown / + * malformed frames must degrade to [Unknown] instead of throwing. + * + * `session-updated` / `session-added` / `machine-updated` keep their `data` + * raw: resolving full-object vs patch is the caller's job (full [Session] / + * [Machine] via [HapiJson], patch via [SessionPatches.parse] / + * [MachinePatches.parse], anything else → REST refetch fallback). + */ +sealed interface SyncEvent { + val namespace: String? + + @Serializable + data class SessionAdded( + override val namespace: String? = null, + val sessionId: String, + val data: JsonElement? = null, + ) : SyncEvent + + @Serializable + data class SessionUpdated( + override val namespace: String? = null, + val sessionId: String, + /** Full `Session` or `SessionPatch`; absent/unparseable ⇒ refetch. */ + val data: JsonElement? = null, + ) : SyncEvent + + @Serializable + data class SessionRemoved( + override val namespace: String? = null, + val sessionId: String, + ) : SyncEvent + + @Serializable + data class MessageReceived( + override val namespace: String? = null, + val sessionId: String, + val message: DecryptedMessage, + ) : SyncEvent + + /** History changed structurally (rewind/fork/import/clear): drop the window, tail-sync. */ + @Serializable + data class MessagesInvalidated( + override val namespace: String? = null, + val sessionId: String, + ) : SyncEvent + + @Serializable + data class ScheduledMatured( + override val namespace: String? = null, + val sessionId: String, + ) : SyncEvent + + @Serializable + data class SessionEnded( + override val namespace: String? = null, + val sessionId: String, + /** `'completed' | 'terminated' | 'error' | 'handoff' | 'cleared'`. */ + val reason: String? = null, + ) : SyncEvent + + /** + * Not `@Serializable`: built by hand in [SyncEvents.parse] because `data` + * is tri-state — `Present(JsonNull)` = machine **removed**, + * `Present(object)` = full `Machine` or `MachinePatch`, [OptionalField.Absent] + * = refetch machines (`docs/api/client-contract/sse.md`). A nullable + * `JsonElement?` property would collapse the first and last cases. + */ + data class MachineUpdated( + override val namespace: String? = null, + val machineId: String, + val data: OptionalField = OptionalField.Absent, + ) : SyncEvent + + @Serializable + data class Toast( + override val namespace: String? = null, + val data: Data, + ) : SyncEvent { + @Serializable + data class Data( + val title: String, + val body: String, + val sessionId: String, + val url: String, + ) + } + + /** The agent consumed queued user messages: stamp `invokedAt`, leave the queued bar. */ + @Serializable + data class MessagesConsumed( + override val namespace: String? = null, + val sessionId: String, + val localIds: List, + val invokedAt: Long, + ) : SyncEvent + + @Serializable + data class MessageCancelled( + override val namespace: String? = null, + val sessionId: String, + val messageId: String, + val localId: String? = null, + ) : SyncEvent + + /** Staleness-watchdog food; carries no SSE `id`. */ + @Serializable + data class Heartbeat( + override val namespace: String? = null, + val data: Data? = null, + ) : SyncEvent { + @Serializable + data class Data( + val timestamp: Long, + ) + } + + /** Subscribe handshake; carries no SSE `id`. */ + @Serializable + data class ConnectionChanged( + override val namespace: String? = null, + val data: Data? = null, + ) : SyncEvent { + @Serializable + data class Data( + val status: String, + /** Needed for `POST /api/visibility` reporting. */ + val subscriptionId: String? = null, + /** `'ok'` = replay is complete, skip resync; `'gap'`/absent = full REST resync. */ + val resume: String? = null, + ) + } + + /** + * Unrecognized `type`, or a known `type` whose payload failed to decode + * (the web reference likewise drops frames failing `safeParse`). Callers + * treat it as a no-op; [raw] is retained for diagnostics. + */ + data class Unknown( + val type: String?, + val raw: JsonObject? = null, + ) : SyncEvent { + override val namespace: String? get() = raw?.get("namespace").stringOrNull + } +} + +object SyncEvents { + /** + * Total decode of one SSE `data:` payload. Never throws: invalid JSON, + * non-object frames, unknown `type`s, and malformed known-type payloads + * all become [SyncEvent.Unknown]. + */ + fun parse(json: String): SyncEvent { + val element = try { + HapiJson.parseToJsonElement(json) + } catch (_: Exception) { + return SyncEvent.Unknown(type = null, raw = null) + } + return parse(element) + } + + fun parse(element: JsonElement): SyncEvent { + val obj = element as? JsonObject ?: return SyncEvent.Unknown(type = null, raw = null) + val type = obj["type"].stringOrNull ?: return SyncEvent.Unknown(type = null, raw = obj) + val strategy: DeserializationStrategy = when (type) { + "session-added" -> SyncEvent.SessionAdded.serializer() + "session-updated" -> SyncEvent.SessionUpdated.serializer() + "session-removed" -> SyncEvent.SessionRemoved.serializer() + "message-received" -> SyncEvent.MessageReceived.serializer() + "messages-invalidated" -> SyncEvent.MessagesInvalidated.serializer() + "scheduled-matured" -> SyncEvent.ScheduledMatured.serializer() + "session-ended" -> SyncEvent.SessionEnded.serializer() + "machine-updated" -> return parseMachineUpdated(obj) + "toast" -> SyncEvent.Toast.serializer() + "messages-consumed" -> SyncEvent.MessagesConsumed.serializer() + "message-cancelled" -> SyncEvent.MessageCancelled.serializer() + "heartbeat" -> SyncEvent.Heartbeat.serializer() + "connection-changed" -> SyncEvent.ConnectionChanged.serializer() + else -> return SyncEvent.Unknown(type = type, raw = obj) + } + return try { + HapiJson.decodeFromJsonElement(strategy, obj) + } catch (_: Exception) { + SyncEvent.Unknown(type = type, raw = obj) + } + } + + private fun parseMachineUpdated(obj: JsonObject): SyncEvent { + val machineId = obj["machineId"].stringOrNull + ?: return SyncEvent.Unknown(type = "machine-updated", raw = obj) + return SyncEvent.MachineUpdated( + namespace = obj["namespace"].stringOrNull, + machineId = machineId, + data = if (obj.containsKey("data")) { + OptionalField.Present(obj.getValue("data")) + } else { + OptionalField.Absent + }, + ) + } +} diff --git a/android/core/protocol/src/test/kotlin/app/hapi/protocol/catalog/CatalogTest.kt b/android/core/protocol/src/test/kotlin/app/hapi/protocol/catalog/CatalogTest.kt new file mode 100644 index 00000000..49267ba4 --- /dev/null +++ b/android/core/protocol/src/test/kotlin/app/hapi/protocol/catalog/CatalogTest.kt @@ -0,0 +1,137 @@ +package app.hapi.protocol.catalog + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * Catalog data pinned against `shared/src/modes.ts`, `shared/src/copilotModes.ts` + * and `shared/src/flavors.ts`. + * + * TODO(K6): once track K lands `shared/fixtures/catalogs/modes.json` + * (generated from `shared/src/modes.ts`), replace these hardcoded + * expectations with assertions against that file so catalog drift turns this + * suite red automatically. + */ +class CatalogTest { + + @Test + fun `permission mode ids follow PERMISSION_MODES declaration order`() { + assertEquals( + listOf( + "default", "acceptEdits", "auto", "bypassPermissions", "plan", + "ask", "debug", "autoReview", "read-only", "safe-yolo", "yolo", + "request-review", "always-proceed", + ), + PermissionMode.entries.map { it.wireId } + ) + } + + @Test + fun `labels and tones match modes-ts`() { + assertEquals("Yolo", PermissionMode.BypassPermissions.label) + assertEquals(PermissionModeTone.Danger, PermissionMode.BypassPermissions.tone) + assertEquals("Plan Mode", PermissionMode.Plan.label) + assertEquals(PermissionModeTone.Info, PermissionMode.Plan.tone) + assertEquals("Accept Edits", PermissionMode.AcceptEdits.label) + assertEquals(PermissionModeTone.Warning, PermissionMode.AcceptEdits.tone) + assertEquals("Auto-review", PermissionMode.AutoReview.label) + assertEquals("Read Only", PermissionMode.ReadOnly.label) + assertEquals(PermissionModeTone.Neutral, PermissionMode.Default.tone) + assertEquals(PermissionModeTone.Danger, PermissionMode.AlwaysProceed.tone) + } + + @Test + fun `per-flavor mode lists match getPermissionModesForFlavor`() { + assertEquals( + listOf("default", "acceptEdits", "auto", "bypassPermissions", "plan"), + PermissionModes.forFlavor("claude").map { it.wireId } + ) + assertEquals( + listOf("default", "read-only", "safe-yolo", "yolo"), + PermissionModes.forFlavor("codex").map { it.wireId } + ) + // gemini/kimi/copilot share the codex list. + assertEquals(PermissionModes.forFlavor("codex"), PermissionModes.forFlavor("gemini")) + assertEquals(PermissionModes.forFlavor("codex"), PermissionModes.forFlavor("kimi")) + assertEquals(PermissionModes.forFlavor("codex"), PermissionModes.forFlavor("copilot")) + assertEquals( + listOf("default", "auto", "plan", "bypassPermissions"), + PermissionModes.forFlavor("grok").map { it.wireId } + ) + assertEquals( + listOf("default", "plan", "yolo"), + PermissionModes.forFlavor("opencode").map { it.wireId } + ) + assertEquals( + listOf("request-review", "always-proceed"), + PermissionModes.forFlavor("agy").map { it.wireId } + ) + assertEquals( + listOf("default", "plan", "ask", "debug", "autoReview", "yolo"), + PermissionModes.forFlavor("cursor").map { it.wireId } + ) + // Pi has no runtime permission switching. + assertTrue(PermissionModes.forFlavor("pi").isEmpty()) + // Unknown / null flavors fall back to the Claude list. + assertEquals(PermissionModes.CLAUDE, PermissionModes.forFlavor(null)) + assertEquals(PermissionModes.CLAUDE, PermissionModes.forFlavor("mystery-agent")) + } + + @Test + fun `mode allowance mirrors isPermissionModeAllowedForFlavor`() { + assertTrue(PermissionModes.isAllowedForFlavor(PermissionMode.Plan, "claude")) + assertFalse(PermissionModes.isAllowedForFlavor(PermissionMode.AcceptEdits, "codex")) + assertFalse(PermissionModes.isAllowedForFlavor(PermissionMode.Yolo, "pi")) + } + + @Test + fun `agent flavors match AGENT_FLAVORS and CREATABLE excludes gemini`() { + assertEquals( + listOf("agy", "claude", "codex", "copilot", "cursor", "gemini", "grok", "kimi", "opencode", "pi"), + AgentFlavor.KNOWN.map { it.id } + ) + assertFalse(AgentFlavor.CREATABLE.contains(AgentFlavor.Gemini)) + assertEquals(AgentFlavor.KNOWN.size - 1, AgentFlavor.CREATABLE.size) + assertEquals(AgentFlavor.Claude, AgentFlavor.from("claude")) + assertEquals(AgentFlavor.Other("newagent"), AgentFlavor.from("newagent")) + assertNull(AgentFlavor.from(null)) + assertTrue(AgentFlavor.isKnown("pi")) + assertFalse(AgentFlavor.isKnown("newagent")) + } + + @Test + fun `flavor capabilities and labels match flavors-ts`() { + assertTrue(Flavors.supportsEffort("claude")) + assertTrue(Flavors.supportsEffort("grok")) + assertTrue(Flavors.supportsEffort("pi")) + assertFalse(Flavors.supportsEffort("codex")) + assertFalse(Flavors.supportsEffort(null)) + assertTrue(AgentFlavor.KNOWN.all { Flavors.supportsModelChange(it.id) }) + assertFalse(Flavors.supportsModelChange("unknown")) + assertEquals("Antigravity", Flavors.label("agy")) + assertEquals("Grok Build", Flavors.label("grok")) + assertEquals("OpenCode", Flavors.label("opencode")) + assertEquals("Unknown", Flavors.label("mystery")) + assertTrue(Flavors.isCodexFamily("copilot")) + assertFalse(Flavors.isCodexFamily("claude")) + assertFalse(Flavors.isCodexFamily("cursor")) + assertFalse(Flavors.isCodexFamily("pi")) + } + + @Test + fun `collaboration and copilot agent modes match the ts catalogs`() { + assertEquals(listOf("default", "plan"), CodexCollaborationMode.entries.map { it.wireId }) + assertEquals("Plan", CodexCollaborationMode.Plan.label) + + assertEquals(listOf("interactive", "plan", "autopilot"), CopilotAgentMode.entries.map { it.wireId }) + assertEquals("Autopilot", CopilotAgentMode.Autopilot.label) + // Legacy 'fleet' (and anything invalid) coerces to Interactive. + assertEquals(CopilotAgentMode.Interactive, CopilotAgentMode.normalize("fleet")) + assertEquals(CopilotAgentMode.Interactive, CopilotAgentMode.normalize("nonsense")) + assertEquals(CopilotAgentMode.Interactive, CopilotAgentMode.normalize(null)) + assertEquals(CopilotAgentMode.Plan, CopilotAgentMode.normalize("plan")) + } +} diff --git a/android/core/protocol/src/test/kotlin/app/hapi/protocol/patch/SessionPatchingTest.kt b/android/core/protocol/src/test/kotlin/app/hapi/protocol/patch/SessionPatchingTest.kt new file mode 100644 index 00000000..0d768c5f --- /dev/null +++ b/android/core/protocol/src/test/kotlin/app/hapi/protocol/patch/SessionPatchingTest.kt @@ -0,0 +1,434 @@ +package app.hapi.protocol.patch + +import app.hapi.protocol.wire.AgentState +import app.hapi.protocol.wire.AgentStateRequest +import app.hapi.protocol.wire.HapiJson +import app.hapi.protocol.wire.OptionalField +import app.hapi.protocol.wire.Session +import app.hapi.protocol.wire.SessionMetadata +import app.hapi.protocol.wire.SessionPatch +import app.hapi.protocol.wire.SessionPatches +import app.hapi.protocol.wire.TodoItem +import app.hapi.protocol.wire.VersionedValue +import kotlinx.serialization.json.JsonElement +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * Port of the pure-function cases from `web/src/hooks/useSSE.test.ts` + * (`isNewerVersionedPatch`, `isRenderIrrelevantSessionPatch`, + * `applySessionDetailPatch`) plus the `SessionPatches.parse` strictness + * contract from `docs/api/client-contract/sse.md`. + */ +class SessionPatchingTest { + + private fun makeSession( + updatedAt: Long = 2_000, + activeAt: Long = 1_000, + metadataVersion: Long = 1, + agentStateVersion: Long = 1, + todosUpdatedAt: Long? = 0, + teamStateUpdatedAt: Long? = 0, + metadata: SessionMetadata? = null, + agentState: AgentState? = null, + todos: List? = null, + model: String? = "gpt-5", + copilotAgentMode: String? = "interactive", + ): Session = Session( + id = "session-1", + namespace = "default", + seq = 1, + createdAt = 1_000, + updatedAt = updatedAt, + active = true, + activeAt = activeAt, + metadata = metadata, + metadataVersion = metadataVersion, + agentState = agentState, + agentStateVersion = agentStateVersion, + thinking = false, + thinkingAt = 0, + todos = todos, + todosUpdatedAt = todosUpdatedAt, + teamStateUpdatedAt = teamStateUpdatedAt, + model = model, + permissionMode = "default", + copilotAgentMode = copilotAgentMode, + ) + + private fun metadata(name: String) = SessionMetadata(path = "/tmp", host = "host-1", name = name) + + // --- isNewerVersionedPatch (useSSE.test.ts, PR #897 review) --- + + @Test + fun `accepts a strictly newer patch`() { + assertTrue(isNewerVersionedPatch(5, 4)) + } + + @Test + fun `rejects an older patch - the stale buffered replay bug case`() { + assertFalse(isNewerVersionedPatch(4, 5)) + } + + @Test + fun `rejects a same-version patch - idempotent duplicate replay`() { + assertFalse(isNewerVersionedPatch(5, 5)) + } + + @Test + fun `accepts the first write into a freshly-cached session`() { + assertTrue(isNewerVersionedPatch(1, 0)) + } + + // --- version gates in applySessionDetailPatch --- + + @Test + fun `stale metadata patch is dropped entirely`() { + val session = makeSession(metadataVersion = 5, metadata = metadata("current")) + val patch = SessionPatch(metadata = VersionedValue(4, metadata("stale"))) + assertNull(applySessionDetailPatch(session, patch)) + } + + @Test + fun `equal-version metadata patch is dropped`() { + val session = makeSession(metadataVersion = 5, metadata = metadata("current")) + val patch = SessionPatch(metadata = VersionedValue(5, metadata("duplicate"))) + assertNull(applySessionDetailPatch(session, patch)) + } + + @Test + fun `newer metadata patch applies value and stores version`() { + val session = makeSession(metadataVersion = 5, metadata = metadata("current")) + val next = assertNotNull( + applySessionDetailPatch(session, SessionPatch(metadata = VersionedValue(6, metadata("newer")))) + ) + assertEquals(6, next.metadataVersion) + assertEquals(metadata("newer"), next.metadata) + } + + @Test + fun `stale agentState patch cannot resurrect resolved requests`() { + val resolved = makeSession(agentStateVersion = 3, agentState = AgentState(requests = emptyMap())) + val staleRequests = AgentState( + requests = mapOf("req-1" to AgentStateRequest(tool = "Bash")) + ) + assertNull( + applySessionDetailPatch(resolved, SessionPatch(agentState = VersionedValue(3, staleRequests))) + ) + assertNull( + applySessionDetailPatch(resolved, SessionPatch(agentState = VersionedValue(2, staleRequests))) + ) + } + + @Test + fun `newer agentState patch applies`() { + val session = makeSession(agentStateVersion = 3) + val incoming = AgentState(requests = mapOf("req-9" to AgentStateRequest(tool = "Edit"))) + val next = assertNotNull( + applySessionDetailPatch(session, SessionPatch(agentState = VersionedValue(4, incoming))) + ) + assertEquals(4, next.agentStateVersion) + assertEquals(incoming, next.agentState) + } + + @Test + fun `versioned null value clears metadata when newer`() { + val session = makeSession(metadataVersion = 2, metadata = metadata("current")) + val next = assertNotNull( + applySessionDetailPatch(session, SessionPatch(metadata = VersionedValue(3, null))) + ) + assertNull(next.metadata) + assertEquals(3, next.metadataVersion) + } + + @Test + fun `todos gate treats absent watermark as zero`() { + val session = makeSession(todosUpdatedAt = null) + val todos = listOf(TodoItem(content = "port the patcher", status = "in_progress")) + val next = assertNotNull( + applySessionDetailPatch(session, SessionPatch(todos = VersionedValue(1, todos))) + ) + assertEquals(todos, next.todos) + assertEquals(1, next.todosUpdatedAt) + + // Same version again: duplicate replay must keep prior identity. + assertNull(applySessionDetailPatch(next, SessionPatch(todos = VersionedValue(1, emptyList())))) + } + + @Test + fun `teamState null value clears the team when newer`() { + val team = HapiJson.parseToJsonElement("""{"teamName":"crew"}""") + val session = makeSession(teamStateUpdatedAt = 10).copy(teamState = team) + val next = assertNotNull( + applySessionDetailPatch(session, SessionPatch(teamState = VersionedValue(11, null))) + ) + assertNull(next.teamState) + assertEquals(11, next.teamStateUpdatedAt) + } + + // --- updatedAt monotonicity --- + + @Test + fun `stale updatedAt alone never rewinds the clock and reports no change`() { + val session = makeSession(updatedAt = 2_000) + assertNull(applySessionDetailPatch(session, SessionPatch(updatedAt = 1_000))) + } + + @Test + fun `newer updatedAt advances`() { + val session = makeSession(updatedAt = 2_000) + val next = assertNotNull(applySessionDetailPatch(session, SessionPatch(updatedAt = 3_000))) + assertEquals(3_000, next.updatedAt) + } + + @Test + fun `stale updatedAt in a mixed patch applies the rest but keeps the clock`() { + val session = makeSession(updatedAt = 2_000) + val next = assertNotNull( + applySessionDetailPatch(session, SessionPatch(thinking = true, updatedAt = 1_000)) + ) + assertTrue(next.thinking) + assertEquals(2_000, next.updatedAt) + } + + // --- flat fields are unconditional (no version involved) --- + + @Test + fun `flat model change applies regardless of versions`() { + val session = makeSession(model = "gpt-5") + val next = assertNotNull( + applySessionDetailPatch(session, SessionPatch(model = OptionalField.Present("opus"))) + ) + assertEquals("opus", next.model) + } + + @Test + fun `present-null serviceTier clears the field`() { + val session = makeSession().copy(serviceTier = "fast") + val next = assertNotNull( + applySessionDetailPatch(session, SessionPatch(serviceTier = OptionalField.Present(null))) + ) + assertNull(next.serviceTier) + } + + @Test + fun `absent model leaves the field untouched`() { + val session = makeSession(model = "gpt-5") + val next = assertNotNull(applySessionDetailPatch(session, SessionPatch(thinking = true))) + assertEquals("gpt-5", next.model) + } + + // --- applySessionDetailPatch (useSSE.test.ts, Copilot keep-alive) --- + + @Test + fun `applies a copilotAgentMode keep-alive change to the detail session`() { + val session = makeSession() + val next = assertNotNull( + applySessionDetailPatch( + session, + SessionPatch(active = true, thinking = false, activeAt = 11_000, copilotAgentMode = "plan") + ) + ) + assertEquals("plan", next.copilotAgentMode) + assertEquals(11_000, next.activeAt) + } + + @Test + fun `returns null for a keep-alive that only repeats the current copilot mode`() { + val session = makeSession() + assertNull( + applySessionDetailPatch( + session, + SessionPatch(active = true, thinking = false, activeAt = 11_000, copilotAgentMode = "interactive") + ) + ) + } + + // --- mixed patch: versioned gates are per-field --- + + @Test + fun `mixed patch applies flat and newer-versioned fields while rejecting stale ones`() { + val session = makeSession( + metadataVersion = 5, + agentStateVersion = 5, + metadata = metadata("current"), + agentState = AgentState(requests = emptyMap()), + ) + val next = assertNotNull( + applySessionDetailPatch( + session, + SessionPatch( + thinking = true, + updatedAt = 9_000, + metadata = VersionedValue(6, metadata("newer")), + agentState = VersionedValue(4, AgentState(requests = mapOf("stale" to AgentStateRequest(tool = "Bash")))), + ) + ) + ) + assertTrue(next.thinking) + assertEquals(9_000, next.updatedAt) + assertEquals(metadata("newer"), next.metadata) + assertEquals(6, next.metadataVersion) + // Stale agentState rejected: value and version untouched. + assertEquals(session.agentState, next.agentState) + assertEquals(5, next.agentStateVersion) + } + + // --- isRenderIrrelevantSessionPatch (useSSE.test.ts) --- + + @Test + fun `treats a sub-minute activeAt keep-alive as irrelevant`() { + val session = makeSession(activeAt = 1_000, model = "opus") + assertTrue( + isRenderIrrelevantSessionPatch( + session, + SessionPatch( + active = true, + thinking = false, + activeAt = 11_000, + model = OptionalField.Present("opus"), + effort = OptionalField.Present(null), + permissionMode = "default", + serviceTier = OptionalField.Present(null), + ) + ) + ) + } + + @Test + fun `treats an activeAt move of at least one minute as render-relevant`() { + val session = makeSession(activeAt = 1_000, model = "opus") + assertFalse( + isRenderIrrelevantSessionPatch( + session, + SessionPatch( + active = true, + thinking = false, + activeAt = 1_000 + 60_000, + model = OptionalField.Present("opus"), + effort = OptionalField.Present(null), + permissionMode = "default", + serviceTier = OptionalField.Present(null), + ) + ) + ) + } + + @Test + fun `reports a changed field as relevant even alongside a new activeAt`() { + val session = makeSession() + assertFalse( + isRenderIrrelevantSessionPatch(session, SessionPatch(thinking = true, activeAt = 11_000)) + ) + } + + @Test + fun `reports a field the session does not carry yet as relevant`() { + val session = makeSession() + assertFalse( + isRenderIrrelevantSessionPatch( + session, + SessionPatch(activeAt = 11_000, scratchlistUpdatedAt = 5_000) + ) + ) + } + + @Test + fun `treats an empty patch as irrelevant`() { + assertTrue(isRenderIrrelevantSessionPatch(makeSession(), SessionPatch())) + } + + // --- SessionPatches.parse: strictness + tri-state decoding --- + + @Test + fun `parse rejects unknown keys - a full Session payload is not a patch`() { + val fullSession = HapiJson.parseToJsonElement( + """{"id":"session-1","namespace":"default","seq":1,"active":true,"updatedAt":2}""" + ) + assertNull(SessionPatches.parse(fullSession)) + } + + @Test + fun `parse rejects an empty patch`() { + assertNull(SessionPatches.parse(HapiJson.parseToJsonElement("{}"))) + assertNull(SessionPatches.parse(null)) + assertNull(SessionPatches.parse(HapiJson.parseToJsonElement("42"))) + } + + @Test + fun `parse keeps absent vs explicit-null apart for clear-fields`() { + val patch = assertNotNull( + SessionPatches.parse(HapiJson.parseToJsonElement("""{"model":null,"activeAt":5}""")) + ) + assertEquals(OptionalField.Present(null), patch.model) + assertEquals(OptionalField.Absent, patch.effort) + assertEquals(5L, patch.activeAt) + } + + @Test + fun `parse decodes versioned wrappers without wholesale-spreading them`() { + val patch = assertNotNull( + SessionPatches.parse( + HapiJson.parseToJsonElement( + """ + { + "metadata": {"version": 7, "value": {"path": "/repo", "host": "devbox"}}, + "todos": {"version": 1755000000000, "value": [{"content": "ship it", "status": "pending"}]}, + "teamState": {"version": 3, "value": null} + } + """ + ) + ) + ) + assertEquals(7L, patch.metadata?.version) + assertEquals("/repo", patch.metadata?.value?.path) + assertEquals(1_755_000_000_000L, patch.todos?.version) + assertEquals(listOf(TodoItem(content = "ship it", status = "pending")), patch.todos?.value) + assertEquals(VersionedValue(3, null), patch.teamState) + } + + @Test + fun `parse rejects a versioned wrapper missing its value key`() { + assertNull(SessionPatches.parse(HapiJson.parseToJsonElement("""{"metadata":{"version":7}}"""))) + } + + @Test + fun `parse rejects malformed wrapped values`() { + // metadata.value without required path/host must fail like zod does. + assertNull( + SessionPatches.parse( + HapiJson.parseToJsonElement("""{"metadata":{"version":7,"value":{"name":"x"}}}""") + ) + ) + assertNull( + SessionPatches.parse(HapiJson.parseToJsonElement("""{"todos":{"version":1,"value":{}}}""")) + ) + } + + @Test + fun `parse normalizes legacy copilot fleet mode`() { + val patch = assertNotNull( + SessionPatches.parse(HapiJson.parseToJsonElement("""{"copilotAgentMode":"fleet"}""")) + ) + assertEquals("interactive", patch.copilotAgentMode) + } + + @Test + fun `parsed keep-alive drives the full pipeline end to end`() { + // The canonical ~10s CLI keep-alive shape: only activeAt moves. + val session = makeSession(activeAt = 1_000) + val keepAlive = assertNotNull( + SessionPatches.parse(HapiJson.parseToJsonElement("""{"active":true,"thinking":false,"activeAt":11000}""")) + ) + assertNull(applySessionDetailPatch(session, keepAlive)) + + val minuteLater = assertNotNull( + SessionPatches.parse(HapiJson.parseToJsonElement("""{"active":true,"thinking":false,"activeAt":61000}""")) + ) + assertEquals(61_000, assertNotNull(applySessionDetailPatch(session, minuteLater)).activeAt) + } +} diff --git a/android/core/protocol/src/test/kotlin/app/hapi/protocol/wire/FixtureDecodingTest.kt b/android/core/protocol/src/test/kotlin/app/hapi/protocol/wire/FixtureDecodingTest.kt new file mode 100644 index 00000000..029e681d --- /dev/null +++ b/android/core/protocol/src/test/kotlin/app/hapi/protocol/wire/FixtureDecodingTest.kt @@ -0,0 +1,170 @@ +package app.hapi.protocol.wire + +import java.io.File +import kotlinx.serialization.builtins.ListSerializer +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * Wire-decoding conformance against the golden fixtures in + * `shared/fixtures/` (path injected by the Gradle test task as + * `hapi.fixtures.dir`). Every `.json` file under `chat/` must have its + * `input.messages` array decode into `List` — newly added + * fixtures are picked up automatically and must never break wire decoding. + */ +class FixtureDecodingTest { + + private val fixturesDir: File by lazy { + val path = System.getProperty("hapi.fixtures.dir") + assertNotNull(path, "hapi.fixtures.dir system property not set (see core/protocol/build.gradle.kts)") + val dir = File(path) + assertTrue(dir.isDirectory, "fixtures dir does not exist: $dir") + dir + } + + private fun chatFixtures(): List { + val files = File(fixturesDir, "chat") + .listFiles { file -> file.isFile && file.name.endsWith(".json") } + ?.sortedBy { it.name } + .orEmpty() + assertTrue(files.isNotEmpty(), "no chat fixtures found under $fixturesDir/chat — refusing to pass on zero files") + return files + } + + private fun readFixture(name: String) = + HapiJson.parseToJsonElement(File(fixturesDir, "chat/$name").readText()).jsonObject + + @Test + fun `fixture VERSION is supported`() { + // Fail loudly when the on-disk fixtureVersion is newer than this port + // supports (shared/fixtures/README.md policy) — never silently skip. + val version = File(fixturesDir, "VERSION").readText().trim().toInt() + assertTrue( + version <= SUPPORTED_PROTOCOL_VERSION, + "fixtures are version $version but this client supports <= $SUPPORTED_PROTOCOL_VERSION — update the port" + ) + } + + @Test + fun `every chat fixture's input messages decode as DecryptedMessage`() { + for (file in chatFixtures()) { + val root = HapiJson.parseToJsonElement(file.readText()).jsonObject + assertEquals(1, root.getValue("fixtureVersion").intOrNull, "fixtureVersion in ${file.name}") + val rawMessages = root.getValue("input").jsonObject.getValue("messages").jsonArray + assertTrue(rawMessages.isNotEmpty(), "empty input.messages in ${file.name}") + + val decoded = HapiJson.decodeFromJsonElement( + ListSerializer(DecryptedMessage.serializer()), + rawMessages + ) + assertEquals(rawMessages.size, decoded.size, "message count in ${file.name}") + decoded.forEach { message -> + assertTrue(message.id.isNotEmpty(), "blank message id in ${file.name}") + assertTrue(message.content !is JsonNull, "missing content in ${file.name}") + } + } + } + + @Test + fun `user-text-with-attachments decodes fields and attachments`() { + val fixture = readFixture("user-text-with-attachments.json") + val messages = HapiJson.decodeFromJsonElement( + ListSerializer(DecryptedMessage.serializer()), + fixture.getValue("input").jsonObject.getValue("messages") + ) + val message = messages.single() + assertEquals("msg-user-101", message.id) + assertEquals(1L, message.seq) + assertEquals("local-7d2b9e4f-03c6-4a18-b7e5-3d0f8c4a6b21", message.localId) + assertEquals(1_755_000_000_000L, message.createdAt) + assertEquals(OptionalField.Present(1_755_000_000_510L), message.invokedAt) + assertEquals(1_755_000_000_510L, message.positionAt) + + val envelope = message.content.jsonObject + assertEquals("user", envelope.getValue("role").stringOrNull) + val attachments = HapiJson.decodeFromJsonElement( + ListSerializer(AttachmentMetadata.serializer()), + envelope.getValue("content").jsonObject.getValue("attachments") + ) + assertEquals(2, attachments.size) + assertEquals( + AttachmentMetadata( + id = "att-01HZXK3Q", + filename = "crash.log", + mimeType = "text/plain", + size = 18_432, + path = "/uploads/att-01HZXK3Q/crash.log", + previewUrl = "/api/uploads/att-01HZXK3Q/preview", + ), + attachments[0] + ) + assertNull(attachments[1].previewUrl) + } + + @Test + fun `claude-assistant-text decodes agent message with absent invokedAt`() { + val fixture = readFixture("claude-assistant-text.json") + val messages = HapiJson.decodeFromJsonElement( + ListSerializer(DecryptedMessage.serializer()), + fixture.getValue("input").jsonObject.getValue("messages") + ) + assertEquals(2, messages.size) + + val agent = messages[1] + assertEquals("msg-agent-002", agent.id) + assertEquals(2L, agent.seq) + assertNull(agent.localId) + // No invokedAt key on the wire: tri-state must read Absent (pre-V8 + // "already invoked"), NOT Present(null) ("queued"). + assertEquals(OptionalField.Absent, agent.invokedAt) + assertEquals(agent.createdAt, agent.positionAt) + assertEquals("agent", agent.content.jsonObject.getValue("role").stringOrNull) + assertEquals( + "output", + agent.content.jsonObject.getValue("content").jsonObject.getValue("type").stringOrNull + ) + } + + @Test + fun `permission-synthesized-pending agentState decodes into AgentState`() { + val fixture = readFixture("permission-synthesized-pending.json") + val agentState = HapiJson.decodeFromJsonElement( + AgentState.serializer(), + fixture.getValue("input").jsonObject.getValue("agentState") + ) + assertEquals(emptyMap(), agentState.completedRequests) + val request = assertNotNull(agentState.requests).getValue("req-01J5XKQ8TZ3M") + assertEquals("Bash", request.tool) + assertEquals(1_755_000_006_000L, request.createdAt) + assertEquals( + "bun add zod", + request.arguments.objOrNull?.get("command").stringOrNull + ) + } + + @Test + fun `decrypted message round-trips through encode preserving invokedAt tri-state`() { + val queued = DecryptedMessage( + id = "local-1", + seq = null, + localId = "local-1", + content = HapiJson.parseToJsonElement("""{"role":"user","content":{"type":"text","text":"hi"}}"""), + createdAt = 42L, + invokedAt = OptionalField.Present(null), + ) + val queuedJson = HapiJson.encodeToString(DecryptedMessage.serializer(), queued) + assertTrue("\"invokedAt\":null" in queuedJson, "explicit null must survive encoding: $queuedJson") + assertEquals(queued, HapiJson.decodeFromString(DecryptedMessage.serializer(), queuedJson)) + + val legacyInvoked = queued.copy(invokedAt = OptionalField.Absent) + val legacyJson = HapiJson.encodeToString(DecryptedMessage.serializer(), legacyInvoked) + assertTrue("invokedAt" !in legacyJson, "absent field must stay absent: $legacyJson") + assertEquals(legacyInvoked, HapiJson.decodeFromString(DecryptedMessage.serializer(), legacyJson)) + } +} diff --git a/android/core/protocol/src/test/kotlin/app/hapi/protocol/wire/SessionDecodingTest.kt b/android/core/protocol/src/test/kotlin/app/hapi/protocol/wire/SessionDecodingTest.kt new file mode 100644 index 00000000..b115caa1 --- /dev/null +++ b/android/core/protocol/src/test/kotlin/app/hapi/protocol/wire/SessionDecodingTest.kt @@ -0,0 +1,215 @@ +package app.hapi.protocol.wire + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +/** + * Full-payload decoding for [Session], [SessionSummary] and [MessagesResponse] + * — the "full session" branch of `session-updated` and the REST list/detail/ + * messages responses. Exercises the [HapiJson] leniency rules the models rely + * on (unknown keys ignored, `nullish` coercion, absent nullables). + */ +class SessionDecodingTest { + + @Test + fun `decodes a realistic full session with lenient metadata`() { + val session = HapiJson.decodeFromString( + Session.serializer(), + """ + { + "id": "sess-1", + "namespace": "default", + "seq": 12, + "createdAt": 1755000000000, + "updatedAt": 1755000005000, + "active": true, + "activeAt": null, + "metadata": { + "path": "/data/github/hapi", + "host": "devbox", + "name": "protocol port", + "flavor": "claude", + "startingMode": "remote", + "summary": {"text": "Porting wire models", "updatedAt": 1755000004000}, + "worktree": {"basePath": "/data/github/hapi", "branch": "native-apps", "name": "na"}, + "capabilities": {"terminal": true, "conversationHistory": {"forkCurrent": true}}, + "slashCommands": ["compact", "clear"], + "claudeSessionId": "unmodeled-field-must-be-ignored", + "homeDir": "/home/dev", + "hostPid": 4242 + }, + "metadataVersion": 3, + "agentState": { + "controlledByUser": null, + "requests": { + "req-1": {"tool": "Bash", "arguments": {"command": "ls"}, "createdAt": 1755000004500} + }, + "completedRequests": { + "req-0": {"tool": "Edit", "arguments": {}, "status": "approved", + "decision": "approved_for_session", "allowTools": ["Edit"], + "completedAt": 1755000003000} + } + }, + "agentStateVersion": 7, + "thinking": true, + "thinkingAt": 1755000004900, + "activeTurnStartedAt": null, + "todos": [{"content": "wire models", "status": "completed"}, + {"content": "patch port", "status": "in_progress", "priority": "high", "id": "t2"}], + "todosUpdatedAt": 1755000002000, + "teamState": {"teamName": "crew", "members": []}, + "teamStateUpdatedAt": 5, + "model": "claude-opus-4-1", + "permissionMode": "acceptEdits", + "pinned": true, + "unknownFutureField": {"nested": true} + } + """ + ) + assertEquals("sess-1", session.id) + assertEquals(12L, session.seq) + // zod: activeAt nullish → ?? 0 (coerceInputValues handles the null). + assertEquals(0L, session.activeAt) + assertEquals(true, session.pinned) + assertNull(session.globalPinned) + + val metadata = assertNotNull(session.metadata) + assertEquals("/data/github/hapi", metadata.path) + assertEquals("claude", metadata.flavor) + assertEquals("remote", metadata.startingMode) + assertEquals("Porting wire models", metadata.summary?.text) + assertEquals("native-apps", metadata.worktree?.branch) + assertEquals(true, metadata.capabilities?.terminal) + assertEquals(true, metadata.capabilities?.conversationHistory?.forkCurrent) + assertEquals(listOf("compact", "clear"), metadata.slashCommands) + + val agentState = assertNotNull(session.agentState) + assertNull(agentState.controlledByUser) + assertEquals("Bash", agentState.requests?.get("req-1")?.tool) + val completed = assertNotNull(agentState.completedRequests?.get("req-0")) + assertEquals("approved", completed.status) + assertEquals("approved_for_session", completed.decision) + assertEquals(listOf("Edit"), completed.allowTools) + assertNull(completed.answers) + + assertEquals(2, session.todos?.size) + assertEquals("medium", session.todos?.get(0)?.priority) // zod default + assertEquals("", session.todos?.get(0)?.id) // zod default + assertEquals("high", session.todos?.get(1)?.priority) + assertEquals("crew", session.teamState.objOrNull?.get("teamName").stringOrNull) + assertEquals(1_755_000_002_000L, session.todosUpdatedAt) + assertNull(session.activeTurnStartedAt) + assertNull(session.serviceTier) // zod .default(null) + assertNull(session.effort) + assertEquals("acceptEdits", session.permissionMode) + } + + @Test + fun `session decode fails without required fields - full-vs-patch discrimination`() { + // A SessionPatch payload must NOT decode as a full Session (mirror of + // SessionSchema.safeParse failing in the web reference). + val patchLike = """{"active":true,"activeAt":123}""" + val result = runCatching { + HapiJson.decodeFromString(Session.serializer(), patchLike) + } + assertTrue(result.isFailure) + } + + @Test + fun `decodes a sessions list response`() { + val response = HapiJson.decodeFromString( + SessionsResponse.serializer(), + """ + {"sessions": [{ + "id": "sess-1", + "active": true, + "thinking": false, + "activeAt": 1755000000000, + "updatedAt": 1755000005000, + "pinned": false, + "globalPinned": false, + "metadata": { + "name": "protocol port", + "path": "/data/github/hapi", + "machineId": "mach-1", + "summary": {"text": "Porting wire models"}, + "flavor": "claude", + "agentSessionId": "abc-123", + "lifecycleState": "running" + }, + "metadataVersion": 3, + "agentStateVersion": 7, + "todosUpdatedAt": 1755000002000, + "todoProgress": {"completed": 1, "total": 2}, + "pendingRequestsCount": 6, + "pendingRequestKinds": ["permission", "input"], + "pendingRequests": [{"id": "req-1", "kind": "permission", "tool": "Bash", "since": 1755000004500}], + "backgroundTaskCount": 0, + "futureScheduledMessageCount": 1, + "nextScheduledAt": 1755000009000, + "model": "claude-opus-4-1", + "modelReasoningEffort": null, + "effort": null + }]} + """ + ) + val summary = response.sessions.single() + assertEquals("sess-1", summary.id) + assertEquals(6, summary.pendingRequestsCount) + assertEquals(listOf("permission", "input"), summary.pendingRequestKinds) + assertEquals( + PendingRequest(id = "req-1", kind = "permission", tool = "Bash", since = 1_755_000_004_500L), + summary.pendingRequests.single() + ) + assertEquals(TodoProgress(completed = 1, total = 2), summary.todoProgress) + assertEquals("Porting wire models", summary.metadata?.summary?.text) + assertEquals("abc-123", summary.metadata?.agentSessionId) + assertEquals(1_755_000_009_000L, summary.nextScheduledAt) + assertNull(summary.modelReasoningEffort) + } + + @Test + fun `decodes a messages page envelope`() { + val response = HapiJson.decodeFromString( + MessagesResponse.serializer(), + """ + { + "messages": [ + {"id": "m-1", "seq": 1, "localId": null, "createdAt": 1000, "invokedAt": 1200, + "content": {"role": "user", "content": {"type": "text", "text": "hello"}}}, + {"id": "local-9", "seq": null, "localId": "local-9", "createdAt": 2000, "invokedAt": null, + "content": {"role": "user", "content": {"type": "text", "text": "queued"}}} + ], + "page": { + "direction": "latest", + "limit": 200, + "epoch": 3, + "reset": false, + "nextBeforeSeq": 1, + "nextBeforeAt": 1200, + "nextAfterSeq": null, + "nextAfterAt": null, + "snapshotHeadSeq": 2, + "snapshotHeadAt": 2000, + "hasMore": true + } + } + """ + ) + assertEquals(2, response.messages.size) + assertEquals(1_200L, response.messages[0].positionAt) + // Queued row: explicit null invokedAt, position falls back to createdAt. + assertEquals(OptionalField.Present(null), response.messages[1].invokedAt) + assertEquals(2_000L, response.messages[1].positionAt) + assertNull(response.messages[1].seq) + + assertEquals("latest", response.page.direction) + assertEquals(3L, response.page.epoch) + assertEquals(1L, response.page.nextBeforeSeq) + assertNull(response.page.nextAfterSeq) + assertTrue(response.page.hasMore) + } +} diff --git a/android/core/protocol/src/test/kotlin/app/hapi/protocol/wire/SyncEventDecodingTest.kt b/android/core/protocol/src/test/kotlin/app/hapi/protocol/wire/SyncEventDecodingTest.kt new file mode 100644 index 00000000..6f5248c3 --- /dev/null +++ b/android/core/protocol/src/test/kotlin/app/hapi/protocol/wire/SyncEventDecodingTest.kt @@ -0,0 +1,190 @@ +package app.hapi.protocol.wire + +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.jsonObject +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * `SyncEvents.parse` coverage for the 13-type union + * (`docs/api/client-contract/sse.md`) plus the never-throw degradation rules. + */ +class SyncEventDecodingTest { + + @Test + fun `decodes connection-changed handshake with resume verdict`() { + val event = SyncEvents.parse( + """{"type":"connection-changed","data":{"status":"connected","subscriptionId":"sub-1","resume":"ok"}}""" + ) + val handshake = assertIs(event) + assertEquals("connected", handshake.data?.status) + assertEquals("sub-1", handshake.data?.subscriptionId) + assertEquals("ok", handshake.data?.resume) + assertNull(handshake.namespace) + } + + @Test + fun `decodes heartbeat with and without data`() { + val with = assertIs( + SyncEvents.parse("""{"type":"heartbeat","namespace":"default","data":{"timestamp":1755000000000}}""") + ) + assertEquals(1_755_000_000_000L, with.data?.timestamp) + assertEquals("default", with.namespace) + + val bare = assertIs(SyncEvents.parse("""{"type":"heartbeat"}""")) + assertNull(bare.data) + } + + @Test + fun `decodes session-updated keeping data raw for two-phase resolution`() { + val event = assertIs( + SyncEvents.parse( + """{"type":"session-updated","namespace":"default","sessionId":"s-1","data":{"activeAt":123,"active":true}}""" + ) + ) + assertEquals("s-1", event.sessionId) + val patch = assertNotNull(SessionPatches.parse(event.data)) + assertEquals(123L, patch.activeAt) + assertEquals(true, patch.active) + } + + @Test + fun `decodes message-received including the tri-state invokedAt`() { + val queued = assertIs( + SyncEvents.parse( + """ + {"type":"message-received","sessionId":"s-1","message": + {"id":"m-1","seq":7,"localId":"local-1","createdAt":1000,"invokedAt":null, + "content":{"role":"user","content":{"type":"text","text":"hi"}}}} + """ + ) + ) + assertEquals("s-1", queued.sessionId) + assertEquals(7L, queued.message.seq) + assertEquals(OptionalField.Present(null), queued.message.invokedAt) + assertEquals("user", queued.message.content.jsonObject.getValue("role").stringOrNull) + } + + @Test + fun `decodes messages-consumed and message-cancelled reconciliation events`() { + val consumed = assertIs( + SyncEvents.parse( + """{"type":"messages-consumed","sessionId":"s-1","localIds":["a","b"],"invokedAt":2000}""" + ) + ) + assertEquals(listOf("a", "b"), consumed.localIds) + assertEquals(2_000L, consumed.invokedAt) + + val cancelled = assertIs( + SyncEvents.parse("""{"type":"message-cancelled","sessionId":"s-1","messageId":"m-9"}""") + ) + assertEquals("m-9", cancelled.messageId) + assertNull(cancelled.localId) + } + + @Test + fun `decodes the remaining session and machine lifecycle events`() { + assertEquals( + SyncEvent.SessionAdded(namespace = "default", sessionId = "s-2"), + SyncEvents.parse("""{"type":"session-added","namespace":"default","sessionId":"s-2"}""") + ) + assertEquals( + SyncEvent.SessionRemoved(sessionId = "s-2"), + SyncEvents.parse("""{"type":"session-removed","sessionId":"s-2"}""") + ) + assertEquals( + SyncEvent.MessagesInvalidated(sessionId = "s-2"), + SyncEvents.parse("""{"type":"messages-invalidated","sessionId":"s-2"}""") + ) + assertEquals( + SyncEvent.ScheduledMatured(sessionId = "s-2"), + SyncEvents.parse("""{"type":"scheduled-matured","sessionId":"s-2"}""") + ) + assertEquals( + SyncEvent.SessionEnded(sessionId = "s-2", reason = "completed"), + SyncEvents.parse("""{"type":"session-ended","sessionId":"s-2","reason":"completed"}""") + ) + + val removal = assertIs( + SyncEvents.parse("""{"type":"machine-updated","machineId":"mach-1","data":null}""") + ) + assertEquals("mach-1", removal.machineId) + // Explicit JSON null (= machine removed) must stay distinguishable + // from an absent data key (= refetch machines). + assertEquals(OptionalField.Present(JsonNull), removal.data) + + val refetch = assertIs( + SyncEvents.parse("""{"type":"machine-updated","machineId":"mach-1"}""") + ) + assertEquals(OptionalField.Absent, refetch.data) + + val patch = assertIs( + SyncEvents.parse("""{"type":"machine-updated","machineId":"mach-1","data":{"active":false,"updatedAt":5}}""") + ) + assertEquals( + MachinePatch(active = false, updatedAt = 5), + MachinePatches.parse(patch.data.valueOrNull()) + ) + } + + @Test + fun `decodes toast payload`() { + val toast = assertIs( + SyncEvents.parse( + """{"type":"toast","data":{"title":"Ready","body":"Agent is done","sessionId":"s-1","url":"/sessions/s-1"}}""" + ) + ) + assertEquals("Ready", toast.data.title) + assertEquals("/sessions/s-1", toast.data.url) + } + + @Test + fun `unknown event types degrade to Unknown - never throw`() { + val unknown = assertIs( + SyncEvents.parse("""{"type":"quantum-entangled","namespace":"default","payload":1}""") + ) + assertEquals("quantum-entangled", unknown.type) + assertEquals("default", unknown.namespace) + } + + @Test + fun `malformed known-type payloads degrade to Unknown`() { + // message-received without its required message: the web reference + // drops frames failing safeParse; we surface Unknown instead. + val malformed = SyncEvents.parse("""{"type":"message-received","sessionId":"s-1"}""") + assertIs(malformed) + assertEquals("message-received", (malformed as SyncEvent.Unknown).type) + } + + @Test + fun `non-object and invalid frames degrade to Unknown`() { + assertIs(SyncEvents.parse("[]")) + assertIs(SyncEvents.parse("not json at all")) + assertIs(SyncEvents.parse("""{"data":{}}""")) + assertTrue((SyncEvents.parse("""{"type":42}""") as SyncEvent.Unknown).type == null) + } + + @Test + fun `machine wire model decodes a realistic payload`() { + val machine = HapiJson.decodeFromString( + Machine.serializer(), + """ + {"id":"mach-1","namespace":"default","seq":3,"createdAt":1,"updatedAt":2, + "active":true,"activeAt":3,"metadataVersion":4,"runnerStateVersion":5, + "metadata":{"host":"devbox","platform":"linux","happyCliVersion":"0.28.0","workspaceRoots":["/data"]}, + "runnerState":{"status":"running","pid":4242,"httpPort":8080,"capabilities":{"agentConfigs":[]}}, + "health":{"collectedAt":9,"cpuCount":16,"cpuPercent":12.5}} + """ + ) + assertEquals("devbox", machine.metadata?.host) + assertEquals("running", machine.runnerState?.status) + assertEquals(4242, machine.runnerState?.pid) + assertEquals(12.5, machine.health?.cpuPercent) + assertNull(machine.runnerState?.lastSpawnError) + } +}