diff --git a/AGENTS.md b/AGENTS.md
index 4d6e5a5d..d74ea619 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 01ee2af4..23f4f272 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 9b164021..1df6c00b 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 5ead9d4c..1f93df31 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 00000000..76622da9
--- /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 d940c8bd..10d5f414 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 00000000..bd7cd882
--- /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 269b50f5..aab24a13 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 96e9a40b..cae975d5 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 e0326f28..78e8ed85 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 9fee270b..8a3a79eb 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 00000000..ed3b3a75
--- /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 05dba2c2..b078ddfa 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 6d7ff245..57786c53 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 00000000..c6bd0bbb
--- /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 3abc0a93..756198f2 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 313783d6..0752a3d4 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 00000000..a6cf9382
--- /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 00000000..1a52cc92
--- /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 b14c0491..281f7b5e 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 70f7a2b5..cdaaad48 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 22902368..df8a05e0 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 71cb746b..eb749e6c 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 01271335..cfb93ff5 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 097236df..e8fc1b69 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 00000000..77d45f30
--- /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 20721221..fb3750ab 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 71926ac2..683a094c 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 ea13b41e..a99a6013 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 4e999bbc..3dde6d98 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 89fa9bcd..991df3c5 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 8d2b6f2e..41c13490 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 a2609165..a9565d9d 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 b56048c6..fcb3020f 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 00000000..245de2fd
--- /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 00000000..af67844f
--- /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 00000000..19b2a56f
--- /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 00000000..e272e44f
--- /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 00000000..541fa4de
--- /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 00000000..5410c98f
--- /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 364791f0..c726eaab 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