From feeb50206455a2d70712fab4af3e3e1cdaa51a0f Mon Sep 17 00:00:00 2001 From: Joshua Mouch Date: Wed, 10 Jun 2026 01:17:19 -0400 Subject: [PATCH 1/2] Add a shared round-trip corpus + fix the wire bugs it caught (SessionStatus, changeset range, origin) A language-agnostic wire round-trip corpus (types/test-cases/round-trips/*.json) run by all five clients: decode wire bytes, re-encode, assert the single canonical form (exact-match; null is NOT normalized to absent). Go, Rust, Swift, and Kotlin decode via their real generated types -- including Kotlin's JsonRpcMessage, dispatched into its real JsonRpcRequest/Notification/SuccessResponse/ErrorResponse variant classes. TypeScript types are erased at runtime, so its round-trip harness verifies runtime wire behavior + fixture self-consistency, not generated-type correctness. Coverage gaps: types/test-cases/round-trips/KNOWN-FIDELITY-GAPS.md. No mocks. SessionStatus bitset (SemVer-major). It is a numeric bitset on the wire, but Rust modeled it as a closed enum and Kotlin as a signed Int -- neither could represent combinations (InProgress|IsArchived = 72) or unknown future bits (2147483720, bit 31), failing fixtures 004/005. Now a uniform 32-bit-UNSIGNED bitset across all clients: Rust u32 (was enum), Go uint32, Kotlin UInt (was Int), Swift UInt32 -- every client holds the same value range, within TypeScript's number 53-bit-safe limit. Proven red->green: the Rust harness fails 004/005 on the old enum, passes on u32. Changeset range (SemVer-major). ChangesetOperationTarget kind=range is 'range: TextRange' per spec (nested {start:{line,character}, end:{line,character}}), but the Go/Rust/Kotlin/Swift generators hardcoded a flat {start,end} int-pair that cannot hold a real range (the in-use nested shape is proven by the annotations reducer fixtures). The generators now reference the canonical TextRange (TS already did); fixture 013 corrected to nested. Wire-fidelity fixes (non-breaking): ActionEnvelope.origin omitted (not null) when absent, per spec (undefined origin = server-originated) -- Go omitempty, Rust skip_serializing_if. Forward-compat unknown fallback for Customization/StateAction/changeset variants so unknown discriminants round-trip instead of throwing (also closes the Swift reducer fixture-103 gap). Exact-match single canonical outputs; fixtures 017/019 split into a TS-vs-runtime group. --- clients/go/CHANGELOG.md | 11 + clients/go/ahptypes/actions.generated.go | 2 +- clients/go/ahptypes/commands.generated.go | 15 +- clients/go/ahptypes/roundtrip_fixture_test.go | 331 ++++++++++++++++++ clients/kotlin/CHANGELOG.md | 17 +- clients/kotlin/build.gradle.kts | 9 + .../microsoft/agenthostprotocol/Reducers.kt | 2 +- .../generated/Commands.generated.kt | 8 +- .../generated/State.generated.kt | 20 +- .../agenthostprotocol/BitsetEnumTest.kt | 20 +- .../DiscriminatedUnionTest.kt | 7 +- .../agenthostprotocol/GeneratedStructsTest.kt | 2 +- .../agenthostprotocol/RoundTripCorpusTest.kt | 308 ++++++++++++++++ clients/rust/CHANGELOG.md | 27 +- clients/rust/crates/ahp-types/src/actions.rs | 1 + clients/rust/crates/ahp-types/src/commands.rs | 8 +- clients/rust/crates/ahp-types/src/lib.rs | 16 +- clients/rust/crates/ahp-types/src/state.rs | 87 ++++- .../ahp-types/tests/roundtrip_corpus.rs | 299 ++++++++++++++++ clients/rust/crates/ahp/src/reducers.rs | 18 +- .../ahp/tests/multi_host_state_mirror.rs | 4 +- .../Generated/Actions.generated.swift | 9 +- .../Generated/Commands.generated.swift | 34 +- .../Generated/State.generated.swift | 79 ++++- .../AgentHostProtocol/NativeReducer.swift | 23 +- .../ToolCallStateExtensions.swift | 10 +- .../TypesRoundTripFixtureTests.swift | 237 +++++++++++++ .../FixtureDrivenReducerTests.swift | 29 +- clients/swift/CHANGELOG.md | 26 ++ clients/typescript/CHANGELOG.md | 6 + .../typescript/test/types-round-trip.test.ts | 289 +++++++++++++++ scripts/generate-go.ts | 20 +- scripts/generate-kotlin.ts | 33 +- scripts/generate-rust.ts | 145 +++++++- scripts/generate-swift.ts | 111 ++++-- ...action-envelope-session-title-changed.json | 24 ++ ...tate-action-unknown-variant-preserved.json | 16 + ...-customization-unknown-type-preserved.json | 18 + .../004-session-status-bitset-flags.json | 10 + ...session-status-unknown-bits-preserved.json | 10 + .../006-string-or-markdown-plain.json | 10 + .../007-string-or-markdown-object.json | 14 + .../round-trips/008-jsonrpc-request.json | 20 ++ .../round-trips/009-jsonrpc-notification.json | 18 + .../round-trips/010-jsonrpc-success.json | 18 + .../round-trips/011-jsonrpc-error.json | 24 ++ .../012-changeset-target-resource.json | 16 + .../013-changeset-target-range.json | 24 ++ .../014-session-input-question-number.json | 22 ++ .../015-session-input-question-integer.json | 20 ++ .../016-long-above-int32-max-preserved.json | 24 ++ .../017-unknown-wire-keys-ignored.json | 36 ++ .../018-nested-optional-null-round-trip.json | 24 ++ .../019-channel-scoped-notification-uri.json | 43 +++ .../020-partial-summary-all-null.json | 10 + ...hangeset-changekind-known-and-unknown.json | 38 ++ .../025-action-envelope-origin-absent.json | 18 + .../026-action-envelope-origin-present.json | 20 ++ .../round-trips/KNOWN-FIDELITY-GAPS.md | 48 +++ 59 files changed, 2610 insertions(+), 178 deletions(-) create mode 100644 clients/go/ahptypes/roundtrip_fixture_test.go create mode 100644 clients/kotlin/src/test/kotlin/com/microsoft/agenthostprotocol/RoundTripCorpusTest.kt create mode 100644 clients/rust/crates/ahp-types/tests/roundtrip_corpus.rs create mode 100644 clients/swift/AgentHostProtocol/Tests/AgentHostProtocolClientTests/TypesRoundTripFixtureTests.swift create mode 100644 clients/typescript/test/types-round-trip.test.ts create mode 100644 types/test-cases/round-trips/001-action-envelope-session-title-changed.json create mode 100644 types/test-cases/round-trips/002-state-action-unknown-variant-preserved.json create mode 100644 types/test-cases/round-trips/003-customization-unknown-type-preserved.json create mode 100644 types/test-cases/round-trips/004-session-status-bitset-flags.json create mode 100644 types/test-cases/round-trips/005-session-status-unknown-bits-preserved.json create mode 100644 types/test-cases/round-trips/006-string-or-markdown-plain.json create mode 100644 types/test-cases/round-trips/007-string-or-markdown-object.json create mode 100644 types/test-cases/round-trips/008-jsonrpc-request.json create mode 100644 types/test-cases/round-trips/009-jsonrpc-notification.json create mode 100644 types/test-cases/round-trips/010-jsonrpc-success.json create mode 100644 types/test-cases/round-trips/011-jsonrpc-error.json create mode 100644 types/test-cases/round-trips/012-changeset-target-resource.json create mode 100644 types/test-cases/round-trips/013-changeset-target-range.json create mode 100644 types/test-cases/round-trips/014-session-input-question-number.json create mode 100644 types/test-cases/round-trips/015-session-input-question-integer.json create mode 100644 types/test-cases/round-trips/016-long-above-int32-max-preserved.json create mode 100644 types/test-cases/round-trips/017-unknown-wire-keys-ignored.json create mode 100644 types/test-cases/round-trips/018-nested-optional-null-round-trip.json create mode 100644 types/test-cases/round-trips/019-channel-scoped-notification-uri.json create mode 100644 types/test-cases/round-trips/020-partial-summary-all-null.json create mode 100644 types/test-cases/round-trips/024-changeset-changekind-known-and-unknown.json create mode 100644 types/test-cases/round-trips/025-action-envelope-origin-absent.json create mode 100644 types/test-cases/round-trips/026-action-envelope-origin-present.json create mode 100644 types/test-cases/round-trips/KNOWN-FIDELITY-GAPS.md diff --git a/clients/go/CHANGELOG.md b/clients/go/CHANGELOG.md index d40b650a..9f06d214 100644 --- a/clients/go/CHANGELOG.md +++ b/clients/go/CHANGELOG.md @@ -14,6 +14,17 @@ tag whose matching `## [X.Y.Z]` heading is missing from this file. ## [Unreleased] +### Changed + +- **BREAKING:** `ChangesetOperationTargetRange` is now a nested `TextRange` + (`{start: {line, character}, end: {line, character}}`) instead of flat + `{start, end}` `int64` fields. + +### Fixed + +- `ActionEnvelope.Origin` is now omitted from JSON output when absent + (`json:"origin,omitempty"`) instead of serializing as `null`. + ### Added - `SnapshotState.ResourceWatch` pointer field — the `Snapshot.state` union diff --git a/clients/go/ahptypes/actions.generated.go b/clients/go/ahptypes/actions.generated.go index 6c5a0263..ecde8018 100644 --- a/clients/go/ahptypes/actions.generated.go +++ b/clients/go/ahptypes/actions.generated.go @@ -104,7 +104,7 @@ type ActionEnvelope struct { Channel URI `json:"channel"` Action StateAction `json:"action"` ServerSeq int64 `json:"serverSeq"` - Origin *ActionOrigin `json:"origin"` + Origin *ActionOrigin `json:"origin,omitempty"` RejectionReason *string `json:"rejectionReason,omitempty"` } diff --git a/clients/go/ahptypes/commands.generated.go b/clients/go/ahptypes/commands.generated.go index a48150c5..c91f7813 100644 --- a/clients/go/ahptypes/commands.generated.go +++ b/clients/go/ahptypes/commands.generated.go @@ -950,21 +950,14 @@ func (*ChangesetOperationResourceTarget) isChangesetOperationTarget() {} // ChangesetOperationRangeTarget targets a range within a resource. type ChangesetOperationRangeTarget struct { - Kind string `json:"kind"` - Resource URI `json:"resource"` - Side *string `json:"side,omitempty"` - Range ChangesetOperationTargetRange `json:"range"` + Kind string `json:"kind"` + Resource URI `json:"resource"` + Side *string `json:"side,omitempty"` + Range TextRange `json:"range"` } func (*ChangesetOperationRangeTarget) isChangesetOperationTarget() {} -// ChangesetOperationTargetRange is the [start, end] index pair for a -// range target. -type ChangesetOperationTargetRange struct { - Start int64 `json:"start"` - End int64 `json:"end"` -} - // UnmarshalJSON dispatches on the `kind` discriminator. func (t *ChangesetOperationTarget) UnmarshalJSON(data []byte) error { disc, _, err := readDiscriminator(data, "kind") diff --git a/clients/go/ahptypes/roundtrip_fixture_test.go b/clients/go/ahptypes/roundtrip_fixture_test.go new file mode 100644 index 00000000..d439435a --- /dev/null +++ b/clients/go/ahptypes/roundtrip_fixture_test.go @@ -0,0 +1,331 @@ +// TestRoundTripCorpus — data-driven wire round-trip parity for the Go client. +// +// Loads the SHARED, language-agnostic round-trip corpus under +// types/test-cases/round-trips/*.json (the same fixtures the Swift and +// TypeScript clients run) and asserts each via the REAL generated Go wire +// types — encoding/json (un)marshal, the real discriminated-union +// UnmarshalJSON/MarshalJSON, the real SessionStatus bitset. +// No mocks, no faked SUT: every fixture decodes real bytes into a real type and +// re-encodes with the same serializer. +// +// Each fixture has the shape: +// { "name": ..., "description": ..., "type": ..., +// "input": , +// "acceptableOutputs": [ ], +// "notApplicable": [ ] } +// +// The harness decodes `input` as the real type named by `type`, re-encodes +// with encoding/json, and asserts the result structurally equals +// acceptableOutputs[0] (key-order-independent, value- and key-presence-sensitive). +// acceptableOutputs MUST have exactly one entry — the single intended wire form. +// +// If the fixture carries "notApplicable": ["go"] (unlikely — only expected for +// the TypeScript structural limitation), the fixture is skipped with a note. +// +// Run: go test ./ahptypes/ -run TestRoundTripCorpus -v + +package ahptypes + +import ( + "encoding/json" + "os" + "path/filepath" + "sort" + "strings" + "testing" +) + +// findRoundTripFixtureDir walks upward from the cwd looking for +// types/test-cases/round-trips so the test works whether `go test` runs from +// clients/go/ahptypes or the module root. +func findRoundTripFixtureDir(t *testing.T) string { + t.Helper() + wd, err := os.Getwd() + if err != nil { + t.Fatal(err) + } + for { + candidate := filepath.Join(wd, "types", "test-cases", "round-trips") + if fi, err := os.Stat(candidate); err == nil && fi.IsDir() { + return candidate + } + parent := filepath.Dir(wd) + if parent == wd { + t.Fatalf("could not locate types/test-cases/round-trips walking upward from cwd") + } + wd = parent + } +} + +// roundTripFixture is the decoded shape of one corpus JSON file. +type roundTripFixture struct { + Name string `json:"name"` + Description string `json:"description"` + // Group "A" = all clients agree (assert acceptableOutputs[0]). + // Group "B" = runtime-decoders drop unknown keys (assert acceptableOutputs[0]); + // TypeScript preserves them (asserts typescriptOutput instead). + // Absent group is treated as "A" for backward compatibility. + Group string `json:"group"` + Type string `json:"type"` + Input json.RawMessage `json:"input"` + AcceptableOutputs []json.RawMessage `json:"acceptableOutputs"` + // TypescriptOutput is the expected output for the TypeScript client (Group B only). + // Go always asserts acceptableOutputs[0] for both groups. + TypescriptOutput json.RawMessage `json:"typescriptOutput"` + // NotApplicable lists client names for which this fixture does not apply. + // Legacy field — new fixtures use group:"B" + typescriptOutput instead. + NotApplicable []string `json:"notApplicable"` +} + +// TestRoundTripCorpus is the primary cross-language wire round-trip parity gate +// for the Go client. +func TestRoundTripCorpus(t *testing.T) { + dir := findRoundTripFixtureDir(t) + + entries, err := os.ReadDir(dir) + if err != nil { + t.Fatal(err) + } + sort.Slice(entries, func(i, j int) bool { return entries[i].Name() < entries[j].Name() }) + + var fixtureFiles []string + for _, entry := range entries { + if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".json") { + continue + } + fixtureFiles = append(fixtureFiles, entry.Name()) + } + + // Loaded-something guard: the checkout must actually include the corpus. + if len(fixtureFiles) == 0 { + t.Fatalf("no round-trip fixtures found in %s — ensure the checkout includes types/test-cases/round-trips/", dir) + } + + ranReal := 0 + for _, name := range fixtureFiles { + name := name + ok := t.Run(name, func(tt *testing.T) { + path := filepath.Join(dir, name) + raw, err := os.ReadFile(path) + if err != nil { + tt.Fatalf("read: %v", err) + } + runRoundTripFixture(tt, name, raw) + }) + if ok { + ranReal++ + } + } + + t.Logf("round-trip corpus: %d fixtures, %d asserted for real", len(fixtureFiles), ranReal) +} + +func runRoundTripFixture(t *testing.T, name string, raw []byte) { + t.Helper() + + var fx roundTripFixture + if err := json.Unmarshal(raw, &fx); err != nil { + t.Fatalf("parse fixture: %v", err) + } + if fx.Type == "" { + t.Fatalf("missing `type`") + } + if len(fx.Input) == 0 { + t.Fatalf("%s: missing `input`", name) + } + if len(fx.AcceptableOutputs) == 0 { + t.Fatalf("%s: fixture made no assertions — `acceptableOutputs` is empty", name) + } + + // Enforce single canonical form: acceptableOutputs MUST have exactly one entry. + // Multi-form acceptance sets encode observed-but-wrong divergence as acceptable. + if len(fx.AcceptableOutputs) != 1 { + t.Fatalf("%s: acceptableOutputs must have exactly 1 entry (the single canonical re-encoded form); got %d. "+ + "Multiple entries cement divergence instead of fixing it.", name, len(fx.AcceptableOutputs)) + } + + // Honor notApplicable: skip clients listed there with a note. + // Legacy field — new fixtures use group:"B" + typescriptOutput instead. + for _, skip := range fx.NotApplicable { + if skip == "go" { + t.Logf("⊘ %s: not applicable to go — %s", name, fx.Description) + t.Skip() + return + } + } + + // Group B: Go is a runtime-decoder — it drops unknown keys → asserts acceptableOutputs[0]. + // (Group A also asserts acceptableOutputs[0]; the group field only affects the TypeScript harness.) + + // Decode `input` as the real generated type, re-encode with encoding/json. + reencoded := decodeAndReencode(t, name, fx.Type, string(fx.Input)) + + // Assert the re-encoded result structurally equals the single canonical output. + if canonicalJSONEqualRaw(t, name, reencoded, string(fx.AcceptableOutputs[0])) { + return // PASS + } + + t.Fatalf("%s: re-encoded output does not match the canonical acceptableOutput.\n got: %s\n expected: %s", + name, reencoded, string(fx.AcceptableOutputs[0])) +} + +// decodeAndReencode decodes inputJSON into the real generated type named by +// `type` and re-encodes it with encoding/json. Adding a wire type to the +// corpus is a deliberate edit here — the corpus never decodes arbitrary types +// reflectively. +func decodeAndReencode(t *testing.T, name, typ, inputJSON string) string { + t.Helper() + + dec := func(v any) { + if err := json.Unmarshal([]byte(inputJSON), v); err != nil { + t.Fatalf("%s: decode %s: %v", name, typ, err) + } + } + enc := func(v any) string { + out, err := json.Marshal(v) + if err != nil { + t.Fatalf("%s: re-encode %s: %v", name, typ, err) + } + return string(out) + } + + switch typ { + case "ActionEnvelope": + var v ActionEnvelope + dec(&v) + return enc(&v) + case "StateAction": + var v StateAction + dec(&v) + return enc(&v) + case "Customization": + var v Customization + dec(&v) + return enc(&v) + case "SessionStatus": + var v SessionStatus + dec(&v) + return enc(v) + case "StringOrMarkdown": + var v StringOrMarkdown + dec(&v) + return enc(&v) + case "JsonRpcMessage": + var v JsonRpcMessage + dec(&v) + return enc(&v) + case "ChangesetOperationTarget": + var v ChangesetOperationTarget + dec(&v) + return enc(&v) + case "SessionInputQuestion": + var v SessionInputQuestion + dec(&v) + return enc(&v) + case "SessionSummary": + var v SessionSummary + dec(&v) + return enc(&v) + case "SessionAddedParams": + var v SessionAddedParams + dec(&v) + return enc(&v) + case "PartialSessionSummary": + var v PartialSessionSummary + dec(&v) + return enc(&v) + default: + t.Fatalf("%s: round-trip fixture: unknown wire type %q. Add a decode entry to decodeAndReencode.", name, typ) + return "" + } +} + +// ─── JSON equality ─────────────────────────────────────────────────────────── + +// canonicalJSONEqualRaw compares two JSON documents structurally (key-order +// independent, value- and key-presence sensitive). +func canonicalJSONEqualRaw(t *testing.T, name, lhs, rhs string) bool { + t.Helper() + lo := parseToAny(t, name, lhs) + ro := parseToAny(t, name, rhs) + return canonicalJSONEqual(t, name, lo, ro) +} + +// parseToAny decodes a JSON document into a generic value using json.Number so +// large 64-bit integers stay exact. +func parseToAny(t *testing.T, name, s string) any { + t.Helper() + d := json.NewDecoder(strings.NewReader(s)) + d.UseNumber() + var out any + if err := d.Decode(&out); err != nil { + t.Fatalf("%s: parse JSON %q: %v", name, s, err) + } + return out +} + +// canonicalJSONEqual re-serializes both sides through encoding/json after +// normalizing json.Number values, so equality is structural. +func canonicalJSONEqual(t *testing.T, name string, a, b any) bool { + t.Helper() + return canonicalString(t, name, a) == canonicalString(t, name, b) +} + +func canonicalString(t *testing.T, name string, v any) string { + t.Helper() + out, err := json.Marshal(normalizeNumbers(v)) + if err != nil { + t.Fatalf("%s: canonicalize: %v", name, err) + } + return string(out) +} + +// normalizeNumbers walks a generic JSON value and converts json.Number leaves to +// a canonical numeric form so that, e.g., 0 and 0.0 compare equal. +func normalizeNumbers(v any) any { + switch x := v.(type) { + case map[string]any: + out := make(map[string]any, len(x)) + for k, val := range x { + out[k] = normalizeNumbers(val) + } + return out + case []any: + out := make([]any, len(x)) + for i, val := range x { + out[i] = normalizeNumbers(val) + } + return out + case json.Number: + if i, err := x.Int64(); err == nil { + return i + } + if f, err := x.Float64(); err == nil { + return f + } + return x.String() + default: + return v + } +} + +// ─── ProtocolVersion constant tests ───────────────────────────────────────── + +// TestProtocolVersionConstants verifies the three properties of the +// ProtocolVersion constants that were previously exercised via corpus +// fixtures 021–023 (now deleted from the round-trip corpus). +func TestProtocolVersionConstants(t *testing.T) { + if strings.TrimSpace(ProtocolVersion) == "" { + t.Errorf("ProtocolVersion must be non-empty, got %q", ProtocolVersion) + } + + supported := SupportedProtocolVersions() + if len(supported) == 0 { + t.Errorf("SupportedProtocolVersions() must be non-empty") + } + + if len(supported) > 0 && supported[0] != ProtocolVersion { + t.Errorf("first SupportedProtocolVersions entry %q must equal ProtocolVersion %q", + supported[0], ProtocolVersion) + } +} diff --git a/clients/kotlin/CHANGELOG.md b/clients/kotlin/CHANGELOG.md index 25586707..534c84c8 100644 --- a/clients/kotlin/CHANGELOG.md +++ b/clients/kotlin/CHANGELOG.md @@ -15,15 +15,21 @@ versions (`*-SNAPSHOT`) are explicitly rejected by the publish pipeline; bump ## [Unreleased] -### Added +### Changed -- `SnapshotState.ResourceWatch` value class — the `Snapshot.state` union now - accepts `ResourceWatchState`, so a snapshot of an `ahp-resource-watch:` - channel decodes via the existing `SnapshotStateSerializer` shape probe - (required `root` + `recursive` keys). +- **BREAKING:** `SessionStatus.rawValue` is now a `UInt` (was `Int`), and the + named flag constants are `UInt` 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` (`UInt` holds the full 32-bit range). +- **BREAKING:** `ChangesetOperationTarget`'s range target now carries a nested + `TextRange` (`{start: {line, character}, end: {line, character}}`) instead of + a flat `{start, end}` integer pair. ### 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. - `sessionReducer` now applies `_meta` (`meta`) updates from every tool-call-scoped action, not only `session/toolCallStart`. @@ -98,6 +104,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`. diff --git a/clients/kotlin/build.gradle.kts b/clients/kotlin/build.gradle.kts index 6b3cbaad..9180b620 100644 --- a/clients/kotlin/build.gradle.kts +++ b/clients/kotlin/build.gradle.kts @@ -56,6 +56,15 @@ tasks.withType().configureEach { .resolve("../../types/test-cases/reducers") .canonicalPath, ) + // Same wiring for the shared round-trip corpus consumed by + // `RoundTripCorpusTest` — 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 { diff --git a/clients/kotlin/src/main/kotlin/com/microsoft/agenthostprotocol/Reducers.kt b/clients/kotlin/src/main/kotlin/com/microsoft/agenthostprotocol/Reducers.kt index c7a3b74f..7d8d8268 100644 --- a/clients/kotlin/src/main/kotlin/com/microsoft/agenthostprotocol/Reducers.kt +++ b/clients/kotlin/src/main/kotlin/com/microsoft/agenthostprotocol/Reducers.kt @@ -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 val STATUS_ACTIVITY_MASK: UInt = (1u shl 5) - 1u /** Sets or clears a metadata flag on a status value. */ private fun withStatusFlag(status: SessionStatus, flag: SessionStatus, set: Boolean): SessionStatus = diff --git a/clients/kotlin/src/main/kotlin/com/microsoft/agenthostprotocol/generated/Commands.generated.kt b/clients/kotlin/src/main/kotlin/com/microsoft/agenthostprotocol/generated/Commands.generated.kt index e532cba7..a645f3c7 100644 --- a/clients/kotlin/src/main/kotlin/com/microsoft/agenthostprotocol/generated/Commands.generated.kt +++ b/clients/kotlin/src/main/kotlin/com/microsoft/agenthostprotocol/generated/Commands.generated.kt @@ -1099,17 +1099,11 @@ data class ChangesetOperationResourceTarget( data class ChangesetOperationRangeTarget( val resource: String, val side: String? = null, - val range: ChangesetOperationTargetRange, + val range: TextRange, /** Discriminator. Always "range". */ val kind: String = "range", ) -@Serializable -data class ChangesetOperationTargetRange( - val start: Long, - val end: Long, -) - internal object ChangesetOperationTargetSerializer : KSerializer { override val descriptor: SerialDescriptor = buildClassSerialDescriptor("ChangesetOperationTarget") diff --git a/clients/kotlin/src/main/kotlin/com/microsoft/agenthostprotocol/generated/State.generated.kt b/clients/kotlin/src/main/kotlin/com/microsoft/agenthostprotocol/generated/State.generated.kt index be4abbfd..ebc4e7bc 100644 --- a/clients/kotlin/src/main/kotlin/com/microsoft/agenthostprotocol/generated/State.generated.kt +++ b/clients/kotlin/src/main/kotlin/com/microsoft/agenthostprotocol/generated/State.generated.kt @@ -123,7 +123,7 @@ enum class SessionLifecycle { */ @Serializable(with = SessionStatusSerializer::class) @JvmInline -value class SessionStatus(val rawValue: Int) { +value class SessionStatus(val rawValue: UInt) { operator fun contains(other: SessionStatus): Boolean = (rawValue and other.rawValue) == other.rawValue @@ -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(1u) /** * Session ended with an error. */ - val ERROR: SessionStatus = SessionStatus(2) + val ERROR: SessionStatus = SessionStatus(2u) /** * A turn is actively streaming. */ - val IN_PROGRESS: SessionStatus = SessionStatus(8) + val IN_PROGRESS: SessionStatus = SessionStatus(8u) /** * 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(24u) /** * The client has viewed this session since its last modification. */ - val IS_READ: SessionStatus = SessionStatus(32) + val IS_READ: SessionStatus = SessionStatus(32u) /** * The session has been archived by the client. */ - val IS_ARCHIVED: SessionStatus = SessionStatus(64) + val IS_ARCHIVED: SessionStatus = SessionStatus(64u) } } internal object SessionStatusSerializer : KSerializer { 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.toLong()) } override fun deserialize(decoder: Decoder): SessionStatus = - SessionStatus(decoder.decodeInt()) + SessionStatus(decoder.decodeLong().toUInt()) } /** diff --git a/clients/kotlin/src/test/kotlin/com/microsoft/agenthostprotocol/BitsetEnumTest.kt b/clients/kotlin/src/test/kotlin/com/microsoft/agenthostprotocol/BitsetEnumTest.kt index 84b297d8..a06b473e 100644 --- a/clients/kotlin/src/test/kotlin/com/microsoft/agenthostprotocol/BitsetEnumTest.kt +++ b/clients/kotlin/src/test/kotlin/com/microsoft/agenthostprotocol/BitsetEnumTest.kt @@ -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(129u, 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 UInt 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(2147483720u, 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 diff --git a/clients/kotlin/src/test/kotlin/com/microsoft/agenthostprotocol/DiscriminatedUnionTest.kt b/clients/kotlin/src/test/kotlin/com/microsoft/agenthostprotocol/DiscriminatedUnionTest.kt index dcd058d7..04f44af1 100644 --- a/clients/kotlin/src/test/kotlin/com/microsoft/agenthostprotocol/DiscriminatedUnionTest.kt +++ b/clients/kotlin/src/test/kotlin/com/microsoft/agenthostprotocol/DiscriminatedUnionTest.kt @@ -3,7 +3,8 @@ package com.microsoft.agenthostprotocol import com.microsoft.agenthostprotocol.generated.ChangesetOperationRangeTarget import com.microsoft.agenthostprotocol.generated.ChangesetOperationResourceTarget import com.microsoft.agenthostprotocol.generated.ChangesetOperationTarget -import com.microsoft.agenthostprotocol.generated.ChangesetOperationTargetRange +import com.microsoft.agenthostprotocol.generated.TextPosition +import com.microsoft.agenthostprotocol.generated.TextRange import com.microsoft.agenthostprotocol.generated.Customization import com.microsoft.agenthostprotocol.generated.CustomizationUnknown import com.microsoft.agenthostprotocol.generated.MarkdownResponsePart @@ -159,7 +160,7 @@ class DiscriminatedUnionTest { "kind": "range", "resource": "file:///a.ts", "side": "after", - "range": { "start": 10, "end": 42 } + "range": { "start": {"line": 10, "character": 0}, "end": {"line": 42, "character": 0} } }""".trimIndent() val res = json.decodeFromString(ChangesetOperationTarget.serializer(), resourceWire) @@ -169,7 +170,7 @@ class DiscriminatedUnionTest { val rng = json.decodeFromString(ChangesetOperationTarget.serializer(), rangeWire) val rngVariant = assertIs(rng) - assertEquals(ChangesetOperationTargetRange(start = 10, end = 42), rngVariant.value.range) + assertEquals(TextRange(start = TextPosition(line = 10, character = 0), end = TextPosition(line = 42, character = 0)), rngVariant.value.range) // Encoding emits the correct discriminator wire value. val encoded = json.encodeToString( diff --git a/clients/kotlin/src/test/kotlin/com/microsoft/agenthostprotocol/GeneratedStructsTest.kt b/clients/kotlin/src/test/kotlin/com/microsoft/agenthostprotocol/GeneratedStructsTest.kt index a0514534..14f27623 100644 --- a/clients/kotlin/src/test/kotlin/com/microsoft/agenthostprotocol/GeneratedStructsTest.kt +++ b/clients/kotlin/src/test/kotlin/com/microsoft/agenthostprotocol/GeneratedStructsTest.kt @@ -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(8u, SessionStatus.IN_PROGRESS.rawValue) } @Test diff --git a/clients/kotlin/src/test/kotlin/com/microsoft/agenthostprotocol/RoundTripCorpusTest.kt b/clients/kotlin/src/test/kotlin/com/microsoft/agenthostprotocol/RoundTripCorpusTest.kt new file mode 100644 index 00000000..9e8db0a7 --- /dev/null +++ b/clients/kotlin/src/test/kotlin/com/microsoft/agenthostprotocol/RoundTripCorpusTest.kt @@ -0,0 +1,308 @@ +package com.microsoft.agenthostprotocol + +// RoundTripCorpusTest — data-driven wire round-trip parity for the Kotlin client. +// +// Loads the SHARED, language-agnostic round-trip corpus under +// types/test-cases/round-trips/*.json (the same fixtures the Go, Swift, +// TypeScript, and Rust clients run) and asserts each via the REAL generated +// Kotlin wire types + kotlinx.serialization + Ahp.json. +// No mocks, no faked SUT: every fixture decodes real bytes into a real type and +// re-encodes with Ahp.json. +// +// Each fixture has the shape: +// { "name": ..., "description": ..., "group": ..., "type": ..., +// "input": , +// "acceptableOutputs": [ ], +// "typescriptOutput": } +// +// Group A: all clients agree — assert acceptableOutputs[0]. +// Group B: runtime-decoder clients drop unknown keys — assert acceptableOutputs[0]. +// (TypeScript asserts typescriptOutput instead; irrelevant to Kotlin.) +// Kotlin is always a runtime decoder → always asserts acceptableOutputs[0]. +// +// Run: +// JAVA_HOME=/opt/homebrew/opt/openjdk@17/libexec/openjdk.jdk/Contents/Home \ +// ./gradlew test --rerun-tasks \ +// --tests com.microsoft.agenthostprotocol.RoundTripCorpusTest +// +// Real-execution: no mocks. Every fixture decodes with Ahp.json into the real +// generated types and re-encodes with Ahp.json. + +import com.microsoft.agenthostprotocol.generated.ActionEnvelope +import com.microsoft.agenthostprotocol.generated.ChangesetOperationTarget +import com.microsoft.agenthostprotocol.generated.Customization +import com.microsoft.agenthostprotocol.generated.JsonRpcErrorResponse +import com.microsoft.agenthostprotocol.generated.JsonRpcNotification +import com.microsoft.agenthostprotocol.generated.JsonRpcRequest +import com.microsoft.agenthostprotocol.generated.JsonRpcSuccessResponse +import com.microsoft.agenthostprotocol.generated.PartialSessionSummary +import com.microsoft.agenthostprotocol.generated.SessionAddedParams +import com.microsoft.agenthostprotocol.generated.SessionInputQuestion +import com.microsoft.agenthostprotocol.generated.SessionStatus +import com.microsoft.agenthostprotocol.generated.SessionSummary +import com.microsoft.agenthostprotocol.generated.StateAction +import com.microsoft.agenthostprotocol.generated.StringOrMarkdown +import java.io.File +import kotlinx.serialization.KSerializer +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import kotlinx.serialization.json.contentOrNull +import org.junit.jupiter.api.DynamicTest +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.TestFactory +import kotlin.test.assertEquals +import kotlin.test.assertTrue +import kotlin.test.fail + +/** + * Data-driven round-trip corpus parity test for the Kotlin client. + * + * Loads corpus fixtures from `types/test-cases/round-trips/`, decodes each + * `input` through the real generated Kotlin type named by `type`, re-encodes + * via [Ahp.json], and asserts structural equality to `acceptableOutputs[0]`. + * + * The fixture directory is located by: + * 1. The `ahp.roundTripFixturesDir` system property (set automatically by + * `build.gradle.kts` for Gradle runs, including IDE runs that delegate to Gradle). + * 2. Fallback: walk upward from `user.dir` looking for `types/test-cases/round-trips/`. + */ +class RoundTripCorpusTest { + + private val json: Json = Ahp.json + + // ─── Fixture directory ──────────────────────────────────────────────────── + + private fun fixtureDir(): File { + val fromProperty = System.getProperty("ahp.roundTripFixturesDir")?.let(::File) + if (fromProperty != null) { + assertTrue( + fromProperty.isDirectory, + "ahp.roundTripFixturesDir points to '${fromProperty.path}' which is not a directory", + ) + return fromProperty + } + // Fallback: walk upward from cwd. + val cwd = File(System.getProperty("user.dir") ?: ".").absoluteFile + var dir: File? = cwd + while (dir != null) { + val candidate = File(dir, "types/test-cases/round-trips") + if (candidate.isDirectory) return candidate + dir = dir.parentFile + } + error( + "Could not locate the round-trip fixtures directory. Set the " + + "'ahp.roundTripFixturesDir' system property (Gradle does this " + + "automatically), or run tests from somewhere inside the repo " + + "checkout containing 'types/test-cases/round-trips/'.", + ) + } + + private fun loadFixtures(): List> { + val dir = fixtureDir() + val files = dir.listFiles { f -> f.isFile && f.name.endsWith(".json") } + ?.sortedBy { it.name } + ?: return emptyList() + // Permissive Json to parse the fixture structure itself (not Ahp.json). + val fixtureJson = Json { ignoreUnknownKeys = true } + return files.map { file -> + val obj = fixtureJson.parseToJsonElement(file.readText()).jsonObject + file to obj + } + } + + // ─── Loaded-something guard ─────────────────────────────────────────────── + + @Test + fun `corpus is present`() { + val fixtures = loadFixtures() + assertTrue( + fixtures.isNotEmpty(), + "No round-trip fixtures found at ${fixtureDir().absolutePath}. " + + "Ensure the repo checkout includes types/test-cases/round-trips/.", + ) + } + + // ─── Whole-corpus runner ────────────────────────────────────────────────── + + @TestFactory + fun `round-trip corpus decodes and re-encodes via the real generated types`(): List { + val fixtures = loadFixtures() + return fixtures.map { (file, fixture) -> + DynamicTest.dynamicTest(file.name) { + runFixture(file, fixture) + } + } + } + + // ─── Per-fixture runner ─────────────────────────────────────────────────── + + private fun runFixture(file: File, fixture: JsonObject) { + val typeName = fixture["type"]?.jsonPrimitive?.contentOrNull + ?: fail("${file.name}: missing `type`") + val inputElement = fixture["input"] + ?: fail("${file.name}: missing `input`") + val acceptableOutputsArray = fixture["acceptableOutputs"]?.jsonArray + ?: fail("${file.name}: missing or non-array `acceptableOutputs`") + + assertTrue( + acceptableOutputsArray.isNotEmpty(), + "${file.name}: fixture made no assertions — `acceptableOutputs` is empty", + ) + + // Enforce single canonical form. + assertEquals( + 1, + acceptableOutputsArray.size, + "${file.name}: acceptableOutputs must have exactly 1 entry (the single canonical " + + "re-encoded form); got ${acceptableOutputsArray.size}. " + + "Multiple entries cement divergence instead of fixing it.", + ) + + // Honor notApplicable (legacy). Kotlin is never listed there, but parse defensively. + val notApplicable = fixture["notApplicable"]?.jsonArray + ?.mapNotNull { it.jsonPrimitive.contentOrNull } + ?: emptyList() + if ("kotlin" in notApplicable) { + println("⊘ ${file.name}: not applicable to kotlin (legacy notApplicable)") + return + } + + // Kotlin is a runtime decoder → always asserts acceptableOutputs[0] (both groups A and B). + val inputJson = Json.encodeToString(kotlinx.serialization.serializer(), inputElement) + val reencoded: JsonElement = decodeAndReencode(file.name, typeName, inputJson) + + val canonical = acceptableOutputsArray[0] + + // Structural equality: compare both sides via key-sorted JSON. + val reencodedNorm = canonicalJson(reencoded) + val expectedNorm = canonicalJson(canonical) + + assertEquals( + expectedNorm, + reencodedNorm, + "${file.name}: re-encoded output does not match the canonical acceptableOutput.\n" + + " got: $reencodedNorm\n" + + " expected: $expectedNorm", + ) + } + + // ─── Real decode dispatch ──────────────────────────────────────────────── + + /** + * Decodes [inputJson] into the real generated Kotlin type named by [typeName] + * and re-encodes with [Ahp.json]. Adding a wire type to the corpus is a + * deliberate edit here — the corpus never decodes arbitrary types reflectively. + */ + private fun decodeAndReencode(file: String, typeName: String, inputJson: String): JsonElement { + fun rt(serializer: KSerializer): JsonElement { + val decoded = try { + json.decodeFromString(serializer, inputJson) + } catch (t: Throwable) { + fail("$file: decode $typeName: ${t.message}") + } + return try { + json.encodeToJsonElement(serializer, decoded) + } catch (t: Throwable) { + fail("$file: re-encode $typeName: ${t.message}") + } + } + + return when (typeName) { + "ActionEnvelope" -> rt(ActionEnvelope.serializer()) + "StateAction" -> rt(StateAction.serializer()) + "Customization" -> rt(Customization.serializer()) + // SessionStatus decodes via the REAL generated value class — no Long sidestep. + // The widened SessionStatus wraps a Long, so it holds bitset combinations (004) + // and unknown high bits (005, value 2147483720 > Int.MAX) and re-encodes as the + // same JSON number. Decoding via a bare Long would bypass the real wire type. + "SessionStatus" -> rt(SessionStatus.serializer()) + "StringOrMarkdown" -> rt(StringOrMarkdown.serializer()) + "JsonRpcMessage" -> { + // Dispatch to the real generated variant class based on JSON shape, + // mirroring the JSON-RPC 2.0 discriminant rules: + // has "error" → JsonRpcErrorResponse + // has "result" → JsonRpcSuccessResponse + // has "id" + "method" → JsonRpcRequest + // else (method only) → JsonRpcNotification + val inputObj = json.parseToJsonElement(inputJson).let { it as? JsonObject } + ?: fail("$file: JsonRpcMessage input is not a JSON object") + when { + inputObj.containsKey("error") -> + rt(JsonRpcErrorResponse.serializer()) + inputObj.containsKey("result") -> + rt(JsonRpcSuccessResponse.serializer(JsonElement.serializer())) + inputObj.containsKey("id") && inputObj.containsKey("method") -> + rt(JsonRpcRequest.serializer(JsonElement.serializer())) + else -> + rt(JsonRpcNotification.serializer(JsonElement.serializer())) + } + } + "ChangesetOperationTarget" -> rt(ChangesetOperationTarget.serializer()) + "SessionInputQuestion" -> rt(SessionInputQuestion.serializer()) + "SessionSummary" -> rt(SessionSummary.serializer()) + "SessionAddedParams" -> rt(SessionAddedParams.serializer()) + "PartialSessionSummary" -> rt(PartialSessionSummary.serializer()) + else -> fail( + "$file: unknown wire type \"$typeName\". " + + "Add a decode entry to decodeAndReencode.", + ) + } + } + + // ─── Structural JSON equality ───────────────────────────────────────────── + + /** + * Returns a key-sorted JSON representation of [element] for structural comparison. + * Key order is normalized (sorted) so object key order doesn't affect equality. + * Null values and absent keys remain distinct (a null value does NOT equal absent). + */ + private fun canonicalJson(element: JsonElement): String = buildString { appendSorted(element) } + + private fun StringBuilder.appendSorted(element: JsonElement) { + when (element) { + is JsonObject -> { + append('{') + val sorted = element.entries.sortedBy { it.key } + sorted.forEachIndexed { idx, (k, v) -> + if (idx > 0) append(',') + // Encode key as a JSON string (quoted, with escaping). + append(Json.encodeToString(kotlinx.serialization.serializer(), k)) + append(':') + appendSorted(v) + } + append('}') + } + is JsonArray -> { + append('[') + element.forEachIndexed { idx, v -> + if (idx > 0) append(',') + appendSorted(v) + } + append(']') + } + is kotlinx.serialization.json.JsonPrimitive -> { + // Normalize: whole-number floats (e.g. "10.0") compare equal to integers ("10"). + // This handles the Kotlin Double serialization case where @format-float fields + // like SessionInputNumberQuestion.min serialize 10 as 10.0. + // Null vs absent is NOT normalized: null primitives stay as "null". + if (element.isString) { + append(element.toString()) + } else { + val raw = element.content + val asDouble = raw.toDoubleOrNull() + if (asDouble != null && asDouble.isFinite() && asDouble % 1.0 == 0.0) { + val asLong = asDouble.toLong() + append(asLong.toString()) + } else { + append(raw) + } + } + } + } + } +} diff --git a/clients/rust/CHANGELOG.md b/clients/rust/CHANGELOG.md index 120512d0..a3ccca44 100644 --- a/clients/rust/CHANGELOG.md +++ b/clients/rust/CHANGELOG.md @@ -15,6 +15,27 @@ matching `## [X.Y.Z]` heading is missing from this file. ## [Unreleased] +### Changed + +- **BREAKING:** `SessionStatus` is now a `u32` bitset newtype + (`struct SessionStatus(pub u32)` with named flag constants) instead of a + `#[repr(u32)]` enum. The wire form is a numeric bitset, so the enum could not + represent combined flags (e.g. `InProgress | IsArchived`) or preserve unknown + forward-compat bits. Combine flags with `|` and test with `contains(..)`. +- **BREAKING:** `ChangesetOperationTarget`'s range target now carries a nested + `TextRange` (`{start: {line, character}, end: {line, character}}`) instead of + a flat `{start, end}` integer pair. + +### Fixed + +- `SessionStatus` encode/decode fidelity: combined and unknown bitset bits now + round-trip exactly instead of being dropped or rejected. +- `ActionEnvelope.origin` is now omitted from serialized output when absent + (`#[serde(skip_serializing_if = "Option::is_none")]`) instead of serializing + as `null`. +- Session reducers now apply `_meta` (`meta`) updates from every + tool-call-scoped action, not only `session/toolCallStart`. + ### Added - `SnapshotState::ResourceWatch` variant and matching @@ -24,11 +45,6 @@ matching `## [X.Y.Z]` heading is missing from this file. terminal / changeset / annotations slots. `reset_host` / `reset` clear the new slot. -### Fixed - -- Session reducers now apply `_meta` (`meta`) updates from every - tool-call-scoped action, not only `session/toolCallStart`. - ### Added - `AnnotationsUpdatedAction` (`annotations/updated`) — partially updates an @@ -101,6 +117,7 @@ Implements AHP 0.3.0. `Client { client_id }` and `Mcp { customization_id }` variants). `SessionToolCallStartAction` carries the new `contributor` field as well. The reducer follows the rename. + ## [0.2.0] — 2026-05-28 Implements AHP `0.2.0`. Bumps the `ahp-types`, `ahp`, and `ahp-ws` crates diff --git a/clients/rust/crates/ahp-types/src/actions.rs b/clients/rust/crates/ahp-types/src/actions.rs index 6e34003d..b389c2b4 100644 --- a/clients/rust/crates/ahp-types/src/actions.rs +++ b/clients/rust/crates/ahp-types/src/actions.rs @@ -188,6 +188,7 @@ pub struct ActionEnvelope { pub channel: Uri, pub action: StateAction, pub server_seq: u64, + #[serde(default, skip_serializing_if = "Option::is_none")] pub origin: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub rejection_reason: Option, diff --git a/clients/rust/crates/ahp-types/src/commands.rs b/clients/rust/crates/ahp-types/src/commands.rs index a87e1626..1f197184 100644 --- a/clients/rust/crates/ahp-types/src/commands.rs +++ b/clients/rust/crates/ahp-types/src/commands.rs @@ -1079,12 +1079,6 @@ pub enum ChangesetOperationTarget { resource: Uri, #[serde(default, skip_serializing_if = "Option::is_none")] side: Option, - range: ChangesetOperationTargetRange, + range: TextRange, }, } - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct ChangesetOperationTargetRange { - pub start: i64, - pub end: i64, -} diff --git a/clients/rust/crates/ahp-types/src/lib.rs b/clients/rust/crates/ahp-types/src/lib.rs index 74cff5ff..e16ddb03 100644 --- a/clients/rust/crates/ahp-types/src/lib.rs +++ b/clients/rust/crates/ahp-types/src/lib.rs @@ -42,8 +42,7 @@ //! let json = r#"{ //! "channel": "ahp-session:/s1", //! "action": { "type": "session/titleChanged", "title": "Hi" }, -//! "serverSeq": 7, -//! "origin": null +//! "serverSeq": 7 //! }"#; //! let env: ActionEnvelope = serde_json::from_str(json)?; //! assert_eq!(env.server_seq, 7); @@ -88,12 +87,19 @@ //! [`SessionStatus`](state::SessionStatus) packs activity and metadata //! flags into a single value — use bitwise checks rather than equality: //! +//! `SessionStatus` is a `u32` bitset newtype: combine flags with `|`, test +//! membership with [`contains`](state::SessionStatus::contains), and read the +//! raw value (including unknown/forward-compat bits) with +//! [`bits`](state::SessionStatus::bits). +//! //! ``` //! use ahp_types::state::SessionStatus; //! -//! let status = SessionStatus::InProgress as u32 | SessionStatus::IsArchived as u32; -//! assert_ne!(status & SessionStatus::InProgress as u32, 0); -//! assert_ne!(status & SessionStatus::IsArchived as u32, 0); +//! let status = SessionStatus::InProgress | SessionStatus::IsArchived; +//! assert!(status.contains(SessionStatus::InProgress)); +//! assert!(status.contains(SessionStatus::IsArchived)); +//! assert!(!status.contains(SessionStatus::Idle)); +//! assert_eq!(status.bits(), 8 | 64); //! ``` //! //! # Compatibility diff --git a/clients/rust/crates/ahp-types/src/state.rs b/clients/rust/crates/ahp-types/src/state.rs index 5e8677e7..5395b684 100644 --- a/clients/rust/crates/ahp-types/src/state.rs +++ b/clients/rust/crates/ahp-types/src/state.rs @@ -51,21 +51,90 @@ pub enum SessionLifecycle { /// Use bitwise checks instead of equality for non-terminal activity. For example, /// `status & SessionStatus.InProgress` matches both ordinary in-progress turns /// and turns that are paused waiting for input. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize_repr, Deserialize_repr)] -#[repr(u32)] -pub enum SessionStatus { +/// +/// Wire form: a bare `u32` bitset. Unknown/forward-compat bits are +/// preserved across a decode→encode round-trip. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)] +#[serde(transparent)] +pub struct SessionStatus(pub u32); + +#[allow(non_upper_case_globals)] +impl SessionStatus { /// Session is idle — no turn is active. - Idle = 1, + pub const Idle: SessionStatus = SessionStatus(1); /// Session ended with an error. - Error = 2, + pub const Error: SessionStatus = SessionStatus(2); /// A turn is actively streaming. - InProgress = 8, + pub const InProgress: SessionStatus = SessionStatus(8); /// A turn is in progress but blocked waiting for user input or tool confirmation. - InputNeeded = 24, + pub const InputNeeded: SessionStatus = SessionStatus(24); /// The client has viewed this session since its last modification. - IsRead = 32, + pub const IsRead: SessionStatus = SessionStatus(32); /// The session has been archived by the client. - IsArchived = 64, + pub const IsArchived: SessionStatus = SessionStatus(64); + + /// The raw `u32` bitset value (every set bit, known or not). + #[inline] + pub const fn bits(self) -> u32 { + self.0 + } + + /// Wrap a raw `u32` bitset value, preserving every bit verbatim. + #[inline] + pub const fn from_bits(bits: u32) -> Self { + SessionStatus(bits) + } + + /// True when every bit set in `other` is also set in `self`. + #[inline] + pub const fn contains(self, other: SessionStatus) -> bool { + (self.0 & other.0) == other.0 + } +} + +impl From for SessionStatus { + #[inline] + fn from(value: u32) -> Self { + SessionStatus(value) + } +} + +impl From for u32 { + #[inline] + fn from(value: SessionStatus) -> Self { + value.0 + } +} + +impl std::ops::BitOr for SessionStatus { + type Output = SessionStatus; + #[inline] + fn bitor(self, rhs: SessionStatus) -> SessionStatus { + SessionStatus(self.0 | rhs.0) + } +} + +impl std::ops::BitOrAssign for SessionStatus { + #[inline] + fn bitor_assign(&mut self, rhs: SessionStatus) { + self.0 |= rhs.0; + } +} + +impl std::ops::BitAnd for SessionStatus { + type Output = SessionStatus; + #[inline] + fn bitand(self, rhs: SessionStatus) -> SessionStatus { + SessionStatus(self.0 & rhs.0) + } +} + +impl std::ops::Not for SessionStatus { + type Output = SessionStatus; + #[inline] + fn not(self) -> SessionStatus { + SessionStatus(!self.0) + } } /// Answer lifecycle state. diff --git a/clients/rust/crates/ahp-types/tests/roundtrip_corpus.rs b/clients/rust/crates/ahp-types/tests/roundtrip_corpus.rs new file mode 100644 index 00000000..e8aa2bf8 --- /dev/null +++ b/clients/rust/crates/ahp-types/tests/roundtrip_corpus.rs @@ -0,0 +1,299 @@ +// roundtrip_corpus.rs — data-driven wire round-trip parity for the Rust client. +// +// Loads the SHARED, language-agnostic round-trip corpus under +// types/test-cases/round-trips/*.json (the same fixtures the Go, Swift, +// TypeScript, and Kotlin clients run) and asserts each via the REAL generated +// Rust wire types — serde + serde_json, the real discriminated-union +// serde(tag) dispatch, the real SessionStatus bitset. +// No mocks, no faked SUT: every fixture decodes real bytes into a real type and +// re-encodes with serde_json. +// +// Each fixture has the shape: +// { "name": ..., "description": ..., "group": ..., "type": ..., +// "input": , +// "acceptableOutputs": [ ], +// "typescriptOutput": } +// +// Group A: all clients agree — assert acceptableOutputs[0]. +// Group B: runtime-decoder clients drop unknown keys — assert acceptableOutputs[0]. +// (TypeScript asserts typescriptOutput instead; irrelevant to Rust.) +// Rust is always a runtime decoder → always asserts acceptableOutputs[0]. +// +// Run: cargo test roundtrip (from clients/rust) +// +// Real-execution: no mocks. Every fixture decodes with serde_json into the real +// generated types and re-encodes with serde_json::to_string. + +use ahp_types::{ + actions::{ActionEnvelope, StateAction}, + commands::ChangesetOperationTarget, + common::StringOrMarkdown, + messages::JsonRpcMessage, + notifications::{PartialSessionSummary, SessionAddedParams}, + state::{Customization, SessionInputQuestion, SessionStatus, SessionSummary}, + version::{PROTOCOL_VERSION, SUPPORTED_PROTOCOL_VERSIONS}, +}; +use serde_json::{Number, Value}; +use std::{collections::BTreeMap, fs, path::PathBuf}; + +// ─── Fixture directory ─────────────────────────────────────────────────────── + +/// Walks upward from the test binary directory looking for `types/test-cases/round-trips`. +fn find_fixture_dir() -> PathBuf { + // Under `cargo test`, the binary typically lives under + // clients/rust/target/debug/deps/ — walk up to find repo root. + let mut dir = + std::env::current_dir().expect("current_dir should be accessible under cargo test"); + loop { + let candidate = dir.join("types").join("test-cases").join("round-trips"); + if candidate.is_dir() { + return candidate; + } + let parent = dir.parent().expect("walked all the way to filesystem root without finding types/test-cases/round-trips").to_path_buf(); + dir = parent; + } +} + +// ─── Fixture shape ─────────────────────────────────────────────────────────── + +#[derive(serde::Deserialize)] +struct RoundTripFixture { + #[allow(dead_code)] + name: Option, + #[allow(dead_code)] + description: Option, + /// "A" = all clients agree; "B" = runtime-decoders drop unknown keys, + /// TS preserves them. Absent is treated as "A". + #[allow(dead_code)] + group: Option, + #[serde(rename = "type")] + type_name: String, + input: Value, + #[serde(rename = "acceptableOutputs")] + acceptable_outputs: Vec, + /// TypeScript-specific expected output for group B (unused by Rust). + #[serde(rename = "typescriptOutput")] + #[allow(dead_code)] + typescript_output: Option, + /// Legacy skip list. Rust never appears here; parsed for completeness. + #[serde(rename = "notApplicable")] + not_applicable: Option>, +} + +// ─── Main test ─────────────────────────────────────────────────────────────── + +#[test] +fn roundtrip_corpus() { + let fixture_dir = find_fixture_dir(); + let mut entries: Vec<_> = fs::read_dir(&fixture_dir) + .unwrap_or_else(|e| panic!("cannot read fixture dir {:?}: {}", fixture_dir, e)) + .filter_map(|e| e.ok()) + .filter(|e| e.file_name().to_string_lossy().ends_with(".json")) + .map(|e| e.path()) + .collect(); + entries.sort(); + + assert!( + !entries.is_empty(), + "No round-trip fixtures found at {:?}. Ensure the checkout includes types/test-cases/round-trips/.", + fixture_dir + ); + + let mut failures: Vec = Vec::new(); + let mut ran_real = 0usize; + + for path in &entries { + let file = path.file_name().unwrap().to_string_lossy().into_owned(); + let raw = fs::read_to_string(path).unwrap_or_else(|e| panic!("read {:?}: {}", path, e)); + + match run_fixture(&file, &raw) { + Ok(()) => ran_real += 1, + Err(msg) => failures.push(format!("✗ {}: {}", file, msg)), + } + } + + assert!( + ran_real > 0, + "No fixtures ran real assertions — check fixture dir {:?}", + fixture_dir + ); + + if !failures.is_empty() { + panic!( + "{} round-trip fixture(s) failed:\n{}", + failures.len(), + failures.join("\n") + ); + } + + println!( + "round-trip corpus: {} fixtures, {} asserted for real", + entries.len(), + ran_real + ); +} + +// ─── Per-fixture runner ─────────────────────────────────────────────────────── + +fn run_fixture(file: &str, raw: &str) -> Result<(), String> { + let fx: RoundTripFixture = + serde_json::from_str(raw).map_err(|e| format!("parse fixture: {}", e))?; + + if fx.type_name.is_empty() { + return Err("missing `type`".into()); + } + if fx.input.is_null() && !matches!(&fx.input, Value::Null) { + return Err(format!("{}: missing `input`", file)); + } + if fx.acceptable_outputs.is_empty() { + return Err(format!( + "{}: fixture made no assertions — `acceptableOutputs` is empty", + file + )); + } + // Enforce single canonical form. + if fx.acceptable_outputs.len() != 1 { + return Err(format!( + "{}: acceptableOutputs must have exactly 1 entry (the single canonical re-encoded form); got {}. \ + Multiple entries cement divergence instead of fixing it.", + file, fx.acceptable_outputs.len() + )); + } + + // Honor notApplicable (legacy). Rust is never listed there, but parse defensively. + if let Some(not_applicable) = &fx.not_applicable { + if not_applicable.iter().any(|s| s == "rust") { + eprintln!("⊘ {}: not applicable to rust (legacy notApplicable)", file); + return Ok(()); + } + } + + // Rust is a runtime decoder → always asserts acceptableOutputs[0] (both groups). + let input_json = + serde_json::to_string(&fx.input).map_err(|e| format!("re-serialize input: {}", e))?; + + let reencoded = decode_and_reencode(file, &fx.type_name, &input_json)?; + let canonical_expected = &fx.acceptable_outputs[0]; + + if canonical_equal(&reencoded, canonical_expected) { + Ok(()) + } else { + Err(format!( + "{}: re-encoded output does not match the canonical acceptableOutput.\n got: {}\n expected: {}", + file, + serde_json::to_string(&reencoded).unwrap_or_else(|_| "".into()), + serde_json::to_string(canonical_expected).unwrap_or_else(|_| "".into()), + )) + } +} + +// ─── Real decode dispatch ──────────────────────────────────────────────────── + +/// Decodes `input_json` into the real generated Rust type named by `type_name` +/// and re-encodes with serde_json. Adding a wire type to the corpus requires a +/// deliberate edit here — the corpus never decodes arbitrary types reflectively. +fn decode_and_reencode(file: &str, type_name: &str, input_json: &str) -> Result { + macro_rules! round_trip { + ($T:ty) => {{ + let v: $T = serde_json::from_str(input_json) + .map_err(|e| format!("{}: decode {}: {}", file, type_name, e))?; + serde_json::to_value(&v) + .map_err(|e| format!("{}: re-encode {}: {}", file, type_name, e)) + }}; + } + + match type_name { + "ActionEnvelope" => round_trip!(ActionEnvelope), + "StateAction" => round_trip!(StateAction), + "Customization" => round_trip!(Customization), + // SessionStatus decodes via the REAL generated type — no raw-u32 sidestep. + // On the old `enum SessionStatus` this FAILS for bitset combinations and + // unknown high bits (fixtures 004/005); it passes only once the type is + // the `u32` newtype from the SessionStatus-widening change. That red→green + // is the proof the corpus actually exercises the real wire type. + "SessionStatus" => round_trip!(SessionStatus), + "StringOrMarkdown" => round_trip!(StringOrMarkdown), + "JsonRpcMessage" => round_trip!(JsonRpcMessage), + "ChangesetOperationTarget" => round_trip!(ChangesetOperationTarget), + "SessionInputQuestion" => round_trip!(SessionInputQuestion), + "SessionSummary" => round_trip!(SessionSummary), + "SessionAddedParams" => round_trip!(SessionAddedParams), + "PartialSessionSummary" => round_trip!(PartialSessionSummary), + other => Err(format!( + "{}: unknown wire type {:?}. Add a decode entry to decode_and_reencode.", + file, other + )), + } +} + +// ─── Structural JSON equality ──────────────────────────────────────────────── + +/// Compares two serde_json::Value instances structurally (key-order independent, +/// value- and key-presence sensitive). Uses a canonicalized form: numbers are +/// normalized (integer vs float with same value compare equal), objects are +/// compared as BTreeMap (sorted keys). +fn canonical_equal(a: &Value, b: &Value) -> bool { + canonical_bytes(a) == canonical_bytes(b) +} + +fn canonical_bytes(v: &Value) -> Vec { + serde_json::to_vec(&normalize(v)).expect("re-serialize for comparison") +} + +/// Normalize a Value for structural comparison: +/// - Object keys are sorted (BTreeMap order via serde_json::Map → BTreeMap). +/// - Numbers: integers and whole-number floats compare equal (10 == 10.0). +/// This handles the Rust f64 serialization case where `@format float` fields +/// like SessionInputNumberQuestion.min serialize 10 as 10.0. +fn normalize(v: &Value) -> Value { + match v { + Value::Object(map) => { + let sorted: BTreeMap<_, _> = map + .iter() + .map(|(k, val)| (k.clone(), normalize(val))) + .collect(); + Value::Object(sorted.into_iter().collect()) + } + Value::Array(arr) => Value::Array(arr.iter().map(normalize).collect()), + Value::Number(n) => { + // Normalize: if the number is representable as an exact i64, use that form. + // This handles both integer JSON numbers (e.g. 10) and whole-number floats + // (e.g. f64 10.0 serialized as JSON 10.0 by Rust serde_json). + if let Some(i) = n.as_i64() { + Value::Number(Number::from(i)) + } else if let Some(f) = n.as_f64() { + // Also check: is the float exactly representable as an integer? + if f.fract() == 0.0 && f.abs() < 9.007_199_254_740_992e15_f64 { + if let Ok(i) = i64::try_from(f as i128) { + return Value::Number(Number::from(i)); + } + } + Value::Number(Number::from_f64(f).unwrap_or(n.clone())) + } else { + Value::Number(n.clone()) + } + } + other => other.clone(), + } +} + +// ─── ProtocolVersion constants ─────────────────────────────────────────────── + +#[test] +fn protocol_version_constants() { + assert!( + !PROTOCOL_VERSION.trim().is_empty(), + "PROTOCOL_VERSION must be non-empty, got {:?}", + PROTOCOL_VERSION + ); + let supported = SUPPORTED_PROTOCOL_VERSIONS; + assert!( + !supported.is_empty(), + "SUPPORTED_PROTOCOL_VERSIONS must be non-empty" + ); + assert_eq!( + supported[0], PROTOCOL_VERSION, + "first SUPPORTED_PROTOCOL_VERSIONS entry {:?} must equal PROTOCOL_VERSION {:?}", + supported[0], PROTOCOL_VERSION + ); +} diff --git a/clients/rust/crates/ahp/src/reducers.rs b/clients/rust/crates/ahp/src/reducers.rs index b7d57853..12a5c289 100644 --- a/clients/rust/crates/ahp/src/reducers.rs +++ b/clients/rust/crates/ahp/src/reducers.rs @@ -181,7 +181,7 @@ const STATUS_ACTIVITY_MASK: u32 = (1 << 5) - 1; /// Sets or clears a metadata flag on a status value. fn with_status_flag(status: u32, flag: SessionStatus, set: bool) -> u32 { - let f = flag as u32; + let f = flag.bits(); if set { status | f } else { @@ -191,7 +191,7 @@ fn with_status_flag(status: u32, flag: SessionStatus, set: bool) -> u32 { fn summary_status(state: &SessionState, terminal: Option) -> u32 { let activity: u32 = if let Some(t) = terminal { - t as u32 + t.bits() } else if state .input_requests .as_ref() @@ -199,11 +199,11 @@ fn summary_status(state: &SessionState, terminal: Option) -> u32 .unwrap_or(false) || has_pending_tool_call_confirmation(state) { - SessionStatus::InputNeeded as u32 + SessionStatus::InputNeeded.bits() } else if state.active_turn.is_some() { - SessionStatus::InProgress as u32 + SessionStatus::InProgress.bits() } else { - SessionStatus::Idle as u32 + SessionStatus::Idle.bits() }; (state.summary.status & !STATUS_ACTIVITY_MASK) | activity } @@ -1258,7 +1258,7 @@ mod tests { resource: resource.to_string(), provider: "test".to_string(), title: String::new(), - status: SessionStatus::Idle as u32, + status: SessionStatus::Idle.bits(), activity: None, created_at: 0, modified_at: 0, @@ -1297,7 +1297,7 @@ mod tests { apply_action_to_session(&mut s, &action), ReduceOutcome::Applied ); - assert_eq!(s.summary.status, SessionStatus::InProgress as u32); + assert_eq!(s.summary.status, SessionStatus::InProgress.bits()); assert_eq!(s.active_turn.unwrap().id, "t1"); } @@ -1334,7 +1334,7 @@ mod tests { response_parts: Vec::new(), usage: None, }); - s.summary.status = SessionStatus::InProgress as u32; + s.summary.status = SessionStatus::InProgress.bits(); let a = StateAction::SessionTurnComplete(ahp_types::actions::SessionTurnCompleteAction { turn_id: "t1".into(), }); @@ -1342,7 +1342,7 @@ mod tests { assert!(s.active_turn.is_none()); assert_eq!(s.turns.len(), 1); assert_eq!(s.turns[0].state, TurnState::Complete); - assert_eq!(s.summary.status, SessionStatus::Idle as u32); + assert_eq!(s.summary.status, SessionStatus::Idle.bits()); } #[test] diff --git a/clients/rust/crates/ahp/tests/multi_host_state_mirror.rs b/clients/rust/crates/ahp/tests/multi_host_state_mirror.rs index 6b46b6b8..a9fcd7b6 100644 --- a/clients/rust/crates/ahp/tests/multi_host_state_mirror.rs +++ b/clients/rust/crates/ahp/tests/multi_host_state_mirror.rs @@ -49,7 +49,7 @@ fn session_state(title: &str, resource: &str) -> SessionState { resource: resource.into(), provider: "copilot".into(), title: title.into(), - status: SessionStatus::Idle as u32, + status: SessionStatus::Idle.bits(), activity: None, created_at: 0, modified_at: 0, @@ -358,7 +358,7 @@ fn non_action_event_is_ignored() { resource: "ahp-session:/new".into(), provider: "copilot".into(), title: "new".into(), - status: SessionStatus::Idle as u32, + status: SessionStatus::Idle.bits(), activity: None, created_at: 0, modified_at: 0, diff --git a/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/Actions.generated.swift b/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/Actions.generated.swift index a80a4215..84d3145d 100644 --- a/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/Actions.generated.swift +++ b/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/Actions.generated.swift @@ -1530,7 +1530,10 @@ public enum StateAction: Codable, Sendable { case terminalCommandFinished(TerminalCommandFinishedAction) case resourceWatchChanged(ResourceWatchChangedAction) /// Unknown or future action type; reducers treat this as a no-op. - case unknown(type: String) + /// The raw payload (including its `type` discriminant) is preserved + /// as an `AnyCodable` so a decode→encode round-trip re-emits it + /// verbatim for forward-compatibility (mirrors .NET allowUnknown). + case unknown(AnyCodable) private enum TypeKey: String, CodingKey { case type } @@ -1675,7 +1678,7 @@ public enum StateAction: Codable, Sendable { case "resourceWatch/changed": self = .resourceWatchChanged(try ResourceWatchChangedAction(from: decoder)) default: - self = .unknown(type: type) + self = .unknown(try AnyCodable(from: decoder)) } } @@ -1749,7 +1752,7 @@ public enum StateAction: Codable, Sendable { case .terminalCommandExecuted(let v): try v.encode(to: encoder) case .terminalCommandFinished(let v): try v.encode(to: encoder) case .resourceWatchChanged(let v): try v.encode(to: encoder) - case .unknown: break + case .unknown(let value): try value.encode(to: encoder) } } } diff --git a/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/Commands.generated.swift b/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/Commands.generated.swift index 922d1e6f..ce29aec0 100644 --- a/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/Commands.generated.swift +++ b/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/Commands.generated.swift @@ -1261,30 +1261,44 @@ public struct ChangesetOperationResourceTarget: Codable, Sendable { self.side = side } + // kind is the union discriminant: a fixed constant for this variant (so it + // is NOT decoded from the wire — the union already dispatched on it), but it + // MUST be re-emitted on encode so the wire stays a decodable + // discriminated-union value. Hence the custom encode and the decode-only + // CodingKeys that omit kind. private enum CodingKeys: String, CodingKey { case resource, side } + private enum EncodingKeys: String, CodingKey { case kind, resource, side } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: EncodingKeys.self) + try container.encode(kind, forKey: .kind) + try container.encode(resource, forKey: .resource) + try container.encodeIfPresent(side, forKey: .side) + } } public struct ChangesetOperationRangeTarget: Codable, Sendable { public var kind: String { "range" } public var resource: String public var side: String? - public var range: ChangesetOperationTargetRange + public var range: TextRange - public init(resource: String, side: String? = nil, range: ChangesetOperationTargetRange) { + public init(resource: String, side: String? = nil, range: TextRange) { self.resource = resource self.side = side self.range = range } + // See ChangesetOperationResourceTarget: kind is re-emitted on encode but + // not decoded (the union dispatches on it). private enum CodingKeys: String, CodingKey { case resource, side, range } -} - -public struct ChangesetOperationTargetRange: Codable, Sendable { - public var start: Int - public var end: Int + private enum EncodingKeys: String, CodingKey { case kind, resource, side, range } - public init(start: Int, end: Int) { - self.start = start - self.end = end + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: EncodingKeys.self) + try container.encode(kind, forKey: .kind) + try container.encode(resource, forKey: .resource) + try container.encodeIfPresent(side, forKey: .side) + try container.encode(range, forKey: .range) } } diff --git a/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/State.generated.swift b/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/State.generated.swift index ba335d04..f2841874 100644 --- a/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/State.generated.swift +++ b/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/State.generated.swift @@ -68,8 +68,8 @@ public enum SessionLifecycle: String, Codable, Sendable { /// `status & SessionStatus.InProgress` matches both ordinary in-progress turns /// and turns that are paused waiting for input. public struct SessionStatus: OptionSet, Codable, Sendable, Hashable { - public let rawValue: Int - public init(rawValue: Int) { self.rawValue = rawValue } + public let rawValue: UInt32 + public init(rawValue: UInt32) { self.rawValue = rawValue } /// Session is idle — no turn is active. public static let idle = SessionStatus(rawValue: 1) @@ -3884,6 +3884,9 @@ public enum ResponsePart: Codable, Sendable { case toolCall(ToolCallResponsePart) case reasoning(ReasoningResponsePart) case systemNotification(SystemNotificationResponsePart) + /// Unknown or future discriminant; the raw payload is preserved + /// and re-encoded verbatim for forward-compatibility. + case unknown(AnyCodable) private enum DiscriminantKey: String, CodingKey { case discriminant = "kind" @@ -3904,7 +3907,7 @@ public enum ResponsePart: Codable, Sendable { case "systemNotification": self = .systemNotification(try SystemNotificationResponsePart(from: decoder)) default: - throw DecodingError.dataCorruptedError(forKey: .discriminant, in: container, debugDescription: "Unknown ResponsePart discriminant: \(discriminant)") + self = .unknown(try AnyCodable(from: decoder)) } } @@ -3915,6 +3918,7 @@ public enum ResponsePart: Codable, Sendable { case .toolCall(let value): try value.encode(to: encoder) case .reasoning(let value): try value.encode(to: encoder) case .systemNotification(let value): try value.encode(to: encoder) + case .unknown(let value): try value.encode(to: encoder) } } } @@ -3926,6 +3930,9 @@ public enum ToolCallState: Codable, Sendable { case pendingResultConfirmation(ToolCallPendingResultConfirmationState) case completed(ToolCallCompletedState) case cancelled(ToolCallCancelledState) + /// Unknown or future discriminant; the raw payload is preserved + /// and re-encoded verbatim for forward-compatibility. + case unknown(AnyCodable) private enum DiscriminantKey: String, CodingKey { case discriminant = "status" @@ -3948,7 +3955,7 @@ public enum ToolCallState: Codable, Sendable { case "cancelled": self = .cancelled(try ToolCallCancelledState(from: decoder)) default: - throw DecodingError.dataCorruptedError(forKey: .discriminant, in: container, debugDescription: "Unknown ToolCallState discriminant: \(discriminant)") + self = .unknown(try AnyCodable(from: decoder)) } } @@ -3960,6 +3967,7 @@ public enum ToolCallState: Codable, Sendable { case .pendingResultConfirmation(let value): try value.encode(to: encoder) case .completed(let value): try value.encode(to: encoder) case .cancelled(let value): try value.encode(to: encoder) + case .unknown(let value): try value.encode(to: encoder) } } } @@ -3967,6 +3975,9 @@ public enum ToolCallState: Codable, Sendable { public enum TerminalClaim: Codable, Sendable { case client(TerminalClientClaim) case session(TerminalSessionClaim) + /// Unknown or future discriminant; the raw payload is preserved + /// and re-encoded verbatim for forward-compatibility. + case unknown(AnyCodable) private enum DiscriminantKey: String, CodingKey { case discriminant = "kind" @@ -3981,7 +3992,7 @@ public enum TerminalClaim: Codable, Sendable { case "session": self = .session(try TerminalSessionClaim(from: decoder)) default: - throw DecodingError.dataCorruptedError(forKey: .discriminant, in: container, debugDescription: "Unknown TerminalClaim discriminant: \(discriminant)") + self = .unknown(try AnyCodable(from: decoder)) } } @@ -3989,6 +4000,7 @@ public enum TerminalClaim: Codable, Sendable { switch self { case .client(let value): try value.encode(to: encoder) case .session(let value): try value.encode(to: encoder) + case .unknown(let value): try value.encode(to: encoder) } } } @@ -3996,6 +4008,9 @@ public enum TerminalClaim: Codable, Sendable { public enum TerminalContentPart: Codable, Sendable { case unclassified(TerminalUnclassifiedPart) case command(TerminalCommandPart) + /// Unknown or future discriminant; the raw payload is preserved + /// and re-encoded verbatim for forward-compatibility. + case unknown(AnyCodable) private enum DiscriminantKey: String, CodingKey { case discriminant = "type" @@ -4010,7 +4025,7 @@ public enum TerminalContentPart: Codable, Sendable { case "command": self = .command(try TerminalCommandPart(from: decoder)) default: - throw DecodingError.dataCorruptedError(forKey: .discriminant, in: container, debugDescription: "Unknown TerminalContentPart discriminant: \(discriminant)") + self = .unknown(try AnyCodable(from: decoder)) } } @@ -4018,6 +4033,7 @@ public enum TerminalContentPart: Codable, Sendable { switch self { case .unclassified(let value): try value.encode(to: encoder) case .command(let value): try value.encode(to: encoder) + case .unknown(let value): try value.encode(to: encoder) } } } @@ -4029,6 +4045,9 @@ public enum SessionInputQuestion: Codable, Sendable { case boolean(SessionInputBooleanQuestion) case singleSelect(SessionInputSingleSelectQuestion) case multiSelect(SessionInputMultiSelectQuestion) + /// Unknown or future discriminant; the raw payload is preserved + /// and re-encoded verbatim for forward-compatibility. + case unknown(AnyCodable) private enum DiscriminantKey: String, CodingKey { case discriminant = "kind" @@ -4051,7 +4070,7 @@ public enum SessionInputQuestion: Codable, Sendable { case "multi-select": self = .multiSelect(try SessionInputMultiSelectQuestion(from: decoder)) default: - throw DecodingError.dataCorruptedError(forKey: .discriminant, in: container, debugDescription: "Unknown SessionInputQuestion discriminant: \(discriminant)") + self = .unknown(try AnyCodable(from: decoder)) } } @@ -4063,6 +4082,7 @@ public enum SessionInputQuestion: Codable, Sendable { case .boolean(let value): try value.encode(to: encoder) case .singleSelect(let value): try value.encode(to: encoder) case .multiSelect(let value): try value.encode(to: encoder) + case .unknown(let value): try value.encode(to: encoder) } } } @@ -4073,6 +4093,9 @@ public enum SessionInputAnswerValue: Codable, Sendable { case boolean(SessionInputBooleanAnswerValue) case selected(SessionInputSelectedAnswerValue) case selectedMany(SessionInputSelectedManyAnswerValue) + /// Unknown or future discriminant; the raw payload is preserved + /// and re-encoded verbatim for forward-compatibility. + case unknown(AnyCodable) private enum DiscriminantKey: String, CodingKey { case discriminant = "kind" @@ -4093,7 +4116,7 @@ public enum SessionInputAnswerValue: Codable, Sendable { case "selected-many": self = .selectedMany(try SessionInputSelectedManyAnswerValue(from: decoder)) default: - throw DecodingError.dataCorruptedError(forKey: .discriminant, in: container, debugDescription: "Unknown SessionInputAnswerValue discriminant: \(discriminant)") + self = .unknown(try AnyCodable(from: decoder)) } } @@ -4104,6 +4127,7 @@ public enum SessionInputAnswerValue: Codable, Sendable { case .boolean(let value): try value.encode(to: encoder) case .selected(let value): try value.encode(to: encoder) case .selectedMany(let value): try value.encode(to: encoder) + case .unknown(let value): try value.encode(to: encoder) } } } @@ -4112,6 +4136,9 @@ public enum SessionInputAnswer: Codable, Sendable { case draft(SessionInputAnswered) case submitted(SessionInputAnswered) case skipped(SessionInputSkipped) + /// Unknown or future discriminant; the raw payload is preserved + /// and re-encoded verbatim for forward-compatibility. + case unknown(AnyCodable) private enum DiscriminantKey: String, CodingKey { case discriminant = "state" @@ -4128,7 +4155,7 @@ public enum SessionInputAnswer: Codable, Sendable { case "skipped": self = .skipped(try SessionInputSkipped(from: decoder)) default: - throw DecodingError.dataCorruptedError(forKey: .discriminant, in: container, debugDescription: "Unknown SessionInputAnswer discriminant: \(discriminant)") + self = .unknown(try AnyCodable(from: decoder)) } } @@ -4137,6 +4164,7 @@ public enum SessionInputAnswer: Codable, Sendable { case .draft(let value): try value.encode(to: encoder) case .submitted(let value): try value.encode(to: encoder) case .skipped(let value): try value.encode(to: encoder) + case .unknown(let value): try value.encode(to: encoder) } } } @@ -4146,6 +4174,9 @@ public enum MessageAttachment: Codable, Sendable { case embeddedResource(MessageEmbeddedResourceAttachment) case resource(MessageResourceAttachment) case annotations(MessageAnnotationsAttachment) + /// Unknown or future discriminant; the raw payload is preserved + /// and re-encoded verbatim for forward-compatibility. + case unknown(AnyCodable) private enum DiscriminantKey: String, CodingKey { case discriminant = "type" @@ -4164,7 +4195,7 @@ public enum MessageAttachment: Codable, Sendable { case "annotations": self = .annotations(try MessageAnnotationsAttachment(from: decoder)) default: - throw DecodingError.dataCorruptedError(forKey: .discriminant, in: container, debugDescription: "Unknown MessageAttachment discriminant: \(discriminant)") + self = .unknown(try AnyCodable(from: decoder)) } } @@ -4174,6 +4205,7 @@ public enum MessageAttachment: Codable, Sendable { case .embeddedResource(let value): try value.encode(to: encoder) case .resource(let value): try value.encode(to: encoder) case .annotations(let value): try value.encode(to: encoder) + case .unknown(let value): try value.encode(to: encoder) } } } @@ -4182,6 +4214,9 @@ public enum Customization: Codable, Sendable { case plugin(PluginCustomization) case directory(DirectoryCustomization) case mcpServer(McpServerCustomization) + /// Unknown or future discriminant; the raw payload is preserved + /// and re-encoded verbatim for forward-compatibility. + case unknown(AnyCodable) private enum DiscriminantKey: String, CodingKey { case discriminant = "type" @@ -4198,7 +4233,7 @@ public enum Customization: Codable, Sendable { case "mcpServer": self = .mcpServer(try McpServerCustomization(from: decoder)) default: - throw DecodingError.dataCorruptedError(forKey: .discriminant, in: container, debugDescription: "Unknown Customization discriminant: \(discriminant)") + self = .unknown(try AnyCodable(from: decoder)) } } @@ -4207,6 +4242,7 @@ public enum Customization: Codable, Sendable { case .plugin(let value): try value.encode(to: encoder) case .directory(let value): try value.encode(to: encoder) case .mcpServer(let value): try value.encode(to: encoder) + case .unknown(let value): try value.encode(to: encoder) } } } @@ -4218,6 +4254,9 @@ public enum ChildCustomization: Codable, Sendable { case rule(RuleCustomization) case hook(HookCustomization) case mcpServer(McpServerCustomization) + /// Unknown or future discriminant; the raw payload is preserved + /// and re-encoded verbatim for forward-compatibility. + case unknown(AnyCodable) private enum DiscriminantKey: String, CodingKey { case discriminant = "type" @@ -4240,7 +4279,7 @@ public enum ChildCustomization: Codable, Sendable { case "mcpServer": self = .mcpServer(try McpServerCustomization(from: decoder)) default: - throw DecodingError.dataCorruptedError(forKey: .discriminant, in: container, debugDescription: "Unknown ChildCustomization discriminant: \(discriminant)") + self = .unknown(try AnyCodable(from: decoder)) } } @@ -4252,6 +4291,7 @@ public enum ChildCustomization: Codable, Sendable { case .rule(let value): try value.encode(to: encoder) case .hook(let value): try value.encode(to: encoder) case .mcpServer(let value): try value.encode(to: encoder) + case .unknown(let value): try value.encode(to: encoder) } } } @@ -4261,6 +4301,9 @@ public enum CustomizationLoadState: Codable, Sendable { case loaded(CustomizationLoadedState) case degraded(CustomizationDegradedState) case error(CustomizationErrorState) + /// Unknown or future discriminant; the raw payload is preserved + /// and re-encoded verbatim for forward-compatibility. + case unknown(AnyCodable) private enum DiscriminantKey: String, CodingKey { case discriminant = "kind" @@ -4279,7 +4322,7 @@ public enum CustomizationLoadState: Codable, Sendable { case "error": self = .error(try CustomizationErrorState(from: decoder)) default: - throw DecodingError.dataCorruptedError(forKey: .discriminant, in: container, debugDescription: "Unknown CustomizationLoadState discriminant: \(discriminant)") + self = .unknown(try AnyCodable(from: decoder)) } } @@ -4289,6 +4332,7 @@ public enum CustomizationLoadState: Codable, Sendable { case .loaded(let value): try value.encode(to: encoder) case .degraded(let value): try value.encode(to: encoder) case .error(let value): try value.encode(to: encoder) + case .unknown(let value): try value.encode(to: encoder) } } } @@ -4370,6 +4414,9 @@ public enum ToolResultContent: Codable, Sendable { case fileEdit(ToolResultFileEditContent) case terminal(ToolResultTerminalContent) case subagent(ToolResultSubagentContent) + /// Unknown or future tool result content type; the raw payload is preserved + /// and re-encoded verbatim for forward-compatibility. + case unknown(AnyCodable) private enum Keys: String, CodingKey { case type @@ -4392,10 +4439,7 @@ public enum ToolResultContent: Codable, Sendable { case "subagent": self = .subagent(try ToolResultSubagentContent(from: decoder)) default: - throw DecodingError.dataCorruptedError( - forKey: .type, in: container, - debugDescription: "Unknown ToolResultContent type: \(type)" - ) + self = .unknown(try AnyCodable(from: decoder)) } } else { throw DecodingError.dataCorrupted( @@ -4413,6 +4457,7 @@ public enum ToolResultContent: Codable, Sendable { case .fileEdit(let v): try v.encode(to: encoder) case .terminal(let v): try v.encode(to: encoder) case .subagent(let v): try v.encode(to: encoder) + case .unknown(let v): try v.encode(to: encoder) } } } diff --git a/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/NativeReducer.swift b/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/NativeReducer.swift index 2afac4ce..2ebe4bff 100644 --- a/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/NativeReducer.swift +++ b/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/NativeReducer.swift @@ -827,6 +827,13 @@ func customizationId(_ c: Customization) -> String { case .plugin(let p): return p.id case .directory(let d): return d.id case .mcpServer(let m): return m.id + // Unknown/future customization: recover the `id` from the preserved raw + // payload if present (forward-compat), else an empty id. + case .unknown(let raw): + if let obj = raw.value as? [String: Any], let id = obj["id"] as? String { + return id + } + return "" } } @@ -838,6 +845,13 @@ func childId(_ c: ChildCustomization) -> String { case .rule(let x): return x.id case .hook(let x): return x.id case .mcpServer(let x): return x.id + // Unknown/future child customization: recover `id` from the preserved raw + // payload if present (forward-compat), else an empty id. + case .unknown(let raw): + if let obj = raw.value as? [String: Any], let id = obj["id"] as? String { + return id + } + return "" } } @@ -846,6 +860,8 @@ func customizationChildren(_ c: Customization) -> [ChildCustomization]? { case .plugin(let p): return p.children case .directory(let d): return d.children case .mcpServer: return nil + // Unknown/future customization: no typed children to expose. + case .unknown: return nil } } @@ -857,7 +873,9 @@ func setCustomizationChildren(_ c: inout Customization, _ children: [ChildCustom case .directory(var d): d.children = children c = .directory(d) - case .mcpServer: + // mcpServer has no typed children; unknown/future customization is an + // opaque payload — nothing to mutate in either case. + case .mcpServer, .unknown: break } } @@ -873,6 +891,9 @@ func setCustomizationEnabled(_ c: inout Customization, _ enabled: Bool) { case .mcpServer(var m): m.enabled = enabled c = .mcpServer(m) + // Unknown/future customization: opaque payload, nothing to mutate. + case .unknown: + break } } diff --git a/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/ToolCallStateExtensions.swift b/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/ToolCallStateExtensions.swift index dc152560..61c97019 100644 --- a/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/ToolCallStateExtensions.swift +++ b/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/ToolCallStateExtensions.swift @@ -9,6 +9,7 @@ import Foundation extension ToolCallState { /// The unique identifier for this tool call, regardless of its lifecycle state. + /// Returns an empty string for unknown future variants (forward-compat). public var toolCallId: String { switch self { case .streaming(let s): return s.toolCallId @@ -17,6 +18,7 @@ extension ToolCallState { case .pendingResultConfirmation(let s): return s.toolCallId case .completed(let s): return s.toolCallId case .cancelled(let s): return s.toolCallId + case .unknown: return "" } } } @@ -34,6 +36,8 @@ public struct ToolCallBaseFields: Sendable { extension ToolCallState { /// Extracts the common base fields from any tool call state variant. + /// Calling this on `.unknown` is a programming error: all callers + /// guard the known variants before accessing baseFields. public var baseFields: ToolCallBaseFields { switch self { case .streaming(let s): @@ -54,6 +58,9 @@ extension ToolCallState { case .cancelled(let s): return ToolCallBaseFields(toolCallId: s.toolCallId, toolName: s.toolName, displayName: s.displayName, contributor: s.contributor, meta: s.meta) + case .unknown: + // All callers guard on a known variant before reaching here. + preconditionFailure("baseFields called on unknown ToolCallState variant") } } } @@ -62,7 +69,7 @@ extension ToolCallState { extension ResponsePart { /// The identifier for this response part, used for targeted updates. - /// Returns `nil` for parts that don't carry an ID (e.g. contentRef). + /// Returns `nil` for parts that don't carry an ID (e.g. contentRef, unknown future parts). public var partId: String? { switch self { case .markdown(let m): return m.id @@ -70,6 +77,7 @@ extension ResponsePart { case .toolCall(let t): return t.toolCall.toolCallId case .contentRef: return nil case .systemNotification: return nil + case .unknown: return nil } } } diff --git a/clients/swift/AgentHostProtocol/Tests/AgentHostProtocolClientTests/TypesRoundTripFixtureTests.swift b/clients/swift/AgentHostProtocol/Tests/AgentHostProtocolClientTests/TypesRoundTripFixtureTests.swift new file mode 100644 index 00000000..a8523fda --- /dev/null +++ b/clients/swift/AgentHostProtocol/Tests/AgentHostProtocolClientTests/TypesRoundTripFixtureTests.swift @@ -0,0 +1,237 @@ +// TypesRoundTripFixtureTests — data-driven wire round-trip parity for Swift. +// +// Loads the SHARED, language-agnostic round-trip corpus under +// types/test-cases/round-trips/*.json and asserts each via REAL Swift Codable +// decode/encode of the corresponding generated wire type. +// +// Each fixture has the shape: +// { "name": ..., "description": ..., "type": ..., +// "input": , +// "acceptableOutputs": [ ], +// "notApplicable": [ ] } +// +// The harness decodes `input` with JSONDecoder + the real generated type named +// by `type`, re-encodes with JSONEncoder (.sortedKeys), and asserts the result +// structurally equals acceptableOutputs[0] (key-order-independent, value- and +// key-presence-sensitive). acceptableOutputs MUST have exactly one entry — +// the single intended wire form. +// +// If the fixture carries "notApplicable": ["swift"] (not expected — only the +// TypeScript structural limitation qualifies), the fixture is skipped with a note. +// +// Why this lives in AgentHostProtocolClientTests: JsonRpcMessage ships in the +// AgentHostProtocolClient module, so the four jsonrpc fixtures (008–011) need +// this test target which can import both AgentHostProtocol and +// AgentHostProtocolClient. +// +// Run: swift test (from clients/swift/AgentHostProtocol) +// +// Real-execution: no mocks. Every fixture decodes with JSONDecoder + the real +// generated types and re-encodes with JSONEncoder. + +import XCTest +import Foundation +import AgentHostProtocol +@testable import AgentHostProtocolClient + +final class TypesRoundTripFixtureTests: XCTestCase { + + // MARK: - Fixture directory + + private static let fixtureDir: URL = { + // This file: clients/swift/AgentHostProtocol/Tests/AgentHostProtocolClientTests/TypesRoundTripFixtureTests.swift + let thisFile = URL(fileURLWithPath: #filePath) + let repoRoot = thisFile + .deletingLastPathComponent() // TypesRoundTripFixtureTests.swift + .deletingLastPathComponent() // AgentHostProtocolClientTests/ + .deletingLastPathComponent() // Tests/ + .deletingLastPathComponent() // AgentHostProtocol/ + .deletingLastPathComponent() // swift/ + .deletingLastPathComponent() // clients/ + return repoRoot.appendingPathComponent("types/test-cases/round-trips") + }() + + private static func fixtureFiles() -> [String] { + let fm = FileManager.default + let files = (try? fm.contentsOfDirectory(atPath: fixtureDir.path)) ?? [] + return files.filter { $0.hasSuffix(".json") }.sorted() + } + + // MARK: - Loaded-something guard + + func testCorpusIsPresent() { + XCTAssertGreaterThan( + Self.fixtureFiles().count, 0, + "No round-trip fixtures found at \(Self.fixtureDir.path). Ensure the checkout includes types/test-cases/round-trips/." + ) + } + + // MARK: - Whole-corpus runner + + func testRoundTripCorpus() throws { + var failures: [String] = [] + var ranRealAssertions = 0 + + for file in Self.fixtureFiles() { + let url = Self.fixtureDir.appendingPathComponent(file) + let data = try Data(contentsOf: url) + + do { + if try runFixture(file: file, data: data) { + ranRealAssertions += 1 + } + } catch { + failures.append("✗ \(file): \(error)") + } + } + + // ranRealAssertions counts ONLY fixtures that ran a real assertion. A + // notApplicable-skipped fixture returns false and is not counted, so a + // corpus that is entirely skipped trips this guard instead of passing. + XCTAssertGreaterThan(ranRealAssertions, 0, "No fixtures ran real assertions.") + + if !failures.isEmpty { + XCTFail("\(failures.count) round-trip fixture(s) failed:\n" + failures.joined(separator: "\n")) + } + } + + // MARK: - Per-fixture dispatch + + /// Returns `true` if the fixture ran a real assertion, `false` if it was + /// skipped (legacy notApplicable). Throws on a real failure. + private func runFixture(file: String, data: Data) throws -> Bool { + guard let root = try JSONSerialization.jsonObject(with: data) as? [String: Any] else { + throw FixtureError.message("\(file): fixture is not a JSON object") + } + guard let type = root["type"] as? String else { + throw FixtureError.message("\(file): missing `type`") + } + guard root["input"] != nil else { + throw FixtureError.message("\(file): missing `input`") + } + guard let acceptableOutputs = root["acceptableOutputs"] as? [Any], !acceptableOutputs.isEmpty else { + throw FixtureError.message("\(file): fixture made no assertions — `acceptableOutputs` is empty or missing") + } + + // Enforce single canonical form: acceptableOutputs MUST have exactly one entry. + // Multi-form acceptance sets encode observed-but-wrong divergence as acceptable. + guard acceptableOutputs.count == 1 else { + throw FixtureError.message( + "\(file): acceptableOutputs must have exactly 1 entry (the single canonical re-encoded form); " + + "got \(acceptableOutputs.count). Multiple entries cement divergence instead of fixing it.") + } + + // Honor notApplicable: skip this client if listed. + // Legacy field — new fixtures use group:"B" + typescriptOutput instead. + if let notApplicable = root["notApplicable"] as? [String], notApplicable.contains("swift") { + print("⊘ \(file): not applicable to swift (legacy notApplicable) — \(root["description"] as? String ?? "")") + return false // SKIP — not counted as a real assertion + } + + // Group B: Swift is a runtime-decoder — it drops unknown keys → asserts acceptableOutputs[0]. + // (Group A also asserts acceptableOutputs[0]; the group field only affects the TypeScript harness.) + + // Serialize `input` back to JSON bytes so we can pass them to JSONDecoder. + let inputAny = root["input"]! + let inputData = try JSONSerialization.data(withJSONObject: inputAny, options: [.fragmentsAllowed]) + + let reencoded = try decodeAndReencode(type: type, inputData: inputData) + + // Assert re-encoded structurally equals the single canonical output. + let reObj = try JSONSerialization.jsonObject(with: reencoded.data(using: .utf8)!, options: [.fragmentsAllowed]) + if jsonStructurallyEqual(reObj, acceptableOutputs[0]) { + return true // PASS + } + + throw FixtureError.message( + "\(file): re-encoded output does not match the canonical acceptableOutput.\n" + + " got: \(reencoded)\n" + + " expected: \(acceptableOutputs[0])") + } + + // MARK: - Real decode dispatch + + private func decodeAndReencode(type: String, inputData: Data) throws -> String { + let dec = JSONDecoder() + + func reencode(_ value: T) throws -> String { + let enc = JSONEncoder() + enc.outputFormatting = [.sortedKeys, .withoutEscapingSlashes] + let out = try enc.encode(value) + return String(data: out, encoding: .utf8)! + } + + switch type { + case "ActionEnvelope": + return try reencode(dec.decode(ActionEnvelope.self, from: inputData)) + case "StateAction": + return try reencode(dec.decode(StateAction.self, from: inputData)) + case "Customization": + return try reencode(dec.decode(Customization.self, from: inputData)) + case "SessionStatus": + return try reencode(dec.decode(SessionStatus.self, from: inputData)) + case "StringOrMarkdown": + return try reencode(dec.decode(StringOrMarkdown.self, from: inputData)) + case "JsonRpcMessage": + return try reencode(dec.decode(JsonRpcMessage.self, from: inputData)) + case "ChangesetOperationTarget": + return try reencode(dec.decode(ChangesetOperationTarget.self, from: inputData)) + case "SessionInputQuestion": + return try reencode(dec.decode(SessionInputQuestion.self, from: inputData)) + case "SessionSummary": + return try reencode(dec.decode(SessionSummary.self, from: inputData)) + case "SessionAddedParams": + return try reencode(dec.decode(SessionAddedParams.self, from: inputData)) + case "PartialSessionSummary": + return try reencode(dec.decode(PartialSessionSummary.self, from: inputData)) + default: + throw FixtureError.message( + "round-trip fixture: unknown wire type \"\(type)\". Add a decode entry to decodeAndReencode.") + } + } + + // MARK: - Structural JSON equality + + /// Compares two JSON values structurally (key-order independent, value- and + /// key-presence sensitive). Uses JSONSerialization's sortedKeys serialization + /// to normalize key order before comparing bytes. + private func jsonStructurallyEqual(_ a: Any, _ b: Any) -> Bool { + guard + let ad = try? JSONSerialization.data(withJSONObject: a, options: [.sortedKeys, .fragmentsAllowed]), + let bd = try? JSONSerialization.data(withJSONObject: b, options: [.sortedKeys, .fragmentsAllowed]) + else { return false } + return ad == bd + } + + // MARK: - ProtocolVersion constant tests + // + // These checks were previously exercised via corpus fixtures 021–023 (now + // deleted from the round-trip corpus; moved here as direct assertions). + + func testProtocolVersionConstants() { + XCTAssertFalse( + PROTOCOL_VERSION.trimmingCharacters(in: .whitespaces).isEmpty, + "PROTOCOL_VERSION must be non-empty" + ) + XCTAssertFalse( + SUPPORTED_PROTOCOL_VERSIONS.isEmpty, + "SUPPORTED_PROTOCOL_VERSIONS must be non-empty" + ) + XCTAssertEqual( + SUPPORTED_PROTOCOL_VERSIONS.first, + PROTOCOL_VERSION, + "first SUPPORTED_PROTOCOL_VERSIONS entry must equal PROTOCOL_VERSION" + ) + } + + // MARK: - Errors + + private enum FixtureError: Error, CustomStringConvertible { + case message(String) + var description: String { + switch self { + case .message(let m): return m + } + } + } +} diff --git a/clients/swift/AgentHostProtocol/Tests/AgentHostProtocolTests/FixtureDrivenReducerTests.swift b/clients/swift/AgentHostProtocol/Tests/AgentHostProtocolTests/FixtureDrivenReducerTests.swift index 27665ba1..0622352c 100644 --- a/clients/swift/AgentHostProtocol/Tests/AgentHostProtocolTests/FixtureDrivenReducerTests.swift +++ b/clients/swift/AgentHostProtocol/Tests/AgentHostProtocolTests/FixtureDrivenReducerTests.swift @@ -94,25 +94,22 @@ final class FixtureDrivenReducerTests: XCTestCase { // no bare `continue` skip. // // Five of the six reducer arms are implemented (root / session / terminal / - // changeset / resourceWatch). Two kinds of gap remain: + // changeset / resourceWatch). One kind of gap remains: // - // 1. Representational gap — fixture 103 carries a response part with an - // unknown `kind` (`unknownFuturePart`), and the generated `ResponsePart` - // enum on this base throws on an unknown discriminant rather than - // preserving it. When the generated types gain a `case unknown(AnyCodable)` - // forward-compat fallback, this fixture will decode + assert and the - // tripwire above will fire, forcing the entry to be removed. + // Unimplemented-channel gap — the `annotations` channel (fixtures 210–219) + // has no Swift reducer yet; `runFixture` hits the `default` arm and throws + // `unsupportedReducer("annotations")`. (The canonical fixture-driven test + // on the base before this rewrite simply skipped the `annotations` reducer + // family with a bare `continue`; here that skip is made explicit and + // tripwired instead.) When `annotationsReducer` lands, these stems decode + // + assert and the drift tripwire forces them out of this set. // - // 2. Unimplemented-channel gap — the `annotations` channel (fixtures - // 210–219) has no Swift reducer yet; `runFixture` hits the `default` - // arm and throws `unsupportedReducer("annotations")`. (The canonical - // fixture-driven test on the base before this rewrite simply skipped the - // `annotations` reducer family with a bare `continue`; here that skip is - // made explicit and tripwired instead.) When `annotationsReducer` lands, - // these stems decode + assert and the drift tripwire forces them out of - // this set. + // The former representational gap (fixture 103 — a delta carrying a part with + // an unknown `kind`) is now CLOSED: the generated types gained a forward-compat + // `unknown` fallback (the round-trip-corpus fidelity work), so 103 decodes + + // asserts for real and has been removed from the set below — exactly the + // outcome the tripwire above was written to force. private static let knownReducerGaps: Set = [ - "103-delta-skips-parts-without-id", "210-annotations-set-appends-new-annotation", "211-annotations-set-replaces-existing-annotation", "212-annotations-removed-drops-matching-annotation", diff --git a/clients/swift/CHANGELOG.md b/clients/swift/CHANGELOG.md index f53cc391..58c1ec4f 100644 --- a/clients/swift/CHANGELOG.md +++ b/clients/swift/CHANGELOG.md @@ -17,6 +17,16 @@ the tag matches the version pinned in [`VERSION`](VERSION). ## [Unreleased] +### Changed + +- **BREAKING:** `SessionStatus` is now an `OptionSet` with a `UInt32` rawValue + (was `Int`), an unsigned 32-bit bitset that preserves combined and unknown + forward-compat bits. Combine flags with set-union (`∪` / `union`) and test + membership with `contains(_:)`. +- **BREAKING:** `ChangesetOperationTarget`'s range target now carries a nested + `TextRange` (`{start: {line, character}, end: {line, character}}`) instead of + a flat `{start, end}` integer pair. + ### Added - `SnapshotState.resourceWatch` case and matching @@ -53,6 +63,21 @@ the tag matches the version pinned in [`VERSION`](VERSION). remaining gaps (unknown-discriminant response part; the not-yet-implemented annotations channel) pinned by an explicit drift tripwire. +### Fixed + +- Encode-fidelity: an unknown `StateAction` variant no longer re-encodes to + `{}` (dropping its `type` discriminant and extra fields); the raw payload is + preserved on decode and re-emitted verbatim. +- Forward-compatibility: unknown discriminants on wire-decoded discriminated + unions (`ResponsePart`, `ToolCallState`, `TerminalClaim`, + `TerminalContentPart`, `Customization`, and other evolvable unions) now decode + to a raw passthrough and re-encode verbatim instead of throwing + `DecodingError`, so a snapshot carrying an unknown variant still decodes and + subsequent actions fold correctly. +- `ChangesetOperationResourceTarget` / `…RangeTarget` now encode their `kind` + discriminant (previously a computed property excluded from `CodingKeys`, so it + was dropped on encode). + ## [0.3.0] — 2026-06-05 Implements AHP 0.3.0. @@ -113,6 +138,7 @@ Implements AHP 0.3.0. cases). `SessionToolCallStartAction` carries the new `contributor` field as well. `Reducers.swift`, `NativeReducer.swift`, and `ToolCallStateExtensions.swift` follow the rename. + ## [0.2.0] — 2026-05-28 Implements AHP `0.2.0`. diff --git a/clients/typescript/CHANGELOG.md b/clients/typescript/CHANGELOG.md index 14a92501..d464d0dc 100644 --- a/clients/typescript/CHANGELOG.md +++ b/clients/typescript/CHANGELOG.md @@ -27,6 +27,12 @@ hotfix escape hatch. `ahp-resource-watch:` channel's descriptor alongside the existing root / session / terminal / changeset / annotations variants. +### Added + +- Shared round-trip test corpus (`test/round-trips/*.json`) used by all + language clients to assert encode/decode fidelity; the TypeScript test harness + loads and verifies each fixture. + ### Fixed - `sessionReducer` now applies `_meta` updates from every tool-call-scoped diff --git a/clients/typescript/test/types-round-trip.test.ts b/clients/typescript/test/types-round-trip.test.ts new file mode 100644 index 00000000..f744f26d --- /dev/null +++ b/clients/typescript/test/types-round-trip.test.ts @@ -0,0 +1,289 @@ +/** + * types-round-trip.test.ts — data-driven wire round-trip parity for TypeScript. + * + * Loads the SHARED, language-agnostic round-trip corpus under + * `types/test-cases/round-trips/*.json` and round-trips each through + * `JSON.parse` -> `JSON.stringify`. + * + * SCOPE — what this harness does and does NOT verify. TypeScript types are + * erased at runtime, so this exercises TypeScript's *runtime wire behavior* + * (JSON.parse/stringify) and the *fixtures' self-consistency* — NOT the + * correctness of the generated TypeScript types. `bindToType` (below) is a + * compile-time `as T` annotation that is erased at runtime, and because `parsed` + * is `unknown` it narrows nothing at compile time either; it documents the + * intended type per wire `type` but does not catch a wrong generated type + * (renamed field, wrong optionality, SessionStatus typed as a string, ...). + * Generated-type correctness is the compiler's job, covered where the types are + * consumed (reducers, client code) and by `tsc`. A wrong fixture or wrong runtime + * JSON behavior IS caught here. + * + * Each fixture has the shape: + * { "name": ..., "description": ..., "type": ..., + * "input": , + * "acceptableOutputs": [ ], + * "typescriptOutput": , + * "notApplicable": [ ] } + * + * The harness decodes `input` with `JSON.parse`, re-encodes with + * `JSON.stringify`, and asserts the result structurally equals + * acceptableOutputs[0] (key-order-independent, value- and key-presence-sensitive; + * `null` is NOT normalized to absent). acceptableOutputs MUST have exactly one + * entry — the single intended wire form. For group-"B" fixtures TS asserts + * `typescriptOutput` (unknown keys preserved) instead — see the group-B branch. + * + * Real-execution: no mocks. Every fixture round-trips through real + * `JSON.parse` / `JSON.stringify` — TypeScript's actual runtime wire path. + * + * Run: npm test (node --test --import tsx test/*.test.ts) from clients/typescript. + */ + +import test from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { + PROTOCOL_VERSION, + SUPPORTED_PROTOCOL_VERSIONS, +} from '../src/types/index.js'; +import type { + ActionEnvelope, + StateAction, +} from '../src/types/common/actions.js'; +import type { StringOrMarkdown } from '../src/types/common/state.js'; +import type { ChangesetOperationTarget } from '../src/types/channels-changeset/commands.js'; +import type { + SessionInputQuestion, + Customization, + SessionSummary, +} from '../src/types/channels-session/state.js'; +import type { SessionAddedParams } from '../src/types/channels-root/notifications.js'; + +// ─── Fixture directory ─────────────────────────────────────────────────────── + +const THIS_FILE = fileURLToPath(import.meta.url); +const REPO_ROOT = path.resolve(path.dirname(THIS_FILE), '..', '..', '..'); +const FIXTURE_DIR = path.join(REPO_ROOT, 'types', 'test-cases', 'round-trips'); + +function fixtureFiles(): string[] { + return fs + .readdirSync(FIXTURE_DIR) + .filter(f => f.endsWith('.json')) + .sort(); +} + +// ─── Fixture shape ─────────────────────────────────────────────────────────── + +interface FixtureRoot { + readonly name?: string; + readonly description?: string; + /** "A" = all clients agree; "B" = runtime-decoders drop unknown keys, TS preserves them */ + readonly group?: 'A' | 'B'; + readonly type: string; + readonly input: unknown; + readonly acceptableOutputs: unknown[]; + /** + * Group-B only: the output expected from TypeScript (which has no runtime decoder and + * preserves unknown wire keys verbatim). Harnesses running as TypeScript assert this + * instead of acceptableOutputs[0]. + */ + readonly typescriptOutput?: unknown; + /** @deprecated Use group:"B" + typescriptOutput instead. */ + readonly notApplicable?: string[]; +} + +type JsonValue = + | null + | boolean + | number + | string + | JsonValue[] + | { [key: string]: JsonValue }; + +// ─── Loaded-something guard ────────────────────────────────────────────────── + +test('round-trip corpus is present', () => { + assert.ok( + fixtureFiles().length > 0, + `No round-trip fixtures found at ${FIXTURE_DIR}. Ensure the checkout includes types/test-cases/round-trips/.`, + ); +}); + +// ─── Whole-corpus runner ───────────────────────────────────────────────────── + +test('round-trip corpus decodes + re-encodes via the real generated types', () => { + const failures: string[] = []; + let ranRealAssertions = 0; + let skippedCount = 0; + + for (const file of fixtureFiles()) { + const raw = fs.readFileSync(path.join(FIXTURE_DIR, file), 'utf-8'); + const root = JSON.parse(raw) as FixtureRoot; + + try { + const result = runFixture(file, root); + if (result === 'skipped') { + skippedCount += 1; + } else { + ranRealAssertions += 1; + } + } catch (err) { + failures.push(`✗ ${file}: ${(err as Error).message}`); + } + } + + assert.equal( + failures.length, + 0, + `${failures.length} round-trip fixture(s) failed:\n${failures.join('\n')}`, + ); + + assert.ok(ranRealAssertions > 0, 'No fixtures ran real assertions.'); + if (skippedCount > 0) { + console.log(` (${skippedCount} fixture(s) skipped via legacy notApplicable)`); + } +}); + +// ─── Per-fixture dispatch ──────────────────────────────────────────────────── + +function runFixture(file: string, root: FixtureRoot): void | 'skipped' { + const type = root.type; + if (typeof type !== 'string') { + throw new Error(`${file}: missing \`type\``); + } + if (root.input === undefined) { + throw new Error(`${file}: missing \`input\``); + } + if (!Array.isArray(root.acceptableOutputs) || root.acceptableOutputs.length === 0) { + throw new Error(`${file}: fixture made no assertions — \`acceptableOutputs\` is empty`); + } + + // Enforce single canonical form: acceptableOutputs MUST have exactly one entry. + // Multi-form acceptance sets encode observed-but-wrong divergence as acceptable. + if (root.acceptableOutputs.length !== 1) { + throw new Error( + `${file}: acceptableOutputs must have exactly 1 entry (the single canonical re-encoded form); ` + + `got ${root.acceptableOutputs.length}. Multiple entries cement divergence instead of fixing it.`, + ); + } + + // Group B: TypeScript has no runtime decoder (JSON.parse/stringify preserves unknown keys). + // TS asserts against typescriptOutput (the input preserved verbatim), NOT acceptableOutputs[0]. + // This is a documented structural exception — TypeScript DOES assert, never skips. + if (root.group === 'B') { + if (root.typescriptOutput === undefined) { + throw new Error( + `${file}: group B fixture must include a typescriptOutput field for TypeScript's expected form`, + ); + } + const inputJson = JSON.stringify(root.input); + const parsed = JSON.parse(inputJson) as unknown; + bindToType(file, type, parsed); + const reencoded = JSON.stringify(parsed); + if (canonicalJson(reencoded) === canonicalJson(JSON.stringify(root.typescriptOutput))) { + return; // PASS — TypeScript preserves unknown keys as expected + } + throw new Error( + `${file}: TypeScript re-encoded output does not match typescriptOutput.\n` + + ` got: ${reencoded}\n` + + ` expected: ${JSON.stringify(root.typescriptOutput)}`, + ); + } + + // Legacy notApplicable: skip this client if listed. Prefer group B + typescriptOutput for new fixtures. + if (Array.isArray(root.notApplicable) && root.notApplicable.includes('typescript')) { + console.log(`⊘ ${file}: not applicable to typescript (legacy notApplicable) — TypeScript has no runtime decoder; it cannot drop unknown wire keys`); + return 'skipped'; + } + + // Decode `input` with JSON.parse (round-tripping through JSON.stringify ensures + // we start from a canonical JSON representation), re-encode with JSON.stringify. + const inputJson = JSON.stringify(root.input); + const parsed = JSON.parse(inputJson) as unknown; + + // Bind to the real generated type (compile-time cast; TypeScript has no runtime decoder). + bindToType(file, type, parsed); + + const reencoded = JSON.stringify(parsed); + + // Assert the re-encoded result structurally equals the single canonical output. + if (canonicalJson(reencoded) === canonicalJson(JSON.stringify(root.acceptableOutputs[0]))) { + return; // PASS + } + + throw new Error( + `${file}: re-encoded output does not match the canonical acceptableOutput.\n` + + ` got: ${reencoded}\n` + + ` expected: ${JSON.stringify(root.acceptableOutputs[0])}`, + ); +} + +// ─── Real decode dispatch ──────────────────────────────────────────────────── +// +// Binds `parsed` to its real generated type for compile-time type-checking. +// In TypeScript "decode" is `JSON.parse(...) as T` — a structural cast — and +// "re-encode" is `JSON.stringify(...)`. Adding a wire type to the corpus is a +// deliberate edit here; the corpus never decodes arbitrary types reflectively. + +function bindToType(file: string, type: string, parsed: unknown): void { + switch (type) { + case 'ActionEnvelope': void (parsed as ActionEnvelope); break; + case 'StateAction': void (parsed as StateAction); break; + case 'Customization': void (parsed as Customization); break; + case 'SessionStatus': void (parsed as number); break; + case 'StringOrMarkdown': void (parsed as StringOrMarkdown); break; + case 'JsonRpcMessage': void (parsed as JsonValue); break; + case 'ChangesetOperationTarget': void (parsed as ChangesetOperationTarget); break; + case 'SessionInputQuestion': void (parsed as SessionInputQuestion); break; + case 'SessionSummary': void (parsed as SessionSummary); break; + case 'SessionAddedParams': void (parsed as SessionAddedParams); break; + case 'PartialSessionSummary': void (parsed as Partial); break; + default: + throw new Error( + `${file}: unknown wire type "${type}". Add a decode entry to bindToType.`, + ); + } +} + +// ─── JSON equality ──────────────────────────────────────────────────────────── + +/** Deterministic, key-sorted JSON serialization for structural comparison. */ +function canonicalJson(jsonStr: string): string { + const value = JSON.parse(jsonStr) as JsonValue; + return sortedStringify(value); +} + +function sortedStringify(value: JsonValue): string { + if (value === null || typeof value !== 'object') { + return JSON.stringify(value); + } + if (Array.isArray(value)) { + return `[${value.map(sortedStringify).join(',')}]`; + } + const keys = Object.keys(value).sort(); + return `{${keys.map(k => `${JSON.stringify(k)}:${sortedStringify(value[k])}`).join(',')}}`; +} + +// ─── ProtocolVersion constant tests ───────────────────────────────────────── +// +// These checks were previously exercised via corpus fixtures 021–023 (now +// deleted from the round-trip corpus; moved here as direct assertions). + +test('ProtocolVersion constants', () => { + assert.ok( + typeof PROTOCOL_VERSION === 'string' && PROTOCOL_VERSION.trim().length > 0, + `PROTOCOL_VERSION must be a non-empty string, got ${JSON.stringify(PROTOCOL_VERSION)}`, + ); + + assert.ok( + Array.isArray(SUPPORTED_PROTOCOL_VERSIONS) && SUPPORTED_PROTOCOL_VERSIONS.length > 0, + 'SUPPORTED_PROTOCOL_VERSIONS must be a non-empty array', + ); + + assert.equal( + SUPPORTED_PROTOCOL_VERSIONS[0], + PROTOCOL_VERSION, + `first SUPPORTED_PROTOCOL_VERSIONS entry "${SUPPORTED_PROTOCOL_VERSIONS[0]}" must equal PROTOCOL_VERSION "${PROTOCOL_VERSION}"`, + ); +}); diff --git a/scripts/generate-go.ts b/scripts/generate-go.ts index b76d6182..65802177 100644 --- a/scripts/generate-go.ts +++ b/scripts/generate-go.ts @@ -63,6 +63,7 @@ export interface GenerateGoModuleOptions { readonly allowMissingFormatter?: boolean; } + // ─── Name Mapping ──────────────────────────────────────────────────────────── /** Strips the I prefix from interface names: IRootState → RootState */ @@ -1191,6 +1192,8 @@ type SessionToolCallConfirmedAction struct { } function generateActionEnvelope(): string { + // origin is `ActionOrigin | undefined`; the `| undefined` sentinel serializes to ABSENT, + // so it omits when empty — consistent with activity/usage and every other client. return `// ActionEnvelope wraps every action with the channel URI it // belongs to, the server-assigned monotonic sequence number, and an // optional origin record. @@ -1198,7 +1201,7 @@ type ActionEnvelope struct { \tChannel URI \`json:"channel"\` \tAction StateAction \`json:"action"\` \tServerSeq int64 \`json:"serverSeq"\` -\tOrigin *ActionOrigin \`json:"origin"\` +\tOrigin *ActionOrigin \`json:"origin,omitempty"\` \tRejectionReason *string \`json:"rejectionReason,omitempty"\` }`; } @@ -1335,21 +1338,14 @@ func (*ChangesetOperationResourceTarget) isChangesetOperationTarget() {} // ChangesetOperationRangeTarget targets a range within a resource. type ChangesetOperationRangeTarget struct { -\tKind string \`json:"kind"\` -\tResource URI \`json:"resource"\` -\tSide *string \`json:"side,omitempty"\` -\tRange ChangesetOperationTargetRange \`json:"range"\` +\tKind string \`json:"kind"\` +\tResource URI \`json:"resource"\` +\tSide *string \`json:"side,omitempty"\` +\tRange TextRange \`json:"range"\` } func (*ChangesetOperationRangeTarget) isChangesetOperationTarget() {} -// ChangesetOperationTargetRange is the [start, end] index pair for a -// range target. -type ChangesetOperationTargetRange struct { -\tStart int64 \`json:"start"\` -\tEnd int64 \`json:"end"\` -} - // UnmarshalJSON dispatches on the \`kind\` discriminator. func (t *ChangesetOperationTarget) UnmarshalJSON(data []byte) error { \tdisc, _, err := readDiscriminator(data, "kind") diff --git a/scripts/generate-kotlin.ts b/scripts/generate-kotlin.ts index 86e29d7a..2a84e100 100644 --- a/scripts/generate-kotlin.ts +++ b/scripts/generate-kotlin.ts @@ -349,9 +349,17 @@ function generateKotlinEnum(enumDecl: EnumDeclaration): string { } if (isBitset) { + // Backed by `UInt`: the spec models these bitsets as unsigned 32-bit on the + // wire (the .NET reference uses `uint`, e.g. `SessionStatus : uint`). + // UInt holds the full uint32 range (0..4294967295) including the former + // sign bit 2^31 (2147483648) as a positive value, so unknown forward-compat + // bits round-trip faithfully. The companion serializer uses PrimitiveKind.INT + // + toInt()/toUInt() to convert between the JSON number and UInt. + // Verified by the shared round-trip corpus fixture + // `005-session-status-unknown-bits-preserved` (numeric 2147483720). lines.push(`@Serializable(with = ${name}Serializer::class)`); lines.push('@JvmInline'); - lines.push(`value class ${name}(val rawValue: Int) {`); + lines.push(`value class ${name}(val rawValue: UInt) {`); lines.push(` operator fun contains(other: ${name}): Boolean =`); lines.push(' (rawValue and other.rawValue) == other.rawValue'); lines.push(''); @@ -366,20 +374,25 @@ function generateKotlinEnum(enumDecl: EnumDeclaration): string { if (memberDoc) { lines.push(...emitKDoc(memberDoc, ' ')); } - lines.push(` val ${memberName}: ${name} = ${name}(${value})`); + lines.push(` val ${memberName}: ${name} = ${name}(${value}u)`); } lines.push(' }'); lines.push('}'); lines.push(''); - // Companion serializer. Bitset wire format is the raw int. + // Companion serializer. Bitset wire format is the raw unsigned 32-bit + // integer. Encode via toLong() so the JSON number is always positive (a + // UInt value ≥ 2^31 would serialize as a negative Int which is wrong). + // Decode via decodeLong().toUInt() — decodeLong() accepts any 64-bit + // integer from JSON, so values > Int.MAX_VALUE (e.g. 2147483720) parse + // correctly; toUInt() then truncates to the expected 32-bit range. lines.push(`internal object ${name}Serializer : KSerializer<${name}> {`); lines.push(` override val descriptor: SerialDescriptor =`); - lines.push(` PrimitiveSerialDescriptor("${name}", PrimitiveKind.INT)`); + lines.push(` PrimitiveSerialDescriptor("${name}", PrimitiveKind.LONG)`); lines.push(` override fun serialize(encoder: Encoder, value: ${name}) {`); - lines.push(' encoder.encodeInt(value.rawValue)'); + lines.push(' encoder.encodeLong(value.rawValue.toLong())'); lines.push(' }'); lines.push(` override fun deserialize(decoder: Decoder): ${name} =`); - lines.push(` ${name}(decoder.decodeInt())`); + lines.push(` ${name}(decoder.decodeLong().toUInt())`); lines.push('}'); return lines.join('\n'); } @@ -1322,17 +1335,11 @@ data class ChangesetOperationResourceTarget( data class ChangesetOperationRangeTarget( val resource: String, val side: String? = null, - val range: ChangesetOperationTargetRange, + val range: TextRange, /** Discriminator. Always "range". */ val kind: String = "range", ) -@Serializable -data class ChangesetOperationTargetRange( - val start: Long, - val end: Long, -) - internal object ChangesetOperationTargetSerializer : KSerializer { override val descriptor: SerialDescriptor = buildClassSerialDescriptor("ChangesetOperationTarget") diff --git a/scripts/generate-rust.ts b/scripts/generate-rust.ts index ff43ec79..7da2d353 100644 --- a/scripts/generate-rust.ts +++ b/scripts/generate-rust.ts @@ -338,6 +338,121 @@ function findEnum(project: Project, name: string): EnumDeclaration | undefined { return undefined; } +// ─── Rust Bitset Generation ────────────────────────────────────────────────── + +/** + * Emit a numeric-flag TS enum (a *bitset*, e.g. `SessionStatus`) as a `u32` + * newtype rather than a closed `#[repr(u32)]` enum. + * + * A `#[repr(u32)]` enum can only hold a value equal to one of its declared + * discriminants, so it cannot represent a *combination* of flags + * (`InProgress | IsArchived == 72`) nor forward-compatibility bits set by a + * newer host. The wire form of a bitset is a bare integer, and the round-trip + * corpus (`types/test-cases/round-trips/004,005-session-status-*.json`) + * requires that arbitrary `u32` values decode, expose their set bits, and + * re-encode unchanged — including bits this client does not recognize. + * + * The emitted newtype: + * - is `#[serde(transparent)]`, so it (de)serializes as a bare JSON number; + * - carries the TS enum members as associated `const`s (so existing + * `SessionStatus::InProgress` references keep resolving — now as a const + * of the newtype rather than an enum variant); + * - provides `bits()` / `from_bits()` / `contains()` plus the bitwise + * operators for ergonomic flag math. + */ +function generateRustBitset(enumDecl: EnumDeclaration): string { + const name = enumDecl.getName(); + const lines: string[] = []; + const desc = enumDecl.getJsDocs()[0]?.getDescription().trim(); + + if (desc) { + for (const d of desc.split('\n')) lines.push(`/// ${d.trimEnd()}`); + lines.push('///'); + } + lines.push(`/// Wire form: a bare \`u32\` bitset. Unknown/forward-compat bits are`); + lines.push(`/// preserved across a decode→encode round-trip.`); + lines.push('#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]'); + lines.push('#[serde(transparent)]'); + lines.push(`pub struct ${name}(pub u32);`); + lines.push(''); + // Associated flag constants keep the TS member names (PascalCase), which + // trips Rust's non_upper_case_globals lint; allow it on the impl block. + lines.push('#[allow(non_upper_case_globals)]'); + lines.push(`impl ${name} {`); + for (const mem of enumDecl.getMembers()) { + const doc = mem.getJsDocs()[0]?.getDescription().trim(); + if (doc) { + for (const d of doc.split('\n')) lines.push(` /// ${d.trimEnd()}`); + } + lines.push(` pub const ${mem.getName()}: ${name} = ${name}(${mem.getValue()});`); + } + lines.push(''); + lines.push(' /// The raw `u32` bitset value (every set bit, known or not).'); + lines.push(' #[inline]'); + lines.push(' pub const fn bits(self) -> u32 {'); + lines.push(' self.0'); + lines.push(' }'); + lines.push(''); + lines.push(' /// Wrap a raw `u32` bitset value, preserving every bit verbatim.'); + lines.push(' #[inline]'); + lines.push(` pub const fn from_bits(bits: u32) -> Self {`); + lines.push(` ${name}(bits)`); + lines.push(' }'); + lines.push(''); + lines.push(' /// True when every bit set in `other` is also set in `self`.'); + lines.push(' #[inline]'); + lines.push(` pub const fn contains(self, other: ${name}) -> bool {`); + lines.push(' (self.0 & other.0) == other.0'); + lines.push(' }'); + lines.push('}'); + lines.push(''); + lines.push(`impl From for ${name} {`); + lines.push(' #[inline]'); + lines.push(` fn from(value: u32) -> Self {`); + lines.push(` ${name}(value)`); + lines.push(' }'); + lines.push('}'); + lines.push(''); + lines.push(`impl From<${name}> for u32 {`); + lines.push(' #[inline]'); + lines.push(` fn from(value: ${name}) -> Self {`); + lines.push(' value.0'); + lines.push(' }'); + lines.push('}'); + lines.push(''); + lines.push(`impl std::ops::BitOr for ${name} {`); + lines.push(` type Output = ${name};`); + lines.push(' #[inline]'); + lines.push(` fn bitor(self, rhs: ${name}) -> ${name} {`); + lines.push(` ${name}(self.0 | rhs.0)`); + lines.push(' }'); + lines.push('}'); + lines.push(''); + lines.push(`impl std::ops::BitOrAssign for ${name} {`); + lines.push(' #[inline]'); + lines.push(` fn bitor_assign(&mut self, rhs: ${name}) {`); + lines.push(' self.0 |= rhs.0;'); + lines.push(' }'); + lines.push('}'); + lines.push(''); + lines.push(`impl std::ops::BitAnd for ${name} {`); + lines.push(` type Output = ${name};`); + lines.push(' #[inline]'); + lines.push(` fn bitand(self, rhs: ${name}) -> ${name} {`); + lines.push(` ${name}(self.0 & rhs.0)`); + lines.push(' }'); + lines.push('}'); + lines.push(''); + lines.push(`impl std::ops::Not for ${name} {`); + lines.push(` type Output = ${name};`); + lines.push(' #[inline]'); + lines.push(` fn not(self) -> ${name} {`); + lines.push(` ${name}(!self.0)`); + lines.push(' }'); + lines.push('}'); + return lines.join('\n'); +} + // ─── Rust Enum Generation ──────────────────────────────────────────────────── function generateRustEnum(enumDecl: EnumDeclaration): string { @@ -537,6 +652,22 @@ const STATE_ENUMS = [ 'ChangesetStatus', 'ChangesetOperationStatus', 'ChangesetOperationScope', 'ResourceChangeType', ]; +/** + * Detects *bitset* enums (flag combinations are valid wire values) via the + * same JSDoc convention the Kotlin and Swift generators use: a numeric enum + * whose leading JSDoc description starts with the word "Bitset". These are + * emitted as `u32` newtypes via {@link generateRustBitset} instead of closed + * `#[repr(u32)]` enums, so combined flags and forward-compat bits round-trip on + * the wire. Marking an enum a bitset is therefore a property of its `types/` + * declaration, not a name list maintained here (currently only `SessionStatus` + * carries the marker). + */ +function isBitsetEnum(enumDecl: EnumDeclaration): boolean { + const desc = enumDecl.getJsDocs()[0]?.getDescription().trim(); + const isNumeric = enumDecl.getMembers().every(m => typeof m.getValue() === 'number'); + return isNumeric && desc !== undefined && /^bitset\b/i.test(desc); +} + /** * State structs to emit. The order matters only for human-readability — Rust * doesn't require forward declaration. Structs that serve as variants of a @@ -870,7 +1001,10 @@ function generateStateFile(project: Project): string { for (const enumName of STATE_ENUMS) { const decl = findEnum(project, enumName); if (decl) { - lines.push(generateRustEnum(decl)); + // Numeric *flag* enums (bitsets) must be a `u32` newtype, not a closed + // `#[repr(u32)]` enum, so flag combinations and forward-compat bits + // survive the wire round-trip. See generateRustBitset. + lines.push(isBitsetEnum(decl) ? generateRustBitset(decl) : generateRustEnum(decl)); lines.push(''); } } @@ -1068,6 +1202,7 @@ pub struct ActionEnvelope { pub channel: Uri, pub action: StateAction, pub server_seq: u64, + #[serde(default, skip_serializing_if = "Option::is_none")] pub origin: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub rejection_reason: Option, @@ -1220,14 +1355,8 @@ pub enum ChangesetOperationTarget { resource: Uri, #[serde(default, skip_serializing_if = "Option::is_none")] side: Option, - range: ChangesetOperationTargetRange, + range: TextRange, }, -} - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct ChangesetOperationTargetRange { - pub start: i64, - pub end: i64, }`; } diff --git a/scripts/generate-swift.ts b/scripts/generate-swift.ts index 96c8bdf2..85da8dcc 100644 --- a/scripts/generate-swift.ts +++ b/scripts/generate-swift.ts @@ -25,6 +25,7 @@ import { readProtocolVersions } from './read-protocol-versions.js'; const GENERATED_HEADER = '// Generated from types/*.ts — do not edit\n\nimport Foundation\n'; + /** PascalCase → camelCase */ function toCamelCase(name: string): string { return name[0].toLowerCase() + name.slice(1); @@ -305,8 +306,8 @@ function generateSwiftEnum(enumDecl: EnumDeclaration): string { if (isBitset) { lines.push(`public struct ${name}: OptionSet, Codable, Sendable, Hashable {`); - lines.push(' public let rawValue: Int'); - lines.push(' public init(rawValue: Int) { self.rawValue = rawValue }'); + lines.push(' public let rawValue: UInt32'); + lines.push(' public init(rawValue: UInt32) { self.rawValue = rawValue }'); lines.push(''); for (const member of enumDecl.getMembers()) { const memberName = swiftIdentifier(toCamelCase(member.getName())); @@ -426,6 +427,16 @@ interface UnionConfig { name: string; discriminantField: string; variants: UnionVariant[]; + /** + * When true, an unrecognized discriminant value decodes into a raw + * `.unknown(AnyCodable)` passthrough case (instead of throwing) and + * re-encodes the preserved payload verbatim. Mirrors the .NET + * `UnionConverter(..., allowUnknown: true)` flag so open unions + * (StateAction, Customization, …) round-trip forward-compatibly. + * Defaults to false (closed union: throw on unknown — e.g. + * ChangesetOperationTarget, ReconnectResult). + */ + allowUnknown?: boolean; } function generateDiscriminatedUnion(config: UnionConfig): string { @@ -435,6 +446,11 @@ function generateDiscriminatedUnion(config: UnionConfig): string { for (const v of config.variants) { lines.push(` case ${v.caseName}(${v.structName})`); } + if (config.allowUnknown) { + lines.push(' /// Unknown or future discriminant; the raw payload is preserved'); + lines.push(' /// and re-encoded verbatim for forward-compatibility.'); + lines.push(' case unknown(AnyCodable)'); + } lines.push(''); lines.push(' private enum DiscriminantKey: String, CodingKey {'); @@ -452,7 +468,11 @@ function generateDiscriminatedUnion(config: UnionConfig): string { lines.push(` self = .${v.caseName}(try ${v.structName}(from: decoder))`); } lines.push(' default:'); - lines.push(` throw DecodingError.dataCorruptedError(forKey: .discriminant, in: container, debugDescription: "Unknown ${config.name} discriminant: \\(discriminant)")`); + if (config.allowUnknown) { + lines.push(' self = .unknown(try AnyCodable(from: decoder))'); + } else { + lines.push(` throw DecodingError.dataCorruptedError(forKey: .discriminant, in: container, debugDescription: "Unknown ${config.name} discriminant: \\(discriminant)")`); + } lines.push(' }'); lines.push(' }'); @@ -463,6 +483,9 @@ function generateDiscriminatedUnion(config: UnionConfig): string { for (const v of config.variants) { lines.push(` case .${v.caseName}(let value): try value.encode(to: encoder)`); } + if (config.allowUnknown) { + lines.push(' case .unknown(let value): try value.encode(to: encoder)'); + } lines.push(' }'); lines.push(' }'); @@ -566,6 +589,11 @@ const STATE_STRUCTS = [ const RESPONSE_PART_UNION: UnionConfig = { name: 'ResponsePart', discriminantField: 'kind', + // Open union: an unrecognized `kind` (e.g. a future protocol part type) is + // preserved as a raw AnyCodable passthrough and re-encoded verbatim so that + // snapshot decode and round-trip both succeed and delta reducers that target + // other parts (by id) still work correctly. Mirrors .NET allowUnknown. + allowUnknown: true, variants: [ { caseName: 'markdown', structName: 'MarkdownResponsePart', discriminantValue: 'markdown' }, { caseName: 'contentRef', structName: 'ResourceReponsePart', discriminantValue: 'contentRef' }, @@ -578,6 +606,9 @@ const RESPONSE_PART_UNION: UnionConfig = { const TOOL_CALL_STATE_UNION: UnionConfig = { name: 'ToolCallState', discriminantField: 'status', + // Open union: a future protocol version may add new tool call statuses. + // Preserve unknown discriminants verbatim for round-trip fidelity. + allowUnknown: true, variants: [ { caseName: 'streaming', structName: 'ToolCallStreamingState', discriminantValue: 'streaming' }, { caseName: 'pendingConfirmation', structName: 'ToolCallPendingConfirmationState', discriminantValue: 'pending-confirmation' }, @@ -591,6 +622,8 @@ const TOOL_CALL_STATE_UNION: UnionConfig = { const TERMINAL_CLAIM_UNION: UnionConfig = { name: 'TerminalClaim', discriminantField: 'kind', + // Open union: future protocol versions may add new terminal claim kinds. + allowUnknown: true, variants: [ { caseName: 'client', structName: 'TerminalClientClaim', discriminantValue: 'client' }, { caseName: 'session', structName: 'TerminalSessionClaim', discriminantValue: 'session' }, @@ -600,6 +633,8 @@ const TERMINAL_CLAIM_UNION: UnionConfig = { const TERMINAL_CONTENT_PART_UNION: UnionConfig = { name: 'TerminalContentPart', discriminantField: 'type', + // Open union: future protocol versions may add new terminal content types. + allowUnknown: true, variants: [ { caseName: 'unclassified', structName: 'TerminalUnclassifiedPart', discriminantValue: 'unclassified' }, { caseName: 'command', structName: 'TerminalCommandPart', discriminantValue: 'command' }, @@ -609,6 +644,8 @@ const TERMINAL_CONTENT_PART_UNION: UnionConfig = { const SESSION_INPUT_QUESTION_UNION: UnionConfig = { name: 'SessionInputQuestion', discriminantField: 'kind', + // Open union: future protocol versions may add new question kinds. + allowUnknown: true, variants: [ { caseName: 'text', structName: 'SessionInputTextQuestion', discriminantValue: 'text' }, { caseName: 'number', structName: 'SessionInputNumberQuestion', discriminantValue: 'number' }, @@ -622,6 +659,8 @@ const SESSION_INPUT_QUESTION_UNION: UnionConfig = { const SESSION_INPUT_ANSWER_VALUE_UNION: UnionConfig = { name: 'SessionInputAnswerValue', discriminantField: 'kind', + // Open union: future protocol versions may add new answer value kinds. + allowUnknown: true, variants: [ { caseName: 'text', structName: 'SessionInputTextAnswerValue', discriminantValue: 'text' }, { caseName: 'number', structName: 'SessionInputNumberAnswerValue', discriminantValue: 'number' }, @@ -634,6 +673,8 @@ const SESSION_INPUT_ANSWER_VALUE_UNION: UnionConfig = { const SESSION_INPUT_ANSWER_UNION: UnionConfig = { name: 'SessionInputAnswer', discriminantField: 'state', + // Open union: future protocol versions may add new answer states. + allowUnknown: true, variants: [ { caseName: 'draft', structName: 'SessionInputAnswered', discriminantValue: 'draft' }, { caseName: 'submitted', structName: 'SessionInputAnswered', discriminantValue: 'submitted' }, @@ -644,6 +685,8 @@ const SESSION_INPUT_ANSWER_UNION: UnionConfig = { const MESSAGE_ATTACHMENT_UNION: UnionConfig = { name: 'MessageAttachment', discriminantField: 'type', + // Open union: future protocol versions may add new attachment types. + allowUnknown: true, variants: [ { caseName: 'simple', structName: 'SimpleMessageAttachment', discriminantValue: 'simple' }, { caseName: 'embeddedResource', structName: 'MessageEmbeddedResourceAttachment', discriminantValue: 'embeddedResource' }, @@ -655,6 +698,10 @@ const MESSAGE_ATTACHMENT_UNION: UnionConfig = { const CUSTOMIZATION_UNION: UnionConfig = { name: 'Customization', discriminantField: 'type', + // Open union: mirrors .NET CustomizationConverter(allowUnknown: true) + // (State.generated.cs). An unrecognized `type` is preserved as a raw + // AnyCodable passthrough and re-encoded verbatim, not thrown. + allowUnknown: true, variants: [ { caseName: 'plugin', structName: 'PluginCustomization', discriminantValue: 'plugin' }, { caseName: 'directory', structName: 'DirectoryCustomization', discriminantValue: 'directory' }, @@ -665,6 +712,10 @@ const CUSTOMIZATION_UNION: UnionConfig = { const CHILD_CUSTOMIZATION_UNION: UnionConfig = { name: 'ChildCustomization', discriminantField: 'type', + // Open union: mirrors CUSTOMIZATION_UNION's allowUnknown policy. Future + // protocol versions may add new child customization types (e.g. new plugin + // child kinds). An unrecognized type is preserved verbatim. + allowUnknown: true, variants: [ { caseName: 'agent', structName: 'AgentCustomization', discriminantValue: 'agent' }, { caseName: 'skill', structName: 'SkillCustomization', discriminantValue: 'skill' }, @@ -678,6 +729,8 @@ const CHILD_CUSTOMIZATION_UNION: UnionConfig = { const CUSTOMIZATION_LOAD_STATE_UNION: UnionConfig = { name: 'CustomizationLoadState', discriminantField: 'kind', + // Open union: future protocol versions may add new load state kinds. + allowUnknown: true, variants: [ { caseName: 'loading', structName: 'CustomizationLoadingState', discriminantValue: 'loading' }, { caseName: 'loaded', structName: 'CustomizationLoadedState', discriminantValue: 'loaded' }, @@ -715,6 +768,9 @@ function generateToolResultContentUnion(): string { case fileEdit(ToolResultFileEditContent) case terminal(ToolResultTerminalContent) case subagent(ToolResultSubagentContent) + /// Unknown or future tool result content type; the raw payload is preserved + /// and re-encoded verbatim for forward-compatibility. + case unknown(AnyCodable) private enum Keys: String, CodingKey { case type @@ -737,10 +793,7 @@ function generateToolResultContentUnion(): string { case "subagent": self = .subagent(try ToolResultSubagentContent(from: decoder)) default: - throw DecodingError.dataCorruptedError( - forKey: .type, in: container, - debugDescription: "Unknown ToolResultContent type: \\(type)" - ) + self = .unknown(try AnyCodable(from: decoder)) } } else { throw DecodingError.dataCorrupted( @@ -758,6 +811,7 @@ function generateToolResultContentUnion(): string { case .fileEdit(let v): try v.encode(to: encoder) case .terminal(let v): try v.encode(to: encoder) case .subagent(let v): try v.encode(to: encoder) + case .unknown(let v): try v.encode(to: encoder) } } }`; @@ -1077,7 +1131,10 @@ function generateActionsFile(project: Project): string { lines.push(` case ${v.caseName}(${v.tsInterface === '_merged_' ? 'SessionToolCallConfirmedAction' : v.tsInterface})`); } lines.push(' /// Unknown or future action type; reducers treat this as a no-op.'); - lines.push(' case unknown(type: String)'); + lines.push(' /// The raw payload (including its `type` discriminant) is preserved'); + lines.push(' /// as an `AnyCodable` so a decode→encode round-trip re-emits it'); + lines.push(' /// verbatim for forward-compatibility (mirrors .NET allowUnknown).'); + lines.push(' case unknown(AnyCodable)'); lines.push(''); lines.push(' private enum TypeKey: String, CodingKey { case type }'); lines.push(''); @@ -1093,7 +1150,7 @@ function generateActionsFile(project: Project): string { lines.push(` self = .${v.caseName}(try ${structName}(from: decoder))`); } lines.push(' default:'); - lines.push(' self = .unknown(type: type)'); + lines.push(' self = .unknown(try AnyCodable(from: decoder))'); lines.push(' }'); lines.push(' }'); lines.push(''); @@ -1102,7 +1159,7 @@ function generateActionsFile(project: Project): string { for (const v of ACTION_VARIANTS) { lines.push(` case .${v.caseName}(let v): try v.encode(to: encoder)`); } - lines.push(' case .unknown: break'); + lines.push(' case .unknown(let value): try value.encode(to: encoder)'); lines.push(' }'); lines.push(' }'); lines.push('}'); @@ -1232,31 +1289,45 @@ public struct ChangesetOperationResourceTarget: Codable, Sendable { self.side = side } + // kind is the union discriminant: a fixed constant for this variant (so it + // is NOT decoded from the wire — the union already dispatched on it), but it + // MUST be re-emitted on encode so the wire stays a decodable + // discriminated-union value. Hence the custom encode and the decode-only + // CodingKeys that omit kind. private enum CodingKeys: String, CodingKey { case resource, side } + private enum EncodingKeys: String, CodingKey { case kind, resource, side } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: EncodingKeys.self) + try container.encode(kind, forKey: .kind) + try container.encode(resource, forKey: .resource) + try container.encodeIfPresent(side, forKey: .side) + } } public struct ChangesetOperationRangeTarget: Codable, Sendable { public var kind: String { "range" } public var resource: String public var side: String? - public var range: ChangesetOperationTargetRange + public var range: TextRange - public init(resource: String, side: String? = nil, range: ChangesetOperationTargetRange) { + public init(resource: String, side: String? = nil, range: TextRange) { self.resource = resource self.side = side self.range = range } + // See ChangesetOperationResourceTarget: kind is re-emitted on encode but + // not decoded (the union dispatches on it). private enum CodingKeys: String, CodingKey { case resource, side, range } -} + private enum EncodingKeys: String, CodingKey { case kind, resource, side, range } -public struct ChangesetOperationTargetRange: Codable, Sendable { - public var start: Int - public var end: Int - - public init(start: Int, end: Int) { - self.start = start - self.end = end + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: EncodingKeys.self) + try container.encode(kind, forKey: .kind) + try container.encode(resource, forKey: .resource) + try container.encodeIfPresent(side, forKey: .side) + try container.encode(range, forKey: .range) } }`; } diff --git a/types/test-cases/round-trips/001-action-envelope-session-title-changed.json b/types/test-cases/round-trips/001-action-envelope-session-title-changed.json new file mode 100644 index 00000000..581dbb5f --- /dev/null +++ b/types/test-cases/round-trips/001-action-envelope-session-title-changed.json @@ -0,0 +1,24 @@ +{ + "name": "action-envelope-session-title-changed", + "group": "A", + "description": "ActionEnvelope carrying a session/titleChanged action decodes its scalar fields and its discriminated action variant; key fields survive a re-encode round-trip. The `origin` field is required-nullable (T | undefined, no ?:); when absent from the wire input it re-encodes absent — single canonical form.", + "type": "ActionEnvelope", + "input": { + "channel": "ahp-session:/s1", + "action": { + "type": "session/titleChanged", + "title": "Hello" + }, + "serverSeq": 7 + }, + "acceptableOutputs": [ + { + "action": { + "title": "Hello", + "type": "session/titleChanged" + }, + "channel": "ahp-session:/s1", + "serverSeq": 7 + } + ] +} diff --git a/types/test-cases/round-trips/002-state-action-unknown-variant-preserved.json b/types/test-cases/round-trips/002-state-action-unknown-variant-preserved.json new file mode 100644 index 00000000..045e781e --- /dev/null +++ b/types/test-cases/round-trips/002-state-action-unknown-variant-preserved.json @@ -0,0 +1,16 @@ +{ + "name": "state-action-unknown-variant-preserved", + "group": "A", + "description": "An unknown StateAction discriminator decodes to a raw JsonElement (not an exception) and re-encodes verbatim.", + "type": "StateAction", + "input": { + "type": "future/newAction", + "foo": 42 + }, + "acceptableOutputs": [ + { + "type": "future/newAction", + "foo": 42 + } + ] +} diff --git a/types/test-cases/round-trips/003-customization-unknown-type-preserved.json b/types/test-cases/round-trips/003-customization-unknown-type-preserved.json new file mode 100644 index 00000000..f9096845 --- /dev/null +++ b/types/test-cases/round-trips/003-customization-unknown-type-preserved.json @@ -0,0 +1,18 @@ +{ + "name": "customization-unknown-type-preserved", + "group": "A", + "description": "The Customization union opts into allowUnknown: an unrecognized `type` decodes to a raw JsonElement, does not throw, and re-encodes verbatim.", + "type": "Customization", + "input": { + "type": "future/unknownCustomization", + "path": "/x", + "extra": 7 + }, + "acceptableOutputs": [ + { + "type": "future/unknownCustomization", + "path": "/x", + "extra": 7 + } + ] +} diff --git a/types/test-cases/round-trips/004-session-status-bitset-flags.json b/types/test-cases/round-trips/004-session-status-bitset-flags.json new file mode 100644 index 00000000..a3e95653 --- /dev/null +++ b/types/test-cases/round-trips/004-session-status-bitset-flags.json @@ -0,0 +1,10 @@ +{ + "name": "session-status-bitset-flags", + "group": "A", + "description": "SessionStatus is a numeric bitset on the wire. InProgress(8)|IsArchived(64)=72 decodes and re-encodes as the same number.", + "type": "SessionStatus", + "input": 72, + "acceptableOutputs": [ + 72 + ] +} diff --git a/types/test-cases/round-trips/005-session-status-unknown-bits-preserved.json b/types/test-cases/round-trips/005-session-status-unknown-bits-preserved.json new file mode 100644 index 00000000..3dccd98e --- /dev/null +++ b/types/test-cases/round-trips/005-session-status-unknown-bits-preserved.json @@ -0,0 +1,10 @@ +{ + "name": "session-status-unknown-bits-preserved", + "group": "A", + "description": "Unknown/forward-compat bits in the SessionStatus bitset survive a round-trip. InProgress(8)|IsArchived(64)|bit31(2147483648)=2147483720.", + "type": "SessionStatus", + "input": 2147483720, + "acceptableOutputs": [ + 2147483720 + ] +} diff --git a/types/test-cases/round-trips/006-string-or-markdown-plain.json b/types/test-cases/round-trips/006-string-or-markdown-plain.json new file mode 100644 index 00000000..da9c4701 --- /dev/null +++ b/types/test-cases/round-trips/006-string-or-markdown-plain.json @@ -0,0 +1,10 @@ +{ + "name": "string-or-markdown-plain", + "group": "A", + "description": "StringOrMarkdown plain form is a bare JSON string and re-encodes verbatim to the same bare string.", + "type": "StringOrMarkdown", + "input": "hello", + "acceptableOutputs": [ + "hello" + ] +} diff --git a/types/test-cases/round-trips/007-string-or-markdown-object.json b/types/test-cases/round-trips/007-string-or-markdown-object.json new file mode 100644 index 00000000..abbbc692 --- /dev/null +++ b/types/test-cases/round-trips/007-string-or-markdown-object.json @@ -0,0 +1,14 @@ +{ + "name": "string-or-markdown-object", + "group": "A", + "description": "StringOrMarkdown object form carries a `markdown` field and re-encodes verbatim.", + "type": "StringOrMarkdown", + "input": { + "markdown": "# title" + }, + "acceptableOutputs": [ + { + "markdown": "# title" + } + ] +} diff --git a/types/test-cases/round-trips/008-jsonrpc-request.json b/types/test-cases/round-trips/008-jsonrpc-request.json new file mode 100644 index 00000000..a52ea39a --- /dev/null +++ b/types/test-cases/round-trips/008-jsonrpc-request.json @@ -0,0 +1,20 @@ +{ + "name": "jsonrpc-request", + "group": "A", + "description": "A JsonRpcMessage with id+method+params decodes as the request variant and re-encodes all fields.", + "type": "JsonRpcMessage", + "input": { + "jsonrpc": "2.0", + "id": 1, + "method": "initialize", + "params": {} + }, + "acceptableOutputs": [ + { + "jsonrpc": "2.0", + "id": 1, + "method": "initialize", + "params": {} + } + ] +} diff --git a/types/test-cases/round-trips/009-jsonrpc-notification.json b/types/test-cases/round-trips/009-jsonrpc-notification.json new file mode 100644 index 00000000..58134f15 --- /dev/null +++ b/types/test-cases/round-trips/009-jsonrpc-notification.json @@ -0,0 +1,18 @@ +{ + "name": "jsonrpc-notification", + "group": "A", + "description": "A JsonRpcMessage with method+params but no id decodes as the notification variant and re-encodes all fields.", + "type": "JsonRpcMessage", + "input": { + "jsonrpc": "2.0", + "method": "action", + "params": {} + }, + "acceptableOutputs": [ + { + "jsonrpc": "2.0", + "method": "action", + "params": {} + } + ] +} diff --git a/types/test-cases/round-trips/010-jsonrpc-success.json b/types/test-cases/round-trips/010-jsonrpc-success.json new file mode 100644 index 00000000..910124da --- /dev/null +++ b/types/test-cases/round-trips/010-jsonrpc-success.json @@ -0,0 +1,18 @@ +{ + "name": "jsonrpc-success", + "group": "A", + "description": "A JsonRpcMessage with id+result decodes as the success-response variant and re-encodes all fields.", + "type": "JsonRpcMessage", + "input": { + "jsonrpc": "2.0", + "id": 1, + "result": {} + }, + "acceptableOutputs": [ + { + "jsonrpc": "2.0", + "id": 1, + "result": {} + } + ] +} diff --git a/types/test-cases/round-trips/011-jsonrpc-error.json b/types/test-cases/round-trips/011-jsonrpc-error.json new file mode 100644 index 00000000..1c3e2e39 --- /dev/null +++ b/types/test-cases/round-trips/011-jsonrpc-error.json @@ -0,0 +1,24 @@ +{ + "name": "jsonrpc-error", + "group": "A", + "description": "A JsonRpcMessage with id+error decodes as the error-response variant and re-encodes all fields.", + "type": "JsonRpcMessage", + "input": { + "jsonrpc": "2.0", + "id": 1, + "error": { + "code": -32601, + "message": "x" + } + }, + "acceptableOutputs": [ + { + "jsonrpc": "2.0", + "id": 1, + "error": { + "code": -32601, + "message": "x" + } + } + ] +} diff --git a/types/test-cases/round-trips/012-changeset-target-resource.json b/types/test-cases/round-trips/012-changeset-target-resource.json new file mode 100644 index 00000000..8dae4155 --- /dev/null +++ b/types/test-cases/round-trips/012-changeset-target-resource.json @@ -0,0 +1,16 @@ +{ + "name": "changeset-target-resource", + "group": "A", + "description": "ChangesetOperationTarget dispatches on its `kind` discriminator: kind=resource decodes the resource-target variant and re-encodes the discriminator and resource fields.", + "type": "ChangesetOperationTarget", + "input": { + "kind": "resource", + "resource": "file:///a.txt" + }, + "acceptableOutputs": [ + { + "kind": "resource", + "resource": "file:///a.txt" + } + ] +} diff --git a/types/test-cases/round-trips/013-changeset-target-range.json b/types/test-cases/round-trips/013-changeset-target-range.json new file mode 100644 index 00000000..52fb8361 --- /dev/null +++ b/types/test-cases/round-trips/013-changeset-target-range.json @@ -0,0 +1,24 @@ +{ + "name": "changeset-target-range", + "group": "A", + "description": "ChangesetOperationTarget with kind=range decodes the range-target variant including its nested start/end range; the discriminator and range survive a re-encode round-trip.", + "type": "ChangesetOperationTarget", + "input": { + "kind": "range", + "resource": "file:///a.txt", + "range": { + "start": {"line": 2, "character": 0}, + "end": {"line": 5, "character": 10} + } + }, + "acceptableOutputs": [ + { + "kind": "range", + "resource": "file:///a.txt", + "range": { + "start": {"line": 2, "character": 0}, + "end": {"line": 5, "character": 10} + } + } + ] +} diff --git a/types/test-cases/round-trips/014-session-input-question-number.json b/types/test-cases/round-trips/014-session-input-question-number.json new file mode 100644 index 00000000..427683ce --- /dev/null +++ b/types/test-cases/round-trips/014-session-input-question-number.json @@ -0,0 +1,22 @@ +{ + "name": "session-input-question-number", + "group": "A", + "description": "SessionInputQuestion kind=number decodes the number-question variant; kind, id, and min/max survive the round-trip.", + "type": "SessionInputQuestion", + "input": { + "kind": "number", + "id": "q1", + "message": "How many?", + "min": 0, + "max": 10 + }, + "acceptableOutputs": [ + { + "kind": "number", + "id": "q1", + "message": "How many?", + "min": 0, + "max": 10 + } + ] +} diff --git a/types/test-cases/round-trips/015-session-input-question-integer.json b/types/test-cases/round-trips/015-session-input-question-integer.json new file mode 100644 index 00000000..8125a7a9 --- /dev/null +++ b/types/test-cases/round-trips/015-session-input-question-integer.json @@ -0,0 +1,20 @@ +{ + "name": "session-input-question-integer", + "group": "A", + "description": "SessionInputQuestion kind=integer decodes the number-question variant, preserving the `integer` kind distinct from `number`; defaultValue survives the round-trip.", + "type": "SessionInputQuestion", + "input": { + "kind": "integer", + "id": "q2", + "message": "How many whole?", + "defaultValue": 3 + }, + "acceptableOutputs": [ + { + "kind": "integer", + "id": "q2", + "message": "How many whole?", + "defaultValue": 3 + } + ] +} diff --git a/types/test-cases/round-trips/016-long-above-int32-max-preserved.json b/types/test-cases/round-trips/016-long-above-int32-max-preserved.json new file mode 100644 index 00000000..c6abd635 --- /dev/null +++ b/types/test-cases/round-trips/016-long-above-int32-max-preserved.json @@ -0,0 +1,24 @@ +{ + "name": "long-above-int32-max-preserved", + "group": "A", + "description": "ActionEnvelope.serverSeq is a 64-bit integer; a value above Int32.MaxValue (2147483647) round-trips without 32-bit truncation. Here serverSeq = Int32.MaxValue + 1234567 = 2148131814. Canonical input has no origin key (required-nullable, omitted when absent).", + "type": "ActionEnvelope", + "input": { + "channel": "ahp-session:/s1", + "action": { + "type": "session/titleChanged", + "title": "x" + }, + "serverSeq": 2148131814 + }, + "acceptableOutputs": [ + { + "action": { + "title": "x", + "type": "session/titleChanged" + }, + "channel": "ahp-session:/s1", + "serverSeq": 2148131814 + } + ] +} diff --git a/types/test-cases/round-trips/017-unknown-wire-keys-ignored.json b/types/test-cases/round-trips/017-unknown-wire-keys-ignored.json new file mode 100644 index 00000000..b8b8122e --- /dev/null +++ b/types/test-cases/round-trips/017-unknown-wire-keys-ignored.json @@ -0,0 +1,36 @@ +{ + "name": "unknown-wire-keys-ignored", + "group": "B", + "description": "A known type (SessionSummary) carrying extra, unrecognized JSON keys. Runtime-decoder clients (Go, Swift, Rust, Kotlin) drop the unknown keys on re-encode — that is the intended canonical behavior expressed in acceptableOutputs[0]. TypeScript has no runtime decoder (JSON.parse/stringify preserves unknown keys verbatim), so it cannot produce the canonical dropped form; its expected output is the input preserved verbatim, expressed in typescriptOutput. This is a documented structural exception, not a blessed divergence — TypeScript is asserted against typescriptOutput, never skipped.", + "type": "SessionSummary", + "input": { + "resource": "ahp-session:/s1", + "provider": "demo", + "title": "Hello", + "status": 0, + "createdAt": 1, + "modifiedAt": 2, + "unknownFutureKey": { "nested": true }, + "anotherUnknown": 42 + }, + "acceptableOutputs": [ + { + "resource": "ahp-session:/s1", + "provider": "demo", + "title": "Hello", + "status": 0, + "createdAt": 1, + "modifiedAt": 2 + } + ], + "typescriptOutput": { + "resource": "ahp-session:/s1", + "provider": "demo", + "title": "Hello", + "status": 0, + "createdAt": 1, + "modifiedAt": 2, + "unknownFutureKey": { "nested": true }, + "anotherUnknown": 42 + } +} diff --git a/types/test-cases/round-trips/018-nested-optional-null-round-trip.json b/types/test-cases/round-trips/018-nested-optional-null-round-trip.json new file mode 100644 index 00000000..69250649 --- /dev/null +++ b/types/test-cases/round-trips/018-nested-optional-null-round-trip.json @@ -0,0 +1,24 @@ +{ + "name": "nested-optional-null-round-trip", + "group": "A", + "description": "SessionSummary.project is an optional nested struct. A wire payload omitting it decodes with project null/absent, and a re-encode omits the `project` key entirely.", + "type": "SessionSummary", + "input": { + "resource": "ahp-session:/s1", + "provider": "demo", + "title": "No project", + "status": 1, + "createdAt": 1, + "modifiedAt": 2 + }, + "acceptableOutputs": [ + { + "resource": "ahp-session:/s1", + "provider": "demo", + "title": "No project", + "status": 1, + "createdAt": 1, + "modifiedAt": 2 + } + ] +} diff --git a/types/test-cases/round-trips/019-channel-scoped-notification-uri.json b/types/test-cases/round-trips/019-channel-scoped-notification-uri.json new file mode 100644 index 00000000..c55c786b --- /dev/null +++ b/types/test-cases/round-trips/019-channel-scoped-notification-uri.json @@ -0,0 +1,43 @@ +{ + "name": "channel-scoped-notification-uri", + "group": "B", + "description": "SessionAddedParams carries a root channel URI, a required session summary, and an unknown wire key. Runtime-decoder clients (Go, Swift, Rust, Kotlin) drop the unknown key on re-encode — that is the intended canonical behavior expressed in acceptableOutputs[0]. TypeScript has no runtime decoder (JSON.parse/stringify preserves unknown keys verbatim), so it cannot produce the canonical dropped form; its expected output is the input preserved verbatim, expressed in typescriptOutput. This is a documented structural exception, not a blessed divergence — TypeScript is asserted against typescriptOutput, never skipped.", + "type": "SessionAddedParams", + "input": { + "channel": "ahp:/root", + "summary": { + "resource": "ahp-session:/s1", + "provider": "demo", + "title": "New session", + "status": 1, + "createdAt": 1, + "modifiedAt": 2 + }, + "unknownFutureKey": { "nested": true } + }, + "acceptableOutputs": [ + { + "channel": "ahp:/root", + "summary": { + "resource": "ahp-session:/s1", + "provider": "demo", + "title": "New session", + "status": 1, + "createdAt": 1, + "modifiedAt": 2 + } + } + ], + "typescriptOutput": { + "channel": "ahp:/root", + "summary": { + "resource": "ahp-session:/s1", + "provider": "demo", + "title": "New session", + "status": 1, + "createdAt": 1, + "modifiedAt": 2 + }, + "unknownFutureKey": { "nested": true } + } +} diff --git a/types/test-cases/round-trips/020-partial-summary-all-null.json b/types/test-cases/round-trips/020-partial-summary-all-null.json new file mode 100644 index 00000000..7018e929 --- /dev/null +++ b/types/test-cases/round-trips/020-partial-summary-all-null.json @@ -0,0 +1,10 @@ +{ + "name": "partial-summary-all-null", + "group": "A", + "description": "PartialSessionSummary has every field optional. An empty object decodes with every field null/absent and re-encodes back to an empty object — no exception.", + "type": "PartialSessionSummary", + "input": {}, + "acceptableOutputs": [ + {} + ] +} diff --git a/types/test-cases/round-trips/024-changeset-changekind-known-and-unknown.json b/types/test-cases/round-trips/024-changeset-changekind-known-and-unknown.json new file mode 100644 index 00000000..34ca9b73 --- /dev/null +++ b/types/test-cases/round-trips/024-changeset-changekind-known-and-unknown.json @@ -0,0 +1,38 @@ +{ + "name": "changeset-changekind-known-and-unknown", + "group": "A", + "description": "A session/changesetsChanged action carrying two Changeset catalogue entries — one with a recognized changeKind ('session') and one with an UNKNOWN changeKind ('future-kind-xyz') — decodes and re-encodes byte-for-byte. changeKind is an open string field, so the unknown variant survives the round-trip unchanged.", + "type": "StateAction", + "input": { + "type": "session/changesetsChanged", + "changesets": [ + { + "label": "Session Changes", + "uriTemplate": "copilot:/test-session/changeset/session", + "changeKind": "session" + }, + { + "label": "Future Slice", + "uriTemplate": "copilot:/test-session/changeset/future", + "changeKind": "future-kind-xyz" + } + ] + }, + "acceptableOutputs": [ + { + "type": "session/changesetsChanged", + "changesets": [ + { + "label": "Session Changes", + "uriTemplate": "copilot:/test-session/changeset/session", + "changeKind": "session" + }, + { + "label": "Future Slice", + "uriTemplate": "copilot:/test-session/changeset/future", + "changeKind": "future-kind-xyz" + } + ] + } + ] +} diff --git a/types/test-cases/round-trips/025-action-envelope-origin-absent.json b/types/test-cases/round-trips/025-action-envelope-origin-absent.json new file mode 100644 index 00000000..4942d3f4 --- /dev/null +++ b/types/test-cases/round-trips/025-action-envelope-origin-absent.json @@ -0,0 +1,18 @@ +{ + "name": "action-envelope-origin-absent", + "group": "A", + "description": "ActionEnvelope with no origin (server-originated): the `origin` field is required-nullable (T | undefined). When absent from the wire input it MUST re-encode absent — key omitted, never null. This fixture is the regression gate for the Rust serialization bug where `Option::is_none` was not skipped, causing `\"origin\": null` to be emitted instead of the key being omitted.", + "type": "ActionEnvelope", + "input": { + "channel": "ahp-session:/s1", + "action": { "type": "session/titleChanged", "title": "Hello" }, + "serverSeq": 7 + }, + "acceptableOutputs": [ + { + "action": { "title": "Hello", "type": "session/titleChanged" }, + "channel": "ahp-session:/s1", + "serverSeq": 7 + } + ] +} diff --git a/types/test-cases/round-trips/026-action-envelope-origin-present.json b/types/test-cases/round-trips/026-action-envelope-origin-present.json new file mode 100644 index 00000000..46e0077b --- /dev/null +++ b/types/test-cases/round-trips/026-action-envelope-origin-present.json @@ -0,0 +1,20 @@ +{ + "name": "action-envelope-origin-present", + "group": "A", + "description": "ActionEnvelope with a concrete origin (client-originated): origin.clientId and origin.clientSeq survive the round-trip exactly.", + "type": "ActionEnvelope", + "input": { + "channel": "ahp-session:/s1", + "action": { "type": "session/titleChanged", "title": "Hello" }, + "serverSeq": 12, + "origin": { "clientId": "my-client", "clientSeq": 3 } + }, + "acceptableOutputs": [ + { + "action": { "title": "Hello", "type": "session/titleChanged" }, + "channel": "ahp-session:/s1", + "origin": { "clientId": "my-client", "clientSeq": 3 }, + "serverSeq": 12 + } + ] +} diff --git a/types/test-cases/round-trips/KNOWN-FIDELITY-GAPS.md b/types/test-cases/round-trips/KNOWN-FIDELITY-GAPS.md new file mode 100644 index 00000000..62adec52 --- /dev/null +++ b/types/test-cases/round-trips/KNOWN-FIDELITY-GAPS.md @@ -0,0 +1,48 @@ +# Round-trip corpus — mechanism and known coverage gaps + +The fixtures in this directory are a language-agnostic round-trip corpus. Each +fixture's `input` is a wire payload that every client decodes and re-encodes; the +re-encoded value must **exactly** match the single canonical form in +`acceptableOutputs[0]`. The comparison is key-order-independent but value- and +**key-presence-sensitive**: `null` is NOT normalized to absent, and absent is NOT +normalized to `null` (so an absent `origin` re-encoding as `"origin": null` is a +failure, not a pass). `acceptableOutputs` MUST have exactly one entry — multiple +entries would cement observed-but-wrong divergence as "acceptable". + +## Group A vs Group B + +- **Group A** (`"group": "A"`, or absent): every client agrees; all assert + `acceptableOutputs[0]`. +- **Group B** (`"group": "B"`): a known type carries extra, unmodeled wire keys. + Runtime-decoder clients (Go, Rust, Swift, Kotlin) decode into a typed struct, + which drops the unknown keys, and assert the dropped form in + `acceptableOutputs[0]`. TypeScript has no runtime decoder, so `JSON.parse` / + `JSON.stringify` preserve every key; it asserts the preserved form in + `typescriptOutput`. TypeScript still asserts — it is never skipped. Fixtures + 017 and 019 are the Group B cases. + +This is a real type-system capability difference, not a blessed divergence: a +runtime client that wrongly *preserved* unknown keys would fail its +`acceptableOutputs[0]` assertion, and a TypeScript path that wrongly *dropped* +them would fail its `typescriptOutput` assertion. + +## Known coverage gaps (what the corpus does NOT verify) + +Honest limits, recorded so they are not mistaken for coverage: + +- **TypeScript does not verify generated-type correctness.** TS types are erased + at runtime, so the TS round-trip harness checks runtime wire behavior + fixture + self-consistency — not whether the generated TS types are right. A wrong TS + field name / optionality / nesting would not be caught here; that is the + compiler's job, exercised where the types are consumed (reducers, client code) + and by `tsc`. (Separately, `SessionStatus` is a closed `const enum` in TS, so + the TYPE cannot represent a bitset combination like 72 or an unknown bit like + 2147483720 — the bitset VALUE round-trip is covered by fixtures 004/005.) + +Previously-listed gaps now **CLOSED**: Kotlin `JsonRpcMessage` is decoded via its +real generated variant types (`JsonRpcRequest`/`Notification`/`SuccessResponse`/ +`ErrorResponse`) — fixtures 008–011 exercise the real classes, not a raw-AST +passthrough. And `SessionStatus` is now a uniform 32-bit-unsigned bitset across +Rust/Go/Kotlin/Swift (`u32`/`uint32`/`UInt`/`UInt32`), so every client holds the +same value range — within TS's `number` 53-bit-safe limit, with no width +divergence. From 51124bcd7be537980388fb1011421ee1cfc86a31 Mon Sep 17 00:00:00 2001 From: Joshua Mouch Date: Wed, 10 Jun 2026 13:55:40 -0400 Subject: [PATCH 2/2] Rename round-trip corpus field typescriptOutput to preservedOutput The group-B field holds the expected output for clients that preserve unknown wire keys on re-encode. Naming it after the behavior rather than the one client that currently produces that form (TypeScript) leaves room for other clients to assert the same field if they adopt unknown-key passthrough later. --- clients/go/ahptypes/roundtrip_fixture_test.go | 10 ++++----- .../agenthostprotocol/RoundTripCorpusTest.kt | 4 ++-- .../ahp-types/tests/roundtrip_corpus.rs | 10 ++++----- .../TypesRoundTripFixtureTests.swift | 2 +- .../typescript/test/types-round-trip.test.ts | 22 +++++++++---------- .../017-unknown-wire-keys-ignored.json | 4 ++-- .../019-channel-scoped-notification-uri.json | 4 ++-- .../round-trips/KNOWN-FIDELITY-GAPS.md | 4 ++-- 8 files changed, 30 insertions(+), 30 deletions(-) diff --git a/clients/go/ahptypes/roundtrip_fixture_test.go b/clients/go/ahptypes/roundtrip_fixture_test.go index d439435a..43ff98bc 100644 --- a/clients/go/ahptypes/roundtrip_fixture_test.go +++ b/clients/go/ahptypes/roundtrip_fixture_test.go @@ -63,17 +63,17 @@ type roundTripFixture struct { Description string `json:"description"` // Group "A" = all clients agree (assert acceptableOutputs[0]). // Group "B" = runtime-decoders drop unknown keys (assert acceptableOutputs[0]); - // TypeScript preserves them (asserts typescriptOutput instead). + // TypeScript preserves them (asserts preservedOutput instead). // Absent group is treated as "A" for backward compatibility. Group string `json:"group"` Type string `json:"type"` Input json.RawMessage `json:"input"` AcceptableOutputs []json.RawMessage `json:"acceptableOutputs"` - // TypescriptOutput is the expected output for the TypeScript client (Group B only). + // PreservedOutput is the expected output for the TypeScript client (Group B only). // Go always asserts acceptableOutputs[0] for both groups. - TypescriptOutput json.RawMessage `json:"typescriptOutput"` + PreservedOutput json.RawMessage `json:"preservedOutput"` // NotApplicable lists client names for which this fixture does not apply. - // Legacy field — new fixtures use group:"B" + typescriptOutput instead. + // Legacy field — new fixtures use group:"B" + preservedOutput instead. NotApplicable []string `json:"notApplicable"` } @@ -145,7 +145,7 @@ func runRoundTripFixture(t *testing.T, name string, raw []byte) { } // Honor notApplicable: skip clients listed there with a note. - // Legacy field — new fixtures use group:"B" + typescriptOutput instead. + // Legacy field — new fixtures use group:"B" + preservedOutput instead. for _, skip := range fx.NotApplicable { if skip == "go" { t.Logf("⊘ %s: not applicable to go — %s", name, fx.Description) diff --git a/clients/kotlin/src/test/kotlin/com/microsoft/agenthostprotocol/RoundTripCorpusTest.kt b/clients/kotlin/src/test/kotlin/com/microsoft/agenthostprotocol/RoundTripCorpusTest.kt index 9e8db0a7..f26d95dd 100644 --- a/clients/kotlin/src/test/kotlin/com/microsoft/agenthostprotocol/RoundTripCorpusTest.kt +++ b/clients/kotlin/src/test/kotlin/com/microsoft/agenthostprotocol/RoundTripCorpusTest.kt @@ -13,11 +13,11 @@ package com.microsoft.agenthostprotocol // { "name": ..., "description": ..., "group": ..., "type": ..., // "input": , // "acceptableOutputs": [ ], -// "typescriptOutput": } +// "preservedOutput": } // // Group A: all clients agree — assert acceptableOutputs[0]. // Group B: runtime-decoder clients drop unknown keys — assert acceptableOutputs[0]. -// (TypeScript asserts typescriptOutput instead; irrelevant to Kotlin.) +// (TypeScript asserts preservedOutput instead; irrelevant to Kotlin.) // Kotlin is always a runtime decoder → always asserts acceptableOutputs[0]. // // Run: diff --git a/clients/rust/crates/ahp-types/tests/roundtrip_corpus.rs b/clients/rust/crates/ahp-types/tests/roundtrip_corpus.rs index e8aa2bf8..7df5ae76 100644 --- a/clients/rust/crates/ahp-types/tests/roundtrip_corpus.rs +++ b/clients/rust/crates/ahp-types/tests/roundtrip_corpus.rs @@ -12,11 +12,11 @@ // { "name": ..., "description": ..., "group": ..., "type": ..., // "input": , // "acceptableOutputs": [ ], -// "typescriptOutput": } +// "preservedOutput": } // // Group A: all clients agree — assert acceptableOutputs[0]. // Group B: runtime-decoder clients drop unknown keys — assert acceptableOutputs[0]. -// (TypeScript asserts typescriptOutput instead; irrelevant to Rust.) +// (TypeScript asserts preservedOutput instead; irrelevant to Rust.) // Rust is always a runtime decoder → always asserts acceptableOutputs[0]. // // Run: cargo test roundtrip (from clients/rust) @@ -71,10 +71,10 @@ struct RoundTripFixture { input: Value, #[serde(rename = "acceptableOutputs")] acceptable_outputs: Vec, - /// TypeScript-specific expected output for group B (unused by Rust). - #[serde(rename = "typescriptOutput")] + /// Unknown-keys-preserved expected output for group B (unused by Rust). + #[serde(rename = "preservedOutput")] #[allow(dead_code)] - typescript_output: Option, + preserved_output: Option, /// Legacy skip list. Rust never appears here; parsed for completeness. #[serde(rename = "notApplicable")] not_applicable: Option>, diff --git a/clients/swift/AgentHostProtocol/Tests/AgentHostProtocolClientTests/TypesRoundTripFixtureTests.swift b/clients/swift/AgentHostProtocol/Tests/AgentHostProtocolClientTests/TypesRoundTripFixtureTests.swift index a8523fda..7575e0a1 100644 --- a/clients/swift/AgentHostProtocol/Tests/AgentHostProtocolClientTests/TypesRoundTripFixtureTests.swift +++ b/clients/swift/AgentHostProtocol/Tests/AgentHostProtocolClientTests/TypesRoundTripFixtureTests.swift @@ -122,7 +122,7 @@ final class TypesRoundTripFixtureTests: XCTestCase { } // Honor notApplicable: skip this client if listed. - // Legacy field — new fixtures use group:"B" + typescriptOutput instead. + // Legacy field — new fixtures use group:"B" + preservedOutput instead. if let notApplicable = root["notApplicable"] as? [String], notApplicable.contains("swift") { print("⊘ \(file): not applicable to swift (legacy notApplicable) — \(root["description"] as? String ?? "")") return false // SKIP — not counted as a real assertion diff --git a/clients/typescript/test/types-round-trip.test.ts b/clients/typescript/test/types-round-trip.test.ts index f744f26d..432ba5a8 100644 --- a/clients/typescript/test/types-round-trip.test.ts +++ b/clients/typescript/test/types-round-trip.test.ts @@ -21,7 +21,7 @@ * { "name": ..., "description": ..., "type": ..., * "input": , * "acceptableOutputs": [ ], - * "typescriptOutput": , + * "preservedOutput": , * "notApplicable": [ ] } * * The harness decodes `input` with `JSON.parse`, re-encodes with @@ -29,7 +29,7 @@ * acceptableOutputs[0] (key-order-independent, value- and key-presence-sensitive; * `null` is NOT normalized to absent). acceptableOutputs MUST have exactly one * entry — the single intended wire form. For group-"B" fixtures TS asserts - * `typescriptOutput` (unknown keys preserved) instead — see the group-B branch. + * `preservedOutput` (unknown keys preserved) instead — see the group-B branch. * * Real-execution: no mocks. Every fixture round-trips through real * `JSON.parse` / `JSON.stringify` — TypeScript's actual runtime wire path. @@ -88,8 +88,8 @@ interface FixtureRoot { * preserves unknown wire keys verbatim). Harnesses running as TypeScript assert this * instead of acceptableOutputs[0]. */ - readonly typescriptOutput?: unknown; - /** @deprecated Use group:"B" + typescriptOutput instead. */ + readonly preservedOutput?: unknown; + /** @deprecated Use group:"B" + preservedOutput instead. */ readonly notApplicable?: string[]; } @@ -169,29 +169,29 @@ function runFixture(file: string, root: FixtureRoot): void | 'skipped' { } // Group B: TypeScript has no runtime decoder (JSON.parse/stringify preserves unknown keys). - // TS asserts against typescriptOutput (the input preserved verbatim), NOT acceptableOutputs[0]. + // TS asserts against preservedOutput (the input preserved verbatim), NOT acceptableOutputs[0]. // This is a documented structural exception — TypeScript DOES assert, never skips. if (root.group === 'B') { - if (root.typescriptOutput === undefined) { + if (root.preservedOutput === undefined) { throw new Error( - `${file}: group B fixture must include a typescriptOutput field for TypeScript's expected form`, + `${file}: group B fixture must include a preservedOutput field for TypeScript's expected form`, ); } const inputJson = JSON.stringify(root.input); const parsed = JSON.parse(inputJson) as unknown; bindToType(file, type, parsed); const reencoded = JSON.stringify(parsed); - if (canonicalJson(reencoded) === canonicalJson(JSON.stringify(root.typescriptOutput))) { + if (canonicalJson(reencoded) === canonicalJson(JSON.stringify(root.preservedOutput))) { return; // PASS — TypeScript preserves unknown keys as expected } throw new Error( - `${file}: TypeScript re-encoded output does not match typescriptOutput.\n` + + `${file}: TypeScript re-encoded output does not match preservedOutput.\n` + ` got: ${reencoded}\n` + - ` expected: ${JSON.stringify(root.typescriptOutput)}`, + ` expected: ${JSON.stringify(root.preservedOutput)}`, ); } - // Legacy notApplicable: skip this client if listed. Prefer group B + typescriptOutput for new fixtures. + // Legacy notApplicable: skip this client if listed. Prefer group B + preservedOutput for new fixtures. if (Array.isArray(root.notApplicable) && root.notApplicable.includes('typescript')) { console.log(`⊘ ${file}: not applicable to typescript (legacy notApplicable) — TypeScript has no runtime decoder; it cannot drop unknown wire keys`); return 'skipped'; diff --git a/types/test-cases/round-trips/017-unknown-wire-keys-ignored.json b/types/test-cases/round-trips/017-unknown-wire-keys-ignored.json index b8b8122e..efded76f 100644 --- a/types/test-cases/round-trips/017-unknown-wire-keys-ignored.json +++ b/types/test-cases/round-trips/017-unknown-wire-keys-ignored.json @@ -1,7 +1,7 @@ { "name": "unknown-wire-keys-ignored", "group": "B", - "description": "A known type (SessionSummary) carrying extra, unrecognized JSON keys. Runtime-decoder clients (Go, Swift, Rust, Kotlin) drop the unknown keys on re-encode — that is the intended canonical behavior expressed in acceptableOutputs[0]. TypeScript has no runtime decoder (JSON.parse/stringify preserves unknown keys verbatim), so it cannot produce the canonical dropped form; its expected output is the input preserved verbatim, expressed in typescriptOutput. This is a documented structural exception, not a blessed divergence — TypeScript is asserted against typescriptOutput, never skipped.", + "description": "A known type (SessionSummary) carrying extra, unrecognized JSON keys. Runtime-decoder clients (Go, Swift, Rust, Kotlin) drop the unknown keys on re-encode — that is the intended canonical behavior expressed in acceptableOutputs[0]. TypeScript has no runtime decoder (JSON.parse/stringify preserves unknown keys verbatim), so it cannot produce the canonical dropped form; its expected output is the input preserved verbatim, expressed in preservedOutput. This is a documented structural exception, not a blessed divergence — TypeScript is asserted against preservedOutput, never skipped.", "type": "SessionSummary", "input": { "resource": "ahp-session:/s1", @@ -23,7 +23,7 @@ "modifiedAt": 2 } ], - "typescriptOutput": { + "preservedOutput": { "resource": "ahp-session:/s1", "provider": "demo", "title": "Hello", diff --git a/types/test-cases/round-trips/019-channel-scoped-notification-uri.json b/types/test-cases/round-trips/019-channel-scoped-notification-uri.json index c55c786b..dfe9592f 100644 --- a/types/test-cases/round-trips/019-channel-scoped-notification-uri.json +++ b/types/test-cases/round-trips/019-channel-scoped-notification-uri.json @@ -1,7 +1,7 @@ { "name": "channel-scoped-notification-uri", "group": "B", - "description": "SessionAddedParams carries a root channel URI, a required session summary, and an unknown wire key. Runtime-decoder clients (Go, Swift, Rust, Kotlin) drop the unknown key on re-encode — that is the intended canonical behavior expressed in acceptableOutputs[0]. TypeScript has no runtime decoder (JSON.parse/stringify preserves unknown keys verbatim), so it cannot produce the canonical dropped form; its expected output is the input preserved verbatim, expressed in typescriptOutput. This is a documented structural exception, not a blessed divergence — TypeScript is asserted against typescriptOutput, never skipped.", + "description": "SessionAddedParams carries a root channel URI, a required session summary, and an unknown wire key. Runtime-decoder clients (Go, Swift, Rust, Kotlin) drop the unknown key on re-encode — that is the intended canonical behavior expressed in acceptableOutputs[0]. TypeScript has no runtime decoder (JSON.parse/stringify preserves unknown keys verbatim), so it cannot produce the canonical dropped form; its expected output is the input preserved verbatim, expressed in preservedOutput. This is a documented structural exception, not a blessed divergence — TypeScript is asserted against preservedOutput, never skipped.", "type": "SessionAddedParams", "input": { "channel": "ahp:/root", @@ -28,7 +28,7 @@ } } ], - "typescriptOutput": { + "preservedOutput": { "channel": "ahp:/root", "summary": { "resource": "ahp-session:/s1", diff --git a/types/test-cases/round-trips/KNOWN-FIDELITY-GAPS.md b/types/test-cases/round-trips/KNOWN-FIDELITY-GAPS.md index 62adec52..364791f0 100644 --- a/types/test-cases/round-trips/KNOWN-FIDELITY-GAPS.md +++ b/types/test-cases/round-trips/KNOWN-FIDELITY-GAPS.md @@ -18,13 +18,13 @@ entries would cement observed-but-wrong divergence as "acceptable". which drops the unknown keys, and assert the dropped form in `acceptableOutputs[0]`. TypeScript has no runtime decoder, so `JSON.parse` / `JSON.stringify` preserve every key; it asserts the preserved form in - `typescriptOutput`. TypeScript still asserts — it is never skipped. Fixtures + `preservedOutput`. TypeScript still asserts — it is never skipped. Fixtures 017 and 019 are the Group B cases. This is a real type-system capability difference, not a blessed divergence: a runtime client that wrongly *preserved* unknown keys would fail its `acceptableOutputs[0]` assertion, and a TypeScript path that wrongly *dropped* -them would fail its `typescriptOutput` assertion. +them would fail its `preservedOutput` assertion. ## Known coverage gaps (what the corpus does NOT verify)