From c73e5cf22e13bb1e86f4a1a6002269b96bf57626 Mon Sep 17 00:00:00 2001 From: Stephen Toub Date: Tue, 26 May 2026 15:50:13 -0400 Subject: [PATCH 1/2] Fix C# canvas action RPC compatibility Register the legacy canvas.action.invoke client-session method alongside canvas.invokeAction so runtimes that still dispatch the older method can reach the C# canvas handler. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- dotnet/src/Canvas.cs | 1 + dotnet/src/Generated/Rpc.cs | 6 ++++++ dotnet/src/Types.cs | 2 +- scripts/codegen/csharp.ts | 35 +++++++++++++++++++++++------------ 4 files changed, 31 insertions(+), 13 deletions(-) diff --git a/dotnet/src/Canvas.cs b/dotnet/src/Canvas.cs index 69514edda..1c472ba2d 100644 --- a/dotnet/src/Canvas.cs +++ b/dotnet/src/Canvas.cs @@ -131,6 +131,7 @@ internal partial class CanvasJsonContext : JsonSerializerContext; /// A session installs a single via /// SessionConfigBase.CanvasHandler. The handler receives every /// inbound canvas.open / canvas.close / canvas.invokeAction +/// (or legacy canvas.action.invoke) /// JSON-RPC request the runtime issues for this session and decides — typically /// by inspecting — which /// application-side canvas should handle the call. diff --git a/dotnet/src/Generated/Rpc.cs b/dotnet/src/Generated/Rpc.cs index 3652fd784..cc0d6aad2 100644 --- a/dotnet/src/Generated/Rpc.cs +++ b/dotnet/src/Generated/Rpc.cs @@ -14803,6 +14803,12 @@ public static void RegisterClientSessionApiHandlers(JsonRpc rpc, Func>)(async (request, cancellationToken) => + { + var handler = getHandlers(request.SessionId).Canvas; + if (handler is null) throw new InvalidOperationException($"No canvas handler registered for session: {request.SessionId}"); + return await handler.InvokeActionAsync(request, cancellationToken); + }), singleObjectParam: true); } } diff --git a/dotnet/src/Types.cs b/dotnet/src/Types.cs index 54c0f71b6..3d2b2e0bf 100644 --- a/dotnet/src/Types.cs +++ b/dotnet/src/Types.cs @@ -2511,7 +2511,7 @@ protected SessionConfigBase(SessionConfigBase? other) /// /// Provider-side canvas lifecycle handler. The SDK routes inbound /// canvas.open / canvas.close / canvas.invokeAction - /// requests to this handler. + /// (or legacy canvas.action.invoke) requests to this handler. /// [Experimental(Diagnostics.Experimental)] [JsonIgnore] diff --git a/scripts/codegen/csharp.ts b/scripts/codegen/csharp.ts index 883895cde..727b215c3 100644 --- a/scripts/codegen/csharp.ts +++ b/scripts/codegen/csharp.ts @@ -2176,6 +2176,15 @@ function clientHandlerMethodName(rpcMethod: string): string { return `${toPascalCase(parts[parts.length - 1])}Async`; } +function clientSessionApiRegistrationRpcMethods(rpcMethod: string): string[] { + if (rpcMethod === "canvas.invokeAction") { + // Older runtimes dispatch canvas actions with this method name. + return [rpcMethod, "canvas.action.invoke"]; + } + + return [rpcMethod]; +} + function emitClientSessionApiRegistration(clientSchema: Record, classes: string[]): string[] { const lines: string[] = []; const groups = collectClientGroups(clientSchema); @@ -2272,20 +2281,22 @@ function emitClientSessionApiRegistration(clientSchema: Record, const paramsClass = paramsTypeName(method); const taskType = handlerTaskType(method); - if (hasParams) { - lines.push(` rpc.SetLocalRpcMethod("${method.rpcMethod}", (Func<${paramsClass}, CancellationToken, ${taskType}>)(async (request, cancellationToken) =>`); - lines.push(` {`); - lines.push(` var handler = getHandlers(request.SessionId).${handlerProperty};`); - lines.push(` if (handler is null) throw new InvalidOperationException($"No ${groupName} handler registered for session: {request.SessionId}");`); - if (!isVoidSchema(resultSchema)) { - lines.push(` return await handler.${handlerMethod}(request, cancellationToken);`); + for (const rpcMethod of clientSessionApiRegistrationRpcMethods(method.rpcMethod)) { + if (hasParams) { + lines.push(` rpc.SetLocalRpcMethod("${rpcMethod}", (Func<${paramsClass}, CancellationToken, ${taskType}>)(async (request, cancellationToken) =>`); + lines.push(` {`); + lines.push(` var handler = getHandlers(request.SessionId).${handlerProperty};`); + lines.push(` if (handler is null) throw new InvalidOperationException($"No ${groupName} handler registered for session: {request.SessionId}");`); + if (!isVoidSchema(resultSchema)) { + lines.push(` return await handler.${handlerMethod}(request, cancellationToken);`); + } else { + lines.push(` await handler.${handlerMethod}(request, cancellationToken);`); + } + lines.push(` }), singleObjectParam: true);`); } else { - lines.push(` await handler.${handlerMethod}(request, cancellationToken);`); + lines.push(` rpc.SetLocalRpcMethod("${rpcMethod}", (Func)(_ =>`); + lines.push(` throw new InvalidOperationException("No params provided for ${rpcMethod}")));`); } - lines.push(` }), singleObjectParam: true);`); - } else { - lines.push(` rpc.SetLocalRpcMethod("${method.rpcMethod}", (Func)(_ =>`); - lines.push(` throw new InvalidOperationException("No params provided for ${method.rpcMethod}")));`); } } } From e1ecbf51549bf72596f8a7d23422f258fb714137 Mon Sep 17 00:00:00 2001 From: Stephen Toub Date: Tue, 26 May 2026 16:20:21 -0400 Subject: [PATCH 2/2] Address C# canvas review feedback Fix the canvas handler XML doc grammar and add JSON-RPC coverage that verifies the legacy canvas.action.invoke alias reaches the same generated client-session handler as canvas.invokeAction. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- dotnet/src/Canvas.cs | 6 +-- dotnet/test/Unit/JsonRpcTests.cs | 93 ++++++++++++++++++++++++++++++++ 2 files changed, 96 insertions(+), 3 deletions(-) diff --git a/dotnet/src/Canvas.cs b/dotnet/src/Canvas.cs index 1c472ba2d..ed892f5a0 100644 --- a/dotnet/src/Canvas.cs +++ b/dotnet/src/Canvas.cs @@ -129,12 +129,12 @@ internal partial class CanvasJsonContext : JsonSerializerContext; /// /// /// A session installs a single via -/// SessionConfigBase.CanvasHandler. The handler receives every +/// SessionConfigBase.CanvasHandler. The handler receives all /// inbound canvas.open / canvas.close / canvas.invokeAction /// (or legacy canvas.action.invoke) -/// JSON-RPC request the runtime issues for this session and decides — typically +/// JSON-RPC requests the runtime issues for this session and decides — typically /// by inspecting — which -/// application-side canvas should handle the call. +/// application-side canvas should handle each call. /// /// The SDK does not maintain a per-canvas registry; multiplexing across /// declared canvases is the implementor's responsibility. diff --git a/dotnet/test/Unit/JsonRpcTests.cs b/dotnet/test/Unit/JsonRpcTests.cs index 6c045c8eb..4b4a87b42 100644 --- a/dotnet/test/Unit/JsonRpcTests.cs +++ b/dotnet/test/Unit/JsonRpcTests.cs @@ -5,6 +5,7 @@ using System.Reflection; using System.Text.Json; using System.Text.Json.Serialization.Metadata; +using Rpc = GitHub.Copilot.Rpc; using Xunit; namespace GitHub.Copilot.Test.Unit; @@ -93,6 +94,64 @@ public async Task JsonRpc_Cancels_And_Disposes_Pending_Requests() await Assert.ThrowsAnyAsync(() => pending); } + [Fact] + public async Task JsonRpc_Routes_CanvasInvokeAction_LegacyAlias_To_Same_Handler() + { + using var pair = JsonRpcReflectionPair.Create(startServer: false); + var handler = new RecordingRpcCanvasHandler(); + RegisterClientSessionApiHandlers(pair.Server, sessionId => + { + Assert.Equal("session-1", sessionId); + return new Rpc.ClientSessionApiHandlers { Canvas = handler }; + }); + + pair.Server.StartListening(); + pair.StartListening(); + + var canonical = await InvokeCanvasActionAsync(pair.Client, "canvas.invokeAction"); + var legacy = await InvokeCanvasActionAsync(pair.Client, "canvas.action.invoke"); + + Assert.Equal(1, canonical.Count); + Assert.Equal("increment", canonical.ActionName); + Assert.Equal(2, legacy.Count); + Assert.Equal("increment", legacy.ActionName); + Assert.Collection(handler.ActionRequests, AssertIncrementActionRequest, AssertIncrementActionRequest); + } + + private static Task InvokeCanvasActionAsync(JsonRpcReflection rpc, string methodName) => + rpc.InvokeAsync( + methodName, + [new Rpc.CanvasProviderInvokeActionRequest + { + SessionId = "session-1", + InstanceId = "canvas-1", + CanvasId = "canvas", + ExtensionId = "provider", + ActionName = "increment" + }]); + + private static void AssertIncrementActionRequest(Rpc.CanvasProviderInvokeActionRequest request) + { + Assert.Equal("session-1", request.SessionId); + Assert.Equal("canvas-1", request.InstanceId); + Assert.Equal("canvas", request.CanvasId); + Assert.Equal("provider", request.ExtensionId); + Assert.Equal("increment", request.ActionName); + } + + private static void RegisterClientSessionApiHandlers( + JsonRpcReflection rpc, + Func getHandlers) + { + var registrationType = typeof(Rpc.ClientSessionApiHandlers).Assembly.GetType( + "GitHub.Copilot.Rpc.ClientSessionApiRegistration", + throwOnError: true)!; + + registrationType + .GetMethod("RegisterClientSessionApiHandlers", BindingFlags.Public | BindingFlags.Static)! + .Invoke(null, [rpc.Instance, getHandlers]); + } + private static int GetRemoteErrorCode(Exception exception) { var property = exception.GetType().GetProperty("ErrorCode", BindingFlags.Instance | BindingFlags.Public); @@ -117,6 +176,38 @@ private sealed class SingleObjectResponse public string Value { get; set; } = string.Empty; } + private sealed class CanvasActionAliasResponse + { + public string ActionName { get; set; } = string.Empty; + + public int Count { get; set; } + } + + private sealed class RecordingRpcCanvasHandler : Rpc.ICanvasHandler + { + public List ActionRequests { get; } = []; + + public Task OpenAsync( + Rpc.CanvasProviderOpenRequest request, + CancellationToken cancellationToken = default) => + throw new NotSupportedException(); + + public Task CloseAsync(Rpc.CanvasProviderCloseRequest request, CancellationToken cancellationToken = default) => + throw new NotSupportedException(); + + public Task InvokeActionAsync( + Rpc.CanvasProviderInvokeActionRequest request, + CancellationToken cancellationToken = default) + { + ActionRequests.Add(request); + return Task.FromResult(new CanvasActionAliasResponse + { + ActionName = request.ActionName, + Count = ActionRequests.Count + }); + } + } + private sealed class JsonRpcReflectionPair : IDisposable { private readonly InMemoryDuplexStream _clientStream; @@ -179,6 +270,8 @@ public JsonRpcReflection(Stream stream) culture: null)!; } + public object Instance => _instance; + public void StartListening() => JsonRpcType.GetMethod(nameof(StartListening))!.Invoke(_instance, null); public void SetLocalRpcMethod(string methodName, Delegate handler, bool singleObjectParam = false) =>