fix(sessions): tolerate integer and string boolean formats for pinned field (#966) (#967)

Co-authored-by: M57 <hy4ri@users.noreply.github.com>
This commit is contained in:
M57andM57 authored and GitHub committed 2026-08-25 17:05:14 +03:00
1 parent ece0288ca8
commit 9217f9f8b6
2 files changed
+98

No files matched your search

@@ -1,5 +1,60 @@
package com.m57.hermescontrol.data.model
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.KSerializer
import kotlinx.serialization.Serializable
import kotlinx.serialization.descriptors.PrimitiveKind
import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor
import kotlinx.serialization.descriptors.SerialDescriptor
import kotlinx.serialization.encoding.Decoder
import kotlinx.serialization.encoding.Encoder
import kotlinx.serialization.json.JsonDecoder
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.booleanOrNull
import kotlinx.serialization.json.intOrNull
/**
* Lenient boolean serializer that decodes both JSON booleans (`true`/`false`)
* and raw SQLite numeric booleans (`1`/`0`), as well as string representations (`"true"`/`"1"`).
*
* This guards against backend versions (e.g. Hermes Agent <= 0.19.0) where database
* boolean flags like `pinned` are returned as raw integer values (issue #966).
*/
@OptIn(ExperimentalSerializationApi::class)
object LenientNullableBooleanSerializer : KSerializer<Boolean?> {
override val descriptor: SerialDescriptor =
PrimitiveSerialDescriptor("LenientNullableBoolean", PrimitiveKind.BOOLEAN)
override fun deserialize(decoder: Decoder): Boolean? {
if (decoder is JsonDecoder) {
val element = decoder.decodeJsonElement()
if (element is JsonPrimitive) {
element.booleanOrNull?.let { return it }
element.intOrNull?.let { return it != 0 }
val str = element.content.trim().lowercase()
return when (str) {
"true", "1" -> true
"false", "0" -> false
"null", "" -> null
else -> null
}
}
return null
}
return decoder.decodeBoolean()
}
override fun serialize(
encoder: Encoder,
value: Boolean?,
) {
if (value == null) {
encoder.encodeNull()
} else {
encoder.encodeBoolean(value)
}
}
}
@Serializable
data class SessionListResponse(
@@ -25,6 +80,8 @@ data class SessionInfo(
val terminal_backend: String? = null,
// Durable "keep" flag: pinned sessions are exempt from the auto-archive
// sweep and are surfaced first in the history list (sorted client-side).
// Uses LenientNullableBooleanSerializer to tolerate both JSON booleans and raw SQLite ints (issue #966).
@Serializable(with = LenientNullableBooleanSerializer::class)
val pinned: Boolean? = null,
)
@@ -271,6 +271,47 @@ class ModelSerializationTest {
}
}
@Test
fun testSessionInfoDeserialization_pinnedToleratesIntAndBooleanAndString() {
// Backend returns integer 0 (SQLite style)
val jsonIntZero = """{"id": "s1", "pinned": 0}"""
val s1 = json.decodeFromString<SessionInfo>(jsonIntZero)
assertEquals(false, s1.pinned)
// Backend returns integer 1
val jsonIntOne = """{"id": "s2", "pinned": 1}"""
val s2 = json.decodeFromString<SessionInfo>(jsonIntOne)
assertEquals(true, s2.pinned)
// Backend returns boolean false
val jsonBoolFalse = """{"id": "s3", "pinned": false}"""
val s3 = json.decodeFromString<SessionInfo>(jsonBoolFalse)
assertEquals(false, s3.pinned)
// Backend returns boolean true
val jsonBoolTrue = """{"id": "s4", "pinned": true}"""
val s4 = json.decodeFromString<SessionInfo>(jsonBoolTrue)
assertEquals(true, s4.pinned)
// Backend returns null or omitted
val jsonNull = """{"id": "s5", "pinned": null}"""
val s5 = json.decodeFromString<SessionInfo>(jsonNull)
assertNull(s5.pinned)
val jsonOmitted = """{"id": "s6"}"""
val s6 = json.decodeFromString<SessionInfo>(jsonOmitted)
assertNull(s6.pinned)
// Backend returns string "1" / "0" / "true" / "false"
val jsonStrOne = """{"id": "s7", "pinned": "1"}"""
val s7 = json.decodeFromString<SessionInfo>(jsonStrOne)
assertEquals(true, s7.pinned)
val jsonStrFalse = """{"id": "s8", "pinned": "false"}"""
val s8 = json.decodeFromString<SessionInfo>(jsonStrFalse)
assertEquals(false, s8.pinned)
}
@Test
fun testSessionMessagesResponseDeserialization_missingMessages_causesNullOnNonNullableField() {
val jsonStr = "{}"