Skip to content
Open
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
7 changes: 4 additions & 3 deletions dotnet/src/Canvas.cs
Original file line number Diff line number Diff line change
Expand Up @@ -129,11 +129,12 @@ internal partial class CanvasJsonContext : JsonSerializerContext;
/// </summary>
/// <remarks>
/// A session installs a single <see cref="ICanvasHandler"/> via
/// <c>SessionConfigBase.CanvasHandler</c>. The handler receives every
/// <c>SessionConfigBase.CanvasHandler</c>. The handler receives all
/// inbound <c>canvas.open</c> / <c>canvas.close</c> / <c>canvas.invokeAction</c>
/// JSON-RPC request the runtime issues for this session and decides — typically
/// (or legacy <c>canvas.action.invoke</c>)
/// JSON-RPC requests the runtime issues for this session and decides — typically
/// by inspecting <see cref="CanvasProviderOpenRequest.CanvasId"/> — which
/// application-side canvas should handle the call.
/// application-side canvas should handle each call.
/// <para>
/// The SDK does not maintain a per-canvas registry; multiplexing across
/// declared canvases is the implementor's responsibility.
Expand Down
6 changes: 6 additions & 0 deletions dotnet/src/Generated/Rpc.cs

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion dotnet/src/Types.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2511,7 +2511,7 @@ protected SessionConfigBase(SessionConfigBase? other)
/// <summary>
/// Provider-side canvas lifecycle handler. The SDK routes inbound
/// <c>canvas.open</c> / <c>canvas.close</c> / <c>canvas.invokeAction</c>
/// requests to this handler.
/// (or legacy <c>canvas.action.invoke</c>) requests to this handler.
/// </summary>
[Experimental(Diagnostics.Experimental)]
[JsonIgnore]
Expand Down
93 changes: 93 additions & 0 deletions dotnet/test/Unit/JsonRpcTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -93,6 +94,64 @@ public async Task JsonRpc_Cancels_And_Disposes_Pending_Requests()
await Assert.ThrowsAnyAsync<ObjectDisposedException>(() => 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<CanvasActionAliasResponse> InvokeCanvasActionAsync(JsonRpcReflection rpc, string methodName) =>
rpc.InvokeAsync<CanvasActionAliasResponse>(
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<string, Rpc.ClientSessionApiHandlers> 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);
Expand All @@ -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<Rpc.CanvasProviderInvokeActionRequest> ActionRequests { get; } = [];

public Task<Rpc.CanvasProviderOpenResult> OpenAsync(
Rpc.CanvasProviderOpenRequest request,
CancellationToken cancellationToken = default) =>
throw new NotSupportedException();

public Task CloseAsync(Rpc.CanvasProviderCloseRequest request, CancellationToken cancellationToken = default) =>
throw new NotSupportedException();

public Task<object> InvokeActionAsync(
Rpc.CanvasProviderInvokeActionRequest request,
CancellationToken cancellationToken = default)
{
ActionRequests.Add(request);
return Task.FromResult<object>(new CanvasActionAliasResponse
{
ActionName = request.ActionName,
Count = ActionRequests.Count
});
}
}

private sealed class JsonRpcReflectionPair : IDisposable
{
private readonly InMemoryDuplexStream _clientStream;
Expand Down Expand Up @@ -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) =>
Expand Down
35 changes: 23 additions & 12 deletions scripts/codegen/csharp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>, classes: string[]): string[] {
const lines: string[] = [];
const groups = collectClientGroups(clientSchema);
Expand Down Expand Up @@ -2272,20 +2281,22 @@ function emitClientSessionApiRegistration(clientSchema: Record<string, unknown>,
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<CancellationToken, ${taskType}>)(_ =>`);
lines.push(` throw new InvalidOperationException("No params provided for ${rpcMethod}")));`);
}
lines.push(` }), singleObjectParam: true);`);
} else {
lines.push(` rpc.SetLocalRpcMethod("${method.rpcMethod}", (Func<CancellationToken, ${taskType}>)(_ =>`);
lines.push(` throw new InvalidOperationException("No params provided for ${method.rpcMethod}")));`);
}
}
}
Expand Down
Loading