Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 0 additions & 11 deletions acp-model/api/acp-model.api
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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**
Expand Down Expand Up @@ -286,3 +289,116 @@ internal object SessionConfigOptionValueSerializer : KSerializer<SessionConfigOp
}
}
}

/**
* **UNSTABLE**
*
* This capability is not part of the spec yet, and may be removed or changed at any point.
*
* Custom serializer for [SetSessionConfigOptionRequest] that flattens the [SessionConfigOptionValue]
* `type` and `value` fields at the top level of the JSON object.
*
* Boolean values serialize as: `{"sessionId":"s","configId":"c","type":"boolean","value":true}`
* String values serialize as: `{"sessionId":"s","configId":"c","value":"code"}` (no `type` field)
* This ensures backward compatibility: the select/value-id format is unchanged.
*/
@OptIn(UnstableApi::class)
internal object SetSessionConfigOptionRequestSerializer : KSerializer<SetSessionConfigOptionRequest> {
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)
Comment on lines +362 to +366
Copy link

Copilot AI Mar 11, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In the type == "boolean" branch, if value is not a JSON boolean (e.g., a string/number/object), the code either throws (non-JsonPrimitive) or falls back to UnknownValue(rawValue) which drops the original type field. That makes payloads like {...,"type":"boolean","value":"false"} impossible to round-trip and reduces forward/robust compatibility.

Consider avoiding the hard throw and, on any non-boolean value, preserving both fields by storing an UnknownValue wrapper object containing the original type and value (similar to the unknown-type branch).

Suggested change
?: throw SerializationException("Expected boolean primitive for type 'boolean'")
if (primitive.booleanOrNull != null) {
SessionConfigOptionValue.BoolValue(primitive.boolean)
} else {
SessionConfigOptionValue.UnknownValue(rawValue)
if (primitive?.booleanOrNull != null) {
SessionConfigOptionValue.BoolValue(primitive.boolean)
} else {
// Preserve both type and value for non-boolean or non-primitive values
val unknownWrapper = buildJsonObject {
put("type", JsonPrimitive("boolean"))
put("value", rawValue)
}
SessionConfigOptionValue.UnknownValue(unknownWrapper)

Copilot uses AI. Check for mistakes.
}
}
null -> {
// No type field = backward-compatible primitive value
val primitive = rawValue as? JsonPrimitive
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: preserve both type and value
val unknownWrapper = buildJsonObject {
put("type", JsonPrimitive(type))
put("value", rawValue)
}
SessionConfigOptionValue.UnknownValue(unknownWrapper)
}
}

val meta = jsonObject["_meta"]

return SetSessionConfigOptionRequest(
sessionId = sessionId,
configId = configId,
value = value,
_meta = meta
)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -479,6 +480,7 @@ class SessionConfigSelectOptionsSerializerTest {
"params": {
"sessionId": "sess_abc123def456",
"configId": "auto_approve",
"type": "boolean",
"value": true
}
}
Expand All @@ -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)
Expand All @@ -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)
Expand All @@ -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<SessionConfigOptionValue.UnknownValue>(request.value)
assertEquals(JsonPrimitive(42), unknown.rawElement)
assertEquals(SessionId("s"), request.sessionId)
assertEquals(SessionConfigId("c"), request.configId)
val stringValue = assertIs<SessionConfigOptionValue.StringValue>(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<SessionConfigOptionValue.UnknownValue>(request.value)
val boolValue = assertIs<SessionConfigOptionValue.BoolValue>(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<SessionConfigOptionValue.UnknownValue>(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<SessionConfigOptionValue.UnknownValue>(request.value)
assertEquals(JsonPrimitive(42), unknown.rawElement)
}

@Test
fun `UnknownValue roundtrip preserves raw element`() {
val json = """{"sessionId":"s","configId":"c","value":42}"""
Expand Down Expand Up @@ -616,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<SessionConfigOptionValue.BoolValue>(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<SessionConfigOptionValue.BoolValue>(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<SessionConfigOptionValue.UnknownValue>(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"])
}

}
2 changes: 1 addition & 1 deletion build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
Loading