Skip to content

Commit 25e1919

Browse files
committed
Exception when deserializing {"jsonrpc":"2.0","id":3,"result":null}
1 parent 8b505c8 commit 25e1919

12 files changed

Lines changed: 117 additions & 22 deletions

File tree

acp-ktor-test/src/commonTest/kotlin/com/agentclientprotocol/FeaturesTest.kt

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -192,6 +192,37 @@ abstract class FeaturesTest(protocolDriver: ProtocolDriver) : ProtocolDriver by
192192

193193

194194

195+
@Test
196+
fun `authenticate returns null when agent returns null`() = testWithProtocols { clientProtocol, agentProtocol ->
197+
val client = Client(protocol = clientProtocol)
198+
199+
val agentSupport = object : AgentSupport {
200+
override suspend fun initialize(clientInfo: ClientInfo): AgentInfo {
201+
return AgentInfo(clientInfo.protocolVersion)
202+
}
203+
204+
override suspend fun authenticate(methodId: AuthMethodId, _meta: JsonElement?): AuthenticateResponse? {
205+
// Return null to simulate {"jsonrpc":"2.0","id":3,"result":null} response
206+
return null
207+
}
208+
209+
override suspend fun createSession(sessionParameters: SessionCreationParameters): AgentSession {
210+
return TestAgentSession()
211+
}
212+
213+
override suspend fun loadSession(sessionId: SessionId, sessionParameters: SessionCreationParameters): AgentSession {
214+
return TestAgentSession()
215+
}
216+
}
217+
Agent(agentProtocol, agentSupport)
218+
219+
client.initialize(ClientInfo())
220+
221+
// This should correctly handle the null response without throwing an exception
222+
val result = client.authenticate(AuthMethodId("test-auth"))
223+
assertNull(result, "authenticate should return null when agent returns null")
224+
}
225+
195226
// @Test
196227
// fun `call agent extension from client`(): TestResult = testWithProtocols { clientProtocol, agentProtocol ->
197228
//

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

Lines changed: 19 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -20,12 +20,24 @@ public open class AcpMethod(public val methodName: MethodName) {
2020
public val responseSerializer: KSerializer<TResponse>
2121
) : AcpMethod(MethodName(method))
2222

23+
public open class AcpRequestResponseNullableMethod<TRequest: AcpRequest, TResponse: AcpResponse>(
24+
method: String,
25+
public val requestSerializer: KSerializer<TRequest>,
26+
public val responseSerializer: KSerializer<TResponse>
27+
) : AcpMethod(MethodName(method))
28+
2329
public open class AcpSessionRequestResponseMethod<TRequest, TResponse: AcpResponse>(method: String,
2430
requestSerializer: KSerializer<TRequest>,
2531
responseSerializer: KSerializer<TResponse>
2632
) : AcpRequestResponseMethod<TRequest, TResponse>(method, requestSerializer, responseSerializer)
2733
where TRequest : AcpRequest, TRequest : AcpWithSessionId
2834

