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
Original file line number Diff line number Diff line change
Expand Up @@ -277,7 +277,7 @@ private class ConversationSession(
cancelRequested.await()
// `session/cancel` does not cut the turn short: saying how it ended is the agent's job, and the idle
// update carrying `cancelled` has to be the last one.
send(toolCall(call, ToolCallStatus.Failed))
send(toolCall(call, ToolCallStatus.Cancelled))
send(idle(StopReason.Cancelled))
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -213,7 +213,7 @@ private class ConversationScenario(
listOf("user_message", "state:running", "tool_call:in_progress"),
updatesBeforeCancellation.labels,
)
assertEquals(listOf("tool_call:failed", "state:idle(cancelled)"), updatesAfterCancellation.labels)
assertEquals(listOf("tool_call:cancelled", "state:idle(cancelled)"), updatesAfterCancellation.labels)
}

suspend fun closeSession() {
Expand Down
8 changes: 8 additions & 0 deletions acp-model/api/acp-model.api
Original file line number Diff line number Diff line change
Expand Up @@ -13359,6 +13359,14 @@ public abstract class com/agentclientprotocol/model/v2/ToolCallStatus {
public abstract fun getValue ()Ljava/lang/String;
}

public final class com/agentclientprotocol/model/v2/ToolCallStatus$Cancelled : com/agentclientprotocol/model/v2/ToolCallStatus {
public static final field INSTANCE Lcom/agentclientprotocol/model/v2/ToolCallStatus$Cancelled;
public fun equals (Ljava/lang/Object;)Z
public fun getValue ()Ljava/lang/String;
public fun hashCode ()I
public fun toString ()Ljava/lang/String;
}

public final class com/agentclientprotocol/model/v2/ToolCallStatus$Companion {
public final fun extension (Ljava/lang/String;)Lcom/agentclientprotocol/model/v2/ToolCallStatus$Unknown;
public final fun serializer ()Lkotlinx/serialization/KSerializer;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -206,6 +206,13 @@ public sealed class ToolCallStatus {
override val value: String = "failed"
}

/**
* The tool call was cancelled before it completed.
*/
public data object Cancelled : ToolCallStatus() {
override val value: String = "cancelled"
}

/**
* Custom or future tool call status.
*/
Expand Down Expand Up @@ -237,6 +244,7 @@ internal object ToolCallStatusSerializer : OpenStringEnumSerializer<ToolCallStat
ToolCallStatus.InProgress,
ToolCallStatus.Completed,
ToolCallStatus.Failed,
ToolCallStatus.Cancelled,
),
wireValue = ToolCallStatus::value,
unknown = ToolCallStatus::Unknown,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -57,15 +57,18 @@ public fun V1ToolKind.toV2(): ToolKind = when (this) {
/**
* Converts this v2 status to its v1 equivalent.
*
* @throws ProtocolConversionException if this is an [ToolCallStatus.Unknown] value,
* which cannot be represented in v1 without data loss
* @throws ProtocolConversionException if this is [ToolCallStatus.Cancelled] or a
* [ToolCallStatus.Unknown] value, which cannot be represented in v1 without data loss
*/
@UnstableApi
public fun ToolCallStatus.toV1(): V1ToolCallStatus = when (this) {
ToolCallStatus.Pending -> V1ToolCallStatus.PENDING
ToolCallStatus.InProgress -> V1ToolCallStatus.IN_PROGRESS
ToolCallStatus.Completed -> V1ToolCallStatus.COMPLETED
ToolCallStatus.Failed -> V1ToolCallStatus.FAILED
ToolCallStatus.Cancelled -> throw ProtocolConversionException(
"v2 ToolCallStatus variant `cancelled` cannot be represented in v1 because v1 has no cancellation status"
)
is ToolCallStatus.Unknown -> throw unknownV2EnumVariant("ToolCallStatus", value)
}

Expand Down Expand Up @@ -115,9 +118,14 @@ private fun V1ToolCallContent.toV2OrNull(): ToolCallContent? = when (this) {
* v1 has no patch semantics, so the tri-state fields collapse: a value becomes a set field,
* while both "no update" and an explicit clear become an unset field. Collections are the
* exception — an explicit clear becomes an empty list, because that is how v1 expresses
* "no content". Fields whose own conversion fails (an [ToolKind.Unknown] kind, say) are
* dropped rather than failing the whole update, and content items with no v1 representation
* are skipped.
* "no content". Fields whose own conversion fails (such as a [ToolKind.Unknown] kind or
* [ToolCallStatus.Cancelled] status) are dropped rather than failing the whole update,
* and content items with no v1 representation are skipped.
*
* Cancellation follows this same skip-on-error policy: mapping it to a failure would
* misreport why the tool stopped. A v1 client may therefore retain its previous status,
* such as `in_progress`; applications bridging versions must choose their own policy
* for reporting cancelled tools as finished in v1.
*
* @throws ProtocolConversionException if [ToolCallUpdate._meta] is an explicit clear, which
* v1 cannot express
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import com.agentclientprotocol.annotations.UnstableApi
import com.agentclientprotocol.model.MessageId
import com.agentclientprotocol.model.SessionModeId
import com.agentclientprotocol.model.ToolCallId
import com.agentclientprotocol.model.ToolCallLocation
import com.agentclientprotocol.model.v2.conversion.LEGACY_V1_PLAN_ID
import com.agentclientprotocol.model.v2.conversion.ProtocolConversionException
import com.agentclientprotocol.model.v2.conversion.toV1
Expand All @@ -23,6 +24,7 @@ import com.agentclientprotocol.model.PlanEntryPriority as V1PlanEntryPriority
import com.agentclientprotocol.model.PlanEntryStatus as V1PlanEntryStatus
import com.agentclientprotocol.model.PlanVariant as V1PlanVariant
import com.agentclientprotocol.model.SessionUpdate as V1SessionUpdate
import com.agentclientprotocol.model.ToolCallContent as V1ToolCallContent
import com.agentclientprotocol.model.ToolCallStatus as V1ToolCallStatus
import com.agentclientprotocol.model.ToolKind as V1ToolKind

Expand Down Expand Up @@ -210,6 +212,43 @@ class SessionUpdateConversionTest {
assertEquals("Read file", v1.title)
}

@Test
fun `a cancelled tool call drops only its status when converted to v1`() {
val locations = listOf(ToolCallLocation("/config.toml"))
val rawInput = buildJsonObject { put("path", JsonPrimitive("/config.toml")) }
val rawOutput = JsonPrimitive("interrupted")
val meta = buildJsonObject { put("source", JsonPrimitive("agent")) }
val update = SessionUpdate.ToolCallUpdate(
ToolCallUpdate(
toolCallId = ToolCallId("tc_1"),
title = MaybeUndefined.Value("Read config"),
kind = MaybeUndefined.Value(ToolKind.Read),
status = MaybeUndefined.Value(ToolCallStatus.Cancelled),
content = MaybeUndefined.Value(listOf(ToolCallContent.Content(ContentBlock.Text("partial")))),
locations = MaybeUndefined.Value(locations),
rawInput = MaybeUndefined.Value(rawInput),
rawOutput = MaybeUndefined.Value(rawOutput),
_meta = MaybeUndefined.Value(meta),
),
)

assertEquals(
listOf(
V1SessionUpdate.ToolCallUpdate(
toolCallId = ToolCallId("tc_1"),
title = "Read config",
kind = V1ToolKind.READ,
content = listOf(V1ToolCallContent.Content(V1ContentBlock.Text("partial"))),
locations = locations,
rawInput = rawInput,
rawOutput = rawOutput,
_meta = meta,
),
),
update.toV1(),
)
}

@Test
fun `a cleared tool call meta cannot be represented in v1`() {
val update = SessionUpdate.ToolCallUpdate(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -68,17 +68,21 @@ class SessionUpdateTest {
}

@Test
fun `round-trips a tool call upsert`() {
val json = """{"sessionUpdate":"tool_call_update","toolCallId":"tc_1","status":"completed"}"""
val update = SessionUpdate.ToolCallUpdate(
ToolCallUpdate(
toolCallId = ToolCallId("tc_1"),
status = MaybeUndefined.Value(ToolCallStatus.Completed),
),
fun `round-trips completed and cancelled tool call upserts`() {
val cases = listOf(
"completed" to ToolCallStatus.Completed,
"cancelled" to ToolCallStatus.Cancelled,
)

assertEquals(update, decode(json))
assertEquals(json, encode(update))
cases.forEach { (wireStatus, status) ->
val json = """{"sessionUpdate":"tool_call_update","toolCallId":"tc_1","status":"$wireStatus"}"""
val update = SessionUpdate.ToolCallUpdate(
ToolCallUpdate(toolCallId = ToolCallId("tc_1"), status = MaybeUndefined.Value(status)),
)

assertEquals(update, decode(json), "decoding $wireStatus")
assertEquals(json, encode(update), "encoding $wireStatus")
}
}

@Test
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ class ToolCallStatusTest {
assertEquals(ToolCallStatus.InProgress, decode("\"in_progress\""))
assertEquals(ToolCallStatus.Completed, decode("\"completed\""))
assertEquals(ToolCallStatus.Failed, decode("\"failed\""))
assertEquals(ToolCallStatus.Cancelled, decode("\"cancelled\""))
}

@Test
Expand All @@ -32,6 +33,7 @@ class ToolCallStatusTest {
assertEquals("\"in_progress\"", encode(ToolCallStatus.InProgress))
assertEquals("\"completed\"", encode(ToolCallStatus.Completed))
assertEquals("\"failed\"", encode(ToolCallStatus.Failed))
assertEquals("\"cancelled\"", encode(ToolCallStatus.Cancelled))
}

// Unknown values (forward compatibility)
Expand Down Expand Up @@ -95,13 +97,25 @@ class ToolCallStatusTest {
// v2 <-> v1 conversion

@Test
fun `converts all known values to v1`() {
fun `converts statuses shared with v1`() {
assertEquals(V1ToolCallStatus.PENDING, ToolCallStatus.Pending.toV1())
assertEquals(V1ToolCallStatus.IN_PROGRESS, ToolCallStatus.InProgress.toV1())
assertEquals(V1ToolCallStatus.COMPLETED, ToolCallStatus.Completed.toV1())
assertEquals(V1ToolCallStatus.FAILED, ToolCallStatus.Failed.toV1())
}

@Test
fun `converting Cancelled to v1 fails instead of reporting failure`() {
val exception = assertFailsWith<ProtocolConversionException> {
ToolCallStatus.Cancelled.toV1()
}

assertEquals(
"v2 ToolCallStatus variant `cancelled` cannot be represented in v1 because v1 has no cancellation status",
exception.message,
)
}

@Test
fun `converting Unknown to v1 fails instead of losing data`() {
val exception = assertFailsWith<ProtocolConversionException> {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,15 @@ class ToolCallUpdateTest {

// Encoding: Undefined is omitted, Null is an explicit null, Value is encoded

@Test
fun `round-trips an upsert with only the tool call id`() {
val json = """{"toolCallId":"tc_1"}"""
val update = ToolCallUpdate(toolCallId = ToolCallId("tc_1"))

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

@Test
fun `encodes only set fields as an upsert`() {
val update = ToolCallUpdate(
Expand Down Expand Up @@ -151,6 +160,27 @@ class ToolCallUpdateTest {

// Upsert application

@Test
fun `applyUpdate replaces collections with nonempty or empty arrays`() {
val stored = ToolCallUpdate(
toolCallId = ToolCallId("tc_1"),
title = MaybeUndefined.Value("Read config"),
content = MaybeUndefined.Value(listOf(ToolCallContent.Content(ContentBlock.Text("old")))),
locations = MaybeUndefined.Value(listOf(ToolCallLocation("/old"))),
)

val cases = listOf(
"""{"toolCallId":"tc_1","content":[{"type":"content","content":{"type":"text","text":"replacement"}}],"locations":[{"path":"/replacement"}]}""" to
stored.copy(
content = MaybeUndefined.Value(listOf(ToolCallContent.Content(ContentBlock.Text("replacement")))),
locations = MaybeUndefined.Value(listOf(ToolCallLocation("/replacement"))),
),
"""{"toolCallId":"tc_1","content":[],"locations":[]}""" to
stored.copy(content = MaybeUndefined.Value(emptyList()), locations = MaybeUndefined.Value(emptyList())),
)
cases.forEach { (json, expected) -> assertEquals(expected, stored.applyUpdate(decode(json)), json) }
}

@Test
fun `applyUpdate patches stored state and preserves explicit nulls`() {
val stored = ToolCallUpdate(
Expand Down
Loading
Loading