mirror of
https://github.com/wu736139669/hapi.git
synced 2026-10-14 20:26:05 +00:00
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:
+1
-1
@@ -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)
|
||||
}
|
||||
}
|
||||
+190
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user