From 70789194305253425230d36fbba3562512df07fc Mon Sep 17 00:00:00 2001 From: Aydin Emre Iskender Date: Tue, 15 Sep 2026 08:23:08 +0200 Subject: [PATCH] Added typed file-edit models across SDKs --- AGENTS.md | 18 ++ .../Generated/Actions.generated.cs | 2 +- .../JsonSerializerContext.generated.cs | 3 + .../Generated/State.generated.cs | 35 ++- .../FileEditTypesTests.cs | 81 ++++++ clients/go/ahptypes/actions.generated.go | 2 +- clients/go/ahptypes/file_edits_test.go | 59 +++++ clients/go/ahptypes/state.generated.go | 28 +- clients/kotlin/README.md | 5 + .../generated/Actions.generated.kt | 2 +- .../generated/State.generated.kt | 31 ++- .../agenthostprotocol/FileEditTypesTest.kt | 249 ++++++++++++++++++ clients/rust/crates/ahp-types/src/actions.rs | 12 +- clients/rust/crates/ahp-types/src/state.rs | 36 ++- .../rust/crates/ahp-types/tests/file_edits.rs | 43 +++ .../Generated/Actions.generated.swift | 4 +- .../Generated/State.generated.swift | 64 ++++- .../FileEditTypesTests.swift | 47 ++++ docs/.changes/20260914-typed-file-edits.json | 4 + docs/guide/changesets.md | 38 +++ schema/actions.schema.json | 142 ++++------ schema/commands.schema.json | 142 ++++------ schema/errors.schema.json | 142 ++++------ schema/notifications.schema.json | 129 ++++----- schema/state.schema.json | 129 ++++----- scripts/file-edit-types.test.ts | 125 +++++++++ scripts/generate-csharp.ts | 3 + scripts/generate-go.ts | 3 + scripts/generate-kotlin.ts | 3 +- scripts/generate-rust.ts | 5 +- scripts/generate-swift.ts | 3 +- types/channels-chat/actions.ts | 4 +- types/channels-chat/state.ts | 3 +- types/common/state.ts | 35 ++- ...272-toolcallready-typed-edit-previews.json | 79 ++++++ .../round-trips/045-file-edit-variants.json | 36 +++ .../round-trips/046-file-edit-preview.json | 20 ++ .../047-file-edit-tool-result.json | 36 +++ .../048-file-edit-empty-preview.json | 8 + .../049-file-edit-unknown-fields.json | 51 ++++ .../round-trips/KNOWN-FIDELITY-GAPS.md | 3 +- 41 files changed, 1339 insertions(+), 525 deletions(-) create mode 100644 clients/dotnet/tests/AgentHostProtocol.Tests/FileEditTypesTests.cs create mode 100644 clients/go/ahptypes/file_edits_test.go create mode 100644 clients/kotlin/src/test/kotlin/com/microsoft/agenthostprotocol/FileEditTypesTest.kt create mode 100644 clients/rust/crates/ahp-types/tests/file_edits.rs create mode 100644 clients/swift/AgentHostProtocol/Tests/AgentHostProtocolTests/FileEditTypesTests.swift create mode 100644 docs/.changes/20260914-typed-file-edits.json create mode 100644 scripts/file-edit-types.test.ts create mode 100644 types/test-cases/reducers/272-toolcallready-typed-edit-previews.json create mode 100644 types/test-cases/round-trips/045-file-edit-variants.json create mode 100644 types/test-cases/round-trips/046-file-edit-preview.json create mode 100644 types/test-cases/round-trips/047-file-edit-tool-result.json create mode 100644 types/test-cases/round-trips/048-file-edit-empty-preview.json create mode 100644 types/test-cases/round-trips/049-file-edit-unknown-fields.json diff --git a/AGENTS.md b/AGENTS.md index 4d6e5a5d9..d74ea619a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -91,3 +91,21 @@ Do **not** edit `CHANGELOG.md` files for normal feature/fix PRs and do not invent a `## [X.Y.Z]` heading. Changelogs are updated by the release maintainer per [`RELEASING.md`](RELEASING.md). Run `npm run verify:change-fragments` to validate fragment JSON. + +## Preserve protocol object types + +For new or changed protocol objects with a fixed schema, prefer named, reusable +types in `types/` and generated native models. Do not replace a known shape with +raw JSON to work around a generator gap. + +Reserve raw JSON for intentionally open data, such as `_meta`, JSON Schema and +tool payloads, provider extensions, and unknown variants. + +Update the canonical definitions and each affected generator. Do not hand-edit +generated files. Preserve wire names, nesting, optionality, numeric mappings, +and collection wrappers such as `{ items: ... }`. + +Check source and binary compatibility separately from JSON compatibility. +Define an explicit migration for public API changes. Keep native decoding +errors visible. Add type-level and wire checks so a known shape cannot become +raw JSON without detection. diff --git a/clients/dotnet/src/AgentHostProtocol.Abstractions/Generated/Actions.generated.cs b/clients/dotnet/src/AgentHostProtocol.Abstractions/Generated/Actions.generated.cs index 01ee2af4a..23f4f272f 100644 --- a/clients/dotnet/src/AgentHostProtocol.Abstractions/Generated/Actions.generated.cs +++ b/clients/dotnet/src/AgentHostProtocol.Abstractions/Generated/Actions.generated.cs @@ -1323,7 +1323,7 @@ public sealed record ChatToolCallReadyAction /// File edits that this tool call will perform, for preview before confirmation [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public JsonElement? Edits { get; init; } + public FileEditCollection? Edits { get; init; } /// Whether the agent host allows the client to edit the tool's input parameters before confirming [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] diff --git a/clients/dotnet/src/AgentHostProtocol.Abstractions/Generated/JsonSerializerContext.generated.cs b/clients/dotnet/src/AgentHostProtocol.Abstractions/Generated/JsonSerializerContext.generated.cs index 9b1640215..1df6c00b6 100644 --- a/clients/dotnet/src/AgentHostProtocol.Abstractions/Generated/JsonSerializerContext.generated.cs +++ b/clients/dotnet/src/AgentHostProtocol.Abstractions/Generated/JsonSerializerContext.generated.cs @@ -200,6 +200,9 @@ namespace Microsoft.AgentHostProtocol; [JsonSerializable(typeof(FetchTurnsParams))] [JsonSerializable(typeof(FetchTurnsResult))] [JsonSerializable(typeof(FileEdit))] +[JsonSerializable(typeof(FileEditCollection))] +[JsonSerializable(typeof(FileEditDiffStats))] +[JsonSerializable(typeof(FileEditSide))] [JsonSerializable(typeof(ForkChatSource))] [JsonSerializable(typeof(HookCustomization))] [JsonSerializable(typeof(Icon))] diff --git a/clients/dotnet/src/AgentHostProtocol.Abstractions/Generated/State.generated.cs b/clients/dotnet/src/AgentHostProtocol.Abstractions/Generated/State.generated.cs index 5ead9d4cf..1f93df312 100644 --- a/clients/dotnet/src/AgentHostProtocol.Abstractions/Generated/State.generated.cs +++ b/clients/dotnet/src/AgentHostProtocol.Abstractions/Generated/State.generated.cs @@ -2795,7 +2795,7 @@ public sealed record ToolCallPendingConfirmationState /// File edits that this tool call will perform, for preview before confirmation [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public JsonElement? Edits { get; init; } + public FileEditCollection? Edits { get; init; } /// Whether the agent host allows the client to edit the tool's input parameters before confirming [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] @@ -3274,15 +3274,15 @@ public sealed record ToolResultFileEditContent { /// The file state before the edit. Absent for file creations or for in-place file edits. [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public JsonElement? Before { get; init; } + public FileEditSide? Before { get; init; } /// The file state after the edit. Absent for file deletions. [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public JsonElement? After { get; init; } + public FileEditSide? After { get; init; } /// Optional diff display metadata [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public JsonElement? Diff { get; init; } + public FileEditDiffStats? Diff { get; init; } public ToolResultContentType Type { get; init; } } @@ -4337,6 +4337,22 @@ public sealed record ToolCallMcpContributor public required string CustomizationId { get; init; } } +public sealed record FileEditSide +{ + public required string Uri { get; init; } + + public required ContentRef Content { get; init; } +} + +public sealed record FileEditDiffStats +{ + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public long? Added { get; init; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public long? Removed { get; init; } +} + /// Describes a file modification with before/after state and diff metadata. /// /// Supports creates (only `after`), deletes (only `before`), renames/moves @@ -4345,15 +4361,20 @@ public sealed record FileEdit { /// The file state before the edit. Absent for file creations or for in-place file edits. [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public JsonElement? Before { get; init; } + public FileEditSide? Before { get; init; } /// The file state after the edit. Absent for file deletions. [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public JsonElement? After { get; init; } + public FileEditSide? After { get; init; } /// Optional diff display metadata [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public JsonElement? Diff { get; init; } + public FileEditDiffStats? Diff { get; init; } +} + +public sealed record FileEditCollection +{ + public required List Items { get; init; } } /// Lightweight terminal metadata exposed on the root state. diff --git a/clients/dotnet/tests/AgentHostProtocol.Tests/FileEditTypesTests.cs b/clients/dotnet/tests/AgentHostProtocol.Tests/FileEditTypesTests.cs new file mode 100644 index 000000000..76622da9a --- /dev/null +++ b/clients/dotnet/tests/AgentHostProtocol.Tests/FileEditTypesTests.cs @@ -0,0 +1,81 @@ +#nullable enable + +using System; +using System.Collections.Generic; +using System.Text.Json; +using Microsoft.AgentHostProtocol; +using Xunit; + +namespace Microsoft.AgentHostProtocol.Tests; + +public sealed class FileEditTypesTests +{ + [Fact] + public void TypedFileEditsUseGeneratedMetadata() + { + var options = new JsonSerializerOptions + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + TypeInfoResolver = AhpJsonMetadata.Default, + }; + foreach (Type type in new[] + { + typeof(FileEditSide), + typeof(FileEditDiffStats), + typeof(FileEditCollection), + }) + { + Assert.NotNull(AhpJsonMetadata.Default.GetTypeInfo(type, options)); + } + + var side = new FileEditSide + { + Uri = "file:///workspace/file.txt", + Content = new ContentRef + { + Uri = "ahp-content:/file", + SizeHint = 32, + ContentType = "text/plain", + Nonce = "v1", + }, + }; + var original = new FileEditCollection + { + Items = new List + { + new() + { + Before = side, + After = side, + Diff = new FileEditDiffStats { Added = 2147483648L, Removed = 0 }, + }, + }, + }; + string wire = JsonSerializer.Serialize(original, options); + var decoded = Assert.IsType( + JsonSerializer.Deserialize(wire, options)); + FileEdit item = Assert.Single(decoded.Items); + var before = Assert.IsType(item.Before); + var after = Assert.IsType(item.After); + var stats = Assert.IsType(item.Diff); + long? added = stats.Added; + Assert.Equal(side.Uri, before.Uri); + Assert.Equal("ahp-content:/file", after.Content.Uri); + Assert.Equal(32L, after.Content.SizeHint); + Assert.Equal("text/plain", after.Content.ContentType); + Assert.Equal("v1", after.Content.Nonce); + Assert.Equal(2147483648L, added); + Assert.Equal(0L, stats.Removed); + } + + [Fact] + public void EmptyCollectionPreservesItems() + { + var empty = new FileEditCollection { Items = new List() }; + string wire = SystemTextJsonAhpSerializer.Default.Serialize(empty); + using JsonDocument document = JsonDocument.Parse(wire); + JsonElement items = document.RootElement.GetProperty("items"); + Assert.Equal(JsonValueKind.Array, items.ValueKind); + Assert.Equal(0, items.GetArrayLength()); + } +} diff --git a/clients/go/ahptypes/actions.generated.go b/clients/go/ahptypes/actions.generated.go index d940c8bda..10d5f4143 100644 --- a/clients/go/ahptypes/actions.generated.go +++ b/clients/go/ahptypes/actions.generated.go @@ -379,7 +379,7 @@ type ChatToolCallReadyAction struct { // Risk assessment that informed the confirmation requirement. RiskAssessment *ToolCallRiskAssessment `json:"riskAssessment,omitempty"` // File edits that this tool call will perform, for preview before confirmation - Edits *json.RawMessage `json:"edits,omitempty"` + Edits *FileEditCollection `json:"edits,omitempty"` // Whether the agent host allows the client to edit the tool's input parameters before confirming Editable *bool `json:"editable,omitempty"` // If set, the tool was auto-confirmed and transitions directly to `running` diff --git a/clients/go/ahptypes/file_edits_test.go b/clients/go/ahptypes/file_edits_test.go new file mode 100644 index 000000000..bd7cd8827 --- /dev/null +++ b/clients/go/ahptypes/file_edits_test.go @@ -0,0 +1,59 @@ +package ahptypes + +import ( + "encoding/json" + "reflect" + "testing" +) + +func TestFileEditTypesRoundTrip(t *testing.T) { + size := int64(32) + added := int64(2147483648) + removed := int64(0) + contentType := "text/plain" + nonce := "v1" + side := FileEditSide{ + Uri: "file:///workspace/file.txt", + Content: ContentRef{ + Uri: "ahp-content:/file", + SizeHint: &size, + ContentType: &contentType, + Nonce: &nonce, + }, + } + original := FileEditCollection{ + Items: []FileEdit{{ + Before: &side, + After: &side, + Diff: &FileEditDiffStats{ + Added: &added, + Removed: &removed, + }, + }}, + } + encoded, err := json.Marshal(original) + if err != nil { + t.Fatal(err) + } + var decoded FileEditCollection + if err := json.Unmarshal(encoded, &decoded); err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(decoded, original) { + t.Fatalf("decoded collection = %#v, want %#v", decoded, original) + } + var after *FileEditSide = decoded.Items[0].After + if after == nil || after.Content.Uri != "ahp-content:/file" { + t.Fatalf("unexpected typed side: %#v", after) + } +} + +func TestFileEditEmptyCollectionPreservesItems(t *testing.T) { + encoded, err := json.Marshal(FileEditCollection{Items: []FileEdit{}}) + if err != nil { + t.Fatal(err) + } + if string(encoded) != `{"items":[]}` { + t.Fatalf("encoded collection = %s, want required empty items", encoded) + } +} diff --git a/clients/go/ahptypes/state.generated.go b/clients/go/ahptypes/state.generated.go index 269b50f54..aab24a13a 100644 --- a/clients/go/ahptypes/state.generated.go +++ b/clients/go/ahptypes/state.generated.go @@ -2104,7 +2104,7 @@ type ToolCallPendingConfirmationState struct { // Risk assessment that informed the confirmation requirement. RiskAssessment *ToolCallRiskAssessment `json:"riskAssessment,omitempty"` // File edits that this tool call will perform, for preview before confirmation - Edits *json.RawMessage `json:"edits,omitempty"` + Edits *FileEditCollection `json:"edits,omitempty"` // Whether the agent host allows the client to edit the tool's input parameters before confirming Editable *bool `json:"editable,omitempty"` // Options the server offers for this confirmation. When present, the client @@ -2431,11 +2431,11 @@ type ToolResultResourceContent struct { // Describes a file modification performed by a tool. type ToolResultFileEditContent struct { // The file state before the edit. Absent for file creations or for in-place file edits. - Before *json.RawMessage `json:"before,omitempty"` + Before *FileEditSide `json:"before,omitempty"` // The file state after the edit. Absent for file deletions. - After *json.RawMessage `json:"after,omitempty"` + After *FileEditSide `json:"after,omitempty"` // Optional diff display metadata - Diff *json.RawMessage `json:"diff,omitempty"` + Diff *FileEditDiffStats `json:"diff,omitempty"` Type ToolResultContentType `json:"type"` } @@ -3238,17 +3238,31 @@ type ToolCallMcpContributor struct { CustomizationId string `json:"customizationId"` } +type FileEditSide struct { + Uri URI `json:"uri"` + Content ContentRef `json:"content"` +} + +type FileEditDiffStats struct { + Added *int64 `json:"added,omitempty"` + Removed *int64 `json:"removed,omitempty"` +} + // Describes a file modification with before/after state and diff metadata. // // Supports creates (only `after`), deletes (only `before`), renames/moves // (different `uri` in `before` and `after`), and edits (same `uri`, different content). type FileEdit struct { // The file state before the edit. Absent for file creations or for in-place file edits. - Before *json.RawMessage `json:"before,omitempty"` + Before *FileEditSide `json:"before,omitempty"` // The file state after the edit. Absent for file deletions. - After *json.RawMessage `json:"after,omitempty"` + After *FileEditSide `json:"after,omitempty"` // Optional diff display metadata - Diff *json.RawMessage `json:"diff,omitempty"` + Diff *FileEditDiffStats `json:"diff,omitempty"` +} + +type FileEditCollection struct { + Items []FileEdit `json:"items"` } // Outcome of a command run in a terminal-style tool, filled in on diff --git a/clients/kotlin/README.md b/clients/kotlin/README.md index 96e9a40b2..cae975d5c 100644 --- a/clients/kotlin/README.md +++ b/clients/kotlin/README.md @@ -86,6 +86,11 @@ when (val action = envelope.action) { } ``` +When migrating to a release with typed file-edit models, see the +[file-edit migration guide](../../docs/guide/changesets.md#typed-file-edit-models). +It covers the new model names, constructor and property changes, and decoding +behavior. This is a client API migration, not a new JSON protocol. + ### What's in the box - **`com.microsoft.agenthostprotocol.Ahp`** — `Ahp.json` configured `Json` instance. diff --git a/clients/kotlin/src/main/kotlin/com/microsoft/agenthostprotocol/generated/Actions.generated.kt b/clients/kotlin/src/main/kotlin/com/microsoft/agenthostprotocol/generated/Actions.generated.kt index e0326f283..78e8ed85f 100644 --- a/clients/kotlin/src/main/kotlin/com/microsoft/agenthostprotocol/generated/Actions.generated.kt +++ b/clients/kotlin/src/main/kotlin/com/microsoft/agenthostprotocol/generated/Actions.generated.kt @@ -440,7 +440,7 @@ data class ChatToolCallReadyAction( /** * File edits that this tool call will perform, for preview before confirmation */ - val edits: JsonElement? = null, + val edits: FileEditCollection? = null, /** * Whether the agent host allows the client to edit the tool's input parameters before confirming */ 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 9fee270bb..8a3a79eb2 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 @@ -3067,7 +3067,7 @@ data class ToolCallPendingConfirmationState( /** * File edits that this tool call will perform, for preview before confirmation */ - val edits: JsonElement? = null, + val edits: FileEditCollection? = null, /** * Whether the agent host allows the client to edit the tool's input parameters before confirming */ @@ -3580,15 +3580,15 @@ data class ToolResultFileEditContent( /** * The file state before the edit. Absent for file creations or for in-place file edits. */ - val before: JsonElement? = null, + val before: FileEditSide? = null, /** * The file state after the edit. Absent for file deletions. */ - val after: JsonElement? = null, + val after: FileEditSide? = null, /** * Optional diff display metadata */ - val diff: JsonElement? = null, + val diff: FileEditDiffStats? = null, val type: ToolResultContentType ) @@ -4521,20 +4521,37 @@ data class ToolCallMcpContributor( val customizationId: String ) +@Serializable +data class FileEditSide( + val uri: String, + val content: ContentRef +) + +@Serializable +data class FileEditDiffStats( + val added: Long? = null, + val removed: Long? = null +) + @Serializable data class FileEdit( /** * The file state before the edit. Absent for file creations or for in-place file edits. */ - val before: JsonElement? = null, + val before: FileEditSide? = null, /** * The file state after the edit. Absent for file deletions. */ - val after: JsonElement? = null, + val after: FileEditSide? = null, /** * Optional diff display metadata */ - val diff: JsonElement? = null + val diff: FileEditDiffStats? = null +) + +@Serializable +data class FileEditCollection( + val items: List ) @Serializable diff --git a/clients/kotlin/src/test/kotlin/com/microsoft/agenthostprotocol/FileEditTypesTest.kt b/clients/kotlin/src/test/kotlin/com/microsoft/agenthostprotocol/FileEditTypesTest.kt new file mode 100644 index 000000000..ed3b3a755 --- /dev/null +++ b/clients/kotlin/src/test/kotlin/com/microsoft/agenthostprotocol/FileEditTypesTest.kt @@ -0,0 +1,249 @@ +package com.microsoft.agenthostprotocol + +import com.microsoft.agenthostprotocol.generated.ChangesetFile +import com.microsoft.agenthostprotocol.generated.ChatToolCallReadyAction +import com.microsoft.agenthostprotocol.generated.ContentRef +import com.microsoft.agenthostprotocol.generated.FileEdit +import com.microsoft.agenthostprotocol.generated.FileEditCollection +import com.microsoft.agenthostprotocol.generated.FileEditDiffStats +import com.microsoft.agenthostprotocol.generated.FileEditSide +import com.microsoft.agenthostprotocol.generated.Snapshot +import com.microsoft.agenthostprotocol.generated.ToolCallPendingConfirmationState +import com.microsoft.agenthostprotocol.generated.ToolResultContent +import com.microsoft.agenthostprotocol.generated.ToolResultFileEditContent +import kotlinx.serialization.SerializationException +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.jsonObject +import org.junit.jupiter.api.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertIs +import kotlin.test.assertNotNull + +class FileEditTypesTest { + private val json: Json = Ahp.json + private val before = FileEditSide( + uri = "file:///workspace/old.txt", + content = ContentRef( + uri = "ahp-content:/before", + sizeHint = 32L, + contentType = "text/plain", + nonce = "before-v1" + ) + ) + private val after = FileEditSide( + uri = "file:///workspace/new.txt", + content = ContentRef( + uri = "ahp-content:/after", + sizeHint = 64L, + contentType = "text/plain", + nonce = "after-v2" + ) + ) + private val edit = FileEdit( + before = before, + after = after, + diff = FileEditDiffStats(added = 2_147_483_648L, removed = 0L) + ) + private val editWire = """{"before":{"uri":"file:///workspace/old.txt","content":{"uri":"ahp-content:/before","sizeHint":32,"contentType":"text/plain","nonce":"before-v1"}},"after":{"uri":"file:///workspace/new.txt","content":{"uri":"ahp-content:/after","sizeHint":64,"contentType":"text/plain","nonce":"after-v2"}},"diff":{"added":2147483648,"removed":0}}""" + private val malformedEdits = buildList { + add("""{"before":{}}""") + add("""{"after":{"uri":"file:///workspace/file.txt"}}""") + add("""{"after":{"content":{"uri":"ahp-content:/file"}}}""") + add("""{"after":{"uri":"file:///workspace/file.txt","content":{}}}""") + add("""{"after":{"uri":"file:///workspace/file.txt","content":[]}}""") + add("""{"before":[]}""") + add("""{"diff":{"added":{}}}""") + add("""{"diff":{"removed":[]}}""") + } + private val malformedCollections = buildList { + add("{}") + add("""{"items":null}""") + add("""{"items":{}}""") + add("""{"items":[null]}""") + } + + @Test + fun `file edit properties have shared serializable types`() { + val decoded = json.decodeFromString( + deserializer = FileEdit.serializer(), + string = editWire + ) + val decodedSide: FileEditSide = assertNotNull(decoded.after) + val decodedStats: FileEditDiffStats = assertNotNull(decoded.diff) + val added: Long? = decodedStats.added + assertEquals(expected = after, actual = decodedSide) + assertEquals(expected = before, actual = decoded.before) + assertEquals(expected = 2_147_483_648L, actual = added) + assertEquals(expected = edit, actual = decoded) + assertEquals( + expected = json.parseToJsonElement(editWire), + actual = json.parseToJsonElement( + json.encodeToString(serializer = FileEdit.serializer(), value = edit) + ) + ) + } + + @Test + fun `ready actions and pending states expose the same collection type`() { + val collection = FileEditCollection(items = listOf(edit)) + val ready = json.decodeFromString( + deserializer = ChatToolCallReadyAction.serializer(), + string = """{"type":"chat/toolCallReady","turnId":"t1","toolCallId":"tc1","invocationMessage":"Review","edits":{"items":[$editWire]}}""" + ) + val pending = json.decodeFromString( + deserializer = ToolCallPendingConfirmationState.serializer(), + string = """{"status":"pending-confirmation","toolCallId":"tc1","toolName":"edit","displayName":"Edit","invocationMessage":"Review","edits":{"items":[$editWire]}}""" + ) + val readyEdits: FileEditCollection? = ready.edits + val pendingEdits: FileEditCollection? = pending.edits + assertEquals(expected = collection, actual = readyEdits) + assertEquals(expected = collection, actual = pendingEdits) + } + + @Test + fun `file edit tool results expose the same side and statistics types`() { + val wire = JsonObject( + json.parseToJsonElement(editWire).jsonObject + + ("type" to JsonPrimitive("fileEdit")) + ) + val result = json.decodeFromString( + deserializer = ToolResultFileEditContent.serializer(), + string = wire.toString() + ) + val resultBefore: FileEditSide? = result.before + val resultAfter: FileEditSide? = result.after + val resultStats: FileEditDiffStats? = result.diff + assertEquals(expected = before, actual = resultBefore) + assertEquals(expected = after, actual = resultAfter) + assertEquals(expected = edit.diff, actual = resultStats) + } + + @Test + fun `empty objects and collections preserve key presence`() { + assertEquals( + expected = "{}", + actual = json.encodeToString( + serializer = FileEdit.serializer(), + value = FileEdit() + ) + ) + assertEquals( + expected = """{"diff":{}}""", + actual = json.encodeToString( + serializer = FileEdit.serializer(), + value = FileEdit(diff = FileEditDiffStats()) + ) + ) + assertEquals( + expected = """{"items":[]}""", + actual = json.encodeToString( + serializer = FileEditCollection.serializer(), + value = FileEditCollection(items = emptyList()) + ) + ) + } + + @Test + fun `malformed known file edits report serialization errors`() { + for (wire in malformedEdits) { + assertFailsWith(message = wire) { + json.decodeFromString( + deserializer = FileEdit.serializer(), + string = wire + ) + } + val resultWire = JsonObject( + json.parseToJsonElement(wire).jsonObject + + ("type" to JsonPrimitive("fileEdit")) + ) + assertFailsWith(message = resultWire.toString()) { + json.decodeFromString( + deserializer = ToolResultContent.serializer(), + string = resultWire.toString() + ) + } + } + } + + @Test + fun `required collection items do not receive silent defaults`() { + for (wire in malformedCollections) { + assertFailsWith(message = wire) { + json.decodeFromString( + deserializer = FileEditCollection.serializer(), + string = wire + ) + } + } + } + + @Test + fun `a malformed file fails the containing changeset snapshot`() { + val wire = """{"resource":"ahp-changeset:/c1","fromSeq":1,"state":{"status":"ready","files":[{"id":"good","edit":$editWire},{"id":"bad","edit":{"after":{"uri":"file:///workspace/bad.txt","content":{}}}}]}}""" + assertFailsWith { + json.decodeFromString( + deserializer = Snapshot.serializer(), + string = wire + ) + } + } + + @Test + fun `known file edit shapes ignore unknown fields`() { + val wire = """{"futureCollection":1,"items":[{"futureEdit":2,"after":{"uri":"file:///workspace/file.txt","futureSide":3,"content":{"uri":"ahp-content:/file","futureContent":4}},"diff":{"futureStats":5}}]}""" + val decoded = json.decodeFromString( + deserializer = FileEditCollection.serializer(), + string = wire + ) + assertEquals( + expected = json.parseToJsonElement("""{"items":[{"after":{"uri":"file:///workspace/file.txt","content":{"uri":"ahp-content:/file"}},"diff":{}}]}"""), + actual = json.parseToJsonElement( + json.encodeToString( + serializer = FileEditCollection.serializer(), + value = decoded + ) + ) + ) + } + + @Test + fun `changeset extension metadata remains raw`() { + val wire = """{"id":"file","edit":$editWire,"_meta":{"vendor":{"keep":true}}}""" + val decoded = json.decodeFromString( + deserializer = ChangesetFile.serializer(), + string = wire + ) + assertEquals( + expected = json.parseToJsonElement(wire), + actual = json.parseToJsonElement( + json.encodeToString( + serializer = ChangesetFile.serializer(), + value = decoded + ) + ) + ) + } + + @Test + fun `unknown tool result variants retain their raw payload`() { + val wire = """{"type":"futureFileEdit","payload":{"keep":true}}""" + val decoded = json.decodeFromString( + deserializer = ToolResultContent.serializer(), + string = wire + ) + val unknown = assertIs(decoded) + assertEquals(expected = json.parseToJsonElement(wire), actual = unknown.raw) + assertEquals( + expected = json.parseToJsonElement(wire), + actual = json.parseToJsonElement( + json.encodeToString( + serializer = ToolResultContent.serializer(), + value = decoded + ) + ) + ) + } +} diff --git a/clients/rust/crates/ahp-types/src/actions.rs b/clients/rust/crates/ahp-types/src/actions.rs index 05dba2c2b..b078ddfab 100644 --- a/clients/rust/crates/ahp-types/src/actions.rs +++ b/clients/rust/crates/ahp-types/src/actions.rs @@ -18,11 +18,11 @@ use crate::state::{ Changeset, ChangesetFile, ChangesetOperation, ChangesetOperationStatus, ChangesetStatus, ChatInputAnswer, ChatInputRequest, ChatInputResponseKind, ChatInteractivity, ChatOrigin, ChatSummary, ConfirmationOption, ContentRef, Customization, CustomizationEnablement, ErrorInfo, - ErrorResponsePart, McpAuthRequirement, McpServerState, Message, ModelSelection, - PendingMessageKind, ResponsePart, SessionActiveClient, SessionInputRequest, SideChatSelection, - TerminalClaim, TerminalInfo, TextRange, ToolCallCancellationReason, ToolCallConfirmationReason, - ToolCallContributor, ToolCallResult, ToolCallRiskAssessment, ToolDefinition, ToolInput, - ToolResultContent, Turn, UsageInfo, + ErrorResponsePart, FileEditCollection, McpAuthRequirement, McpServerState, Message, + ModelSelection, PendingMessageKind, ResponsePart, SessionActiveClient, SessionInputRequest, + SideChatSelection, TerminalClaim, TerminalInfo, TextRange, ToolCallCancellationReason, + ToolCallConfirmationReason, ToolCallContributor, ToolCallResult, ToolCallRiskAssessment, + ToolDefinition, ToolInput, ToolResultContent, Turn, UsageInfo, }; // ─── ActionType ────────────────────────────────────────────────────── @@ -711,7 +711,7 @@ pub struct ChatToolCallReadyAction { pub risk_assessment: Option, /// File edits that this tool call will perform, for preview before confirmation #[serde(default, skip_serializing_if = "Option::is_none")] - pub edits: Option, + pub edits: Option, /// Whether the agent host allows the client to edit the tool's input parameters before confirming #[serde(default, skip_serializing_if = "Option::is_none")] pub editable: Option, diff --git a/clients/rust/crates/ahp-types/src/state.rs b/clients/rust/crates/ahp-types/src/state.rs index 6d7ff245a..57786c536 100644 --- a/clients/rust/crates/ahp-types/src/state.rs +++ b/clients/rust/crates/ahp-types/src/state.rs @@ -3333,7 +3333,7 @@ pub struct ToolCallPendingConfirmationState { pub risk_assessment: Option, /// File edits that this tool call will perform, for preview before confirmation #[serde(default, skip_serializing_if = "Option::is_none")] - pub edits: Option, + pub edits: Option, /// Whether the agent host allows the client to edit the tool's input parameters before confirming #[serde(default, skip_serializing_if = "Option::is_none")] pub editable: Option, @@ -3727,13 +3727,13 @@ pub struct ToolResultResourceContent { pub struct ToolResultFileEditContent { /// The file state before the edit. Absent for file creations or for in-place file edits. #[serde(default, skip_serializing_if = "Option::is_none")] - pub before: Option, + pub before: Option, /// The file state after the edit. Absent for file deletions. #[serde(default, skip_serializing_if = "Option::is_none")] - pub after: Option, + pub after: Option, /// Optional diff display metadata #[serde(default, skip_serializing_if = "Option::is_none")] - pub diff: Option, + pub diff: Option, } /// A reference to a terminal whose output is relevant to this tool result. @@ -4637,6 +4637,22 @@ pub struct ToolCallMcpContributor { pub customization_id: String, } +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct FileEditSide { + pub uri: Uri, + pub content: ContentRef, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub struct FileEditDiffStats { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub added: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub removed: Option, +} + /// Describes a file modification with before/after state and diff metadata. /// /// Supports creates (only `after`), deletes (only `before`), renames/moves @@ -4646,13 +4662,19 @@ pub struct ToolCallMcpContributor { pub struct FileEdit { /// The file state before the edit. Absent for file creations or for in-place file edits. #[serde(default, skip_serializing_if = "Option::is_none")] - pub before: Option, + pub before: Option, /// The file state after the edit. Absent for file deletions. #[serde(default, skip_serializing_if = "Option::is_none")] - pub after: Option, + pub after: Option, /// Optional diff display metadata #[serde(default, skip_serializing_if = "Option::is_none")] - pub diff: Option, + pub diff: Option, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct FileEditCollection { + pub items: Vec, } /// Outcome of a command run in a terminal-style tool, filled in on diff --git a/clients/rust/crates/ahp-types/tests/file_edits.rs b/clients/rust/crates/ahp-types/tests/file_edits.rs new file mode 100644 index 000000000..c6bd0bbb7 --- /dev/null +++ b/clients/rust/crates/ahp-types/tests/file_edits.rs @@ -0,0 +1,43 @@ +use ahp_types::state::{ContentRef, FileEdit, FileEditCollection, FileEditDiffStats, FileEditSide}; +use serde_json::json; + +#[test] +fn typed_file_edits_round_trip() -> Result<(), serde_json::Error> { + let side = FileEditSide { + uri: "file:///workspace/file.txt".into(), + content: ContentRef { + uri: "ahp-content:/file".into(), + size_hint: Some(32), + content_type: Some("text/plain".into()), + nonce: Some("v1".into()), + }, + }; + let original = FileEditCollection { + items: vec![FileEdit { + before: Some(side.clone()), + after: Some(side), + diff: Some(FileEditDiffStats { + added: Some(2_147_483_648), + removed: Some(0), + }), + }], + }; + let encoded = serde_json::to_value(&original)?; + let decoded: FileEditCollection = serde_json::from_value(encoded)?; + assert_eq!(decoded, original); + let after: Option<&FileEditSide> = decoded.items[0].after.as_ref(); + assert_eq!( + after.map(|side| side.content.uri.as_str()), + Some("ahp-content:/file") + ); + let added: Option = decoded.items[0].diff.as_ref().and_then(|stats| stats.added); + assert_eq!(added, Some(2_147_483_648)); + Ok(()) +} + +#[test] +fn empty_collection_preserves_items() -> Result<(), serde_json::Error> { + let empty = FileEditCollection { items: vec![] }; + assert_eq!(serde_json::to_value(empty)?, json!({"items": []})); + Ok(()) +} diff --git a/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/Actions.generated.swift b/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/Actions.generated.swift index 3abc0a935..756198f26 100644 --- a/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/Actions.generated.swift +++ b/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/Actions.generated.swift @@ -715,7 +715,7 @@ public struct ChatToolCallReadyAction: Codable, Sendable { /// Risk assessment that informed the confirmation requirement. public var riskAssessment: ToolCallRiskAssessment? /// File edits that this tool call will perform, for preview before confirmation - public var edits: AnyCodable? + public var edits: FileEditCollection? /// Whether the agent host allows the client to edit the tool's input parameters before confirming public var editable: Bool? /// If set, the tool was auto-confirmed and transitions directly to `running` @@ -754,7 +754,7 @@ public struct ChatToolCallReadyAction: Codable, Sendable { toolInput: ToolInput? = nil, confirmationTitle: StringOrMarkdown? = nil, riskAssessment: ToolCallRiskAssessment? = nil, - edits: AnyCodable? = nil, + edits: FileEditCollection? = nil, editable: Bool? = nil, confirmed: ToolCallConfirmationReason? = nil, options: [ConfirmationOption]? = nil diff --git a/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/State.generated.swift b/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/State.generated.swift index 313783d66..0752a3d48 100644 --- a/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/State.generated.swift +++ b/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/State.generated.swift @@ -3396,7 +3396,7 @@ public struct ToolCallPendingConfirmationState: Codable, Sendable { /// Risk assessment that informed the confirmation requirement. public var riskAssessment: ToolCallRiskAssessment? /// File edits that this tool call will perform, for preview before confirmation - public var edits: AnyCodable? + public var edits: FileEditCollection? /// Whether the agent host allows the client to edit the tool's input parameters before confirming public var editable: Bool? /// Options the server offers for this confirmation. When present, the client @@ -3434,7 +3434,7 @@ public struct ToolCallPendingConfirmationState: Codable, Sendable { status: ToolCallStatus, confirmationTitle: StringOrMarkdown? = nil, riskAssessment: ToolCallRiskAssessment? = nil, - edits: AnyCodable? = nil, + edits: FileEditCollection? = nil, editable: Bool? = nil, options: [ConfirmationOption]? = nil ) { @@ -4105,17 +4105,17 @@ public struct ToolResultResourceContent: Codable, Sendable { public struct ToolResultFileEditContent: Codable, Sendable { /// The file state before the edit. Absent for file creations or for in-place file edits. - public var before: AnyCodable? + public var before: FileEditSide? /// The file state after the edit. Absent for file deletions. - public var after: AnyCodable? + public var after: FileEditSide? /// Optional diff display metadata - public var diff: AnyCodable? + public var diff: FileEditDiffStats? public var type: ToolResultContentType public init( - before: AnyCodable? = nil, - after: AnyCodable? = nil, - diff: AnyCodable? = nil, + before: FileEditSide? = nil, + after: FileEditSide? = nil, + diff: FileEditDiffStats? = nil, type: ToolResultContentType ) { self.before = before @@ -5296,18 +5296,44 @@ public struct ToolCallMcpContributor: Codable, Sendable { } } +public struct FileEditSide: Codable, Sendable { + public var uri: String + public var content: ContentRef + + public init( + uri: String, + content: ContentRef + ) { + self.uri = uri + self.content = content + } +} + +public struct FileEditDiffStats: Codable, Sendable { + public var added: Int? + public var removed: Int? + + public init( + added: Int? = nil, + removed: Int? = nil + ) { + self.added = added + self.removed = removed + } +} + public struct FileEdit: Codable, Sendable { /// The file state before the edit. Absent for file creations or for in-place file edits. - public var before: AnyCodable? + public var before: FileEditSide? /// The file state after the edit. Absent for file deletions. - public var after: AnyCodable? + public var after: FileEditSide? /// Optional diff display metadata - public var diff: AnyCodable? + public var diff: FileEditDiffStats? public init( - before: AnyCodable? = nil, - after: AnyCodable? = nil, - diff: AnyCodable? = nil + before: FileEditSide? = nil, + after: FileEditSide? = nil, + diff: FileEditDiffStats? = nil ) { self.before = before self.after = after @@ -5315,6 +5341,16 @@ public struct FileEdit: Codable, Sendable { } } +public struct FileEditCollection: Codable, Sendable { + public var items: [FileEdit] + + public init( + items: [FileEdit] + ) { + self.items = items + } +} + public struct TerminalCommandResult: Codable, Sendable { /// Exit code from the completed command, if reported by the runtime public var exitCode: Int? diff --git a/clients/swift/AgentHostProtocol/Tests/AgentHostProtocolTests/FileEditTypesTests.swift b/clients/swift/AgentHostProtocol/Tests/AgentHostProtocolTests/FileEditTypesTests.swift new file mode 100644 index 000000000..a6cf93820 --- /dev/null +++ b/clients/swift/AgentHostProtocol/Tests/AgentHostProtocolTests/FileEditTypesTests.swift @@ -0,0 +1,47 @@ +import Foundation +import XCTest +import AgentHostProtocol + +final class FileEditTypesTests: XCTestCase { + func testTypedFileEditsRoundTrip() throws { + let side = FileEditSide( + uri: "file:///workspace/file.txt", + content: ContentRef( + uri: "ahp-content:/file", + sizeHint: 32, + contentType: "text/plain", + nonce: "v1" + ) + ) + let edit = FileEdit( + before: side, + after: side, + diff: FileEditDiffStats(added: 2_147_483_648, removed: 0) + ) + let original = FileEditCollection(items: [edit]) + let encoded = try JSONEncoder().encode(original) + let decoded = try JSONDecoder().decode(FileEditCollection.self, from: encoded) + XCTAssertEqual(decoded.items.count, 1) + let before: FileEditSide = try XCTUnwrap(decoded.items[0].before) + let after: FileEditSide = try XCTUnwrap(decoded.items[0].after) + let stats: FileEditDiffStats = try XCTUnwrap(decoded.items[0].diff) + let added: Int? = stats.added + XCTAssertEqual(before.uri, side.uri) + XCTAssertEqual(after.content.uri, "ahp-content:/file") + XCTAssertEqual(after.content.sizeHint, 32) + XCTAssertEqual(after.content.contentType, "text/plain") + XCTAssertEqual(after.content.nonce, "v1") + XCTAssertEqual(added, 2_147_483_648) + XCTAssertEqual(stats.removed, 0) + } + + func testEmptyCollectionPreservesItems() throws { + let encoded = try JSONEncoder().encode(FileEditCollection(items: [])) + let object = try XCTUnwrap( + try JSONSerialization.jsonObject(with: encoded) as? [String: Any] + ) + XCTAssertEqual(Set(object.keys), Set(["items"])) + let items = try XCTUnwrap(object["items"] as? [Any]) + XCTAssertTrue(items.isEmpty) + } +} diff --git a/docs/.changes/20260914-typed-file-edits.json b/docs/.changes/20260914-typed-file-edits.json new file mode 100644 index 000000000..1a52cc925 --- /dev/null +++ b/docs/.changes/20260914-typed-file-edits.json @@ -0,0 +1,4 @@ +{ + "type": "changed", + "message": "`FileEdit` and preview `edits` now use named models in generated SDKs. This breaks affected SDK APIs but keeps JSON shapes unchanged." +} diff --git a/docs/guide/changesets.md b/docs/guide/changesets.md index b14c04916..281f7b5ee 100644 --- a/docs/guide/changesets.md +++ b/docs/guide/changesets.md @@ -103,6 +103,44 @@ of the changeset URI: | `changeset/operationStatusChanged` | No | A single operation's `status` transitioned (e.g. `idle → running → error`). | | `changeset/cleared` | No | All files dropped (e.g. branch switched, or the owning session ended). | +### Typed file-edit models + +The client API uses three shared model types: + +| Type | Purpose | +| --- | --- | +| `FileEditSide` | A file URI and its required `ContentRef`. | +| `FileEditDiffStats` | Optional added and removed item counts. | +| `FileEditCollection` | The preview wrapper with required `items`. | + +`FileEdit.before` and `after` use the same side type. +`ToolResultFileEditContent` exposes the same fields. +The ready action and pending-confirmation state both expose previews through +`edits.items`. + +When migrating from a client with raw-JSON file-edit properties, replace JSON +lookups and construction with these typed properties and constructors. +This changes the affected SDK APIs, not the JSON structure. Kotlin consumers +must also rebuild dependent binaries. + +Sides remain optional. In Kotlin, a present side requires its file URI and +content reference. The existing serializer reports missing required fields +and incompatible values. A file that causes a serialization error fails the +containing payload. Callers must handle that error rather than apply a partial +snapshot. + +Each SDK keeps its native validation rules. Go can use zero values for missing +fields. TypeScript types do not perform runtime validation. + +Runtime model decoders accept unknown keys but do not retain them on recognized +objects. Keep the original raw payload separately if forwarding must be +lossless. Intentional extension fields and unknown variants keep their existing +raw-data behavior. + +The statistics count items, such as text lines or notebook cells. They are not +patch data. `ContentRef` retains its existing URI, size hint, content type, and +nonce. This API change does not add diff computation or a new resource protocol. + ### File Review **Review is a capability of the changeset.** A changeset advertises support for diff --git a/schema/actions.schema.json b/schema/actions.schema.json index 70f7a2b5a..cdaaad481 100644 --- a/schema/actions.schema.json +++ b/schema/actions.schema.json @@ -916,18 +916,7 @@ "description": "Risk assessment that informed the confirmation requirement." }, "edits": { - "type": "object", - "properties": { - "items": { - "type": "array", - "items": { - "$ref": "#/$defs/FileEdit" - } - } - }, - "required": [ - "items" - ], + "$ref": "#/$defs/FileEditCollection", "description": "File edits that this tool call will perform, for preview before confirmation" }, "editable": { @@ -2702,56 +2691,64 @@ "uri" ] }, + "FileEditSide": { + "type": "object", + "properties": { + "uri": { + "$ref": "#/$defs/URI" + }, + "content": { + "$ref": "#/$defs/ContentRef" + } + }, + "required": [ + "uri", + "content" + ] + }, + "FileEditDiffStats": { + "type": "object", + "properties": { + "added": { + "type": "number" + }, + "removed": { + "type": "number" + } + } + }, "FileEdit": { "type": "object", "description": "Describes a file modification with before/after state and diff metadata.\n\nSupports creates (only `after`), deletes (only `before`), renames/moves\n(different `uri` in `before` and `after`), and edits (same `uri`, different content).", "properties": { "before": { - "type": "object", - "properties": { - "uri": { - "$ref": "#/$defs/URI" - }, - "content": { - "$ref": "#/$defs/ContentRef" - } - }, - "required": [ - "uri", - "content" - ], + "$ref": "#/$defs/FileEditSide", "description": "The file state before the edit. Absent for file creations or for in-place file edits." }, "after": { - "type": "object", - "properties": { - "uri": { - "$ref": "#/$defs/URI" - }, - "content": { - "$ref": "#/$defs/ContentRef" - } - }, - "required": [ - "uri", - "content" - ], + "$ref": "#/$defs/FileEditSide", "description": "The file state after the edit. Absent for file deletions." }, "diff": { - "type": "object", - "properties": { - "added": { - "type": "number" - }, - "removed": { - "type": "number" - } - }, + "$ref": "#/$defs/FileEditDiffStats", "description": "Optional diff display metadata" } } }, + "FileEditCollection": { + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "$ref": "#/$defs/FileEdit" + } + } + }, + "required": [ + "items" + ] + }, "UsageInfo": { "type": "object", "properties": { @@ -6202,18 +6199,7 @@ "description": "Risk assessment that informed the confirmation requirement." }, "edits": { - "type": "object", - "properties": { - "items": { - "type": "array", - "items": { - "$ref": "#/$defs/FileEdit" - } - } - }, - "required": [ - "items" - ], + "$ref": "#/$defs/FileEditCollection", "description": "File edits that this tool call will perform, for preview before confirmation" }, "editable": { @@ -6718,47 +6704,15 @@ "description": "Describes a file modification performed by a tool.", "properties": { "before": { - "type": "object", - "properties": { - "uri": { - "$ref": "#/$defs/URI" - }, - "content": { - "$ref": "#/$defs/ContentRef" - } - }, - "required": [ - "uri", - "content" - ], + "$ref": "#/$defs/FileEditSide", "description": "The file state before the edit. Absent for file creations or for in-place file edits." }, "after": { - "type": "object", - "properties": { - "uri": { - "$ref": "#/$defs/URI" - }, - "content": { - "$ref": "#/$defs/ContentRef" - } - }, - "required": [ - "uri", - "content" - ], + "$ref": "#/$defs/FileEditSide", "description": "The file state after the edit. Absent for file deletions." }, "diff": { - "type": "object", - "properties": { - "added": { - "type": "number" - }, - "removed": { - "type": "number" - } - }, + "$ref": "#/$defs/FileEditDiffStats", "description": "Optional diff display metadata" }, "type": { diff --git a/schema/commands.schema.json b/schema/commands.schema.json index 229023682..df8a05e06 100644 --- a/schema/commands.schema.json +++ b/schema/commands.schema.json @@ -1946,56 +1946,64 @@ "uri" ] }, + "FileEditSide": { + "type": "object", + "properties": { + "uri": { + "$ref": "#/$defs/URI" + }, + "content": { + "$ref": "#/$defs/ContentRef" + } + }, + "required": [ + "uri", + "content" + ] + }, + "FileEditDiffStats": { + "type": "object", + "properties": { + "added": { + "type": "number" + }, + "removed": { + "type": "number" + } + } + }, "FileEdit": { "type": "object", "description": "Describes a file modification with before/after state and diff metadata.\n\nSupports creates (only `after`), deletes (only `before`), renames/moves\n(different `uri` in `before` and `after`), and edits (same `uri`, different content).", "properties": { "before": { - "type": "object", - "properties": { - "uri": { - "$ref": "#/$defs/URI" - }, - "content": { - "$ref": "#/$defs/ContentRef" - } - }, - "required": [ - "uri", - "content" - ], + "$ref": "#/$defs/FileEditSide", "description": "The file state before the edit. Absent for file creations or for in-place file edits." }, "after": { - "type": "object", - "properties": { - "uri": { - "$ref": "#/$defs/URI" - }, - "content": { - "$ref": "#/$defs/ContentRef" - } - }, - "required": [ - "uri", - "content" - ], + "$ref": "#/$defs/FileEditSide", "description": "The file state after the edit. Absent for file deletions." }, "diff": { - "type": "object", - "properties": { - "added": { - "type": "number" - }, - "removed": { - "type": "number" - } - }, + "$ref": "#/$defs/FileEditDiffStats", "description": "Optional diff display metadata" } } }, + "FileEditCollection": { + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "$ref": "#/$defs/FileEdit" + } + } + }, + "required": [ + "items" + ] + }, "UsageInfo": { "type": "object", "properties": { @@ -5446,18 +5454,7 @@ "description": "Risk assessment that informed the confirmation requirement." }, "edits": { - "type": "object", - "properties": { - "items": { - "type": "array", - "items": { - "$ref": "#/$defs/FileEdit" - } - } - }, - "required": [ - "items" - ], + "$ref": "#/$defs/FileEditCollection", "description": "File edits that this tool call will perform, for preview before confirmation" }, "editable": { @@ -5962,47 +5959,15 @@ "description": "Describes a file modification performed by a tool.", "properties": { "before": { - "type": "object", - "properties": { - "uri": { - "$ref": "#/$defs/URI" - }, - "content": { - "$ref": "#/$defs/ContentRef" - } - }, - "required": [ - "uri", - "content" - ], + "$ref": "#/$defs/FileEditSide", "description": "The file state before the edit. Absent for file creations or for in-place file edits." }, "after": { - "type": "object", - "properties": { - "uri": { - "$ref": "#/$defs/URI" - }, - "content": { - "$ref": "#/$defs/ContentRef" - } - }, - "required": [ - "uri", - "content" - ], + "$ref": "#/$defs/FileEditSide", "description": "The file state after the edit. Absent for file deletions." }, "diff": { - "type": "object", - "properties": { - "added": { - "type": "number" - }, - "removed": { - "type": "number" - } - }, + "$ref": "#/$defs/FileEditDiffStats", "description": "Optional diff display metadata" }, "type": { @@ -8139,18 +8104,7 @@ "description": "Risk assessment that informed the confirmation requirement." }, "edits": { - "type": "object", - "properties": { - "items": { - "type": "array", - "items": { - "$ref": "#/$defs/FileEdit" - } - } - }, - "required": [ - "items" - ], + "$ref": "#/$defs/FileEditCollection", "description": "File edits that this tool call will perform, for preview before confirmation" }, "editable": { diff --git a/schema/errors.schema.json b/schema/errors.schema.json index 71cb746bd..eb749e6c2 100644 --- a/schema/errors.schema.json +++ b/schema/errors.schema.json @@ -375,56 +375,64 @@ "uri" ] }, + "FileEditSide": { + "type": "object", + "properties": { + "uri": { + "$ref": "#/$defs/URI" + }, + "content": { + "$ref": "#/$defs/ContentRef" + } + }, + "required": [ + "uri", + "content" + ] + }, + "FileEditDiffStats": { + "type": "object", + "properties": { + "added": { + "type": "number" + }, + "removed": { + "type": "number" + } + } + }, "FileEdit": { "type": "object", "description": "Describes a file modification with before/after state and diff metadata.\n\nSupports creates (only `after`), deletes (only `before`), renames/moves\n(different `uri` in `before` and `after`), and edits (same `uri`, different content).", "properties": { "before": { - "type": "object", - "properties": { - "uri": { - "$ref": "#/$defs/URI" - }, - "content": { - "$ref": "#/$defs/ContentRef" - } - }, - "required": [ - "uri", - "content" - ], + "$ref": "#/$defs/FileEditSide", "description": "The file state before the edit. Absent for file creations or for in-place file edits." }, "after": { - "type": "object", - "properties": { - "uri": { - "$ref": "#/$defs/URI" - }, - "content": { - "$ref": "#/$defs/ContentRef" - } - }, - "required": [ - "uri", - "content" - ], + "$ref": "#/$defs/FileEditSide", "description": "The file state after the edit. Absent for file deletions." }, "diff": { - "type": "object", - "properties": { - "added": { - "type": "number" - }, - "removed": { - "type": "number" - } - }, + "$ref": "#/$defs/FileEditDiffStats", "description": "Optional diff display metadata" } } }, + "FileEditCollection": { + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "$ref": "#/$defs/FileEdit" + } + } + }, + "required": [ + "items" + ] + }, "UsageInfo": { "type": "object", "properties": { @@ -3875,18 +3883,7 @@ "description": "Risk assessment that informed the confirmation requirement." }, "edits": { - "type": "object", - "properties": { - "items": { - "type": "array", - "items": { - "$ref": "#/$defs/FileEdit" - } - } - }, - "required": [ - "items" - ], + "$ref": "#/$defs/FileEditCollection", "description": "File edits that this tool call will perform, for preview before confirmation" }, "editable": { @@ -4391,47 +4388,15 @@ "description": "Describes a file modification performed by a tool.", "properties": { "before": { - "type": "object", - "properties": { - "uri": { - "$ref": "#/$defs/URI" - }, - "content": { - "$ref": "#/$defs/ContentRef" - } - }, - "required": [ - "uri", - "content" - ], + "$ref": "#/$defs/FileEditSide", "description": "The file state before the edit. Absent for file creations or for in-place file edits." }, "after": { - "type": "object", - "properties": { - "uri": { - "$ref": "#/$defs/URI" - }, - "content": { - "$ref": "#/$defs/ContentRef" - } - }, - "required": [ - "uri", - "content" - ], + "$ref": "#/$defs/FileEditSide", "description": "The file state after the edit. Absent for file deletions." }, "diff": { - "type": "object", - "properties": { - "added": { - "type": "number" - }, - "removed": { - "type": "number" - } - }, + "$ref": "#/$defs/FileEditDiffStats", "description": "Optional diff display metadata" }, "type": { @@ -9308,18 +9273,7 @@ "description": "Risk assessment that informed the confirmation requirement." }, "edits": { - "type": "object", - "properties": { - "items": { - "type": "array", - "items": { - "$ref": "#/$defs/FileEdit" - } - } - }, - "required": [ - "items" - ], + "$ref": "#/$defs/FileEditCollection", "description": "File edits that this tool call will perform, for preview before confirmation" }, "editable": { diff --git a/schema/notifications.schema.json b/schema/notifications.schema.json index 01271335e..cfb93ff5e 100644 --- a/schema/notifications.schema.json +++ b/schema/notifications.schema.json @@ -542,56 +542,64 @@ "uri" ] }, + "FileEditSide": { + "type": "object", + "properties": { + "uri": { + "$ref": "#/$defs/URI" + }, + "content": { + "$ref": "#/$defs/ContentRef" + } + }, + "required": [ + "uri", + "content" + ] + }, + "FileEditDiffStats": { + "type": "object", + "properties": { + "added": { + "type": "number" + }, + "removed": { + "type": "number" + } + } + }, "FileEdit": { "type": "object", "description": "Describes a file modification with before/after state and diff metadata.\n\nSupports creates (only `after`), deletes (only `before`), renames/moves\n(different `uri` in `before` and `after`), and edits (same `uri`, different content).", "properties": { "before": { - "type": "object", - "properties": { - "uri": { - "$ref": "#/$defs/URI" - }, - "content": { - "$ref": "#/$defs/ContentRef" - } - }, - "required": [ - "uri", - "content" - ], + "$ref": "#/$defs/FileEditSide", "description": "The file state before the edit. Absent for file creations or for in-place file edits." }, "after": { - "type": "object", - "properties": { - "uri": { - "$ref": "#/$defs/URI" - }, - "content": { - "$ref": "#/$defs/ContentRef" - } - }, - "required": [ - "uri", - "content" - ], + "$ref": "#/$defs/FileEditSide", "description": "The file state after the edit. Absent for file deletions." }, "diff": { - "type": "object", - "properties": { - "added": { - "type": "number" - }, - "removed": { - "type": "number" - } - }, + "$ref": "#/$defs/FileEditDiffStats", "description": "Optional diff display metadata" } } }, + "FileEditCollection": { + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "$ref": "#/$defs/FileEdit" + } + } + }, + "required": [ + "items" + ] + }, "UsageInfo": { "type": "object", "properties": { @@ -4042,18 +4050,7 @@ "description": "Risk assessment that informed the confirmation requirement." }, "edits": { - "type": "object", - "properties": { - "items": { - "type": "array", - "items": { - "$ref": "#/$defs/FileEdit" - } - } - }, - "required": [ - "items" - ], + "$ref": "#/$defs/FileEditCollection", "description": "File edits that this tool call will perform, for preview before confirmation" }, "editable": { @@ -4558,47 +4555,15 @@ "description": "Describes a file modification performed by a tool.", "properties": { "before": { - "type": "object", - "properties": { - "uri": { - "$ref": "#/$defs/URI" - }, - "content": { - "$ref": "#/$defs/ContentRef" - } - }, - "required": [ - "uri", - "content" - ], + "$ref": "#/$defs/FileEditSide", "description": "The file state before the edit. Absent for file creations or for in-place file edits." }, "after": { - "type": "object", - "properties": { - "uri": { - "$ref": "#/$defs/URI" - }, - "content": { - "$ref": "#/$defs/ContentRef" - } - }, - "required": [ - "uri", - "content" - ], + "$ref": "#/$defs/FileEditSide", "description": "The file state after the edit. Absent for file deletions." }, "diff": { - "type": "object", - "properties": { - "added": { - "type": "number" - }, - "removed": { - "type": "number" - } - }, + "$ref": "#/$defs/FileEditDiffStats", "description": "Optional diff display metadata" }, "type": { diff --git a/schema/state.schema.json b/schema/state.schema.json index 097236dfa..e8fc1b69a 100644 --- a/schema/state.schema.json +++ b/schema/state.schema.json @@ -286,56 +286,64 @@ "uri" ] }, + "FileEditSide": { + "type": "object", + "properties": { + "uri": { + "$ref": "#/$defs/URI" + }, + "content": { + "$ref": "#/$defs/ContentRef" + } + }, + "required": [ + "uri", + "content" + ] + }, + "FileEditDiffStats": { + "type": "object", + "properties": { + "added": { + "type": "number" + }, + "removed": { + "type": "number" + } + } + }, "FileEdit": { "type": "object", "description": "Describes a file modification with before/after state and diff metadata.\n\nSupports creates (only `after`), deletes (only `before`), renames/moves\n(different `uri` in `before` and `after`), and edits (same `uri`, different content).", "properties": { "before": { - "type": "object", - "properties": { - "uri": { - "$ref": "#/$defs/URI" - }, - "content": { - "$ref": "#/$defs/ContentRef" - } - }, - "required": [ - "uri", - "content" - ], + "$ref": "#/$defs/FileEditSide", "description": "The file state before the edit. Absent for file creations or for in-place file edits." }, "after": { - "type": "object", - "properties": { - "uri": { - "$ref": "#/$defs/URI" - }, - "content": { - "$ref": "#/$defs/ContentRef" - } - }, - "required": [ - "uri", - "content" - ], + "$ref": "#/$defs/FileEditSide", "description": "The file state after the edit. Absent for file deletions." }, "diff": { - "type": "object", - "properties": { - "added": { - "type": "number" - }, - "removed": { - "type": "number" - } - }, + "$ref": "#/$defs/FileEditDiffStats", "description": "Optional diff display metadata" } } }, + "FileEditCollection": { + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "$ref": "#/$defs/FileEdit" + } + } + }, + "required": [ + "items" + ] + }, "UsageInfo": { "type": "object", "properties": { @@ -3786,18 +3794,7 @@ "description": "Risk assessment that informed the confirmation requirement." }, "edits": { - "type": "object", - "properties": { - "items": { - "type": "array", - "items": { - "$ref": "#/$defs/FileEdit" - } - } - }, - "required": [ - "items" - ], + "$ref": "#/$defs/FileEditCollection", "description": "File edits that this tool call will perform, for preview before confirmation" }, "editable": { @@ -4302,47 +4299,15 @@ "description": "Describes a file modification performed by a tool.", "properties": { "before": { - "type": "object", - "properties": { - "uri": { - "$ref": "#/$defs/URI" - }, - "content": { - "$ref": "#/$defs/ContentRef" - } - }, - "required": [ - "uri", - "content" - ], + "$ref": "#/$defs/FileEditSide", "description": "The file state before the edit. Absent for file creations or for in-place file edits." }, "after": { - "type": "object", - "properties": { - "uri": { - "$ref": "#/$defs/URI" - }, - "content": { - "$ref": "#/$defs/ContentRef" - } - }, - "required": [ - "uri", - "content" - ], + "$ref": "#/$defs/FileEditSide", "description": "The file state after the edit. Absent for file deletions." }, "diff": { - "type": "object", - "properties": { - "added": { - "type": "number" - }, - "removed": { - "type": "number" - } - }, + "$ref": "#/$defs/FileEditDiffStats", "description": "Optional diff display metadata" }, "type": { diff --git a/scripts/file-edit-types.test.ts b/scripts/file-edit-types.test.ts new file mode 100644 index 000000000..77d45f30a --- /dev/null +++ b/scripts/file-edit-types.test.ts @@ -0,0 +1,125 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { dirname, resolve } from 'node:path'; +import test from 'node:test'; +import { fileURLToPath } from 'node:url'; +import { Project } from 'ts-morph'; + +const root = resolve(dirname(fileURLToPath(import.meta.url)), '..'); + +function projectForTypes(): Project { + return new Project({ tsConfigFilePath: resolve(root, 'types/tsconfig.json') }); +} + +function isJsonObject(value: unknown): value is Record { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} + +function jsonObject(value: unknown): Record { + assert.ok(isJsonObject(value), 'expected a JSON object'); + return value; +} + +test('file-edit fields reference shared named types', () => { + const project = projectForTypes(); + const common = project.getSourceFileOrThrow(resolve(root, 'types/common/state.ts')); + const side = common.getInterfaceOrThrow('FileEditSide'); + const stats = common.getInterfaceOrThrow('FileEditDiffStats'); + const collection = common.getInterfaceOrThrow('FileEditCollection'); + const edit = common.getInterfaceOrThrow('FileEdit'); + + assert.deepEqual(side.getProperties().map(p => p.getName()), ['uri', 'content']); + assert.equal(side.getPropertyOrThrow('uri').getTypeNodeOrThrow().getText(), 'URI'); + assert.equal(side.getPropertyOrThrow('content').getTypeNodeOrThrow().getText(), 'ContentRef'); + assert.ok(side.getProperties().every(p => !p.hasQuestionToken())); + assert.deepEqual(stats.getProperties().map(p => p.getName()), ['added', 'removed']); + assert.ok(stats.getProperties().every(p => p.hasQuestionToken())); + assert.ok(stats.getProperties().every(p => p.getTypeNodeOrThrow().getText() === 'number')); + assert.equal(collection.getPropertyOrThrow('items').getTypeNodeOrThrow().getText(), 'FileEdit[]'); + assert.equal(collection.getPropertyOrThrow('items').hasQuestionToken(), false); + + for (const name of ['before', 'after']) { + const property = edit.getPropertyOrThrow(name); + assert.equal(property.getTypeNodeOrThrow().getText(), 'FileEditSide'); + assert.equal(property.hasQuestionToken(), true); + } + assert.equal(edit.getPropertyOrThrow('diff').getTypeNodeOrThrow().getText(), 'FileEditDiffStats'); + assert.equal(edit.getPropertyOrThrow('diff').hasQuestionToken(), true); + + for (const [file, name] of [ + ['types/channels-chat/state.ts', 'ToolCallPendingConfirmationState'], + ['types/channels-chat/actions.ts', 'ChatToolCallReadyAction'], + ]) { + const property = project.getSourceFileOrThrow(resolve(root, file)) + .getInterfaceOrThrow(name).getPropertyOrThrow('edits'); + assert.equal(property.getTypeNodeOrThrow().getText(), 'FileEditCollection'); + assert.equal(property.hasQuestionToken(), true); + } + + const result = project.getSourceFileOrThrow(resolve(root, 'types/channels-chat/state.ts')) + .getInterfaceOrThrow('ToolResultFileEditContent'); + assert.ok(result.getExtends().some(base => base.getText() === 'FileEdit')); +}); + +test('the public TypeScript entry point supports typed file-edit consumers', () => { + const project = projectForTypes(); + project.createSourceFile(resolve(root, 'types/__file_edit_api_check__.ts'), ` + import type { + ChatToolCallReadyAction, + FileEdit, + FileEditCollection, + FileEditDiffStats, + FileEditSide, + ToolCallPendingConfirmationState, + ToolResultFileEditContent, + } from './index.js'; + + const side: FileEditSide = { + uri: 'file:///workspace/file.txt', + content: { uri: 'ahp-content:/file' }, + }; + const edit: FileEdit = { + before: side, + after: side, + diff: { added: 2147483648, removed: 0 }, + }; + const collection: FileEditCollection = { items: [edit] }; + const ready: ChatToolCallReadyAction['edits'] = collection; + const pending: ToolCallPendingConfirmationState['edits'] = collection; + const resultSide: ToolResultFileEditContent['after'] = side; + const stats: FileEditDiffStats | undefined = edit.diff; + void [ready, pending, resultSide, stats]; + `); + assert.deepEqual( + project.getPreEmitDiagnostics().map(d => d.getMessageText()), + [], + ); +}); + +test('named file-edit schemas keep their original required fields and wrappers', () => { + for (const file of ['state.schema.json', 'actions.schema.json']) { + const schema = jsonObject(JSON.parse(readFileSync(resolve(root, 'schema', file), 'utf8'))); + const defs = jsonObject(schema.$defs); + const edit = jsonObject(defs.FileEdit); + const editProperties = jsonObject(edit.properties); + assert.equal(jsonObject(editProperties.before).$ref, '#/$defs/FileEditSide'); + assert.equal(jsonObject(editProperties.after).$ref, '#/$defs/FileEditSide'); + assert.equal(jsonObject(editProperties.diff).$ref, '#/$defs/FileEditDiffStats'); + assert.equal(edit.required, undefined); + + const side = jsonObject(defs.FileEditSide); + assert.deepEqual(side.required, ['uri', 'content']); + assert.equal(jsonObject(jsonObject(side.properties).content).$ref, '#/$defs/ContentRef'); + + const stats = jsonObject(defs.FileEditDiffStats); + assert.equal(stats.required, undefined); + assert.equal(jsonObject(jsonObject(stats.properties).added).type, 'number'); + assert.equal(jsonObject(jsonObject(stats.properties).removed).type, 'number'); + + const collection = jsonObject(defs.FileEditCollection); + assert.deepEqual(collection.required, ['items']); + const items = jsonObject(jsonObject(collection.properties).items); + assert.equal(items.type, 'array'); + assert.equal(jsonObject(items.items).$ref, '#/$defs/FileEdit'); + } +}); diff --git a/scripts/generate-csharp.ts b/scripts/generate-csharp.ts index 207212210..fb3750ab7 100644 --- a/scripts/generate-csharp.ts +++ b/scripts/generate-csharp.ts @@ -758,7 +758,10 @@ const STATE_STRUCTS: { name: string; omitDiscriminants?: boolean; csName?: strin { name: 'McpServerStoppedState' }, { name: 'ToolCallClientContributor' }, { name: 'ToolCallMcpContributor' }, + { name: 'FileEditSide' }, + { name: 'FileEditDiffStats' }, { name: 'FileEdit' }, + { name: 'FileEditCollection' }, { name: 'TerminalInfo' }, { name: 'TerminalClientClaim' }, { name: 'TerminalSessionClaim' }, diff --git a/scripts/generate-go.ts b/scripts/generate-go.ts index 71926ac2f..683a094c5 100644 --- a/scripts/generate-go.ts +++ b/scripts/generate-go.ts @@ -837,7 +837,10 @@ const STATE_STRUCTS: { name: string; omitDiscriminants?: boolean; goName?: strin { name: 'McpAuthRequirement' }, { name: 'ToolCallClientContributor' }, { name: 'ToolCallMcpContributor' }, + { name: 'FileEditSide' }, + { name: 'FileEditDiffStats' }, { name: 'FileEdit' }, + { name: 'FileEditCollection' }, { name: 'TerminalCommandResult' }, { name: 'TerminalInfo' }, { name: 'TerminalClientClaim' }, diff --git a/scripts/generate-kotlin.ts b/scripts/generate-kotlin.ts index ea13b41e5..a99a60132 100644 --- a/scripts/generate-kotlin.ts +++ b/scripts/generate-kotlin.ts @@ -1028,7 +1028,8 @@ const STATE_STRUCTS = [ 'McpServerStartingState', 'McpServerReadyState', 'McpServerAuthRequiredState', 'McpServerErrorState', 'McpServerStoppedState', 'McpOAuthClient', 'McpAuthRequirement', 'ToolCallClientContributor', 'ToolCallMcpContributor', - 'FileEdit', 'TerminalCommandResult', 'TerminalInfo', + 'FileEditSide', 'FileEditDiffStats', 'FileEdit', 'FileEditCollection', + 'TerminalCommandResult', 'TerminalInfo', 'TerminalClientClaim', 'TerminalSessionClaim', 'TerminalRunningLifecycleState', 'TerminalExitedLifecycleState', 'TerminalState', 'TerminalUnclassifiedPart', 'TerminalCommandPart', diff --git a/scripts/generate-rust.ts b/scripts/generate-rust.ts index 4e999bbcf..3dde6d98e 100644 --- a/scripts/generate-rust.ts +++ b/scripts/generate-rust.ts @@ -898,7 +898,10 @@ const STATE_STRUCTS: { name: string; omitDiscriminants?: boolean; rustName?: str { name: 'McpAuthRequirement' }, { name: 'ToolCallClientContributor', omitDiscriminants: true }, { name: 'ToolCallMcpContributor', omitDiscriminants: true }, + { name: 'FileEditSide' }, + { name: 'FileEditDiffStats' }, { name: 'FileEdit' }, + { name: 'FileEditCollection' }, { name: 'TerminalCommandResult' }, { name: 'TerminalInfo' }, { name: 'TerminalClientClaim', omitDiscriminants: true }, @@ -1590,7 +1593,7 @@ impl Serialize for ChatErrorAction { function generateActionsFile(project: Project): string { const lines: string[] = [GENERATED_HEADER]; lines.push('#[allow(unused_imports)]'); - lines.push('use crate::state::{AgentInfo, AgentSelection, Annotation, AnnotationEntry, AnnotationOrigin, AutomationDefinition, AutomationDefinitionPatch, AutomationEntry, AutomationRunLifecycle, AutomationRunSummary, ChatInputAnswer, ChatInputRequest, ChatInputResponseKind, ChatInteractivity, ChatOrigin, ConfirmationOption, ContentRef, Customization, CustomizationEnablement, ErrorInfo, ErrorResponsePart, McpAuthRequirement, McpServerState, ModelSelection, ResponsePart, SessionActiveClient, SessionInputRequest, SideChatSelection, TerminalClaim, TerminalInfo, TextRange, ToolCallContributor, ToolCallResult, ToolCallRiskAssessment, ToolCallConfirmationReason, ToolCallCancellationReason, ToolDefinition, ToolInput, ToolResultContent, UsageInfo, Message, PendingMessageKind, Turn, ChangesetStatus, ChangesetFile, ChangesetOperation, ChangesetOperationStatus, Changeset, ChatSummary};'); + lines.push('use crate::state::{AgentInfo, AgentSelection, Annotation, AnnotationEntry, AnnotationOrigin, AutomationDefinition, AutomationDefinitionPatch, AutomationEntry, AutomationRunLifecycle, AutomationRunSummary, ChatInputAnswer, ChatInputRequest, ChatInputResponseKind, ChatInteractivity, ChatOrigin, ConfirmationOption, ContentRef, Customization, CustomizationEnablement, ErrorInfo, ErrorResponsePart, FileEditCollection, McpAuthRequirement, McpServerState, ModelSelection, ResponsePart, SessionActiveClient, SessionInputRequest, SideChatSelection, TerminalClaim, TerminalInfo, TextRange, ToolCallContributor, ToolCallResult, ToolCallRiskAssessment, ToolCallConfirmationReason, ToolCallCancellationReason, ToolDefinition, ToolInput, ToolResultContent, UsageInfo, Message, PendingMessageKind, Turn, ChangesetStatus, ChangesetFile, ChangesetOperation, ChangesetOperationStatus, Changeset, ChatSummary};'); lines.push(''); // ActionType enum diff --git a/scripts/generate-swift.ts b/scripts/generate-swift.ts index 89fa9bcdf..991df3c54 100644 --- a/scripts/generate-swift.ts +++ b/scripts/generate-swift.ts @@ -733,7 +733,8 @@ const STATE_STRUCTS = [ 'McpServerStartingState', 'McpServerReadyState', 'McpServerAuthRequiredState', 'McpServerErrorState', 'McpServerStoppedState', 'McpOAuthClient', 'McpAuthRequirement', 'ToolCallClientContributor', 'ToolCallMcpContributor', - 'FileEdit', 'TerminalCommandResult', 'TerminalInfo', + 'FileEditSide', 'FileEditDiffStats', 'FileEdit', 'FileEditCollection', + 'TerminalCommandResult', 'TerminalInfo', 'TerminalClientClaim', 'TerminalSessionClaim', 'TerminalRunningLifecycleState', 'TerminalExitedLifecycleState', 'TerminalState', 'TerminalUnclassifiedPart', 'TerminalCommandPart', diff --git a/types/channels-chat/actions.ts b/types/channels-chat/actions.ts index 8d2b6f2ea..41c134904 100644 --- a/types/channels-chat/actions.ts +++ b/types/channels-chat/actions.ts @@ -5,7 +5,7 @@ */ import { ActionType } from '../common/actions.js'; -import type { StringOrMarkdown, FileEdit, UsageInfo, URI } from '../common/state.js'; +import type { StringOrMarkdown, FileEditCollection, UsageInfo, URI } from '../common/state.js'; import type { McpAuthRequirement } from '../channels-session/state.js'; import type { Message, @@ -223,7 +223,7 @@ export interface ChatToolCallReadyAction extends ToolCallActionBase { /** Risk assessment that informed the confirmation requirement. */ riskAssessment?: ToolCallRiskAssessment; /** File edits that this tool call will perform, for preview before confirmation */ - edits?: { items: FileEdit[] }; + edits?: FileEditCollection; /** Whether the agent host allows the client to edit the tool's input parameters before confirming */ editable?: boolean; /** If set, the tool was auto-confirmed and transitions directly to `running` */ diff --git a/types/channels-chat/state.ts b/types/channels-chat/state.ts index a2609165b..a9565d9d5 100644 --- a/types/channels-chat/state.ts +++ b/types/channels-chat/state.ts @@ -13,6 +13,7 @@ import type { ContentRef, ErrorInfo, FileEdit, + FileEditCollection, StringOrMarkdown, TextRange, TextSelection, @@ -1305,7 +1306,7 @@ export interface ToolCallPendingConfirmationState extends ToolCallBase, ToolCall /** Risk assessment that informed the confirmation requirement. */ riskAssessment?: ToolCallRiskAssessment; /** File edits that this tool call will perform, for preview before confirmation */ - edits?: { items: FileEdit[] }; + edits?: FileEditCollection; /** Whether the agent host allows the client to edit the tool's input parameters before confirming */ editable?: boolean; /** diff --git a/types/common/state.ts b/types/common/state.ts index b56048c6a..fcb3020f3 100644 --- a/types/common/state.ts +++ b/types/common/state.ts @@ -254,6 +254,16 @@ export interface ContentRef { // ─── File Edit ─────────────────────────────────────────────────────────────── +export interface FileEditSide { + uri: URI; + content: ContentRef; +} + +export interface FileEditDiffStats { + added?: number; + removed?: number; +} + /** * Describes a file modification with before/after state and diff metadata. * @@ -264,26 +274,15 @@ export interface ContentRef { */ export interface FileEdit { /** The file state before the edit. Absent for file creations or for in-place file edits. */ - before?: { - /** URI of the file before the edit */ - uri: URI; - /** Reference to the file content before the edit */ - content: ContentRef; - }; + before?: FileEditSide; /** The file state after the edit. Absent for file deletions. */ - after?: { - /** URI of the file after the edit */ - uri: URI; - /** Reference to the file content after the edit */ - content: ContentRef; - }; + after?: FileEditSide; /** Optional diff display metadata */ - diff?: { - /** Number of items added (e.g., lines for text files, cells for notebooks) */ - added?: number; - /** Number of items removed (e.g., lines for text files, cells for notebooks) */ - removed?: number; - }; + diff?: FileEditDiffStats; +} + +export interface FileEditCollection { + items: FileEdit[]; } // ─── Common Types ──────────────────────────────────────────────────────────── diff --git a/types/test-cases/reducers/272-toolcallready-typed-edit-previews.json b/types/test-cases/reducers/272-toolcallready-typed-edit-previews.json new file mode 100644 index 000000000..245de2fd3 --- /dev/null +++ b/types/test-cases/reducers/272-toolcallready-typed-edit-previews.json @@ -0,0 +1,79 @@ +{ + "description": "toolCallReady replaces typed edit previews and preserves them when omitted", + "reducer": "chat", + "initial": { + "turns": [], + "activeTurn": { + "id": "turn-1", + "startedAt": "1970-01-01T00:00:01.000Z", + "message": {"text":"Edit files","origin":{"kind":"user"}}, + "responseParts": [{ + "kind": "toolCall", + "toolCall": { + "status": "pending-confirmation", + "toolCallId": "tc-1", + "toolName": "edit", + "displayName": "Edit", + "intention": null, + "contributor": null, + "_meta": null, + "invocationMessage": "Review old preview", + "toolInput": null, + "confirmationTitle": null, + "edits": {"items":[{"after":{"uri":"file:///workspace/old.txt","content":{"uri":"ahp-content:/old"}}}]} + } + }], + "usage": null + }, + "resource": "copilot:/test-session", + "title": "Test Session", + "status": 24, + "modifiedAt": "1970-01-01T00:00:02.000Z" + }, + "actions": [ + { + "type": "chat/toolCallReady", + "turnId": "turn-1", + "toolCallId": "tc-1", + "invocationMessage": "Review new preview", + "edits": {"items":[{"after":{"uri":"file:///workspace/new.txt","content":{"uri":"ahp-content:/new"}},"diff":{}}]} + }, + { + "type": "chat/toolCallReady", + "turnId": "turn-1", + "toolCallId": "tc-1", + "invocationMessage": "Review retained preview" + } + ], + "expected": { + "turns": [], + "activeTurn": { + "id": "turn-1", + "startedAt": "1970-01-01T00:00:01.000Z", + "message": {"text":"Edit files","origin":{"kind":"user"}}, + "responseParts": [{ + "kind": "toolCall", + "toolCall": { + "status": "pending-confirmation", + "toolCallId": "tc-1", + "toolName": "edit", + "displayName": "Edit", + "intention": null, + "contributor": null, + "_meta": null, + "invocationMessage": "Review retained preview", + "toolInput": null, + "confirmationTitle": null, + "riskAssessment": null, + "edits": {"items":[{"after":{"uri":"file:///workspace/new.txt","content":{"uri":"ahp-content:/new"}},"diff":{}}]}, + "editable": null + } + }], + "usage": null + }, + "resource": "copilot:/test-session", + "title": "Test Session", + "status": 24, + "modifiedAt": "1970-01-01T00:00:02.000Z" + } +} diff --git a/types/test-cases/round-trips/045-file-edit-variants.json b/types/test-cases/round-trips/045-file-edit-variants.json new file mode 100644 index 000000000..af67844ff --- /dev/null +++ b/types/test-cases/round-trips/045-file-edit-variants.json @@ -0,0 +1,36 @@ +{ + "name": "file-edit-variants", + "group": "A", + "description": "A changeset preserves file-edit variants, metadata, and optional field presence.", + "type": "Snapshot", + "input": { + "resource": "ahp-changeset:/c1", + "fromSeq": 1, + "state": { + "status": "ready", + "files": [ + {"id":"create","edit":{"after":{"uri":"file:///workspace/new.txt","content":{"uri":"ahp-content:/new","sizeHint":32,"contentType":"text/plain","nonce":"v1"}},"diff":{"added":2147483648,"removed":0}},"_meta":{"vendor":{"keep":true}}}, + {"id":"delete","edit":{"before":{"uri":"file:///workspace/deleted.txt","content":{"uri":"ahp-content:/deleted"}},"diff":{"removed":1}}}, + {"id":"rename","edit":{"before":{"uri":"file:///workspace/old.txt","content":{"uri":"ahp-content:/renamed"}},"after":{"uri":"file:///workspace/renamed.txt","content":{"uri":"ahp-content:/renamed"}}}}, + {"id":"edit","edit":{"before":{"uri":"file:///workspace/edit.txt","content":{"uri":"ahp-content:/before"}},"after":{"uri":"file:///workspace/edit.txt","content":{"uri":"ahp-content:/after"}},"diff":{"added":1,"removed":1}}}, + {"id":"in-place","edit":{"after":{"uri":"file:///workspace/existing.txt","content":{"uri":"ahp-content:/existing"}},"diff":{}}}, + {"id":"empty","edit":{}} + ] + } + }, + "acceptableOutputs": [{ + "resource": "ahp-changeset:/c1", + "fromSeq": 1, + "state": { + "status": "ready", + "files": [ + {"id":"create","edit":{"after":{"uri":"file:///workspace/new.txt","content":{"uri":"ahp-content:/new","sizeHint":32,"contentType":"text/plain","nonce":"v1"}},"diff":{"added":2147483648,"removed":0}},"_meta":{"vendor":{"keep":true}}}, + {"id":"delete","edit":{"before":{"uri":"file:///workspace/deleted.txt","content":{"uri":"ahp-content:/deleted"}},"diff":{"removed":1}}}, + {"id":"rename","edit":{"before":{"uri":"file:///workspace/old.txt","content":{"uri":"ahp-content:/renamed"}},"after":{"uri":"file:///workspace/renamed.txt","content":{"uri":"ahp-content:/renamed"}}}}, + {"id":"edit","edit":{"before":{"uri":"file:///workspace/edit.txt","content":{"uri":"ahp-content:/before"}},"after":{"uri":"file:///workspace/edit.txt","content":{"uri":"ahp-content:/after"}},"diff":{"added":1,"removed":1}}}, + {"id":"in-place","edit":{"after":{"uri":"file:///workspace/existing.txt","content":{"uri":"ahp-content:/existing"}},"diff":{}}}, + {"id":"empty","edit":{}} + ] + } + }] +} diff --git a/types/test-cases/round-trips/046-file-edit-preview.json b/types/test-cases/round-trips/046-file-edit-preview.json new file mode 100644 index 000000000..19b2a56f6 --- /dev/null +++ b/types/test-cases/round-trips/046-file-edit-preview.json @@ -0,0 +1,20 @@ +{ + "name": "file-edit-preview", + "group": "A", + "description": "A ready action preserves its typed preview collection.", + "type": "StateAction", + "input": { + "type": "chat/toolCallReady", + "turnId": "t1", + "toolCallId": "tc1", + "invocationMessage": "Review", + "edits": {"items":[{"after":{"uri":"file:///workspace/file.txt","content":{"uri":"ahp-content:/file"}},"diff":{"added":0}}]} + }, + "acceptableOutputs": [{ + "type": "chat/toolCallReady", + "turnId": "t1", + "toolCallId": "tc1", + "invocationMessage": "Review", + "edits": {"items":[{"after":{"uri":"file:///workspace/file.txt","content":{"uri":"ahp-content:/file"}},"diff":{"added":0}}]} + }] +} diff --git a/types/test-cases/round-trips/047-file-edit-tool-result.json b/types/test-cases/round-trips/047-file-edit-tool-result.json new file mode 100644 index 000000000..e272e44fd --- /dev/null +++ b/types/test-cases/round-trips/047-file-edit-tool-result.json @@ -0,0 +1,36 @@ +{ + "name": "file-edit-tool-result", + "group": "A", + "description": "A tool result preserves inherited file-edit side and statistics fields.", + "type": "StateAction", + "input": { + "type": "chat/toolCallComplete", + "turnId": "t1", + "toolCallId": "tc1", + "result": { + "success": true, + "pastTenseMessage": "Edited file", + "content": [{ + "type": "fileEdit", + "before": {"uri":"file:///workspace/file.txt","content":{"uri":"ahp-content:/before"}}, + "after": {"uri":"file:///workspace/file.txt","content":{"uri":"ahp-content:/after"}}, + "diff": {"added":1,"removed":1} + }] + } + }, + "acceptableOutputs": [{ + "type": "chat/toolCallComplete", + "turnId": "t1", + "toolCallId": "tc1", + "result": { + "success": true, + "pastTenseMessage": "Edited file", + "content": [{ + "type": "fileEdit", + "before": {"uri":"file:///workspace/file.txt","content":{"uri":"ahp-content:/before"}}, + "after": {"uri":"file:///workspace/file.txt","content":{"uri":"ahp-content:/after"}}, + "diff": {"added":1,"removed":1} + }] + } + }] +} diff --git a/types/test-cases/round-trips/048-file-edit-empty-preview.json b/types/test-cases/round-trips/048-file-edit-empty-preview.json new file mode 100644 index 000000000..541fa4de1 --- /dev/null +++ b/types/test-cases/round-trips/048-file-edit-empty-preview.json @@ -0,0 +1,8 @@ +{ + "name": "file-edit-empty-preview", + "group": "A", + "description": "An empty preview keeps its required items array.", + "type": "StateAction", + "input": {"type":"chat/toolCallReady","turnId":"t1","toolCallId":"tc1","invocationMessage":"Review","edits":{"items":[]}}, + "acceptableOutputs": [{"type":"chat/toolCallReady","turnId":"t1","toolCallId":"tc1","invocationMessage":"Review","edits":{"items":[]}}] +} diff --git a/types/test-cases/round-trips/049-file-edit-unknown-fields.json b/types/test-cases/round-trips/049-file-edit-unknown-fields.json new file mode 100644 index 000000000..5410c98f1 --- /dev/null +++ b/types/test-cases/round-trips/049-file-edit-unknown-fields.json @@ -0,0 +1,51 @@ +{ + "name": "file-edit-unknown-fields", + "group": "B", + "description": "Typed file-edit models drop unknown keys while intentional metadata stays raw.", + "type": "StateAction", + "input": { + "type": "chat/toolCallReady", + "turnId": "t1", + "toolCallId": "tc1", + "invocationMessage": "Review", + "futureAction": 0, + "_meta": {"vendor":{"keep":true}}, + "edits": { + "futureCollection": 1, + "items": [{ + "futureEdit": 2, + "after": {"uri":"file:///workspace/file.txt","futureSide":3,"content":{"uri":"ahp-content:/file","futureContent":4}}, + "diff": {"added":1,"futureStats":5} + }] + } + }, + "acceptableOutputs": [{ + "type": "chat/toolCallReady", + "turnId": "t1", + "toolCallId": "tc1", + "invocationMessage": "Review", + "_meta": {"vendor":{"keep":true}}, + "edits": { + "items": [{ + "after": {"uri":"file:///workspace/file.txt","content":{"uri":"ahp-content:/file"}}, + "diff": {"added":1} + }] + } + }], + "preservedOutput": { + "type": "chat/toolCallReady", + "turnId": "t1", + "toolCallId": "tc1", + "invocationMessage": "Review", + "futureAction": 0, + "_meta": {"vendor":{"keep":true}}, + "edits": { + "futureCollection": 1, + "items": [{ + "futureEdit": 2, + "after": {"uri":"file:///workspace/file.txt","futureSide":3,"content":{"uri":"ahp-content:/file","futureContent":4}}, + "diff": {"added":1,"futureStats":5} + }] + } + } +} diff --git a/types/test-cases/round-trips/KNOWN-FIDELITY-GAPS.md b/types/test-cases/round-trips/KNOWN-FIDELITY-GAPS.md index 364791f0c..c726eaab3 100644 --- a/types/test-cases/round-trips/KNOWN-FIDELITY-GAPS.md +++ b/types/test-cases/round-trips/KNOWN-FIDELITY-GAPS.md @@ -18,8 +18,7 @@ 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 - `preservedOutput`. TypeScript still asserts — it is never skipped. Fixtures - 017 and 019 are the Group B cases. + `preservedOutput`. TypeScript still asserts. It never skips Group B fixtures. This is a real type-system capability difference, not a blessed divergence: a runtime client that wrongly *preserved* unknown keys would fail its