Skip to content

Commit eb2f162

Browse files
Align the Kotlin SDK with agent-client-protocol pr 576 that flattens type and value at the top level (#81)
* 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 * 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 <junie@jetbrains.com> --------- Co-authored-by: Junie <junie@jetbrains.com>
1 parent adf3c45 commit eb2f162

5 files changed

Lines changed: 175 additions & 22 deletions

File tree

acp-model/api/acp-model.api

Lines changed: 0 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -3602,17 +3602,6 @@ public final class com/agentclientprotocol/model/SetSessionConfigOptionRequest :
36023602
public fun toString ()Ljava/lang/String;
36033603
}
36043604

3605-
public final synthetic class com/agentclientprotocol/model/SetSessionConfigOptionRequest$$serializer : kotlinx/serialization/internal/GeneratedSerializer {
3606-
public static final field INSTANCE Lcom/agentclientprotocol/model/SetSessionConfigOptionRequest$$serializer;
3607-
public final fun childSerializers ()[Lkotlinx/serialization/KSerializer;
3608-
public final fun deserialize (Lkotlinx/serialization/encoding/Decoder;)Lcom/agentclientprotocol/model/SetSessionConfigOptionRequest;
3609-
public synthetic fun deserialize (Lkotlinx/serialization/encoding/Decoder;)Ljava/lang/Object;
3610-
public final fun getDescriptor ()Lkotlinx/serialization/descriptors/SerialDescriptor;
3611-
public final fun serialize (Lkotlinx/serialization/encoding/Encoder;Lcom/agentclientprotocol/model/SetSessionConfigOptionRequest;)V
3612-
public synthetic fun serialize (Lkotlinx/serialization/encoding/Encoder;Ljava/lang/Object;)V
3613-
public fun typeParametersSerializers ()[Lkotlinx/serialization/KSerializer;
3614-
}
3615-
36163605
public final class com/agentclientprotocol/model/SetSessionConfigOptionRequest$Companion {
36173606
public final fun serializer ()Lkotlinx/serialization/KSerializer;
36183607
}

acp-model/src/commonMain/kotlin/com/agentclientprotocol/model/Requests.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -766,7 +766,7 @@ public data class ResumeSessionResponse(
766766
* Request to set a configuration option for a session.
767767
*/
768768
@UnstableApi
769-
@Serializable
769+
@Serializable(with = SetSessionConfigOptionRequestSerializer::class)
770770
public data class SetSessionConfigOptionRequest(
771771
override val sessionId: SessionId,
772772
val configId: SessionConfigId,

acp-model/src/commonMain/kotlin/com/agentclientprotocol/model/SessionConfig.kt

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,10 @@ import kotlinx.serialization.json.JsonPrimitive
2424
import kotlinx.serialization.json.boolean
2525
import kotlinx.serialization.json.booleanOrNull
2626
import kotlinx.serialization.json.jsonArray
27+
import kotlinx.serialization.json.JsonObject
28+
import kotlinx.serialization.json.buildJsonObject
2729
import kotlinx.serialization.json.jsonObject
30+
import kotlinx.serialization.json.put
2831

2932
/**
3033
* **UNSTABLE**
@@ -286,3 +289,116 @@ internal object SessionConfigOptionValueSerializer : KSerializer<SessionConfigOp
286289
}
287290
}
288291
}
292+
293+
/**
294+
* **UNSTABLE**
295+
*
296+
* This capability is not part of the spec yet, and may be removed or changed at any point.
297+
*
298+
* Custom serializer for [SetSessionConfigOptionRequest] that flattens the [SessionConfigOptionValue]
299+
* `type` and `value` fields at the top level of the JSON object.
300+
*
301+
* Boolean values serialize as: `{"sessionId":"s","configId":"c","type":"boolean","value":true}`
302+
* String values serialize as: `{"sessionId":"s","configId":"c","value":"code"}` (no `type` field)
303+
* This ensures backward compatibility: the select/value-id format is unchanged.
304+
*/
305+
@OptIn(UnstableApi::class)
306+
internal object SetSessionConfigOptionRequestSerializer : KSerializer<SetSessionConfigOptionRequest> {
307+
override val descriptor: SerialDescriptor = buildClassSerialDescriptor("SetSessionConfigOptionRequest")
308+
309+
override fun serialize(encoder: Encoder, value: SetSessionConfigOptionRequest) {
310+
val jsonEncoder = encoder as? JsonEncoder
311+
?: throw SerializationException("SetSessionConfigOptionRequestSerializer supports only JSON")
312+
val jsonElement = buildJsonObject {
313+
put("sessionId", value.sessionId.value)
314+
put("configId", value.configId.value)
315+
when (val v = value.value) {
316+
is SessionConfigOptionValue.BoolValue -> {
317+
put("type", "boolean")
318+
put("value", v.value)
319+
}
320+
is SessionConfigOptionValue.StringValue -> {
321+
put("value", v.value)
322+
}
323+
is SessionConfigOptionValue.UnknownValue -> {
324+
// Preserve unknown fields - if it was an object with type+value, re-flatten them
325+
val raw = v.rawElement
326+
if (raw is JsonObject) {
327+
for ((key, element) in raw) {
328+
put(key, element)
329+
}
330+
} else {
331+
put("value", raw)
332+
}
333+
}
334+
}
335+
if (value._meta != null) {
336+
put("_meta", value._meta)
337+
}
338+
}
339+
jsonEncoder.encodeJsonElement(jsonElement)
340+
}
341+
342+
override fun deserialize(decoder: Decoder): SetSessionConfigOptionRequest {
343+
val jsonDecoder = decoder as? JsonDecoder
344+
?: throw SerializationException("SetSessionConfigOptionRequestSerializer supports only JSON")
345+
val jsonObject = jsonDecoder.decodeJsonElement().jsonObject
346+
347+
val sessionId = SessionId(jsonObject["sessionId"]?.let {
348+
(it as? JsonPrimitive)?.content
349+
} ?: throw SerializationException("Missing 'sessionId'"))
350+
351+
val configId = SessionConfigId(jsonObject["configId"]?.let {
352+
(it as? JsonPrimitive)?.content
353+
} ?: throw SerializationException("Missing 'configId'"))
354+
355+
val type = (jsonObject["type"] as? JsonPrimitive)?.content
356+
val rawValue = jsonObject["value"]
357+
?: throw SerializationException("Missing 'value'")
358+
359+
val value: SessionConfigOptionValue = when (type) {
360+
"boolean" -> {
361+
val primitive = rawValue as? JsonPrimitive
362+
?: throw SerializationException("Expected boolean primitive for type 'boolean'")
363+
if (primitive.booleanOrNull != null) {
364+
SessionConfigOptionValue.BoolValue(primitive.boolean)
365+
} else {
366+
SessionConfigOptionValue.UnknownValue(rawValue)
367+
}
368+
}
369+
null -> {
370+
// No type field = backward-compatible primitive value
371+
val primitive = rawValue as? JsonPrimitive
372+
if (primitive != null) {
373+
when {
374+
primitive.isString ->
375+
SessionConfigOptionValue.StringValue(primitive.content)
376+
primitive.booleanOrNull != null ->
377+
SessionConfigOptionValue.BoolValue(primitive.boolean)
378+
else ->
379+
SessionConfigOptionValue.UnknownValue(rawValue)
380+
}
381+
} else {
382+
SessionConfigOptionValue.UnknownValue(rawValue)
383+
}
384+
}
385+
else -> {
386+
// Unknown type - forward compatibility: preserve both type and value
387+
val unknownWrapper = buildJsonObject {
388+
put("type", JsonPrimitive(type))
389+
put("value", rawValue)
390+
}
391+
SessionConfigOptionValue.UnknownValue(unknownWrapper)
392+
}
393+
}
394+
395+
val meta = jsonObject["_meta"]
396+
397+
return SetSessionConfigOptionRequest(
398+
sessionId = sessionId,
399+
configId = configId,
400+
value = value,
401+
_meta = meta
402+
)
403+
}
404+
}

acp-model/src/commonTest/kotlin/com/agentclientprotocol/model/SessionConfigSelectOptionsSerializerTest.kt

Lines changed: 57 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import com.agentclientprotocol.rpc.JsonRpcRequest
88
import com.agentclientprotocol.rpc.JsonRpcResponse
99
import kotlinx.serialization.json.JsonNull
1010
import kotlinx.serialization.json.JsonPrimitive
11+
import kotlinx.serialization.json.jsonObject
1112
import kotlin.test.Test
1213
import kotlin.test.assertEquals
1314
import kotlin.test.assertIs
@@ -479,6 +480,7 @@ class SessionConfigSelectOptionsSerializerTest {
479480
"params": {
480481
"sessionId": "sess_abc123def456",
481482
"configId": "auto_approve",
483+
"type": "boolean",
482484
"value": true
483485
}
484486
}
@@ -504,6 +506,9 @@ class SessionConfigSelectOptionsSerializerTest {
504506
)
505507

506508
val encoded = ACPJson.encodeToString(SetSessionConfigOptionRequest.serializer(), original)
509+
// Verify the encoded JSON contains "type":"boolean" at top level
510+
val encodedJson = ACPJson.parseToJsonElement(encoded)
511+
assertEquals("boolean", encodedJson.jsonObject["type"]?.let { (it as? JsonPrimitive)?.content })
507512
val decoded = ACPJson.decodeFromString(SetSessionConfigOptionRequest.serializer(), encoded)
508513
assertEquals(original.sessionId, decoded.sessionId)
509514
assertEquals(original.configId, decoded.configId)
@@ -520,6 +525,9 @@ class SessionConfigSelectOptionsSerializerTest {
520525
)
521526

522527
val encoded = ACPJson.encodeToString(SetSessionConfigOptionRequest.serializer(), original)
528+
// Verify the encoded JSON does NOT contain "type" field for string values
529+
val encodedJson = ACPJson.parseToJsonElement(encoded)
530+
assertEquals(null, encodedJson.jsonObject["type"])
523531
val decoded = ACPJson.decodeFromString(SetSessionConfigOptionRequest.serializer(), encoded)
524532
assertEquals(original.sessionId, decoded.sessionId)
525533
assertEquals(original.configId, decoded.configId)
@@ -546,27 +554,39 @@ class SessionConfigSelectOptionsSerializerTest {
546554
}
547555

548556
@Test
549-
fun `numeric value in config option deserializes as UnknownValue`() {
550-
val json = """{"sessionId":"s","configId":"c","value":42}"""
557+
fun `decode set config option request without type field is backward compatible`() {
558+
// Old format without "type" field should still work as StringValue
559+
val json = """{"sessionId":"s","configId":"c","value":"model-1"}"""
551560
val request = ACPJson.decodeFromString(SetSessionConfigOptionRequest.serializer(), json)
552-
val unknown = assertIs<SessionConfigOptionValue.UnknownValue>(request.value)
553-
assertEquals(JsonPrimitive(42), unknown.rawElement)
561+
assertEquals(SessionId("s"), request.sessionId)
562+
assertEquals(SessionConfigId("c"), request.configId)
563+
val stringValue = assertIs<SessionConfigOptionValue.StringValue>(request.value)
564+
assertEquals("model-1", stringValue.value)
554565
}
555566

556567
@Test
557-
fun `array value in config option deserializes as UnknownValue`() {
558-
val json = """{"sessionId":"s","configId":"c","value":["a","b"]}"""
568+
fun `decode set config option request with type boolean`() {
569+
val json = """{"sessionId":"s","configId":"c","type":"boolean","value":true}"""
559570
val request = ACPJson.decodeFromString(SetSessionConfigOptionRequest.serializer(), json)
560-
assertIs<SessionConfigOptionValue.UnknownValue>(request.value)
571+
val boolValue = assertIs<SessionConfigOptionValue.BoolValue>(request.value)
572+
assertEquals(true, boolValue.value)
561573
}
562574

563575
@Test
564-
fun `object value in config option deserializes as UnknownValue`() {
565-
val json = """{"sessionId":"s","configId":"c","value":{"key":"val"}}"""
576+
fun `decode set config option request with unknown type falls back to UnknownValue`() {
577+
val json = """{"sessionId":"s","configId":"c","type":"multi_select","value":["a","b"]}"""
566578
val request = ACPJson.decodeFromString(SetSessionConfigOptionRequest.serializer(), json)
567579
assertIs<SessionConfigOptionValue.UnknownValue>(request.value)
568580
}
569581

582+
@Test
583+
fun `numeric value in config option without type deserializes as UnknownValue`() {
584+
val json = """{"sessionId":"s","configId":"c","value":42}"""
585+
val request = ACPJson.decodeFromString(SetSessionConfigOptionRequest.serializer(), json)
586+
val unknown = assertIs<SessionConfigOptionValue.UnknownValue>(request.value)
587+
assertEquals(JsonPrimitive(42), unknown.rawElement)
588+
}
589+
570590
@Test
571591
fun `UnknownValue roundtrip preserves raw element`() {
572592
val json = """{"sessionId":"s","configId":"c","value":42}"""
@@ -616,4 +636,32 @@ class SessionConfigSelectOptionsSerializerTest {
616636
assertEquals(true, boolValue.value)
617637
}
618638

639+
@Test
640+
fun `boolean value without type field deserializes as BoolValue`() {
641+
val json = """{"sessionId":"s","configId":"c","value":true}"""
642+
val request = ACPJson.decodeFromString(SetSessionConfigOptionRequest.serializer(), json)
643+
val boolValue = assertIs<SessionConfigOptionValue.BoolValue>(request.value)
644+
assertEquals(true, boolValue.value)
645+
}
646+
647+
@Test
648+
fun `false value without type field deserializes as BoolValue`() {
649+
val json = """{"sessionId":"s","configId":"c","value":false}"""
650+
val request = ACPJson.decodeFromString(SetSessionConfigOptionRequest.serializer(), json)
651+
val boolValue = assertIs<SessionConfigOptionValue.BoolValue>(request.value)
652+
assertEquals(false, boolValue.value)
653+
}
654+
655+
@Test
656+
fun `unknown type roundtrip preserves type and value`() {
657+
val json = """{"sessionId":"s","configId":"c","type":"multi_select","value":["a","b"]}"""
658+
val request = ACPJson.decodeFromString(SetSessionConfigOptionRequest.serializer(), json)
659+
assertIs<SessionConfigOptionValue.UnknownValue>(request.value)
660+
// Re-serialize and verify the type and value are preserved
661+
val encoded = ACPJson.encodeToString(SetSessionConfigOptionRequest.serializer(), request)
662+
val reDecoded = ACPJson.parseToJsonElement(encoded).jsonObject
663+
assertEquals("multi_select", (reDecoded["type"] as? JsonPrimitive)?.content)
664+
assertNotNull(reDecoded["value"])
665+
}
666+
619667
}

build.gradle.kts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ plugins {
77
private val buildNumber: String? = System.getenv("GITHUB_RUN_NUMBER")
88
private val isReleasePublication = System.getenv("RELEASE_PUBLICATION")?.toBoolean() ?: false
99

10-
private val baseVersion = "0.16.5"
10+
private val baseVersion = "0.16.6"
1111

1212
allprojects {
1313
group = "com.agentclientprotocol"

0 commit comments

Comments
 (0)