Skip to content
Open
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
5 changes: 5 additions & 0 deletions acp/api/acp.api
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -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 {
Expand Down
24 changes: 20 additions & 4 deletions acp/src/commonMain/kotlin/com/agentclientprotocol/agent/Agent.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<SessionInfo, ListSessionsRequest, ListSessionsResponse>(
// 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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>?,
cursor: String?,
_meta: JsonElement?,
): Pair<List<SessionInfo>, String?>? = null

/**
* Deletes a session from history.
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,32 @@ public fun<TRequest : AcpPaginatedRequest, TResponse : AcpPaginatedResponse<TIte
)
}
}

/**
* Register a handler for incoming paginated requests where [pageFactory] fetches one page directly —
* for example, from a database — instead of a materialized [Sequence].
*
* Unlike [setPaginatedRequestHandler], no iterator or cursor state is kept here: [pageFactory] receives
* exactly the request a client sent, including whatever cursor it carries, and returns the batch together
* with the next cursor. The cursor is entirely opaque to this function; it is never stored, inspected, or
* validated here, so it can only be as durable or as short-lived as [pageFactory] makes it — an agent that
* encodes it as a stable position (a database keyset, an offset) gets a cursor that survives a reconnect
* and can be reused indefinitely, unlike the single-use, server-memory cursors [setPaginatedRequestHandler]
* hands out.
*/
@OptIn(UnstableApi::class)
public fun<TRequest : AcpPaginatedRequest, TResponse : AcpPaginatedResponse<TItem>, TItem> RpcMethodsOperations.setSuspendPaginatedRequestHandler(
method: AcpMethod.AcpRequestResponseMethod<TRequest, TResponse>,
additionalContext: CoroutineContext = EmptyCoroutineContext,
resultFactory: (request: TRequest, batch: List<TItem>, newCursor: String?) -> TResponse,
pageFactory: suspend (request: TRequest) -> Pair<List<TItem>, String?>
) {
this.setRequestHandler(method, additionalContext) { request ->
val (batch, newCursor) = pageFactory(request)
resultFactory(request, batch, newCursor)
}
}

/**
* Register a handler for incoming notifications.
*/
Expand Down
Original file line number Diff line number Diff line change
@@ -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<List<SessionInfo>, String?>?)? = null,
private val sequenceItems: List<SessionInfo> = 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<String>?, _meta: JsonElement?): Sequence<SessionInfo> {
if (pageHandler != null) fail("listSessions should not be called when listSessionsPage is overridden")
return sequenceItems.asSequence()
}

override suspend fun listSessionsPage(
cwd: String?,
additionalDirectories: List<String>?,
cursor: String?,
_meta: JsonElement?,
): Pair<List<SessionInfo>, 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()
}
}
Original file line number Diff line number Diff line change
@@ -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<Int>,
override val nextCursor: String?,
override val _meta: JsonElement? = null,
) : AcpPaginatedResponse<Int> {
override fun getItemsBatch(): List<Int> = items
}

private object TestMethod : AcpMethod.AcpRequestResponseMethod<TestRequest, TestResponse>(
"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()
}
}