From 0226c308c9eaccaa87933e89e9cdaf0754eac51b Mon Sep 17 00:00:00 2001 From: "fabrizio.scarponi" Date: Wed, 11 Mar 2026 11:23:45 +0100 Subject: [PATCH 1/2] Add custom serializer for SetSessionConfigOptionRequest Align with agentclientprotocol/agent-client-protocol#576 by introducing a custom serializer that uses a "type" discriminator field for boolean config options while preserving backward compatibility for select/string values. - Boolean values serialize as {"type":"boolean","value":true} - String values serialize as {"value":"..."} (no type field, unchanged) - Unknown types fall back to UnknownValue for forward compatibility --- acp-model/api/acp-model.api | 11 -- .../com/agentclientprotocol/model/Requests.kt | 2 +- .../model/SessionConfig.kt | 105 ++++++++++++++++++ ...essionConfigSelectOptionsSerializerTest.kt | 38 +++++-- build.gradle.kts | 2 +- 5 files changed, 136 insertions(+), 22 deletions(-) diff --git a/acp-model/api/acp-model.api b/acp-model/api/acp-model.api index cb8c88e..a0b39af 100644 --- a/acp-model/api/acp-model.api +++ b/acp-model/api/acp-model.api @@ -3602,17 +3602,6 @@ public final class com/agentclientprotocol/model/SetSessionConfigOptionRequest : public fun toString ()Ljava/lang/String; } -public final synthetic class com/agentclientprotocol/model/SetSessionConfigOptionRequest$$serializer : kotlinx/serialization/internal/GeneratedSerializer { - public static final field INSTANCE Lcom/agentclientprotocol/model/SetSessionConfigOptionRequest$$serializer; - public final fun childSerializers ()[Lkotlinx/serialization/KSerializer; - public final fun deserialize (Lkotlinx/serialization/encoding/Decoder;)Lcom/agentclientprotocol/model/SetSessionConfigOptionRequest; - public synthetic fun deserialize (Lkotlinx/serialization/encoding/Decoder;)Ljava/lang/Object; - public final fun getDescriptor ()Lkotlinx/serialization/descriptors/SerialDescriptor; - public final fun serialize (Lkotlinx/serialization/encoding/Encoder;Lcom/agentclientprotocol/model/SetSessionConfigOptionRequest;)V - public synthetic fun serialize (Lkotlinx/serialization/encoding/Encoder;Ljava/lang/Object;)V - public fun typeParametersSerializers ()[Lkotlinx/serialization/KSerializer; -} - public final class com/agentclientprotocol/model/SetSessionConfigOptionRequest$Companion { public final fun serializer ()Lkotlinx/serialization/KSerializer; } diff --git a/acp-model/src/commonMain/kotlin/com/agentclientprotocol/model/Requests.kt b/acp-model/src/commonMain/kotlin/com/agentclientprotocol/model/Requests.kt index 9be7b04..5d71829 100644 --- a/acp-model/src/commonMain/kotlin/com/agentclientprotocol/model/Requests.kt +++ b/acp-model/src/commonMain/kotlin/com/agentclientprotocol/model/Requests.kt @@ -766,7 +766,7 @@ public data class ResumeSessionResponse( * Request to set a configuration option for a session. */ @UnstableApi -@Serializable +@Serializable(with = SetSessionConfigOptionRequestSerializer::class) public data class SetSessionConfigOptionRequest( override val sessionId: SessionId, val configId: SessionConfigId, diff --git a/acp-model/src/commonMain/kotlin/com/agentclientprotocol/model/SessionConfig.kt b/acp-model/src/commonMain/kotlin/com/agentclientprotocol/model/SessionConfig.kt index b5da6e1..30f6573 100644 --- a/acp-model/src/commonMain/kotlin/com/agentclientprotocol/model/SessionConfig.kt +++ b/acp-model/src/commonMain/kotlin/com/agentclientprotocol/model/SessionConfig.kt @@ -24,7 +24,10 @@ import kotlinx.serialization.json.JsonPrimitive import kotlinx.serialization.json.boolean import kotlinx.serialization.json.booleanOrNull import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.buildJsonObject import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.put /** * **UNSTABLE** @@ -286,3 +289,105 @@ internal object SessionConfigOptionValueSerializer : KSerializer { + override val descriptor: SerialDescriptor = buildClassSerialDescriptor("SetSessionConfigOptionRequest") + + override fun serialize(encoder: Encoder, value: SetSessionConfigOptionRequest) { + val jsonEncoder = encoder as? JsonEncoder + ?: throw SerializationException("SetSessionConfigOptionRequestSerializer supports only JSON") + val jsonElement = buildJsonObject { + put("sessionId", value.sessionId.value) + put("configId", value.configId.value) + when (val v = value.value) { + is SessionConfigOptionValue.BoolValue -> { + put("type", "boolean") + put("value", v.value) + } + is SessionConfigOptionValue.StringValue -> { + put("value", v.value) + } + is SessionConfigOptionValue.UnknownValue -> { + // Preserve unknown fields - if it was an object with type+value, re-flatten them + val raw = v.rawElement + if (raw is JsonObject) { + for ((key, element) in raw) { + put(key, element) + } + } else { + put("value", raw) + } + } + } + if (value._meta != null) { + put("_meta", value._meta) + } + } + jsonEncoder.encodeJsonElement(jsonElement) + } + + override fun deserialize(decoder: Decoder): SetSessionConfigOptionRequest { + val jsonDecoder = decoder as? JsonDecoder + ?: throw SerializationException("SetSessionConfigOptionRequestSerializer supports only JSON") + val jsonObject = jsonDecoder.decodeJsonElement().jsonObject + + val sessionId = SessionId(jsonObject["sessionId"]?.let { + (it as? JsonPrimitive)?.content + } ?: throw SerializationException("Missing 'sessionId'")) + + val configId = SessionConfigId(jsonObject["configId"]?.let { + (it as? JsonPrimitive)?.content + } ?: throw SerializationException("Missing 'configId'")) + + val type = (jsonObject["type"] as? JsonPrimitive)?.content + val rawValue = jsonObject["value"] + ?: throw SerializationException("Missing 'value'") + + val value: SessionConfigOptionValue = when (type) { + "boolean" -> { + val primitive = rawValue as? JsonPrimitive + ?: throw SerializationException("Expected boolean primitive for type 'boolean'") + if (primitive.booleanOrNull != null) { + SessionConfigOptionValue.BoolValue(primitive.boolean) + } else { + SessionConfigOptionValue.UnknownValue(rawValue) + } + } + null -> { + // No type field = backward-compatible string value (select/value-id) + val primitive = rawValue as? JsonPrimitive + if (primitive != null && primitive.isString) { + SessionConfigOptionValue.StringValue(primitive.content) + } else { + SessionConfigOptionValue.UnknownValue(rawValue) + } + } + else -> { + // Unknown type - forward compatibility + SessionConfigOptionValue.UnknownValue(rawValue) + } + } + + val meta = jsonObject["_meta"] + + return SetSessionConfigOptionRequest( + sessionId = sessionId, + configId = configId, + value = value, + _meta = meta + ) + } +} diff --git a/acp-model/src/commonTest/kotlin/com/agentclientprotocol/model/SessionConfigSelectOptionsSerializerTest.kt b/acp-model/src/commonTest/kotlin/com/agentclientprotocol/model/SessionConfigSelectOptionsSerializerTest.kt index 94919e1..da379bb 100644 --- a/acp-model/src/commonTest/kotlin/com/agentclientprotocol/model/SessionConfigSelectOptionsSerializerTest.kt +++ b/acp-model/src/commonTest/kotlin/com/agentclientprotocol/model/SessionConfigSelectOptionsSerializerTest.kt @@ -8,6 +8,7 @@ import com.agentclientprotocol.rpc.JsonRpcRequest import com.agentclientprotocol.rpc.JsonRpcResponse import kotlinx.serialization.json.JsonNull import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.jsonObject import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertIs @@ -479,6 +480,7 @@ class SessionConfigSelectOptionsSerializerTest { "params": { "sessionId": "sess_abc123def456", "configId": "auto_approve", + "type": "boolean", "value": true } } @@ -504,6 +506,9 @@ class SessionConfigSelectOptionsSerializerTest { ) val encoded = ACPJson.encodeToString(SetSessionConfigOptionRequest.serializer(), original) + // Verify the encoded JSON contains "type":"boolean" at top level + val encodedJson = ACPJson.parseToJsonElement(encoded) + assertEquals("boolean", encodedJson.jsonObject["type"]?.let { (it as? JsonPrimitive)?.content }) val decoded = ACPJson.decodeFromString(SetSessionConfigOptionRequest.serializer(), encoded) assertEquals(original.sessionId, decoded.sessionId) assertEquals(original.configId, decoded.configId) @@ -520,6 +525,9 @@ class SessionConfigSelectOptionsSerializerTest { ) val encoded = ACPJson.encodeToString(SetSessionConfigOptionRequest.serializer(), original) + // Verify the encoded JSON does NOT contain "type" field for string values + val encodedJson = ACPJson.parseToJsonElement(encoded) + assertEquals(null, encodedJson.jsonObject["type"]) val decoded = ACPJson.decodeFromString(SetSessionConfigOptionRequest.serializer(), encoded) assertEquals(original.sessionId, decoded.sessionId) assertEquals(original.configId, decoded.configId) @@ -546,27 +554,39 @@ class SessionConfigSelectOptionsSerializerTest { } @Test - fun `numeric value in config option deserializes as UnknownValue`() { - val json = """{"sessionId":"s","configId":"c","value":42}""" + fun `decode set config option request without type field is backward compatible`() { + // Old format without "type" field should still work as StringValue + val json = """{"sessionId":"s","configId":"c","value":"model-1"}""" val request = ACPJson.decodeFromString(SetSessionConfigOptionRequest.serializer(), json) - val unknown = assertIs(request.value) - assertEquals(JsonPrimitive(42), unknown.rawElement) + assertEquals(SessionId("s"), request.sessionId) + assertEquals(SessionConfigId("c"), request.configId) + val stringValue = assertIs(request.value) + assertEquals("model-1", stringValue.value) } @Test - fun `array value in config option deserializes as UnknownValue`() { - val json = """{"sessionId":"s","configId":"c","value":["a","b"]}""" + fun `decode set config option request with type boolean`() { + val json = """{"sessionId":"s","configId":"c","type":"boolean","value":true}""" val request = ACPJson.decodeFromString(SetSessionConfigOptionRequest.serializer(), json) - assertIs(request.value) + val boolValue = assertIs(request.value) + assertEquals(true, boolValue.value) } @Test - fun `object value in config option deserializes as UnknownValue`() { - val json = """{"sessionId":"s","configId":"c","value":{"key":"val"}}""" + fun `decode set config option request with unknown type falls back to UnknownValue`() { + val json = """{"sessionId":"s","configId":"c","type":"multi_select","value":["a","b"]}""" val request = ACPJson.decodeFromString(SetSessionConfigOptionRequest.serializer(), json) assertIs(request.value) } + @Test + fun `numeric value in config option without type deserializes as UnknownValue`() { + val json = """{"sessionId":"s","configId":"c","value":42}""" + val request = ACPJson.decodeFromString(SetSessionConfigOptionRequest.serializer(), json) + val unknown = assertIs(request.value) + assertEquals(JsonPrimitive(42), unknown.rawElement) + } + @Test fun `UnknownValue roundtrip preserves raw element`() { val json = """{"sessionId":"s","configId":"c","value":42}""" diff --git a/build.gradle.kts b/build.gradle.kts index 4680d8d..c983fd9 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -7,7 +7,7 @@ plugins { private val buildNumber: String? = System.getenv("GITHUB_RUN_NUMBER") private val isReleasePublication = System.getenv("RELEASE_PUBLICATION")?.toBoolean() ?: false -private val baseVersion = "0.16.5" +private val baseVersion = "0.16.6" allprojects { group = "com.agentclientprotocol" From 6486f5d8a90502b8504e23c73f548f16b77469cc Mon Sep 17 00:00:00 2001 From: "fabrizio.scarponi" Date: Wed, 11 Mar 2026 16:58:09 +0100 Subject: [PATCH 2/2] fix: improve SetSessionConfigOptionRequest serializer backward/forward compat - Deserialize boolean primitives without type field as BoolValue (backward compat) - Preserve both type and value in UnknownValue for unknown types (forward compat) - Add regression tests for boolean-without-type and unknown-type round-trip Co-authored-by: Junie --- .../model/SessionConfig.kt | 21 ++++++++++---- ...essionConfigSelectOptionsSerializerTest.kt | 28 +++++++++++++++++++ 2 files changed, 44 insertions(+), 5 deletions(-) diff --git a/acp-model/src/commonMain/kotlin/com/agentclientprotocol/model/SessionConfig.kt b/acp-model/src/commonMain/kotlin/com/agentclientprotocol/model/SessionConfig.kt index 30f6573..4285b17 100644 --- a/acp-model/src/commonMain/kotlin/com/agentclientprotocol/model/SessionConfig.kt +++ b/acp-model/src/commonMain/kotlin/com/agentclientprotocol/model/SessionConfig.kt @@ -367,17 +367,28 @@ internal object SetSessionConfigOptionRequestSerializer : KSerializer { - // No type field = backward-compatible string value (select/value-id) + // No type field = backward-compatible primitive value val primitive = rawValue as? JsonPrimitive - if (primitive != null && primitive.isString) { - SessionConfigOptionValue.StringValue(primitive.content) + if (primitive != null) { + when { + primitive.isString -> + SessionConfigOptionValue.StringValue(primitive.content) + primitive.booleanOrNull != null -> + SessionConfigOptionValue.BoolValue(primitive.boolean) + else -> + SessionConfigOptionValue.UnknownValue(rawValue) + } } else { SessionConfigOptionValue.UnknownValue(rawValue) } } else -> { - // Unknown type - forward compatibility - SessionConfigOptionValue.UnknownValue(rawValue) + // Unknown type - forward compatibility: preserve both type and value + val unknownWrapper = buildJsonObject { + put("type", JsonPrimitive(type)) + put("value", rawValue) + } + SessionConfigOptionValue.UnknownValue(unknownWrapper) } } diff --git a/acp-model/src/commonTest/kotlin/com/agentclientprotocol/model/SessionConfigSelectOptionsSerializerTest.kt b/acp-model/src/commonTest/kotlin/com/agentclientprotocol/model/SessionConfigSelectOptionsSerializerTest.kt index da379bb..4380ee9 100644 --- a/acp-model/src/commonTest/kotlin/com/agentclientprotocol/model/SessionConfigSelectOptionsSerializerTest.kt +++ b/acp-model/src/commonTest/kotlin/com/agentclientprotocol/model/SessionConfigSelectOptionsSerializerTest.kt @@ -636,4 +636,32 @@ class SessionConfigSelectOptionsSerializerTest { assertEquals(true, boolValue.value) } + @Test + fun `boolean value without type field deserializes as BoolValue`() { + val json = """{"sessionId":"s","configId":"c","value":true}""" + val request = ACPJson.decodeFromString(SetSessionConfigOptionRequest.serializer(), json) + val boolValue = assertIs(request.value) + assertEquals(true, boolValue.value) + } + + @Test + fun `false value without type field deserializes as BoolValue`() { + val json = """{"sessionId":"s","configId":"c","value":false}""" + val request = ACPJson.decodeFromString(SetSessionConfigOptionRequest.serializer(), json) + val boolValue = assertIs(request.value) + assertEquals(false, boolValue.value) + } + + @Test + fun `unknown type roundtrip preserves type and value`() { + val json = """{"sessionId":"s","configId":"c","type":"multi_select","value":["a","b"]}""" + val request = ACPJson.decodeFromString(SetSessionConfigOptionRequest.serializer(), json) + assertIs(request.value) + // Re-serialize and verify the type and value are preserved + val encoded = ACPJson.encodeToString(SetSessionConfigOptionRequest.serializer(), request) + val reDecoded = ACPJson.parseToJsonElement(encoded).jsonObject + assertEquals("multi_select", (reDecoded["type"] as? JsonPrimitive)?.content) + assertNotNull(reDecoded["value"]) + } + }