Skip to content

Commit d99871c

Browse files
x-opaque-json -> JsonElement mapping with object boundary at RPC params (#1359)
1 parent 0c7886c commit d99871c

30 files changed

Lines changed: 1171 additions & 338 deletions

dotnet/src/Client.cs

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1629,6 +1629,20 @@ private async Task<Connection> ConnectToServerAsync(Process? cliProcess, string?
16291629

16301630
private static JsonSerializerOptions SerializerOptionsForMessageFormatter { get; } = CreateSerializerOptions();
16311631

1632+
/// <summary>
1633+
/// Converts an arbitrary value into the <see cref="JsonElement"/> representation that wire
1634+
/// DTOs use for opaque-JSON fields. Pass-through for <see cref="JsonElement"/>, otherwise
1635+
/// serializes the runtime type using the shared JSON-RPC serializer options so that any
1636+
/// type registered in the SDK's source-generated contexts (e.g. primitives,
1637+
/// <c>Dictionary&lt;string, object&gt;</c>, generated DTOs) is supported.
1638+
/// </summary>
1639+
public static JsonElement? ToJsonElementForWire(object? value) => value switch
1640+
{
1641+
null => null,
1642+
JsonElement je => je,
1643+
_ => JsonSerializer.SerializeToElement(value, SerializerOptionsForMessageFormatter.GetTypeInfo(value.GetType()))
1644+
};
1645+
16321646
private static JsonSerializerOptions CreateSerializerOptions()
16331647
{
16341648
var options = new JsonSerializerOptions(JsonSerializerDefaults.Web)

dotnet/src/Generated/Rpc.cs

Lines changed: 32 additions & 30 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

dotnet/src/Generated/SessionEvents.cs

Lines changed: 53 additions & 34 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

dotnet/src/Session.cs

Lines changed: 44 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -637,7 +637,7 @@ private async Task HandleBroadcastEventAsync(SessionEvent sessionEvent)
637637
? new ElicitationSchema
638638
{
639639
Type = data.RequestedSchema.Type,
640-
Properties = data.RequestedSchema.Properties,
640+
Properties = data.RequestedSchema.Properties.ToDictionary(kvp => kvp.Key, kvp => (object)kvp.Value),
641641
Required = data.RequestedSchema.Required?.ToList()
642642
}
643643
: null;
@@ -687,7 +687,7 @@ await HandleElicitationRequestAsync(
687687
/// <summary>
688688
/// Executes a tool handler and sends the result back via the HandlePendingToolCall RPC.
689689
/// </summary>
690-
private async Task ExecuteToolAndRespondAsync(string requestId, string toolName, string toolCallId, object? arguments, AIFunction tool)
690+
private async Task ExecuteToolAndRespondAsync(string requestId, string toolName, string toolCallId, JsonElement? arguments, AIFunction tool)
691691
{
692692
try
693693
{
@@ -707,13 +707,8 @@ private async Task ExecuteToolAndRespondAsync(string requestId, string toolName,
707707
}
708708
};
709709

710-
if (arguments is not null)
710+
if (arguments is JsonElement incomingJsonArgs)
711711
{
712-
if (arguments is not JsonElement incomingJsonArgs)
713-
{
714-
throw new InvalidOperationException($"Incoming arguments must be a {nameof(JsonElement)}; received {arguments.GetType().Name}");
715-
}
716-
717712
foreach (var prop in incomingJsonArgs.EnumerateObject())
718713
{
719714
aiFunctionArgs[prop.Name] = prop.Value;
@@ -948,7 +943,9 @@ private async Task HandleElicitationRequestAsync(ElicitationContext context, str
948943
await Rpc.Ui.HandlePendingElicitationAsync(requestId, new UIElicitationResponse
949944
{
950945
Action = result.Action,
951-
Content = result.Content
946+
Content = result.Content?.ToDictionary(
947+
kvp => kvp.Key,
948+
kvp => CopilotClient.ToJsonElementForWire(kvp.Value)!.Value)
952949
});
953950
LoggingHelpers.LogTiming(_logger, LogLevel.Debug, null,
954951
"CopilotSession.HandleElicitationRequestAsync response sent successfully. Elapsed={Elapsed}, SessionId={SessionId}, RequestId={RequestId}",
@@ -991,6 +988,15 @@ private void AssertElicitation()
991988
/// </summary>
992989
private sealed class SessionUiApiImpl(CopilotSession session) : ISessionUiApi
993990
{
991+
// Parses a JSON string and returns a detached JsonElement. Using `using`
992+
// ensures the pooled buffers backing the JsonDocument are released
993+
// promptly; the cloned RootElement is independent of the document.
994+
private static JsonElement ParseJsonElement(string json)
995+
{
996+
using var doc = JsonDocument.Parse(json);
997+
return doc.RootElement.Clone();
998+
}
999+
9941000
public async Task<ElicitationResult> ElicitAsync(ElicitationParams elicitationParams, CancellationToken cancellationToken)
9951001
{
9961002
ArgumentNullException.ThrowIfNull(elicitationParams);
@@ -1000,12 +1006,18 @@ public async Task<ElicitationResult> ElicitAsync(ElicitationParams elicitationPa
10001006
var schema = new UIElicitationSchema
10011007
{
10021008
Type = elicitationParams.RequestedSchema.Type,
1003-
Properties = elicitationParams.RequestedSchema.Properties,
1009+
Properties = elicitationParams.RequestedSchema.Properties.ToDictionary(
1010+
kvp => kvp.Key,
1011+
kvp => CopilotClient.ToJsonElementForWire(kvp.Value)!.Value),
10041012
Required = elicitationParams.RequestedSchema.Required
10051013
};
10061014

10071015
var result = await session.Rpc.Ui.ElicitationAsync(elicitationParams.Message, schema, cancellationToken);
1008-
return new ElicitationResult { Action = result.Action, Content = result.Content };
1016+
return new ElicitationResult
1017+
{
1018+
Action = result.Action,
1019+
Content = result.Content?.ToDictionary(kvp => kvp.Key, kvp => (object)kvp.Value)
1020+
};
10091021
}
10101022

10111023
public async Task<bool> ConfirmAsync(string message, CancellationToken cancellationToken)
@@ -1017,9 +1029,9 @@ public async Task<bool> ConfirmAsync(string message, CancellationToken cancellat
10171029
var schema = new UIElicitationSchema
10181030
{
10191031
Type = "object",
1020-
Properties = new Dictionary<string, object>
1032+
Properties = new Dictionary<string, JsonElement>
10211033
{
1022-
["confirmed"] = new Dictionary<string, object> { ["type"] = "boolean", ["default"] = true }
1034+
["confirmed"] = ParseJsonElement("""{"type":"boolean","default":true}""")
10231035
},
10241036
Required = ["confirmed"]
10251037
};
@@ -1029,11 +1041,10 @@ public async Task<bool> ConfirmAsync(string message, CancellationToken cancellat
10291041
&& result.Content != null
10301042
&& result.Content.TryGetValue("confirmed", out var val))
10311043
{
1032-
return val switch
1044+
return val.ValueKind switch
10331045
{
1034-
bool b => b,
1035-
JsonElement { ValueKind: JsonValueKind.True } => true,
1036-
JsonElement { ValueKind: JsonValueKind.False } => false,
1046+
JsonValueKind.True => true,
1047+
JsonValueKind.False => false,
10371048
_ => false
10381049
};
10391050
}
@@ -1048,12 +1059,13 @@ public async Task<bool> ConfirmAsync(string message, CancellationToken cancellat
10481059
session.ThrowIfDisposed();
10491060
session.AssertElicitation();
10501061

1062+
var enumJson = JsonSerializer.Serialize(options, TypesJsonContext.Default.StringArray);
10511063
var schema = new UIElicitationSchema
10521064
{
10531065
Type = "object",
1054-
Properties = new Dictionary<string, object>
1066+
Properties = new Dictionary<string, JsonElement>
10551067
{
1056-
["selection"] = new Dictionary<string, object> { ["type"] = "string", ["enum"] = options }
1068+
["selection"] = ParseJsonElement($$"""{"type":"string","enum":{{enumJson}}}""")
10571069
},
10581070
Required = ["selection"]
10591071
};
@@ -1063,12 +1075,7 @@ public async Task<bool> ConfirmAsync(string message, CancellationToken cancellat
10631075
&& result.Content != null
10641076
&& result.Content.TryGetValue("selection", out var val))
10651077
{
1066-
return val switch
1067-
{
1068-
string s => s,
1069-
JsonElement { ValueKind: JsonValueKind.String } je => je.GetString(),
1070-
_ => val.ToString()
1071-
};
1078+
return val.ValueKind == JsonValueKind.String ? val.GetString() : val.ToString();
10721079
}
10731080

10741081
return null;
@@ -1080,18 +1087,21 @@ public async Task<bool> ConfirmAsync(string message, CancellationToken cancellat
10801087
session.ThrowIfDisposed();
10811088
session.AssertElicitation();
10821089

1083-
var field = new Dictionary<string, object> { ["type"] = "string" };
1084-
if (options?.Title != null) field["title"] = options.Title;
1085-
if (options?.Description != null) field["description"] = options.Description;
1086-
if (options?.MinLength != null) field["minLength"] = options.MinLength;
1087-
if (options?.MaxLength != null) field["maxLength"] = options.MaxLength;
1088-
if (options?.Format != null) field["format"] = options.Format;
1089-
if (options?.Default != null) field["default"] = options.Default;
1090+
var fieldNode = new System.Text.Json.Nodes.JsonObject { ["type"] = "string" };
1091+
if (options?.Title != null) fieldNode["title"] = options.Title;
1092+
if (options?.Description != null) fieldNode["description"] = options.Description;
1093+
if (options?.MinLength != null) fieldNode["minLength"] = options.MinLength;
1094+
if (options?.MaxLength != null) fieldNode["maxLength"] = options.MaxLength;
1095+
if (options?.Format != null) fieldNode["format"] = options.Format;
1096+
if (options?.Default != null) fieldNode["default"] = options.Default;
10901097

10911098
var schema = new UIElicitationSchema
10921099
{
10931100
Type = "object",
1094-
Properties = new Dictionary<string, object> { ["value"] = field },
1101+
Properties = new Dictionary<string, JsonElement>
1102+
{
1103+
["value"] = ParseJsonElement(fieldNode.ToJsonString())
1104+
},
10951105
Required = ["value"]
10961106
};
10971107

@@ -1100,12 +1110,7 @@ public async Task<bool> ConfirmAsync(string message, CancellationToken cancellat
11001110
&& result.Content != null
11011111
&& result.Content.TryGetValue("value", out var val))
11021112
{
1103-
return val switch
1104-
{
1105-
string s => s,
1106-
JsonElement { ValueKind: JsonValueKind.String } je => je.GetString(),
1107-
_ => val.ToString()
1108-
};
1113+
return val.ValueKind == JsonValueKind.String ? val.GetString() : val.ToString();
11091114
}
11101115

11111116
return null;

dotnet/src/SessionFsProvider.cs

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
*--------------------------------------------------------------------------------------------*/
44

55
using GitHub.Copilot.Rpc;
6+
using System.Text.Json;
67

78
namespace GitHub.Copilot;
89

@@ -44,7 +45,7 @@ public interface ISessionFsSqliteProvider
4445
Task<SessionFsSqliteResult?> QueryAsync(
4546
SessionFsSqliteQueryType queryType,
4647
string query,
47-
IDictionary<string, object>? bindParams,
48+
IDictionary<string, object?>? bindParams,
4849
CancellationToken cancellationToken);
4950

5051
/// <summary>
@@ -287,11 +288,16 @@ async Task<SessionFsSqliteQueryResult> ISessionFsHandler.SqliteQueryAsync(Sessio
287288

288289
try
289290
{
290-
var result = await sqliteProvider.QueryAsync(request.QueryType, request.Query, request.Params, cancellationToken).ConfigureAwait(false);
291+
var bindParams = request.Params?.ToDictionary(
292+
kvp => kvp.Key,
293+
kvp => JsonElementToValue(kvp.Value));
294+
var result = await sqliteProvider.QueryAsync(request.QueryType, request.Query, bindParams, cancellationToken).ConfigureAwait(false);
291295

292296
return new SessionFsSqliteQueryResult
293297
{
294-
Rows = result?.Rows ?? [],
298+
Rows = result?.Rows?.Select(row => (IDictionary<string, JsonElement>)row.ToDictionary(
299+
kvp => kvp.Key,
300+
kvp => CopilotClient.ToJsonElementForWire(kvp.Value)!.Value)).ToList() ?? [],
295301
Columns = result?.Columns ?? [],
296302
RowsAffected = result?.RowsAffected ?? 0,
297303
LastInsertRowid = result?.LastInsertRowid,
@@ -329,4 +335,14 @@ private static SessionFsError ToSessionFsError(Exception ex)
329335
: SessionFsErrorCode.UNKNOWN;
330336
return new SessionFsError { Code = code, Message = ex.Message };
331337
}
338+
339+
private static object? JsonElementToValue(JsonElement element) => element.ValueKind switch
340+
{
341+
JsonValueKind.Null => null,
342+
JsonValueKind.True => true,
343+
JsonValueKind.False => false,
344+
JsonValueKind.String => element.GetString(),
345+
JsonValueKind.Number => element.TryGetInt64(out var l) ? l : element.GetDouble(),
346+
_ => element.GetRawText(),
347+
};
332348
}

dotnet/src/Types.cs

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -672,7 +672,7 @@ public sealed class ToolInvocation
672672
/// <summary>
673673
/// Arguments passed to the tool by the language model.
674674
/// </summary>
675-
public object? Arguments { get; set; }
675+
public JsonElement? Arguments { get; set; }
676676
}
677677

678678
/// <summary>
@@ -1123,7 +1123,7 @@ public sealed class PreToolUseHookInput
11231123
/// Arguments that will be passed to the tool.
11241124
/// </summary>
11251125
[JsonPropertyName("toolArgs")]
1126-
public object? ToolArgs { get; set; }
1126+
public JsonElement? ToolArgs { get; set; }
11271127
}
11281128

11291129
/// <summary>
@@ -1278,13 +1278,13 @@ public sealed class PostToolUseHookInput
12781278
/// Arguments that were passed to the tool.
12791279
/// </summary>
12801280
[JsonPropertyName("toolArgs")]
1281-
public object? ToolArgs { get; set; }
1281+
public JsonElement? ToolArgs { get; set; }
12821282

12831283
/// <summary>
12841284
/// Result returned by the tool execution.
12851285
/// </summary>
12861286
[JsonPropertyName("toolResult")]
1287-
public object? ToolResult { get; set; }
1287+
public JsonElement? ToolResult { get; set; }
12881288
}
12891289

12901290
/// <summary>

dotnet/test/E2E/InMemorySessionFsSqliteHandler.cs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ private SqliteConnection GetOrCreateDb()
4343
public Task<SessionFsSqliteResult?> QueryAsync(
4444
SessionFsSqliteQueryType queryType,
4545
string query,
46-
IDictionary<string, object>? bindParams,
46+
IDictionary<string, object?>? bindParams,
4747
CancellationToken cancellationToken)
4848
{
4949
sqliteCalls.Add(new SqliteCall(sessionId, queryType.Value, query));
@@ -125,7 +125,7 @@ public Task<bool> ExistsAsync(CancellationToken cancellationToken)
125125
return Task.FromResult(_db is not null);
126126
}
127127

128-
private static void AddParams(SqliteCommand cmd, IDictionary<string, object>? bindParams)
128+
private static void AddParams(SqliteCommand cmd, IDictionary<string, object?>? bindParams)
129129
{
130130
if (bindParams is null) return;
131131
foreach (var (key, value) in bindParams)

dotnet/test/E2E/PendingWorkResumeE2ETests.cs

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
using GitHub.Copilot.Test.Harness;
77
using Microsoft.Extensions.AI;
88
using System.ComponentModel;
9+
using System.Text.Json;
910
using Xunit;
1011
using Xunit.Abstractions;
1112
using RpcPermissionDecisionApproveOnce = GitHub.Copilot.Rpc.PermissionDecisionApproveOnce;
@@ -136,7 +137,7 @@ await session1.SendAsync(new MessageOptions
136137

137138
var toolResult = await session2.Rpc.Tools.HandlePendingToolCallAsync(
138139
toolEvent.Data.RequestId,
139-
result: "EXTERNAL_RESUMED_BETA");
140+
result: JsonDocument.Parse("\"EXTERNAL_RESUMED_BETA\"").RootElement.Clone());
140141
Assert.True(toolResult.Success);
141142

142143
var answer = await TestHelper.GetFinalAssistantMessageAsync(session2, PendingWorkTimeout);
@@ -205,7 +206,7 @@ await session1.SendAsync(new MessageOptions
205206

206207
var resumedResult = await session2.Rpc.Tools.HandlePendingToolCallAsync(
207208
toolEvent.Data.RequestId,
208-
result: "EXTERNAL_RESUMED_BETA");
209+
result: JsonDocument.Parse("\"EXTERNAL_RESUMED_BETA\"").RootElement.Clone());
209210
Assert.True(resumedResult.Success);
210211

211212
// continuePendingWork=false may interrupt agent continuation before this response,
@@ -282,11 +283,11 @@ await Task.WhenAll(
282283
var toolB = toolEvents["pending_lookup_b"];
283284
var resultB = await session2.Rpc.Tools.HandlePendingToolCallAsync(
284285
toolB.Data.RequestId,
285-
result: "PARALLEL_B_BETA");
286+
result: JsonDocument.Parse("\"PARALLEL_B_BETA\"").RootElement.Clone());
286287
Assert.True(resultB.Success);
287288
var resultA = await session2.Rpc.Tools.HandlePendingToolCallAsync(
288289
toolA.Data.RequestId,
289-
result: "PARALLEL_A_ALPHA");
290+
result: JsonDocument.Parse("\"PARALLEL_A_ALPHA\"").RootElement.Clone());
290291
Assert.True(resultA.Success);
291292

292293
await session2.DisposeAsync();

dotnet/test/E2E/RpcTasksAndHandlersE2ETests.cs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
using GitHub.Copilot.Rpc;
66
using GitHub.Copilot.Test.Harness;
7+
using System.Text.Json;
78
using Xunit;
89
using Xunit.Abstractions;
910

@@ -143,7 +144,7 @@ public async Task Should_Return_Expected_Results_For_Missing_Pending_Handler_Req
143144

144145
var tool = await session.Rpc.Tools.HandlePendingToolCallAsync(
145146
requestId: "missing-tool-request",
146-
result: "tool result");
147+
result: JsonDocument.Parse("\"tool result\"").RootElement.Clone());
147148
Assert.False(tool.Success);
148149

149150
var command = await session.Rpc.Commands.HandlePendingCommandAsync(

dotnet/test/E2E/SessionFsE2ETests.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -609,7 +609,7 @@ protected override Task RemoveAsync(string path, bool recursive, bool force, Can
609609
protected override Task RenameAsync(string src, string dest, CancellationToken cancellationToken) =>
610610
Task.FromException(exception);
611611

612-
Task<SessionFsSqliteResult?> ISessionFsSqliteProvider.QueryAsync(SessionFsSqliteQueryType queryType, string query, IDictionary<string, object>? bindParams, CancellationToken cancellationToken) =>
612+
Task<SessionFsSqliteResult?> ISessionFsSqliteProvider.QueryAsync(SessionFsSqliteQueryType queryType, string query, IDictionary<string, object?>? bindParams, CancellationToken cancellationToken) =>
613613
Task.FromException<SessionFsSqliteResult?>(exception);
614614

615615
Task<bool> ISessionFsSqliteProvider.ExistsAsync(CancellationToken cancellationToken) =>

0 commit comments

Comments
 (0)