Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Original file line number Diff line number Diff line change
Expand Up @@ -1323,7 +1323,7 @@ public sealed record ChatToolCallReadyAction

/// <summary>File edits that this tool call will perform, for preview before confirmation</summary>
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public JsonElement? Edits { get; init; }
public FileEditCollection? Edits { get; init; }

/// <summary>Whether the agent host allows the client to edit the tool's input parameters before confirming</summary>
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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))]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2795,7 +2795,7 @@ public sealed record ToolCallPendingConfirmationState

/// <summary>File edits that this tool call will perform, for preview before confirmation</summary>
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public JsonElement? Edits { get; init; }
public FileEditCollection? Edits { get; init; }

/// <summary>Whether the agent host allows the client to edit the tool's input parameters before confirming</summary>
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
Expand Down Expand Up @@ -3274,15 +3274,15 @@ public sealed record ToolResultFileEditContent
{
/// <summary>The file state before the edit. Absent for file creations or for in-place file edits.</summary>
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public JsonElement? Before { get; init; }
public FileEditSide? Before { get; init; }

/// <summary>The file state after the edit. Absent for file deletions.</summary>
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public JsonElement? After { get; init; }
public FileEditSide? After { get; init; }

/// <summary>Optional diff display metadata</summary>
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public JsonElement? Diff { get; init; }
public FileEditDiffStats? Diff { get; init; }

public ToolResultContentType Type { get; init; }
}
Expand Down Expand Up @@ -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; }
}

/// <summary>Describes a file modification with before/after state and diff metadata.
///
/// Supports creates (only `after`), deletes (only `before`), renames/moves
Expand All @@ -4345,15 +4361,20 @@ public sealed record FileEdit
{
/// <summary>The file state before the edit. Absent for file creations or for in-place file edits.</summary>
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public JsonElement? Before { get; init; }
public FileEditSide? Before { get; init; }

/// <summary>The file state after the edit. Absent for file deletions.</summary>
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public JsonElement? After { get; init; }
public FileEditSide? After { get; init; }

/// <summary>Optional diff display metadata</summary>
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public JsonElement? Diff { get; init; }
public FileEditDiffStats? Diff { get; init; }
}

public sealed record FileEditCollection
{
public required List<FileEdit> Items { get; init; }
}

/// <summary>Lightweight terminal metadata exposed on the root state.</summary>
Expand Down
Original file line number Diff line number Diff line change
@@ -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<FileEdit>
{
new()
{
Before = side,
After = side,
Diff = new FileEditDiffStats { Added = 2147483648L, Removed = 0 },
},
},
};
string wire = JsonSerializer.Serialize(original, options);
var decoded = Assert.IsType<FileEditCollection>(
JsonSerializer.Deserialize<FileEditCollection>(wire, options));
FileEdit item = Assert.Single(decoded.Items);
var before = Assert.IsType<FileEditSide>(item.Before);
var after = Assert.IsType<FileEditSide>(item.After);
var stats = Assert.IsType<FileEditDiffStats>(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<FileEdit>() };
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());
}
}
2 changes: 1 addition & 1 deletion clients/go/ahptypes/actions.generated.go
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
59 changes: 59 additions & 0 deletions clients/go/ahptypes/file_edits_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
28 changes: 21 additions & 7 deletions clients/go/ahptypes/state.generated.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"`
}

Expand Down Expand Up @@ -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
Expand Down
5 changes: 5 additions & 0 deletions clients/kotlin/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
*/
Expand Down
Loading