35+
public open class AcpSessionRequestResponseNullableMethod<TRequest, TResponse: AcpResponse>(method: String,
36+
requestSerializer: KSerializer<TRequest>,
37+
responseSerializer: KSerializer<TResponse>
38+
) : AcpRequestResponseNullableMethod<TRequest, TResponse>(method, requestSerializer, responseSerializer)
39+
where TRequest : AcpRequest, TRequest : AcpWithSessionId
40+
2941
public open class AcpNotificationMethod<TNotification: AcpNotification>(
3042
method: String,
3143
public val serializer: KSerializer<TNotification>,
@@ -43,16 +55,16 @@ public open class AcpMethod(public val methodName: MethodName) {
4355
public object AgentMethods {
4456
// Agent-side operations (methods that agents can call on clients)
4557
public object Initialize : AcpRequestResponseMethod<InitializeRequest, InitializeResponse>("initialize", InitializeRequest.serializer(), InitializeResponse.serializer())
46-
public object Authenticate : AcpRequestResponseMethod<AuthenticateRequest, AuthenticateResponse>("authenticate", AuthenticateRequest.serializer(), AuthenticateResponse.serializer())
58+
public object Authenticate : AcpRequestResponseNullableMethod<AuthenticateRequest, AuthenticateResponse>("authenticate", AuthenticateRequest.serializer(), AuthenticateResponse.serializer())
4759
public object SessionNew : AcpRequestResponseMethod<NewSessionRequest, NewSessionResponse>("session/new", NewSessionRequest.serializer(), NewSessionResponse.serializer())
4860
public object SessionLoad : AcpRequestResponseMethod<LoadSessionRequest, LoadSessionResponse>("session/load", LoadSessionRequest.serializer(), LoadSessionResponse.serializer())
4961

5062
// session specific
5163
public object SessionPrompt : AcpSessionRequestResponseMethod<PromptRequest, PromptResponse>("session/prompt", PromptRequest.serializer(), PromptResponse.serializer())
5264
public object SessionCancel : AcpSessionNotificationMethod<CancelNotification>("session/cancel", CancelNotification.serializer())
53-
public object SessionSetMode : AcpSessionRequestResponseMethod<SetSessionModeRequest, SetSessionModeResponse>("session/set_mode", SetSessionModeRequest.serializer(), SetSessionModeResponse.serializer())
65+
public object SessionSetMode : AcpSessionRequestResponseNullableMethod<SetSessionModeRequest, SetSessionModeResponse>("session/set_mode", SetSessionModeRequest.serializer(), SetSessionModeResponse.serializer())
5466
@UnstableApi
55-
public object SessionSetModel : AcpSessionRequestResponseMethod<SetSessionModelRequest, SetSessionModelResponse>("session/set_model", SetSessionModelRequest.serializer(), SetSessionModelResponse.serializer())
67+
public object SessionSetModel : AcpSessionRequestResponseNullableMethod<SetSessionModelRequest, SetSessionModelResponse>("session/set_model", SetSessionModelRequest.serializer(), SetSessionModelResponse.serializer())
5668
}
5769

5870
public object ClientMethods {
@@ -62,12 +74,12 @@ public open class AcpMethod(public val methodName: MethodName) {
6274

6375
// extensions
6476
public object FsReadTextFile : AcpSessionRequestResponseMethod<ReadTextFileRequest, ReadTextFileResponse>("fs/read_text_file", ReadTextFileRequest.serializer(), ReadTextFileResponse.serializer())
65-
public object FsWriteTextFile : AcpSessionRequestResponseMethod<WriteTextFileRequest, WriteTextFileResponse>("fs/write_text_file", WriteTextFileRequest.serializer(), WriteTextFileResponse.serializer())
77+
public object FsWriteTextFile : AcpSessionRequestResponseNullableMethod<WriteTextFileRequest, WriteTextFileResponse>("fs/write_text_file", WriteTextFileRequest.serializer(), WriteTextFileResponse.serializer())
6678
public object TerminalCreate : AcpSessionRequestResponseMethod<CreateTerminalRequest, CreateTerminalResponse>("terminal/create", CreateTerminalRequest.serializer(), CreateTerminalResponse.serializer())
6779
public object TerminalOutput : AcpSessionRequestResponseMethod<TerminalOutputRequest, TerminalOutputResponse>("terminal/output", TerminalOutputRequest.serializer(), TerminalOutputResponse.serializer())
68-
public object TerminalRelease : AcpSessionRequestResponseMethod<ReleaseTerminalRequest, ReleaseTerminalResponse>("terminal/release", ReleaseTerminalRequest.serializer(), ReleaseTerminalResponse.serializer())
69-
public object TerminalWaitForExit : AcpSessionRequestResponseMethod<WaitForTerminalExitRequest, WaitForTerminalExitResponse>("terminal/wait_for_exit", WaitForTerminalExitRequest.serializer(), WaitForTerminalExitResponse.serializer())
70-
public object TerminalKill : AcpSessionRequestResponseMethod<KillTerminalCommandRequest, KillTerminalCommandResponse>("terminal/kill", KillTerminalCommandRequest.serializer(), KillTerminalCommandResponse.serializer())
80+
public object TerminalRelease : AcpSessionRequestResponseNullableMethod<ReleaseTerminalRequest, ReleaseTerminalResponse>("terminal/release", ReleaseTerminalRequest.serializer(), ReleaseTerminalResponse.serializer())
81+
public object TerminalWaitForExit : AcpSessionRequestResponseNullableMethod<WaitForTerminalExitRequest, WaitForTerminalExitResponse>("terminal/wait_for_exit", WaitForTerminalExitRequest.serializer(), WaitForTerminalExitResponse.serializer())
82+
public object TerminalKill : AcpSessionRequestResponseNullableMethod<KillTerminalCommandRequest, KillTerminalCommandResponse>("terminal/kill", KillTerminalCommandRequest.serializer(), KillTerminalCommandResponse.serializer())
7183
}
7284

7385

acp/src/commonMain/kotlin/com/agentclientprotocol/agent/AgentSupport.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import kotlinx.serialization.json.JsonElement
99

1010
public interface AgentSupport {
1111
public suspend fun initialize(clientInfo: ClientInfo): AgentInfo
12-
public suspend fun authenticate(methodId: AuthMethodId, _meta: JsonElement?): AuthenticateResponse = AuthenticateResponse()
12+
public suspend fun authenticate(methodId: AuthMethodId, _meta: JsonElement?): AuthenticateResponse? = AuthenticateResponse()
1313
public suspend fun createSession(sessionParameters: SessionCreationParameters): AgentSession
1414
public suspend fun loadSession(sessionId: SessionId, sessionParameters: SessionCreationParameters): AgentSession
1515
}

acp/src/commonMain/kotlin/com/agentclientprotocol/agent/RemoteClientSessionOperations.kt

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ internal class RemoteClientSessionOperations(private val rpc: RpcMethodsOperatio
3838
_meta: JsonElement?,
3939
): WriteTextFileResponse {
4040
if (clientCapabilities.fs?.writeTextFile != true) error("Client does not support fs.writeTextFile capability")
41-
return AcpMethod.ClientMethods.FsWriteTextFile(rpc, WriteTextFileRequest(sessionId, path, content, _meta))
41+
return AcpMethod.ClientMethods.FsWriteTextFile(rpc, WriteTextFileRequest(sessionId, path, content, _meta)) ?: WriteTextFileResponse()
4242
}
4343

4444
override suspend fun terminalCreate(
@@ -64,23 +64,23 @@ internal class RemoteClientSessionOperations(private val rpc: RpcMethodsOperatio
6464
override suspend fun terminalRelease(
6565
terminalId: String,
6666
_meta: JsonElement?,
67-
): ReleaseTerminalResponse {
67+
): ReleaseTerminalResponse? {
6868
if (!clientCapabilities.terminal) error("Client does not support terminal capability")
6969
return AcpMethod.ClientMethods.TerminalRelease(rpc, ReleaseTerminalRequest(sessionId, terminalId, _meta))
7070
}
7171

7272
override suspend fun terminalWaitForExit(
7373
terminalId: String,
7474
_meta: JsonElement?,
75-
): WaitForTerminalExitResponse {
75+
): WaitForTerminalExitResponse? {
7676
if (!clientCapabilities.terminal) error("Client does not support terminal capability")
7777
return AcpMethod.ClientMethods.TerminalWaitForExit(rpc, WaitForTerminalExitRequest(sessionId, terminalId, _meta))
7878
}
7979

8080
override suspend fun terminalKill(
8181
terminalId: String,
8282
_meta: JsonElement?,
83-
): KillTerminalCommandResponse {
83+
): KillTerminalCommandResponse? {
8484
if (!clientCapabilities.terminal) error("Client does not support terminal capability")
8585
return AcpMethod.ClientMethods.TerminalKill(rpc, KillTerminalCommandRequest(sessionId, terminalId, _meta))
8686
}

acp/src/commonMain/kotlin/com/agentclientprotocol/client/Client.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -148,7 +148,7 @@ public class Client(
148148
* Performs authentication of the agent with the specified [methodId].
149149
* The method may throw an exception if the authentication fails.
150150
*/
151-
public suspend fun authenticate(methodId: AuthMethodId, _meta: JsonElement? = null): AuthenticateResponse {
151+
public suspend fun authenticate(methodId: AuthMethodId, _meta: JsonElement? = null): AuthenticateResponse? {
152152
return AcpMethod.AgentMethods.Authenticate(protocol, AuthenticateRequest(methodId, _meta))
153153
}
154154

acp/src/commonMain/kotlin/com/agentclientprotocol/client/ClientSession.kt

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@ public interface ClientSession {
4949
/**
5050
* Changes the session mode to the specified mode. The real change will be reported by an agent via [currentMode] and [ClientSessionOperations.notify].
5151
*/
52-
public suspend fun setMode(modeId: SessionModeId, _meta: JsonElement? = null): SetSessionModeResponse
52+
public suspend fun setMode(modeId: SessionModeId, _meta: JsonElement? = null): SetSessionModeResponse?
5353

5454
/**
5555
* The flag indicates whether the agent supports the session model changing.
@@ -75,5 +75,5 @@ public interface ClientSession {
7575
* Changes the session model to the specified model. The real change will be reported by an agent via [currentModel] and [ClientSessionOperations.notify].
7676
*/
7777
@UnstableApi
78-
public suspend fun setModel(modelId: ModelId, _meta: JsonElement? = null): SetSessionModelResponse
78+
public suspend fun setModel(modelId: ModelId, _meta: JsonElement? = null): SetSessionModelResponse?
7979
}

acp/src/commonMain/kotlin/com/agentclientprotocol/client/ClientSessionImpl.kt

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -96,7 +96,7 @@ internal class ClientSessionImpl(
9696
get() = _currentMode
9797

9898

99-
override suspend fun setMode(modeId: SessionModeId, _meta: JsonElement?): SetSessionModeResponse {
99+
override suspend fun setMode(modeId: SessionModeId, _meta: JsonElement?): SetSessionModeResponse? {
100100
return AcpMethod.AgentMethods.SessionSetMode(protocol, SetSessionModeRequest(sessionId, modeId, _meta))
101101
}
102102

@@ -113,7 +113,7 @@ internal class ClientSessionImpl(
113113
get() = _currentModel
114114

115115
@UnstableApi
116-
override suspend fun setModel(modelId: ModelId, _meta: JsonElement?): SetSessionModelResponse {
116+
override suspend fun setModel(modelId: ModelId, _meta: JsonElement?): SetSessionModelResponse? {
117117
return AcpMethod.AgentMethods.SessionSetModel(protocol, SetSessionModelRequest(sessionId, modelId, _meta))
118118
}
119119

acp/src/commonMain/kotlin/com/agentclientprotocol/common/TerminalOperations.kt

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,17 +19,17 @@ public interface TerminalOperations {
1919
}
2020

2121
public suspend fun terminalRelease(terminalId: String,
22-
_meta: JsonElement? = null): ReleaseTerminalResponse {
22+
_meta: JsonElement? = null): ReleaseTerminalResponse? {
2323
throw NotImplementedError("Must be implemented by client when advertising terminal capability")
2424
}
2525

2626
public suspend fun terminalWaitForExit(terminalId: String,
27-
_meta: JsonElement? = null): WaitForTerminalExitResponse {
27+
_meta: JsonElement? = null): WaitForTerminalExitResponse? {
2828
throw NotImplementedError("Must be implemented by client when advertising terminal capability")
2929
}
3030

3131
public suspend fun terminalKill(terminalId: String,
32-
_meta: JsonElement? = null): KillTerminalCommandResponse {
32+
_meta: JsonElement? = null): KillTerminalCommandResponse? {
3333
throw NotImplementedError("Must be implemented by client when advertising terminal capability")
3434
}
3535
}

acp/src/commonMain/kotlin/com/agentclientprotocol/protocol/Protocol.extensions.kt

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import com.agentclientprotocol.model.AcpResponse
77
import com.agentclientprotocol.rpc.ACPJson
88
import com.agentclientprotocol.rpc.JsonRpcRequest
99
import kotlinx.serialization.json.JsonNull
10+
import kotlinx.serialization.json.buildJsonObject
1011
import kotlinx.serialization.json.decodeFromJsonElement
1112
import kotlinx.serialization.json.encodeToJsonElement
1213
import kotlin.coroutines.AbstractCoroutineContextElement
@@ -26,6 +27,16 @@ public suspend fun <TRequest : AcpRequest, TResponse : AcpResponse> RpcMethodsOp
2627
return ACPJson.decodeFromJsonElement(method.responseSerializer, responseJson)
2728
}
2829

30+
public suspend fun <TRequest : AcpRequest, TResponse : AcpResponse> RpcMethodsOperations.sendRequestNullable(
31+
method: AcpMethod.AcpRequestResponseNullableMethod<TRequest, TResponse>,
32+
request: TRequest?
33+
): TResponse? {
34+
val params = request?.let { ACPJson.encodeToJsonElement(method.requestSerializer, request) }
35+
val responseJson = this.sendRequestRaw(method.methodName, params)
36+
if (responseJson is JsonNull) return null
37+
return ACPJson.decodeFromJsonElement(method.responseSerializer, responseJson)
38+
}
39+
2940
/**
3041
* Send a notification (no response expected).
3142
*/
@@ -51,6 +62,21 @@ public fun<TRequest : AcpRequest, TResponse : AcpResponse> RpcMethodsOperations.
5162
ACPJson.encodeToJsonElement(method.responseSerializer, responseObject)
5263
}
5364
}
65+
66+
/**
67+
* Register a handler for incoming requests.
68+
*/
69+
public fun<TRequest : AcpRequest, TResponse : AcpResponse> RpcMethodsOperations.setRequestHandler(
70+
method: AcpMethod.AcpRequestResponseNullableMethod<TRequest, TResponse>,
71+
additionalContext: CoroutineContext = EmptyCoroutineContext,
72+
handler: suspend (TRequest) -> TResponse?
73+
) {
74+
this.setRequestHandlerRaw(method, additionalContext) { request ->
75+
val requestParams = ACPJson.decodeFromJsonElement(method.requestSerializer, request.params ?: JsonNull)
76+
val responseObject = handler(requestParams)
77+
responseObject?.let { ACPJson.encodeToJsonElement(method.responseSerializer, responseObject) } ?: JsonNull
78+
}
79+
}
5480
/**
5581
* Register a handler for incoming notifications.
5682
*/
@@ -69,6 +95,10 @@ public suspend operator fun <TRequest: AcpRequest, TResponse: AcpResponse> AcpMe
6995
return rpc.sendRequest(this, request)
7096
}
7197

98+
public suspend operator fun <TRequest: AcpRequest, TResponse: AcpResponse> AcpMethod.AcpRequestResponseNullableMethod<TRequest, TResponse>.invoke(rpc: RpcMethodsOperations, request: TRequest): TResponse? {
99+
return rpc.sendRequestNullable(this, request)
100+
}
101+
72102
public operator fun <TNotification : AcpNotification> AcpMethod.AcpNotificationMethod<TNotification>.invoke(rpc: RpcMethodsOperations, notification: TNotification) {
73103
return rpc.sendNotification(this, notification)
74104
}

acp/src/commonMain/kotlin/com/agentclientprotocol/protocol/Protocol.kt

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,12 @@ public interface RpcMethodsOperations {
8787
handler: suspend (JsonRpcRequest) -> JsonElement?
8888
)
8989

90+
public fun setRequestHandlerRaw(
91+
method: AcpMethod.AcpRequestResponseNullableMethod<*, *>,
92+
additionalContext: CoroutineContext = EmptyCoroutineContext,
93+
handler: suspend (JsonRpcRequest) -> JsonElement?
94+
)
95+
9096
public fun setNotificationHandlerRaw(
9197
method: AcpMethod.AcpNotificationMethod<*>,
9298
additionalContext: CoroutineContext = EmptyCoroutineContext,
@@ -264,6 +270,22 @@ public class Protocol(
264270
method: AcpMethod.AcpRequestResponseMethod<*, *>,
265271
additionalContext: CoroutineContext,
266272
handler: suspend (JsonRpcRequest) -> JsonElement?
273+
) {
274+
doSetRequestHandlerRaw(method, additionalContext, handler)
275+
}
276+
277+
override fun setRequestHandlerRaw(
278+
method: AcpMethod.AcpRequestResponseNullableMethod<*, *>,
279+
additionalContext: CoroutineContext,
280+
handler: suspend (JsonRpcRequest) -> JsonElement?
281+
) {
282+
doSetRequestHandlerRaw(method, additionalContext, handler)
283+
}
284+
285+
private fun doSetRequestHandlerRaw(
286+
method: AcpMethod,
287+
additionalContext: CoroutineContext,
288+
handler: suspend (JsonRpcRequest) -> JsonElement?
267289
) {
268290
requestHandlers.update {
269291
it.put(method.methodName) { params ->

0 commit comments

Comments
 (0)