diff --git a/acp/api/acp.api b/acp/api/acp.api index 4e9ce6b..a491e78 100644 --- a/acp/api/acp.api +++ b/acp/api/acp.api @@ -100,6 +100,8 @@ public abstract interface class com/agentclientprotocol/agent/AgentSupport { public static synthetic fun listProviders$suspendImpl (Lcom/agentclientprotocol/agent/AgentSupport;Lkotlinx/serialization/json/JsonElement;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public fun listSessions (Ljava/lang/String;Ljava/util/List;Lkotlinx/serialization/json/JsonElement;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public static synthetic fun listSessions$suspendImpl (Lcom/agentclientprotocol/agent/AgentSupport;Ljava/lang/String;Ljava/util/List;Lkotlinx/serialization/json/JsonElement;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun listSessionsPage (Ljava/lang/String;Ljava/util/List;Ljava/lang/String;Lkotlinx/serialization/json/JsonElement;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public static synthetic fun listSessionsPage$suspendImpl (Lcom/agentclientprotocol/agent/AgentSupport;Ljava/lang/String;Ljava/util/List;Ljava/lang/String;Lkotlinx/serialization/json/JsonElement;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public fun loadSession-nk3TnMc (Ljava/lang/String;Lcom/agentclientprotocol/common/SessionCreationParameters;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public static synthetic fun loadSession-nk3TnMc$suspendImpl (Lcom/agentclientprotocol/agent/AgentSupport;Ljava/lang/String;Lcom/agentclientprotocol/common/SessionCreationParameters;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public fun logout (Lkotlinx/serialization/json/JsonElement;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; @@ -118,6 +120,7 @@ public final class com/agentclientprotocol/agent/AgentSupport$DefaultImpls { public static fun forkSession-nk3TnMc (Lcom/agentclientprotocol/agent/AgentSupport;Ljava/lang/String;Lcom/agentclientprotocol/common/SessionCreationParameters;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public static fun listProviders (Lcom/agentclientprotocol/agent/AgentSupport;Lkotlinx/serialization/json/JsonElement;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public static fun listSessions (Lcom/agentclientprotocol/agent/AgentSupport;Ljava/lang/String;Ljava/util/List;Lkotlinx/serialization/json/JsonElement;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public static fun listSessionsPage (Lcom/agentclientprotocol/agent/AgentSupport;Ljava/lang/String;Ljava/util/List;Ljava/lang/String;Lkotlinx/serialization/json/JsonElement;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public static fun loadSession-nk3TnMc (Lcom/agentclientprotocol/agent/AgentSupport;Ljava/lang/String;Lcom/agentclientprotocol/common/SessionCreationParameters;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public static fun logout (Lcom/agentclientprotocol/agent/AgentSupport;Lkotlinx/serialization/json/JsonElement;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public static fun resumeSession-nk3TnMc (Lcom/agentclientprotocol/agent/AgentSupport;Ljava/lang/String;Lcom/agentclientprotocol/common/SessionCreationParameters;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; @@ -473,6 +476,8 @@ public final class com/agentclientprotocol/protocol/Protocol_extensionsKt { public static synthetic fun setPaginatedRequestHandler$default (Lcom/agentclientprotocol/protocol/RpcMethodsOperations;Lcom/agentclientprotocol/model/AcpMethod$AcpRequestResponseMethod;ILkotlin/coroutines/CoroutineContext;Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function2;ILjava/lang/Object;)V public static final fun setRequestHandler (Lcom/agentclientprotocol/protocol/RpcMethodsOperations;Lcom/agentclientprotocol/model/AcpMethod$AcpRequestResponseMethod;Lkotlin/coroutines/CoroutineContext;Lkotlin/jvm/functions/Function2;)V public static synthetic fun setRequestHandler$default (Lcom/agentclientprotocol/protocol/RpcMethodsOperations;Lcom/agentclientprotocol/model/AcpMethod$AcpRequestResponseMethod;Lkotlin/coroutines/CoroutineContext;Lkotlin/jvm/functions/Function2;ILjava/lang/Object;)V + public static final fun setSuspendPaginatedRequestHandler (Lcom/agentclientprotocol/protocol/RpcMethodsOperations;Lcom/agentclientprotocol/model/AcpMethod$AcpRequestResponseMethod;Lkotlin/coroutines/CoroutineContext;Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function2;)V + public static synthetic fun setSuspendPaginatedRequestHandler$default (Lcom/agentclientprotocol/protocol/RpcMethodsOperations;Lcom/agentclientprotocol/model/AcpMethod$AcpRequestResponseMethod;Lkotlin/coroutines/CoroutineContext;Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function2;ILjava/lang/Object;)V } public abstract interface class com/agentclientprotocol/protocol/RpcMethodsOperations { diff --git a/acp/src/commonMain/kotlin/com/agentclientprotocol/agent/Agent.kt b/acp/src/commonMain/kotlin/com/agentclientprotocol/agent/Agent.kt index 9b51a89..c2c7ef3 100644 --- a/acp/src/commonMain/kotlin/com/agentclientprotocol/agent/Agent.kt +++ b/acp/src/commonMain/kotlin/com/agentclientprotocol/agent/Agent.kt @@ -8,6 +8,7 @@ import com.agentclientprotocol.common.SessionCreationParameters import com.agentclientprotocol.model.* import com.agentclientprotocol.protocol.* import com.agentclientprotocol.rpc.RequestId +import com.agentclientprotocol.util.SequenceToPaginatedResponseAdapter import io.github.oshai.kotlinlogging.KotlinLogging import kotlinx.atomicfu.atomic import kotlinx.atomicfu.update @@ -176,13 +177,28 @@ public class Agent( return@setRequestHandler agentSupport.disableProvider(params.id, params._meta) } - protocol.setPaginatedRequestHandler( - AcpMethod.AgentMethods.SessionList, + // `listSessionsPage` is tried first on every request; a `null` result means the agent hasn't + // overridden it, so this falls back to the `listSessions`/`Sequence` adapter below. The adapter is + // built once, same as before, since its iterator/cursor state must persist across the fallback + // path's own calls regardless of how many requests took the `listSessionsPage` branch in between. + @OptIn(UnstableApi::class) + val legacySessionListAdapter = SequenceToPaginatedResponseAdapter( // TODO: move to some global agent/client settings batchSize = 10, - batchedResultFactory = { _, batch, newCursor -> ListSessionsResponse(batch, newCursor) }, - sequenceFactory = { p -> agentSupport.listSessions(p.cwd, p.additionalDirectories, p._meta) } ) + @OptIn(UnstableApi::class) + protocol.setRequestHandler(AcpMethod.AgentMethods.SessionList) { params: ListSessionsRequest -> + val page = agentSupport.listSessionsPage(params.cwd, params.additionalDirectories, params.cursor, params._meta) + if (page != null) { + val (batch, newCursor) = page + return@setRequestHandler ListSessionsResponse(batch, newCursor) + } + return@setRequestHandler legacySessionListAdapter.next( + params = params, + sequenceFactory = { p -> agentSupport.listSessions(p.cwd, p.additionalDirectories, p._meta) }, + resultFactory = { _, batch, newCursor -> ListSessionsResponse(batch, newCursor) } + ) + } protocol.setRequestHandler(AcpMethod.AgentMethods.SessionDelete) { params: DeleteSessionRequest -> return@setRequestHandler agentSupport.deleteSession(params.sessionId, params._meta) diff --git a/acp/src/commonMain/kotlin/com/agentclientprotocol/agent/AgentSupport.kt b/acp/src/commonMain/kotlin/com/agentclientprotocol/agent/AgentSupport.kt index 5c1ae49..3076923 100644 --- a/acp/src/commonMain/kotlin/com/agentclientprotocol/agent/AgentSupport.kt +++ b/acp/src/commonMain/kotlin/com/agentclientprotocol/agent/AgentSupport.kt @@ -129,6 +129,37 @@ public interface AgentSupport { throw NotImplementedError("listSessions is not implemented. The capability is declared in AgentCapabilities.sessionCapabilities.list") } + /** + * **UNSTABLE** + * + * This capability is not part of the spec yet, and may be removed or changed at any point. + * + * An alternative to [listSessions] for a source that can fetch one page directly — for example, a + * database — instead of materializing a [Sequence] up front. When overridden, `session/list` is served + * through [com.agentclientprotocol.protocol.setSuspendPaginatedRequestHandler] instead of + * [com.agentclientprotocol.protocol.setPaginatedRequestHandler]: no server-side iterator is kept, so + * [cursor] must be self-describing and is never inspected or validated by the SDK, and the batch size is + * entirely up to the implementation. + * + * The default returns `null`, meaning "not overridden" — `session/list` then falls back to [listSessions]. + * An override must never return `null` itself; doing so is indistinguishable from not overriding this + * method and will silently fall back to [listSessions] for that request. + * + * @param cwd optional current working directory filter + * @param additionalDirectories optional additional directories filter + * @param cursor the cursor from the incoming request, or `null` for the first page + * @param _meta optional metadata + * @return the batch together with the next cursor (`null` if this is the last page), or `null` if this + * hook is not implemented and [listSessions] should be used instead + */ + @UnstableApi + public suspend fun listSessionsPage( + cwd: String?, + additionalDirectories: List?, + cursor: String?, + _meta: JsonElement?, + ): Pair, String?>? = null + /** * Deletes a session from history. * diff --git a/acp/src/commonMain/kotlin/com/agentclientprotocol/protocol/Protocol.extensions.kt b/acp/src/commonMain/kotlin/com/agentclientprotocol/protocol/Protocol.extensions.kt index 59e4daa..be0a1a1 100644 --- a/acp/src/commonMain/kotlin/com/agentclientprotocol/protocol/Protocol.extensions.kt +++ b/acp/src/commonMain/kotlin/com/agentclientprotocol/protocol/Protocol.extensions.kt @@ -91,6 +91,32 @@ public fun, TItem> RpcMethodsOperations.setSuspendPaginatedRequestHandler( + method: AcpMethod.AcpRequestResponseMethod, + additionalContext: CoroutineContext = EmptyCoroutineContext, + resultFactory: (request: TRequest, batch: List, newCursor: String?) -> TResponse, + pageFactory: suspend (request: TRequest) -> Pair, String?> +) { + this.setRequestHandler(method, additionalContext) { request -> + val (batch, newCursor) = pageFactory(request) + resultFactory(request, batch, newCursor) + } +} + /** * Register a handler for incoming notifications. */ diff --git a/acp/src/jvmTest/kotlin/com/agentclientprotocol/agent/AgentSessionListTest.kt b/acp/src/jvmTest/kotlin/com/agentclientprotocol/agent/AgentSessionListTest.kt new file mode 100644 index 0000000..69a9c92 --- /dev/null +++ b/acp/src/jvmTest/kotlin/com/agentclientprotocol/agent/AgentSessionListTest.kt @@ -0,0 +1,89 @@ +package com.agentclientprotocol.agent + +import com.agentclientprotocol.annotations.UnstableApi +import com.agentclientprotocol.client.ClientInfo +import com.agentclientprotocol.common.SessionCreationParameters +import com.agentclientprotocol.model.AcpMethod +import com.agentclientprotocol.model.ListSessionsRequest +import com.agentclientprotocol.model.ListSessionsResponse +import com.agentclientprotocol.model.SessionId +import com.agentclientprotocol.model.SessionInfo +import com.agentclientprotocol.protocol.Protocol +import com.agentclientprotocol.rpc.ACPJson +import com.agentclientprotocol.rpc.JsonRpcResponse +import kotlinx.coroutines.runBlocking +import kotlinx.serialization.json.JsonElement +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.fail +import kotlin.time.Duration.Companion.seconds + +@OptIn(UnstableApi::class) +class AgentSessionListTest { + + private fun testSessionInfo(id: String) = SessionInfo(sessionId = SessionId(id), cwd = "/tmp", title = id, updatedAt = "now") + + private class StubAgentSupport( + private val pageHandler: (suspend (cursor: String?) -> Pair, String?>?)? = null, + private val sequenceItems: List = emptyList(), + ) : AgentSupport { + override suspend fun initialize(clientInfo: ClientInfo) = AgentInfo() + override suspend fun createSession(sessionParameters: SessionCreationParameters): AgentSession = + error("Not needed for session/list tests") + + override suspend fun listSessions(cwd: String?, additionalDirectories: List?, _meta: JsonElement?): Sequence { + if (pageHandler != null) fail("listSessions should not be called when listSessionsPage is overridden") + return sequenceItems.asSequence() + } + + override suspend fun listSessionsPage( + cwd: String?, + additionalDirectories: List?, + cursor: String?, + _meta: JsonElement?, + ): Pair, String?>? = pageHandler?.invoke(cursor) + } + + private suspend fun TestTransport.testSessionList(request: ListSessionsRequest): ListSessionsResponse { + val received = fireTestRequest( + AcpMethod.AgentMethods.SessionList.methodName, + ACPJson.encodeToJsonElement(AcpMethod.AgentMethods.SessionList.requestSerializer, request), + ) + val response = received.last() as JsonRpcResponse + return ACPJson.decodeFromJsonElement(AcpMethod.AgentMethods.SessionList.responseSerializer, requireNotNull(response.result)) + } + + @Test + fun `session list uses listSessionsPage when the agent overrides it`() = runBlocking { + val transport = TestTransport(5.seconds) + val protocol = Protocol(this, transport) + val agentSupport = StubAgentSupport(pageHandler = { cursor -> + assertEquals("db-keyset-position-42", cursor) + listOf(testSessionInfo("a"), testSessionInfo("b")) to "next-page" + }) + Agent(protocol, agentSupport) + protocol.start() + + val response = transport.testSessionList(ListSessionsRequest(cursor = "db-keyset-position-42")) + + assertEquals(listOf(SessionId("a"), SessionId("b")), response.sessions.map { it.sessionId }) + assertEquals("next-page", response.nextCursor) + protocol.close() + } + + @Test + fun `session list falls back to listSessions when listSessionsPage is not overridden`() = runBlocking { + val transport = TestTransport(5.seconds) + val protocol = Protocol(this, transport) + val agentSupport = StubAgentSupport(sequenceItems = listOf(testSessionInfo("a"), testSessionInfo("b"))) + Agent(protocol, agentSupport) + protocol.start() + + val response = transport.testSessionList(ListSessionsRequest()) + + assertEquals(listOf(SessionId("a"), SessionId("b")), response.sessions.map { it.sessionId }) + assertNull(response.nextCursor) + protocol.close() + } +} diff --git a/acp/src/jvmTest/kotlin/com/agentclientprotocol/protocol/SuspendPaginatedRequestHandlerTest.kt b/acp/src/jvmTest/kotlin/com/agentclientprotocol/protocol/SuspendPaginatedRequestHandlerTest.kt new file mode 100644 index 0000000..b9346c2 --- /dev/null +++ b/acp/src/jvmTest/kotlin/com/agentclientprotocol/protocol/SuspendPaginatedRequestHandlerTest.kt @@ -0,0 +1,132 @@ +package com.agentclientprotocol.protocol + +import com.agentclientprotocol.agent.TestTransport +import com.agentclientprotocol.annotations.UnstableApi +import com.agentclientprotocol.model.AcpMethod +import com.agentclientprotocol.model.AcpPaginatedRequest +import com.agentclientprotocol.model.AcpPaginatedResponse +import com.agentclientprotocol.rpc.ACPJson +import com.agentclientprotocol.rpc.JsonRpcResponse +import kotlinx.coroutines.runBlocking +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.JsonElement +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.time.Duration.Companion.seconds + +@OptIn(UnstableApi::class) +class SuspendPaginatedRequestHandlerTest { + + @Serializable + private data class TestRequest( + override val cursor: String? = null, + override val _meta: JsonElement? = null, + ) : AcpPaginatedRequest + + @Serializable + private data class TestResponse( + val items: List, + override val nextCursor: String?, + override val _meta: JsonElement? = null, + ) : AcpPaginatedResponse { + override fun getItemsBatch(): List = items + } + + private object TestMethod : AcpMethod.AcpRequestResponseMethod( + "test/paginate", + TestRequest.serializer(), + TestResponse.serializer(), + ) + + private suspend fun TestTransport.testPaginate(request: TestRequest): TestResponse { + val received = fireTestRequest(TestMethod.methodName, ACPJson.encodeToJsonElement(TestMethod.requestSerializer, request)) + val response = received.last() as JsonRpcResponse + return ACPJson.decodeFromJsonElement(TestMethod.responseSerializer, requireNotNull(response.result)) + } + + @Test + fun `pageFactory receives null cursor for the first request`() = runBlocking { + val transport = TestTransport(5.seconds) + val protocol = Protocol(this, transport) + var receivedCursor: String? = "not called" + + protocol.setSuspendPaginatedRequestHandler( + TestMethod, + resultFactory = { _, batch, newCursor -> TestResponse(batch, newCursor) }, + pageFactory = { request -> + receivedCursor = request.cursor + listOf(1, 2, 3) to null + }, + ) + protocol.start() + + transport.testPaginate(TestRequest()) + + assertNull(receivedCursor) + protocol.close() + } + + @Test + fun `pageFactory receives exactly the cursor the client sends back`() = runBlocking { + val transport = TestTransport(5.seconds) + val protocol = Protocol(this, transport) + var receivedCursor: String? = null + + protocol.setSuspendPaginatedRequestHandler( + TestMethod, + resultFactory = { _, batch, newCursor -> TestResponse(batch, newCursor) }, + pageFactory = { request -> + receivedCursor = request.cursor + listOf(4, 5) to null + }, + ) + protocol.start() + + transport.testPaginate(TestRequest(cursor = "db-keyset-position-42")) + + assertEquals("db-keyset-position-42", receivedCursor) + protocol.close() + } + + @Test + fun `batch and cursor from pageFactory flow through to the response`() = runBlocking { + val transport = TestTransport(5.seconds) + val protocol = Protocol(this, transport) + + protocol.setSuspendPaginatedRequestHandler( + TestMethod, + resultFactory = { _, batch, newCursor -> TestResponse(batch, newCursor) }, + pageFactory = { listOf(1, 2, 3) to "next-page-cursor" }, + ) + protocol.start() + + val response = transport.testPaginate(TestRequest()) + + assertEquals(listOf(1, 2, 3), response.items) + assertEquals("next-page-cursor", response.nextCursor) + protocol.close() + } + + @Test + fun `a cursor can be reused, unlike a single-use iterator cursor`() = runBlocking { + val transport = TestTransport(5.seconds) + val protocol = Protocol(this, transport) + // No state kept by the handler itself: the same cursor always maps to the same page, because the + // caller (not the SDK) owns what the cursor encodes. + val pages = mapOf(null to (listOf(1, 2) to "page-2"), "page-2" to (listOf(3, 4) to null)) + + protocol.setSuspendPaginatedRequestHandler( + TestMethod, + resultFactory = { _, batch, newCursor -> TestResponse(batch, newCursor) }, + pageFactory = { request -> pages.getValue(request.cursor) }, + ) + protocol.start() + + val first = transport.testPaginate(TestRequest(cursor = "page-2")) + val second = transport.testPaginate(TestRequest(cursor = "page-2")) + + assertEquals(first, second) + protocol.close() + } +}