From 883e93cecb9cb4f4ea44d80d874d712a53f62090 Mon Sep 17 00:00:00 2001 From: kami Date: Thu, 2 Jul 2026 13:26:34 +0400 Subject: [PATCH] fix(events): AnyMapSerializer drops nulls instead of failing to read them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit toJsonElement wrote a null map value as JSON null, but toAny threw on reading it back — asymmetric, so a single null-valued tool-arg made the whole session's events undecodable: 500 on /events AND a runtime WorkflowFailed when the projection rebuilt mid-run. A Map can't hold a null anyway, so drop null-valued keys on both serialize and deserialize, keeping the log round-trippable. --- .../events/serialization/AnyMapSerializer.kt | 35 +++++++++++------ .../serialization/AnyMapSerializerTest.kt | 39 +++++++++++++++++++ 2 files changed, 63 insertions(+), 11 deletions(-) create mode 100644 core/events/src/test/kotlin/com/correx/core/events/serialization/AnyMapSerializerTest.kt diff --git a/core/events/src/main/kotlin/com/correx/core/events/serialization/AnyMapSerializer.kt b/core/events/src/main/kotlin/com/correx/core/events/serialization/AnyMapSerializer.kt index e5db3101..0a64138a 100644 --- a/core/events/src/main/kotlin/com/correx/core/events/serialization/AnyMapSerializer.kt +++ b/core/events/src/main/kotlin/com/correx/core/events/serialization/AnyMapSerializer.kt @@ -28,43 +28,56 @@ private object AnySerializer : KSerializer { override fun deserialize(decoder: Decoder): Any { val jsonDecoder = decoder as? JsonDecoder ?: throw SerializationException("AnySerializer requires a JSON decoder") - return jsonDecoder.decodeJsonElement().toAny() + return jsonDecoder.decodeJsonElement().toAnyOrNull() + ?: throw SerializationException("null values are not supported in Map") } } +// Null values are dropped, not written: a Map can't hold a Kotlin null, so a null +// (e.g. a tool arg the model emitted as JSON null) carries no information. Serialize and deserialize +// must stay symmetric — the event log has to round-trip anything it writes, or a single null-valued +// map key makes the whole session's events undecodable (500 on /events, unreplayable). private fun Any?.toJsonElement(): JsonElement = when (this) { null -> JsonNull is String -> JsonPrimitive(this) is Number -> JsonPrimitive(this) is Boolean -> JsonPrimitive(this) - is Map<*, *> -> JsonObject(entries.associate { (k, v) -> k.toString() to v.toJsonElement() }) - is List<*> -> JsonArray(map { it.toJsonElement() }) + is Map<*, *> -> JsonObject(entries.mapNotNull { (k, v) -> v?.let { k.toString() to it.toJsonElement() } }.toMap()) + is List<*> -> JsonArray(mapNotNull { it?.toJsonElement() }) else -> throw SerializationException("Unsupported type: ${this::class.simpleName}") } -private fun JsonElement.toAny(): Any = when (this) { - is JsonNull -> throw SerializationException("null values are not supported in Map") +// Returns null for JSON null (and the literal "null" scalar); callers drop those entries so a +// null value never reaches the non-null Map. +private fun JsonElement.toAnyOrNull(): Any? = when (this) { + is JsonNull -> null is JsonPrimitive -> { val content = this.content when { content == "true" || content == "false" -> content == "true" - content == "null" -> throw SerializationException("null values are not supported in Map") + content == "null" -> null content.toDoubleOrNull() != null && content.contains('.') -> content.toDouble() content.toLongOrNull() != null -> content.toLong() else -> content // fallback to string } } - is JsonObject -> entries.associate { (k, v) -> k to v.toAny() } - is JsonArray -> map { it.toAny() } + is JsonObject -> entries.mapNotNull { (k, v) -> v.toAnyOrNull()?.let { k to it } }.toMap() + is JsonArray -> mapNotNull { it.toAnyOrNull() } } object AnyMapSerializer : KSerializer> { private val delegate = MapSerializer(String.serializer(), AnySerializer) override val descriptor: SerialDescriptor = delegate.descriptor - @Suppress("UNCHECKED_CAST") - override fun deserialize(decoder: Decoder): Map = - delegate.deserialize(decoder) + override fun deserialize(decoder: Decoder): Map { + // Decode the object directly and drop null-valued keys, rather than delegating per-value + // (which throws on a null before the map can skip it). Keeps a null-containing map readable. + val jsonDecoder = decoder as? JsonDecoder + ?: return delegate.deserialize(decoder) + val obj = jsonDecoder.decodeJsonElement() as? JsonObject + ?: throw SerializationException("expected a JSON object for Map") + return obj.entries.mapNotNull { (k, v) -> v.toAnyOrNull()?.let { k to it } }.toMap() + } override fun serialize(encoder: Encoder, value: Map) = delegate.serialize(encoder, value) diff --git a/core/events/src/test/kotlin/com/correx/core/events/serialization/AnyMapSerializerTest.kt b/core/events/src/test/kotlin/com/correx/core/events/serialization/AnyMapSerializerTest.kt new file mode 100644 index 00000000..dc38eed5 --- /dev/null +++ b/core/events/src/test/kotlin/com/correx/core/events/serialization/AnyMapSerializerTest.kt @@ -0,0 +1,39 @@ +package com.correx.core.events.serialization + +import kotlinx.serialization.json.Json +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +class AnyMapSerializerTest { + + @Test + fun `deserializes a map whose value is JSON null by dropping that key`() { + // The exact shape that used to 500 the whole session's /events: a persisted tool-arg map + // with a null value. Reading it back must succeed, not throw. + val decoded = Json.decodeFromString(AnyMapSerializer, """{"path":"a.txt","mode":null}""") + assertEquals("a.txt", decoded["path"]) + assertFalse(decoded.containsKey("mode")) + } + + @Test + fun `round-trips scalars, nested objects, and arrays`() { + val decoded = Json.decodeFromString( + AnyMapSerializer, + """{"s":"x","n":3,"b":true,"arr":[1,2],"obj":{"k":"v"}}""", + ) + assertEquals("x", decoded["s"]) + assertEquals(3L, decoded["n"]) + assertEquals(true, decoded["b"]) + assertEquals(listOf(1L, 2L), decoded["arr"]) + assertEquals(mapOf("k" to "v"), decoded["obj"]) + } + + @Test + fun `does not write null values`() { + val json = Json.encodeToString(AnyMapSerializer, mapOf("a" to "keep")) + assertFalse(json.contains("null")) + assertTrue(json.contains("\"a\":\"keep\"")) + } +}