diff --git a/acp/api/acp.api b/acp/api/acp.api index a86b83c..8054da5 100644 --- a/acp/api/acp.api +++ b/acp/api/acp.api @@ -276,8 +276,8 @@ public final class com/agentclientprotocol/agent/v2/SessionCreationParameters { } public final class com/agentclientprotocol/client/Client { - public fun (Lcom/agentclientprotocol/protocol/Protocol;Lcom/agentclientprotocol/client/GlobalElicitationHandler;)V - public synthetic fun (Lcom/agentclientprotocol/protocol/Protocol;Lcom/agentclientprotocol/client/GlobalElicitationHandler;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun (Lcom/agentclientprotocol/protocol/Protocol;Lcom/agentclientprotocol/client/GlobalElicitationHandler;Lcom/agentclientprotocol/client/GlobalSessionUpdateHandler;)V + public synthetic fun (Lcom/agentclientprotocol/protocol/Protocol;Lcom/agentclientprotocol/client/GlobalElicitationHandler;Lcom/agentclientprotocol/client/GlobalSessionUpdateHandler;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public final fun authenticate-fMnwWJU (Ljava/lang/String;Lkotlinx/serialization/json/JsonElement;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public static synthetic fun authenticate-fMnwWJU$default (Lcom/agentclientprotocol/client/Client;Ljava/lang/String;Lkotlinx/serialization/json/JsonElement;Lkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object; public final fun deleteSession-nk3TnMc (Ljava/lang/String;Lkotlinx/serialization/json/JsonElement;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; @@ -288,6 +288,7 @@ public final class com/agentclientprotocol/client/Client { public final fun getAgentInfo ()Lcom/agentclientprotocol/agent/AgentInfo; public final fun getClientInfo ()Lcom/agentclientprotocol/client/ClientInfo; public final fun getGlobalElicitationHandler ()Lcom/agentclientprotocol/client/GlobalElicitationHandler; + public final fun getGlobalSessionUpdateHandler ()Lcom/agentclientprotocol/client/GlobalSessionUpdateHandler; public final fun getNesSession-0izbxq0 (Ljava/lang/String;)Lcom/agentclientprotocol/client/ClientNesSession; public final fun getProtocol ()Lcom/agentclientprotocol/protocol/Protocol; public final fun getSession-0izbxq0 (Ljava/lang/String;)Lcom/agentclientprotocol/client/ClientSession; @@ -426,6 +427,10 @@ public abstract interface class com/agentclientprotocol/client/GlobalElicitation public abstract fun createElicitation (Lcom/agentclientprotocol/model/CreateElicitationRequest;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; } +public abstract interface class com/agentclientprotocol/client/GlobalSessionUpdateHandler { + public abstract fun onUnconnectedSessionUpdate-wPMwmcM (Ljava/lang/String;Lcom/agentclientprotocol/model/SessionUpdate;Lkotlinx/serialization/json/JsonElement;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; +} + public abstract interface class com/agentclientprotocol/client/NegotiatedClient { public abstract fun getProtocolVersion ()I } diff --git a/acp/src/commonMain/kotlin/com/agentclientprotocol/client/Client.kt b/acp/src/commonMain/kotlin/com/agentclientprotocol/client/Client.kt index 96ef6fc..f694cce 100644 --- a/acp/src/commonMain/kotlin/com/agentclientprotocol/client/Client.kt +++ b/acp/src/commonMain/kotlin/com/agentclientprotocol/client/Client.kt @@ -43,7 +43,9 @@ public typealias ClientInstance = Client public class Client( public val protocol: Protocol, @property:UnstableApi - public val globalElicitationHandler: GlobalElicitationHandler? = null + public val globalElicitationHandler: GlobalElicitationHandler? = null, + @property:UnstableApi + public val globalSessionUpdateHandler: GlobalSessionUpdateHandler? = null ) { private class ClientSessionHolder { private val sessionDeferred: CompletableDeferred = CompletableDeferred() @@ -72,6 +74,20 @@ public class Client( sessionDeferred.completeExceptionally(cause) } + /** + * Like [completeExceptionally], but also returns whatever was queued before this call - for a holder + * that was speculatively created for a session that never actually got claimed (see the + * `initializingSessionsCount > 0` branch of [findSessionHolder]), those notifications belong to no live + * session and would otherwise be silently discarded. + */ + fun completeExceptionallyAndDrainQueue(cause: Throwable): List> { + val drained = buildList { + while (true) add(notifications.tryReceive().getOrNull() ?: break) + } + completeExceptionally(cause) + return drained + } + suspend fun handleOrQueue(notification: SessionUpdate, _meta: JsonElement?) { val sendResult = notifications.trySend(Pair(notification, _meta)) @@ -94,9 +110,12 @@ public class Client( private val _elicitationToSession = ElicitationSessionStore() /** - * Creates a new entry only if there are some currently initializing sessions. Otherwise, throws in the case of missing session. + * Looks up the holder for [sessionId], creating a new entry only if there are some currently initializing + * sessions. Returns `null` if the session is neither registered nor being initialized, instead of throwing - + * callers that need a session to exist (e.g., to service a request against it) should use + * [getOrCreateSessionHolder] instead. */ - private fun getOrCreateSessionHolder(sessionId: SessionId): ClientSessionHolder { + private fun findSessionHolder(sessionId: SessionId): ClientSessionHolder? { // Fast path for the common case of an already registered session. _sessions.value.sessions[sessionId]?.let { return it } var clientSessionHolder: ClientSessionHolder? = null @@ -122,9 +141,15 @@ public class Client( } } } - return clientSessionHolder ?: acpFail("Session $sessionId not found") + return clientSessionHolder } + /** + * Creates a new entry only if there are some currently initializing sessions. Otherwise, throws in the case of missing session. + */ + private fun getOrCreateSessionHolder(sessionId: SessionId): ClientSessionHolder = + findSessionHolder(sessionId) ?: acpFail("Session $sessionId not found") + internal fun removeSessionHolder(sessionId: SessionId) { _sessions.update { currentMap -> currentMap.copy(sessions = currentMap.sessions.remove(sessionId)) @@ -214,7 +239,20 @@ public class Client( } protocol.setNotificationHandler(AcpMethod.ClientMethods.V1.SessionUpdate) { params: SessionNotification -> - val sessionHolder = getOrCreateSessionHolder(params.sessionId) + // The agent may report an update (e.g., a status change on `session/list`) for a session this client + // never called `session/new` / `session/load` / `session/resume` for. It can live on the server, + // created from another IDE window, the web, or another machine. That's not a protocol violation, so + // unlike other session-scoped methods, an unknown/unconnected session here must not fail the call. + val sessionHolder = findSessionHolder(params.sessionId) + if (sessionHolder == null) { + val handler = globalSessionUpdateHandler + if (handler != null) { + handler.onUnconnectedSessionUpdate(params.sessionId, params.update, params._meta) + } else { + logger.debug { "Ignoring session/update for session ${params.sessionId}: client is not connected to it" } + } + return@setNotificationHandler + } sessionHolder.handleOrQueue(params.update, params._meta) } @@ -684,8 +722,17 @@ public class Client( if (hangingSessions != null) { for ((id, holder) in hangingSessions) { logger.trace { "Removing hanging session $id" } - // report it as non existent session - holder.completeExceptionally(AcpExpectedError("Session $id not found")) + // report it as a non-existent session + val queuedUpdates = holder.completeExceptionallyAndDrainQueue(AcpExpectedError("Session $id not found")) + // These were buffered on the assumption they might belong to this (or another concurrent) + // initialization; since none claimed `id`, it's an unconnected session, same as if no + // initialization had been in progress when its updates arrived. + val handler = globalSessionUpdateHandler + if (handler != null) { + for ((update, meta) in queuedUpdates) { + handler.onUnconnectedSessionUpdate(id, update, meta) + } + } } } } diff --git a/acp/src/commonMain/kotlin/com/agentclientprotocol/client/GlobalSessionUpdateHandler.kt b/acp/src/commonMain/kotlin/com/agentclientprotocol/client/GlobalSessionUpdateHandler.kt new file mode 100644 index 0000000..8198ccc --- /dev/null +++ b/acp/src/commonMain/kotlin/com/agentclientprotocol/client/GlobalSessionUpdateHandler.kt @@ -0,0 +1,19 @@ +package com.agentclientprotocol.client + +import com.agentclientprotocol.annotations.UnstableApi +import com.agentclientprotocol.model.SessionId +import com.agentclientprotocol.model.SessionUpdate +import kotlinx.serialization.json.JsonElement + +/** + * Handler for `session/update` notifications about a session the client is not connected to. + * + * A session can live on the server without this client ever having called `session/new` / `session/load` / + * `session/resume` for it - e.g. it was created from another IDE window, the web, or another machine. This is + * invoked instead of failing or silently dropping the notification, letting a client observe such updates - + * for example to keep a `session/list`-rendered list live without polling. + */ +@UnstableApi +public fun interface GlobalSessionUpdateHandler { + public suspend fun onUnconnectedSessionUpdate(sessionId: SessionId, update: SessionUpdate, _meta: JsonElement?) +} diff --git a/acp/src/jvmTest/kotlin/com/agentclientprotocol/client/ClientGlobalSessionUpdateHandlerTest.kt b/acp/src/jvmTest/kotlin/com/agentclientprotocol/client/ClientGlobalSessionUpdateHandlerTest.kt new file mode 100644 index 0000000..0495888 --- /dev/null +++ b/acp/src/jvmTest/kotlin/com/agentclientprotocol/client/ClientGlobalSessionUpdateHandlerTest.kt @@ -0,0 +1,85 @@ +@file:OptIn(UnstableApi::class) + +package com.agentclientprotocol.client + +import com.agentclientprotocol.annotations.UnstableApi +import com.agentclientprotocol.model.* +import com.agentclientprotocol.protocol.Protocol +import com.agentclientprotocol.rpc.ACPJson +import com.agentclientprotocol.rpc.JsonRpcMessage +import com.agentclientprotocol.rpc.JsonRpcNotification +import com.agentclientprotocol.transport.BaseTransport +import com.agentclientprotocol.transport.Transport +import kotlinx.coroutines.* +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.time.Duration.Companion.seconds + +/** + * A session can live on the server without this client ever having called `session/new` / `session/load` / + * `session/resume` for it. [GlobalSessionUpdateHandler] lets a client observe such updates - e.g. to keep a + * `session/list`-rendered list live without polling - instead of the update being silently dropped. + * + * See https://youtrack.jetbrains.com/issue/IJAI-1133 + */ +class ClientGlobalSessionUpdateHandlerTest { + @Test + fun `session update for an unconnected session is delivered to the global session update handler`() { + val scope = CoroutineScope(Dispatchers.Default + SupervisorJob()) + try { + val transport = NotifyingTransport() + val protocol = Protocol(scope, transport) + protocol.start() + transport.start() + + val received = CompletableDeferred>() + Client( + protocol, + globalSessionUpdateHandler = { sessionId, update, _ -> + received.complete(sessionId to update) + }, + ) + + val sessionId = SessionId("unconnected-session") + val update = SessionUpdate.AgentMessageChunk(ContentBlock.Text("update")) + + runBlocking { + transport.emitSessionUpdate(sessionId, update) + val result = withTimeoutOrNull(5.seconds) { received.await() } + assertNotNull(result, "the global session update handler must be invoked for an unconnected session") + assertEquals(sessionId, result.first) + assertEquals(update, result.second) + } + } finally { + scope.cancel() + } + } +} + +/** A transport whose only job is to let the test push arbitrary `session/update` notifications to the client. */ +private class NotifyingTransport : BaseTransport() { + override fun start() { + _state.value = Transport.State.STARTED + } + + override fun close() { + _state.value = Transport.State.CLOSING + fireClose() + _state.value = Transport.State.CLOSED + } + + override fun send(message: JsonRpcMessage) = Unit + + fun emitSessionUpdate(sessionId: SessionId, update: SessionUpdate) { + fireMessage( + JsonRpcNotification( + method = AcpMethod.ClientMethods.SessionUpdate.methodName, + params = ACPJson.encodeToJsonElement( + AcpMethod.ClientMethods.SessionUpdate.serializer, + SessionNotification(sessionId, update), + ), + ) + ) + } +} diff --git a/acp/src/jvmTest/kotlin/com/agentclientprotocol/client/ClientHangingSessionUpdateDeliveryTest.kt b/acp/src/jvmTest/kotlin/com/agentclientprotocol/client/ClientHangingSessionUpdateDeliveryTest.kt new file mode 100644 index 0000000..251ba37 --- /dev/null +++ b/acp/src/jvmTest/kotlin/com/agentclientprotocol/client/ClientHangingSessionUpdateDeliveryTest.kt @@ -0,0 +1,159 @@ +@file:OptIn(UnstableApi::class) + +package com.agentclientprotocol.client + +import com.agentclientprotocol.annotations.UnstableApi +import com.agentclientprotocol.common.ClientSessionOperations +import com.agentclientprotocol.common.SessionCreationParameters +import com.agentclientprotocol.model.AcpMethod +import com.agentclientprotocol.model.ContentBlock +import com.agentclientprotocol.model.NewSessionResponse +import com.agentclientprotocol.model.PermissionOption +import com.agentclientprotocol.model.RequestPermissionResponse +import com.agentclientprotocol.model.SessionId +import com.agentclientprotocol.model.SessionNotification +import com.agentclientprotocol.model.SessionUpdate +import com.agentclientprotocol.protocol.Protocol +import com.agentclientprotocol.rpc.ACPJson +import com.agentclientprotocol.rpc.JsonRpcMessage +import com.agentclientprotocol.rpc.JsonRpcNotification +import com.agentclientprotocol.rpc.JsonRpcRequest +import com.agentclientprotocol.rpc.JsonRpcResponse +import com.agentclientprotocol.rpc.RequestId +import com.agentclientprotocol.transport.BaseTransport +import com.agentclientprotocol.transport.Transport +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.async +import kotlinx.coroutines.cancel +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeoutOrNull +import kotlinx.serialization.json.JsonElement +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.time.Duration.Companion.seconds + +/** + * `findSessionHolder` buffers updates for *any* unknown session id while some session is initializing, since the + * client can't yet tell a legitimate concurrent `newSession`/`loadSession` apart from a genuinely unconnected + * session. When the in-flight initialization finishes without ever claiming that id, the buffered holder is + * reaped as "hanging" - its queued updates must still reach [GlobalSessionUpdateHandler] instead of being + * silently discarded. + * + * See https://youtrack.jetbrains.com/issue/IJAI-1133 + */ +class ClientHangingSessionUpdateDeliveryTest { + @Test + fun `update buffered for an unrelated session during a concurrent session-new still reaches the global handler`() { + val scope = CoroutineScope(Dispatchers.Default + SupervisorJob()) + try { + val transport = DeferredSessionNewTransport() + val protocol = Protocol(scope, transport) + protocol.start() + transport.start() + + val received = CompletableDeferred>() + val client = Client( + protocol, + globalSessionUpdateHandler = { sessionId, update, _ -> + received.complete(sessionId to update) + }, + ) + + val unrelatedSessionId = SessionId("unrelated-session") + val unrelatedUpdate = SessionUpdate.AgentMessageChunk(ContentBlock.Text("update for an unrelated session")) + + runBlocking { + val newSessionResult = async(Dispatchers.Default) { + client.newSession(SessionCreationParameters(cwd = ".", mcpServers = emptyList())) { _, _ -> + NoOpSessionOperations + } + } + + // Wait until `session/new` is actually on the wire: `initializingSessionsCount` is now positive. + assertNotNull( + withTimeoutOrNull(5.seconds) { transport.sessionNewSent.await() }, + "session/new was never sent", + ) + + // An update for a totally unrelated session arrives while our own session/new is still in flight. + transport.emitSessionUpdate(unrelatedSessionId, unrelatedUpdate) + + // Completing session/new drops `initializingSessionsCount` back to zero, reaping the buffered + // holder for the unrelated session. + transport.completePendingSessionNew() + newSessionResult.await() + + val result = withTimeoutOrNull(5.seconds) { received.await() } + assertNotNull(result, "the buffered update for the unrelated session must reach the global handler") + assertEquals(unrelatedSessionId, result.first) + assertEquals(unrelatedUpdate, result.second) + } + } finally { + scope.cancel() + } + } +} + +/** Operations for the session actually being created; this test doesn't expect it to receive any updates. */ +private object NoOpSessionOperations : ClientSessionOperations { + override suspend fun requestPermissions( + toolCall: SessionUpdate.ToolCallUpdate, + permissions: List, + _meta: JsonElement?, + ): RequestPermissionResponse = error("not expected in this test") + + override suspend fun notify(notification: SessionUpdate, _meta: JsonElement?) { + error("no updates expected for the session being created in this test") + } +} + +/** A transport that holds the `session/new` response back until the test explicitly releases it. */ +private class DeferredSessionNewTransport : BaseTransport() { + val sessionNewSent = CompletableDeferred() + private var pendingRequestId: RequestId? = null + + override fun start() { + _state.value = Transport.State.STARTED + } + + override fun close() { + _state.value = Transport.State.CLOSING + fireClose() + _state.value = Transport.State.CLOSED + } + + override fun send(message: JsonRpcMessage) { + if (message !is JsonRpcRequest || message.method != AcpMethod.AgentMethods.SessionNew.methodName) return + pendingRequestId = message.id + sessionNewSent.complete(Unit) + } + + fun completePendingSessionNew() { + val requestId = checkNotNull(pendingRequestId) { "session/new was not sent yet" } + fireMessage( + JsonRpcResponse( + id = requestId, + result = ACPJson.encodeToJsonElement( + AcpMethod.AgentMethods.SessionNew.responseSerializer, + NewSessionResponse(SessionId("session-created")), + ), + ) + ) + } + + fun emitSessionUpdate(sessionId: SessionId, update: SessionUpdate) { + fireMessage( + JsonRpcNotification( + method = AcpMethod.ClientMethods.SessionUpdate.methodName, + params = ACPJson.encodeToJsonElement( + AcpMethod.ClientMethods.SessionUpdate.serializer, + SessionNotification(sessionId, update), + ), + ) + ) + } +} diff --git a/acp/src/jvmTest/kotlin/com/agentclientprotocol/client/ClientSessionUpdateForUnconnectedSessionTest.kt b/acp/src/jvmTest/kotlin/com/agentclientprotocol/client/ClientSessionUpdateForUnconnectedSessionTest.kt new file mode 100644 index 0000000..63fcd59 --- /dev/null +++ b/acp/src/jvmTest/kotlin/com/agentclientprotocol/client/ClientSessionUpdateForUnconnectedSessionTest.kt @@ -0,0 +1,149 @@ +@file:OptIn(UnstableApi::class) + +package com.agentclientprotocol.client + +import com.agentclientprotocol.annotations.UnstableApi +import com.agentclientprotocol.common.ClientSessionOperations +import com.agentclientprotocol.common.SessionCreationParameters +import com.agentclientprotocol.model.AcpMethod +import com.agentclientprotocol.model.ContentBlock +import com.agentclientprotocol.model.NewSessionResponse +import com.agentclientprotocol.model.PermissionOption +import com.agentclientprotocol.model.RequestPermissionResponse +import com.agentclientprotocol.model.SessionId +import com.agentclientprotocol.model.SessionNotification +import com.agentclientprotocol.model.SessionUpdate +import com.agentclientprotocol.protocol.Protocol +import com.agentclientprotocol.rpc.ACPJson +import com.agentclientprotocol.rpc.JsonRpcMessage +import com.agentclientprotocol.rpc.JsonRpcNotification +import com.agentclientprotocol.rpc.JsonRpcRequest +import com.agentclientprotocol.rpc.JsonRpcResponse +import com.agentclientprotocol.transport.BaseTransport +import com.agentclientprotocol.transport.Transport +import kotlinx.atomicfu.atomic +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeoutOrNull +import kotlinx.serialization.json.JsonElement +import java.io.ByteArrayOutputStream +import java.io.PrintStream +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue +import kotlin.time.Duration.Companion.seconds + +/** + * A session can live on the server without this client ever having called `session/new` / `session/load` / + * `session/resume` for it - e.g. it was created from another IDE window, the web, or another machine. An agent + * notifying this client about such a session via `session/update` (for example a `session_info_update` reporting + * a status change) is not a protocol violation and must not be treated as an error. + * + * See https://youtrack.jetbrains.com/issue/IJAI-1133 + */ +class ClientSessionUpdateForUnconnectedSessionTest { + @Test + fun `session update for a session the client never connected to is ignored, not failed`() { + val capturedErr = ByteArrayOutputStream() + val originalErr = System.err + val scope = CoroutineScope(Dispatchers.Default + SupervisorJob()) + try { + System.setErr(PrintStream(capturedErr)) + val transport = ManualUpdateAgentTransport() + val protocol = Protocol(scope, transport) + protocol.start() + transport.start() + val client = Client(protocol) + + runBlocking { + val connectedSessionUpdate = UpdateRecorder() + val connectedSession = withContext(Dispatchers.Default) { + client.newSession(SessionCreationParameters(cwd = ".", mcpServers = emptyList())) { _, _ -> + connectedSessionUpdate + } + } + + // An update for a session this client never created/loaded. + transport.emitSessionUpdate(SessionId("unconnected-session"), SessionUpdate.AgentMessageChunk(ContentBlock.Text("update"))) + + // Sent right after: the handler dispatcher processes notifications in the order they arrive, so this + // one being delivered proves the unconnected-session update above didn't wedge or crash the pipeline. + transport.emitSessionUpdate(connectedSession.sessionId, SessionUpdate.AgentMessageChunk(ContentBlock.Text("update"))) + assertTrue(connectedSessionUpdate.awaitNotification(), "update for the connected session must still be delivered") + } + } finally { + scope.cancel() + System.setErr(originalErr) + } + + val loggedOutput = capturedErr.toString() + assertFalse(loggedOutput.contains("not found"), "an update for an unconnected session must not fail: $loggedOutput") + } +} + +/** Records the single `session/update` the fake agent sends for a session. */ +private class UpdateRecorder : ClientSessionOperations { + private val notified = CompletableDeferred() + + override suspend fun requestPermissions( + toolCall: SessionUpdate.ToolCallUpdate, + permissions: List, + _meta: JsonElement?, + ): RequestPermissionResponse = error("not expected in this test") + + override suspend fun notify(notification: SessionUpdate, _meta: JsonElement?) { + notified.complete(Unit) + } + + suspend fun awaitNotification(): Boolean = withTimeoutOrNull(NOTIFICATION_TIMEOUT) { notified.await() } != null + + private companion object { + private val NOTIFICATION_TIMEOUT = 5.seconds + } +} + +/** A minimal agent transport that answers `session/new` and lets the test fire arbitrary `session/update`s. */ +private class ManualUpdateAgentTransport : BaseTransport() { + private val sessionCounter = atomic(0) + + override fun start() { + _state.value = Transport.State.STARTED + } + + override fun close() { + _state.value = Transport.State.CLOSING + fireClose() + _state.value = Transport.State.CLOSED + } + + override fun send(message: JsonRpcMessage) { + if (message !is JsonRpcRequest || message.method != AcpMethod.AgentMethods.SessionNew.methodName) return + val sessionId = SessionId("session-${sessionCounter.incrementAndGet()}") + fireMessage( + JsonRpcResponse( + id = message.id, + result = ACPJson.encodeToJsonElement( + AcpMethod.AgentMethods.SessionNew.responseSerializer, + NewSessionResponse(sessionId), + ), + ) + ) + } + + fun emitSessionUpdate(sessionId: SessionId, update: SessionUpdate) { + fireMessage( + JsonRpcNotification( + method = AcpMethod.ClientMethods.SessionUpdate.methodName, + params = ACPJson.encodeToJsonElement( + AcpMethod.ClientMethods.SessionUpdate.serializer, + SessionNotification(sessionId, update), + ), + ) + ) + } +}