feat(android): protocol wire models, catalogs, session patching (B-M1a)

:core:protocol, package app.hapi.protocol — pure-JVM foundations for the
Android client, mirrored from shared/src/schemas.ts + sessionSummary.ts and
docs/api/client-contract/{sse,pagination,messages}.md:

- wire/: shared HapiJson config; DecryptedMessage with tri-state invokedAt
  (custom serializer — explicit null = queued vs absent = legacy-invoked);
  AttachmentMetadata; AgentState + (completed) requests; Session verified
  field-by-field against SessionSchema; typed-subset SessionMetadata;
  SessionSummary/PendingRequest; SessionPatch with VersionedValue wrappers
  and a strict SessionPatches.parse (unknown key / empty / mistyped -> null,
  replicating SessionPatchSchema.strict()); Machine family; the 13-type
  SyncEvent union behind total SyncEvents.parse (unknown/malformed ->
  Unknown, never throws; machine-updated data kept tri-state for the
  removed-vs-refetch distinction); MessagesPage + REST envelopes.
- catalog/: AgentFlavor (known + Other(raw)) with capabilities/labels from
  flavors.ts; PermissionMode/tone table, per-flavor mode lists, codex
  collaboration + copilot agent modes incl. legacy 'fleet' normalization.
- patch/SessionPatching.kt: exact port of web/src/lib/sessionPatch.ts —
  strict > version gates, max-monotonic updatedAt, activeAt <60s
  render-irrelevance, and deliberately NOT applying activeTurnStartedAt /
  scratchlistUpdatedAt (the TS reference never assigns them).

Tests (85 total, all green): golden-fixture decoding over shared/fixtures/
chat/* (fails loudly on zero files or unsupported VERSION), version-gate /
keep-alive cases ported from useSSE.test.ts, full-session + list + messages
page decode, SyncEvent union coverage, catalog pins (TODO(K6): switch to
fixtures/catalogs/modes.json once generated). :app:assembleDebug verified.
This commit is contained in:
weishu
2026-08-17 12:06:32 +08:00
parent 81146e68c5
commit b09dd67268
20 changed files with 2508 additions and 1 deletions
+1 -1
View File
@@ -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). |
@@ -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<AgentFlavor> = 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<AgentFlavor> = KNOWN.filter { it != Gemini }
private val BY_ID: Map<String, AgentFlavor> = 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<String, Set<FlavorCapability>> = 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<String, String> = 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"
}
@@ -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<String, PermissionMode> = 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<PermissionMode> = 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<String, CodexCollaborationMode> = 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<String, CopilotAgentMode> = 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
}
}
@@ -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
}
@@ -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<String, AgentStateRequest>? = null,
/** Resolved requests keyed by request id. */
val completedRequests: Map<String, AgentStateCompletedRequest>? = 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<String>? = 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,
)
@@ -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<DecryptedMessage>,
val page: MessagesPage,
)
/** `GET /api/sessions`. */
@Serializable
data class SessionsResponse(
val sessions: List<SessionSummary>,
)
/** `GET /api/sessions/:id`. */
@Serializable
data class SessionResponse(
val session: Session,
)
/** `GET /api/machines`. */
@Serializable
data class MachinesResponse(
val machines: List<Machine>,
)
@@ -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<Long?> = 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<DecryptedMessage> {
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,
)
@@ -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
}
@@ -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<String>? = null,
/** Machine-scoped RPC capability ids this runner registers. */
val capabilities: List<String>? = 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)
}
}
@@ -0,0 +1,24 @@
package app.hapi.protocol.wire
/**
* Tri-state wire field: distinguishes an absent key from an explicit JSON
* `null` (use `OptionalField<T?>` 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<out T> {
data object Absent : OptionalField<Nothing>
data class Present<out T>(val value: T) : OptionalField<T>
}
val OptionalField<*>.isPresent: Boolean
get() = this is OptionalField.Present
/** The present value, or null when absent (collapses the tri-state). */
fun <T> OptionalField<T?>.valueOrNull(): T? = (this as? OptionalField.Present)?.value
@@ -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<TodoItem>? = 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,
)
@@ -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<String>? = null,
val tools: List<String>? = 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,
)
@@ -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<T>(
/**
* 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<Long?> = OptionalField.Absent,
val activeAt: Long? = null,
val updatedAt: Long? = null,
val metadata: VersionedValue<SessionMetadata>? = null,
val agentState: VersionedValue<AgentState>? = null,
val todos: VersionedValue<List<TodoItem>>? = null,
val teamState: VersionedValue<JsonElement>? = null,
val model: OptionalField<String?> = OptionalField.Absent,
val modelReasoningEffort: OptionalField<String?> = OptionalField.Absent,
val effort: OptionalField<String?> = OptionalField.Absent,
val serviceTier: OptionalField<String?> = 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 <T> versioned(wrapper: JsonElement, decodeValue: (JsonElement) -> T?): VersionedValue<T>? {
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<Long?>? {
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<String?>? {
val raw = obj[key] ?: return OptionalField.Absent
if (raw is JsonNull) return OptionalField.Present(null)
return raw.stringOrNull?.let { OptionalField.Present(it) }
}
}
@@ -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<String> = emptyList(),
/** Capped (≤ 5), oldest-first slice for per-row hover copy. */
val pendingRequests: List<PendingRequest> = 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,
)
@@ -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<JsonElement> = 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<String>,
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<SyncEvent> = 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
},
)
}
}
@@ -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"))
}
}
@@ -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<TodoItem>? = 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<String?>(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<JsonElement>(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)
}
}
@@ -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<DecryptedMessage>` — 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<File> {
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<Long?>(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))
}
}
@@ -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<Long?>(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)
}
}
@@ -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<SyncEvent.ConnectionChanged>(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<SyncEvent.Heartbeat>(
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<SyncEvent.Heartbeat>(SyncEvents.parse("""{"type":"heartbeat"}"""))
assertNull(bare.data)
}
@Test
fun `decodes session-updated keeping data raw for two-phase resolution`() {
val event = assertIs<SyncEvent.SessionUpdated>(
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<SyncEvent.MessageReceived>(
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<Long?>(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<SyncEvent.MessagesConsumed>(
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<SyncEvent.MessageCancelled>(
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<SyncEvent.MachineUpdated>(
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<JsonElement>(JsonNull), removal.data)
val refetch = assertIs<SyncEvent.MachineUpdated>(
SyncEvents.parse("""{"type":"machine-updated","machineId":"mach-1"}""")
)
assertEquals(OptionalField.Absent, refetch.data)
val patch = assertIs<SyncEvent.MachineUpdated>(
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<SyncEvent.Toast>(
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<SyncEvent.Unknown>(
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<SyncEvent.Unknown>(malformed)
assertEquals("message-received", (malformed as SyncEvent.Unknown).type)
}
@Test
fun `non-object and invalid frames degrade to Unknown`() {
assertIs<SyncEvent.Unknown>(SyncEvents.parse("[]"))
assertIs<SyncEvent.Unknown>(SyncEvents.parse("not json at all"))
assertIs<SyncEvent.Unknown>(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)
}
}