diff --git a/dotnet/src/Canvas.cs b/dotnet/src/Canvas.cs new file mode 100644 index 000000000..6a6134e18 --- /dev/null +++ b/dotnet/src/Canvas.cs @@ -0,0 +1,288 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Threading; +using System.Threading.Tasks; +using GitHub.Copilot.Rpc; + +namespace GitHub.Copilot; + +/// +/// Declarative metadata for a single canvas, sent over the wire on +/// session.create / session.resume. +/// +[Experimental(Diagnostics.Experimental)] +public sealed class CanvasDeclaration +{ + /// Canvas identifier, unique within the declaring connection. + [JsonPropertyName("id")] + public string Id { get; set; } = string.Empty; + + /// Human-readable name shown in host UI and canvas pickers. + [JsonPropertyName("displayName")] + public string DisplayName { get; set; } = string.Empty; + + /// Short, single-sentence description shown to the agent in canvas catalogs. + [JsonPropertyName("description")] + public string Description { get; set; } = string.Empty; + + /// JSON Schema for the input payload accepted by canvas.open. + [JsonPropertyName("inputSchema")] + public JsonElement? InputSchema { get; set; } + + /// Agent-callable actions this canvas exposes. + [JsonPropertyName("actions")] + public IList? Actions { get; set; } +} + +/// +/// Stable extension identity for session participants that provide canvases. +/// +[Experimental(Diagnostics.Experimental)] +public sealed class ExtensionInfo +{ + /// Extension namespace/source, e.g. "github-app". + [JsonPropertyName("source")] + public string Source { get; set; } = string.Empty; + + /// Stable provider name within the source namespace. + [JsonPropertyName("name")] + public string Name { get; set; } = string.Empty; +} + +/// Response returned from . +[Experimental(Diagnostics.Experimental)] +public sealed class CanvasOpenResponse +{ + /// URL the host should render. Optional for canvases with no visual surface. + [JsonPropertyName("url")] + public string? Url { get; set; } + + /// Provider-supplied title shown in host chrome. + [JsonPropertyName("title")] + public string? Title { get; set; } + + /// Provider-supplied status text shown in host chrome. + [JsonPropertyName("status")] + public string? Status { get; set; } +} + +/// Host capabilities passed to canvas provider callbacks. +[Experimental(Diagnostics.Experimental)] +public sealed class CanvasHostContext +{ + /// Host capability details. + [JsonPropertyName("capabilities")] + public CanvasHostCapabilities Capabilities { get; set; } = new(); +} + +/// Host capability details passed to canvas provider callbacks. +[Experimental(Diagnostics.Experimental)] +public sealed class CanvasHostCapabilities +{ + /// Whether the host supports canvas rendering. + [JsonPropertyName("canvases")] + public bool Canvases { get; set; } +} + +/// Context handed to . +[Experimental(Diagnostics.Experimental)] +public sealed class CanvasOpenContext +{ + /// Session that requested the canvas. + public string SessionId { get; init; } = string.Empty; + + /// Owning provider identifier. + public string ExtensionId { get; init; } = string.Empty; + + /// Canvas id from the declaring . + public string CanvasId { get; init; } = string.Empty; + + /// Stable instance id supplied by the runtime. + public string InstanceId { get; init; } = string.Empty; + + /// Validated input payload. + public JsonElement Input { get; init; } + + /// Host capabilities supplied by the runtime. + public CanvasHostContext? Host { get; init; } +} + +/// Context handed to . +[Experimental(Diagnostics.Experimental)] +public sealed class CanvasActionContext +{ + /// Session that invoked the action. + public string SessionId { get; init; } = string.Empty; + + /// Owning provider identifier. + public string ExtensionId { get; init; } = string.Empty; + + /// Canvas id targeted by the action. + public string CanvasId { get; init; } = string.Empty; + + /// Instance id targeted by the action. + public string InstanceId { get; init; } = string.Empty; + + /// Action name from . + public string ActionName { get; init; } = string.Empty; + + /// Validated input payload. + public JsonElement Input { get; init; } + + /// Host capabilities supplied by the runtime. + public CanvasHostContext? Host { get; init; } +} + +/// Context handed to a canvas's close lifecycle hook. +[Experimental(Diagnostics.Experimental)] +public sealed class CanvasLifecycleContext +{ + /// Session owning the canvas instance. + public string SessionId { get; init; } = string.Empty; + + /// Owning provider identifier. + public string ExtensionId { get; init; } = string.Empty; + + /// Canvas id from the declaring . + public string CanvasId { get; init; } = string.Empty; + + /// Instance id this lifecycle event applies to. + public string InstanceId { get; init; } = string.Empty; + + /// Host capabilities supplied by the runtime. + public CanvasHostContext? Host { get; init; } +} + +/// Structured error returned from canvas handlers. +/// +/// Throw this from implementations to surface a +/// machine-readable error code to the runtime. Any other exception is wrapped +/// in a generic canvas_handler_error envelope. +/// +[Experimental(Diagnostics.Experimental)] +public sealed class CanvasError : Exception +{ + /// Initializes a new . + /// Machine-readable error code. + /// Human-readable message. + public CanvasError(string code, string message) : base(message) + { + Code = code; + } + + /// Machine-readable error code. + public string Code { get; } + + /// + /// Default error returned when a custom action has no handler. + /// + public static CanvasError NoHandler() => new( + "canvas_action_no_handler", + "No handler implemented for this canvas action"); +} + +/// +/// Internal helpers used by the session runtime to translate +/// (and other handler-thrown exceptions) into structured JSON-RPC error responses. +/// +internal static class CanvasErrorHelpers +{ + private const int InternalError = -32603; + + public static LocalRpcInvocationException HandlerUnset() => Build( + "canvas_handler_unset", + "No canvas handler is registered on this session"); + + public static LocalRpcInvocationException HandlerError(string message) => Build( + "canvas_handler_error", + message); + + public static LocalRpcInvocationException ToRpcException(CanvasError error) => Build(error.Code, error.Message); + + private static LocalRpcInvocationException Build(string code, string message) + { + var json = JsonSerializer.Serialize( + new CanvasErrorPayload { Code = code, Message = message }, + CanvasJsonContext.Default.CanvasErrorPayload); + using var doc = JsonDocument.Parse(json); + return new LocalRpcInvocationException(InternalError, message, doc.RootElement.Clone()); + } + + internal sealed class CanvasErrorPayload + { + [JsonPropertyName("code")] + public string Code { get; set; } = string.Empty; + + [JsonPropertyName("message")] + public string Message { get; set; } = string.Empty; + } +} + +[JsonSourceGenerationOptions(PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase)] +[JsonSerializable(typeof(CanvasErrorHelpers.CanvasErrorPayload))] +internal partial class CanvasJsonContext : JsonSerializerContext; + +/// +/// Provider-side canvas lifecycle handler. +/// +/// +/// A session installs a single via +/// SessionConfigBase.CanvasHandler. The handler receives every +/// inbound canvas.open / canvas.close / 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. +/// +/// The SDK does not maintain a per-canvas registry; multiplexing across +/// declared canvases is the implementor's responsibility. +/// +/// +/// Implementations targeting netstandard2.0 cannot rely on default +/// interface methods; derive from to inherit +/// sensible defaults for and . +/// +/// +[Experimental(Diagnostics.Experimental)] +public interface ICanvasHandler +{ + /// Open a new canvas instance. + Task OnOpenAsync(CanvasOpenContext context, CancellationToken cancellationToken); + + /// Canvas was closed by the user or agent. Default: no-op. + Task OnCloseAsync(CanvasLifecycleContext context, CancellationToken cancellationToken); + + /// + /// Handle a non-lifecycle action declared by the canvas. + /// Default: throws . + /// + Task OnActionAsync(CanvasActionContext context, CancellationToken cancellationToken); +} + +/// +/// Convenience base class for that supplies +/// default no-op / no-handler implementations of the optional callbacks. +/// +[Experimental(Diagnostics.Experimental)] +public abstract class CanvasHandlerBase : ICanvasHandler +{ + /// + public abstract Task OnOpenAsync(CanvasOpenContext context, CancellationToken cancellationToken); + + /// + public virtual Task OnCloseAsync(CanvasLifecycleContext context, CancellationToken cancellationToken) +#if NET8_0_OR_GREATER + => Task.CompletedTask; +#else + => Task.FromResult(null); +#endif + + /// + public virtual Task OnActionAsync(CanvasActionContext context, CancellationToken cancellationToken) + => Task.FromException(CanvasError.NoHandler()); +} diff --git a/dotnet/src/Client.cs b/dotnet/src/Client.cs index a5cc62354..0e7730690 100644 --- a/dotnet/src/Client.cs +++ b/dotnet/src/Client.cs @@ -567,6 +567,7 @@ public async Task CreateSessionAsync(SessionConfig config, Cance session.On(config.OnEvent); } ConfigureSessionFsHandlers(session, config.CreateSessionFsProvider); + session.SetCanvasHandler(config.CanvasHandler); RegisterSession(session); session.StartProcessingEvents(); LoggingHelpers.LogTiming(_logger, LogLevel.Debug, null, @@ -618,7 +619,11 @@ public async Task CreateSessionAsync(SessionConfig config, Cance GitHubToken: config.GitHubToken, RemoteSession: config.RemoteSession, Cloud: config.Cloud, - InstructionDirectories: config.InstructionDirectories); + InstructionDirectories: config.InstructionDirectories, + Canvases: config.Canvases, + RequestCanvasRenderer: config.RequestCanvasRenderer, + RequestExtensions: config.RequestExtensions, + ExtensionInfo: config.ExtensionInfo); var rpcTimestamp = Stopwatch.GetTimestamp(); var response = await InvokeRpcAsync( @@ -630,6 +635,7 @@ public async Task CreateSessionAsync(SessionConfig config, Cance session.WorkspacePath = response.WorkspacePath; session.SetCapabilities(response.Capabilities); + session.SetOpenCanvases(response.OpenCanvases); } catch (Exception ex) { @@ -726,6 +732,7 @@ public async Task ResumeSessionAsync(string sessionId, ResumeSes session.On(config.OnEvent); } ConfigureSessionFsHandlers(session, config.CreateSessionFsProvider); + session.SetCanvasHandler(config.CanvasHandler); RegisterSession(session); session.StartProcessingEvents(); LoggingHelpers.LogTiming(_logger, LogLevel.Debug, null, @@ -778,7 +785,12 @@ public async Task ResumeSessionAsync(string sessionId, ResumeSes GitHubToken: config.GitHubToken, RemoteSession: config.RemoteSession, ContinuePendingWork: config.ContinuePendingWork, - InstructionDirectories: config.InstructionDirectories); + InstructionDirectories: config.InstructionDirectories, + Canvases: config.Canvases, + RequestCanvasRenderer: config.RequestCanvasRenderer, + RequestExtensions: config.RequestExtensions, + ExtensionInfo: config.ExtensionInfo, + OpenCanvases: config.OpenCanvases); var rpcTimestamp = Stopwatch.GetTimestamp(); var response = await InvokeRpcAsync( @@ -790,6 +802,7 @@ public async Task ResumeSessionAsync(string sessionId, ResumeSes session.WorkspacePath = response.WorkspacePath; session.SetCapabilities(response.Capabilities); + session.SetOpenCanvases(response.OpenCanvases); } catch (Exception ex) { @@ -1612,6 +1625,9 @@ private async Task ConnectToServerAsync(Process? cliProcess, string? rpc.SetLocalRpcMethod("autoModeSwitch.request", handler.OnAutoModeSwitchRequest); rpc.SetLocalRpcMethod("hooks.invoke", handler.OnHooksInvoke); rpc.SetLocalRpcMethod("systemMessage.transform", handler.OnSystemMessageTransform); + rpc.SetLocalRpcMethod("canvas.open", handler.OnCanvasOpen); + rpc.SetLocalRpcMethod("canvas.close", handler.OnCanvasClose); + rpc.SetLocalRpcMethod("canvas.action.invoke", handler.OnCanvasInvokeAction); ClientSessionApiRegistration.RegisterClientSessionApiHandlers(rpc, sessionId => { var session = GetSession(sessionId) ?? throw new ArgumentException($"Unknown session {sessionId}"); @@ -1804,6 +1820,47 @@ public async ValueTask OnSystemMessageTransfo var session = client.GetSession(sessionId) ?? throw new ArgumentException($"Unknown session {sessionId}"); return await session.HandleSystemMessageTransformAsync(sections); } + +#pragma warning disable GHCP001 + public ValueTask OnCanvasOpen( + string sessionId, + string extensionId, + string canvasId, + string instanceId, + JsonElement? input = null, + CanvasHostContext? host = null) + { + var session = client.GetSession(sessionId) ?? throw new ArgumentException($"Unknown session {sessionId}"); + return session.HandleCanvasOpenAsync( + extensionId, canvasId, instanceId, input ?? default, host); + } + + public async ValueTask OnCanvasClose( + string sessionId, + string extensionId, + string canvasId, + string instanceId, + JsonElement? input = null, + CanvasHostContext? host = null) + { + var session = client.GetSession(sessionId) ?? throw new ArgumentException($"Unknown session {sessionId}"); + await session.HandleCanvasCloseAsync(extensionId, canvasId, instanceId, host); + } + + public ValueTask OnCanvasInvokeAction( + string sessionId, + string extensionId, + string canvasId, + string instanceId, + string actionName, + JsonElement? input = null, + CanvasHostContext? host = null) + { + var session = client.GetSession(sessionId) ?? throw new ArgumentException($"Unknown session {sessionId}"); + return session.HandleCanvasActionAsync( + extensionId, canvasId, instanceId, actionName, input ?? default, host); + } +#pragma warning restore GHCP001 } private class Connection( @@ -1866,7 +1923,13 @@ internal record CreateSessionRequest( string? GitHubToken = null, RemoteSessionMode? RemoteSession = null, CloudSessionOptions? Cloud = null, - IList? InstructionDirectories = null); + IList? InstructionDirectories = null, +#pragma warning disable GHCP001 + IList? Canvases = null, + bool? RequestCanvasRenderer = null, + bool? RequestExtensions = null, + ExtensionInfo? ExtensionInfo = null); +#pragma warning restore GHCP001 internal record ToolDefinition( string Name, @@ -1888,7 +1951,10 @@ public static ToolDefinition FromAIFunction(AIFunctionDeclaration function) internal record CreateSessionResponse( string SessionId, string? WorkspacePath, - SessionCapabilities? Capabilities = null); + SessionCapabilities? Capabilities = null, +#pragma warning disable GHCP001 + IList? OpenCanvases = null); +#pragma warning restore GHCP001 internal record ResumeSessionRequest( string SessionId, @@ -1928,12 +1994,22 @@ internal record ResumeSessionRequest( string? GitHubToken = null, RemoteSessionMode? RemoteSession = null, bool? ContinuePendingWork = null, - IList? InstructionDirectories = null); + IList? InstructionDirectories = null, +#pragma warning disable GHCP001 + IList? Canvases = null, + bool? RequestCanvasRenderer = null, + bool? RequestExtensions = null, + ExtensionInfo? ExtensionInfo = null, + IList? OpenCanvases = null); +#pragma warning restore GHCP001 internal record ResumeSessionResponse( string SessionId, string? WorkspacePath, - SessionCapabilities? Capabilities = null); + SessionCapabilities? Capabilities = null, +#pragma warning disable GHCP001 + IList? OpenCanvases = null); +#pragma warning restore GHCP001 internal record CommandWireDefinition( string Name, diff --git a/dotnet/src/JsonRpc.cs b/dotnet/src/JsonRpc.cs index 866bb868f..df7170373 100644 --- a/dotnet/src/JsonRpc.cs +++ b/dotnet/src/JsonRpc.cs @@ -486,13 +486,21 @@ private async Task HandleIncomingMethodAsync(string methodName, JsonElement mess } catch (Exception ex) when (ex is not OperationCanceledException) { + var actual = ex is TargetInvocationException tie && tie.InnerException != null ? tie.InnerException : ex; if (_logger.IsEnabled(LogLevel.Debug)) { - _logger.LogDebug("Error handling JSON-RPC method {Method}: {Error}", methodName, ex.Message); + _logger.LogDebug("Error handling JSON-RPC method {Method}: {Error}", methodName, actual.Message); } if (requestId.HasValue) { - await SendErrorResponseAsync(requestId.Value, ErrorCodeInternalError, ex.Message, cancellationToken).ConfigureAwait(false); + if (actual is LocalRpcInvocationException lre) + { + await SendErrorResponseAsync(requestId.Value, lre.Code, lre.Message, lre.Data, cancellationToken).ConfigureAwait(false); + } + else + { + await SendErrorResponseAsync(requestId.Value, ErrorCodeInternalError, actual.Message, cancellationToken).ConfigureAwait(false); + } } } } @@ -718,13 +726,16 @@ await SendMessageAsync(new JsonRpcResponse } private async Task SendErrorResponseAsync(JsonElement id, int code, string message, CancellationToken cancellationToken) + => await SendErrorResponseAsync(id, code, message, data: null, cancellationToken).ConfigureAwait(false); + + private async Task SendErrorResponseAsync(JsonElement id, int code, string message, JsonElement? data, CancellationToken cancellationToken) { try { await SendMessageAsync(new JsonRpcErrorResponse { Id = id, - Error = new JsonRpcError { Code = code, Message = message }, + Error = new JsonRpcError { Code = code, Message = message, Data = data }, }, JsonRpcWireContext.Default.JsonRpcErrorResponse, cancellationToken).ConfigureAwait(false); } catch (Exception ex) when (ex is IOException or ObjectDisposedException or OperationCanceledException) @@ -852,6 +863,10 @@ private sealed class JsonRpcError [JsonPropertyName("message")] public string Message { get; set; } = string.Empty; + + [JsonPropertyName("data")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public JsonElement? Data { get; set; } } private sealed class JsonRpcNotification @@ -891,3 +906,20 @@ internal sealed class RemoteRpcException(string message, int errorCode, Exceptio public int ErrorCode { get; } = errorCode; } + +/// +/// Allows handler methods registered via JsonRpcConnection.SetLocalRpcMethod +/// to surface a structured JSON-RPC error response (code, message, and optional +/// data payload) instead of the default ErrorCodeInternalError envelope. +/// +internal sealed class LocalRpcInvocationException : Exception +{ + public LocalRpcInvocationException(int code, string message, JsonElement? data = null) : base(message) + { + Code = code; + Data = data; + } + + public int Code { get; } + public new JsonElement? Data { get; } +} diff --git a/dotnet/src/Session.cs b/dotnet/src/Session.cs index 6ad8e14d9..bd2309187 100644 --- a/dotnet/src/Session.cs +++ b/dotnet/src/Session.cs @@ -7,6 +7,7 @@ using Microsoft.Extensions.Logging; using System.Collections.Immutable; using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; using System.Text.Json; using System.Text.Json.Nodes; using System.Text.Json.Serialization; @@ -75,6 +76,11 @@ private sealed record EventSubscription(Type EventType, Action Han private Dictionary>>? _transformCallbacks; private readonly SemaphoreSlim _transformCallbacksLock = new(1, 1); +#pragma warning disable GHCP001 + private volatile ICanvasHandler? _canvasHandler; + private IReadOnlyList _openCanvases = Array.Empty(); +#pragma warning restore GHCP001 + private int _isDisposed; /// @@ -121,6 +127,19 @@ public SessionCapabilities Capabilities private set; } +#pragma warning disable GHCP001 + /// + /// Canvas instances currently known to be open for this session. + /// + /// + /// Populated from the most recent session.create / session.resume + /// response. This snapshot is not refreshed automatically when canvases open or + /// close after the session is established. + /// + [Experimental(Diagnostics.Experimental)] + public IReadOnlyList OpenCanvases => _openCanvases; +#pragma warning restore GHCP001 + /// /// Gets the UI API for eliciting information from the user during this session. /// @@ -861,6 +880,125 @@ internal void SetCapabilities(SessionCapabilities? capabilities) Capabilities = capabilities ?? new SessionCapabilities(); } +#pragma warning disable GHCP001 + internal void SetOpenCanvases(IList? canvases) + { + _openCanvases = canvases is { Count: > 0 } + ? new List(canvases).AsReadOnly() + : Array.Empty(); + } + + internal void SetCanvasHandler(ICanvasHandler? handler) + { + _canvasHandler = handler; + } + + internal async ValueTask HandleCanvasOpenAsync( + string extensionId, + string canvasId, + string instanceId, + JsonElement input, + CanvasHostContext? host) + { + var handler = _canvasHandler ?? throw CanvasErrorHelpers.HandlerUnset(); + var ctx = new CanvasOpenContext + { + SessionId = SessionId, + ExtensionId = extensionId, + CanvasId = canvasId, + InstanceId = instanceId, + Input = input, + Host = host, + }; + try + { + return await handler.OnOpenAsync(ctx, CancellationToken.None).ConfigureAwait(false); + } + catch (CanvasError ce) + { + throw CanvasErrorHelpers.ToRpcException(ce); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + throw CanvasErrorHelpers.HandlerError(ex.Message); + } + } + + internal async ValueTask HandleCanvasCloseAsync( + string extensionId, + string canvasId, + string instanceId, + CanvasHostContext? host) + { + var handler = _canvasHandler ?? throw CanvasErrorHelpers.HandlerUnset(); + var ctx = new CanvasLifecycleContext + { + SessionId = SessionId, + ExtensionId = extensionId, + CanvasId = canvasId, + InstanceId = instanceId, + Host = host, + }; + try + { + await handler.OnCloseAsync(ctx, CancellationToken.None).ConfigureAwait(false); + } + catch (CanvasError ce) + { + throw CanvasErrorHelpers.ToRpcException(ce); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + throw CanvasErrorHelpers.HandlerError(ex.Message); + } + } + + internal async ValueTask HandleCanvasActionAsync( + string extensionId, + string canvasId, + string instanceId, + string actionName, + JsonElement input, + CanvasHostContext? host) + { + var handler = _canvasHandler ?? throw CanvasErrorHelpers.HandlerUnset(); + var ctx = new CanvasActionContext + { + SessionId = SessionId, + ExtensionId = extensionId, + CanvasId = canvasId, + InstanceId = instanceId, + ActionName = actionName, + Input = input, + Host = host, + }; + try + { + var result = await handler.OnActionAsync(ctx, CancellationToken.None).ConfigureAwait(false); + return SerializeActionResult(result); + } + catch (CanvasError ce) + { + throw CanvasErrorHelpers.ToRpcException(ce); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + throw CanvasErrorHelpers.HandlerError(ex.Message); + } + } + + private static JsonElement SerializeActionResult(object? value) + { + var element = CopilotClient.ToJsonElementForWire(value); + if (element.HasValue) + { + return element.Value; + } + using var doc = JsonDocument.Parse("null"); + return doc.RootElement.Clone(); + } +#pragma warning restore GHCP001 + /// /// Dispatches a command.execute event to the registered handler and /// responds via the commands.handlePendingCommand RPC. diff --git a/dotnet/src/Types.cs b/dotnet/src/Types.cs index 29fceb40c..a02a5db3a 100644 --- a/dotnet/src/Types.cs +++ b/dotnet/src/Types.cs @@ -2218,6 +2218,13 @@ protected SessionConfigBase(SessionConfigBase? other) CreateSessionFsProvider = other.CreateSessionFsProvider; GitHubToken = other.GitHubToken; RemoteSession = other.RemoteSession; +#pragma warning disable GHCP001 + Canvases = other.Canvases is not null ? [.. other.Canvases] : null; + RequestCanvasRenderer = other.RequestCanvasRenderer; + RequestExtensions = other.RequestExtensions; + ExtensionInfo = other.ExtensionInfo; + CanvasHandler = other.CanvasHandler; +#pragma warning restore GHCP001 SkillDirectories = other.SkillDirectories is not null ? [.. other.SkillDirectories] : null; InstructionDirectories = other.InstructionDirectories is not null ? [.. other.InstructionDirectories] : null; Streaming = other.Streaming; @@ -2400,6 +2407,47 @@ protected SessionConfigBase(SessionConfigBase? other) /// /// public RemoteSessionMode? RemoteSession { get; set; } + +#pragma warning disable GHCP001 + /// + /// Canvas declarations advertised by this connection. The runtime forwards + /// these to the agent and routes inbound canvas.* requests for any + /// declared canvas to . + /// + [Experimental(Diagnostics.Experimental)] + public IList? Canvases { get; set; } + + /// + /// When , asks the host to expose canvas renderer tools + /// for this session. The host typically grants this only to trusted clients. + /// + [Experimental(Diagnostics.Experimental)] + public bool? RequestCanvasRenderer { get; set; } + + /// + /// When , asks the host to expose extension-discovery + /// tools for this session. The host typically grants this only to trusted clients. + /// + [Experimental(Diagnostics.Experimental)] + public bool? RequestExtensions { get; set; } + + /// + /// Stable extension identity for canvas/tool providers on this connection. + /// Required when is set so the runtime can attribute + /// declared canvases back to this provider. + /// + [Experimental(Diagnostics.Experimental)] + public ExtensionInfo? ExtensionInfo { get; set; } + + /// + /// Provider-side canvas lifecycle handler. The SDK routes inbound + /// canvas.open / canvas.close / canvas.action.invoke + /// requests to this handler. + /// + [Experimental(Diagnostics.Experimental)] + [JsonIgnore] + public ICanvasHandler? CanvasHandler { get; set; } +#pragma warning restore GHCP001 } /// @@ -2462,6 +2510,7 @@ private ResumeSessionConfig(ResumeSessionConfig? other) : base(other) SuppressResumeEvent = other.SuppressResumeEvent; ContinuePendingWork = other.ContinuePendingWork; + OpenCanvases = other.OpenCanvases is not null ? [.. other.OpenCanvases] : null; } /// @@ -2484,6 +2533,16 @@ private ResumeSessionConfig(ResumeSessionConfig? other) : base(other) /// public bool? ContinuePendingWork { get; set; } +#pragma warning disable GHCP001 + /// + /// Snapshot of canvases that were already open when the session was suspended. + /// When provided on resume, the runtime can rehydrate canvas state so consumers + /// do not need to re-open canvases that were active before the previous shutdown. + /// + [Experimental(Diagnostics.Experimental)] + public IList? OpenCanvases { get; set; } +#pragma warning restore GHCP001 + /// /// Creates a shallow clone of this instance. /// @@ -3029,4 +3088,11 @@ public sealed class SystemMessageTransformRpcResponse [JsonSerializable(typeof(object))] [JsonSerializable(typeof(Dictionary))] [JsonSerializable(typeof(string[]))] +#pragma warning disable GHCP001 +[JsonSerializable(typeof(CanvasDeclaration))] +[JsonSerializable(typeof(CanvasOpenResponse))] +[JsonSerializable(typeof(CanvasHostContext))] +[JsonSerializable(typeof(CanvasHostCapabilities))] +[JsonSerializable(typeof(ExtensionInfo))] +#pragma warning restore GHCP001 internal partial class TypesJsonContext : JsonSerializerContext; diff --git a/dotnet/test/Unit/CanvasTests.cs b/dotnet/test/Unit/CanvasTests.cs new file mode 100644 index 000000000..fc5db84aa --- /dev/null +++ b/dotnet/test/Unit/CanvasTests.cs @@ -0,0 +1,160 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +using System; +using System.Reflection; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using GitHub.Copilot; +using Xunit; + +namespace GitHub.Copilot.Test.Unit; + +public class CanvasTests +{ + private static JsonSerializerOptions GetSerializerOptions() + { + var prop = typeof(CopilotClient).GetProperty( + "SerializerOptionsForMessageFormatter", + BindingFlags.NonPublic | BindingFlags.Static); + var options = (JsonSerializerOptions?)prop?.GetValue(null); + Assert.NotNull(options); + return options!; + } + + [Fact] + public void CanvasDeclaration_Serializes_CamelCase_SkippingNulls() + { + var options = GetSerializerOptions(); + var decl = new CanvasDeclaration + { + Id = "report", + DisplayName = "Quarterly Report", + Description = "Renders the latest report", + }; + + var json = JsonSerializer.Serialize(decl, options); + using var doc = JsonDocument.Parse(json); + var root = doc.RootElement; + + Assert.Equal("report", root.GetProperty("id").GetString()); + Assert.Equal("Quarterly Report", root.GetProperty("displayName").GetString()); + Assert.Equal("Renders the latest report", root.GetProperty("description").GetString()); + Assert.False(root.TryGetProperty("inputSchema", out _)); + Assert.False(root.TryGetProperty("actions", out _)); + } + + [Fact] + public void CanvasOpenResponse_Roundtrips_WithCamelCaseFields() + { + var options = GetSerializerOptions(); + var response = new CanvasOpenResponse + { + Url = "https://example.com/c/1", + Title = "Demo", + Status = "ready" + }; + + var json = JsonSerializer.Serialize(response, options); + var parsed = JsonSerializer.Deserialize(json, options); + + Assert.NotNull(parsed); + Assert.Equal("https://example.com/c/1", parsed!.Url); + Assert.Equal("Demo", parsed.Title); + Assert.Equal("ready", parsed.Status); + } + + [Fact] + public void ExtensionInfo_Serializes_SourceAndName() + { + var options = GetSerializerOptions(); + var info = new ExtensionInfo { Source = "github-app", Name = "demo" }; + var json = JsonSerializer.Serialize(info, options); + using var doc = JsonDocument.Parse(json); + Assert.Equal("github-app", doc.RootElement.GetProperty("source").GetString()); + Assert.Equal("demo", doc.RootElement.GetProperty("name").GetString()); + } + + [Fact] + public async Task CanvasHandlerBase_DefaultOnClose_Completes() + { + var handler = new TestHandler(); + await handler.OnCloseAsync(new CanvasLifecycleContext(), CancellationToken.None); + } + + [Fact] + public async Task CanvasHandlerBase_DefaultOnAction_ThrowsNoHandlerCanvasError() + { + var handler = new TestHandler(); + var ex = await Assert.ThrowsAsync( + () => handler.OnActionAsync(new CanvasActionContext(), CancellationToken.None)); + Assert.Equal("canvas_action_no_handler", ex.Code); + } + + [Fact] + public void CanvasError_NoHandler_HasExpectedCode() + { + var err = CanvasError.NoHandler(); + Assert.Equal("canvas_action_no_handler", err.Code); + Assert.False(string.IsNullOrEmpty(err.Message)); + } + + [Fact] + public void SessionConfig_Clone_CopiesCanvasFields() + { + var handler = new TestHandler(); + var declaration = new CanvasDeclaration { Id = "c1", DisplayName = "C", Description = "d" }; + var config = new SessionConfig + { + Canvases = new[] { declaration }, + RequestCanvasRenderer = true, + RequestExtensions = true, + ExtensionInfo = new ExtensionInfo { Source = "github-app", Name = "demo" }, + CanvasHandler = handler + }; + + var clone = config.Clone(); + + Assert.NotNull(clone.Canvases); + Assert.Single(clone.Canvases!); + Assert.Equal("c1", clone.Canvases![0].Id); + Assert.True(clone.RequestCanvasRenderer); + Assert.True(clone.RequestExtensions); + Assert.NotNull(clone.ExtensionInfo); + Assert.Equal("github-app", clone.ExtensionInfo!.Source); + Assert.Same(handler, clone.CanvasHandler); + + // Mutating the clone's list does not affect the original. + clone.Canvases!.Add(new CanvasDeclaration { Id = "c2", DisplayName = "C2", Description = "d2" }); + Assert.Single(config.Canvases!); + } + + [Fact] + public void ResumeSessionConfig_Clone_CopiesCanvasFields() + { + var handler = new TestHandler(); + var config = new ResumeSessionConfig + { + Canvases = new[] { new CanvasDeclaration { Id = "c1", DisplayName = "C", Description = "d" } }, + RequestCanvasRenderer = true, + ExtensionInfo = new ExtensionInfo { Source = "s", Name = "n" }, + CanvasHandler = handler + }; + + var clone = config.Clone(); + + Assert.NotNull(clone.Canvases); + Assert.Single(clone.Canvases!); + Assert.True(clone.RequestCanvasRenderer); + Assert.NotNull(clone.ExtensionInfo); + Assert.Same(handler, clone.CanvasHandler); + } + + private sealed class TestHandler : CanvasHandlerBase + { + public override Task OnOpenAsync(CanvasOpenContext context, CancellationToken cancellationToken) + => Task.FromResult(new CanvasOpenResponse { Url = "https://example.com" }); + } +} diff --git a/dotnet/test/Unit/SerializationTests.cs b/dotnet/test/Unit/SerializationTests.cs index a95bd7ce2..f225fe4ee 100644 --- a/dotnet/test/Unit/SerializationTests.cs +++ b/dotnet/test/Unit/SerializationTests.cs @@ -203,6 +203,34 @@ public void ResumeSessionRequest_CanSerializeEnableSessionTelemetry_WithSdkOptio Assert.False(root.GetProperty("enableSessionTelemetry").GetBoolean()); } + [Fact] + public void ResumeSessionRequest_CanSerializeOpenCanvases_WithSdkOptions() + { + var options = GetSerializerOptions(); + var requestType = GetNestedType(typeof(CopilotClient), "ResumeSessionRequest"); + var instances = new List + { + new() + { + CanvasId = "canvas-id", + ExtensionId = "ext-id", + InstanceId = "instance-1", + Availability = CanvasInstanceAvailability.Ready, + }, + }; + var request = CreateInternalRequest( + requestType, + ("SessionId", "session-id"), + ("OpenCanvases", instances)); + + var json = JsonSerializer.Serialize(request, requestType, options); + using var document = JsonDocument.Parse(json); + var root = document.RootElement; + var openCanvases = root.GetProperty("openCanvases"); + Assert.Equal(1, openCanvases.GetArrayLength()); + Assert.Equal("canvas-id", openCanvases[0].GetProperty("canvasId").GetString()); + } + [Fact] public void ResumeSessionRequest_CanSerializeModeRequestFlags_WithSdkOptions() { diff --git a/go/canvas.go b/go/canvas.go new file mode 100644 index 000000000..2d122db29 --- /dev/null +++ b/go/canvas.go @@ -0,0 +1,232 @@ +// Canvas declarations, provider callbacks, and host-side canvas RPC types. +// +// This file mirrors rust/src/canvas.rs. The SDK does not maintain a per-canvas +// registry; multiplexing across declared canvases is the CanvasHandler +// implementor's responsibility (typically by switching on CanvasOpenContext.CanvasID). + +package copilot + +import ( + "context" + + "github.com/github/copilot-sdk/go/rpc" +) + +// CanvasDeclaration is the declarative metadata for a single canvas, sent over +// the wire on `session.create` / `session.resume`. +type CanvasDeclaration struct { + // ID is the canvas identifier, unique within the declaring connection. + ID string `json:"id"` + // DisplayName is the human-readable name shown in host UI and canvas pickers. + DisplayName string `json:"displayName"` + // Description is a short, single-sentence description shown to the agent in canvas catalogs. + Description string `json:"description"` + // InputSchema is the JSON Schema for the `input` payload accepted by `canvas.open`. + InputSchema map[string]any `json:"inputSchema,omitempty"` + // Actions are the agent-callable actions this canvas exposes. + Actions []rpc.CanvasAction `json:"actions,omitempty"` +} + +// CanvasOpenResponse is the response returned from CanvasHandler.OnOpen. +type CanvasOpenResponse struct { + // URL the host should render. Optional for canvases with no visual surface. + URL *string `json:"url,omitempty"` + // Title is the provider-supplied title shown in host chrome. + Title *string `json:"title,omitempty"` + // Status is the provider-supplied status text shown in host chrome. + Status *string `json:"status,omitempty"` +} + +// CanvasHostContext carries host capability hints passed to canvas provider callbacks. +type CanvasHostContext struct { + // Capabilities describes host feature support relevant to canvases. + Capabilities CanvasHostCapabilities `json:"capabilities"` +} + +// CanvasHostCapabilities describes host capability details passed to canvas provider callbacks. +type CanvasHostCapabilities struct { + // Canvases indicates whether the host supports canvas rendering. + Canvases bool `json:"canvases"` +} + +// CanvasOpenContext is the context handed to CanvasHandler.OnOpen. +type CanvasOpenContext struct { + // SessionID is the session that requested the canvas. + SessionID string + // ExtensionID is the owning provider identifier. + ExtensionID string + // CanvasID is the canvas id from the declaring CanvasDeclaration. + CanvasID string + // InstanceID is the stable instance id supplied by the runtime. + InstanceID string + // Input is the validated input payload. + Input any + // Host carries host capabilities supplied by the runtime. + Host *CanvasHostContext +} + +// CanvasActionContext is the context handed to CanvasHandler.OnAction. +type CanvasActionContext struct { + // SessionID is the session that invoked the action. + SessionID string + // ExtensionID is the owning provider identifier. + ExtensionID string + // CanvasID is the canvas id targeted by the action. + CanvasID string + // InstanceID is the instance id targeted by the action. + InstanceID string + // ActionName is the action name from CanvasAction.Name. + ActionName string + // Input is the validated input payload. + Input any + // Host carries host capabilities supplied by the runtime. + Host *CanvasHostContext +} + +// CanvasLifecycleContext is the context handed to a canvas's close lifecycle hook. +type CanvasLifecycleContext struct { + // SessionID is the session owning the canvas instance. + SessionID string + // ExtensionID is the owning provider identifier. + ExtensionID string + // CanvasID is the canvas id from the declaring CanvasDeclaration. + CanvasID string + // InstanceID is the instance id this lifecycle event applies to. + InstanceID string + // Host carries host capabilities supplied by the runtime. + Host *CanvasHostContext +} + +// CanvasError is a structured error returned from canvas handlers. +// +// Wire envelope: +// +// { "code": "", "message": "" } +type CanvasError struct { + // Code is the machine-readable error code. + Code string `json:"code"` + // Message is the human-readable message. + Message string `json:"message"` +} + +// Error implements the error interface. +func (e *CanvasError) Error() string { + return e.Code + ": " + e.Message +} + +// NewCanvasError constructs a new error envelope with the given code and message. +func NewCanvasError(code, message string) *CanvasError { + return &CanvasError{Code: code, Message: message} +} + +// CanvasErrorNoHandler is the default error returned when a custom action has no handler. +func CanvasErrorNoHandler() *CanvasError { + return NewCanvasError( + "canvas_action_no_handler", + "No handler implemented for this canvas action", + ) +} + +// CanvasHandler is the provider-side canvas lifecycle handler. +// +// A session installs a single CanvasHandler (via SessionConfig.CanvasHandler). +// The handler receives every inbound `canvas.open` / `canvas.close` / +// `canvas.action.invoke` JSON-RPC request the runtime issues for this session +// and decides — typically by inspecting CanvasOpenContext.CanvasID — which +// application-side canvas should handle the call. +// +// The SDK does not maintain a per-canvas registry; multiplexing across declared +// canvases is the implementor's responsibility. +// +// Embed CanvasHandlerDefaults to inherit no-op defaults for OnClose and a +// "no handler" error for OnAction. +type CanvasHandler interface { + OnOpen(ctx context.Context, c CanvasOpenContext) (CanvasOpenResponse, error) + OnClose(ctx context.Context, c CanvasLifecycleContext) error + OnAction(ctx context.Context, c CanvasActionContext) (any, error) +} + +// CanvasHandlerDefaults supplies default OnClose / OnAction implementations +// that consumers can inherit by embedding it in their CanvasHandler. +// +// Example: +// +// type myHandler struct { +// copilot.CanvasHandlerDefaults +// } +// func (h *myHandler) OnOpen(ctx context.Context, c copilot.CanvasOpenContext) (copilot.CanvasOpenResponse, error) { ... } +type CanvasHandlerDefaults struct{} + +// OnClose returns nil by default. +func (CanvasHandlerDefaults) OnClose(ctx context.Context, c CanvasLifecycleContext) error { + return nil +} + +// OnAction returns CanvasErrorNoHandler() by default. +func (CanvasHandlerDefaults) OnAction(ctx context.Context, c CanvasActionContext) (any, error) { + return nil, CanvasErrorNoHandler() +} + +// canvasProviderRequestParams is the wire shape of the common fields sent by +// direct `canvas.*` provider callbacks (canvas.open / canvas.close). +type canvasProviderRequestParams struct { + SessionID string `json:"sessionId"` + ExtensionID string `json:"extensionId"` + CanvasID string `json:"canvasId"` + InstanceID string `json:"instanceId"` + Input any `json:"input,omitempty"` + Host *CanvasHostContext `json:"host,omitempty"` +} + +func (p *canvasProviderRequestParams) toOpenContext() CanvasOpenContext { + return CanvasOpenContext{ + SessionID: p.SessionID, + ExtensionID: p.ExtensionID, + CanvasID: p.CanvasID, + InstanceID: p.InstanceID, + Input: p.Input, + Host: p.Host, + } +} + +func (p *canvasProviderRequestParams) toLifecycleContext() CanvasLifecycleContext { + return CanvasLifecycleContext{ + SessionID: p.SessionID, + ExtensionID: p.ExtensionID, + CanvasID: p.CanvasID, + InstanceID: p.InstanceID, + Host: p.Host, + } +} + +// canvasInvokeParams is the wire shape for `canvas.action.invoke`. +type canvasInvokeParams struct { + SessionID string `json:"sessionId"` + ExtensionID string `json:"extensionId"` + CanvasID string `json:"canvasId"` + InstanceID string `json:"instanceId"` + ActionName string `json:"actionName"` + Input any `json:"input,omitempty"` + Host *CanvasHostContext `json:"host,omitempty"` +} + +func (p *canvasInvokeParams) toActionContext() CanvasActionContext { + return CanvasActionContext{ + SessionID: p.SessionID, + ExtensionID: p.ExtensionID, + CanvasID: p.CanvasID, + InstanceID: p.InstanceID, + ActionName: p.ActionName, + Input: p.Input, + Host: p.Host, + } +} + +// ExtensionInfo carries stable extension identity for session participants +// that provide canvases. +type ExtensionInfo struct { + // Source is the extension namespace/source, e.g. "github-app". + Source string `json:"source"` + // Name is the stable provider name within the source namespace. + Name string `json:"name"` +} diff --git a/go/canvas_test.go b/go/canvas_test.go new file mode 100644 index 000000000..be0538d58 --- /dev/null +++ b/go/canvas_test.go @@ -0,0 +1,319 @@ +package copilot + +import ( + "context" + "encoding/json" + "errors" + "testing" + + "github.com/github/copilot-sdk/go/internal/jsonrpc2" + "github.com/github/copilot-sdk/go/rpc" +) + +func TestCanvasDeclaration_JSONShape(t *testing.T) { + desc := "bump" + decl := CanvasDeclaration{ + ID: "counter", + DisplayName: "Counter", + Description: "Count things", + Actions: []rpc.CanvasAction{ + {Name: "increment", Description: &desc}, + }, + } + + data, err := json.Marshal(decl) + if err != nil { + t.Fatalf("marshal failed: %v", err) + } + + var decoded map[string]any + if err := json.Unmarshal(data, &decoded); err != nil { + t.Fatalf("unmarshal failed: %v", err) + } + + if decoded["id"] != "counter" { + t.Fatalf("expected id=counter, got %v", decoded["id"]) + } + if decoded["displayName"] != "Counter" { + t.Fatalf("expected displayName=Counter, got %v", decoded["displayName"]) + } + if decoded["description"] != "Count things" { + t.Fatalf("expected description, got %v", decoded["description"]) + } + if _, present := decoded["inputSchema"]; present { + t.Fatalf("inputSchema should be omitted when nil, got %v", decoded["inputSchema"]) + } + actions, ok := decoded["actions"].([]any) + if !ok || len(actions) != 1 { + t.Fatalf("expected actions array of length 1, got %v", decoded["actions"]) + } + first, _ := actions[0].(map[string]any) + if first["name"] != "increment" { + t.Fatalf("expected first action name=increment, got %v", first["name"]) + } +} + +func TestCanvasDeclaration_OmitsEmptyActions(t *testing.T) { + decl := CanvasDeclaration{ID: "x", DisplayName: "X", Description: "y"} + data, err := json.Marshal(decl) + if err != nil { + t.Fatalf("marshal failed: %v", err) + } + var decoded map[string]any + _ = json.Unmarshal(data, &decoded) + if _, present := decoded["actions"]; present { + t.Fatalf("actions should be omitted when nil, got %v", decoded["actions"]) + } +} + +func TestCanvasHandlerDefaults_OnAction_ReturnsNoHandler(t *testing.T) { + d := CanvasHandlerDefaults{} + _, err := d.OnAction(context.Background(), CanvasActionContext{}) + if err == nil { + t.Fatalf("expected error from default OnAction") + } + cerr, ok := err.(*CanvasError) + if !ok { + t.Fatalf("expected *CanvasError, got %T", err) + } + if cerr.Code != "canvas_action_no_handler" { + t.Fatalf("expected code=canvas_action_no_handler, got %q", cerr.Code) + } +} + +func TestCanvasHandlerDefaults_OnClose_ReturnsNil(t *testing.T) { + d := CanvasHandlerDefaults{} + if err := d.OnClose(context.Background(), CanvasLifecycleContext{}); err != nil { + t.Fatalf("expected nil from default OnClose, got %v", err) + } +} + +func TestCanvasError_ErrorString(t *testing.T) { + e := NewCanvasError("foo_code", "bar message") + if got := e.Error(); got != "foo_code: bar message" { + t.Fatalf("unexpected Error() output: %q", got) + } +} + +// recordingCanvasHandler captures calls for assertion. +type recordingCanvasHandler struct { + CanvasHandlerDefaults + openCtx *CanvasOpenContext + openResult CanvasOpenResponse + openErr error +} + +func (h *recordingCanvasHandler) OnOpen(ctx context.Context, c CanvasOpenContext) (CanvasOpenResponse, error) { + h.openCtx = &c + return h.openResult, h.openErr +} + +func TestClient_HandleCanvasOpen_DispatchesToHandler(t *testing.T) { + title := "Echo" + url := "https://example.test/echo" + handler := &recordingCanvasHandler{ + openResult: CanvasOpenResponse{URL: &url, Title: &title}, + } + + session := &Session{SessionID: "s1"} + session.registerCanvasHandler(handler) + + c := &Client{sessions: map[string]*Session{"s1": session}} + + params := canvasProviderRequestParams{ + SessionID: "s1", + ExtensionID: "project:echo", + CanvasID: "echo", + InstanceID: "echo-1", + Input: map[string]any{"x": float64(1)}, + } + resp, rpcErr := c.handleCanvasOpen(params) + if rpcErr != nil { + t.Fatalf("unexpected rpc error: %+v", rpcErr) + } + if handler.openCtx == nil { + t.Fatalf("handler.OnOpen was not called") + } + if handler.openCtx.CanvasID != "echo" || handler.openCtx.InstanceID != "echo-1" { + t.Fatalf("unexpected ctx: %+v", handler.openCtx) + } + if resp.URL == nil || *resp.URL != url { + t.Fatalf("response URL not propagated: %+v", resp) + } +} + +func TestClient_HandleCanvasOpen_NoHandler_ReturnsUnsetError(t *testing.T) { + session := &Session{SessionID: "s1"} + c := &Client{sessions: map[string]*Session{"s1": session}} + + _, rpcErr := c.handleCanvasOpen(canvasProviderRequestParams{SessionID: "s1"}) + if rpcErr == nil { + t.Fatalf("expected error when no canvas handler installed") + } + if rpcErr.Code != -32603 { + t.Fatalf("expected internal-error code, got %d", rpcErr.Code) + } + var data map[string]string + if err := json.Unmarshal(rpcErr.Data, &data); err != nil { + t.Fatalf("invalid error data: %v", err) + } + if data["code"] != "canvas_handler_unset" { + t.Fatalf("expected code=canvas_handler_unset, got %q", data["code"]) + } +} + +func TestClient_HandleCanvasOpen_HandlerCanvasError_Wired(t *testing.T) { + handler := &recordingCanvasHandler{ + openErr: NewCanvasError("permission_denied", "nope"), + } + session := &Session{SessionID: "s1"} + session.registerCanvasHandler(handler) + c := &Client{sessions: map[string]*Session{"s1": session}} + + _, rpcErr := c.handleCanvasOpen(canvasProviderRequestParams{SessionID: "s1"}) + if rpcErr == nil { + t.Fatalf("expected error") + } + var data map[string]string + _ = json.Unmarshal(rpcErr.Data, &data) + if data["code"] != "permission_denied" { + t.Fatalf("expected propagated code, got %q", data["code"]) + } +} + +func TestClient_HandleCanvasOpen_HandlerGenericError_WrappedAsCanvasHandlerError(t *testing.T) { + handler := &recordingCanvasHandler{openErr: errors.New("boom")} + session := &Session{SessionID: "s1"} + session.registerCanvasHandler(handler) + c := &Client{sessions: map[string]*Session{"s1": session}} + + _, rpcErr := c.handleCanvasOpen(canvasProviderRequestParams{SessionID: "s1"}) + if rpcErr == nil { + t.Fatalf("expected error") + } + var data map[string]string + _ = json.Unmarshal(rpcErr.Data, &data) + if data["code"] != "canvas_handler_error" { + t.Fatalf("expected code=canvas_handler_error, got %q", data["code"]) + } + if data["message"] != "boom" { + t.Fatalf("expected message=boom, got %q", data["message"]) + } +} + +// Ensure the JSON-RPC inbound parsing wires through RequestHandlerFor correctly. +func TestClient_HandleCanvasOpen_RawJSONRoundTrip(t *testing.T) { + handler := &recordingCanvasHandler{ + openResult: CanvasOpenResponse{Status: strPtr("ready")}, + } + session := &Session{SessionID: "s1"} + session.registerCanvasHandler(handler) + c := &Client{sessions: map[string]*Session{"s1": session}} + + rpcHandler := jsonrpc2.RequestHandlerFor(c.handleCanvasOpen) + raw := []byte(`{"sessionId":"s1","extensionId":"ext","canvasId":"echo","instanceId":"i1","input":{"k":"v"},"host":{"capabilities":{"canvases":true}}}`) + out, rpcErr := rpcHandler(raw) + if rpcErr != nil { + t.Fatalf("unexpected rpc error: %v", rpcErr) + } + if handler.openCtx == nil { + t.Fatalf("handler not invoked") + } + if handler.openCtx.Host == nil || !handler.openCtx.Host.Capabilities.Canvases { + t.Fatalf("host capabilities not parsed: %+v", handler.openCtx.Host) + } + var decoded map[string]any + if err := json.Unmarshal(out, &decoded); err != nil { + t.Fatalf("bad output JSON: %v", err) + } + if decoded["status"] != "ready" { + t.Fatalf("expected status=ready, got %v", decoded["status"]) + } +} + +func TestResumeSessionResponse_OpenCanvasesParse(t *testing.T) { + raw := []byte(`{ + "sessionId": "s1", + "workspacePath": "/tmp/ws", + "openCanvases": [ + { + "availability": "ready", + "canvasId": "echo", + "extensionId": "project:echo", + "instanceId": "echo-1", + "reopen": false + } + ] + }`) + + var resp resumeSessionResponse + if err := json.Unmarshal(raw, &resp); err != nil { + t.Fatalf("unmarshal failed: %v", err) + } + if len(resp.OpenCanvases) != 1 { + t.Fatalf("expected 1 open canvas, got %d", len(resp.OpenCanvases)) + } + if resp.OpenCanvases[0].CanvasID != "echo" { + t.Fatalf("unexpected canvasId: %q", resp.OpenCanvases[0].CanvasID) + } + + session := &Session{SessionID: "s1"} + session.setOpenCanvases(resp.OpenCanvases) + got := session.OpenCanvases() + if len(got) != 1 || got[0].InstanceID != "echo-1" { + t.Fatalf("OpenCanvases did not surface snapshot: %+v", got) + } +} + +func TestResumeSessionRequest_OpenCanvasesWireShape(t *testing.T) { + req := resumeSessionRequest{ + SessionID: "s1", + OpenCanvases: []rpc.OpenCanvasInstance{ + { + Availability: "ready", + CanvasID: "echo", + ExtensionID: "project:echo", + InstanceID: "echo-1", + Reopen: false, + }, + }, + } + + data, err := json.Marshal(req) + if err != nil { + t.Fatalf("marshal failed: %v", err) + } + + var decoded map[string]any + if err := json.Unmarshal(data, &decoded); err != nil { + t.Fatalf("unmarshal failed: %v", err) + } + + raw, ok := decoded["openCanvases"].([]any) + if !ok || len(raw) != 1 { + t.Fatalf("expected openCanvases array of length 1, got %v", decoded["openCanvases"]) + } + first, _ := raw[0].(map[string]any) + if first["canvasId"] != "echo" { + t.Fatalf("expected canvasId=echo, got %v", first["canvasId"]) + } + if first["instanceId"] != "echo-1" { + t.Fatalf("expected instanceId=echo-1, got %v", first["instanceId"]) + } + + // Omitted when nil + empty := resumeSessionRequest{SessionID: "s1"} + emptyData, err := json.Marshal(empty) + if err != nil { + t.Fatalf("marshal empty failed: %v", err) + } + var emptyDecoded map[string]any + if err := json.Unmarshal(emptyData, &emptyDecoded); err != nil { + t.Fatalf("unmarshal empty failed: %v", err) + } + if _, present := emptyDecoded["openCanvases"]; present { + t.Fatalf("openCanvases should be omitted when nil") + } +} + +func strPtr(s string) *string { return &s } diff --git a/go/client.go b/go/client.go index 9491eb199..6e7557b0b 100644 --- a/go/client.go +++ b/go/client.go @@ -626,6 +626,10 @@ func (c *Client) CreateSession(ctx context.Context, config *SessionConfig) (*Ses req.GitHubToken = config.GitHubToken req.RemoteSession = config.RemoteSession req.Cloud = config.Cloud + req.Canvases = config.Canvases + req.RequestCanvasRenderer = config.RequestCanvasRenderer + req.RequestExtensions = config.RequestExtensions + req.ExtensionInfo = config.ExtensionInfo if len(config.Commands) > 0 { cmds := make([]wireCommand, 0, len(config.Commands)) @@ -708,6 +712,9 @@ func (c *Client) CreateSession(ctx context.Context, config *SessionConfig) (*Ses if config.OnAutoModeSwitchRequest != nil { session.registerAutoModeSwitchHandler(config.OnAutoModeSwitchRequest) } + if config.CanvasHandler != nil { + session.registerCanvasHandler(config.CanvasHandler) + } c.sessionsMux.Lock() c.sessions[sessionID] = session @@ -841,6 +848,11 @@ func (c *Client) ResumeSessionWithOptions(ctx context.Context, sessionID string, req.InfiniteSessions = config.InfiniteSessions req.GitHubToken = config.GitHubToken req.RemoteSession = config.RemoteSession + req.Canvases = config.Canvases + req.OpenCanvases = config.OpenCanvases + req.RequestCanvasRenderer = config.RequestCanvasRenderer + req.RequestExtensions = config.RequestExtensions + req.ExtensionInfo = config.ExtensionInfo if config.OnPermissionRequest != nil { req.RequestPermission = Bool(true) } @@ -896,6 +908,9 @@ func (c *Client) ResumeSessionWithOptions(ctx context.Context, sessionID string, if config.OnAutoModeSwitchRequest != nil { session.registerAutoModeSwitchHandler(config.OnAutoModeSwitchRequest) } + if config.CanvasHandler != nil { + session.registerCanvasHandler(config.CanvasHandler) + } c.sessionsMux.Lock() c.sessions[sessionID] = session @@ -938,6 +953,7 @@ func (c *Client) ResumeSessionWithOptions(ctx context.Context, sessionID string, session.workspacePath = response.WorkspacePath session.setCapabilities(response.Capabilities) + session.setOpenCanvases(response.OpenCanvases) return session, nil } @@ -1746,6 +1762,9 @@ func (c *Client) setupNotificationHandler() { c.client.SetRequestHandler("autoModeSwitch.request", jsonrpc2.RequestHandlerFor(c.handleAutoModeSwitchRequest)) c.client.SetRequestHandler("hooks.invoke", jsonrpc2.RequestHandlerFor(c.handleHooksInvoke)) c.client.SetRequestHandler("systemMessage.transform", jsonrpc2.RequestHandlerFor(c.handleSystemMessageTransform)) + c.client.SetRequestHandler("canvas.open", jsonrpc2.RequestHandlerFor(c.handleCanvasOpen)) + c.client.SetRequestHandler("canvas.close", jsonrpc2.RequestHandlerFor(c.handleCanvasClose)) + c.client.SetRequestHandler("canvas.action.invoke", jsonrpc2.RequestHandlerFor(c.handleCanvasActionInvoke)) rpc.RegisterClientSessionApiHandlers(c.client, func(sessionID string) *rpc.ClientSessionApiHandlers { c.sessionsMux.Lock() defer c.sessionsMux.Unlock() @@ -1894,3 +1913,89 @@ func (c *Client) handleSystemMessageTransform(req systemMessageTransformRequest) } return resp, nil } + +// canvasJSONRPCError converts a CanvasError into the structured JSON-RPC error +// envelope used by all canvas.* dispatch responses. +func canvasJSONRPCError(cerr *CanvasError) *jsonrpc2.Error { + data, _ := json.Marshal(map[string]string{ + "code": cerr.Code, + "message": cerr.Message, + }) + return &jsonrpc2.Error{ + Code: -32603, + Message: cerr.Message, + Data: data, + } +} + +// resolveCanvasSession looks up a session and its installed CanvasHandler, +// returning the canvas_handler_unset error envelope if either is missing. +func (c *Client) resolveCanvasSession(sessionID string) (*Session, CanvasHandler, *jsonrpc2.Error) { + c.sessionsMux.Lock() + session, ok := c.sessions[sessionID] + c.sessionsMux.Unlock() + if !ok { + return nil, nil, canvasJSONRPCError(NewCanvasError( + "canvas_handler_unset", + fmt.Sprintf("unknown session %s", sessionID), + )) + } + handler := session.getCanvasHandler() + if handler == nil { + return session, nil, canvasJSONRPCError(NewCanvasError( + "canvas_handler_unset", + "No CanvasHandler installed on this session; install one via SessionConfig.CanvasHandler before creating the session.", + )) + } + return session, handler, nil +} + +// canvasResultError normalizes any error returned from a CanvasHandler method +// into the structured JSON-RPC error envelope. +func canvasResultError(err error) *jsonrpc2.Error { + if err == nil { + return nil + } + if cerr, ok := err.(*CanvasError); ok { + return canvasJSONRPCError(cerr) + } + return canvasJSONRPCError(NewCanvasError("canvas_handler_error", err.Error())) +} + +// handleCanvasOpen dispatches an inbound canvas.open request to the session's CanvasHandler. +func (c *Client) handleCanvasOpen(params canvasProviderRequestParams) (CanvasOpenResponse, *jsonrpc2.Error) { + _, handler, rpcErr := c.resolveCanvasSession(params.SessionID) + if rpcErr != nil { + return CanvasOpenResponse{}, rpcErr + } + resp, err := handler.OnOpen(context.Background(), params.toOpenContext()) + if err != nil { + return CanvasOpenResponse{}, canvasResultError(err) + } + return resp, nil +} + +// handleCanvasClose dispatches an inbound canvas.close request to the session's CanvasHandler. +func (c *Client) handleCanvasClose(params canvasProviderRequestParams) (any, *jsonrpc2.Error) { + _, handler, rpcErr := c.resolveCanvasSession(params.SessionID) + if rpcErr != nil { + return nil, rpcErr + } + if err := handler.OnClose(context.Background(), params.toLifecycleContext()); err != nil { + return nil, canvasResultError(err) + } + return nil, nil +} + +// handleCanvasActionInvoke dispatches an inbound canvas.action.invoke request to the session's CanvasHandler. +func (c *Client) handleCanvasActionInvoke(params canvasInvokeParams) (any, *jsonrpc2.Error) { + _, handler, rpcErr := c.resolveCanvasSession(params.SessionID) + if rpcErr != nil { + return nil, rpcErr + } + result, err := handler.OnAction(context.Background(), params.toActionContext()) + if err != nil { + return nil, canvasResultError(err) + } + return result, nil +} diff --git a/go/session.go b/go/session.go index f38b4be17..eca928c19 100644 --- a/go/session.go +++ b/go/session.go @@ -75,6 +75,10 @@ type Session struct { commandHandlersMu sync.RWMutex elicitationHandler ElicitationHandler elicitationMu sync.RWMutex + canvasHandler CanvasHandler + canvasMu sync.RWMutex + openCanvases []rpc.OpenCanvasInstance + openCanvasesMu sync.RWMutex capabilities SessionCapabilities capabilitiesMu sync.RWMutex @@ -94,6 +98,38 @@ func (s *Session) WorkspacePath() string { return s.workspacePath } +// OpenCanvases returns the open-canvas snapshot last reported by the runtime +// (currently populated from the session.resume response). The returned slice +// is a copy and is safe to mutate by the caller. +func (s *Session) OpenCanvases() []rpc.OpenCanvasInstance { + s.openCanvasesMu.RLock() + defer s.openCanvasesMu.RUnlock() + if len(s.openCanvases) == 0 { + return nil + } + out := make([]rpc.OpenCanvasInstance, len(s.openCanvases)) + copy(out, s.openCanvases) + return out +} + +func (s *Session) setOpenCanvases(canvases []rpc.OpenCanvasInstance) { + s.openCanvasesMu.Lock() + defer s.openCanvasesMu.Unlock() + s.openCanvases = canvases +} + +func (s *Session) registerCanvasHandler(handler CanvasHandler) { + s.canvasMu.Lock() + defer s.canvasMu.Unlock() + s.canvasHandler = handler +} + +func (s *Session) getCanvasHandler() CanvasHandler { + s.canvasMu.RLock() + defer s.canvasMu.RUnlock() + return s.canvasHandler +} + // newSession creates a new session wrapper with the given session ID and client. func newSession(sessionID string, client *jsonrpc2.Client, workspacePath string) *Session { s := &Session{ diff --git a/go/types.go b/go/types.go index be86a326c..fe7f9d93c 100644 --- a/go/types.go +++ b/go/types.go @@ -927,6 +927,21 @@ type SessionConfig struct { // Cloud creates a remote session in the cloud instead of a local session. // The optional repository is associated with the cloud session. Cloud *CloudSessionOptions + // Canvases declares canvases this session provides. Sent over the wire on + // `session.create`. CanvasHandler must be set when this is non-empty (the + // SDK does not enforce this — declarations without a handler will surface + // canvas RPCs that return a canvas_handler_unset error envelope). + Canvases []CanvasDeclaration + // RequestCanvasRenderer asks the host to enable canvas rendering for this session. + RequestCanvasRenderer *bool + // RequestExtensions asks the host to surface declared canvases as agent-visible extensions. + RequestExtensions *bool + // CanvasHandler receives inbound canvas.open / canvas.close / canvas.action.invoke + // requests for this session. The SDK does not maintain a per-canvas registry; + // the handler must dispatch on CanvasOpenContext.CanvasID itself. + CanvasHandler CanvasHandler `json:"-"` + // ExtensionInfo identifies the stable extension providing this session's canvases. + ExtensionInfo *ExtensionInfo } type Tool struct { Name string `json:"name"` @@ -1175,6 +1190,21 @@ type ResumeSessionConfig struct { // OnAutoModeSwitchRequest is a handler for auto-mode-switch requests from the server. // See SessionConfig.OnAutoModeSwitchRequest. OnAutoModeSwitchRequest AutoModeSwitchRequestHandler + // Canvases declares canvases this session provides. Sent over the wire on + // `session.resume`. See SessionConfig.Canvases. + Canvases []CanvasDeclaration + // OpenCanvases declares canvas instances the caller knows were open before + // this resume so the runtime can re-attach them. Sent over the wire on + // `session.resume` as `openCanvases`. + OpenCanvases []rpc.OpenCanvasInstance + // RequestCanvasRenderer asks the host to enable canvas rendering for this session. + RequestCanvasRenderer *bool + // RequestExtensions asks the host to surface declared canvases as agent-visible extensions. + RequestExtensions *bool + // CanvasHandler receives inbound canvas.* requests for this session. See SessionConfig.CanvasHandler. + CanvasHandler CanvasHandler `json:"-"` + // ExtensionInfo identifies the stable extension providing this session's canvases. + ExtensionInfo *ExtensionInfo } type ProviderConfig struct { // Type is the provider type: "openai", "azure", or "anthropic". Defaults to "openai". @@ -1399,6 +1429,10 @@ type createSessionRequest struct { GitHubToken string `json:"gitHubToken,omitempty"` RemoteSession rpc.RemoteSessionMode `json:"remoteSession,omitempty"` Cloud *CloudSessionOptions `json:"cloud,omitempty"` + Canvases []CanvasDeclaration `json:"canvases,omitempty"` + RequestCanvasRenderer *bool `json:"requestCanvasRenderer,omitempty"` + RequestExtensions *bool `json:"requestExtensions,omitempty"` + ExtensionInfo *ExtensionInfo `json:"extensionInfo,omitempty"` Traceparent string `json:"traceparent,omitempty"` Tracestate string `json:"tracestate,omitempty"` } @@ -1454,15 +1488,21 @@ type resumeSessionRequest struct { RequestElicitation *bool `json:"requestElicitation,omitempty"` GitHubToken string `json:"gitHubToken,omitempty"` RemoteSession rpc.RemoteSessionMode `json:"remoteSession,omitempty"` + Canvases []CanvasDeclaration `json:"canvases,omitempty"` + OpenCanvases []rpc.OpenCanvasInstance `json:"openCanvases,omitempty"` + RequestCanvasRenderer *bool `json:"requestCanvasRenderer,omitempty"` + RequestExtensions *bool `json:"requestExtensions,omitempty"` + ExtensionInfo *ExtensionInfo `json:"extensionInfo,omitempty"` Traceparent string `json:"traceparent,omitempty"` Tracestate string `json:"tracestate,omitempty"` } // resumeSessionResponse is the response from session.resume type resumeSessionResponse struct { - SessionID string `json:"sessionId"` - WorkspacePath string `json:"workspacePath"` - Capabilities *SessionCapabilities `json:"capabilities,omitempty"` + SessionID string `json:"sessionId"` + WorkspacePath string `json:"workspacePath"` + Capabilities *SessionCapabilities `json:"capabilities,omitempty"` + OpenCanvases []rpc.OpenCanvasInstance `json:"openCanvases,omitempty"` } type hooksInvokeRequest struct { diff --git a/nodejs/src/canvas.ts b/nodejs/src/canvas.ts new file mode 100644 index 000000000..738dfc851 --- /dev/null +++ b/nodejs/src/canvas.ts @@ -0,0 +1,286 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +/** + * Extension-owned canvases declared via + * `joinSession({ canvases: [createCanvas({...})] })`. + * + * The runtime sends provider callbacks directly as `canvas.open`, + * `canvas.close`, and `canvas.action.invoke` JSON-RPC requests. The SDK + * routes those requests by `canvasId` to the in-process handlers bound by + * `createCanvas`. Re-opening with an existing `instanceId` is how the host + * focuses an existing panel; reload is a renderer-only concern. + */ + +/** JSON Schema object used for canvas inputs. */ +export type CanvasJsonSchema = Record; + +/** + * A single agent-callable action contributed by a canvas. The metadata + * (`name`, `description`, `inputSchema`) is serialized over the wire on + * `session.create` / `session.resume`; the `handler` closure is stripped + * before the declaration is sent and dispatched in-process by the SDK. + * + * Names MUST NOT start with `canvas.` — that prefix is reserved for + * lifecycle verbs. + */ +export interface CanvasAction { + /** Action identifier, unique within the canvas. */ + name: string; + /** Description shown to the model when picking an action. */ + description?: string; + /** Optional JSON Schema for the action's `input` payload. */ + inputSchema?: CanvasJsonSchema; + /** Required per-action dispatch handler. */ + handler: (ctx: CanvasActionContext) => Promise | unknown; +} + +/** + * Declarative metadata for a single canvas, serialized over the wire on + * `session.create` / `session.resume`. + */ +export interface CanvasDeclaration { + /** Canvas id, unique within the declaring connection. */ + id: string; + /** Human-readable label shown in discovery and host UI chrome. */ + displayName: string; + /** Short, single-sentence description shown to the agent in canvas catalogs. */ + description: string; + /** Optional JSON Schema for the `input` payload accepted by `canvas.open`. */ + inputSchema?: CanvasJsonSchema; + /** Agent-invocable actions exposed via `invoke_canvas_action`. */ + actions?: Omit[]; +} + +/** Response returned from `open`. */ +export interface CanvasOpenResponse { + /** URL the host should render. Optional for native canvases. */ + url?: string; + /** Provider-supplied title shown in host chrome. */ + title?: string; + /** Provider-supplied status text shown in host chrome. */ + status?: string; +} + +/** Host capabilities passed to canvas callbacks. */ +export interface CanvasHostContext { + capabilities?: { + canvases?: boolean; + }; +} + +/** Context handed to a canvas's `open` handler. */ +export interface CanvasOpenContext { + /** Session that requested the canvas. */ + sessionId: string; + /** Extension id that owns the canvas. */ + extensionId: string; + /** Canvas id (matches the declaring `CanvasDeclaration.id`). */ + canvasId: string; + /** Stable instance id supplied by the runtime. */ + instanceId: string; + /** Validated `input` payload, shaped by `CanvasDeclaration.inputSchema`. */ + input: unknown; + /** Host capabilities supplied by the runtime. */ + host?: CanvasHostContext; +} + +/** Context handed to a canvas action handler. */ +export interface CanvasActionContext { + /** Session that invoked the action. */ + sessionId: string; + /** Extension id that owns the canvas. */ + extensionId: string; + /** Canvas id targeted by the action. */ + canvasId: string; + /** Instance id targeted by the action. */ + instanceId: string; + /** Action name from `CanvasAction.name`. */ + actionName: string; + /** Validated `input` payload, shaped by the action's `inputSchema`. */ + input: unknown; + /** Host capabilities supplied by the runtime. */ + host?: CanvasHostContext; +} + +/** Context handed to a canvas's `onClose` handler. */ +export interface CanvasLifecycleContext { + /** Session owning the canvas instance. */ + sessionId: string; + /** Extension id that owns the canvas. */ + extensionId: string; + /** Canvas id (matches the declaring `CanvasDeclaration.id`). */ + canvasId: string; + /** Instance id this lifecycle event applies to. */ + instanceId: string; + /** Host capabilities supplied by the runtime. */ + host?: CanvasHostContext; +} + +/** Structured error returned from canvas handlers. */ +export class CanvasError extends Error { + constructor( + public readonly code: string, + message: string + ) { + super(message); + this.name = "CanvasError"; + } + + /** Default error when an action is declared but no `handler` is wired. */ + static noHandler(): CanvasError { + return new CanvasError( + "canvas_action_no_handler", + "No handler implemented for this canvas action" + ); + } +} + +/** + * Options accepted by {@link createCanvas}. Combines the declarative + * {@link CanvasDeclaration} fields with the in-process handler closures. + */ +export interface CanvasOptions { + /** @see CanvasDeclaration.id */ + id: string; + /** @see CanvasDeclaration.displayName */ + displayName: string; + /** @see CanvasDeclaration.description */ + description: string; + /** @see CanvasDeclaration.inputSchema */ + inputSchema?: CanvasJsonSchema; + /** + * Agent-invocable actions exposed via `invoke_canvas_action`. Each action + * carries its own required `handler`; the action's wire metadata + * (`name`, `description`, `inputSchema`) is what reaches the runtime. + */ + actions?: CanvasAction[]; + + /** Required. Open a new canvas instance. */ + open: (ctx: CanvasOpenContext) => Promise | CanvasOpenResponse; + + /** + * Optional. Notified when a canvas instance is closed by the user, the + * agent, or the host. Fire-and-forget: the return value is ignored and + * errors are logged but not surfaced to the runtime. + */ + onClose?: (ctx: CanvasLifecycleContext) => Promise | void; +} + +/** A registered canvas: declarative metadata + in-process handler closures. + * + * Node intentionally uses a per-canvas factory pattern (mirroring + * {@link https://github.com/github/copilot-sdk | `DefineTool`}'s co-location + * ergonomics) where other SDKs (Rust, Python, Go, .NET) expose a single + * `CanvasHandler` per session that switches on `canvasId`. Both shapes target + * the same JSON-RPC wire protocol; the divergence is API ergonomics only. + */ +export class Canvas { + readonly declaration: CanvasDeclaration; + readonly open: NonNullable; + readonly onClose?: CanvasOptions["onClose"]; + /** @internal */ + readonly actionHandlers: Map; + + /** @internal */ + constructor(options: CanvasOptions) { + const actionHandlers = new Map(); + const wireActions: Omit[] | undefined = options.actions?.map( + ({ handler, ...wire }) => { + actionHandlers.set(wire.name, handler); + return wire; + } + ); + + this.declaration = { + id: options.id, + displayName: options.displayName, + description: options.description, + inputSchema: options.inputSchema, + actions: wireActions, + }; + this.open = options.open; + this.onClose = options.onClose; + this.actionHandlers = actionHandlers; + } +} + +/** Create a canvas declaration with bound in-process handlers. + * + * Node intentionally uses this per-canvas factory pattern (mirroring + * `DefineTool`'s co-location ergonomics) where other SDKs (Rust, Python, Go, + * .NET) expose a single `CanvasHandler` per session that switches on + * `canvasId`. Both shapes target the same JSON-RPC wire protocol. + */ +export function createCanvas(options: CanvasOptions): Canvas { + return new Canvas(options); +} + +/** @internal */ +export interface CanvasProviderRequestParams { + sessionId: string; + extensionId: string; + canvasId: string; + instanceId: string; + input?: unknown; + host?: CanvasHostContext; +} + +/** @internal */ +export interface CanvasActionInvokeParams extends CanvasProviderRequestParams { + actionName: string; +} + +/** + * Dispatch a direct `canvas.*` provider request to the matching {@link Canvas} + * handler. + * + * @internal + */ +export async function dispatchCanvasProviderRequest( + canvas: Canvas, + actionName: "canvas.open" | "canvas.close" | string, + params: CanvasActionInvokeParams | CanvasProviderRequestParams +): Promise { + switch (actionName) { + case "canvas.open": { + const result = await canvas.open({ + sessionId: params.sessionId, + extensionId: params.extensionId, + canvasId: params.canvasId, + instanceId: params.instanceId, + input: params.input, + host: params.host, + }); + return result ?? {}; + } + case "canvas.close": { + if (canvas.onClose) { + await canvas.onClose({ + sessionId: params.sessionId, + extensionId: params.extensionId, + canvasId: params.canvasId, + instanceId: params.instanceId, + host: params.host, + }); + } + return undefined; + } + default: { + const perAction = canvas.actionHandlers.get(actionName); + if (!perAction) { + throw CanvasError.noHandler(); + } + return perAction({ + sessionId: params.sessionId, + extensionId: params.extensionId, + canvasId: params.canvasId, + instanceId: params.instanceId, + actionName, + input: params.input, + host: params.host, + }); + } + } +} diff --git a/nodejs/src/client.ts b/nodejs/src/client.ts index 21563d598..3e8a4cfa3 100644 --- a/nodejs/src/client.ts +++ b/nodejs/src/client.ts @@ -31,6 +31,12 @@ import { createInternalServerRpc, registerClientSessionApiHandlers, } from "./generated/rpc.js"; +import type { OpenCanvasInstance } from "./generated/rpc.js"; +import { + type CanvasActionInvokeParams, + type CanvasProviderRequestParams, + dispatchCanvasProviderRequest, +} from "./canvas.js"; import { getSdkProtocolVersion } from "./sdkProtocolVersion.js"; import { CopilotSession } from "./session.js"; import { createSessionFsAdapter, type SessionFsProvider } from "./sessionFsProvider.js"; @@ -51,6 +57,7 @@ import type { ResumeSessionConfig, SectionTransformFn, SessionConfig, + SessionCapabilities, SessionEvent, SessionFsConfig, SessionLifecycleEvent, @@ -127,6 +134,32 @@ function toWireCustomAgents(agents: CustomAgentConfig[] | undefined): unknown[] }); } +function isCanvasProviderRequestParams(params: unknown): params is CanvasProviderRequestParams { + if (!params || typeof params !== "object") { + return false; + } + + const request = params as { + sessionId?: unknown; + extensionId?: unknown; + canvasId?: unknown; + instanceId?: unknown; + }; + return ( + typeof request.sessionId === "string" && + typeof request.extensionId === "string" && + typeof request.canvasId === "string" && + typeof request.instanceId === "string" + ); +} + +function isCanvasActionInvokeParams(params: unknown): params is CanvasActionInvokeParams { + return ( + isCanvasProviderRequestParams(params) && + typeof (params as { actionName?: unknown }).actionName === "string" + ); +} + /** * Extract transform callbacks from a system message config and prepare the wire payload. * Function-valued actions are replaced with `{ action: "transform" }` for serialization, @@ -803,6 +836,7 @@ export class CopilotClient { this.onGetTraceContext ); session.registerTools(config.tools); + session.registerCanvases(config.canvases); session.registerCommands(config.commands); session.registerPermissionHandler(config.onPermissionRequest); if (config.onUserInputRequest) { @@ -849,6 +883,10 @@ export class CopilotClient { overridesBuiltInTool: tool.overridesBuiltInTool, skipPermission: tool.skipPermission, })), + canvases: config.canvases?.map((canvas) => canvas.declaration), + requestCanvasRenderer: config.requestCanvasRenderer, + requestExtensions: config.requestExtensions, + extensionInfo: config.extensionInfo, commands: config.commands?.map((cmd) => ({ name: cmd.name, description: cmd.description, @@ -887,7 +925,7 @@ export class CopilotClient { const { workspacePath, capabilities } = response as { sessionId: string; workspacePath?: string; - capabilities?: { ui?: { elicitation?: boolean } }; + capabilities?: SessionCapabilities; }; session["_workspacePath"] = workspacePath; session.setCapabilities(capabilities); @@ -937,6 +975,7 @@ export class CopilotClient { this.onGetTraceContext ); session.registerTools(config.tools); + session.registerCanvases(config.canvases); session.registerCommands(config.commands); session.registerPermissionHandler(config.onPermissionRequest); if (config.onUserInputRequest) { @@ -987,6 +1026,10 @@ export class CopilotClient { overridesBuiltInTool: tool.overridesBuiltInTool, skipPermission: tool.skipPermission, })), + canvases: config.canvases?.map((canvas) => canvas.declaration), + requestCanvasRenderer: config.requestCanvasRenderer, + requestExtensions: config.requestExtensions, + extensionInfo: config.extensionInfo, commands: config.commands?.map((cmd) => ({ name: cmd.name, description: cmd.description, @@ -1018,15 +1061,18 @@ export class CopilotClient { continuePendingWork: config.continuePendingWork, gitHubToken: config.gitHubToken, remoteSession: config.remoteSession, + openCanvases: config.openCanvases, }); - const { workspacePath, capabilities } = response as { + const { workspacePath, capabilities, openCanvases } = response as { sessionId: string; workspacePath?: string; - capabilities?: { ui?: { elicitation?: boolean } }; + capabilities?: SessionCapabilities; + openCanvases?: OpenCanvasInstance[]; }; session["_workspacePath"] = workspacePath; session.setCapabilities(capabilities); + session.setOpenCanvases(openCanvases ?? []); } catch (e) { this.sessions.delete(sessionId); throw e; @@ -1880,6 +1926,17 @@ export class CopilotClient { await this.handleSystemMessageTransform(params) ); + this.connection.onRequest("canvas.open", async (params: CanvasProviderRequestParams) => + this.handleCanvasProviderRequest("canvas.open", params) + ); + this.connection.onRequest("canvas.close", async (params: CanvasProviderRequestParams) => + this.handleCanvasProviderRequest("canvas.close", params) + ); + this.connection.onRequest( + "canvas.action.invoke", + async (params: CanvasActionInvokeParams) => this.handleCanvasActionInvokeRequest(params) + ); + // Register client session API handlers. const sessions = this.sessions; registerClientSessionApiHandlers(this.connection, (sessionId) => { @@ -2083,4 +2140,33 @@ export class CopilotClient { return await session._handleSystemMessageTransform(params.sections); } + + private async handleCanvasProviderRequest( + actionName: string, + params: unknown + ): Promise { + if (!isCanvasProviderRequestParams(params)) { + throw new Error("Invalid canvas provider request payload"); + } + + const session = this.sessions.get(params.sessionId); + if (!session) { + throw new Error(`Session not found: ${params.sessionId}`); + } + + const canvas = session.getCanvas(params.canvasId); + if (!canvas) { + throw new Error(`No canvas registered with id "${params.canvasId}"`); + } + + return dispatchCanvasProviderRequest(canvas, actionName, params); + } + + private async handleCanvasActionInvokeRequest(params: unknown): Promise { + if (!isCanvasActionInvokeParams(params)) { + throw new Error("Invalid canvas provider request payload"); + } + + return this.handleCanvasProviderRequest(params.actionName, params); + } } diff --git a/nodejs/src/extension.ts b/nodejs/src/extension.ts index 617052546..95346dec4 100644 --- a/nodejs/src/extension.ts +++ b/nodejs/src/extension.ts @@ -6,14 +6,32 @@ import { CopilotClient } from "./client.js"; import type { CopilotSession } from "./session.js"; import { defaultJoinSessionPermissionHandler, + type ExtensionInfo, type PermissionHandler, type ResumeSessionConfig, } from "./types.js"; +export { + Canvas, + CanvasError, + createCanvas, + type CanvasAction, + type CanvasActionContext, + type CanvasDeclaration, + type CanvasHostContext, + type CanvasJsonSchema, + type CanvasLifecycleContext, + type CanvasOpenContext, + type CanvasOpenResponse, + type CanvasOptions, +} from "./canvas.js"; + export type JoinSessionConfig = Omit & { onPermissionRequest?: PermissionHandler; }; +export type { ExtensionInfo }; + /** * Joins the current foreground session. * diff --git a/nodejs/src/index.ts b/nodejs/src/index.ts index b92ca38a6..42498c58f 100644 --- a/nodejs/src/index.ts +++ b/nodejs/src/index.ts @@ -11,6 +11,20 @@ export { CopilotClient } from "./client.js"; export { RuntimeConnection } from "./types.js"; export { CopilotSession, type AssistantMessageEvent } from "./session.js"; +export { + Canvas, + CanvasError, + createCanvas, + type CanvasAction, + type CanvasActionContext, + type CanvasDeclaration, + type CanvasHostContext, + type CanvasJsonSchema, + type CanvasLifecycleContext, + type CanvasOpenContext, + type CanvasOpenResponse, + type CanvasOptions, +} from "./canvas.js"; export { defineTool, approveAll, @@ -55,6 +69,7 @@ export type { ExitPlanModeHandler, ExitPlanModeRequest, ExitPlanModeResult, + ExtensionInfo, ForegroundSessionInfo, GetAuthStatusResponse, GetStatusResponse, diff --git a/nodejs/src/session.ts b/nodejs/src/session.ts index 6f2a002b1..74823602e 100644 --- a/nodejs/src/session.ts +++ b/nodejs/src/session.ts @@ -11,6 +11,8 @@ import type { MessageConnection } from "vscode-jsonrpc/node.js"; import { ConnectionError, ResponseError } from "vscode-jsonrpc/node.js"; import { createSessionRpc } from "./generated/rpc.js"; import type { ClientSessionApiHandlers } from "./generated/rpc.js"; +import type { Canvas } from "./canvas.js"; +import type { OpenCanvasInstance } from "./generated/rpc.js"; import { getTraceContext } from "./telemetry.js"; import type { CommandHandler, @@ -100,6 +102,7 @@ export class CopilotSession { private typedEventHandlers: Map void>> = new Map(); private toolHandlers: Map = new Map(); + private canvases: Map = new Map(); private commandHandlers: Map = new Map(); private permissionHandler?: PermissionHandler; private userInputHandler?: UserInputHandler; @@ -111,6 +114,7 @@ export class CopilotSession { private _rpc: ReturnType | null = null; private traceContextProvider?: TraceContextProvider; private _capabilities: SessionCapabilities = {}; + private openCanvasInstances: OpenCanvasInstance[] = []; /** @internal Client session API handlers, populated by CopilotClient during create/resume. */ clientSessionApis: ClientSessionApiHandlers = {}; @@ -635,6 +639,33 @@ export class CopilotSession { return this.toolHandlers.get(name); } + /** + * Registers canvas declarations and handlers for this session. + * + * @param canvases - Canvases created via `createCanvas`, or undefined to clear all canvases + * @internal Called by the SDK when creating/resuming a session with `canvases`. + */ + registerCanvases(canvases?: Canvas[]): void { + this.canvases.clear(); + if (!canvases) { + return; + } + for (const canvas of canvases) { + this.canvases.set(canvas.declaration.id, canvas); + } + } + + /** + * Retrieves a registered canvas by id. + * + * @param canvasId - The id of the canvas to retrieve + * @returns The registered Canvas if found, or undefined + * @internal Used by the SDK's direct `canvas.*` dispatcher. + */ + getCanvas(canvasId: string): Canvas | undefined { + return this.canvases.get(canvasId); + } + /** * Registers command handlers for this session. * @@ -745,6 +776,26 @@ export class CopilotSession { this._capabilities = capabilities ?? {}; } + /** + * Snapshot of canvas instances that were already open when the session was + * resumed. Populated from the `session.resume` response; empty for freshly + * created sessions. Returns a defensive copy — mutating the returned array + * has no effect on the session. + */ + get openCanvases(): OpenCanvasInstance[] { + return [...this.openCanvasInstances]; + } + + /** + * Sets the open-canvas snapshot for this session. + * + * @param instances - The `openCanvases` array from the `session.resume` response. + * @internal This method is typically called internally when resuming a session. + */ + setOpenCanvases(instances: OpenCanvasInstance[]): void { + this.openCanvasInstances = [...instances]; + } + private assertElicitation(): void { if (!this._capabilities.ui?.elicitation) { throw new Error( diff --git a/nodejs/src/types.ts b/nodejs/src/types.ts index 4f3de000b..623a4cabd 100644 --- a/nodejs/src/types.ts +++ b/nodejs/src/types.ts @@ -7,10 +7,12 @@ */ // Import and re-export generated session event types +import type { Canvas } from "./canvas.js"; import type { SessionFsProvider } from "./sessionFsProvider.js"; import type { SessionEvent as GeneratedSessionEvent } from "./generated/session-events.js"; import type { CopilotSession } from "./session.js"; import type { RemoteSessionMode } from "./generated/rpc.js"; +import type { OpenCanvasInstance } from "./generated/rpc.js"; export type { RemoteSessionMode } from "./generated/rpc.js"; export type SessionEvent = GeneratedSessionEvent; export type { SessionFsProvider } from "./sessionFsProvider.js"; @@ -543,6 +545,8 @@ export interface SessionCapabilities { ui?: { /** Whether the host supports interactive elicitation dialogs. */ elicitation?: boolean; + /** Whether the host supports canvas rendering. */ + canvases?: boolean; }; } @@ -1409,6 +1413,16 @@ export interface InfiniteSessionConfig { */ export type ReasoningEffort = "low" | "medium" | "high" | "xhigh"; +/** + * Stable extension identity for session participants that provide canvases. + */ +export interface ExtensionInfo { + /** Extension namespace/source, e.g. "github-app". */ + source: string; + /** Stable provider name within the source namespace. */ + name: string; +} + /** * Shared configuration fields used by both {@link SessionConfig} (for * creating a new session) and {@link ResumeSessionConfig} (for resuming @@ -1462,6 +1476,38 @@ export interface SessionConfigBase { // eslint-disable-next-line @typescript-eslint/no-explicit-any tools?: Tool[]; + /** + * Canvases contributed by this session participant. The declaring + * connection becomes the live provider for `canvas.open|focus|close|reload` + * and `canvas.action.invoke` dispatches targeting each canvas's `id` for + * the lifetime of the connection. Re-declaring the same id on resume + * replaces the prior declaration. + */ + canvases?: Canvas[]; + + /** + * Renderer-side opt-in: when true, the runtime surfaces canvas agent tools + * (`list_canvas_capabilities`, `open_canvas`, `invoke_canvas_action`) to + * the model for this connection. Default off so SDK callers that cannot + * display canvases stay clean. + */ + requestCanvasRenderer?: boolean; + + /** + * Extension surface opt-in: when true, the runtime wires extension + * management tools and per-extension tool dispatch onto the session for + * this connection. Default off so callers that do not expose extensions + * stay clean. + */ + requestExtensions?: boolean; + + /** + * Stable extension identity for canvas providers on this connection. When + * set, the runtime uses `${source}:${name}` as the agent-facing extension + * id instead of a reconnect-specific connection id. + */ + extensionInfo?: ExtensionInfo; + /** * Slash commands registered for this session. * When the CLI has a TUI, each command appears as `/name` for the user to invoke. @@ -1692,6 +1738,12 @@ export interface ResumeSessionConfig extends SessionConfigBase { * @default false */ continuePendingWork?: boolean; + /** + * Snapshot of canvases that were already open when the session was suspended. + * When provided on resume, the runtime can rehydrate canvas state so consumers + * do not need to re-open canvases that were active before the previous shutdown. + */ + openCanvases?: OpenCanvasInstance[]; } /** diff --git a/nodejs/test/client.test.ts b/nodejs/test/client.test.ts index b9a34c214..ff46c75b3 100644 --- a/nodejs/test/client.test.ts +++ b/nodejs/test/client.test.ts @@ -1,6 +1,12 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ import { describe, expect, it, onTestFinished, vi } from "vitest"; -import { approveAll, CopilotClient, RuntimeConnection, type ModelInfo } from "../src/index.js"; +import { + approveAll, + CopilotClient, + createCanvas, + RuntimeConnection, + type ModelInfo, +} from "../src/index.js"; import { CopilotSession } from "../src/session.js"; import { defaultJoinSessionPermissionHandler } from "../src/types.js"; @@ -17,6 +23,197 @@ describe("CopilotClient", () => { expect(spy).not.toHaveBeenCalled(); }); + it("forwards canvas declarations and request flags in session.create", async () => { + const client = new CopilotClient(); + await client.start(); + onTestFinished(() => client.forceStop()); + + const canvas = createCanvas({ + id: "counter", + displayName: "Counter", + description: "A counter canvas", + actions: [{ name: "increment", description: "Increment the counter" }], + open: () => ({ url: "https://example.test/counter" }), + }); + const spy = vi + .spyOn((client as any).connection!, "sendRequest") + .mockImplementation(async (method: string, params: any) => { + if (method === "session.create") return { sessionId: params.sessionId }; + throw new Error(`Unexpected method: ${method}`); + }); + + await client.createSession({ + onPermissionRequest: approveAll, + canvases: [canvas], + requestCanvasRenderer: true, + requestExtensions: true, + extensionInfo: { source: "github-app", name: "counter-provider" }, + }); + + const payload = spy.mock.calls.find(([method]) => method === "session.create")![1] as any; + expect(payload.canvases).toEqual([ + expect.objectContaining({ + id: "counter", + displayName: "Counter", + description: "A counter canvas", + actions: [{ name: "increment", description: "Increment the counter" }], + }), + ]); + expect(payload.requestCanvasRenderer).toBe(true); + expect(payload.requestExtensions).toBe(true); + expect(payload.extensionInfo).toEqual({ + source: "github-app", + name: "counter-provider", + }); + }); + + it("forwards canvas declarations in session.resume", async () => { + const client = new CopilotClient(); + await client.start(); + onTestFinished(() => client.forceStop()); + + const session = await client.createSession({ onPermissionRequest: approveAll }); + const canvas = createCanvas({ + id: "counter", + displayName: "Counter", + description: "A counter canvas", + open: () => ({ url: "https://example.test/counter" }), + }); + const spy = vi + .spyOn((client as any).connection!, "sendRequest") + .mockImplementation(async (method: string, params: any) => { + if (method === "session.resume") return { sessionId: params.sessionId }; + throw new Error(`Unexpected method: ${method}`); + }); + + await client.resumeSession(session.sessionId, { + onPermissionRequest: approveAll, + canvases: [canvas], + requestCanvasRenderer: true, + requestExtensions: true, + extensionInfo: { source: "github-app", name: "counter-provider" }, + }); + + const payload = spy.mock.calls.find(([method]) => method === "session.resume")![1] as any; + expect(payload.canvases).toEqual([expect.objectContaining({ id: "counter" })]); + expect(payload.requestCanvasRenderer).toBe(true); + expect(payload.requestExtensions).toBe(true); + expect(payload.extensionInfo).toEqual({ + source: "github-app", + name: "counter-provider", + }); + expect(payload.openCanvasInstances).toBeUndefined(); + }); + + it("routes direct canvas action requests to registered canvases", async () => { + const canvas = createCanvas({ + id: "counter", + displayName: "Counter", + description: "A counter canvas", + open: ({ instanceId }) => ({ url: `https://example.test/${instanceId}` }), + actions: [ + { + name: "increment", + handler: ({ actionName, input }) => ({ actionName, input }), + }, + ], + }); + const session = new CopilotSession("session-1", {} as any); + session.registerCanvases([canvas]); + const client = new CopilotClient(); + (client as any).sessions.set(session.sessionId, session); + + const result = await (client as any).handleCanvasProviderRequest("increment", { + sessionId: session.sessionId, + extensionId: "project:counter", + canvasId: "counter", + instanceId: "counter-1", + actionName: "increment", + input: { amount: 1 }, + }); + + expect(result).toEqual({ actionName: "increment", input: { amount: 1 } }); + }); + + it("returns canvas_action_no_handler when no per-action handler is registered", async () => { + const canvas = createCanvas({ + id: "counter", + displayName: "Counter", + description: "A counter canvas", + open: () => ({ url: "https://example.test/counter" }), + }); + + const session = new CopilotSession("session-1", {} as any); + session.registerCanvases([canvas]); + const client = new CopilotClient(); + (client as any).sessions.set(session.sessionId, session); + + await expect( + (client as any).handleCanvasProviderRequest("ghost", { + sessionId: session.sessionId, + extensionId: "project:counter", + canvasId: "counter", + instanceId: "counter-1", + actionName: "ghost", + input: undefined, + }) + ).rejects.toMatchObject({ code: "canvas_action_no_handler" }); + }); + + it("rejects malformed direct canvas action payloads", async () => { + const client = new CopilotClient(); + + await expect((client as any).handleCanvasActionInvokeRequest(undefined)).rejects.toThrow( + "Invalid canvas provider request payload" + ); + await expect( + (client as any).handleCanvasActionInvokeRequest({ + sessionId: "session-1", + extensionId: "project:counter", + canvasId: "counter", + instanceId: "counter-1", + }) + ).rejects.toThrow("Invalid canvas provider request payload"); + }); + + it("rejects direct canvas provider payloads without extension ids", async () => { + const open = vi.fn(() => ({ url: "https://example.test/counter" })); + const canvas = createCanvas({ + id: "counter", + displayName: "Counter", + description: "A counter canvas", + open, + }); + const session = new CopilotSession("session-1", {} as any); + session.registerCanvases([canvas]); + const client = new CopilotClient(); + (client as any).sessions.set(session.sessionId, session); + + await expect( + (client as any).handleCanvasProviderRequest("canvas.open", { + sessionId: session.sessionId, + canvasId: "counter", + instanceId: "counter-1", + }) + ).rejects.toThrow("Invalid canvas provider request payload"); + expect(open).not.toHaveBeenCalled(); + }); + + it("throws for unknown direct canvas dispatches", async () => { + const session = new CopilotSession("session-1", {} as any); + const client = new CopilotClient(); + (client as any).sessions.set(session.sessionId, session); + + await expect( + (client as any).handleCanvasProviderRequest("canvas.open", { + sessionId: session.sessionId, + extensionId: "project:missing", + canvasId: "missing", + instanceId: "missing-1", + }) + ).rejects.toThrow('No canvas registered with id "missing"'); + }); + it("forwards clientName in session.create request", async () => { const client = new CopilotClient(); await client.start(); diff --git a/nodejs/test/extension.test.ts b/nodejs/test/extension.test.ts index a522d23d5..1baa83a3a 100644 --- a/nodejs/test/extension.test.ts +++ b/nodejs/test/extension.test.ts @@ -1,7 +1,7 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { CopilotClient } from "../src/client.js"; import { approveAll } from "../src/index.js"; -import { joinSession } from "../src/extension.js"; +import { createCanvas, joinSession } from "../src/extension.js"; import { defaultJoinSessionPermissionHandler } from "../src/types.js"; describe("joinSession", () => { @@ -46,4 +46,15 @@ describe("joinSession", () => { expect(config.onPermissionRequest).toBe(approveAll); expect(config.suppressResumeEvent).toBe(false); }); + + it("exports the canvas helper from the extension surface", () => { + const canvas = createCanvas({ + id: "counter", + displayName: "Counter", + description: "A counter canvas", + open: () => ({ url: "https://example.test/counter" }), + }); + + expect(canvas.declaration.id).toBe("counter"); + }); }); diff --git a/python/copilot/__init__.py b/python/copilot/__init__.py index ee53264d2..874267c9f 100644 --- a/python/copilot/__init__.py +++ b/python/copilot/__init__.py @@ -4,6 +4,20 @@ JSON-RPC based SDK for programmatic control of GitHub Copilot CLI """ +from .canvas import ( + CanvasAction, + CanvasActionContext, + CanvasDeclaration, + CanvasError, + CanvasHandler, + CanvasHostCapabilities, + CanvasHostContext, + CanvasLifecycleContext, + CanvasOpenContext, + CanvasOpenResponse, + ExtensionInfo, + OpenCanvasInstance, +) from .client import ( ChildProcessRuntimeConnection, CloudSessionOptions, @@ -130,6 +144,16 @@ "AutoModeSwitchHandler", "AutoModeSwitchRequest", "AutoModeSwitchResponse", + "CanvasAction", + "CanvasActionContext", + "CanvasDeclaration", + "CanvasError", + "CanvasHandler", + "CanvasHostCapabilities", + "CanvasHostContext", + "CanvasLifecycleContext", + "CanvasOpenContext", + "CanvasOpenResponse", "ChildProcessRuntimeConnection", "CloudSessionOptions", "CloudSessionRepository", @@ -148,6 +172,7 @@ "ExitPlanModeHandler", "ExitPlanModeRequest", "ExitPlanModeResult", + "ExtensionInfo", "GetAuthStatusResponse", "GetStatusResponse", "InfiniteSessionConfig", @@ -167,6 +192,7 @@ "ModelSupportsOverride", "ModelVisionLimits", "ModelVisionLimitsOverride", + "OpenCanvasInstance", "PermissionHandler", "PermissionNoResult", "PermissionRequest", diff --git a/python/copilot/_jsonrpc.py b/python/copilot/_jsonrpc.py index ecae75b6b..df84a1c5d 100644 --- a/python/copilot/_jsonrpc.py +++ b/python/copilot/_jsonrpc.py @@ -400,9 +400,12 @@ async def _dispatch_request(self, message: dict, handler: RequestHandler): outcome = handler(params) if inspect.isawaitable(outcome): outcome = await outcome - if outcome is not None and not isinstance(outcome, dict): + if outcome is not None and not isinstance( + outcome, dict | list | str | int | float | bool + ): raise ValueError( - f"Request handler must return a dict, got {type(outcome).__name__}" + "Request handler must return a JSON-serializable value, " + f"got {type(outcome).__name__}" ) await self._send_response(message["id"], outcome) except JsonRpcError as exc: @@ -419,7 +422,7 @@ async def _dispatch_request(self, message: dict, handler: RequestHandler): ) await self._send_error_response(message["id"], -32603, str(exc), None) - async def _send_response(self, request_id: str, result: dict | None): + async def _send_response(self, request_id: str, result: Any): response = { "jsonrpc": "2.0", "id": request_id, diff --git a/python/copilot/canvas.py b/python/copilot/canvas.py new file mode 100644 index 000000000..58c5b297d --- /dev/null +++ b/python/copilot/canvas.py @@ -0,0 +1,312 @@ +""" +Canvas declarations, provider callbacks, and host-side canvas RPC types. + +The Copilot CLI runtime sends inbound JSON-RPC requests (``canvas.open``, +``canvas.close``, ``canvas.action.invoke``) to any session that declares +canvases. The SDK forwards every such request to a single user-supplied +:class:`CanvasHandler`; multiplexing across multiple declared canvases is +the implementor's responsibility (e.g. by switching on +:attr:`CanvasOpenContext.canvas_id`). +""" + +from __future__ import annotations + +from abc import ABC, abstractmethod +from dataclasses import dataclass, field +from typing import Any + +from .generated.rpc import CanvasAction, OpenCanvasInstance + +__all__ = [ + "CanvasAction", + "CanvasActionContext", + "CanvasDeclaration", + "CanvasError", + "CanvasHandler", + "CanvasHostCapabilities", + "CanvasHostContext", + "CanvasLifecycleContext", + "CanvasOpenContext", + "CanvasOpenResponse", + "ExtensionInfo", + "OpenCanvasInstance", +] + + +@dataclass +class ExtensionInfo: + """Stable extension identity for session participants that provide canvases. + + Serializes to ``{"source": ..., "name": ...}`` on the wire. + """ + + source: str + """Extension namespace/source, e.g. ``"github-app"``.""" + + name: str + """Stable provider name within the source namespace.""" + + def to_dict(self) -> dict[str, Any]: + return {"source": self.source, "name": self.name} + + +@dataclass +class CanvasDeclaration: + """Declarative metadata for a single canvas, sent on + ``session.create`` / ``session.resume``. + """ + + id: str + """Canvas identifier, unique within the declaring connection.""" + + display_name: str + """Human-readable name shown in host UI and canvas pickers.""" + + description: str + """Short, single-sentence description shown to the agent in canvas catalogs.""" + + input_schema: dict[str, Any] | None = None + """JSON Schema for the ``input`` payload accepted by ``canvas.open``.""" + + actions: list[CanvasAction] | None = None + """Agent-callable actions this canvas exposes.""" + + def to_dict(self) -> dict[str, Any]: + result: dict[str, Any] = { + "id": self.id, + "displayName": self.display_name, + "description": self.description, + } + if self.input_schema is not None: + result["inputSchema"] = self.input_schema + if self.actions is not None: + result["actions"] = [action.to_dict() for action in self.actions] + return result + + +@dataclass +class CanvasOpenResponse: + """Response returned from :meth:`CanvasHandler.on_open`.""" + + url: str | None = None + """URL the host should render. Optional for canvases with no visual surface.""" + + title: str | None = None + """Provider-supplied title shown in host chrome.""" + + status: str | None = None + """Provider-supplied status text shown in host chrome.""" + + def to_dict(self) -> dict[str, Any]: + result: dict[str, Any] = {} + if self.url is not None: + result["url"] = self.url + if self.title is not None: + result["title"] = self.title + if self.status is not None: + result["status"] = self.status + return result + + +@dataclass +class CanvasHostCapabilities: + """Host capability details passed to canvas provider callbacks.""" + + canvases: bool = False + """Whether the host supports canvas rendering.""" + + @staticmethod + def from_dict(obj: Any) -> CanvasHostCapabilities: + if not isinstance(obj, dict): + return CanvasHostCapabilities() + return CanvasHostCapabilities(canvases=bool(obj.get("canvases", False))) + + +@dataclass +class CanvasHostContext: + """Host capabilities passed to canvas provider callbacks.""" + + capabilities: CanvasHostCapabilities = field(default_factory=CanvasHostCapabilities) + """Host capability details.""" + + @staticmethod + def from_dict(obj: Any) -> CanvasHostContext: + if not isinstance(obj, dict): + return CanvasHostContext() + return CanvasHostContext( + capabilities=CanvasHostCapabilities.from_dict(obj.get("capabilities")) + ) + + +@dataclass +class CanvasOpenContext: + """Context handed to :meth:`CanvasHandler.on_open`.""" + + session_id: str + """Session that requested the canvas.""" + + extension_id: str + """Owning provider identifier.""" + + canvas_id: str + """Canvas id from the declaring :class:`CanvasDeclaration`.""" + + instance_id: str + """Stable instance id supplied by the runtime.""" + + input: Any + """Validated input payload.""" + + host: CanvasHostContext | None = None + """Host capabilities supplied by the runtime.""" + + +@dataclass +class CanvasActionContext: + """Context handed to :meth:`CanvasHandler.on_action`.""" + + session_id: str + """Session that invoked the action.""" + + extension_id: str + """Owning provider identifier.""" + + canvas_id: str + """Canvas id targeted by the action.""" + + instance_id: str + """Instance id targeted by the action.""" + + action_name: str + """Action name from :attr:`CanvasAction.name`.""" + + input: Any + """Validated input payload.""" + + host: CanvasHostContext | None = None + """Host capabilities supplied by the runtime.""" + + +@dataclass +class CanvasLifecycleContext: + """Context handed to a canvas's close lifecycle hook.""" + + session_id: str + """Session owning the canvas instance.""" + + extension_id: str + """Owning provider identifier.""" + + canvas_id: str + """Canvas id from the declaring :class:`CanvasDeclaration`.""" + + instance_id: str + """Instance id this lifecycle event applies to.""" + + host: CanvasHostContext | None = None + """Host capabilities supplied by the runtime.""" + + +class CanvasError(Exception): + """Structured error returned from canvas handlers. + + The serialized envelope is ``{"code": ..., "message": ...}``. The SDK + surfaces this through the JSON-RPC error's ``data`` field while sending + a standard ``-32603`` (internal error) wire code. + """ + + def __init__(self, code: str, message: str) -> None: + self.code = code + self.message = message + super().__init__(f"{code}: {message}") + + def to_envelope(self) -> dict[str, str]: + return {"code": self.code, "message": self.message} + + @classmethod + def no_handler(cls) -> CanvasError: + """Default error returned when a custom action has no handler.""" + return cls( + "canvas_action_no_handler", + "No handler implemented for this canvas action", + ) + + @classmethod + def handler_unset(cls) -> CanvasError: + """Error returned when a canvas RPC arrives but no handler is installed.""" + return cls( + "canvas_handler_unset", + "No CanvasHandler installed on this session; " + "install one via SessionConfig.canvas_handler before creating the session.", + ) + + +class CanvasHandler(ABC): + """Provider-side canvas lifecycle handler. + + A session installs a single :class:`CanvasHandler` via the + ``canvas_handler=`` argument to + :meth:`copilot.CopilotClient.create_session` / + :meth:`copilot.CopilotClient.resume_session`. The handler receives every + inbound ``canvas.open`` / ``canvas.close`` / ``canvas.action.invoke`` + JSON-RPC request the runtime issues for this session and decides — + typically by inspecting :attr:`CanvasOpenContext.canvas_id` — which + application-side canvas should handle the call. + + The SDK does not maintain a per-canvas registry; multiplexing across + declared canvases is the implementor's responsibility. + """ + + @abstractmethod + async def on_open(self, ctx: CanvasOpenContext) -> CanvasOpenResponse: + """Open a new canvas instance. + + May raise :class:`CanvasError` to surface a structured failure to + the host. + """ + + async def on_close(self, ctx: CanvasLifecycleContext) -> None: + """Canvas was closed by the user or agent. Default: no-op.""" + + async def on_action(self, ctx: CanvasActionContext) -> Any: + """Handle a non-lifecycle action declared by the canvas. + + Default raises :meth:`CanvasError.no_handler`. + """ + raise CanvasError.no_handler() + + +# ----- Internal helpers for inbound RPC dispatch (not part of the public API). ----- + + +def _open_context_from_params(params: dict[str, Any]) -> CanvasOpenContext: + return CanvasOpenContext( + session_id=params["sessionId"], + extension_id=params["extensionId"], + canvas_id=params["canvasId"], + instance_id=params["instanceId"], + input=params.get("input"), + host=CanvasHostContext.from_dict(params.get("host")) if params.get("host") else None, + ) + + +def _lifecycle_context_from_params(params: dict[str, Any]) -> CanvasLifecycleContext: + return CanvasLifecycleContext( + session_id=params["sessionId"], + extension_id=params["extensionId"], + canvas_id=params["canvasId"], + instance_id=params["instanceId"], + host=CanvasHostContext.from_dict(params.get("host")) if params.get("host") else None, + ) + + +def _action_context_from_params(params: dict[str, Any]) -> CanvasActionContext: + return CanvasActionContext( + session_id=params["sessionId"], + extension_id=params["extensionId"], + canvas_id=params["canvasId"], + instance_id=params["instanceId"], + action_name=params["actionName"], + input=params.get("input"), + host=CanvasHostContext.from_dict(params.get("host")) if params.get("host") else None, + ) diff --git a/python/copilot/client.py b/python/copilot/client.py index a52b8711f..5e795bdde 100644 --- a/python/copilot/client.py +++ b/python/copilot/client.py @@ -36,8 +36,18 @@ from ._jsonrpc import JsonRpcClient, JsonRpcError, ProcessExitedError from ._sdk_protocol_version import get_sdk_protocol_version from ._telemetry import get_trace_context +from .canvas import ( + CanvasDeclaration, + CanvasError, + CanvasHandler, + ExtensionInfo, + _action_context_from_params, + _lifecycle_context_from_params, + _open_context_from_params, +) from .generated.rpc import ( ClientSessionApiHandlers, + OpenCanvasInstance, RemoteSessionMode, ServerRpc, _ConnectRequest, @@ -1544,6 +1554,11 @@ async def create_session( github_token: str | None = None, remote_session: RemoteSessionMode | None = None, cloud: CloudSessionOptions | None = None, + canvases: list[CanvasDeclaration] | None = None, + request_canvas_renderer: bool | None = None, + request_extensions: bool | None = None, + extension_info: ExtensionInfo | None = None, + canvas_handler: CanvasHandler | None = None, ) -> CopilotSession: """ Create a new conversation session with the Copilot CLI. @@ -1782,6 +1797,15 @@ async def create_session( ] payload["infiniteSessions"] = wire_config + if canvases: + payload["canvases"] = [c.to_dict() for c in canvases] + if request_canvas_renderer is not None: + payload["requestCanvasRenderer"] = request_canvas_renderer + if request_extensions is not None: + payload["requestExtensions"] = request_extensions + if extension_info is not None: + payload["extensionInfo"] = extension_info.to_dict() + if not self._client: raise RuntimeError("Client not connected") @@ -1825,6 +1849,8 @@ async def create_session( session._register_exit_plan_mode_handler(on_exit_plan_mode_request) if on_auto_mode_switch_request: session._register_auto_mode_switch_handler(on_auto_mode_switch_request) + if canvas_handler is not None: + session._register_canvas_handler(canvas_handler) if hooks: session._register_hooks(hooks) if transform_callbacks: @@ -1919,6 +1945,12 @@ async def resume_session( github_token: str | None = None, remote_session: RemoteSessionMode | None = None, continue_pending_work: bool | None = None, + canvases: list[CanvasDeclaration] | None = None, + request_canvas_renderer: bool | None = None, + request_extensions: bool | None = None, + extension_info: ExtensionInfo | None = None, + canvas_handler: CanvasHandler | None = None, + open_canvases: list[OpenCanvasInstance] | None = None, ) -> CopilotSession: """ Resume an existing conversation session by its ID. @@ -2135,6 +2167,17 @@ async def resume_session( ] payload["infiniteSessions"] = wire_config + if canvases: + payload["canvases"] = [c.to_dict() for c in canvases] + if open_canvases: + payload["openCanvases"] = [inst.to_dict() for inst in open_canvases] + if request_canvas_renderer is not None: + payload["requestCanvasRenderer"] = request_canvas_renderer + if request_extensions is not None: + payload["requestExtensions"] = request_extensions + if extension_info is not None: + payload["extensionInfo"] = extension_info.to_dict() + if not self._client: raise RuntimeError("Client not connected") @@ -2175,6 +2218,8 @@ async def resume_session( session._register_exit_plan_mode_handler(on_exit_plan_mode_request) if on_auto_mode_switch_request: session._register_auto_mode_switch_handler(on_auto_mode_switch_request) + if canvas_handler is not None: + session._register_canvas_handler(canvas_handler) if hooks: session._register_hooks(hooks) if transform_callbacks: @@ -2207,6 +2252,11 @@ async def resume_session( session._workspace_path = response.get("workspacePath") capabilities = response.get("capabilities") session._set_capabilities(capabilities) + open_canvases_raw = response.get("openCanvases") + if isinstance(open_canvases_raw, list): + session._set_open_canvases( + [OpenCanvasInstance.from_dict(inst) for inst in open_canvases_raw] + ) except BaseException as exc: with self._sessions_lock: self._sessions.pop(session_id, None) @@ -2988,6 +3038,18 @@ def handle_notification(method: str, params: dict): self._client.set_request_handler( "systemMessage.transform", self._handle_system_message_transform ) + self._client.set_request_handler( + "canvas.open", + self._canvas_request_handler(self._handle_canvas_open), + ) + self._client.set_request_handler( + "canvas.close", + self._canvas_request_handler(self._handle_canvas_close), + ) + self._client.set_request_handler( + "canvas.action.invoke", + self._canvas_request_handler(self._handle_canvas_action_invoke), + ) register_client_session_api_handlers(self._client, self._get_client_session_handlers) # Start listening for messages @@ -3107,6 +3169,18 @@ def handle_notification(method: str, params: dict): self._client.set_request_handler( "systemMessage.transform", self._handle_system_message_transform ) + self._client.set_request_handler( + "canvas.open", + self._canvas_request_handler(self._handle_canvas_open), + ) + self._client.set_request_handler( + "canvas.close", + self._canvas_request_handler(self._handle_canvas_close), + ) + self._client.set_request_handler( + "canvas.action.invoke", + self._canvas_request_handler(self._handle_canvas_action_invoke), + ) register_client_session_api_handlers(self._client, self._get_client_session_handlers) # Start listening for messages @@ -3236,3 +3310,113 @@ async def _handle_system_message_transform(self, params: dict) -> dict: raise ValueError(f"unknown session {session_id}") return await session._handle_system_message_transform(sections) + + def _resolve_canvas_handler(self, session_id: str) -> CanvasHandler: + """Look up the canvas handler for ``session_id`` or raise CanvasError.""" + with self._sessions_lock: + session = self._sessions.get(session_id) + if session is None: + raise CanvasError( + "canvas_handler_unset", + f"No session registered for {session_id}; cannot dispatch canvas RPC.", + ) + handler = session._get_canvas_handler() + if handler is None: + raise CanvasError.handler_unset() + return handler + + async def _handle_canvas_open(self, params: dict) -> dict: + """Handle an inbound ``canvas.open`` request from the CLI runtime.""" + try: + session_id = params["sessionId"] + except KeyError as exc: + raise CanvasError( + "canvas_invalid_request", "canvas.open params missing sessionId" + ) from exc + handler = self._resolve_canvas_handler(session_id) + try: + ctx = _open_context_from_params(params) + except KeyError as exc: + raise CanvasError( + "canvas_invalid_request", f"canvas.open params missing field: {exc.args[0]}" + ) from exc + try: + response = await handler.on_open(ctx) + except CanvasError: + raise + except Exception as exc: + raise CanvasError( + "canvas_open_handler_failed", + f"canvas.open handler raised: {exc}", + ) from exc + return response.to_dict() + + async def _handle_canvas_close(self, params: dict) -> None: + """Handle an inbound ``canvas.close`` request from the CLI runtime.""" + try: + session_id = params["sessionId"] + except KeyError as exc: + raise CanvasError( + "canvas_invalid_request", "canvas.close params missing sessionId" + ) from exc + handler = self._resolve_canvas_handler(session_id) + try: + ctx = _lifecycle_context_from_params(params) + except KeyError as exc: + raise CanvasError( + "canvas_invalid_request", f"canvas.close params missing field: {exc.args[0]}" + ) from exc + try: + await handler.on_close(ctx) + except CanvasError: + raise + except Exception as exc: + raise CanvasError( + "canvas_close_handler_failed", + f"canvas.close handler raised: {exc}", + ) from exc + return None + + async def _handle_canvas_action_invoke(self, params: dict) -> Any: + """Handle an inbound ``canvas.action.invoke`` request from the CLI runtime.""" + try: + session_id = params["sessionId"] + except KeyError as exc: + raise CanvasError( + "canvas_invalid_request", + "canvas.action.invoke params missing sessionId", + ) from exc + handler = self._resolve_canvas_handler(session_id) + try: + ctx = _action_context_from_params(params) + except KeyError as exc: + raise CanvasError( + "canvas_invalid_request", + f"canvas.action.invoke params missing field: {exc.args[0]}", + ) from exc + try: + return await handler.on_action(ctx) + except CanvasError: + raise + except Exception as exc: + raise CanvasError( + "canvas_action_handler_failed", + f"canvas.action.invoke handler raised: {exc}", + ) from exc + + @staticmethod + def _canvas_request_handler( + coro: Callable[[dict], Awaitable[Any]], + ) -> Callable[[dict], Awaitable[Any]]: + """Wrap a canvas RPC coroutine so ``CanvasError`` becomes a JSON-RPC error + with the structured envelope in the error's ``data`` field, matching the + Rust SDK wire shape. + """ + + async def wrapper(params: dict) -> Any: + try: + return await coro(params) + except CanvasError as err: + raise JsonRpcError(-32603, err.message, data=err.to_envelope()) from err + + return wrapper diff --git a/python/copilot/session.py b/python/copilot/session.py index c775ef58e..90134a151 100644 --- a/python/copilot/session.py +++ b/python/copilot/session.py @@ -25,6 +25,7 @@ from ._diagnostics import log_timing from ._jsonrpc import JsonRpcError, ProcessExitedError from ._telemetry import get_trace_context, trace_context +from .canvas import CanvasHandler, OpenCanvasInstance from .generated.rpc import ( ClientSessionApiHandlers, CommandsHandlePendingCommandRequest, @@ -1023,6 +1024,10 @@ def __init__( self._elicitation_handler_lock = threading.Lock() self._capabilities: SessionCapabilities = {} self._client_session_apis = ClientSessionApiHandlers() + self._canvas_handler: CanvasHandler | None = None + self._canvas_handler_lock = threading.Lock() + self._open_canvases: list[OpenCanvasInstance] = [] + self._open_canvases_lock = threading.Lock() self._rpc: SessionRpc | None = None self._destroyed = False @@ -1739,6 +1744,29 @@ def _register_auto_mode_switch_handler(self, handler: AutoModeSwitchHandler | No with self._auto_mode_switch_handler_lock: self._auto_mode_switch_handler = handler + def _register_canvas_handler(self, handler: CanvasHandler | None) -> None: + """Register the canvas handler for this session.""" + with self._canvas_handler_lock: + self._canvas_handler = handler + + def _get_canvas_handler(self) -> CanvasHandler | None: + with self._canvas_handler_lock: + return self._canvas_handler + + def _set_open_canvases(self, instances: list[OpenCanvasInstance]) -> None: + with self._open_canvases_lock: + self._open_canvases = list(instances) + + @property + def open_canvases(self) -> list[OpenCanvasInstance]: + """Open canvas instances reported by the most recent ``session.resume``. + + Returns an empty list for sessions created via ``session.create`` or + when the server did not include any open canvases on resume. + """ + with self._open_canvases_lock: + return list(self._open_canvases) + def _set_capabilities(self, capabilities: SessionCapabilities | None) -> None: """Set the host capabilities for this session. diff --git a/python/test_canvas.py b/python/test_canvas.py new file mode 100644 index 000000000..4c9ab223f --- /dev/null +++ b/python/test_canvas.py @@ -0,0 +1,249 @@ +"""Unit tests for the canvas SDK surface.""" + +from __future__ import annotations + +import threading +from typing import Any + +import pytest + +from copilot._jsonrpc import JsonRpcError +from copilot.canvas import ( + CanvasAction, + CanvasActionContext, + CanvasDeclaration, + CanvasError, + CanvasHandler, + CanvasOpenContext, + CanvasOpenResponse, + ExtensionInfo, + OpenCanvasInstance, + _action_context_from_params, + _lifecycle_context_from_params, + _open_context_from_params, +) +from copilot.client import CopilotClient + + +def test_canvas_declaration_serializes_camelcase_and_drops_optional(): + decl = CanvasDeclaration( + id="my-canvas", + display_name="My Canvas", + description="Does the thing", + ) + assert decl.to_dict() == { + "id": "my-canvas", + "displayName": "My Canvas", + "description": "Does the thing", + } + + +def test_canvas_declaration_serializes_input_schema_and_actions(): + action = CanvasAction( + name="refresh", + description="Refresh the canvas", + ) + decl = CanvasDeclaration( + id="c", + display_name="C", + description="D", + input_schema={"type": "object"}, + actions=[action], + ) + payload = decl.to_dict() + assert payload["inputSchema"] == {"type": "object"} + assert payload["actions"] == [action.to_dict()] + + +def test_extension_info_serializes(): + info = ExtensionInfo(source="github-app", name="my-ext") + assert info.to_dict() == {"source": "github-app", "name": "my-ext"} + + +def test_canvas_open_response_drops_none_fields(): + assert CanvasOpenResponse().to_dict() == {} + assert CanvasOpenResponse(url="https://x", status="ok").to_dict() == { + "url": "https://x", + "status": "ok", + } + + +def test_canvas_error_envelope_and_factories(): + err = CanvasError("oops", "something broke") + assert err.code == "oops" + assert err.message == "something broke" + assert err.to_envelope() == {"code": "oops", "message": "something broke"} + + no_handler = CanvasError.no_handler() + assert no_handler.code == "canvas_action_no_handler" + + unset = CanvasError.handler_unset() + assert unset.code == "canvas_handler_unset" + + +async def test_default_canvas_handler_on_action_raises_no_handler(): + class StubHandler(CanvasHandler): + async def on_open(self, ctx: CanvasOpenContext) -> CanvasOpenResponse: + return CanvasOpenResponse() + + handler = StubHandler() + ctx = CanvasActionContext( + session_id="s", + extension_id="e", + canvas_id="c", + instance_id="i", + action_name="any", + input=None, + ) + with pytest.raises(CanvasError) as excinfo: + await handler.on_action(ctx) + assert excinfo.value.code == "canvas_action_no_handler" + + +def test_context_helpers_parse_params(): + base = { + "sessionId": "s", + "extensionId": "e", + "canvasId": "c", + "instanceId": "i", + "input": {"foo": 1}, + "host": {"capabilities": {"canvases": True}}, + } + open_ctx = _open_context_from_params(base) + assert open_ctx.session_id == "s" + assert open_ctx.canvas_id == "c" + assert open_ctx.input == {"foo": 1} + assert open_ctx.host is not None and open_ctx.host.capabilities.canvases is True + + close_ctx = _lifecycle_context_from_params(base) + assert close_ctx.canvas_id == "c" + assert close_ctx.instance_id == "i" + + action_ctx = _action_context_from_params({**base, "actionName": "refresh"}) + assert action_ctx.action_name == "refresh" + + +class _StubSession: + """Minimal CopilotSession stand-in for the inbound dispatch tests.""" + + def __init__(self, handler: CanvasHandler | None) -> None: + self._handler = handler + self._open_canvases: list[OpenCanvasInstance] = [] + self._open_canvases_lock = threading.Lock() + + def _get_canvas_handler(self) -> CanvasHandler | None: + return self._handler + + def _set_open_canvases(self, instances: list[OpenCanvasInstance]) -> None: + with self._open_canvases_lock: + self._open_canvases = list(instances) + + @property + def open_canvases(self) -> list[OpenCanvasInstance]: + with self._open_canvases_lock: + return list(self._open_canvases) + + +def _make_client_with_session(session_id: str, session: Any) -> CopilotClient: + """Construct a CopilotClient skeleton sufficient for testing the inbound + canvas dispatch helpers without actually launching the CLI.""" + client = CopilotClient.__new__(CopilotClient) + client._sessions = {session_id: session} + client._sessions_lock = threading.Lock() + return client + + +async def test_handle_canvas_open_dispatches_to_handler(): + class Handler(CanvasHandler): + def __init__(self) -> None: + self.received: CanvasOpenContext | None = None + + async def on_open(self, ctx: CanvasOpenContext) -> CanvasOpenResponse: + self.received = ctx + return CanvasOpenResponse(url="https://canvas.example", title="Hi") + + async def on_action(self, ctx: CanvasActionContext) -> Any: + return {"echo": ctx.input} + + handler = Handler() + session = _StubSession(handler) + client = _make_client_with_session("sess-1", session) + + result = await client._handle_canvas_open( + { + "sessionId": "sess-1", + "extensionId": "ext", + "canvasId": "c", + "instanceId": "i", + "input": {"q": 1}, + } + ) + assert result == {"url": "https://canvas.example", "title": "Hi"} + assert handler.received is not None + assert handler.received.canvas_id == "c" + + +async def test_handle_canvas_open_raises_when_handler_unset(): + session = _StubSession(handler=None) + client = _make_client_with_session("sess-1", session) + + with pytest.raises(CanvasError) as excinfo: + await client._handle_canvas_open( + { + "sessionId": "sess-1", + "extensionId": "ext", + "canvasId": "c", + "instanceId": "i", + } + ) + assert excinfo.value.code == "canvas_handler_unset" + + +async def test_handle_canvas_action_returns_arbitrary_value(): + class Handler(CanvasHandler): + async def on_open(self, ctx: CanvasOpenContext) -> CanvasOpenResponse: + return CanvasOpenResponse() + + async def on_action(self, ctx: CanvasActionContext) -> Any: + return [1, 2, 3] + + client = _make_client_with_session("sess-1", _StubSession(Handler())) + result = await client._handle_canvas_action_invoke( + { + "sessionId": "sess-1", + "extensionId": "ext", + "canvasId": "c", + "instanceId": "i", + "actionName": "do", + } + ) + assert result == [1, 2, 3] + + +async def test_canvas_request_handler_translates_canvas_error(): + err = CanvasError("bad", "fail") + + async def coro(params: dict) -> Any: + raise err + + wrapped = CopilotClient._canvas_request_handler(coro) + with pytest.raises(JsonRpcError) as excinfo: + await wrapped({}) + assert excinfo.value.code == -32603 + assert excinfo.value.message == "fail" + assert excinfo.value.data == {"code": "bad", "message": "fail"} + + +def test_set_open_canvases_round_trip(): + from copilot.generated.rpc import CanvasInstanceAvailability + + inst = OpenCanvasInstance( + availability=CanvasInstanceAvailability.READY, + canvas_id="c", + extension_id="e", + instance_id="i", + reopen=False, + ) + session = _StubSession(handler=None) + session._set_open_canvases([inst]) + assert session.open_canvases == [inst] diff --git a/rust/README.md b/rust/README.md index 4ed046230..f4d80fefd 100644 --- a/rust/README.md +++ b/rust/README.md @@ -698,6 +698,15 @@ let session = client See [`examples/session_fs.rs`](examples/session_fs.rs) for a complete in-memory provider implementation. +- **Canvas action dispatch is a single trait method, not per-action closures.** + The Node SDK binds an optional `handler` closure on each entry of a canvas's + `actions[]`. The Rust SDK exposes + [`CanvasHandler::on_action`](crate::canvas::CanvasHandler::on_action) and expects the implementor to match on + `ctx.action_name`. Same reasoning as `SessionFsProvider`: per-callback + `Box` fields fight `Send + Sync + 'static` and skip exhaustiveness + checks, and the SDK prefers trait + default-impl methods for handler-shaped + extension points. + ### Rust-only API A handful of conveniences exist only on the Rust SDK as of 0.1.0. These diff --git a/rust/src/canvas.rs b/rust/src/canvas.rs new file mode 100644 index 000000000..ba13742ff --- /dev/null +++ b/rust/src/canvas.rs @@ -0,0 +1,380 @@ +//! Canvas declarations, provider callbacks, and host-side canvas RPC types. + +use async_trait::async_trait; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use thiserror::Error; + +use crate::generated::api_types::CanvasAction; +use crate::types::SessionId; + +/// JSON Schema object used for canvas inputs and canvas-scoped tools. +pub type CanvasJsonSchema = serde_json::Map; + +/// Declarative metadata for a single canvas, sent over the wire on +/// `session.create` / `session.resume`. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +#[non_exhaustive] +pub struct CanvasDeclaration { + /// Canvas identifier, unique within the declaring connection. + pub id: String, + /// Human-readable name shown in host UI and canvas pickers. + pub display_name: String, + /// Short, single-sentence description shown to the agent in canvas catalogs. + pub description: String, + /// JSON Schema for the `input` payload accepted by `canvas.open`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub input_schema: Option, + /// Agent-callable actions this canvas exposes. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub actions: Option>, +} + +impl CanvasDeclaration { + /// Construct a canvas declaration with the required fields set. + pub fn new( + id: impl Into, + display_name: impl Into, + description: impl Into, + ) -> Self { + Self { + id: id.into(), + display_name: display_name.into(), + description: description.into(), + input_schema: None, + actions: None, + } + } + + /// Set the description surfaced in discovery and agent context. + pub fn with_description(mut self, description: impl Into) -> Self { + self.description = description.into(); + self + } +} + +/// Response returned from [`CanvasHandler::on_open`]. +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct CanvasOpenResponse { + /// URL the host should render. Optional for canvases with no visual surface. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub url: Option, + /// Provider-supplied title shown in host chrome. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub title: Option, + /// Provider-supplied status text shown in host chrome. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub status: Option, +} + +/// Host capabilities passed to canvas provider callbacks. +#[derive(Debug, Clone, Default, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CanvasHostContext { + /// Host capability details. + #[serde(default)] + pub capabilities: CanvasHostCapabilities, +} + +/// Host capability details passed to canvas provider callbacks. +#[derive(Debug, Clone, Default, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CanvasHostCapabilities { + /// Whether the host supports canvas rendering. + #[serde(default)] + pub canvases: bool, +} + +/// Context handed to [`CanvasHandler::on_open`]. +#[derive(Debug, Clone)] +pub struct CanvasOpenContext { + /// Session that requested the canvas. + pub session_id: SessionId, + /// Owning provider identifier. + pub extension_id: String, + /// Canvas id from the declaring [`CanvasDeclaration`]. + pub canvas_id: String, + /// Stable instance id supplied by the runtime. + pub instance_id: String, + /// Validated input payload. + pub input: Value, + /// Host capabilities supplied by the runtime. + pub host: Option, +} + +/// Context handed to [`CanvasHandler::on_action`]. +#[derive(Debug, Clone)] +pub struct CanvasActionContext { + /// Session that invoked the action. + pub session_id: SessionId, + /// Owning provider identifier. + pub extension_id: String, + /// Canvas id targeted by the action. + pub canvas_id: String, + /// Instance id targeted by the action. + pub instance_id: String, + /// Action name from [`crate::generated::api_types::CanvasAction::name`]. + pub action_name: String, + /// Validated input payload. + pub input: Value, + /// Host capabilities supplied by the runtime. + pub host: Option, +} + +/// Context handed to a canvas's close lifecycle hook. +#[derive(Debug, Clone)] +pub struct CanvasLifecycleContext { + /// Session owning the canvas instance. + pub session_id: SessionId, + /// Owning provider identifier. + pub extension_id: String, + /// Canvas id from the declaring [`CanvasDeclaration`]. + pub canvas_id: String, + /// Instance id this lifecycle event applies to. + pub instance_id: String, + /// Host capabilities supplied by the runtime. + pub host: Option, +} + +/// Structured error returned from canvas handlers. +#[derive(Debug, Clone, Error, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +#[error("{code}: {message}")] +pub struct CanvasError { + /// Machine-readable error code. + pub code: String, + /// Human-readable message. + pub message: String, +} + +impl CanvasError { + /// Construct a new error envelope with the given code and message. + pub fn new(code: impl Into, message: impl Into) -> Self { + Self { + code: code.into(), + message: message.into(), + } + } + + /// Default error returned when a custom action has no handler. + pub fn no_handler() -> Self { + Self::new( + "canvas_action_no_handler", + "No handler implemented for this canvas action", + ) + } +} + +/// Result alias for canvas handler methods. +pub type CanvasResult = Result; + +/// Provider-side canvas lifecycle handler. +/// +/// A session installs a single [`CanvasHandler`] (via +/// [`SessionConfig::with_canvas_handler`](crate::types::SessionConfig::with_canvas_handler)). +/// The handler receives every inbound `canvas.open` / `canvas.close` / +/// `canvas.action.invoke` JSON-RPC request the runtime issues for this +/// session and decides — typically by inspecting [`CanvasOpenContext::canvas_id`] +/// — which application-side canvas should handle the call. +/// +/// The SDK does not maintain a per-canvas registry; multiplexing across +/// declared canvases is the implementor's responsibility. +#[async_trait] +pub trait CanvasHandler: Send + Sync { + /// Open a new canvas instance. + async fn on_open(&self, ctx: CanvasOpenContext) -> CanvasResult; + + /// Handle a non-lifecycle action declared by the canvas. + async fn on_action(&self, _ctx: CanvasActionContext) -> CanvasResult { + Err(CanvasError::no_handler()) + } + + /// Canvas was closed by the user or agent. + async fn on_close(&self, _ctx: CanvasLifecycleContext) -> CanvasResult<()> { + Ok(()) + } +} + +/// Common fields sent by direct `canvas.*` provider callbacks. +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct CanvasProviderRequestParams { + pub session_id: SessionId, + pub extension_id: String, + pub canvas_id: String, + pub instance_id: String, + #[serde(default)] + pub input: Value, + #[serde(default)] + pub host: Option, +} + +/// Wire-level params for `canvas.action.invoke`. +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct CanvasInvokeParams { + pub session_id: SessionId, + pub extension_id: String, + pub canvas_id: String, + pub instance_id: String, + pub action_name: String, + #[serde(default)] + pub input: Value, + #[serde(default)] + pub host: Option, +} + +impl CanvasProviderRequestParams { + pub(crate) fn into_open_context(self) -> CanvasOpenContext { + CanvasOpenContext { + session_id: self.session_id, + extension_id: self.extension_id, + canvas_id: self.canvas_id, + instance_id: self.instance_id, + input: self.input, + host: self.host, + } + } + + pub(crate) fn into_lifecycle_context(self) -> CanvasLifecycleContext { + CanvasLifecycleContext { + session_id: self.session_id, + extension_id: self.extension_id, + canvas_id: self.canvas_id, + instance_id: self.instance_id, + host: self.host, + } + } +} + +impl CanvasInvokeParams { + pub(crate) fn into_action_context(self) -> CanvasActionContext { + CanvasActionContext { + session_id: self.session_id, + extension_id: self.extension_id, + canvas_id: self.canvas_id, + instance_id: self.instance_id, + action_name: self.action_name, + input: self.input, + host: self.host, + } + } +} + +#[cfg(test)] +mod tests { + use serde_json::json; + + use super::*; + + struct EchoHandler; + + #[async_trait] + impl CanvasHandler for EchoHandler { + async fn on_open(&self, ctx: CanvasOpenContext) -> CanvasResult { + Ok(CanvasOpenResponse { + url: Some(format!("https://example.test/{}", ctx.canvas_id)), + title: Some("Echo".to_string()), + status: Some("ready".to_string()), + }) + } + + async fn on_action(&self, ctx: CanvasActionContext) -> CanvasResult { + Ok(json!({ "echoed": ctx.action_name, "input": ctx.input })) + } + } + + #[test] + fn declaration_serializes_camel_case_and_skips_none() { + let decl = CanvasDeclaration { + id: "counter".to_string(), + display_name: "Counter".to_string(), + description: "Count things".to_string(), + input_schema: None, + actions: Some(vec![CanvasAction { + name: "increment".to_string(), + description: Some("bump".to_string()), + input_schema: None, + }]), + }; + + let value = serde_json::to_value(&decl).unwrap(); + + assert_eq!(value["id"], "counter"); + assert_eq!(value["displayName"], "Counter"); + assert_eq!(value["description"], "Count things"); + assert_eq!(value["actions"][0]["name"], "increment"); + } + + #[tokio::test] + async fn handler_on_open_returns_response() { + let handler = EchoHandler; + let response = handler + .on_open(CanvasOpenContext { + session_id: SessionId::from("s1"), + extension_id: "project:echo".to_string(), + canvas_id: "echo".to_string(), + instance_id: "echo-1".to_string(), + input: json!({ "x": 1 }), + host: None, + }) + .await + .unwrap(); + + assert_eq!(response.url.as_deref(), Some("https://example.test/echo")); + assert_eq!(response.title.as_deref(), Some("Echo")); + assert_eq!(response.status.as_deref(), Some("ready")); + } + + #[tokio::test] + async fn handler_on_action_returns_value() { + let handler = EchoHandler; + let result = handler + .on_action(CanvasActionContext { + session_id: SessionId::from("s1"), + extension_id: "project:echo".to_string(), + canvas_id: "echo".to_string(), + instance_id: "inst-1".to_string(), + action_name: "shout".to_string(), + input: json!("hi"), + host: None, + }) + .await + .unwrap(); + + assert_eq!(result["echoed"], "shout"); + assert_eq!(result["input"], "hi"); + } + + #[tokio::test] + async fn default_on_action_returns_no_handler_error() { + struct OpenOnly; + #[async_trait] + impl CanvasHandler for OpenOnly { + async fn on_open(&self, _ctx: CanvasOpenContext) -> CanvasResult { + Ok(CanvasOpenResponse { + url: None, + title: None, + status: None, + }) + } + } + + let err = OpenOnly + .on_action(CanvasActionContext { + session_id: SessionId::from("s1"), + extension_id: "project:open-only".to_string(), + canvas_id: "x".to_string(), + instance_id: "x-1".to_string(), + action_name: "anything".to_string(), + input: Value::Null, + host: None, + }) + .await + .unwrap_err(); + + assert_eq!(err.code, "canvas_action_no_handler"); + } +} diff --git a/rust/src/lib.rs b/rust/src/lib.rs index c7294c3c3..787697e2e 100644 --- a/rust/src/lib.rs +++ b/rust/src/lib.rs @@ -3,6 +3,8 @@ #![deny(rustdoc::broken_intra_doc_links)] #![cfg_attr(test, allow(clippy::unwrap_used))] +/// Canvas declarations, provider callbacks, and host-side canvas RPC types. +pub mod canvas; /// Bundled CLI binary extraction and caching. pub(crate) mod embeddedcli; /// Event handler traits for session lifecycle. diff --git a/rust/src/session.rs b/rust/src/session.rs index f8a35ce0c..f216b866b 100644 --- a/rust/src/session.rs +++ b/rust/src/session.rs @@ -4,13 +4,15 @@ use std::sync::Arc; use std::time::{Duration, Instant}; use parking_lot::Mutex as ParkingLotMutex; +use serde::de::DeserializeOwned; use serde_json::Value; use tokio::sync::oneshot; use tokio::task::JoinHandle; use tokio_util::sync::CancellationToken; use tracing::{Instrument, warn}; -use crate::generated::api_types::{LogRequest, ModelSwitchToRequest}; +use crate::canvas::{CanvasHandler, CanvasInvokeParams, CanvasProviderRequestParams}; +use crate::generated::api_types::{LogRequest, ModelSwitchToRequest, OpenCanvasInstance}; use crate::generated::session_events::{ CommandExecuteData, ElicitationRequestedData, ExternalToolRequestedData, SessionErrorData, SessionEventType, @@ -26,9 +28,10 @@ use crate::transforms::SystemMessageTransform; use crate::types::{ CommandContext, CommandDefinition, CommandHandler, CreateSessionResult, ElicitationRequest, ElicitationResult, ExitPlanModeData, GetMessagesResponse, MessageOptions, - PermissionRequestData, RequestId, ResumeSessionConfig, SectionOverride, SessionCapabilities, - SessionConfig, SessionEvent, SessionId, SetModelOptions, SystemMessageConfig, ToolInvocation, - ToolResult, ToolResultExpanded, TraceContext, UiInputOptions, ensure_attachment_display_names, + PermissionRequestData, RequestId, ResumeSessionConfig, ResumeSessionResult, SectionOverride, + SessionCapabilities, SessionConfig, SessionEvent, SessionId, SetModelOptions, + SystemMessageConfig, ToolInvocation, ToolResult, ToolResultExpanded, TraceContext, + UiInputOptions, ensure_attachment_display_names, }; use crate::{Client, Error, JsonRpcResponse, SessionError, SessionEventNotification, error_codes}; @@ -162,6 +165,8 @@ pub struct Session { idle_waiter: Arc>>, /// Capabilities negotiated with the CLI, updated on `capabilities.changed` events. capabilities: Arc>, + /// Canvas instances currently known to be open for this session. + open_canvases: Arc>>, /// Broadcast channel for runtime event subscribers — see [`Session::subscribe`]. event_tx: tokio::sync::broadcast::Sender, } @@ -195,6 +200,12 @@ impl Session { self.capabilities.read().clone() } + /// Open canvas instances reported by the most recent `session.resume` + /// response or surfaced by inbound `canvas.opened` events. + pub fn open_canvases(&self) -> Vec { + self.open_canvases.read().clone() + } + /// Returns a [`CancellationToken`] that fires when this session shuts /// down (via [`Session::stop_event_loop`], [`Session::destroy`], or /// [`Drop`]). @@ -808,6 +819,7 @@ impl Client { let commands_count = runtime.commands.as_ref().map_or(0, Vec::len); let has_hooks = hooks.is_some(); let command_handlers = build_command_handler_map(runtime.commands.as_deref()); + let canvas_handler = runtime.canvas_handler.take(); let session_fs_provider = runtime.session_fs_provider.take(); if self.inner.session_fs_configured && session_fs_provider.is_none() { return Err(Error::Session(SessionError::SessionFsProviderRequired)); @@ -840,6 +852,7 @@ impl Client { hooks, transforms, command_handlers, + canvas_handler, session_fs_provider, channels, idle_waiter.clone(), @@ -902,6 +915,7 @@ impl Client { shutdown, idle_waiter, capabilities, + open_canvases: Arc::new(parking_lot::RwLock::new(Vec::new())), event_tx, }) } @@ -945,6 +959,7 @@ impl Client { let commands_count = runtime.commands.as_ref().map_or(0, Vec::len); let has_hooks = hooks.is_some(); let command_handlers = build_command_handler_map(runtime.commands.as_deref()); + let canvas_handler = runtime.canvas_handler.take(); let session_fs_provider = runtime.session_fs_provider.take(); if self.inner.session_fs_configured && session_fs_provider.is_none() { return Err(Error::Session(SessionError::SessionFsProviderRequired)); @@ -977,6 +992,7 @@ impl Client { hooks, transforms, command_handlers, + canvas_handler, session_fs_provider, channels, idle_waiter.clone(), @@ -1009,12 +1025,17 @@ impl Client { "Client::resume_session session resume request completed successfully" ); - // The CLI may reassign the session ID on resume. - let cli_session_id: SessionId = result - .get("sessionId") - .and_then(|v| v.as_str()) - .unwrap_or(&session_id) - .into(); + let resume_result: ResumeSessionResult = match serde_json::from_value(result) { + Ok(result) => result, + Err(error) => { + registration.cleanup(event_loop).await; + return Err(error.into()); + } + }; + let cli_session_id = resume_result + .session_id + .clone() + .unwrap_or_else(|| session_id.clone()); if cli_session_id != session_id { registration.cleanup(event_loop).await; return Err(Error::Session(SessionError::SessionIdMismatch { @@ -1023,19 +1044,6 @@ impl Client { })); } - let resume_capabilities: Option = result - .get("capabilities") - .and_then(|v| { - serde_json::from_value(v.clone()) - .map_err(|e| warn!(error = %e, "failed to deserialize capabilities from resume response")) - .ok() - }); - let remote_url = result - .get("remoteUrl") - .or_else(|| result.get("remote_url")) - .and_then(|value| value.as_str()) - .map(ToString::to_string); - // Reload skills after resume (best-effort). let skills_reload_start = Instant::now(); if let Err(e) = self @@ -1059,7 +1067,10 @@ impl Client { ); } - *capabilities.write() = resume_capabilities.unwrap_or_default(); + *capabilities.write() = resume_result.capabilities.unwrap_or_default(); + let open_canvases = Arc::new(parking_lot::RwLock::new( + resume_result.open_canvases.unwrap_or_default(), + )); tracing::debug!( elapsed_ms = total_start.elapsed().as_millis(), @@ -1070,13 +1081,14 @@ impl Client { Ok(Session { id: session_id, cwd: self.cwd().clone(), - workspace_path: None, - remote_url, + workspace_path: resume_result.workspace_path, + remote_url: resume_result.remote_url, client: self.clone(), event_loop: ParkingLotMutex::new(Some(event_loop)), shutdown, idle_waiter, capabilities, + open_canvases, event_tx, }) } @@ -1104,6 +1116,7 @@ fn spawn_event_loop( hooks: Option>, transforms: Option>, command_handlers: Arc, + canvas_handler: Option>, session_fs_provider: Option>, channels: crate::router::SessionChannels, idle_waiter: Arc>>, @@ -1138,9 +1151,15 @@ fn spawn_event_loop( ).await; } Some(request) = requests.recv() => { - handle_request( - &session_id, &client, &handlers, hooks.as_deref(), transforms.as_deref(), session_fs_provider.as_ref(), request, - ).await; + let ctx = RequestDispatchContext { + client: &client, + handlers: &handlers, + hooks: hooks.as_deref(), + transforms: transforms.as_deref(), + canvas_handler: canvas_handler.as_ref(), + session_fs_provider: session_fs_provider.as_ref(), + }; + handle_request(&session_id, ctx, request).await; } else => break, } @@ -1688,17 +1707,28 @@ async fn handle_notification( } } +struct RequestDispatchContext<'a> { + client: &'a Client, + handlers: &'a SessionHandlers, + hooks: Option<&'a dyn SessionHooks>, + transforms: Option<&'a dyn SystemMessageTransform>, + canvas_handler: Option<&'a Arc>, + session_fs_provider: Option<&'a Arc>, +} + /// Process a JSON-RPC request from the CLI. async fn handle_request( session_id: &SessionId, - client: &Client, - handlers: &SessionHandlers, - hooks: Option<&dyn SessionHooks>, - transforms: Option<&dyn SystemMessageTransform>, - session_fs_provider: Option<&Arc>, + ctx: RequestDispatchContext<'_>, request: crate::JsonRpcRequest, ) { let sid = session_id.clone(); + let client = ctx.client; + let handlers = ctx.handlers; + let hooks = ctx.hooks; + let transforms = ctx.transforms; + let canvas_handler = ctx.canvas_handler; + let session_fs_provider = ctx.session_fs_provider; if request.method.starts_with("sessionFs.") { crate::session_fs_dispatch::dispatch(client, session_fs_provider, request).await; @@ -1706,6 +1736,38 @@ async fn handle_request( } match request.method.as_str() { + "canvas.open" => { + let Some(params) = + parse_request_params::(client, request.id, &request) + .await + else { + return; + }; + let result = dispatch_canvas_open(canvas_handler, params).await; + send_canvas_dispatch_response(client, request.id, result).await; + } + + "canvas.close" => { + let Some(params) = + parse_request_params::(client, request.id, &request) + .await + else { + return; + }; + let result = dispatch_canvas_close(canvas_handler, params).await; + send_canvas_dispatch_response(client, request.id, result).await; + } + + "canvas.action.invoke" => { + let Some(params) = + parse_request_params::(client, request.id, &request).await + else { + return; + }; + let result = dispatch_canvas_action(canvas_handler, params).await; + send_canvas_dispatch_response(client, request.id, result).await; + } + "hooks.invoke" => { let params = request.params.as_ref(); let hook_type = params @@ -1938,6 +2000,112 @@ async fn handle_request( } } +async fn parse_request_params( + client: &Client, + id: u64, + request: &crate::JsonRpcRequest, +) -> Option +where + T: DeserializeOwned, +{ + let params = request + .params + .as_ref() + .cloned() + .unwrap_or(Value::Object(serde_json::Map::new())); + match serde_json::from_value(params) { + Ok(params) => Some(params), + Err(error) => { + let _ = send_error_response( + client, + id, + error_codes::INVALID_PARAMS, + &format!("invalid params: {error}"), + ) + .await; + None + } + } +} + +async fn send_canvas_dispatch_response( + client: &Client, + id: u64, + result: crate::canvas::CanvasResult, +) { + let response = match result { + Ok(value) => JsonRpcResponse { + jsonrpc: "2.0".to_string(), + id, + result: Some(value), + error: None, + }, + Err(error) => JsonRpcResponse { + jsonrpc: "2.0".to_string(), + id, + result: None, + error: Some(crate::JsonRpcError { + code: error_codes::INTERNAL_ERROR, + message: error.message.clone(), + data: Some(serde_json::json!({ + "code": error.code, + "message": error.message, + })), + }), + }, + }; + if let Err(error) = client.send_response(&response).await { + warn!( + request_id = id, + error = %error, + "failed to send canvas provider response" + ); + } +} + +fn canvas_handler_or_err( + handler: Option<&Arc>, +) -> crate::canvas::CanvasResult<&Arc> { + handler.ok_or_else(|| { + crate::canvas::CanvasError::new( + "canvas_handler_unset", + "No CanvasHandler installed on this session; \ + call SessionConfig::with_canvas_handler before creating the session.", + ) + }) +} + +async fn dispatch_canvas_open( + handler: Option<&Arc>, + params: CanvasProviderRequestParams, +) -> crate::canvas::CanvasResult { + let handler = canvas_handler_or_err(handler)?; + let response = handler.on_open(params.into_open_context()).await?; + serde_json::to_value(response).map_err(|error| { + crate::canvas::CanvasError::new( + "canvas_open_response_serialization_failed", + format!("failed to serialize canvas.open response: {error}"), + ) + }) +} + +async fn dispatch_canvas_close( + handler: Option<&Arc>, + params: CanvasProviderRequestParams, +) -> crate::canvas::CanvasResult { + let handler = canvas_handler_or_err(handler)?; + handler.on_close(params.into_lifecycle_context()).await?; + Ok(Value::Null) +} + +async fn dispatch_canvas_action( + handler: Option<&Arc>, + params: CanvasInvokeParams, +) -> crate::canvas::CanvasResult { + let handler = canvas_handler_or_err(handler)?; + handler.on_action(params.into_action_context()).await +} + async fn send_error_response( client: &Client, id: u64, diff --git a/rust/src/types.rs b/rust/src/types.rs index df5767aa4..d841096c5 100644 --- a/rust/src/types.rs +++ b/rust/src/types.rs @@ -12,6 +12,8 @@ use std::time::Duration; use serde::{Deserialize, Serialize}; use serde_json::Value; +use crate::canvas::{CanvasDeclaration, CanvasHandler}; +use crate::generated::api_types::OpenCanvasInstance; use crate::handler::{ AutoModeSwitchHandler, ElicitationHandler, ExitPlanModeHandler, PermissionHandler, UserInputHandler, @@ -773,6 +775,26 @@ impl CloudSessionOptions { } } +/// Stable extension identity for session participants that provide canvases. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct ExtensionInfo { + /// Extension namespace/source, e.g. `"github-app"`. + pub source: String, + /// Stable provider name within the source namespace. + pub name: String, +} + +impl ExtensionInfo { + /// Create stable extension identity metadata. + pub fn new(source: impl Into, name: impl Into) -> Self { + Self { + source: source.into(), + name: name.into(), + } + } +} + /// Configuration for a single MCP server. /// /// MCP (Model Context Protocol) servers expose external tools to the @@ -1088,6 +1110,19 @@ pub struct SessionConfig { pub system_message: Option, /// Client-defined tool declarations to expose to the agent. pub tools: Option>, + /// Canvas declarations this connection provides to the runtime. + pub canvases: Option>, + /// Provider-side canvas lifecycle handler. The SDK routes inbound + /// `canvas.open` / `canvas.close` / `canvas.action.invoke` requests to + /// this handler. Use [`with_canvas_handler`](Self::with_canvas_handler) + /// to install one. + pub canvas_handler: Option>, + /// Request canvas renderer tools for this connection. + pub request_canvas_renderer: Option, + /// Request extension tools and dispatch for this connection. + pub request_extensions: Option, + /// Stable extension identity for canvas/tool providers on this connection. + pub extension_info: Option, /// Allowlist of built-in tool names the agent may use. pub available_tools: Option>, /// Blocklist of built-in tool names the agent must not use. @@ -1211,6 +1246,14 @@ impl std::fmt::Debug for SessionConfig { .field("streaming", &self.streaming) .field("system_message", &self.system_message) .field("tools", &self.tools) + .field("canvases", &self.canvases) + .field( + "canvas_handler", + &self.canvas_handler.as_ref().map(|_| ""), + ) + .field("request_canvas_renderer", &self.request_canvas_renderer) + .field("request_extensions", &self.request_extensions) + .field("extension_info", &self.extension_info) .field("available_tools", &self.available_tools) .field("excluded_tools", &self.excluded_tools) .field("mcp_servers", &self.mcp_servers) @@ -1290,6 +1333,11 @@ impl Default for SessionConfig { streaming: None, system_message: None, tools: None, + canvases: None, + canvas_handler: None, + request_canvas_renderer: None, + request_extensions: None, + extension_info: None, available_tools: None, excluded_tools: None, mcp_servers: None, @@ -1340,6 +1388,7 @@ pub(crate) struct SessionConfigRuntime { pub hooks_handler: Option>, pub system_message_transform: Option>, pub tool_handlers: HashMap>, + pub canvas_handler: Option>, pub session_fs_provider: Option>, pub commands: Option>, } @@ -1390,6 +1439,8 @@ impl SessionConfig { }) .collect() }); + let wire_canvases = self.canvases.clone(); + let canvas_handler = self.canvas_handler.clone(); let wire = crate::wire::SessionCreateWire { session_id, @@ -1399,6 +1450,10 @@ impl SessionConfig { streaming: self.streaming, system_message: self.system_message, tools: self.tools, + canvases: wire_canvases, + request_canvas_renderer: self.request_canvas_renderer, + request_extensions: self.request_extensions, + extension_info: self.extension_info, available_tools: self.available_tools, excluded_tools: self.excluded_tools, mcp_servers: self.mcp_servers, @@ -1439,6 +1494,7 @@ impl SessionConfig { hooks_handler: self.hooks_handler, system_message_transform: self.system_message_transform, tool_handlers, + canvas_handler, session_fs_provider: self.session_fs_provider, commands: self.commands, }; @@ -1589,6 +1645,39 @@ impl SessionConfig { self } + /// Set canvas declarations for this connection. The runtime advertises + /// these to the agent; install a [`CanvasHandler`] via + /// [`with_canvas_handler`](Self::with_canvas_handler) to receive the + /// resulting provider callbacks. + pub fn with_canvases>(mut self, canvases: I) -> Self { + self.canvases = Some(canvases.into_iter().collect()); + self + } + + /// Install the provider-side [`CanvasHandler`] for this session. + pub fn with_canvas_handler(mut self, handler: Arc) -> Self { + self.canvas_handler = Some(handler); + self + } + + /// Request host canvas renderer tools for this connection. + pub fn with_request_canvas_renderer(mut self, request: bool) -> Self { + self.request_canvas_renderer = Some(request); + self + } + + /// Request extension tools and dispatch for this connection. + pub fn with_request_extensions(mut self, request: bool) -> Self { + self.request_extensions = Some(request); + self + } + + /// Set stable extension identity metadata for this connection. + pub fn with_extension_info(mut self, extension_info: ExtensionInfo) -> Self { + self.extension_info = Some(extension_info); + self + } + /// Set the allowlist of built-in tool names the agent may use. pub fn with_available_tools(mut self, tools: I) -> Self where @@ -1773,6 +1862,19 @@ pub struct ResumeSessionConfig { pub system_message: Option, /// Client-defined tool declarations to re-supply on resume. pub tools: Option>, + /// Canvas declarations this connection provides to the runtime. + pub canvases: Option>, + /// Provider-side canvas lifecycle handler. See + /// [`SessionConfig::canvas_handler`]. + pub canvas_handler: Option>, + /// Open canvas instances the caller knows were open before this resume. + pub open_canvases: Option>, + /// Request canvas renderer tools for this connection. + pub request_canvas_renderer: Option, + /// Request extension tools and dispatch for this connection. + pub request_extensions: Option, + /// Stable extension identity for canvas/tool providers on this connection. + pub extension_info: Option, /// Allowlist of tool names the agent may use. pub available_tools: Option>, /// Blocklist of built-in tool names. @@ -1874,6 +1976,15 @@ impl std::fmt::Debug for ResumeSessionConfig { .field("streaming", &self.streaming) .field("system_message", &self.system_message) .field("tools", &self.tools) + .field("canvases", &self.canvases) + .field( + "canvas_handler", + &self.canvas_handler.as_ref().map(|_| ""), + ) + .field("open_canvases", &self.open_canvases) + .field("request_canvas_renderer", &self.request_canvas_renderer) + .field("request_extensions", &self.request_extensions) + .field("extension_info", &self.extension_info) .field("available_tools", &self.available_tools) .field("excluded_tools", &self.excluded_tools) .field("mcp_servers", &self.mcp_servers) @@ -1980,6 +2091,8 @@ impl ResumeSessionConfig { }) .collect() }); + let wire_canvases = self.canvases.clone(); + let canvas_handler = self.canvas_handler.clone(); let wire = crate::wire::SessionResumeWire { session_id: self.session_id, @@ -1988,6 +2101,11 @@ impl ResumeSessionConfig { streaming: self.streaming, system_message: self.system_message, tools: self.tools, + canvases: wire_canvases, + open_canvases: self.open_canvases, + request_canvas_renderer: self.request_canvas_renderer, + request_extensions: self.request_extensions, + extension_info: self.extension_info, available_tools: self.available_tools, excluded_tools: self.excluded_tools, mcp_servers: self.mcp_servers, @@ -2029,6 +2147,7 @@ impl ResumeSessionConfig { hooks_handler: self.hooks_handler, system_message_transform: self.system_message_transform, tool_handlers, + canvas_handler, session_fs_provider: self.session_fs_provider, commands: self.commands, }; @@ -2048,6 +2167,12 @@ impl ResumeSessionConfig { streaming: None, system_message: None, tools: None, + canvases: None, + canvas_handler: None, + open_canvases: None, + request_canvas_renderer: None, + request_extensions: None, + extension_info: None, available_tools: None, excluded_tools: None, mcp_servers: None, @@ -2202,6 +2327,45 @@ impl ResumeSessionConfig { self } + /// Re-supply canvas declarations on resume. + pub fn with_canvases>(mut self, canvases: I) -> Self { + self.canvases = Some(canvases.into_iter().collect()); + self + } + + /// Install the provider-side [`CanvasHandler`] for the resumed session. + pub fn with_canvas_handler(mut self, handler: Arc) -> Self { + self.canvas_handler = Some(handler); + self + } + + /// Seed open canvas instances that were visible before resuming. + pub fn with_open_canvases>( + mut self, + open_canvases: I, + ) -> Self { + self.open_canvases = Some(open_canvases.into_iter().collect()); + self + } + + /// Request host canvas renderer tools for this connection on resume. + pub fn with_request_canvas_renderer(mut self, request: bool) -> Self { + self.request_canvas_renderer = Some(request); + self + } + + /// Request extension tools and dispatch for this connection on resume. + pub fn with_request_extensions(mut self, request: bool) -> Self { + self.request_extensions = Some(request); + self + } + + /// Set stable extension identity metadata for this connection on resume. + pub fn with_extension_info(mut self, extension_info: ExtensionInfo) -> Self { + self.extension_info = Some(extension_info); + self + } + /// Set the allowlist of tool names the agent may use. pub fn with_available_tools(mut self, tools: I) -> Self where @@ -2450,6 +2614,31 @@ pub struct CreateSessionResult { pub capabilities: Option, } +/// Response from `session.resume`. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct ResumeSessionResult { + /// The CLI-assigned session ID. Older runtimes may omit this on resume. + #[serde(default)] + pub session_id: Option, + /// Workspace directory for the session (infinite sessions). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub workspace_path: Option, + /// Remote session URL, if the session is running remotely. + #[serde(default, alias = "remote_url")] + pub remote_url: Option, + /// Capabilities negotiated with the CLI for this session. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub capabilities: Option, + /// Canvas instances already open when the session was resumed. + #[serde( + default, + alias = "openCanvasInstances", + skip_serializing_if = "Option::is_none" + )] + pub open_canvases: Option>, +} + /// Severity level for [`Session::log`](crate::session::Session::log) messages. #[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] @@ -3282,6 +3471,9 @@ pub struct UiCapabilities { /// Whether the host supports interactive elicitation dialogs. #[serde(skip_serializing_if = "Option::is_none")] pub elicitation: Option, + /// Host-specific canvas capabilities. + #[serde(skip_serializing_if = "Option::is_none")] + pub canvases: Option, } /// Options for the [`SessionUi::input`](crate::session::SessionUi::input) convenience method. @@ -3432,7 +3624,7 @@ mod tests { use super::{ Attachment, AttachmentLineRange, AttachmentSelectionPosition, AttachmentSelectionRange, - ConnectionState, CustomAgentConfig, DeliveryMode, GitHubReferenceType, + ConnectionState, CustomAgentConfig, DeliveryMode, ExtensionInfo, GitHubReferenceType, InfiniteSessionConfig, ProviderConfig, ResumeSessionConfig, SessionConfig, SessionEvent, SessionId, SystemMessageConfig, Tool, ToolBinaryResult, ToolResult, ToolResultExpanded, ToolResultResponse, ensure_attachment_display_names, @@ -3677,7 +3869,8 @@ mod tests { .with_working_directory(PathBuf::from("/tmp/work")) .with_github_token("ghp_test") .with_enable_session_telemetry(false) - .with_include_sub_agent_streaming_events(false); + .with_include_sub_agent_streaming_events(false) + .with_extension_info(ExtensionInfo::new("github-app", "counter")); assert_eq!(cfg.session_id.as_ref().map(|s| s.as_str()), Some("sess-1")); assert_eq!(cfg.model.as_deref(), Some("claude-sonnet-4")); @@ -3709,6 +3902,10 @@ mod tests { assert_eq!(cfg.github_token.as_deref(), Some("ghp_test")); assert_eq!(cfg.enable_session_telemetry, Some(false)); assert_eq!(cfg.include_sub_agent_streaming_events, Some(false)); + assert_eq!( + cfg.extension_info, + Some(ExtensionInfo::new("github-app", "counter")) + ); } #[test] @@ -3732,7 +3929,8 @@ mod tests { .with_enable_session_telemetry(false) .with_include_sub_agent_streaming_events(true) .with_suppress_resume_event(true) - .with_continue_pending_work(true); + .with_continue_pending_work(true) + .with_extension_info(ExtensionInfo::new("github-app", "counter")); assert_eq!(cfg.session_id.as_str(), "sess-2"); assert_eq!(cfg.client_name.as_deref(), Some("test-app")); @@ -3764,6 +3962,10 @@ mod tests { assert_eq!(cfg.include_sub_agent_streaming_events, Some(true)); assert_eq!(cfg.suppress_resume_event, Some(true)); assert_eq!(cfg.continue_pending_work, Some(true)); + assert_eq!( + cfg.extension_info, + Some(ExtensionInfo::new("github-app", "counter")) + ); } /// `continue_pending_work` must serialize to wire as `continuePendingWork` diff --git a/rust/src/wire.rs b/rust/src/wire.rs index bc6af5651..b97aea261 100644 --- a/rust/src/wire.rs +++ b/rust/src/wire.rs @@ -18,10 +18,13 @@ use std::path::PathBuf; use serde::Serialize; -use crate::generated::api_types::{ModelCapabilitiesOverride, RemoteSessionMode}; +use crate::canvas::CanvasDeclaration; +use crate::generated::api_types::{ + ModelCapabilitiesOverride, OpenCanvasInstance, RemoteSessionMode, +}; use crate::types::{ - CloudSessionOptions, CustomAgentConfig, DefaultAgentConfig, InfiniteSessionConfig, - McpServerConfig, ProviderConfig, SessionId, SystemMessageConfig, Tool, + CloudSessionOptions, CustomAgentConfig, DefaultAgentConfig, ExtensionInfo, + InfiniteSessionConfig, McpServerConfig, ProviderConfig, SessionId, SystemMessageConfig, Tool, }; /// Wire representation of a slash command (name + description only). The @@ -53,6 +56,14 @@ pub(crate) struct SessionCreateWire { #[serde(skip_serializing_if = "Option::is_none")] pub tools: Option>, #[serde(skip_serializing_if = "Option::is_none")] + pub canvases: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub request_canvas_renderer: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub request_extensions: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub extension_info: Option, + #[serde(skip_serializing_if = "Option::is_none")] pub available_tools: Option>, #[serde(skip_serializing_if = "Option::is_none")] pub excluded_tools: Option>, @@ -119,6 +130,16 @@ pub(crate) struct SessionResumeWire { #[serde(skip_serializing_if = "Option::is_none")] pub tools: Option>, #[serde(skip_serializing_if = "Option::is_none")] + pub canvases: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub open_canvases: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub request_canvas_renderer: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub request_extensions: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub extension_info: Option, + #[serde(skip_serializing_if = "Option::is_none")] pub available_tools: Option>, #[serde(skip_serializing_if = "Option::is_none")] pub excluded_tools: Option>, diff --git a/rust/tests/e2e/elicitation.rs b/rust/tests/e2e/elicitation.rs index 5d38ee132..7f8ab3bed 100644 --- a/rust/tests/e2e/elicitation.rs +++ b/rust/tests/e2e/elicitation.rs @@ -383,6 +383,7 @@ async fn session_capabilities_types_are_properly_structured() { let capabilities = github_copilot_sdk::SessionCapabilities { ui: Some(UiCapabilities { elicitation: Some(true), + canvases: None, }), }; diff --git a/rust/tests/session_test.rs b/rust/tests/session_test.rs index ed3698951..050c5898d 100644 --- a/rust/tests/session_test.rs +++ b/rust/tests/session_test.rs @@ -6,14 +6,19 @@ use std::sync::atomic::{AtomicUsize, Ordering}; use std::time::Duration; use async_trait::async_trait; +use github_copilot_sdk::canvas::{ + CanvasActionContext, CanvasDeclaration, CanvasHandler, CanvasOpenContext, CanvasOpenResponse, + CanvasResult, +}; +use github_copilot_sdk::generated::api_types::{CanvasInstanceAvailability, OpenCanvasInstance}; use github_copilot_sdk::handler::{ ApproveAllHandler, AutoModeSwitchHandler, AutoModeSwitchResponse, ElicitationHandler, ExitPlanModeHandler, ExitPlanModeResult, UserInputHandler, UserInputResponse, }; use github_copilot_sdk::types::{ CommandContext, CommandDefinition, CommandHandler, DeliveryMode, ElicitationRequest, - ElicitationResult, ExitPlanModeData, MessageOptions, RequestId, SessionConfig, SessionId, Tool, - ToolInvocation, ToolResult, + ElicitationResult, ExitPlanModeData, ExtensionInfo, MessageOptions, RequestId, SessionConfig, + SessionId, Tool, ToolInvocation, ToolResult, }; use github_copilot_sdk::{Client, tool}; use serde_json::Value; @@ -22,6 +27,34 @@ use tokio::time::timeout; const TIMEOUT: Duration = Duration::from_secs(2); +struct TestCanvasHandler; + +#[async_trait] +impl CanvasHandler for TestCanvasHandler { + async fn on_open(&self, ctx: CanvasOpenContext) -> CanvasResult { + Ok(CanvasOpenResponse { + url: Some(format!("https://example.test/{}", ctx.canvas_id)), + title: Some("Test Canvas".to_string()), + status: Some("ready".to_string()), + }) + } + + async fn on_action(&self, ctx: CanvasActionContext) -> CanvasResult { + Ok(serde_json::json!({ + "actionName": ctx.action_name, + "input": ctx.input, + })) + } +} + +fn test_canvas(id: &str) -> CanvasDeclaration { + CanvasDeclaration::new(id, "Test Canvas", "Test canvas description") +} + +fn test_canvas_handler() -> Arc { + Arc::new(TestCanvasHandler) +} + async fn write_framed(writer: &mut (impl AsyncWrite + Unpin), body: &[u8]) { let header = format!("Content-Length: {}\r\n\r\n", body.len()); writer.write_all(header.as_bytes()).await.unwrap(); @@ -289,6 +322,82 @@ async fn create_session_sends_correct_rpc() { assert_eq!(session.workspace_path(), Some(Path::new("/ws"))); } +#[tokio::test] +async fn create_session_sends_canvas_wire_fields() { + let (client, mut server_read, mut server_write) = make_client(); + + let create_handle = tokio::spawn({ + let client = client.clone(); + async move { + client + .create_session( + SessionConfig::default() + .with_canvases([test_canvas("counter")]) + .with_request_canvas_renderer(true) + .with_request_extensions(true) + .with_extension_info(ExtensionInfo::new("github-app", "counter-provider")), + ) + .await + .unwrap() + } + }); + + let request = read_framed(&mut server_read).await; + assert_eq!(request["method"], "session.create"); + assert_eq!(request["params"]["canvases"][0]["id"], "counter"); + assert_eq!( + request["params"]["canvases"][0]["displayName"], + "Test Canvas" + ); + assert_eq!(request["params"]["requestCanvasRenderer"], true); + assert_eq!(request["params"]["requestExtensions"], true); + assert_eq!(request["params"]["extensionInfo"]["source"], "github-app"); + assert_eq!( + request["params"]["extensionInfo"]["name"], + "counter-provider" + ); + + let id = request["id"].as_u64().unwrap(); + let session_id = requested_session_id(&request).to_string(); + let response = serde_json::json!({ + "jsonrpc": "2.0", + "id": id, + "result": { "sessionId": session_id }, + }); + write_framed(&mut server_write, &serde_json::to_vec(&response).unwrap()).await; + + timeout(TIMEOUT, create_handle).await.unwrap().unwrap(); +} + +#[tokio::test] +async fn provider_canvas_dispatch_routes_direct_canvas_action_requests() { + let (session, mut server) = create_session_pair_with_config(|cfg| { + cfg.with_canvases([test_canvas("counter")]) + .with_canvas_handler(test_canvas_handler()) + }) + .await; + + server + .send_request( + 42, + "canvas.action.invoke", + serde_json::json!({ + "sessionId": session.id(), + "extensionId": "project:counter", + "canvasId": "counter", + "instanceId": "counter-1", + "actionName": "increment", + "input": { "amount": 1 } + }), + ) + .await; + + let response = timeout(TIMEOUT, server.read_response()).await.unwrap(); + assert_eq!(response["id"], 42); + assert_eq!(response["result"]["actionName"], "increment"); + assert_eq!(response["result"]["input"]["amount"], 1); +} + #[tokio::test] async fn send_injects_session_id() { let (session, mut server) = create_session_pair().await; @@ -692,9 +801,20 @@ fn permission_request_data_extracts_typed_kind() { let custom: PermissionRequestData = serde_json::from_value(serde_json::json!({ "kind": "custom-tool", + "toolName": "open_canvas", + "args": { + "extensionId": "github-app:counter-provider", + "canvasId": "counter", + "instanceId": "counter-1" + } })) .unwrap(); assert_eq!(custom.kind, Some(PermissionRequestKind::CustomTool)); + assert_eq!(custom.extra["toolName"], "open_canvas"); + assert_eq!( + custom.extra["args"]["extensionId"], + "github-app:counter-provider" + ); // Unknown kinds fall through to the catch-all variant rather than failing. let unknown: PermissionRequestData = serde_json::from_value(serde_json::json!({ @@ -2380,6 +2500,86 @@ async fn env_value_mode_hardcoded_direct_on_create_and_resume() { timeout(TIMEOUT, resume_handle).await.unwrap().unwrap(); } +#[tokio::test] +async fn resume_session_sends_canvas_fields_and_captures_open_canvases() { + use github_copilot_sdk::types::ResumeSessionConfig; + + let (client, mut server_read, mut server_write) = make_client(); + let resume_handle = tokio::spawn({ + let client = client.clone(); + async move { + let cfg = ResumeSessionConfig::new(SessionId::from("canvas-resume")) + .with_canvases([test_canvas("counter")]) + .with_request_canvas_renderer(true) + .with_request_extensions(true) + .with_extension_info(ExtensionInfo::new("github-app", "counter-provider")) + .with_open_canvases([OpenCanvasInstance { + instance_id: "counter-1".to_string(), + extension_id: "github-app:counter-provider".to_string(), + extension_name: Some("Counter Provider".to_string()), + canvas_id: "counter".to_string(), + title: Some("Counter".to_string()), + status: Some("ready".to_string()), + url: Some("https://example.test/counter".to_string()), + input: Some(serde_json::json!({ "seed": 1 })), + reopen: false, + availability: CanvasInstanceAvailability::Stale, + }]); + client.resume_session(cfg).await.unwrap() + } + }); + + let request = read_framed(&mut server_read).await; + assert_eq!(request["method"], "session.resume"); + assert_eq!(request["params"]["canvases"][0]["id"], "counter"); + assert_eq!(request["params"]["requestCanvasRenderer"], true); + assert_eq!(request["params"]["requestExtensions"], true); + assert_eq!(request["params"]["extensionInfo"]["source"], "github-app"); + assert_eq!( + request["params"]["extensionInfo"]["name"], + "counter-provider" + ); + assert_eq!( + request["params"]["openCanvases"][0]["availability"], + "stale" + ); + + let id = request["id"].as_u64().unwrap(); + let response = serde_json::json!({ + "jsonrpc": "2.0", + "id": id, + "result": { + "sessionId": "canvas-resume", + "openCanvases": [{ + "extensionId": "project:counter", + "canvasId": "counter", + "instanceId": "counter-1", + "url": "https://example.test/counter", + "reopen": false, + "availability": "ready" + }], + "capabilities": { + "ui": { "canvases": true } + } + }, + }); + write_framed(&mut server_write, &serde_json::to_vec(&response).unwrap()).await; + + let reload = read_framed(&mut server_read).await; + assert_eq!(reload["method"], "session.skills.reload"); + let id = reload["id"].as_u64().unwrap(); + let response = serde_json::json!({ "jsonrpc": "2.0", "id": id, "result": {} }); + write_framed(&mut server_write, &serde_json::to_vec(&response).unwrap()).await; + + let session = timeout(TIMEOUT, resume_handle).await.unwrap().unwrap(); + let open = session.open_canvases(); + assert_eq!(open.len(), 1); + assert_eq!(open[0].instance_id, "counter-1"); + assert_eq!(open[0].availability, CanvasInstanceAvailability::Ready); + let caps = session.capabilities(); + assert_eq!(caps.ui.unwrap().canvases, Some(true)); +} + #[tokio::test] async fn elicitation_methods_fail_without_capability() { let (session, _server) = create_session_pair().await;