Skip to content
Closed
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
872 changes: 872 additions & 0 deletions clients/go/ahptypes/roundtrip_fixture_test.go

Large diffs are not rendered by default.

14 changes: 14 additions & 0 deletions clients/kotlin/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,19 @@ versions (`*-SNAPSHOT`) are explicitly rejected by the publish pipeline; bump
JsonElement>?`) for implementation-defined agent-host metadata, such as a
well-known `hostBuild` key carrying the host's build version/commit/date.

### Changed

- **BREAKING:** `SessionStatus.rawValue` is now a `Long` (was `Int`), and the
named flag constants are `Long` literals. `SessionStatus` is an unsigned
32-bit bitset on the wire; a signed `Int` could not hold a forward-compat bit
at or above `2^31`.

### Fixed

- `SessionStatus` decode fidelity: an unknown forward-compat bit at or above
`2^31` (e.g. `2147483720`) now round-trips as a plain JSON integer instead of
throwing `JsonDecodingException` and dropping the bit.

## [0.3.0] β€” 2026-06-05

Implements AHP 0.3.0.
Expand Down Expand Up @@ -93,6 +106,7 @@ Implements AHP 0.3.0.
with `Client(clientId)` and `Mcp(customizationId)` variants).
`SessionToolCallStartAction` carries the new `contributor` field as
well.

## [0.2.0] β€” 2026-05-28

Implements AHP `0.2.0`.
Expand Down
9 changes: 9 additions & 0 deletions clients/kotlin/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,15 @@ tasks.withType<Test>().configureEach {
.resolve("../../types/test-cases/reducers")
.canonicalPath,
)
// Same wiring for the shared round-trip corpus consumed by
// `TypesRoundTripFixtureTest` β€” the language-agnostic wire-fidelity
// fixtures shared with the .NET / Swift / Rust clients.
systemProperty(
"ahp.roundTripFixturesDir",
rootProject.projectDir
.resolve("../../types/test-cases/round-trips")
.canonicalPath,
)
}

mavenPublishing {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -214,7 +214,7 @@ private fun now(): Long = currentTimestampProvider()
// ─── Status Bitset Helpers ──────────────────────────────────────────────────

/** Bitmask covering the mutually-exclusive activity bits (bits 0–4). */
private const val STATUS_ACTIVITY_MASK: Int = (1 shl 5) - 1
private const val STATUS_ACTIVITY_MASK: Long = (1L shl 5) - 1L

/** Sets or clears a metadata flag on a status value. */
private fun withStatusFlag(status: SessionStatus, flag: SessionStatus, set: Boolean): SessionStatus =
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -123,7 +123,7 @@ enum class SessionLifecycle {
*/
@Serializable(with = SessionStatusSerializer::class)
@JvmInline
value class SessionStatus(val rawValue: Int) {
value class SessionStatus(val rawValue: Long) {
operator fun contains(other: SessionStatus): Boolean =
(rawValue and other.rawValue) == other.rawValue

Expand All @@ -134,38 +134,38 @@ value class SessionStatus(val rawValue: Int) {
/**
* Session is idle β€” no turn is active.
*/
val IDLE: SessionStatus = SessionStatus(1)
val IDLE: SessionStatus = SessionStatus(1L)
/**
* Session ended with an error.
*/
val ERROR: SessionStatus = SessionStatus(2)
val ERROR: SessionStatus = SessionStatus(2L)
/**
* A turn is actively streaming.
*/
val IN_PROGRESS: SessionStatus = SessionStatus(8)
val IN_PROGRESS: SessionStatus = SessionStatus(8L)
/**
* A turn is in progress but blocked waiting for user input or tool confirmation.
*/
val INPUT_NEEDED: SessionStatus = SessionStatus(24)
val INPUT_NEEDED: SessionStatus = SessionStatus(24L)
/**
* The client has viewed this session since its last modification.
*/
val IS_READ: SessionStatus = SessionStatus(32)
val IS_READ: SessionStatus = SessionStatus(32L)
/**
* The session has been archived by the client.
*/
val IS_ARCHIVED: SessionStatus = SessionStatus(64)
val IS_ARCHIVED: SessionStatus = SessionStatus(64L)
}
}

internal object SessionStatusSerializer : KSerializer<SessionStatus> {
override val descriptor: SerialDescriptor =
PrimitiveSerialDescriptor("SessionStatus", PrimitiveKind.INT)
PrimitiveSerialDescriptor("SessionStatus", PrimitiveKind.LONG)
override fun serialize(encoder: Encoder, value: SessionStatus) {
encoder.encodeInt(value.rawValue)
encoder.encodeLong(value.rawValue)
}
override fun deserialize(decoder: Decoder): SessionStatus =
SessionStatus(decoder.decodeInt())
SessionStatus(decoder.decodeLong())
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import kotlin.test.assertTrue

/**
* Tests for bitset-style enums emitted as `@JvmInline value class` wrappers
* over [Int]. These verify bitwise containment, the OR/AND combinators, and
* over [Long]. These verify bitwise containment, the OR/AND combinators, and
* β€” most importantly β€” that unknown future bits survive a decode/encode
* round-trip without being dropped.
*/
Expand Down Expand Up @@ -52,13 +52,31 @@ class BitsetEnumTest {
// preserve it so subsequent re-encoding doesn't drop the unknown
// capability.
val withFutureBit = json.decodeFromString(SessionStatus.serializer(), "129")
assertEquals(129, withFutureBit.rawValue)
assertEquals(129L, withFutureBit.rawValue)
assertTrue(SessionStatus.IDLE in withFutureBit)

val reencoded = json.encodeToString(SessionStatus.serializer(), withFutureBit)
assertEquals("129", reencoded)
}

@Test
fun `high bits above signed int32 range survive round trip`() {
// SessionStatus is an unsigned 32-bit bitset on the wire (the .NET
// reference models it as `uint`). A forward-compat unknown bit at or
// above the sign bit 2^31 (2147483648) is a positive value that does
// NOT fit a signed 32-bit Int β€” backing rawValue with Long is what lets
// it round-trip. Mirrors the shared corpus fixture
// 005-session-status-unknown-bits-preserved: 8|64|2^31 = 2147483720.
val wire = "2147483720"
val status = json.decodeFromString(SessionStatus.serializer(), wire)
assertEquals(2147483720L, status.rawValue)
assertTrue(SessionStatus.IN_PROGRESS in status)
assertTrue(SessionStatus.IS_ARCHIVED in status)

val reencoded = json.encodeToString(SessionStatus.serializer(), status)
assertEquals(wire, reencoded)
}

@Test
fun `bitset wire value is a plain JSON number`() {
val parsed = json.parseToJsonElement("64") as JsonPrimitive
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -139,7 +139,7 @@ class GeneratedStructsTest {
// Sanity check that Ahp object initializes lazily and produces the
// SessionStatus reference (just to ensure the import graph compiles).
assertNotNull(Ahp.json)
assertEquals(8, SessionStatus.IN_PROGRESS.rawValue)
assertEquals(8L, SessionStatus.IN_PROGRESS.rawValue)
}

@Test
Expand Down
Loading