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
36 changes: 36 additions & 0 deletions acp-model/api/acp-model.api
Original file line number Diff line number Diff line change
Expand Up @@ -12105,6 +12105,42 @@ public abstract class com/agentclientprotocol/model/v2/RequestPermissionSubject
public static final field Companion Lcom/agentclientprotocol/model/v2/RequestPermissionSubject$Companion;
}

public final class com/agentclientprotocol/model/v2/RequestPermissionSubject$Command : com/agentclientprotocol/model/v2/RequestPermissionSubject, com/agentclientprotocol/model/AcpWithMeta {
public static final field Companion Lcom/agentclientprotocol/model/v2/RequestPermissionSubject$Command$Companion;
public synthetic fun <init> (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lkotlinx/serialization/json/JsonElement;ILkotlin/jvm/internal/DefaultConstructorMarker;)V
public synthetic fun <init> (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lkotlinx/serialization/json/JsonElement;Lkotlin/jvm/internal/DefaultConstructorMarker;)V
public final fun component1 ()Ljava/lang/String;
public final fun component2 ()Ljava/lang/String;
public final fun component3-6c_jIZA ()Ljava/lang/String;
public final fun component4 ()Ljava/lang/String;
public final fun component5 ()Lkotlinx/serialization/json/JsonElement;
public final fun copy-ARPg0Pc (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lkotlinx/serialization/json/JsonElement;)Lcom/agentclientprotocol/model/v2/RequestPermissionSubject$Command;
public static synthetic fun copy-ARPg0Pc$default (Lcom/agentclientprotocol/model/v2/RequestPermissionSubject$Command;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lkotlinx/serialization/json/JsonElement;ILjava/lang/Object;)Lcom/agentclientprotocol/model/v2/RequestPermissionSubject$Command;
public fun equals (Ljava/lang/Object;)Z
public final fun getCommand ()Ljava/lang/String;
public final fun getCwd ()Ljava/lang/String;
public final fun getTerminalId ()Ljava/lang/String;
public final fun getToolCallId-6c_jIZA ()Ljava/lang/String;
public fun get_meta ()Lkotlinx/serialization/json/JsonElement;
public fun hashCode ()I
public fun toString ()Ljava/lang/String;
}

public final synthetic class com/agentclientprotocol/model/v2/RequestPermissionSubject$Command$$serializer : kotlinx/serialization/internal/GeneratedSerializer {
public static final field INSTANCE Lcom/agentclientprotocol/model/v2/RequestPermissionSubject$Command$$serializer;
public final fun childSerializers ()[Lkotlinx/serialization/KSerializer;
public final fun deserialize (Lkotlinx/serialization/encoding/Decoder;)Lcom/agentclientprotocol/model/v2/RequestPermissionSubject$Command;
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/v2/RequestPermissionSubject$Command;)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/v2/RequestPermissionSubject$Command$Companion {
public final fun serializer ()Lkotlinx/serialization/KSerializer;
}

public final class com/agentclientprotocol/model/v2/RequestPermissionSubject$Companion {
public final fun serializer ()Lkotlinx/serialization/KSerializer;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import com.agentclientprotocol.model.AcpWithMeta
import com.agentclientprotocol.model.AcpWithSessionId
import com.agentclientprotocol.model.PermissionOptionId
import com.agentclientprotocol.model.SessionId
import com.agentclientprotocol.model.ToolCallId
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonObject
Expand Down Expand Up @@ -47,6 +48,27 @@ public sealed class RequestPermissionSubject {
}
}

/**
* Permission to execute [command] in [cwd], which MUST be an absolute path on the agent's system.
*
* [toolCallId] and [terminalId] only associate the permission with displayed state and may be
* unavailable before execution starts. Omitted or `null` IDs mean no association was provided.
* Selecting an allow option authorizes the agent to execute the command; it does not ask the
* client to execute it.
*/
@Serializable
public data class Command(
val command: String,
val cwd: String,
val toolCallId: ToolCallId? = null,
val terminalId: String? = null,
override val _meta: JsonElement? = null,
) : RequestPermissionSubject(), AcpWithMeta {
public companion object {
internal const val DISCRIMINATOR: String = "command"
}
}

/**
* Custom or future subject.
*
Expand All @@ -62,10 +84,12 @@ internal object RequestPermissionSubjectSerializer : OpenTaggedUnionSerializer<R
discriminatorKey = "type",
known = mapOf(
RequestPermissionSubject.ToolCall.DISCRIMINATOR to RequestPermissionSubject.ToolCall.serializer(),
RequestPermissionSubject.Command.DISCRIMINATOR to RequestPermissionSubject.Command.serializer(),
),
discriminator = { value ->
when (value) {
is RequestPermissionSubject.ToolCall -> RequestPermissionSubject.ToolCall.DISCRIMINATOR
is RequestPermissionSubject.Command -> RequestPermissionSubject.Command.DISCRIMINATOR
is RequestPermissionSubject.Unknown -> value.type
}
},
Expand All @@ -80,6 +104,9 @@ internal object RequestPermissionSubjectSerializer : OpenTaggedUnionSerializer<R
* [prompt lifecycle](https://agentclientprotocol.com/protocol/v2/prompt-lifecycle#cancellation), a client
* that cancels active work MUST answer every pending request of this kind with
* [RequestPermissionOutcome.Cancelled].
*
* [title] and [description] belong to the permission prompt and do not update the subject's displayed
* title or content. Omitted or `null` [description] and [subject] mean they were not provided.
*/
@UnstableApi
@Serializable
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
package com.agentclientprotocol.model.v2

import com.agentclientprotocol.annotations.UnstableApi
import com.agentclientprotocol.model.AcpMethod
import com.agentclientprotocol.model.PermissionOptionId
import com.agentclientprotocol.model.v2.conversion.ProtocolConversionException
import com.agentclientprotocol.model.v2.conversion.toV1
Expand Down Expand Up @@ -58,6 +59,17 @@ class RequestPermissionOutcomeTest {
assertEquals(json, encode(decode(json)))
}

@Test
fun `unknown response outcome carrying an allow option id is not selected`() {
val json = """{"outcome":{"outcome":"_policy","optionId":"allow-once"}}"""
val serializer = AcpMethod.ClientMethods.V2.SessionRequestPermission.responseSerializer
val response = ACPJson.decodeFromString(serializer, json)

assertIs<RequestPermissionOutcome.Unknown>(response.outcome)
assertEquals(json, ACPJson.encodeToString(serializer, response))
assertFailsWith<ProtocolConversionException> { response.outcome.toV1() }
}

// Strictness

@Test
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
@file:OptIn(UnstableApi::class)

package com.agentclientprotocol.model.v2

import com.agentclientprotocol.annotations.UnstableApi
import com.agentclientprotocol.model.AcpMethod
import com.agentclientprotocol.model.PermissionOptionId
import com.agentclientprotocol.model.SessionId
import com.agentclientprotocol.model.ToolCallId
import com.agentclientprotocol.rpc.ACPJson
import kotlinx.serialization.SerializationException
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.put
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFailsWith
import kotlin.test.assertIs

class RequestPermissionRequestTest {
private val method = AcpMethod.ClientMethods.V2.SessionRequestPermission
private val options = listOf(PermissionOption(PermissionOptionId("allow-once"), "Allow once", PermissionOptionKind.AllowOnce))

@Test
fun `minimal permission prompt needs no description or subject`() {
val json = """{"sessionId":"sess_1","title":"Continue?","options":[""" +
"""{"optionId":"allow-once","name":"Allow once","kind":"allow_once"}]}"""
val request = RequestPermissionRequest(SessionId("sess_1"), "Continue?", options)

assertEquals(request, decode(json))
assertEquals(json, encode(request))
assertEquals(
request,
decode(json.dropLast(1) + """, "description":null,"subject":null}"""),
)
}

@Test
fun `command request uses the v2 wire shape and preserves metadata at each level`() {
val json = """{
"sessionId":"sess_1",
"title":"Run the test suite?",
"description":"Allow the agent to run cargo test?",
"subject":{
"type":"command",
"command":"cargo test",
"cwd":"/project",
"toolCallId":"call_001",
"terminalId":"term_001",
"_meta":{"scope":"subject"}
},
"options":[{"optionId":"allow-once","name":"Allow once","kind":"allow_once"}],
"_meta":{"scope":"request"}
}"""
val request = decode(json)

assertEquals("Run the test suite?", request.title)
assertEquals("Allow the agent to run cargo test?", request.description)
assertEquals(options, request.options)
assertEquals(
RequestPermissionSubject.Command(
command = "cargo test",
cwd = "/project",
toolCallId = ToolCallId("call_001"),
terminalId = "term_001",
_meta = buildJsonObject { put("scope", "subject") },
),
request.subject,
)
assertEquals(buildJsonObject { put("scope", "request") }, request._meta)
assertEquals(ACPJson.parseToJsonElement(json), ACPJson.parseToJsonElement(encode(request)))
}

@Test
fun `prompt text stays separate from tool call upsert fields`() {
val json = """{
"sessionId":"sess_1",
"title":"Approve the edit?",
"description":"Allow this change?",
"subject":{
"type":"tool_call",
"toolCall":{"toolCallId":"call_001","title":"Editing configuration","content":null,"locations":[]}
},
"options":[{"optionId":"allow-once","name":"Allow once","kind":"allow_once"}]
}"""
val request = decode(json)
val subject = assertIs<RequestPermissionSubject.ToolCall>(request.subject)

assertEquals("Approve the edit?", request.title)
assertEquals("Allow this change?", request.description)
assertEquals(MaybeUndefined.Value("Editing configuration"), subject.toolCall.title)
assertEquals(MaybeUndefined.Null, subject.toolCall.content)
assertEquals(MaybeUndefined.Value(emptyList()), subject.toolCall.locations)
assertEquals(MaybeUndefined.Undefined, subject.toolCall.status)
assertEquals(ACPJson.parseToJsonElement(json), ACPJson.parseToJsonElement(encode(request)))
}

@Test
fun `session id title and options are required`() {
val valid = ACPJson.parseToJsonElement(encode(RequestPermissionRequest(SessionId("sess_1"), "Continue?", options)))
.jsonObject

for (field in listOf("sessionId", "title", "options")) {
val missingField = JsonObject(valid.filterKeys { it != field })
assertFailsWith<SerializationException>(field) { decode(missingField.toString()) }
}
assertFailsWith<SerializationException> {
decode(
"""{"sessionId":"sess_1","toolCall":{"toolCallId":"call_001"},"options":[""" +
"""{"optionId":"allow-once","name":"Allow once","kind":"allow_once"}]}"""
)
}
}

@Test
fun `future subjects and option kinds survive request forwarding`() {
val json = """{
"sessionId":"sess_1",
"title":"Approve network access?",
"subject":{"type":"_network","host":"example.com"},
"options":[{"optionId":"scoped","name":"Allow for this host","kind":"_allow_host"}]
}"""
val request = decode(json)

assertIs<RequestPermissionSubject.Unknown>(request.subject)
assertEquals(PermissionOptionKind.Unknown("_allow_host"), request.options.single().kind)
assertEquals(ACPJson.parseToJsonElement(json), ACPJson.parseToJsonElement(encode(request)))
}

private fun decode(json: String): RequestPermissionRequest =
ACPJson.decodeFromString(method.requestSerializer, json)

private fun encode(request: RequestPermissionRequest): String =
ACPJson.encodeToString(method.requestSerializer, request)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
@file:OptIn(UnstableApi::class)

package com.agentclientprotocol.model.v2

import com.agentclientprotocol.annotations.UnstableApi
import com.agentclientprotocol.model.ToolCallId
import com.agentclientprotocol.rpc.ACPJson
import kotlinx.serialization.SerializationException
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFailsWith
import kotlin.test.assertIs

class RequestPermissionSubjectTest {
@Test
fun `command requires no association with an existing tool call or terminal`() {
val json = """{"type":"command","command":"cargo test","cwd":"/home/user/project"}"""
val subject = RequestPermissionSubject.Command(command = "cargo test", cwd = "/home/user/project")

assertEquals(subject, decode(json))
assertEquals(json, encode(subject))
}

@Test
fun `command preserves optional associations and metadata`() {
val json = """{"type":"command","command":"cargo test","cwd":"/home/user/project",""" +
""""toolCallId":"call_001","terminalId":"term_001","_meta":{"source":"agent"}}"""
val subject = RequestPermissionSubject.Command(
command = "cargo test",
cwd = "/home/user/project",
toolCallId = ToolCallId("call_001"),
terminalId = "term_001",
_meta = buildJsonObject { put("source", "agent") },
)

assertEquals(subject, decode(json))
assertEquals(json, encode(subject))
}

@Test
fun `omitted and null optional command fields are equivalent`() {
assertEquals(
decode("""{"type":"command","command":"cargo test","cwd":"/project"}"""),
decode(
"""{"type":"command","command":"cargo test","cwd":"/project",""" +
""""toolCallId":null,"terminalId":null,"_meta":null}"""
),
)
}

@Test
fun `command paths are preserved independently of the receiving platform`() {
for (cwd in listOf("/project", "C:\\project", "C:/project", "\\\\server\\share\\project")) {
val subject = RequestPermissionSubject.Command(command = "gradlew test", cwd = cwd)

assertEquals(subject, decode(encode(subject)))
}
}

@Test
fun `tool call subject accepts an upsert containing only the id`() {
val json = """{"type":"tool_call","toolCall":{"toolCallId":"call_001"}}"""
val subject = RequestPermissionSubject.ToolCall(ToolCallUpdate(ToolCallId("call_001")))

assertEquals(subject, decode(json))
assertEquals(json, encode(subject))
}

@Test
fun `known subjects with missing or null required fields fail instead of becoming Unknown`() {
val malformed = listOf(
"""{"type":"command","cwd":"/project"}""",
"""{"type":"command","command":"cargo test"}""",
"""{"type":"command","command":null,"cwd":"/project"}""",
"""{"type":"command","command":"cargo test","cwd":null}""",
"""{"type":"tool_call"}""",
"""{"type":"tool_call","toolCall":{}}""",
)

for (json in malformed) {
assertFailsWith<SerializationException>(json) { decode(json) }
}
}

@Test
fun `subject discriminator must be present and a string`() {
for (json in listOf("{}", """{"type":null}""", """{"type":42}""", """{"type":{}}""")) {
assertFailsWith<SerializationException>(json) { decode(json) }
}
}

@Test
fun `future and extension subjects preserve the full payload`() {
for (type in listOf("network", "_vendor_subject")) {
val json = """{"type":"$type","hosts":["example.com"],"policy":{"expires":null}}"""
val subject = assertIs<RequestPermissionSubject.Unknown>(decode(json))

assertEquals(type, subject.type)
assertEquals(json, encode(subject))
}
}

private fun decode(json: String): RequestPermissionSubject =
ACPJson.decodeFromString(RequestPermissionSubject.serializer(), json)

private fun encode(subject: RequestPermissionSubject): String =
ACPJson.encodeToString(RequestPermissionSubject.serializer(), subject)
}
Loading