diff --git a/dotnet/src/Canvas.cs b/dotnet/src/Canvas.cs index 6a6134e18..69514edda 100644 --- a/dotnet/src/Canvas.cs +++ b/dotnet/src/Canvas.cs @@ -55,110 +55,6 @@ public sealed class ExtensionInfo 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 @@ -234,9 +130,9 @@ internal partial class CanvasJsonContext : JsonSerializerContext; /// /// A session installs a single via /// SessionConfigBase.CanvasHandler. The handler receives every -/// inbound canvas.open / canvas.close / canvas.action.invoke +/// inbound canvas.open / canvas.close / canvas.invokeAction /// JSON-RPC request the runtime issues for this session and decides — typically -/// by inspecting — which +/// by inspecting — which /// application-side canvas should handle the call. /// /// The SDK does not maintain a per-canvas registry; multiplexing across @@ -252,16 +148,16 @@ internal partial class CanvasJsonContext : JsonSerializerContext; public interface ICanvasHandler { /// Open a new canvas instance. - Task OnOpenAsync(CanvasOpenContext context, CancellationToken cancellationToken); + Task OnOpenAsync(CanvasProviderOpenRequest context, CancellationToken cancellationToken); /// Canvas was closed by the user or agent. Default: no-op. - Task OnCloseAsync(CanvasLifecycleContext context, CancellationToken cancellationToken); + Task OnCloseAsync(CanvasProviderCloseRequest context, CancellationToken cancellationToken); /// /// Handle a non-lifecycle action declared by the canvas. /// Default: throws . /// - Task OnActionAsync(CanvasActionContext context, CancellationToken cancellationToken); + Task OnActionAsync(CanvasProviderInvokeActionRequest context, CancellationToken cancellationToken); } /// @@ -272,10 +168,10 @@ public interface ICanvasHandler public abstract class CanvasHandlerBase : ICanvasHandler { /// - public abstract Task OnOpenAsync(CanvasOpenContext context, CancellationToken cancellationToken); + public abstract Task OnOpenAsync(CanvasProviderOpenRequest context, CancellationToken cancellationToken); /// - public virtual Task OnCloseAsync(CanvasLifecycleContext context, CancellationToken cancellationToken) + public virtual Task OnCloseAsync(CanvasProviderCloseRequest context, CancellationToken cancellationToken) #if NET8_0_OR_GREATER => Task.CompletedTask; #else @@ -283,6 +179,6 @@ public virtual Task OnCloseAsync(CanvasLifecycleContext context, CancellationTok #endif /// - public virtual Task OnActionAsync(CanvasActionContext context, CancellationToken cancellationToken) + public virtual Task OnActionAsync(CanvasProviderInvokeActionRequest context, CancellationToken cancellationToken) => Task.FromException(CanvasError.NoHandler()); } diff --git a/dotnet/src/Client.cs b/dotnet/src/Client.cs index 0e7730690..055fd95be 100644 --- a/dotnet/src/Client.cs +++ b/dotnet/src/Client.cs @@ -1625,9 +1625,6 @@ 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}"); @@ -1821,46 +1818,6 @@ public async ValueTask OnSystemMessageTransfo 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( diff --git a/dotnet/src/Generated/Rpc.cs b/dotnet/src/Generated/Rpc.cs index 470fae6cb..3652fd784 100644 --- a/dotnet/src/Generated/Rpc.cs +++ b/dotnet/src/Generated/Rpc.cs @@ -1011,11 +1011,11 @@ internal sealed class SessionsReleaseLockRequest public string SessionId { get; set; } = string.Empty; } -/// The same metadata records, with summary and context fields backfilled where available. +/// The enriched metadata records, with summary and context fields backfilled where available. Sessions confirmed empty and unnamed are omitted. [Experimental(Diagnostics.Experimental)] public sealed class SessionEnrichMetadataResult { - /// Same records, with summary and context backfilled. + /// Enriched records, with summary and context backfilled. Sessions confirmed empty and unnamed may be omitted. [JsonPropertyName("sessions")] public IList Sessions { get => field ??= []; set; } } @@ -7449,6 +7449,7 @@ internal sealed class ScheduleStopRequest } /// Describes a filesystem error. +[Experimental(Diagnostics.Experimental)] public sealed class SessionFsError { /// Error classification. @@ -7461,6 +7462,7 @@ public sealed class SessionFsError } /// File content as a UTF-8 string, or a filesystem error if the read failed. +[Experimental(Diagnostics.Experimental)] public sealed class SessionFsReadFileResult { /// File content as UTF-8 string. @@ -7525,6 +7527,7 @@ public sealed class SessionFsAppendFileRequest } /// Indicates whether the requested path exists in the client-provided session filesystem. +[Experimental(Diagnostics.Experimental)] public sealed class SessionFsExistsResult { /// Whether the path exists. @@ -7545,6 +7548,7 @@ public sealed class SessionFsExistsRequest } /// Filesystem metadata for the requested path, or a filesystem error if the stat failed. +[Experimental(Diagnostics.Experimental)] public sealed class SessionFsStatResult { /// ISO 8601 timestamp of creation. @@ -7605,6 +7609,7 @@ public sealed class SessionFsMkdirRequest } /// Names of entries in the requested directory, or a filesystem error if the read failed. +[Experimental(Diagnostics.Experimental)] public sealed class SessionFsReaddirResult { /// Entry names in the directory. @@ -7629,6 +7634,7 @@ public sealed class SessionFsReaddirRequest } /// Schema for the `SessionFsReaddirWithTypesEntry` type. +[Experimental(Diagnostics.Experimental)] public sealed class SessionFsReaddirWithTypesEntry { /// Entry name. @@ -7641,6 +7647,7 @@ public sealed class SessionFsReaddirWithTypesEntry } /// Entries in the requested directory paired with file/directory type information, or a filesystem error if the read failed. +[Experimental(Diagnostics.Experimental)] public sealed class SessionFsReaddirWithTypesResult { /// Directory entries with type information. @@ -7701,6 +7708,7 @@ public sealed class SessionFsRenameRequest } /// Query results including rows, columns, and rows affected, or a filesystem error if execution failed. +[Experimental(Diagnostics.Experimental)] public sealed class SessionFsSqliteQueryResult { /// Column names from the result set. @@ -7745,6 +7753,7 @@ public sealed class SessionFsSqliteQueryRequest } /// Indicates whether the per-session SQLite database already exists. +[Experimental(Diagnostics.Experimental)] public sealed class SessionFsSqliteExistsResult { /// Whether the session database already exists. @@ -7760,6 +7769,125 @@ public sealed class SessionFsSqliteExistsRequest public string SessionId { get; set; } = string.Empty; } +/// Canvas open result returned by the provider. +[Experimental(Diagnostics.Experimental)] +public sealed class CanvasProviderOpenResult +{ + /// Provider-supplied status text. + [JsonPropertyName("status")] + public string? Status { get; set; } + + /// Provider-supplied title. + [JsonPropertyName("title")] + public string? Title { get; set; } + + /// URL for web-rendered canvases. + [JsonPropertyName("url")] + public string? Url { get; set; } +} + +/// Host capabilities. +[Experimental(Diagnostics.Experimental)] +public sealed class CanvasHostContextCapabilities +{ + /// Whether canvas rendering is supported. + [JsonPropertyName("canvases")] + public bool? Canvases { get; set; } +} + +/// Host context supplied by the runtime. +[Experimental(Diagnostics.Experimental)] +public sealed class CanvasHostContext +{ + /// Host capabilities. + [JsonPropertyName("capabilities")] + public CanvasHostContextCapabilities? Capabilities { get; set; } +} + +/// Canvas open parameters sent to the provider. +public sealed class CanvasProviderOpenRequest +{ + /// Provider-local canvas identifier. + [JsonPropertyName("canvasId")] + public string CanvasId { get; set; } = string.Empty; + + /// Owning provider identifier. + [JsonPropertyName("extensionId")] + public string ExtensionId { get; set; } = string.Empty; + + /// Host context supplied by the runtime. + [JsonPropertyName("host")] + public CanvasHostContext? Host { get; set; } + + /// Canvas open input. + [JsonPropertyName("input")] + public JsonElement? Input { get; set; } + + /// Stable caller-supplied canvas instance identifier. + [JsonPropertyName("instanceId")] + public string InstanceId { get; set; } = string.Empty; + + /// Target session identifier. + [JsonPropertyName("sessionId")] + public string SessionId { get; set; } = string.Empty; +} + +/// Canvas close parameters sent to the provider. +public sealed class CanvasProviderCloseRequest +{ + /// Provider-local canvas identifier. + [JsonPropertyName("canvasId")] + public string CanvasId { get; set; } = string.Empty; + + /// Owning provider identifier. + [JsonPropertyName("extensionId")] + public string ExtensionId { get; set; } = string.Empty; + + /// Host context supplied by the runtime. + [JsonPropertyName("host")] + public CanvasHostContext? Host { get; set; } + + /// Canvas instance identifier. + [JsonPropertyName("instanceId")] + public string InstanceId { get; set; } = string.Empty; + + /// Target session identifier. + [JsonPropertyName("sessionId")] + public string SessionId { get; set; } = string.Empty; +} + +/// Canvas action invocation parameters sent to the provider. +public sealed class CanvasProviderInvokeActionRequest +{ + /// Action name to invoke. + [JsonPropertyName("actionName")] + public string ActionName { get; set; } = string.Empty; + + /// Provider-local canvas identifier. + [JsonPropertyName("canvasId")] + public string CanvasId { get; set; } = string.Empty; + + /// Owning provider identifier. + [JsonPropertyName("extensionId")] + public string ExtensionId { get; set; } = string.Empty; + + /// Host context supplied by the runtime. + [JsonPropertyName("host")] + public CanvasHostContext? Host { get; set; } + + /// Action input. + [JsonPropertyName("input")] + public JsonElement? Input { get; set; } + + /// Canvas instance identifier. + [JsonPropertyName("instanceId")] + public string InstanceId { get; set; } = string.Empty; + + /// Target session identifier. + [JsonPropertyName("sessionId")] + public string SessionId { get; set; } = string.Empty; +} + /// Model capability category for grouping in the model picker. [JsonConverter(typeof(Converter))] [DebuggerDisplay("{Value,nq}")] @@ -11242,6 +11370,7 @@ public override void Write(Utf8JsonWriter writer, RemoteSessionMode value, JsonS /// Error classification. +[Experimental(Diagnostics.Experimental)] [JsonConverter(typeof(Converter))] [DebuggerDisplay("{Value,nq}")] public readonly struct SessionFsErrorCode : IEquatable @@ -11304,6 +11433,7 @@ public override void Write(Utf8JsonWriter writer, SessionFsErrorCode value, Json /// Entry type. +[Experimental(Diagnostics.Experimental)] [JsonConverter(typeof(Converter))] [DebuggerDisplay("{Value,nq}")] public readonly struct SessionFsReaddirWithTypesEntryType : IEquatable @@ -11366,6 +11496,7 @@ public override void Write(Utf8JsonWriter writer, SessionFsReaddirWithTypesEntry /// How to execute the query: 'exec' for DDL/multi-statement (no results), 'query' for SELECT (returns rows), 'run' for INSERT/UPDATE/DELETE (returns rowsAffected). +[Experimental(Diagnostics.Experimental)] [JsonConverter(typeof(Converter))] [DebuggerDisplay("{Value,nq}")] public readonly struct SessionFsSqliteQueryType : IEquatable @@ -11967,7 +12098,7 @@ public async Task ReleaseLockAsync(string sessionId, /// Backfills missing summary and context fields on the supplied session metadata records. /// Session metadata records to enrich. Records that already have summary and context are returned unchanged. /// The to monitor for cancellation requests. The default is . - /// The same metadata records, with summary and context fields backfilled where available. + /// The enriched metadata records, with summary and context fields backfilled where available. Sessions confirmed empty and unnamed are omitted. public async Task EnrichMetadataAsync(IList sessions, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(sessions); @@ -14477,6 +14608,7 @@ public async Task StopAsync(long id, CancellationToken cance } /// Handles `sessionFs` client session API methods. +[Experimental(Diagnostics.Experimental)] public interface ISessionFsHandler { /// Reads a file from the client-provided session filesystem. @@ -14541,11 +14673,34 @@ public interface ISessionFsHandler Task SqliteExistsAsync(SessionFsSqliteExistsRequest request, CancellationToken cancellationToken = default); } +/// Handles `canvas` client session API methods. +[Experimental(Diagnostics.Experimental)] +public interface ICanvasHandler +{ + /// Opens a canvas instance on the provider. + /// Canvas open parameters sent to the provider. + /// The to monitor for cancellation requests. The default is . + /// Canvas open result returned by the provider. + Task OpenAsync(CanvasProviderOpenRequest request, CancellationToken cancellationToken = default); + /// Closes a canvas instance on the provider. + /// Canvas close parameters sent to the provider. + /// The to monitor for cancellation requests. The default is . + Task CloseAsync(CanvasProviderCloseRequest request, CancellationToken cancellationToken = default); + /// Invokes an action on an open canvas instance via the provider. + /// Canvas action invocation parameters sent to the provider. + /// The to monitor for cancellation requests. The default is . + /// Provider-supplied action result. + Task InvokeActionAsync(CanvasProviderInvokeActionRequest request, CancellationToken cancellationToken = default); +} + /// Provides all client session API handler groups for a session. public sealed class ClientSessionApiHandlers { /// Optional handler for SessionFs client session API methods. public ISessionFsHandler? SessionFs { get; set; } + + /// Optional handler for Canvas client session API methods. + public ICanvasHandler? Canvas { get; set; } } /// Registers client session API handlers on a JSON-RPC connection. @@ -14630,6 +14785,24 @@ public static void RegisterClientSessionApiHandlers(JsonRpc rpc, Func>)(async (request, cancellationToken) => + { + var handler = getHandlers(request.SessionId).Canvas; + if (handler is null) throw new InvalidOperationException($"No canvas handler registered for session: {request.SessionId}"); + return await handler.OpenAsync(request, cancellationToken); + }), singleObjectParam: true); + rpc.SetLocalRpcMethod("canvas.close", (Func)(async (request, cancellationToken) => + { + var handler = getHandlers(request.SessionId).Canvas; + if (handler is null) throw new InvalidOperationException($"No canvas handler registered for session: {request.SessionId}"); + await handler.CloseAsync(request, cancellationToken); + }), singleObjectParam: true); + rpc.SetLocalRpcMethod("canvas.invokeAction", (Func>)(async (request, cancellationToken) => + { + var handler = getHandlers(request.SessionId).Canvas; + if (handler is null) throw new InvalidOperationException($"No canvas handler registered for session: {request.SessionId}"); + return await handler.InvokeActionAsync(request, cancellationToken); + }), singleObjectParam: true); } } @@ -14895,11 +15068,17 @@ public static void RegisterClientSessionApiHandlers(JsonRpc rpc, Func Han private readonly SemaphoreSlim _transformCallbacksLock = new(1, 1); #pragma warning disable GHCP001 - private volatile ICanvasHandler? _canvasHandler; private IReadOnlyList _openCanvases = Array.Empty(); #pragma warning restore GHCP001 @@ -890,114 +889,72 @@ internal void SetOpenCanvases(IList? canvases) internal void SetCanvasHandler(ICanvasHandler? handler) { - _canvasHandler = handler; + ClientSessionApis.Canvas = handler is null ? null : new CanvasHandlerAdapter(handler); } - internal async ValueTask HandleCanvasOpenAsync( - string extensionId, - string canvasId, - string instanceId, - JsonElement input, - CanvasHostContext? host) + private static JsonElement SerializeActionResult(object? value) { - 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) + var element = CopilotClient.ToJsonElementForWire(value); + if (element.HasValue) { - throw CanvasErrorHelpers.HandlerError(ex.Message); + return element.Value; } + using var doc = JsonDocument.Parse("null"); + return doc.RootElement.Clone(); } +#pragma warning restore GHCP001 - internal async ValueTask HandleCanvasCloseAsync( - string extensionId, - string canvasId, - string instanceId, - CanvasHostContext? host) + private sealed class CanvasHandlerAdapter(ICanvasHandler handler) : Rpc.ICanvasHandler { - var handler = _canvasHandler ?? throw CanvasErrorHelpers.HandlerUnset(); - var ctx = new CanvasLifecycleContext + public async Task OpenAsync(CanvasProviderOpenRequest request, CancellationToken cancellationToken = default) { - 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); + try + { + return await handler.OnOpenAsync(request, cancellationToken).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 + public async Task CloseAsync(CanvasProviderCloseRequest request, CancellationToken cancellationToken = default) { - 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); + try + { + await handler.OnCloseAsync(request, cancellationToken).ConfigureAwait(false); + } + 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) + public async Task InvokeActionAsync(CanvasProviderInvokeActionRequest request, CancellationToken cancellationToken = default) { - return element.Value; + try + { + var result = await handler.OnActionAsync(request, cancellationToken).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); + } } - using var doc = JsonDocument.Parse("null"); - return doc.RootElement.Clone(); } -#pragma warning restore GHCP001 /// /// Dispatches a command.execute event to the registered handler and diff --git a/dotnet/src/Types.cs b/dotnet/src/Types.cs index a02a5db3a..94130b604 100644 --- a/dotnet/src/Types.cs +++ b/dotnet/src/Types.cs @@ -2441,7 +2441,7 @@ protected SessionConfigBase(SessionConfigBase? other) /// /// Provider-side canvas lifecycle handler. The SDK routes inbound - /// canvas.open / canvas.close / canvas.action.invoke + /// canvas.open / canvas.close / canvas.invokeAction /// requests to this handler. /// [Experimental(Diagnostics.Experimental)] @@ -3090,9 +3090,8 @@ public sealed class SystemMessageTransformRpcResponse [JsonSerializable(typeof(string[]))] #pragma warning disable GHCP001 [JsonSerializable(typeof(CanvasDeclaration))] -[JsonSerializable(typeof(CanvasOpenResponse))] +[JsonSerializable(typeof(CanvasProviderOpenResult))] [JsonSerializable(typeof(CanvasHostContext))] -[JsonSerializable(typeof(CanvasHostCapabilities))] [JsonSerializable(typeof(ExtensionInfo))] #pragma warning restore GHCP001 internal partial class TypesJsonContext : JsonSerializerContext; diff --git a/dotnet/test/E2E/CanvasE2ETests.cs b/dotnet/test/E2E/CanvasE2ETests.cs new file mode 100644 index 000000000..a4e60479d --- /dev/null +++ b/dotnet/test/E2E/CanvasE2ETests.cs @@ -0,0 +1,254 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +using GitHub.Copilot.Rpc; +using System.Collections.Generic; +using System.Linq; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Xunit; +using Xunit.Abstractions; + +namespace GitHub.Copilot.Test.E2E; + +public class CanvasE2ETests(E2ETestFixture fixture, ITestOutputHelper output) + : E2ETestBase(fixture, "canvas", output) +{ + [Fact] + public async Task Should_Discover_Canvas_Via_List() + { + var handler = new TestCanvasHandler(); + await using var session = await CreateCanvasSessionAsync(handler); + + var result = await session.Rpc.Canvas.ListAsync(); + + var canvas = Assert.Single(result.Canvases); + Assert.Equal("counter", canvas.CanvasId); + Assert.Equal("Counter", canvas.DisplayName); + Assert.Equal("Tracks a counter value.", canvas.Description); + Assert.Single(canvas.Actions!); + Assert.Equal("increment", canvas.Actions![0].Name); + Assert.Empty(handler.OpenRequests); + } + + [Fact] + public async Task Should_Open_Canvas_Through_The_Handler() + { + var handler = new TestCanvasHandler(); + await using var session = await CreateCanvasSessionAsync(handler); + var canvas = Assert.Single((await session.Rpc.Canvas.ListAsync()).Canvases); + + var openResult = await session.Rpc.Canvas.OpenAsync( + canvasId: "counter", + instanceId: "counter-1", + extensionId: canvas.ExtensionId, + input: new Dictionary { ["start"] = 41 }); + + Assert.Equal("counter", openResult.CanvasId); + Assert.Equal("counter-1", openResult.InstanceId); + Assert.Equal(canvas.ExtensionId, openResult.ExtensionId); + Assert.Equal("Counter counter-1", openResult.Title); + Assert.Equal("ready", openResult.Status); + Assert.Equal("https://example.com/counter/counter-1", openResult.Url); + + var request = Assert.Single(handler.OpenRequests); + Assert.Equal(session.SessionId, request.SessionId); + Assert.Equal(canvas.ExtensionId, request.ExtensionId); + Assert.Equal("counter", request.CanvasId); + Assert.Equal("counter-1", request.InstanceId); + Assert.Equal(41, GetRequiredInt32(request.Input, "start")); + + var openCanvases = await session.Rpc.Canvas.ListOpenAsync(); + Assert.Single(openCanvases.OpenCanvases); + Assert.Equal("counter-1", openCanvases.OpenCanvases[0].InstanceId); + } + + [Fact] + public async Task Should_Invoke_Canvas_Action_Through_The_Handler() + { + var handler = new TestCanvasHandler(); + await using var session = await CreateCanvasSessionAsync(handler); + var canvas = Assert.Single((await session.Rpc.Canvas.ListAsync()).Canvases); + await session.Rpc.Canvas.OpenAsync( + canvasId: "counter", + instanceId: "counter-1", + extensionId: canvas.ExtensionId, + input: new Dictionary { ["start"] = 41 }); + + var result = await session.Rpc.Canvas.InvokeActionAsync( + instanceId: "counter-1", + actionName: "increment", + input: new Dictionary { ["delta"] = 1 }); + + var request = Assert.Single(handler.ActionRequests); + Assert.Equal(session.SessionId, request.SessionId); + Assert.Equal(canvas.ExtensionId, request.ExtensionId); + Assert.Equal("counter", request.CanvasId); + Assert.Equal("counter-1", request.InstanceId); + Assert.Equal("increment", request.ActionName); + Assert.Equal(1, GetRequiredInt32(request.Input, "delta")); + Assert.True(result.Result.HasValue); + Assert.NotEqual(JsonValueKind.Undefined, result.Result.Value.ValueKind); + } + + [Fact] + public async Task Should_Close_Canvas_Through_The_Handler() + { + var handler = new TestCanvasHandler(); + await using var session = await CreateCanvasSessionAsync(handler); + var canvas = Assert.Single((await session.Rpc.Canvas.ListAsync()).Canvases); + await session.Rpc.Canvas.OpenAsync( + canvasId: "counter", + instanceId: "counter-1", + extensionId: canvas.ExtensionId, + input: new Dictionary { ["start"] = 41 }); + + await session.Rpc.Canvas.CloseAsync("counter-1"); + + var request = Assert.Single(handler.CloseRequests); + Assert.Equal(session.SessionId, request.SessionId); + Assert.Equal(canvas.ExtensionId, request.ExtensionId); + Assert.Equal("counter", request.CanvasId); + Assert.Equal("counter-1", request.InstanceId); + + var openCanvases = await session.Rpc.Canvas.ListOpenAsync(); + Assert.Empty(openCanvases.OpenCanvases); + } + + private Task CreateCanvasSessionAsync(TestCanvasHandler handler) + { + return CreateSessionAsync(new SessionConfig + { + OnPermissionRequest = PermissionHandler.ApproveAll, + RequestCanvasRenderer = true, + RequestExtensions = true, + ExtensionInfo = new ExtensionInfo { Source = "dotnet-sdk-tests", Name = "canvas-provider" }, + Canvases = + [ + new CanvasDeclaration + { + Id = "counter", + DisplayName = "Counter", + Description = "Tracks a counter value.", + Actions = + [ + new CanvasAction + { + Name = "increment", + Description = "Increments the counter.", + } + ], + } + ], + CanvasHandler = handler, + }); + } + + private static int GetRequiredInt32(JsonElement? element, string propertyName) + { + Assert.True(element.HasValue); + return element.Value.GetProperty(propertyName).GetInt32(); + } + + private sealed class TestCanvasHandler : CanvasHandlerBase + { + public List OpenRequests { get; } = []; + public List CloseRequests { get; } = []; + public List ActionRequests { get; } = []; + + public override Task OnOpenAsync(CanvasProviderOpenRequest context, CancellationToken cancellationToken) + { + OpenRequests.Add(Clone(context)); + return Task.FromResult(new CanvasProviderOpenResult + { + Url = $"https://example.com/counter/{context.InstanceId}", + Title = $"Counter {context.InstanceId}", + Status = "ready", + }); + } + + public override Task OnCloseAsync(CanvasProviderCloseRequest context, CancellationToken cancellationToken) + { + CloseRequests.Add(Clone(context)); + return Task.CompletedTask; + } + + public override Task OnActionAsync(CanvasProviderInvokeActionRequest context, CancellationToken cancellationToken) + { + ActionRequests.Add(Clone(context)); + var openRequest = OpenRequests.LastOrDefault(request => request.InstanceId == context.InstanceId); + var current = openRequest is not null && openRequest.Input.HasValue + ? openRequest.Input.Value.GetProperty("start").GetInt32() + : 0; + var delta = context.Input.HasValue + ? context.Input.Value.GetProperty("delta").GetInt32() + : 0; + using var document = JsonDocument.Parse($@"{{""count"":{current + delta}}}"); + return Task.FromResult(document.RootElement.Clone()); + } + + private static CanvasProviderOpenRequest Clone(CanvasProviderOpenRequest request) + => new() + { + SessionId = request.SessionId, + ExtensionId = request.ExtensionId, + CanvasId = request.CanvasId, + InstanceId = request.InstanceId, + Input = Clone(request.Input), + Host = Clone(request.Host), + }; + + private static CanvasProviderCloseRequest Clone(CanvasProviderCloseRequest request) + => new() + { + SessionId = request.SessionId, + ExtensionId = request.ExtensionId, + CanvasId = request.CanvasId, + InstanceId = request.InstanceId, + Host = Clone(request.Host), + }; + + private static CanvasProviderInvokeActionRequest Clone(CanvasProviderInvokeActionRequest request) + => new() + { + SessionId = request.SessionId, + ExtensionId = request.ExtensionId, + CanvasId = request.CanvasId, + InstanceId = request.InstanceId, + ActionName = request.ActionName, + Input = Clone(request.Input), + Host = Clone(request.Host), + }; + + private static JsonElement? Clone(JsonElement? element) + { + if (!element.HasValue) + { + return null; + } + + using var document = JsonDocument.Parse(element.Value.GetRawText()); + return document.RootElement.Clone(); + } + + private static CanvasHostContext? Clone(CanvasHostContext? host) + { + if (host is null) + { + return null; + } + + return new CanvasHostContext + { + Capabilities = host.Capabilities is null + ? null + : new CanvasHostContextCapabilities + { + Canvases = host.Capabilities.Canvases, + }, + }; + } + } +} diff --git a/dotnet/test/Unit/CanvasTests.cs b/dotnet/test/Unit/CanvasTests.cs index fc5db84aa..6a2e71a58 100644 --- a/dotnet/test/Unit/CanvasTests.cs +++ b/dotnet/test/Unit/CanvasTests.cs @@ -8,6 +8,7 @@ using System.Threading; using System.Threading.Tasks; using GitHub.Copilot; +using GitHub.Copilot.Rpc; using Xunit; namespace GitHub.Copilot.Test.Unit; @@ -47,10 +48,10 @@ public void CanvasDeclaration_Serializes_CamelCase_SkippingNulls() } [Fact] - public void CanvasOpenResponse_Roundtrips_WithCamelCaseFields() + public void CanvasProviderOpenResult_Roundtrips_WithCamelCaseFields() { var options = GetSerializerOptions(); - var response = new CanvasOpenResponse + var response = new CanvasProviderOpenResult { Url = "https://example.com/c/1", Title = "Demo", @@ -58,7 +59,7 @@ public void CanvasOpenResponse_Roundtrips_WithCamelCaseFields() }; var json = JsonSerializer.Serialize(response, options); - var parsed = JsonSerializer.Deserialize(json, options); + var parsed = JsonSerializer.Deserialize(json, options); Assert.NotNull(parsed); Assert.Equal("https://example.com/c/1", parsed!.Url); @@ -81,7 +82,7 @@ public void ExtensionInfo_Serializes_SourceAndName() public async Task CanvasHandlerBase_DefaultOnClose_Completes() { var handler = new TestHandler(); - await handler.OnCloseAsync(new CanvasLifecycleContext(), CancellationToken.None); + await handler.OnCloseAsync(new CanvasProviderCloseRequest(), CancellationToken.None); } [Fact] @@ -89,7 +90,7 @@ public async Task CanvasHandlerBase_DefaultOnAction_ThrowsNoHandlerCanvasError() { var handler = new TestHandler(); var ex = await Assert.ThrowsAsync( - () => handler.OnActionAsync(new CanvasActionContext(), CancellationToken.None)); + () => handler.OnActionAsync(new CanvasProviderInvokeActionRequest(), CancellationToken.None)); Assert.Equal("canvas_action_no_handler", ex.Code); } @@ -154,7 +155,7 @@ public void ResumeSessionConfig_Clone_CopiesCanvasFields() private sealed class TestHandler : CanvasHandlerBase { - public override Task OnOpenAsync(CanvasOpenContext context, CancellationToken cancellationToken) - => Task.FromResult(new CanvasOpenResponse { Url = "https://example.com" }); + public override Task OnOpenAsync(CanvasProviderOpenRequest context, CancellationToken cancellationToken) + => Task.FromResult(new CanvasProviderOpenResult { Url = "https://example.com" }); } } diff --git a/go/canvas.go b/go/canvas.go index 2d122db29..49216c3d9 100644 --- a/go/canvas.go +++ b/go/canvas.go @@ -2,7 +2,7 @@ // // 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). +// implementor's responsibility (typically by switching on CanvasProviderOpenRequest.CanvasID). package copilot @@ -27,74 +27,13 @@ type CanvasDeclaration struct { 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 +// 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 extension identifier within that source, e.g. "my-app". + Name string `json:"name"` } // CanvasError is a structured error returned from canvas handlers. @@ -131,8 +70,8 @@ func CanvasErrorNoHandler() *CanvasError { // // 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 +// `canvas.invokeAction` JSON-RPC request the runtime issues for this session +// and decides — typically by inspecting CanvasProviderOpenRequest.CanvasID — which // application-side canvas should handle the call. // // The SDK does not maintain a per-canvas registry; multiplexing across declared @@ -141,9 +80,9 @@ func CanvasErrorNoHandler() *CanvasError { // 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) + OnOpen(ctx context.Context, c rpc.CanvasProviderOpenRequest) (rpc.CanvasProviderOpenResult, error) + OnClose(ctx context.Context, c rpc.CanvasProviderCloseRequest) error + OnAction(ctx context.Context, c rpc.CanvasProviderInvokeActionRequest) (any, error) } // CanvasHandlerDefaults supplies default OnClose / OnAction implementations @@ -154,79 +93,15 @@ type CanvasHandler interface { // type myHandler struct { // copilot.CanvasHandlerDefaults // } -// func (h *myHandler) OnOpen(ctx context.Context, c copilot.CanvasOpenContext) (copilot.CanvasOpenResponse, error) { ... } +// func (h *myHandler) OnOpen(ctx context.Context, c rpc.CanvasProviderOpenRequest) (rpc.CanvasProviderOpenResult, error) { ... } type CanvasHandlerDefaults struct{} // OnClose returns nil by default. -func (CanvasHandlerDefaults) OnClose(ctx context.Context, c CanvasLifecycleContext) error { +func (CanvasHandlerDefaults) OnClose(ctx context.Context, c rpc.CanvasProviderCloseRequest) error { return nil } // OnAction returns CanvasErrorNoHandler() by default. -func (CanvasHandlerDefaults) OnAction(ctx context.Context, c CanvasActionContext) (any, error) { +func (CanvasHandlerDefaults) OnAction(ctx context.Context, c rpc.CanvasProviderInvokeActionRequest) (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 index be0538d58..34d4a160d 100644 --- a/go/canvas_test.go +++ b/go/canvas_test.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "errors" + "io" "testing" "github.com/github/copilot-sdk/go/internal/jsonrpc2" @@ -68,7 +69,7 @@ func TestCanvasDeclaration_OmitsEmptyActions(t *testing.T) { func TestCanvasHandlerDefaults_OnAction_ReturnsNoHandler(t *testing.T) { d := CanvasHandlerDefaults{} - _, err := d.OnAction(context.Background(), CanvasActionContext{}) + _, err := d.OnAction(context.Background(), rpc.CanvasProviderInvokeActionRequest{}) if err == nil { t.Fatalf("expected error from default OnAction") } @@ -83,7 +84,7 @@ func TestCanvasHandlerDefaults_OnAction_ReturnsNoHandler(t *testing.T) { func TestCanvasHandlerDefaults_OnClose_ReturnsNil(t *testing.T) { d := CanvasHandlerDefaults{} - if err := d.OnClose(context.Background(), CanvasLifecycleContext{}); err != nil { + if err := d.OnClose(context.Background(), rpc.CanvasProviderCloseRequest{}); err != nil { t.Fatalf("expected nil from default OnClose, got %v", err) } } @@ -95,143 +96,215 @@ func TestCanvasError_ErrorString(t *testing.T) { } } -// recordingCanvasHandler captures calls for assertion. type recordingCanvasHandler struct { CanvasHandlerDefaults - openCtx *CanvasOpenContext - openResult CanvasOpenResponse - openErr error + openCtx *rpc.CanvasProviderOpenRequest + closeCtx *rpc.CanvasProviderCloseRequest + actionCtx *rpc.CanvasProviderInvokeActionRequest + openResult rpc.CanvasProviderOpenResult + actionResult any + openErr error + closeErr error + actionErr error } -func (h *recordingCanvasHandler) OnOpen(ctx context.Context, c CanvasOpenContext) (CanvasOpenResponse, error) { +func (h *recordingCanvasHandler) OnOpen(ctx context.Context, c rpc.CanvasProviderOpenRequest) (rpc.CanvasProviderOpenResult, error) { h.openCtx = &c return h.openResult, h.openErr } -func TestClient_HandleCanvasOpen_DispatchesToHandler(t *testing.T) { +func (h *recordingCanvasHandler) OnClose(ctx context.Context, c rpc.CanvasProviderCloseRequest) error { + h.closeCtx = &c + return h.closeErr +} + +func (h *recordingCanvasHandler) OnAction(ctx context.Context, c rpc.CanvasProviderInvokeActionRequest) (any, error) { + h.actionCtx = &c + return h.actionResult, h.actionErr +} + +func TestCanvasAdapter_DispatchesToHandler(t *testing.T) { title := "Echo" url := "https://example.test/echo" handler := &recordingCanvasHandler{ - openResult: CanvasOpenResponse{URL: &url, Title: &title}, + openResult: rpc.CanvasProviderOpenResult{URL: &url, Title: &title}, + actionResult: map[string]any{ + "count": float64(2), + }, } - session := &Session{SessionID: "s1"} + session := newTestCanvasSession("s1") session.registerCanvasHandler(handler) - c := &Client{sessions: map[string]*Session{"s1": session}} - - params := canvasProviderRequestParams{ + openResp, err := session.clientSessionApis.Canvas.Open(&rpc.CanvasProviderOpenRequest{ 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 err != nil { + t.Fatalf("unexpected open error: %v", err) } 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) + t.Fatalf("unexpected open ctx: %+v", handler.openCtx) } - if resp.URL == nil || *resp.URL != url { - t.Fatalf("response URL not propagated: %+v", resp) + if openResp.URL == nil || *openResp.URL != url { + t.Fatalf("response URL not propagated: %+v", openResp) } -} -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") + actionResp, err := session.clientSessionApis.Canvas.InvokeAction(&rpc.CanvasProviderInvokeActionRequest{ + SessionID: "s1", + ExtensionID: "project:echo", + CanvasID: "echo", + InstanceID: "echo-1", + ActionName: "increment", + Input: map[string]any{"amount": float64(1)}, + }) + if err != nil { + t.Fatalf("unexpected action error: %v", err) } - if rpcErr.Code != -32603 { - t.Fatalf("expected internal-error code, got %d", rpcErr.Code) + if handler.actionCtx == nil { + t.Fatalf("handler.OnAction was not called") } - var data map[string]string - if err := json.Unmarshal(rpcErr.Data, &data); err != nil { - t.Fatalf("invalid error data: %v", err) + if handler.actionCtx.ActionName != "increment" { + t.Fatalf("unexpected action ctx: %+v", handler.actionCtx) } - if data["code"] != "canvas_handler_unset" { - t.Fatalf("expected code=canvas_handler_unset, got %q", data["code"]) + result, ok := actionResp.(map[string]any) + if !ok || result["count"] != float64(2) { + t.Fatalf("unexpected action result: %#v", actionResp) } -} -func TestClient_HandleCanvasOpen_HandlerCanvasError_Wired(t *testing.T) { - handler := &recordingCanvasHandler{ - openErr: NewCanvasError("permission_denied", "nope"), + closeResp, err := session.clientSessionApis.Canvas.Close(&rpc.CanvasProviderCloseRequest{ + SessionID: "s1", + ExtensionID: "project:echo", + CanvasID: "echo", + InstanceID: "echo-1", + }) + if err != nil { + t.Fatalf("unexpected close error: %v", err) } - 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") + if closeResp != nil { + t.Fatal("expected nil close response") } - var data map[string]string - _ = json.Unmarshal(rpcErr.Data, &data) - if data["code"] != "permission_denied" { - t.Fatalf("expected propagated code, got %q", data["code"]) + if handler.closeCtx == nil || handler.closeCtx.CanvasID != "echo" { + t.Fatalf("unexpected close ctx: %+v", handler.closeCtx) } } -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}} +func TestCanvasAdapter_NoHandler_ReturnsUnsetError(t *testing.T) { + session := newTestCanvasSession("s1") - _, 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"]) - } + _, err := session.clientSessionApis.Canvas.Open(&rpc.CanvasProviderOpenRequest{SessionID: "s1"}) + assertCanvasJSONRPCError(t, err, "canvas_handler_unset", "") } -// 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}} +func TestCanvasAdapter_HandlerCanvasError_Wired(t *testing.T) { + session := newTestCanvasSession("s1") + session.registerCanvasHandler(&recordingCanvasHandler{ + openErr: NewCanvasError("permission_denied", "nope"), + }) + + _, err := session.clientSessionApis.Canvas.Open(&rpc.CanvasProviderOpenRequest{SessionID: "s1"}) + assertCanvasJSONRPCError(t, err, "permission_denied", "nope") +} + +func TestCanvasAdapter_HandlerGenericError_WrappedAsCanvasHandlerError(t *testing.T) { + session := newTestCanvasSession("s1") + session.registerCanvasHandler(&recordingCanvasHandler{ + openErr: errors.New("boom"), + }) + + _, err := session.clientSessionApis.Canvas.Open(&rpc.CanvasProviderOpenRequest{SessionID: "s1"}) + assertCanvasJSONRPCError(t, err, "canvas_handler_error", "boom") +} - 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) +func TestCanvasRegisterClientSessionApiHandlers_RawJSONRoundTrip(t *testing.T) { + clientToServerReader, clientToServerWriter := io.Pipe() + serverToClientReader, serverToClientWriter := io.Pipe() + + requester := jsonrpc2.NewClient(clientToServerWriter, serverToClientReader) + server := jsonrpc2.NewClient(serverToClientWriter, clientToServerReader) + session := newTestCanvasSession("s1") + session.registerCanvasHandler(&recordingCanvasHandler{ + openResult: rpc.CanvasProviderOpenResult{Status: strPtr("ready")}, + actionResult: map[string]any{"count": float64(2)}, + }) + rpc.RegisterClientSessionApiHandlers(server, func(sessionID string) *rpc.ClientSessionApiHandlers { + if sessionID == "s1" { + return session.clientSessionApis + } + return nil + }) + + requester.Start() + server.Start() + t.Cleanup(func() { + requester.Stop() + server.Stop() + _ = clientToServerWriter.Close() + _ = clientToServerReader.Close() + _ = serverToClientWriter.Close() + _ = serverToClientReader.Close() + }) + + raw, err := requester.Request("canvas.open", map[string]any{ + "sessionId": "s1", + "extensionId": "ext", + "canvasId": "echo", + "instanceId": "i1", + "input": map[string]any{"k": "v"}, + "host": map[string]any{ + "capabilities": map[string]any{ + "canvases": true, + }, + }, + }) + if err != nil { + t.Fatalf("unexpected rpc error: %v", err) } + + handler := session.getCanvasHandler().(*recordingCanvasHandler) if handler.openCtx == nil { t.Fatalf("handler not invoked") } - if handler.openCtx.Host == nil || !handler.openCtx.Host.Capabilities.Canvases { + if handler.openCtx.Host == nil || handler.openCtx.Host.Capabilities == nil || + handler.openCtx.Host.Capabilities.Canvases == 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 { + if err := json.Unmarshal(raw, &decoded); err != nil { t.Fatalf("bad output JSON: %v", err) } if decoded["status"] != "ready" { t.Fatalf("expected status=ready, got %v", decoded["status"]) } + + actionRaw, err := requester.Request("canvas.invokeAction", map[string]any{ + "sessionId": "s1", + "extensionId": "ext", + "canvasId": "echo", + "instanceId": "i1", + "actionName": "increment", + "input": map[string]any{"amount": float64(2)}, + }) + if err != nil { + t.Fatalf("unexpected action rpc error: %v", err) + } + var actionDecoded map[string]any + if err := json.Unmarshal(actionRaw, &actionDecoded); err != nil { + t.Fatalf("bad action output JSON: %v", err) + } + if actionDecoded["count"] != float64(2) { + t.Fatalf("expected raw provider result, got %v", actionDecoded) + } } -func TestResumeSessionResponse_OpenCanvasesParse(t *testing.T) { +func TestCanvasResumeSessionResponse_OpenCanvasesParse(t *testing.T) { raw := []byte(`{ "sessionId": "s1", "workspacePath": "/tmp/ws", @@ -265,7 +338,7 @@ func TestResumeSessionResponse_OpenCanvasesParse(t *testing.T) { } } -func TestResumeSessionRequest_OpenCanvasesWireShape(t *testing.T) { +func TestCanvasResumeSessionRequest_OpenCanvasesWireShape(t *testing.T) { req := resumeSessionRequest{ SessionID: "s1", OpenCanvases: []rpc.OpenCanvasInstance{ @@ -301,7 +374,6 @@ func TestResumeSessionRequest_OpenCanvasesWireShape(t *testing.T) { 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 { @@ -316,4 +388,39 @@ func TestResumeSessionRequest_OpenCanvasesWireShape(t *testing.T) { } } +func assertCanvasJSONRPCError(t *testing.T, err error, wantCode, wantMessage string) { + t.Helper() + + if err == nil { + t.Fatal("expected error") + } + rpcErr, ok := err.(*jsonrpc2.Error) + if !ok { + t.Fatalf("expected *jsonrpc2.Error, got %T", err) + } + 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"] != wantCode { + t.Fatalf("expected code=%s, got %q", wantCode, data["code"]) + } + if wantMessage != "" && data["message"] != wantMessage { + t.Fatalf("expected message=%q, got %q", wantMessage, data["message"]) + } +} + +func newTestCanvasSession(sessionID string) *Session { + session := &Session{ + SessionID: sessionID, + clientSessionApis: &rpc.ClientSessionApiHandlers{}, + } + session.clientSessionApis.Canvas = newCanvasClientSessionAdapter(session) + return session +} + func strPtr(s string) *string { return &s } diff --git a/go/client.go b/go/client.go index 6e7557b0b..b6da53461 100644 --- a/go/client.go +++ b/go/client.go @@ -629,7 +629,6 @@ func (c *Client) CreateSession(ctx context.Context, config *SessionConfig) (*Ses 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)) @@ -852,7 +851,6 @@ func (c *Client) ResumeSessionWithOptions(ctx context.Context, sessionID string, req.OpenCanvases = config.OpenCanvases req.RequestCanvasRenderer = config.RequestCanvasRenderer req.RequestExtensions = config.RequestExtensions - req.ExtensionInfo = config.ExtensionInfo if config.OnPermissionRequest != nil { req.RequestPermission = Bool(true) } @@ -1762,9 +1760,6 @@ 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() @@ -1913,89 +1908,3 @@ 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/internal/e2e/canvas_e2e_test.go b/go/internal/e2e/canvas_e2e_test.go new file mode 100644 index 000000000..7f7b71544 --- /dev/null +++ b/go/internal/e2e/canvas_e2e_test.go @@ -0,0 +1,225 @@ +package e2e + +import ( + "context" + "sync" + "testing" + + copilot "github.com/github/copilot-sdk/go" + "github.com/github/copilot-sdk/go/internal/e2e/testharness" + "github.com/github/copilot-sdk/go/rpc" +) + +func TestCanvasE2E(t *testing.T) { + ctx := testharness.NewTestContext(t) + client := ctx.NewClient() + t.Cleanup(func() { client.ForceStop() }) + + handler := &testCanvasHandler{} + canvasDecl := copilot.CanvasDeclaration{ + ID: "counter", + DisplayName: "Counter", + Description: "A simple counter canvas for e2e testing", + InputSchema: map[string]any{ + "type": "object", + "properties": map[string]any{ + "startValue": map[string]any{"type": "number"}, + }, + }, + Actions: []rpc.CanvasAction{{ + Name: "increment", + Description: copilot.String("Increment the counter"), + InputSchema: map[string]any{ + "type": "object", + "properties": map[string]any{ + "amount": map[string]any{"type": "number"}, + }, + }, + }}, + } + + session, err := client.CreateSession(t.Context(), &copilot.SessionConfig{ + OnPermissionRequest: copilot.PermissionHandler.ApproveAll, + Canvases: []copilot.CanvasDeclaration{canvasDecl}, + CanvasHandler: handler, + }) + if err != nil { + t.Fatalf("Failed to create session: %v", err) + } + + listResult, err := session.RPC.Canvas.List(t.Context()) + if err != nil { + t.Fatalf("Canvas.List failed: %v", err) + } + if len(listResult.Canvases) != 1 { + t.Fatalf("expected 1 canvas, got %d", len(listResult.Canvases)) + } + if listResult.Canvases[0].CanvasID != "counter" { + t.Fatalf("expected canvasId=counter, got %q", listResult.Canvases[0].CanvasID) + } + + openResult, err := session.RPC.Canvas.Open(t.Context(), &rpc.CanvasOpenRequest{ + CanvasID: "counter", + InstanceID: "counter-1", + Input: map[string]any{ + "startValue": float64(3), + }, + }) + if err != nil { + t.Fatalf("Canvas.Open failed: %v", err) + } + if openResult.CanvasID != "counter" || openResult.InstanceID != "counter-1" { + t.Fatalf("unexpected open result: %+v", openResult) + } + if openResult.URL == nil || *openResult.URL != "https://example.test/counter/counter-1" { + t.Fatalf("unexpected open URL: %+v", openResult.URL) + } + if calls := handler.OpenCalls(); len(calls) != 1 || calls[0].CanvasID != "counter" || calls[0].InstanceID != "counter-1" { + t.Fatalf("unexpected open calls: %+v", calls) + } + + actionResult, err := session.RPC.Canvas.InvokeAction(t.Context(), &rpc.CanvasInvokeActionRequest{ + InstanceID: "counter-1", + ActionName: "increment", + Input: map[string]any{ + "amount": float64(2), + }, + }) + if err != nil { + t.Fatalf("Canvas.InvokeAction failed: %v", err) + } + actionPayload, ok := actionResult.Result.(map[string]any) + if !ok || actionPayload["count"] != float64(5) { + t.Fatalf("unexpected action result: %#v", actionResult.Result) + } + if calls := handler.ActionCalls(); len(calls) != 1 || calls[0].ActionName != "increment" { + t.Fatalf("unexpected action calls: %+v", calls) + } + + closeResult, err := session.RPC.Canvas.Close(t.Context(), &rpc.CanvasCloseRequest{ + InstanceID: "counter-1", + }) + if err != nil { + t.Fatalf("Canvas.Close failed: %v", err) + } + if closeResult == nil { + t.Fatal("expected non-nil close result") + } + if calls := handler.CloseCalls(); len(calls) != 1 || calls[0].CanvasID != "counter" || calls[0].InstanceID != "counter-1" { + t.Fatalf("unexpected close calls: %+v", calls) + } +} + +type testCanvasHandler struct { + copilot.CanvasHandlerDefaults + + mu sync.Mutex + openCalls []canvasOpenCall + closeCalls []canvasCloseCall + actionCalls []canvasActionCall + counts map[string]float64 +} + +type canvasOpenCall struct { + CanvasID string + InstanceID string + Input any +} + +type canvasCloseCall struct { + CanvasID string + InstanceID string +} + +type canvasActionCall struct { + CanvasID string + InstanceID string + ActionName string + Input any +} + +func (h *testCanvasHandler) OnOpen(ctx context.Context, req rpc.CanvasProviderOpenRequest) (rpc.CanvasProviderOpenResult, error) { + h.mu.Lock() + defer h.mu.Unlock() + + if h.counts == nil { + h.counts = make(map[string]float64) + } + h.openCalls = append(h.openCalls, canvasOpenCall{ + CanvasID: req.CanvasID, + InstanceID: req.InstanceID, + Input: req.Input, + }) + h.counts[req.InstanceID] = numberField(req.Input, "startValue") + + return rpc.CanvasProviderOpenResult{ + URL: copilot.String("https://example.test/counter/" + req.InstanceID), + Title: copilot.String("Counter"), + Status: copilot.String("ready"), + }, nil +} + +func (h *testCanvasHandler) OnClose(ctx context.Context, req rpc.CanvasProviderCloseRequest) error { + h.mu.Lock() + defer h.mu.Unlock() + + h.closeCalls = append(h.closeCalls, canvasCloseCall{ + CanvasID: req.CanvasID, + InstanceID: req.InstanceID, + }) + delete(h.counts, req.InstanceID) + return nil +} + +func (h *testCanvasHandler) OnAction(ctx context.Context, req rpc.CanvasProviderInvokeActionRequest) (any, error) { + h.mu.Lock() + defer h.mu.Unlock() + + if h.counts == nil { + h.counts = make(map[string]float64) + } + h.actionCalls = append(h.actionCalls, canvasActionCall{ + CanvasID: req.CanvasID, + InstanceID: req.InstanceID, + ActionName: req.ActionName, + Input: req.Input, + }) + h.counts[req.InstanceID] += numberField(req.Input, "amount") + return map[string]any{"count": h.counts[req.InstanceID]}, nil +} + +func (h *testCanvasHandler) OpenCalls() []canvasOpenCall { + h.mu.Lock() + defer h.mu.Unlock() + out := make([]canvasOpenCall, len(h.openCalls)) + copy(out, h.openCalls) + return out +} + +func (h *testCanvasHandler) CloseCalls() []canvasCloseCall { + h.mu.Lock() + defer h.mu.Unlock() + out := make([]canvasCloseCall, len(h.closeCalls)) + copy(out, h.closeCalls) + return out +} + +func (h *testCanvasHandler) ActionCalls() []canvasActionCall { + h.mu.Lock() + defer h.mu.Unlock() + out := make([]canvasActionCall, len(h.actionCalls)) + copy(out, h.actionCalls) + return out +} + +func numberField(value any, key string) float64 { + m, ok := value.(map[string]any) + if !ok { + return 0 + } + n, ok := m[key].(float64) + if !ok { + return 0 + } + return n +} diff --git a/go/rpc/zrpc.go b/go/rpc/zrpc.go index 0b947df35..9ba283cfd 100644 --- a/go/rpc/zrpc.go +++ b/go/rpc/zrpc.go @@ -305,6 +305,27 @@ type CanvasCloseRequest struct { InstanceID string `json:"instanceId"` } +// Experimental: CanvasCloseResult is part of an experimental API and may change or be +// removed. +type CanvasCloseResult struct { +} + +// Host context supplied by the runtime. +// Experimental: CanvasHostContext is part of an experimental API and may change or be +// removed. +type CanvasHostContext struct { + // Host capabilities + Capabilities *CanvasHostContextCapabilities `json:"capabilities,omitempty"` +} + +// Host capabilities +// Experimental: CanvasHostContextCapabilities is part of an experimental API and may change +// or be removed. +type CanvasHostContextCapabilities struct { + // Whether canvas rendering is supported + Canvases *bool `json:"canvases,omitempty"` +} + // Canvas action invocation parameters. // Experimental: CanvasInvokeActionRequest is part of an experimental API and may change or // be removed. @@ -360,6 +381,72 @@ type CanvasOpenRequest struct { InstanceID string `json:"instanceId"` } +// Canvas close parameters sent to the provider. +// Experimental: CanvasProviderCloseRequest is part of an experimental API and may change or +// be removed. +type CanvasProviderCloseRequest struct { + // Provider-local canvas identifier + CanvasID string `json:"canvasId"` + // Owning provider identifier + ExtensionID string `json:"extensionId"` + // Host context supplied by the runtime. + Host *CanvasHostContext `json:"host,omitempty"` + // Canvas instance identifier + InstanceID string `json:"instanceId"` + // Target session identifier + SessionID string `json:"sessionId"` +} + +// Canvas action invocation parameters sent to the provider. +// Experimental: CanvasProviderInvokeActionRequest is part of an experimental API and may +// change or be removed. +type CanvasProviderInvokeActionRequest struct { + // Action name to invoke + ActionName string `json:"actionName"` + // Provider-local canvas identifier + CanvasID string `json:"canvasId"` + // Owning provider identifier + ExtensionID string `json:"extensionId"` + // Host context supplied by the runtime. + Host *CanvasHostContext `json:"host,omitempty"` + // Action input + Input any `json:"input,omitempty"` + // Canvas instance identifier + InstanceID string `json:"instanceId"` + // Target session identifier + SessionID string `json:"sessionId"` +} + +// Canvas open parameters sent to the provider. +// Experimental: CanvasProviderOpenRequest is part of an experimental API and may change or +// be removed. +type CanvasProviderOpenRequest struct { + // Provider-local canvas identifier + CanvasID string `json:"canvasId"` + // Owning provider identifier + ExtensionID string `json:"extensionId"` + // Host context supplied by the runtime. + Host *CanvasHostContext `json:"host,omitempty"` + // Canvas open input + Input any `json:"input,omitempty"` + // Stable caller-supplied canvas instance identifier + InstanceID string `json:"instanceId"` + // Target session identifier + SessionID string `json:"sessionId"` +} + +// Canvas open result returned by the provider. +// Experimental: CanvasProviderOpenResult is part of an experimental API and may change or +// be removed. +type CanvasProviderOpenResult struct { + // Provider-supplied status text + Status *string `json:"status,omitempty"` + // Provider-supplied title + Title *string `json:"title,omitempty"` + // URL for web-rendered canvases + URL *string `json:"url,omitempty"` +} + // Slash commands available in the session, after applying any include/exclude filters. // Experimental: CommandList is part of an experimental API and may change or be removed. type CommandList struct { @@ -1735,6 +1822,8 @@ type McpServerConfigHTTP struct { OauthGrantType *McpServerConfigHTTPOauthGrantType `json:"oauthGrantType,omitempty"` // Whether the configured OAuth client is public and does not require a client secret. OauthPublicClient *bool `json:"oauthPublicClient,omitempty"` + // OIDC token configuration. When truthy, a token is automatically gathered. + Oidc McpServerConfigHTTPOidc `json:"oidc,omitempty"` // Timeout in milliseconds for tool calls to this server. Timeout *int64 `json:"timeout,omitempty"` // Tools to include. Defaults to all tools if not specified. @@ -1751,6 +1840,8 @@ func (McpServerConfigHTTP) mcpServerConfig() {} type McpServerConfigStdio struct { // Command-line arguments passed to the Stdio MCP server process. Args []string `json:"args,omitempty"` + // Authentication configuration for this server. + Auth McpServerConfigStdioAuth `json:"auth,omitempty"` // Executable command used to start the Stdio MCP server process. Command string `json:"command"` // Working directory for the Stdio MCP server process. @@ -1763,6 +1854,8 @@ type McpServerConfigStdio struct { // Whether this server is a built-in fallback used when the user has not configured their // own server. IsDefaultServer *bool `json:"isDefaultServer,omitempty"` + // OIDC token configuration. When truthy, a token is automatically gathered. + Oidc McpServerConfigStdioOidc `json:"oidc,omitempty"` // Timeout in milliseconds for tool calls to this server. Timeout *int64 `json:"timeout,omitempty"` // Tools to include. Defaults to all tools if not specified. @@ -1777,6 +1870,45 @@ type McpServerConfigHTTPAuth struct { RedirectPort *int32 `json:"redirectPort,omitempty"` } +// OIDC token configuration. When truthy, a token is automatically gathered. +type McpServerConfigHTTPOidc interface { + mcpServerConfigHTTPOidc() +} + +type McpServerConfigHTTPOidcAnyMap map[string]any + +func (McpServerConfigHTTPOidcAnyMap) mcpServerConfigHTTPOidc() {} + +type McpServerConfigHTTPOidcBoolean bool + +func (McpServerConfigHTTPOidcBoolean) mcpServerConfigHTTPOidc() {} + +// Authentication configuration for this server. +type McpServerConfigStdioAuth interface { + mcpServerConfigStdioAuth() +} + +type McpServerConfigStdioAuthAnyMap map[string]any + +func (McpServerConfigStdioAuthAnyMap) mcpServerConfigStdioAuth() {} + +type McpServerConfigStdioAuthBoolean bool + +func (McpServerConfigStdioAuthBoolean) mcpServerConfigStdioAuth() {} + +// OIDC token configuration. When truthy, a token is automatically gathered. +type McpServerConfigStdioOidc interface { + mcpServerConfigStdioOidc() +} + +type McpServerConfigStdioOidcAnyMap map[string]any + +func (McpServerConfigStdioOidcAnyMap) mcpServerConfigStdioOidc() {} + +type McpServerConfigStdioOidcBoolean bool + +func (McpServerConfigStdioOidcBoolean) mcpServerConfigStdioOidc() {} + // MCP servers configured for the session, with their connection status. // Experimental: McpServerList is part of an experimental API and may change or be removed. type McpServerList struct { @@ -3833,11 +3965,13 @@ type SessionContextInfo struct { TotalTokens int64 `json:"totalTokens"` } -// The same metadata records, with summary and context fields backfilled where available. +// The enriched metadata records, with summary and context fields backfilled where +// available. Sessions confirmed empty and unnamed are omitted. // Experimental: SessionEnrichMetadataResult is part of an experimental API and may change // or be removed. type SessionEnrichMetadataResult struct { - // Same records, with summary and context backfilled + // Enriched records, with summary and context backfilled. Sessions confirmed empty and + // unnamed may be omitted. Sessions []SessionMetadata `json:"sessions"` } @@ -3858,6 +3992,8 @@ type SessionExtensionsReloadResult struct { // File path, content to append, and optional mode for the client-provided session // filesystem. +// Experimental: SessionFsAppendFileRequest is part of an experimental API and may change or +// be removed. type SessionFsAppendFileRequest struct { // Content to append Content string `json:"content"` @@ -3870,6 +4006,7 @@ type SessionFsAppendFileRequest struct { } // Describes a filesystem error. +// Experimental: SessionFsError is part of an experimental API and may change or be removed. type SessionFsError struct { // Error classification Code SessionFsErrorCode `json:"code"` @@ -3878,6 +4015,8 @@ type SessionFsError struct { } // Path to test for existence in the client-provided session filesystem. +// Experimental: SessionFsExistsRequest is part of an experimental API and may change or be +// removed. type SessionFsExistsRequest struct { // Path using SessionFs conventions Path string `json:"path"` @@ -3886,6 +4025,8 @@ type SessionFsExistsRequest struct { } // Indicates whether the requested path exists in the client-provided session filesystem. +// Experimental: SessionFsExistsResult is part of an experimental API and may change or be +// removed. type SessionFsExistsResult struct { // Whether the path exists Exists bool `json:"exists"` @@ -3893,6 +4034,8 @@ type SessionFsExistsResult struct { // Directory path to create in the client-provided session filesystem, with options for // recursive creation and POSIX mode. +// Experimental: SessionFsMkdirRequest is part of an experimental API and may change or be +// removed. type SessionFsMkdirRequest struct { // Optional POSIX-style mode for newly created directories Mode *int64 `json:"mode,omitempty"` @@ -3905,6 +4048,8 @@ type SessionFsMkdirRequest struct { } // Directory path whose entries should be listed from the client-provided session filesystem. +// Experimental: SessionFsReaddirRequest is part of an experimental API and may change or be +// removed. type SessionFsReaddirRequest struct { // Path using SessionFs conventions Path string `json:"path"` @@ -3913,6 +4058,8 @@ type SessionFsReaddirRequest struct { } // Names of entries in the requested directory, or a filesystem error if the read failed. +// Experimental: SessionFsReaddirResult is part of an experimental API and may change or be +// removed. type SessionFsReaddirResult struct { // Entry names in the directory Entries []string `json:"entries"` @@ -3921,6 +4068,8 @@ type SessionFsReaddirResult struct { } // Schema for the `SessionFsReaddirWithTypesEntry` type. +// Experimental: SessionFsReaddirWithTypesEntry is part of an experimental API and may +// change or be removed. type SessionFsReaddirWithTypesEntry struct { // Entry name Name string `json:"name"` @@ -3930,6 +4079,8 @@ type SessionFsReaddirWithTypesEntry struct { // Directory path whose entries (with type information) should be listed from the // client-provided session filesystem. +// Experimental: SessionFsReaddirWithTypesRequest is part of an experimental API and may +// change or be removed. type SessionFsReaddirWithTypesRequest struct { // Path using SessionFs conventions Path string `json:"path"` @@ -3939,6 +4090,8 @@ type SessionFsReaddirWithTypesRequest struct { // Entries in the requested directory paired with file/directory type information, or a // filesystem error if the read failed. +// Experimental: SessionFsReaddirWithTypesResult is part of an experimental API and may +// change or be removed. type SessionFsReaddirWithTypesResult struct { // Directory entries with type information Entries []SessionFsReaddirWithTypesEntry `json:"entries"` @@ -3947,6 +4100,8 @@ type SessionFsReaddirWithTypesResult struct { } // Path of the file to read from the client-provided session filesystem. +// Experimental: SessionFsReadFileRequest is part of an experimental API and may change or +// be removed. type SessionFsReadFileRequest struct { // Path using SessionFs conventions Path string `json:"path"` @@ -3955,6 +4110,8 @@ type SessionFsReadFileRequest struct { } // File content as a UTF-8 string, or a filesystem error if the read failed. +// Experimental: SessionFsReadFileResult is part of an experimental API and may change or be +// removed. type SessionFsReadFileResult struct { // File content as UTF-8 string Content string `json:"content"` @@ -3964,6 +4121,8 @@ type SessionFsReadFileResult struct { // Source and destination paths for renaming or moving an entry in the client-provided // session filesystem. +// Experimental: SessionFsRenameRequest is part of an experimental API and may change or be +// removed. type SessionFsRenameRequest struct { // Destination path using SessionFs conventions Dest string `json:"dest"` @@ -3975,6 +4134,8 @@ type SessionFsRenameRequest struct { // Path to remove from the client-provided session filesystem, with options for recursive // removal and force. +// Experimental: SessionFsRmRequest is part of an experimental API and may change or be +// removed. type SessionFsRmRequest struct { // Ignore errors if the path does not exist Force *bool `json:"force,omitempty"` @@ -4012,12 +4173,16 @@ type SessionFsSetProviderResult struct { } // Identifies the target session. +// Experimental: SessionFsSqliteExistsRequest is part of an experimental API and may change +// or be removed. type SessionFsSqliteExistsRequest struct { // Target session identifier SessionID string `json:"sessionId"` } // Indicates whether the per-session SQLite database already exists. +// Experimental: SessionFsSqliteExistsResult is part of an experimental API and may change +// or be removed. type SessionFsSqliteExistsResult struct { // Whether the session database already exists Exists bool `json:"exists"` @@ -4025,6 +4190,8 @@ type SessionFsSqliteExistsResult struct { // SQL query, query type, and optional bind parameters for executing a SQLite query against // the per-session database. +// Experimental: SessionFsSqliteQueryRequest is part of an experimental API and may change +// or be removed. type SessionFsSqliteQueryRequest struct { // Optional named bind parameters Params map[string]any `json:"params,omitempty"` @@ -4039,6 +4206,8 @@ type SessionFsSqliteQueryRequest struct { // Query results including rows, columns, and rows affected, or a filesystem error if // execution failed. +// Experimental: SessionFsSqliteQueryResult is part of an experimental API and may change or +// be removed. type SessionFsSqliteQueryResult struct { // Column names from the result set Columns []string `json:"columns"` @@ -4053,6 +4222,8 @@ type SessionFsSqliteQueryResult struct { } // Path whose metadata should be returned from the client-provided session filesystem. +// Experimental: SessionFsStatRequest is part of an experimental API and may change or be +// removed. type SessionFsStatRequest struct { // Path using SessionFs conventions Path string `json:"path"` @@ -4061,6 +4232,8 @@ type SessionFsStatRequest struct { } // Filesystem metadata for the requested path, or a filesystem error if the stat failed. +// Experimental: SessionFsStatResult is part of an experimental API and may change or be +// removed. type SessionFsStatResult struct { // ISO 8601 timestamp of creation Birthtime time.Time `json:"birthtime"` @@ -4077,6 +4250,8 @@ type SessionFsStatResult struct { } // File path, content to write, and optional mode for the client-provided session filesystem. +// Experimental: SessionFsWriteFileRequest is part of an experimental API and may change or +// be removed. type SessionFsWriteFileRequest struct { // Content to write Content string `json:"content"` @@ -6992,6 +7167,8 @@ const ( ) // Error classification +// Experimental: SessionFsErrorCode is part of an experimental API and may change or be +// removed. type SessionFsErrorCode string const ( @@ -7002,6 +7179,8 @@ const ( ) // Entry type +// Experimental: SessionFsReaddirWithTypesEntryType is part of an experimental API and may +// change or be removed. type SessionFsReaddirWithTypesEntryType string const ( @@ -7023,6 +7202,8 @@ const ( // How to execute the query: 'exec' for DDL/multi-statement (no results), 'query' for SELECT // (returns rows), 'run' for INSERT/UPDATE/DELETE (returns rowsAffected) +// Experimental: SessionFsSqliteQueryType is part of an experimental API and may change or +// be removed. type SessionFsSqliteQueryType string const ( @@ -7710,8 +7891,8 @@ func (a *ServerSessionsApi) Connect(ctx context.Context, params *ConnectRemoteSe // // Parameters: Session metadata records to enrich with summary and context information. // -// Returns: The same metadata records, with summary and context fields backfilled where -// available. +// Returns: The enriched metadata records, with summary and context fields backfilled where +// available. Sessions confirmed empty and unnamed are omitted. func (a *ServerSessionsApi) EnrichMetadata(ctx context.Context, params *SessionsEnrichMetadataRequest) (*SessionEnrichMetadataResult, error) { raw, err := a.client.Request("sessions.enrichMetadata", params) if err != nil { @@ -11749,6 +11930,33 @@ func NewSessionRpc(client *jsonrpc2.Client, sessionID string) *SessionRpc { return r } +// Experimental: CanvasHandler contains experimental APIs that may change or be removed. +type CanvasHandler interface { + // Closes a canvas instance on the provider. + // + // RPC method: canvas.close. + // + // Parameters: Canvas close parameters sent to the provider. + Close(request *CanvasProviderCloseRequest) (*CanvasCloseResult, error) + // InvokeAction invokes an action on an open canvas instance via the provider. + // + // RPC method: canvas.invokeAction. + // + // Parameters: Canvas action invocation parameters sent to the provider. + // + // Returns: Provider-supplied action result. + InvokeAction(request *CanvasProviderInvokeActionRequest) (any, error) + // Opens a canvas instance on the provider. + // + // RPC method: canvas.open. + // + // Parameters: Canvas open parameters sent to the provider. + // + // Returns: Canvas open result returned by the provider. + Open(request *CanvasProviderOpenRequest) (*CanvasProviderOpenResult, error) +} + +// Experimental: SessionFsHandler contains experimental APIs that may change or be removed. type SessionFsHandler interface { // AppendFile appends content to a file in the client-provided session filesystem. // @@ -11866,6 +12074,7 @@ type SessionFsHandler interface { // ClientSessionApiHandlers provides all client session API handler groups for a session. type ClientSessionApiHandlers struct { + Canvas CanvasHandler SessionFs SessionFsHandler } @@ -11883,6 +12092,63 @@ func clientSessionHandlerError(err error) *jsonrpc2.Error { // RegisterClientSessionApiHandlers registers handlers for server-to-client session API // calls. func RegisterClientSessionApiHandlers(client *jsonrpc2.Client, getHandlers func(sessionID string) *ClientSessionApiHandlers) { + client.SetRequestHandler("canvas.close", func(params json.RawMessage) (json.RawMessage, *jsonrpc2.Error) { + var request CanvasProviderCloseRequest + if err := json.Unmarshal(params, &request); err != nil { + return nil, &jsonrpc2.Error{Code: -32602, Message: fmt.Sprintf("Invalid params: %v", err)} + } + handlers := getHandlers(request.SessionID) + if handlers == nil || handlers.Canvas == nil { + return nil, &jsonrpc2.Error{Code: -32603, Message: fmt.Sprintf("No canvas handler registered for session: %s", request.SessionID)} + } + result, err := handlers.Canvas.Close(&request) + if err != nil { + return nil, clientSessionHandlerError(err) + } + raw, err := json.Marshal(result) + if err != nil { + return nil, &jsonrpc2.Error{Code: -32603, Message: fmt.Sprintf("Failed to marshal response: %v", err)} + } + return raw, nil + }) + client.SetRequestHandler("canvas.invokeAction", func(params json.RawMessage) (json.RawMessage, *jsonrpc2.Error) { + var request CanvasProviderInvokeActionRequest + if err := json.Unmarshal(params, &request); err != nil { + return nil, &jsonrpc2.Error{Code: -32602, Message: fmt.Sprintf("Invalid params: %v", err)} + } + handlers := getHandlers(request.SessionID) + if handlers == nil || handlers.Canvas == nil { + return nil, &jsonrpc2.Error{Code: -32603, Message: fmt.Sprintf("No canvas handler registered for session: %s", request.SessionID)} + } + result, err := handlers.Canvas.InvokeAction(&request) + if err != nil { + return nil, clientSessionHandlerError(err) + } + raw, err := json.Marshal(result) + if err != nil { + return nil, &jsonrpc2.Error{Code: -32603, Message: fmt.Sprintf("Failed to marshal response: %v", err)} + } + return raw, nil + }) + client.SetRequestHandler("canvas.open", func(params json.RawMessage) (json.RawMessage, *jsonrpc2.Error) { + var request CanvasProviderOpenRequest + if err := json.Unmarshal(params, &request); err != nil { + return nil, &jsonrpc2.Error{Code: -32602, Message: fmt.Sprintf("Invalid params: %v", err)} + } + handlers := getHandlers(request.SessionID) + if handlers == nil || handlers.Canvas == nil { + return nil, &jsonrpc2.Error{Code: -32603, Message: fmt.Sprintf("No canvas handler registered for session: %s", request.SessionID)} + } + result, err := handlers.Canvas.Open(&request) + if err != nil { + return nil, clientSessionHandlerError(err) + } + raw, err := json.Marshal(result) + if err != nil { + return nil, &jsonrpc2.Error{Code: -32603, Message: fmt.Sprintf("Failed to marshal response: %v", err)} + } + return raw, nil + }) client.SetRequestHandler("sessionFs.appendFile", func(params json.RawMessage) (json.RawMessage, *jsonrpc2.Error) { var request SessionFsAppendFileRequest if err := json.Unmarshal(params, &request); err != nil { diff --git a/go/rpc/zrpc_encoding.go b/go/rpc/zrpc_encoding.go index f013a1fef..9224f65cf 100644 --- a/go/rpc/zrpc_encoding.go +++ b/go/rpc/zrpc_encoding.go @@ -668,6 +668,25 @@ func (r RawMcpServerConfigData) MarshalJSON() ([]byte, error) { return []byte("null"), nil } +func unmarshalMcpServerConfigHTTPOidc(data []byte) (McpServerConfigHTTPOidc, error) { + if string(data) == "null" { + return nil, nil + } + { + var value bool + if err := json.Unmarshal(data, &value); err == nil { + return McpServerConfigHTTPOidcBoolean(value), nil + } + } + { + var value McpServerConfigHTTPOidcAnyMap + if err := json.Unmarshal(data, &value); err == nil { + return value, nil + } + } + return nil, errors.New("data did not match any union variant for McpServerConfigHTTPOidc") +} + func (r *McpServerConfigHTTP) UnmarshalJSON(data []byte) error { type rawMcpServerConfigHTTP struct { Auth *McpServerConfigHTTPAuth `json:"auth,omitempty"` @@ -677,6 +696,7 @@ func (r *McpServerConfigHTTP) UnmarshalJSON(data []byte) error { OauthClientID *string `json:"oauthClientId,omitempty"` OauthGrantType *McpServerConfigHTTPOauthGrantType `json:"oauthGrantType,omitempty"` OauthPublicClient *bool `json:"oauthPublicClient,omitempty"` + Oidc json.RawMessage `json:"oidc,omitempty"` Timeout *int64 `json:"timeout,omitempty"` Tools []string `json:"tools,omitempty"` Type *McpServerConfigHTTPType `json:"type,omitempty"` @@ -699,6 +719,13 @@ func (r *McpServerConfigHTTP) UnmarshalJSON(data []byte) error { r.OauthClientID = raw.OauthClientID r.OauthGrantType = raw.OauthGrantType r.OauthPublicClient = raw.OauthPublicClient + if raw.Oidc != nil { + value, err := unmarshalMcpServerConfigHTTPOidc(raw.Oidc) + if err != nil { + return err + } + r.Oidc = value + } r.Timeout = raw.Timeout r.Tools = raw.Tools r.Type = raw.Type @@ -706,14 +733,54 @@ func (r *McpServerConfigHTTP) UnmarshalJSON(data []byte) error { return nil } +func unmarshalMcpServerConfigStdioAuth(data []byte) (McpServerConfigStdioAuth, error) { + if string(data) == "null" { + return nil, nil + } + { + var value bool + if err := json.Unmarshal(data, &value); err == nil { + return McpServerConfigStdioAuthBoolean(value), nil + } + } + { + var value McpServerConfigStdioAuthAnyMap + if err := json.Unmarshal(data, &value); err == nil { + return value, nil + } + } + return nil, errors.New("data did not match any union variant for McpServerConfigStdioAuth") +} + +func unmarshalMcpServerConfigStdioOidc(data []byte) (McpServerConfigStdioOidc, error) { + if string(data) == "null" { + return nil, nil + } + { + var value bool + if err := json.Unmarshal(data, &value); err == nil { + return McpServerConfigStdioOidcBoolean(value), nil + } + } + { + var value McpServerConfigStdioOidcAnyMap + if err := json.Unmarshal(data, &value); err == nil { + return value, nil + } + } + return nil, errors.New("data did not match any union variant for McpServerConfigStdioOidc") +} + func (r *McpServerConfigStdio) UnmarshalJSON(data []byte) error { type rawMcpServerConfigStdio struct { Args []string `json:"args,omitempty"` + Auth json.RawMessage `json:"auth,omitempty"` Command string `json:"command"` Cwd *string `json:"cwd,omitempty"` Env map[string]string `json:"env,omitempty"` FilterMapping json.RawMessage `json:"filterMapping,omitempty"` IsDefaultServer *bool `json:"isDefaultServer,omitempty"` + Oidc json.RawMessage `json:"oidc,omitempty"` Timeout *int64 `json:"timeout,omitempty"` Tools []string `json:"tools,omitempty"` } @@ -722,6 +789,13 @@ func (r *McpServerConfigStdio) UnmarshalJSON(data []byte) error { return err } r.Args = raw.Args + if raw.Auth != nil { + value, err := unmarshalMcpServerConfigStdioAuth(raw.Auth) + if err != nil { + return err + } + r.Auth = value + } r.Command = raw.Command r.Cwd = raw.Cwd r.Env = raw.Env @@ -733,6 +807,13 @@ func (r *McpServerConfigStdio) UnmarshalJSON(data []byte) error { r.FilterMapping = value } r.IsDefaultServer = raw.IsDefaultServer + if raw.Oidc != nil { + value, err := unmarshalMcpServerConfigStdioOidc(raw.Oidc) + if err != nil { + return err + } + r.Oidc = value + } r.Timeout = raw.Timeout r.Tools = raw.Tools return nil diff --git a/go/session.go b/go/session.go index eca928c19..6c89137b3 100644 --- a/go/session.go +++ b/go/session.go @@ -130,6 +130,118 @@ func (s *Session) getCanvasHandler() CanvasHandler { return s.canvasHandler } +type canvasClientSessionAdapter struct { + session *Session +} + +func newCanvasClientSessionAdapter(session *Session) rpc.CanvasHandler { + return &canvasClientSessionAdapter{session: session} +} + +func (a *canvasClientSessionAdapter) Close(request *rpc.CanvasProviderCloseRequest) (*rpc.CanvasCloseResult, error) { + if request == nil { + return nil, canvasJSONRPCError(NewCanvasError("canvas_handler_unset", "missing canvas close request")) + } + handler, err := a.resolveHandler(canvasProviderSessionID(request)) + if err != nil { + return nil, err + } + if err := handler.OnClose(context.Background(), *request); err != nil { + return nil, canvasResultError(err) + } + return nil, nil +} + +func (a *canvasClientSessionAdapter) InvokeAction(request *rpc.CanvasProviderInvokeActionRequest) (any, error) { + if request == nil { + return nil, canvasJSONRPCError(NewCanvasError("canvas_handler_unset", "missing canvas action request")) + } + handler, err := a.resolveHandler(canvasProviderSessionID(request)) + if err != nil { + return nil, err + } + result, actionErr := handler.OnAction(context.Background(), *request) + if actionErr != nil { + return nil, canvasResultError(actionErr) + } + return result, nil +} + +func (a *canvasClientSessionAdapter) Open(request *rpc.CanvasProviderOpenRequest) (*rpc.CanvasProviderOpenResult, error) { + if request == nil { + return nil, canvasJSONRPCError(NewCanvasError("canvas_handler_unset", "missing canvas open request")) + } + handler, err := a.resolveHandler(canvasProviderSessionID(request)) + if err != nil { + return nil, err + } + result, openErr := handler.OnOpen(context.Background(), *request) + if openErr != nil { + return nil, canvasResultError(openErr) + } + return &result, nil +} + +func (a *canvasClientSessionAdapter) resolveHandler(sessionID string) (CanvasHandler, error) { + if sessionID == "" { + return nil, canvasJSONRPCError(NewCanvasError("canvas_handler_unset", "missing session ID")) + } + if a.session == nil || a.session.SessionID != sessionID { + return nil, canvasJSONRPCError(NewCanvasError("canvas_handler_unset", fmt.Sprintf("unknown session %s", sessionID))) + } + handler := a.session.getCanvasHandler() + if handler == nil { + return nil, canvasJSONRPCError(NewCanvasError( + "canvas_handler_unset", + "No CanvasHandler installed on this session; install one via SessionConfig.CanvasHandler before creating the session.", + )) + } + return handler, nil +} + +func canvasProviderSessionID(request any) string { + switch req := request.(type) { + case *rpc.CanvasProviderCloseRequest: + if req != nil { + return req.SessionID + } + case *rpc.CanvasProviderInvokeActionRequest: + if req != nil { + return req.SessionID + } + case *rpc.CanvasProviderOpenRequest: + if req != nil { + return req.SessionID + } + } + return "" +} + +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, + } +} + +func canvasResultError(err error) error { + if err == nil { + return nil + } + if rpcErr, ok := err.(*jsonrpc2.Error); ok { + return rpcErr + } + if cerr, ok := err.(*CanvasError); ok { + return canvasJSONRPCError(cerr) + } + return canvasJSONRPCError(NewCanvasError("canvas_handler_error", err.Error())) +} + // 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{ @@ -143,6 +255,7 @@ func newSession(sessionID string, client *jsonrpc2.Client, workspacePath string) eventCh: make(chan SessionEvent, 128), RPC: rpc.NewSessionRpc(client, sessionID), } + s.clientSessionApis.Canvas = newCanvasClientSessionAdapter(s) go s.processEvents() return s } diff --git a/go/types.go b/go/types.go index fe7f9d93c..a38ec6f03 100644 --- a/go/types.go +++ b/go/types.go @@ -936,9 +936,9 @@ type SessionConfig struct { 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 + // CanvasHandler receives inbound canvas.open / canvas.close / canvas.invokeAction // requests for this session. The SDK does not maintain a per-canvas registry; - // the handler must dispatch on CanvasOpenContext.CanvasID itself. + // the handler must dispatch on CanvasProviderOpenRequest.CanvasID itself. CanvasHandler CanvasHandler `json:"-"` // ExtensionInfo identifies the stable extension providing this session's canvases. ExtensionInfo *ExtensionInfo diff --git a/nodejs/package-lock.json b/nodejs/package-lock.json index e4c73dd27..9b81710db 100644 --- a/nodejs/package-lock.json +++ b/nodejs/package-lock.json @@ -9,7 +9,7 @@ "version": "0.1.8", "license": "MIT", "dependencies": { - "@github/copilot": "^1.0.55-0", + "@github/copilot": "^1.0.55-1", "vscode-jsonrpc": "^8.2.1", "zod": "^4.3.6" }, @@ -663,9 +663,9 @@ } }, "node_modules/@github/copilot": { - "version": "1.0.55-0", - "resolved": "https://registry.npmjs.org/@github/copilot/-/copilot-1.0.55-0.tgz", - "integrity": "sha512-w94Y1QT90lgoXOCUxKw3CtKD5yNtwOXwUB0KGthrCNcyYaBFapUnVh0Hik0oenucv13R8yyuET4Wrl0zJvaeUA==", + "version": "1.0.55-1", + "resolved": "https://registry.npmjs.org/@github/copilot/-/copilot-1.0.55-1.tgz", + "integrity": "sha512-P8uFRbbKWyImqPWaHub8dCt2R4f/GWjY/4xyf3uyHs5wZfSPLzh9DvagAA2ryQjuGHsgCZeDscUkJoYW+yn2UA==", "license": "SEE LICENSE IN LICENSE.md", "dependencies": { "detect-libc": "^2.1.2" @@ -674,20 +674,20 @@ "copilot": "npm-loader.js" }, "optionalDependencies": { - "@github/copilot-darwin-arm64": "1.0.55-0", - "@github/copilot-darwin-x64": "1.0.55-0", - "@github/copilot-linux-arm64": "1.0.55-0", - "@github/copilot-linux-x64": "1.0.55-0", - "@github/copilot-linuxmusl-arm64": "1.0.55-0", - "@github/copilot-linuxmusl-x64": "1.0.55-0", - "@github/copilot-win32-arm64": "1.0.55-0", - "@github/copilot-win32-x64": "1.0.55-0" + "@github/copilot-darwin-arm64": "1.0.55-1", + "@github/copilot-darwin-x64": "1.0.55-1", + "@github/copilot-linux-arm64": "1.0.55-1", + "@github/copilot-linux-x64": "1.0.55-1", + "@github/copilot-linuxmusl-arm64": "1.0.55-1", + "@github/copilot-linuxmusl-x64": "1.0.55-1", + "@github/copilot-win32-arm64": "1.0.55-1", + "@github/copilot-win32-x64": "1.0.55-1" } }, "node_modules/@github/copilot-darwin-arm64": { - "version": "1.0.55-0", - "resolved": "https://registry.npmjs.org/@github/copilot-darwin-arm64/-/copilot-darwin-arm64-1.0.55-0.tgz", - "integrity": "sha512-9Af5VzQtXXeRo7/8jvARZ8NFEy53wPubrMi6/Ji9cTdUNtrIz9u6Dj4VxKJuIYYv5JnBCgsL4D4FpXdcppS4pA==", + "version": "1.0.55-1", + "resolved": "https://registry.npmjs.org/@github/copilot-darwin-arm64/-/copilot-darwin-arm64-1.0.55-1.tgz", + "integrity": "sha512-0z5P/FZZ7OIJbcs+WWIwkCcY7+sRGxmL80GTvT6x0QiJdJLqLlvwcX/Pfhomp4NxwEYBAK3I51F8I8A3GacjuQ==", "cpu": [ "arm64" ], @@ -701,9 +701,9 @@ } }, "node_modules/@github/copilot-darwin-x64": { - "version": "1.0.55-0", - "resolved": "https://registry.npmjs.org/@github/copilot-darwin-x64/-/copilot-darwin-x64-1.0.55-0.tgz", - "integrity": "sha512-jveV7dT8k7piYs0Xkck7BH0oAoszbsUM8Y9YVzKo5CZeRAvAncD/vsxP3e/BLnI3onJO9gIfWapruXfrvELwLQ==", + "version": "1.0.55-1", + "resolved": "https://registry.npmjs.org/@github/copilot-darwin-x64/-/copilot-darwin-x64-1.0.55-1.tgz", + "integrity": "sha512-334+JCBb0iqg7omB2szc+/Ii2xkq81HKaer+mTaXj+1kHUGtfZacmzejwvutl6CxKTuZ8E9BcozP65KEYLBbwQ==", "cpu": [ "x64" ], @@ -717,9 +717,9 @@ } }, "node_modules/@github/copilot-linux-arm64": { - "version": "1.0.55-0", - "resolved": "https://registry.npmjs.org/@github/copilot-linux-arm64/-/copilot-linux-arm64-1.0.55-0.tgz", - "integrity": "sha512-E3i9taaBsQltyirWqCnOK4B/gmnemNGL5WB3uT3MiEBJywmEPmks1aA7+yDSdaqSTT/2MpkToNmbjsmnxypyHw==", + "version": "1.0.55-1", + "resolved": "https://registry.npmjs.org/@github/copilot-linux-arm64/-/copilot-linux-arm64-1.0.55-1.tgz", + "integrity": "sha512-UTZuNCWzEaeqjhEyz+BUPNECPy5b6KKnym60q9x9dZw1ufqQSxKdhu02p62x53PA1fd4l6N8FRdhoHOOhKfEkQ==", "cpu": [ "arm64" ], @@ -733,9 +733,9 @@ } }, "node_modules/@github/copilot-linux-x64": { - "version": "1.0.55-0", - "resolved": "https://registry.npmjs.org/@github/copilot-linux-x64/-/copilot-linux-x64-1.0.55-0.tgz", - "integrity": "sha512-iR7XSllFyy8FVLeuEDk6eJVdLpakxtcvA6A7Y9iCT9VMOLnFvvXqTWUYdrU+0qngBUJnfSgDQ0Nr1KMTgwOyKA==", + "version": "1.0.55-1", + "resolved": "https://registry.npmjs.org/@github/copilot-linux-x64/-/copilot-linux-x64-1.0.55-1.tgz", + "integrity": "sha512-IwKgCrSXy7zeQlGgf8tZue8eaM/TcxVxXvpijXUTY0F9KXldzO0xv3WAgh5540ALH+jwkLI85UuPq5aoVCBuCA==", "cpu": [ "x64" ], @@ -749,9 +749,9 @@ } }, "node_modules/@github/copilot-linuxmusl-arm64": { - "version": "1.0.55-0", - "resolved": "https://registry.npmjs.org/@github/copilot-linuxmusl-arm64/-/copilot-linuxmusl-arm64-1.0.55-0.tgz", - "integrity": "sha512-0BslCyHOouUmj1ZTi1H/WLpZkyzYxrOdGiH0f2s9+uRd7DqmWugZrNURbx9vicm5QMiVax4RgvgLmE2PQsjpJg==", + "version": "1.0.55-1", + "resolved": "https://registry.npmjs.org/@github/copilot-linuxmusl-arm64/-/copilot-linuxmusl-arm64-1.0.55-1.tgz", + "integrity": "sha512-3Uu9JOYU9zmr5dcYBQZHwXF1JpzVPZXpBau0khLd6OFZkA6al1D2miNmw4nO/WoT0IfheW6EhYUBN1WuthmXTw==", "cpu": [ "arm64" ], @@ -765,9 +765,9 @@ } }, "node_modules/@github/copilot-linuxmusl-x64": { - "version": "1.0.55-0", - "resolved": "https://registry.npmjs.org/@github/copilot-linuxmusl-x64/-/copilot-linuxmusl-x64-1.0.55-0.tgz", - "integrity": "sha512-7GE3q7Cahe/urK16KMgis1b3pryofrdfMUJqvhhaycPv0EiaaatSn715XxgoQrZxnfwtEhqAYzCBzRkUxJl6Pg==", + "version": "1.0.55-1", + "resolved": "https://registry.npmjs.org/@github/copilot-linuxmusl-x64/-/copilot-linuxmusl-x64-1.0.55-1.tgz", + "integrity": "sha512-0szVr6ejslqi6O+Rbmx5ETRTEMFpGv9A9qiK3E0XuzE4uXSPyPad/wOSSE2yat7YEqUVU8J/HIUKU9TpBpiWbA==", "cpu": [ "x64" ], @@ -781,9 +781,9 @@ } }, "node_modules/@github/copilot-win32-arm64": { - "version": "1.0.55-0", - "resolved": "https://registry.npmjs.org/@github/copilot-win32-arm64/-/copilot-win32-arm64-1.0.55-0.tgz", - "integrity": "sha512-KEQ0GcAzoaVvO5SyMzAVIDmvTZywStdxh1bc7NyRYu2kpOKaFdUL18lAbH9aMQBB+2SKnpur9Svi9fS63ClsTQ==", + "version": "1.0.55-1", + "resolved": "https://registry.npmjs.org/@github/copilot-win32-arm64/-/copilot-win32-arm64-1.0.55-1.tgz", + "integrity": "sha512-h0LXoTv7cT8J/RVuocWbwd5+VYbxJk5/s7EsMXn+FR6m8ZqYZdBjYMAyDe9v/HcCyAPY0xLYRKt6k3Jb17Upag==", "cpu": [ "arm64" ], @@ -797,9 +797,9 @@ } }, "node_modules/@github/copilot-win32-x64": { - "version": "1.0.55-0", - "resolved": "https://registry.npmjs.org/@github/copilot-win32-x64/-/copilot-win32-x64-1.0.55-0.tgz", - "integrity": "sha512-ybesOFzaeK6u4j1Dx3pIqX3PcBT1SGfiHdzTpaLmue86jbCvRSS0aVZuoVBYYGI95UfRBbuK8bOMuWAShaxJpw==", + "version": "1.0.55-1", + "resolved": "https://registry.npmjs.org/@github/copilot-win32-x64/-/copilot-win32-x64-1.0.55-1.tgz", + "integrity": "sha512-lxaRdPd5NZaC5YyMOTt2uTV34fQyuClg71JAYoKVZ1w7Dc6E+f7+WMM3L67ZieornfgG80h4QMNtfpVpCVgpAQ==", "cpu": [ "x64" ], diff --git a/nodejs/package.json b/nodejs/package.json index 7a1fd9a05..f89463195 100644 --- a/nodejs/package.json +++ b/nodejs/package.json @@ -56,7 +56,7 @@ "author": "GitHub", "license": "MIT", "dependencies": { - "@github/copilot": "^1.0.55-0", + "@github/copilot": "^1.0.55-1", "vscode-jsonrpc": "^8.2.1", "zod": "^4.3.6" }, diff --git a/nodejs/src/canvas.ts b/nodejs/src/canvas.ts index 738dfc851..4a3b25206 100644 --- a/nodejs/src/canvas.ts +++ b/nodejs/src/canvas.ts @@ -2,20 +2,27 @@ * Copyright (c) Microsoft Corporation. All rights reserved. *--------------------------------------------------------------------------------------------*/ +import type { + CanvasJsonSchema, + CanvasProviderCloseRequest, + CanvasProviderInvokeActionRequest, + CanvasProviderOpenRequest, + CanvasProviderOpenResult, +} from "./generated/rpc.js"; + +export type { CanvasJsonSchema, CanvasHostContext } from "./generated/rpc.js"; + /** * 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. + * The runtime sends provider callbacks as `canvas.open`, `canvas.close`, and + * `canvas.invokeAction` JSON-RPC requests via the codegen client session API + * pipeline. 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 @@ -33,7 +40,7 @@ export interface CanvasAction { /** Optional JSON Schema for the action's `input` payload. */ inputSchema?: CanvasJsonSchema; /** Required per-action dispatch handler. */ - handler: (ctx: CanvasActionContext) => Promise | unknown; + handler: (ctx: CanvasProviderInvokeActionRequest) => Promise | unknown; } /** @@ -53,71 +60,6 @@ export interface CanvasDeclaration { 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( @@ -158,14 +100,16 @@ export interface CanvasOptions { actions?: CanvasAction[]; /** Required. Open a new canvas instance. */ - open: (ctx: CanvasOpenContext) => Promise | CanvasOpenResponse; + open: ( + ctx: CanvasProviderOpenRequest + ) => Promise | CanvasProviderOpenResult; /** * 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; + onClose?: (ctx: CanvasProviderCloseRequest) => Promise | void; } /** A registered canvas: declarative metadata + in-process handler closures. @@ -216,71 +160,3 @@ export class Canvas { 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 3e8a4cfa3..11e6131cb 100644 --- a/nodejs/src/client.ts +++ b/nodejs/src/client.ts @@ -32,11 +32,6 @@ import { 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"; @@ -134,32 +129,6 @@ 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, @@ -1926,17 +1895,6 @@ 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) => { @@ -2140,33 +2098,4 @@ 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 95346dec4..0e315ea28 100644 --- a/nodejs/src/extension.ts +++ b/nodejs/src/extension.ts @@ -16,13 +16,9 @@ export { CanvasError, createCanvas, type CanvasAction, - type CanvasActionContext, type CanvasDeclaration, type CanvasHostContext, type CanvasJsonSchema, - type CanvasLifecycleContext, - type CanvasOpenContext, - type CanvasOpenResponse, type CanvasOptions, } from "./canvas.js"; diff --git a/nodejs/src/generated/rpc.ts b/nodejs/src/generated/rpc.ts index 229fd4ff0..c79da28e9 100644 --- a/nodejs/src/generated/rpc.ts +++ b/nodejs/src/generated/rpc.ts @@ -452,6 +452,28 @@ export type McpAppsSetHostContextDetailsPlatform = * via the `definition` "McpServerConfig". */ export type McpServerConfig = McpServerConfigStdio | McpServerConfigHttp; +/** + * OIDC token configuration. When truthy, a token is automatically gathered. + * + * This interface was referenced by `_RpcSchemaRoot`'s JSON-Schema + * via the `definition` "McpServerConfigStdioOidc". + */ +export type McpServerConfigStdioOidc = + | boolean + | { + [k: string]: unknown | undefined; + }; +/** + * Authentication configuration for this server. + * + * This interface was referenced by `_RpcSchemaRoot`'s JSON-Schema + * via the `definition` "McpServerConfigStdioAuth". + */ +export type McpServerConfigStdioAuth = + | boolean + | { + [k: string]: unknown | undefined; + }; /** * Remote transport type. Defaults to "http" when omitted. * @@ -463,6 +485,17 @@ export type McpServerConfigHttpType = | "http" /** Server-Sent Events transport. */ | "sse"; +/** + * OIDC token configuration. When truthy, a token is automatically gathered. + * + * This interface was referenced by `_RpcSchemaRoot`'s JSON-Schema + * via the `definition` "McpServerConfigHttpOidc". + */ +export type McpServerConfigHttpOidc = + | boolean + | { + [k: string]: unknown | undefined; + }; /** * OAuth grant type to use when authenticating to the remote MCP server. * @@ -861,6 +894,7 @@ export type SessionContextHostType = * This interface was referenced by `_RpcSchemaRoot`'s JSON-Schema * via the `definition` "SessionFsErrorCode". */ +/** @experimental */ export type SessionFsErrorCode = /** The requested path does not exist. */ | "ENOENT" @@ -872,6 +906,7 @@ export type SessionFsErrorCode = * This interface was referenced by `_RpcSchemaRoot`'s JSON-Schema * via the `definition` "SessionFsReaddirWithTypesEntryType". */ +/** @experimental */ export type SessionFsReaddirWithTypesEntryType = /** The entry is a file. */ | "file" @@ -894,6 +929,7 @@ export type SessionFsSetProviderConventions = * This interface was referenced by `_RpcSchemaRoot`'s JSON-Schema * via the `definition` "SessionFsSqliteQueryType". */ +/** @experimental */ export type SessionFsSqliteQueryType = /** Execute DDL or multi-statement SQL without returning rows. */ | "exec" @@ -1775,6 +1811,29 @@ export interface CanvasCloseRequest { */ instanceId: string; } +/** + * Host context supplied by the runtime. + * + * This interface was referenced by `_RpcSchemaRoot`'s JSON-Schema + * via the `definition` "CanvasHostContext". + */ +/** @experimental */ +export interface CanvasHostContext { + capabilities?: CanvasHostContextCapabilities; +} +/** + * Host capabilities + * + * This interface was referenced by `_RpcSchemaRoot`'s JSON-Schema + * via the `definition` "CanvasHostContextCapabilities". + */ +/** @experimental */ +export interface CanvasHostContextCapabilities { + /** + * Whether canvas rendering is supported + */ + canvases?: boolean; +} /** * Canvas action invocation parameters. * @@ -1799,19 +1858,14 @@ export interface CanvasInvokeActionRequest { }; } /** - * Canvas action invocation result. + * Provider-supplied action result. * * This interface was referenced by `_RpcSchemaRoot`'s JSON-Schema * via the `definition` "CanvasInvokeActionResult". */ /** @experimental */ export interface CanvasInvokeActionResult { - /** - * Provider-supplied action result - */ - result?: { - [k: string]: unknown | undefined; - }; + [k: string]: unknown | undefined; } /** * Declared canvases available in this session. @@ -1948,6 +2002,121 @@ export interface CanvasOpenRequest { [k: string]: unknown | undefined; }; } +/** + * Canvas close parameters sent to the provider. + * + * This interface was referenced by `_RpcSchemaRoot`'s JSON-Schema + * via the `definition` "CanvasProviderCloseRequest". + */ +/** @experimental */ +export interface CanvasProviderCloseRequest { + /** + * Target session identifier + */ + sessionId: string; + /** + * Owning provider identifier + */ + extensionId: string; + /** + * Provider-local canvas identifier + */ + canvasId: string; + /** + * Canvas instance identifier + */ + instanceId: string; + host?: CanvasHostContext; +} +/** + * Canvas action invocation parameters sent to the provider. + * + * This interface was referenced by `_RpcSchemaRoot`'s JSON-Schema + * via the `definition` "CanvasProviderInvokeActionRequest". + */ +/** @experimental */ +export interface CanvasProviderInvokeActionRequest { + /** + * Target session identifier + */ + sessionId: string; + /** + * Owning provider identifier + */ + extensionId: string; + /** + * Provider-local canvas identifier + */ + canvasId: string; + /** + * Canvas instance identifier + */ + instanceId: string; + /** + * Action name to invoke + */ + actionName: string; + /** + * Action input + */ + input?: { + [k: string]: unknown | undefined; + }; + host?: CanvasHostContext; +} +/** + * Canvas open parameters sent to the provider. + * + * This interface was referenced by `_RpcSchemaRoot`'s JSON-Schema + * via the `definition` "CanvasProviderOpenRequest". + */ +/** @experimental */ +export interface CanvasProviderOpenRequest { + /** + * Target session identifier + */ + sessionId: string; + /** + * Owning provider identifier + */ + extensionId: string; + /** + * Provider-local canvas identifier + */ + canvasId: string; + /** + * Stable caller-supplied canvas instance identifier + */ + instanceId: string; + /** + * Canvas open input + */ + input?: { + [k: string]: unknown | undefined; + }; + host?: CanvasHostContext; +} +/** + * Canvas open result returned by the provider. + * + * This interface was referenced by `_RpcSchemaRoot`'s JSON-Schema + * via the `definition` "CanvasProviderOpenResult". + */ +/** @experimental */ +export interface CanvasProviderOpenResult { + /** + * URL for web-rendered canvases + */ + url?: string; + /** + * Provider-supplied title + */ + title?: string; + /** + * Provider-supplied status text + */ + status?: string; +} /** * Slash commands available in the session, after applying any include/exclude filters. * @@ -3463,6 +3632,8 @@ export interface McpServerConfigStdio { * Timeout in milliseconds for tool calls to this server. */ timeout?: number; + oidc?: McpServerConfigStdioOidc; + auth?: McpServerConfigStdioAuth; /** * Executable command used to start the Stdio MCP server process. */ @@ -3503,6 +3674,8 @@ export interface McpServerConfigHttp { * Timeout in milliseconds for tool calls to this server. */ timeout?: number; + oidc?: McpServerConfigHttpOidc; + auth?: McpServerConfigHttpAuth; /** * URL of the remote MCP server endpoint. */ @@ -3522,7 +3695,6 @@ export interface McpServerConfigHttp { */ oauthPublicClient?: boolean; oauthGrantType?: McpServerConfigHttpOauthGrantType; - auth?: McpServerConfigHttpAuth; } /** * Additional authentication configuration for this server. @@ -3535,6 +3707,7 @@ export interface McpServerConfigHttpAuth { * Fixed port for the OAuth redirect callback server. */ redirectPort?: number; + [k: string]: unknown | undefined; } /** * MCP server names to disable for new sessions. @@ -6395,7 +6568,7 @@ export interface SessionContext { branch?: string; } /** - * The same metadata records, with summary and context fields backfilled where available. + * The enriched metadata records, with summary and context fields backfilled where available. Sessions confirmed empty and unnamed are omitted. * * This interface was referenced by `_RpcSchemaRoot`'s JSON-Schema * via the `definition` "SessionEnrichMetadataResult". @@ -6403,7 +6576,7 @@ export interface SessionContext { /** @experimental */ export interface SessionEnrichMetadataResult { /** - * Same records, with summary and context backfilled + * Enriched records, with summary and context backfilled. Sessions confirmed empty and unnamed may be omitted. */ sessions: SessionMetadata[]; } @@ -6451,6 +6624,7 @@ export interface SessionMetadata { * This interface was referenced by `_RpcSchemaRoot`'s JSON-Schema * via the `definition` "SessionFsAppendFileRequest". */ +/** @experimental */ export interface SessionFsAppendFileRequest { /** * Target session identifier @@ -6475,6 +6649,7 @@ export interface SessionFsAppendFileRequest { * This interface was referenced by `_RpcSchemaRoot`'s JSON-Schema * via the `definition` "SessionFsError". */ +/** @experimental */ export interface SessionFsError { code: SessionFsErrorCode; /** @@ -6488,6 +6663,7 @@ export interface SessionFsError { * This interface was referenced by `_RpcSchemaRoot`'s JSON-Schema * via the `definition` "SessionFsExistsRequest". */ +/** @experimental */ export interface SessionFsExistsRequest { /** * Target session identifier @@ -6504,6 +6680,7 @@ export interface SessionFsExistsRequest { * This interface was referenced by `_RpcSchemaRoot`'s JSON-Schema * via the `definition` "SessionFsExistsResult". */ +/** @experimental */ export interface SessionFsExistsResult { /** * Whether the path exists @@ -6516,6 +6693,7 @@ export interface SessionFsExistsResult { * This interface was referenced by `_RpcSchemaRoot`'s JSON-Schema * via the `definition` "SessionFsMkdirRequest". */ +/** @experimental */ export interface SessionFsMkdirRequest { /** * Target session identifier @@ -6540,6 +6718,7 @@ export interface SessionFsMkdirRequest { * This interface was referenced by `_RpcSchemaRoot`'s JSON-Schema * via the `definition` "SessionFsReaddirRequest". */ +/** @experimental */ export interface SessionFsReaddirRequest { /** * Target session identifier @@ -6556,6 +6735,7 @@ export interface SessionFsReaddirRequest { * This interface was referenced by `_RpcSchemaRoot`'s JSON-Schema * via the `definition` "SessionFsReaddirResult". */ +/** @experimental */ export interface SessionFsReaddirResult { /** * Entry names in the directory @@ -6569,6 +6749,7 @@ export interface SessionFsReaddirResult { * This interface was referenced by `_RpcSchemaRoot`'s JSON-Schema * via the `definition` "SessionFsReaddirWithTypesEntry". */ +/** @experimental */ export interface SessionFsReaddirWithTypesEntry { /** * Entry name @@ -6582,6 +6763,7 @@ export interface SessionFsReaddirWithTypesEntry { * This interface was referenced by `_RpcSchemaRoot`'s JSON-Schema * via the `definition` "SessionFsReaddirWithTypesRequest". */ +/** @experimental */ export interface SessionFsReaddirWithTypesRequest { /** * Target session identifier @@ -6598,6 +6780,7 @@ export interface SessionFsReaddirWithTypesRequest { * This interface was referenced by `_RpcSchemaRoot`'s JSON-Schema * via the `definition` "SessionFsReaddirWithTypesResult". */ +/** @experimental */ export interface SessionFsReaddirWithTypesResult { /** * Directory entries with type information @@ -6611,6 +6794,7 @@ export interface SessionFsReaddirWithTypesResult { * This interface was referenced by `_RpcSchemaRoot`'s JSON-Schema * via the `definition` "SessionFsReadFileRequest". */ +/** @experimental */ export interface SessionFsReadFileRequest { /** * Target session identifier @@ -6627,6 +6811,7 @@ export interface SessionFsReadFileRequest { * This interface was referenced by `_RpcSchemaRoot`'s JSON-Schema * via the `definition` "SessionFsReadFileResult". */ +/** @experimental */ export interface SessionFsReadFileResult { /** * File content as UTF-8 string @@ -6640,6 +6825,7 @@ export interface SessionFsReadFileResult { * This interface was referenced by `_RpcSchemaRoot`'s JSON-Schema * via the `definition` "SessionFsRenameRequest". */ +/** @experimental */ export interface SessionFsRenameRequest { /** * Target session identifier @@ -6660,6 +6846,7 @@ export interface SessionFsRenameRequest { * This interface was referenced by `_RpcSchemaRoot`'s JSON-Schema * via the `definition` "SessionFsRmRequest". */ +/** @experimental */ export interface SessionFsRmRequest { /** * Target session identifier @@ -6726,6 +6913,7 @@ export interface SessionFsSetProviderResult { * This interface was referenced by `_RpcSchemaRoot`'s JSON-Schema * via the `definition` "SessionFsSqliteExistsResult". */ +/** @experimental */ export interface SessionFsSqliteExistsResult { /** * Whether the session database already exists @@ -6738,6 +6926,7 @@ export interface SessionFsSqliteExistsResult { * This interface was referenced by `_RpcSchemaRoot`'s JSON-Schema * via the `definition` "SessionFsSqliteQueryRequest". */ +/** @experimental */ export interface SessionFsSqliteQueryRequest { /** * Target session identifier @@ -6761,6 +6950,7 @@ export interface SessionFsSqliteQueryRequest { * This interface was referenced by `_RpcSchemaRoot`'s JSON-Schema * via the `definition` "SessionFsSqliteQueryResult". */ +/** @experimental */ export interface SessionFsSqliteQueryResult { /** * For SELECT: array of row objects. For others: empty array. @@ -6788,6 +6978,7 @@ export interface SessionFsSqliteQueryResult { * This interface was referenced by `_RpcSchemaRoot`'s JSON-Schema * via the `definition` "SessionFsStatRequest". */ +/** @experimental */ export interface SessionFsStatRequest { /** * Target session identifier @@ -6804,6 +6995,7 @@ export interface SessionFsStatRequest { * This interface was referenced by `_RpcSchemaRoot`'s JSON-Schema * via the `definition` "SessionFsStatResult". */ +/** @experimental */ export interface SessionFsStatResult { /** * Whether the path is a file @@ -6833,6 +7025,7 @@ export interface SessionFsStatResult { * This interface was referenced by `_RpcSchemaRoot`'s JSON-Schema * via the `definition` "SessionFsWriteFileRequest". */ +/** @experimental */ export interface SessionFsWriteFileRequest { /** * Target session identifier @@ -9475,6 +9668,7 @@ export interface SessionMcpAppsCallToolResult { * This interface was referenced by `_RpcSchemaRoot`'s JSON-Schema * via the `definition` "SessionFsSqliteExistsRequest". */ +/** @experimental */ export interface SessionFsSqliteExistsRequest { /** * Target session identifier @@ -9764,7 +9958,7 @@ export function createServerRpc(connection: MessageConnection) { * * @param params Session metadata records to enrich with summary and context information. * - * @returns The same metadata records, with summary and context fields backfilled where available. + * @returns The enriched metadata records, with summary and context fields backfilled where available. Sessions confirmed empty and unnamed are omitted. */ enrichMetadata: async (params: SessionsEnrichMetadataRequest): Promise => connection.sendRequest("sessions.enrichMetadata", params), @@ -11048,6 +11242,7 @@ export function createSessionRpc(connection: MessageConnection, sessionId: strin } /** Handler for `sessionFs` client session API methods. */ +/** @experimental */ export interface SessionFsHandler { /** * Reads a file from the client-provided session filesystem. @@ -11147,9 +11342,37 @@ export interface SessionFsHandler { sqliteExists(params: SessionFsSqliteExistsRequest): Promise; } +/** Handler for `canvas` client session API methods. */ +/** @experimental */ +export interface CanvasHandler { + /** + * Opens a canvas instance on the provider. + * + * @param params Canvas open parameters sent to the provider. + * + * @returns Canvas open result returned by the provider. + */ + open(params: CanvasProviderOpenRequest): Promise; + /** + * Closes a canvas instance on the provider. + * + * @param params Canvas close parameters sent to the provider. + */ + close(params: CanvasProviderCloseRequest): Promise; + /** + * Invokes an action on an open canvas instance via the provider. + * + * @param params Canvas action invocation parameters sent to the provider. + * + * @returns Provider-supplied action result. + */ + invokeAction(params: CanvasProviderInvokeActionRequest): Promise; +} + /** All client session API handler groups. */ export interface ClientSessionApiHandlers { sessionFs?: SessionFsHandler; + canvas?: CanvasHandler; } /** @@ -11222,4 +11445,19 @@ export function registerClientSessionApiHandlers( if (!handler) throw new Error(`No sessionFs handler registered for session: ${params.sessionId}`); return handler.sqliteExists(params); }); + connection.onRequest("canvas.open", async (params: CanvasProviderOpenRequest) => { + const handler = getHandlers(params.sessionId).canvas; + if (!handler) throw new Error(`No canvas handler registered for session: ${params.sessionId}`); + return handler.open(params); + }); + connection.onRequest("canvas.close", async (params: CanvasProviderCloseRequest) => { + const handler = getHandlers(params.sessionId).canvas; + if (!handler) throw new Error(`No canvas handler registered for session: ${params.sessionId}`); + return handler.close(params); + }); + connection.onRequest("canvas.invokeAction", async (params: CanvasProviderInvokeActionRequest) => { + const handler = getHandlers(params.sessionId).canvas; + if (!handler) throw new Error(`No canvas handler registered for session: ${params.sessionId}`); + return handler.invokeAction(params); + }); } diff --git a/nodejs/src/index.ts b/nodejs/src/index.ts index 42498c58f..c39621c0b 100644 --- a/nodejs/src/index.ts +++ b/nodejs/src/index.ts @@ -16,13 +16,9 @@ export { CanvasError, createCanvas, type CanvasAction, - type CanvasActionContext, type CanvasDeclaration, type CanvasHostContext, type CanvasJsonSchema, - type CanvasLifecycleContext, - type CanvasOpenContext, - type CanvasOpenResponse, type CanvasOptions, } from "./canvas.js"; export { diff --git a/nodejs/src/session.ts b/nodejs/src/session.ts index 74823602e..76cdb577d 100644 --- a/nodejs/src/session.ts +++ b/nodejs/src/session.ts @@ -8,10 +8,10 @@ */ import type { MessageConnection } from "vscode-jsonrpc/node.js"; -import { ConnectionError, ResponseError } from "vscode-jsonrpc/node.js"; +import { ConnectionError, ErrorCodes, 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 { ClientSessionApiHandlers, CanvasInvokeActionResult } from "./generated/rpc.js"; +import { type Canvas, CanvasError } from "./canvas.js"; import type { OpenCanvasInstance } from "./generated/rpc.js"; import { getTraceContext } from "./telemetry.js"; import type { @@ -647,23 +647,53 @@ export class CopilotSession { */ registerCanvases(canvases?: Canvas[]): void { this.canvases.clear(); - if (!canvases) { + if (!canvases || canvases.length === 0) { + delete this.clientSessionApis.canvas; 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); + const self = this; + this.clientSessionApis.canvas = { + async open(params) { + const canvas = self.canvases.get(params.canvasId); + if (!canvas) throw new Error(`No canvas registered with id "${params.canvasId}"`); + try { + return (await canvas.open(params)) ?? {}; + } catch (error) { + throw toCanvasRpcError(error); + } + }, + async close(params) { + const canvas = self.canvases.get(params.canvasId); + if (!canvas) throw new Error(`No canvas registered with id "${params.canvasId}"`); + try { + if (canvas.onClose) { + await canvas.onClose(params); + } + } catch (error) { + throw toCanvasRpcError(error); + } + }, + async invokeAction(params) { + const canvas = self.canvases.get(params.canvasId); + if (!canvas) throw new Error(`No canvas registered with id "${params.canvasId}"`); + const handler = canvas.actionHandlers.get(params.actionName); + if (!handler) { + throw new CanvasError( + "canvas_action_no_handler", + "No handler implemented for this canvas action" + ); + } + try { + return (await handler(params)) as CanvasInvokeActionResult; + } catch (error) { + throw toCanvasRpcError(error); + } + }, + }; } /** @@ -1197,3 +1227,11 @@ function isToolResultObject(value: unknown): value is ToolResultObject { return allowedResultTypes.includes((value as ToolResultObject).resultType); } + +/** Convert a canvas handler error into a ResponseError with a structured data envelope. */ +function toCanvasRpcError(error: unknown): ResponseError { + if (error instanceof ResponseError) return error; + const code = error instanceof CanvasError ? error.code : "canvas_handler_error"; + const message = error instanceof Error ? error.message : String(error); + return new ResponseError(ErrorCodes.InternalError, message, { code, message }); +} diff --git a/nodejs/test/client.test.ts b/nodejs/test/client.test.ts index ff46c75b3..ccaa6ef59 100644 --- a/nodejs/test/client.test.ts +++ b/nodejs/test/client.test.ts @@ -105,7 +105,7 @@ describe("CopilotClient", () => { expect(payload.openCanvasInstances).toBeUndefined(); }); - it("routes direct canvas action requests to registered canvases", async () => { + it("routes canvas.invokeAction to registered canvas action handlers via clientSessionApis", async () => { const canvas = createCanvas({ id: "counter", displayName: "Counter", @@ -120,10 +120,8 @@ describe("CopilotClient", () => { }); 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", { + const result = await session.clientSessionApis.canvas!.invokeAction({ sessionId: session.sessionId, extensionId: "project:counter", canvasId: "counter", @@ -145,11 +143,9 @@ describe("CopilotClient", () => { 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", { + session.clientSessionApis.canvas!.invokeAction({ sessionId: session.sessionId, extensionId: "project:counter", canvasId: "counter", @@ -160,52 +156,18 @@ describe("CopilotClient", () => { ).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" })); + it("throws for unknown canvasId in canvas.open via clientSessionApis", async () => { + const session = new CopilotSession("session-1", {} as any); const canvas = createCanvas({ - id: "counter", - displayName: "Counter", - description: "A counter canvas", - open, + id: "other", + displayName: "Other", + description: "Some other canvas", + open: () => ({ url: "https://example.test/other" }), }); - 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", { + session.clientSessionApis.canvas!.open({ sessionId: session.sessionId, extensionId: "project:missing", canvasId: "missing", diff --git a/nodejs/test/e2e/canvas.e2e.test.ts b/nodejs/test/e2e/canvas.e2e.test.ts new file mode 100644 index 000000000..d08cba9ea --- /dev/null +++ b/nodejs/test/e2e/canvas.e2e.test.ts @@ -0,0 +1,181 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +import { describe, expect, it } from "vitest"; +import { approveAll, createCanvas } from "../../src/index.js"; +import { createSdkTestContext } from "./harness/sdkTestContext.js"; + +describe("Canvas RPC", async () => { + const openCalls: Array<{ canvasId: string; instanceId: string; input?: unknown }> = []; + const closeCalls: Array<{ canvasId: string; instanceId: string }> = []; + const actionCalls: Array<{ + canvasId: string; + instanceId: string; + actionName: string; + input?: unknown; + }> = []; + + const counter = createCanvas({ + id: "counter", + displayName: "Counter", + description: "A simple counter canvas for e2e testing", + inputSchema: { + type: "object", + properties: { startValue: { type: "number" } }, + }, + actions: [ + { + name: "increment", + description: "Increment the counter", + inputSchema: { + type: "object", + properties: { amount: { type: "number" } }, + }, + handler: (ctx) => { + actionCalls.push({ + canvasId: ctx.canvasId, + instanceId: ctx.instanceId, + actionName: ctx.actionName, + input: ctx.input, + }); + return { newValue: 42 }; + }, + }, + ], + open: (ctx) => { + openCalls.push({ + canvasId: ctx.canvasId, + instanceId: ctx.instanceId, + input: ctx.input, + }); + return { + url: "https://example.test/counter", + title: "Counter Canvas", + status: "ready", + }; + }, + onClose: (ctx) => { + closeCalls.push({ + canvasId: ctx.canvasId, + instanceId: ctx.instanceId, + }); + }, + }); + + const { copilotClient: client } = await createSdkTestContext(); + + it("discovers declared canvases via session.canvas.list", async () => { + const session = await client.createSession({ + onPermissionRequest: approveAll, + canvases: [counter], + }); + + const result = await session.rpc.canvas.list(); + expect(result.canvases).toHaveLength(1); + expect(result.canvases[0]).toMatchObject({ + canvasId: "counter", + displayName: "Counter", + description: "A simple counter canvas for e2e testing", + }); + + await session.disconnect(); + }); + + it("opens a canvas instance via session.canvas.open round-trip", async () => { + openCalls.length = 0; + + const session = await client.createSession({ + onPermissionRequest: approveAll, + canvases: [counter], + }); + + const result = await session.rpc.canvas.open({ + canvasId: "counter", + instanceId: "counter-1", + input: { startValue: 10 }, + }); + + expect(result.url).toBe("https://example.test/counter"); + expect(result.title).toBe("Counter Canvas"); + expect(openCalls).toHaveLength(1); + expect(openCalls[0]).toMatchObject({ + canvasId: "counter", + instanceId: "counter-1", + input: { startValue: 10 }, + }); + + // Verify it appears in the open list + const openList = await session.rpc.canvas.listOpen(); + expect(openList.openCanvases).toHaveLength(1); + expect(openList.openCanvases[0]).toMatchObject({ + canvasId: "counter", + instanceId: "counter-1", + }); + + await session.disconnect(); + }); + + it("invokes an action on an open canvas instance", async () => { + openCalls.length = 0; + actionCalls.length = 0; + + const session = await client.createSession({ + onPermissionRequest: approveAll, + canvases: [counter], + }); + + await session.rpc.canvas.open({ + canvasId: "counter", + instanceId: "counter-2", + input: {}, + }); + + const result = await session.rpc.canvas.invokeAction({ + instanceId: "counter-2", + actionName: "increment", + input: { amount: 5 }, + }); + + expect(result.result).toEqual({ newValue: 42 }); + expect(actionCalls).toHaveLength(1); + expect(actionCalls[0]).toMatchObject({ + canvasId: "counter", + instanceId: "counter-2", + actionName: "increment", + input: { amount: 5 }, + }); + + await session.disconnect(); + }); + + it("closes an open canvas instance via session.canvas.close", async () => { + openCalls.length = 0; + closeCalls.length = 0; + + const session = await client.createSession({ + onPermissionRequest: approveAll, + canvases: [counter], + }); + + await session.rpc.canvas.open({ + canvasId: "counter", + instanceId: "counter-3", + input: {}, + }); + expect(closeCalls).toHaveLength(0); + + await session.rpc.canvas.close({ instanceId: "counter-3" }); + expect(closeCalls).toHaveLength(1); + expect(closeCalls[0]).toMatchObject({ + canvasId: "counter", + instanceId: "counter-3", + }); + + // Verify it's no longer in the open list + const openList = await session.rpc.canvas.listOpen(); + expect(openList.openCanvases).toHaveLength(0); + + await session.disconnect(); + }); +}); diff --git a/python/copilot/__init__.py b/python/copilot/__init__.py index 874267c9f..175d032a9 100644 --- a/python/copilot/__init__.py +++ b/python/copilot/__init__.py @@ -6,15 +6,12 @@ from .canvas import ( CanvasAction, - CanvasActionContext, CanvasDeclaration, CanvasError, CanvasHandler, - CanvasHostCapabilities, CanvasHostContext, - CanvasLifecycleContext, - CanvasOpenContext, - CanvasOpenResponse, + CanvasHostContextCapabilities, + CanvasJsonSchema, ExtensionInfo, OpenCanvasInstance, ) @@ -145,15 +142,12 @@ "AutoModeSwitchRequest", "AutoModeSwitchResponse", "CanvasAction", - "CanvasActionContext", "CanvasDeclaration", "CanvasError", "CanvasHandler", - "CanvasHostCapabilities", "CanvasHostContext", - "CanvasLifecycleContext", - "CanvasOpenContext", - "CanvasOpenResponse", + "CanvasHostContextCapabilities", + "CanvasJsonSchema", "ChildProcessRuntimeConnection", "CloudSessionOptions", "CloudSessionRepository", diff --git a/python/copilot/canvas.py b/python/copilot/canvas.py index 58c5b297d..772c15b39 100644 --- a/python/copilot/canvas.py +++ b/python/copilot/canvas.py @@ -1,33 +1,39 @@ """ 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`). +The Copilot CLI runtime sends inbound canvas JSON-RPC requests 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 (for example by switching on +``ctx.canvas_id``). """ from __future__ import annotations from abc import ABC, abstractmethod -from dataclasses import dataclass, field +from dataclasses import dataclass from typing import Any -from .generated.rpc import CanvasAction, OpenCanvasInstance +from .generated.rpc import ( + CanvasAction, + CanvasHostContext, + CanvasHostContextCapabilities, + CanvasJsonSchema, + CanvasProviderCloseRequest, + CanvasProviderInvokeActionRequest, + CanvasProviderOpenRequest, + CanvasProviderOpenResult, + OpenCanvasInstance, +) __all__ = [ "CanvasAction", - "CanvasActionContext", "CanvasDeclaration", "CanvasError", "CanvasHandler", - "CanvasHostCapabilities", "CanvasHostContext", - "CanvasLifecycleContext", - "CanvasOpenContext", - "CanvasOpenResponse", + "CanvasHostContextCapabilities", + "CanvasJsonSchema", "ExtensionInfo", "OpenCanvasInstance", ] @@ -52,9 +58,7 @@ def to_dict(self) -> dict[str, Any]: @dataclass class CanvasDeclaration: - """Declarative metadata for a single canvas, sent on - ``session.create`` / ``session.resume``. - """ + """Declarative metadata for a single canvas, sent on create/resume.""" id: str """Canvas identifier, unique within the declaring connection.""" @@ -63,9 +67,9 @@ class CanvasDeclaration: """Human-readable name shown in host UI and canvas pickers.""" description: str - """Short, single-sentence description shown to the agent in canvas catalogs.""" + """Short description shown to the agent in canvas catalogs.""" - input_schema: dict[str, Any] | None = None + input_schema: CanvasJsonSchema | None = None """JSON Schema for the ``input`` payload accepted by ``canvas.open``.""" actions: list[CanvasAction] | None = None @@ -84,136 +88,8 @@ def to_dict(self) -> dict[str, Any]: 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. - """ + """Structured error returned from canvas handlers.""" def __init__(self, code: str, message: str) -> None: self.code = code @@ -242,71 +118,19 @@ def handler_unset(cls) -> CanvasError: 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. - """ + """Provider-side canvas lifecycle handler.""" @abstractmethod - async def on_open(self, ctx: CanvasOpenContext) -> CanvasOpenResponse: + async def on_open(self, ctx: CanvasProviderOpenRequest) -> CanvasProviderOpenResult: """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: + async def on_close(self, ctx: CanvasProviderCloseRequest) -> 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`. - """ + async def on_action(self, ctx: CanvasProviderInvokeActionRequest) -> Any: + """Handle a non-lifecycle action declared by the canvas.""" 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 5e795bdde..4386adb08 100644 --- a/python/copilot/client.py +++ b/python/copilot/client.py @@ -38,12 +38,8 @@ 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, @@ -3038,18 +3034,6 @@ 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 @@ -3169,18 +3153,6 @@ 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 @@ -3310,113 +3282,3 @@ 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/generated/rpc.py b/python/copilot/generated/rpc.py index b438ae5dc..e18f83f8b 100644 --- a/python/copilot/generated/rpc.py +++ b/python/copilot/generated/rpc.py @@ -357,26 +357,6 @@ def to_dict(self) -> dict: result["input"] = self.input return result -# Experimental: this type is part of an experimental API and may change or be removed. -@dataclass -class CanvasInvokeActionResult: - """Canvas action invocation result.""" - - result: Any = None - """Provider-supplied action result""" - - @staticmethod - def from_dict(obj: Any) -> 'CanvasInvokeActionResult': - assert isinstance(obj, dict) - result = obj.get("result") - return CanvasInvokeActionResult(result) - - def to_dict(self) -> dict: - result: dict = {} - if self.result is not None: - result["result"] = self.result - return result - # Experimental: this type is part of an experimental API and may change or be removed. @dataclass class CanvasOpenRequest: @@ -414,6 +394,38 @@ def to_dict(self) -> dict: result["input"] = self.input return result +# Experimental: this type is part of an experimental API and may change or be removed. +@dataclass +class CanvasProviderOpenResult: + """Canvas open result returned by the provider.""" + + status: str | None = None + """Provider-supplied status text""" + + title: str | None = None + """Provider-supplied title""" + + url: str | None = None + """URL for web-rendered canvases""" + + @staticmethod + def from_dict(obj: Any) -> 'CanvasProviderOpenResult': + assert isinstance(obj, dict) + status = from_union([from_str, from_none], obj.get("status")) + title = from_union([from_str, from_none], obj.get("title")) + url = from_union([from_str, from_none], obj.get("url")) + return CanvasProviderOpenResult(status, title, url) + + def to_dict(self) -> dict: + result: dict = {} + if self.status is not None: + result["status"] = from_union([from_str, from_none], self.status) + if self.title is not None: + result["title"] = from_union([from_str, from_none], self.title) + if self.url is not None: + result["url"] = from_union([from_str, from_none], self.url) + return result + # Experimental: this type is part of an experimental API and may change or be removed. class SlashCommandInputCompletion(Enum): """Optional completion hint for the input (e.g. 'directory' for filesystem path completion)""" @@ -1822,17 +1834,17 @@ def to_dict(self) -> dict: return result @dataclass -class MCPServerConfigHTTPAuth: +class AuthAuth: """Additional authentication configuration for this server.""" redirect_port: int | None = None """Fixed port for the OAuth redirect callback server.""" @staticmethod - def from_dict(obj: Any) -> 'MCPServerConfigHTTPAuth': + def from_dict(obj: Any) -> 'AuthAuth': assert isinstance(obj, dict) redirect_port = from_union([from_int, from_none], obj.get("redirectPort")) - return MCPServerConfigHTTPAuth(redirect_port) + return AuthAuth(redirect_port) def to_dict(self) -> dict: result: dict = {} @@ -4134,6 +4146,7 @@ def to_dict(self) -> dict: result["freedBytes"] = from_dict(from_int, self.freed_bytes) return result +# Experimental: this type is part of an experimental API and may change or be removed. @dataclass class SessionFSAppendFileRequest: """File path, content to append, and optional mode for the client-provided session @@ -4169,12 +4182,14 @@ def to_dict(self) -> dict: result["mode"] = from_union([from_int, from_none], self.mode) return result +# Experimental: this type is part of an experimental API and may change or be removed. class SessionFSErrorCode(Enum): """Error classification""" ENOENT = "ENOENT" UNKNOWN = "UNKNOWN" +# Experimental: this type is part of an experimental API and may change or be removed. @dataclass class SessionFSExistsRequest: """Path to test for existence in the client-provided session filesystem.""" @@ -4198,6 +4213,7 @@ def to_dict(self) -> dict: result["sessionId"] = from_str(self.session_id) return result +# Experimental: this type is part of an experimental API and may change or be removed. @dataclass class SessionFSExistsResult: """Indicates whether the requested path exists in the client-provided session filesystem.""" @@ -4216,6 +4232,7 @@ def to_dict(self) -> dict: result["exists"] = from_bool(self.exists) return result +# Experimental: this type is part of an experimental API and may change or be removed. @dataclass class SessionFSMkdirRequest: """Directory path to create in the client-provided session filesystem, with options for @@ -4252,6 +4269,7 @@ def to_dict(self) -> dict: result["recursive"] = from_union([from_bool, from_none], self.recursive) return result +# Experimental: this type is part of an experimental API and may change or be removed. @dataclass class SessionFSReadFileRequest: """Path of the file to read from the client-provided session filesystem.""" @@ -4275,6 +4293,7 @@ def to_dict(self) -> dict: result["sessionId"] = from_str(self.session_id) return result +# Experimental: this type is part of an experimental API and may change or be removed. @dataclass class SessionFSReaddirRequest: """Directory path whose entries should be listed from the client-provided session filesystem.""" @@ -4298,12 +4317,14 @@ def to_dict(self) -> dict: result["sessionId"] = from_str(self.session_id) return result +# Experimental: this type is part of an experimental API and may change or be removed. class SessionFSReaddirWithTypesEntryType(Enum): """Entry type""" DIRECTORY = "directory" FILE = "file" +# Experimental: this type is part of an experimental API and may change or be removed. @dataclass class SessionFSReaddirWithTypesRequest: """Directory path whose entries (with type information) should be listed from the @@ -4328,6 +4349,7 @@ def to_dict(self) -> dict: result["sessionId"] = from_str(self.session_id) return result +# Experimental: this type is part of an experimental API and may change or be removed. @dataclass class SessionFSRenameRequest: """Source and destination paths for renaming or moving an entry in the client-provided @@ -4357,6 +4379,7 @@ def to_dict(self) -> dict: result["src"] = from_str(self.src) return result +# Experimental: this type is part of an experimental API and may change or be removed. @dataclass class SessionFSRmRequest: """Path to remove from the client-provided session filesystem, with options for recursive @@ -4436,6 +4459,7 @@ def to_dict(self) -> dict: result["success"] = from_bool(self.success) return result +# Experimental: this type is part of an experimental API and may change or be removed. @dataclass class SessionFSSqliteExistsRequest: """Identifies the target session.""" @@ -4454,6 +4478,7 @@ def to_dict(self) -> dict: result["sessionId"] = from_str(self.session_id) return result +# Experimental: this type is part of an experimental API and may change or be removed. @dataclass class SessionFSSqliteExistsResult: """Indicates whether the per-session SQLite database already exists.""" @@ -4472,6 +4497,7 @@ def to_dict(self) -> dict: result["exists"] = from_bool(self.exists) return result +# Experimental: this type is part of an experimental API and may change or be removed. class SessionFSSqliteQueryType(Enum): """How to execute the query: 'exec' for DDL/multi-statement (no results), 'query' for SELECT (returns rows), 'run' for INSERT/UPDATE/DELETE (returns rowsAffected) @@ -4480,6 +4506,7 @@ class SessionFSSqliteQueryType(Enum): QUERY = "query" RUN = "run" +# Experimental: this type is part of an experimental API and may change or be removed. @dataclass class SessionFSStatRequest: """Path whose metadata should be returned from the client-provided session filesystem.""" @@ -4503,6 +4530,7 @@ def to_dict(self) -> dict: result["sessionId"] = from_str(self.session_id) return result +# Experimental: this type is part of an experimental API and may change or be removed. @dataclass class SessionFSWriteFileRequest: """File path, content to write, and optional mode for the client-provided session filesystem.""" @@ -7049,6 +7077,9 @@ class MCPServerConfigStdio: args: list[str] | None = None """Command-line arguments passed to the Stdio MCP server process.""" + auth: bool | dict[str, Any] | None = None + """Authentication configuration for this server.""" + cwd: str | None = None """Working directory for the Stdio MCP server process.""" @@ -7063,6 +7094,9 @@ class MCPServerConfigStdio: """Whether this server is a built-in fallback used when the user has not configured their own server. """ + oidc: bool | dict[str, Any] | None = None + """OIDC token configuration. When truthy, a token is automatically gathered.""" + timeout: int | None = None """Timeout in milliseconds for tool calls to this server.""" @@ -7074,19 +7108,23 @@ def from_dict(obj: Any) -> 'MCPServerConfigStdio': assert isinstance(obj, dict) command = from_str(obj.get("command")) args = from_union([lambda x: from_list(from_str, x), from_none], obj.get("args")) + auth = from_union([from_bool, lambda x: from_dict(lambda x: x, x), from_none], obj.get("auth")) cwd = from_union([from_str, from_none], obj.get("cwd")) env = from_union([lambda x: from_dict(from_str, x), from_none], obj.get("env")) filter_mapping = from_union([lambda x: from_dict(ContentFilterMode, x), ContentFilterMode, from_none], obj.get("filterMapping")) is_default_server = from_union([from_bool, from_none], obj.get("isDefaultServer")) + oidc = from_union([from_bool, lambda x: from_dict(lambda x: x, x), from_none], obj.get("oidc")) timeout = from_union([from_int, from_none], obj.get("timeout")) tools = from_union([lambda x: from_list(from_str, x), from_none], obj.get("tools")) - return MCPServerConfigStdio(command, args, cwd, env, filter_mapping, is_default_server, timeout, tools) + return MCPServerConfigStdio(command, args, auth, cwd, env, filter_mapping, is_default_server, oidc, timeout, tools) def to_dict(self) -> dict: result: dict = {} result["command"] = from_str(self.command) if self.args is not None: result["args"] = from_union([lambda x: from_list(from_str, x), from_none], self.args) + if self.auth is not None: + result["auth"] = from_union([from_bool, lambda x: from_dict(lambda x: x, x), from_none], self.auth) if self.cwd is not None: result["cwd"] = from_union([from_str, from_none], self.cwd) if self.env is not None: @@ -7095,12 +7133,34 @@ def to_dict(self) -> dict: result["filterMapping"] = from_union([lambda x: from_dict(lambda x: to_enum(ContentFilterMode, x), x), lambda x: to_enum(ContentFilterMode, x), from_none], self.filter_mapping) if self.is_default_server is not None: result["isDefaultServer"] = from_union([from_bool, from_none], self.is_default_server) + if self.oidc is not None: + result["oidc"] = from_union([from_bool, lambda x: from_dict(lambda x: x, x), from_none], self.oidc) if self.timeout is not None: result["timeout"] = from_union([from_int, from_none], self.timeout) if self.tools is not None: result["tools"] = from_union([lambda x: from_list(from_str, x), from_none], self.tools) return result +# Experimental: this type is part of an experimental API and may change or be removed. +@dataclass +class CanvasHostContextCapabilities: + """Host capabilities""" + + canvases: bool | None = None + """Whether canvas rendering is supported""" + + @staticmethod + def from_dict(obj: Any) -> 'CanvasHostContextCapabilities': + assert isinstance(obj, dict) + canvases = from_union([from_bool, from_none], obj.get("canvases")) + return CanvasHostContextCapabilities(canvases) + + def to_dict(self) -> dict: + result: dict = {} + if self.canvases is not None: + result["canvases"] = from_union([from_bool, from_none], self.canvases) + return result + # Experimental: this type is part of an experimental API and may change or be removed. @dataclass class CopilotUserResponseQuotaSnapshots: @@ -8092,6 +8152,11 @@ class MCPServerConfig: args: list[str] | None = None """Command-line arguments passed to the Stdio MCP server process.""" + auth: bool | AuthAuth | None = None + """Authentication configuration for this server. + + Additional authentication configuration for this server. + """ command: str | None = None """Executable command used to start the Stdio MCP server process.""" @@ -8109,15 +8174,15 @@ class MCPServerConfig: """Whether this server is a built-in fallback used when the user has not configured their own server. """ + oidc: bool | dict[str, Any] | None = None + """OIDC token configuration. When truthy, a token is automatically gathered.""" + timeout: int | None = None """Timeout in milliseconds for tool calls to this server.""" tools: list[str] | None = None """Tools to include. Defaults to all tools if not specified.""" - auth: MCPServerConfigHTTPAuth | None = None - """Additional authentication configuration for this server.""" - headers: dict[str, str] | None = None """HTTP headers to include in requests to the remote MCP server.""" @@ -8140,26 +8205,29 @@ class MCPServerConfig: def from_dict(obj: Any) -> 'MCPServerConfig': assert isinstance(obj, dict) args = from_union([lambda x: from_list(from_str, x), from_none], obj.get("args")) + auth = from_union([from_bool, AuthAuth.from_dict, from_none], obj.get("auth")) command = from_union([from_str, from_none], obj.get("command")) cwd = from_union([from_str, from_none], obj.get("cwd")) env = from_union([lambda x: from_dict(from_str, x), from_none], obj.get("env")) filter_mapping = from_union([lambda x: from_dict(ContentFilterMode, x), ContentFilterMode, from_none], obj.get("filterMapping")) is_default_server = from_union([from_bool, from_none], obj.get("isDefaultServer")) + oidc = from_union([from_bool, lambda x: from_dict(lambda x: x, x), from_none], obj.get("oidc")) timeout = from_union([from_int, from_none], obj.get("timeout")) tools = from_union([lambda x: from_list(from_str, x), from_none], obj.get("tools")) - auth = from_union([MCPServerConfigHTTPAuth.from_dict, from_none], obj.get("auth")) headers = from_union([lambda x: from_dict(from_str, x), from_none], obj.get("headers")) oauth_client_id = from_union([from_str, from_none], obj.get("oauthClientId")) oauth_grant_type = from_union([MCPServerConfigHTTPOauthGrantType, from_none], obj.get("oauthGrantType")) oauth_public_client = from_union([from_bool, from_none], obj.get("oauthPublicClient")) type = from_union([MCPServerConfigHTTPType, from_none], obj.get("type")) url = from_union([from_str, from_none], obj.get("url")) - return MCPServerConfig(args, command, cwd, env, filter_mapping, is_default_server, timeout, tools, auth, headers, oauth_client_id, oauth_grant_type, oauth_public_client, type, url) + return MCPServerConfig(args, auth, command, cwd, env, filter_mapping, is_default_server, oidc, timeout, tools, headers, oauth_client_id, oauth_grant_type, oauth_public_client, type, url) def to_dict(self) -> dict: result: dict = {} if self.args is not None: result["args"] = from_union([lambda x: from_list(from_str, x), from_none], self.args) + if self.auth is not None: + result["auth"] = from_union([from_bool, lambda x: to_class(AuthAuth, x), from_none], self.auth) if self.command is not None: result["command"] = from_union([from_str, from_none], self.command) if self.cwd is not None: @@ -8170,12 +8238,12 @@ def to_dict(self) -> dict: result["filterMapping"] = from_union([lambda x: from_dict(lambda x: to_enum(ContentFilterMode, x), x), lambda x: to_enum(ContentFilterMode, x), from_none], self.filter_mapping) if self.is_default_server is not None: result["isDefaultServer"] = from_union([from_bool, from_none], self.is_default_server) + if self.oidc is not None: + result["oidc"] = from_union([from_bool, lambda x: from_dict(lambda x: x, x), from_none], self.oidc) if self.timeout is not None: result["timeout"] = from_union([from_int, from_none], self.timeout) if self.tools is not None: result["tools"] = from_union([lambda x: from_list(from_str, x), from_none], self.tools) - if self.auth is not None: - result["auth"] = from_union([lambda x: to_class(MCPServerConfigHTTPAuth, x), from_none], self.auth) if self.headers is not None: result["headers"] = from_union([lambda x: from_dict(from_str, x), from_none], self.headers) if self.oauth_client_id is not None: @@ -8197,7 +8265,7 @@ class MCPServerConfigHTTP: url: str """URL of the remote MCP server endpoint.""" - auth: MCPServerConfigHTTPAuth | None = None + auth: AuthAuth | None = None """Additional authentication configuration for this server.""" filter_mapping: dict[str, ContentFilterMode] | ContentFilterMode | None = None @@ -8220,6 +8288,9 @@ class MCPServerConfigHTTP: oauth_public_client: bool | None = None """Whether the configured OAuth client is public and does not require a client secret.""" + oidc: bool | dict[str, Any] | None = None + """OIDC token configuration. When truthy, a token is automatically gathered.""" + timeout: int | None = None """Timeout in milliseconds for tool calls to this server.""" @@ -8233,23 +8304,24 @@ class MCPServerConfigHTTP: def from_dict(obj: Any) -> 'MCPServerConfigHTTP': assert isinstance(obj, dict) url = from_str(obj.get("url")) - auth = from_union([MCPServerConfigHTTPAuth.from_dict, from_none], obj.get("auth")) + auth = from_union([AuthAuth.from_dict, from_none], obj.get("auth")) filter_mapping = from_union([lambda x: from_dict(ContentFilterMode, x), ContentFilterMode, from_none], obj.get("filterMapping")) headers = from_union([lambda x: from_dict(from_str, x), from_none], obj.get("headers")) is_default_server = from_union([from_bool, from_none], obj.get("isDefaultServer")) oauth_client_id = from_union([from_str, from_none], obj.get("oauthClientId")) oauth_grant_type = from_union([MCPServerConfigHTTPOauthGrantType, from_none], obj.get("oauthGrantType")) oauth_public_client = from_union([from_bool, from_none], obj.get("oauthPublicClient")) + oidc = from_union([from_bool, lambda x: from_dict(lambda x: x, x), from_none], obj.get("oidc")) timeout = from_union([from_int, from_none], obj.get("timeout")) tools = from_union([lambda x: from_list(from_str, x), from_none], obj.get("tools")) type = from_union([MCPServerConfigHTTPType, from_none], obj.get("type")) - return MCPServerConfigHTTP(url, auth, filter_mapping, headers, is_default_server, oauth_client_id, oauth_grant_type, oauth_public_client, timeout, tools, type) + return MCPServerConfigHTTP(url, auth, filter_mapping, headers, is_default_server, oauth_client_id, oauth_grant_type, oauth_public_client, oidc, timeout, tools, type) def to_dict(self) -> dict: result: dict = {} result["url"] = from_str(self.url) if self.auth is not None: - result["auth"] = from_union([lambda x: to_class(MCPServerConfigHTTPAuth, x), from_none], self.auth) + result["auth"] = from_union([lambda x: to_class(AuthAuth, x), from_none], self.auth) if self.filter_mapping is not None: result["filterMapping"] = from_union([lambda x: from_dict(lambda x: to_enum(ContentFilterMode, x), x), lambda x: to_enum(ContentFilterMode, x), from_none], self.filter_mapping) if self.headers is not None: @@ -8262,6 +8334,8 @@ def to_dict(self) -> dict: result["oauthGrantType"] = from_union([lambda x: to_enum(MCPServerConfigHTTPOauthGrantType, x), from_none], self.oauth_grant_type) if self.oauth_public_client is not None: result["oauthPublicClient"] = from_union([from_bool, from_none], self.oauth_public_client) + if self.oidc is not None: + result["oidc"] = from_union([from_bool, lambda x: from_dict(lambda x: x, x), from_none], self.oidc) if self.timeout is not None: result["timeout"] = from_union([from_int, from_none], self.timeout) if self.tools is not None: @@ -10183,6 +10257,7 @@ def to_dict(self) -> dict: result["skills"] = from_list(lambda x: to_class(ServerSkill, x), self.skills) return result +# Experimental: this type is part of an experimental API and may change or be removed. @dataclass class SessionFSError: """Describes a filesystem error.""" @@ -10207,6 +10282,7 @@ def to_dict(self) -> dict: result["message"] = from_union([from_str, from_none], self.message) return result +# Experimental: this type is part of an experimental API and may change or be removed. @dataclass class SessionFSReaddirWithTypesEntry: """Schema for the `SessionFsReaddirWithTypesEntry` type.""" @@ -10265,6 +10341,7 @@ def to_dict(self) -> dict: result["capabilities"] = from_union([lambda x: to_class(SessionFSSetProviderCapabilities, x), from_none], self.capabilities) return result +# Experimental: this type is part of an experimental API and may change or be removed. @dataclass class SessionFSSqliteQueryRequest: """SQL query, query type, and optional bind parameters for executing a SQLite query against @@ -11479,6 +11556,26 @@ def to_dict(self) -> dict: result["sessionId"] = from_str(self.session_id) return result +# Experimental: this type is part of an experimental API and may change or be removed. +@dataclass +class CanvasHostContext: + """Host context supplied by the runtime.""" + + capabilities: CanvasHostContextCapabilities | None = None + """Host capabilities""" + + @staticmethod + def from_dict(obj: Any) -> 'CanvasHostContext': + assert isinstance(obj, dict) + capabilities = from_union([CanvasHostContextCapabilities.from_dict, from_none], obj.get("capabilities")) + return CanvasHostContext(capabilities) + + def to_dict(self) -> dict: + result: dict = {} + if self.capabilities is not None: + result["capabilities"] = from_union([lambda x: to_class(CanvasHostContextCapabilities, x), from_none], self.capabilities) + return result + # Experimental: this type is part of an experimental API and may change or be removed. @dataclass class CopilotUserResponse: @@ -12428,6 +12525,7 @@ def to_dict(self) -> dict: result["type"] = self.type return result +# Experimental: this type is part of an experimental API and may change or be removed. @dataclass class SessionFSReadFileResult: """File content as a UTF-8 string, or a filesystem error if the read failed.""" @@ -12452,6 +12550,7 @@ def to_dict(self) -> dict: result["error"] = from_union([lambda x: to_class(SessionFSError, x), from_none], self.error) return result +# Experimental: this type is part of an experimental API and may change or be removed. @dataclass class SessionFSReaddirResult: """Names of entries in the requested directory, or a filesystem error if the read failed.""" @@ -12476,6 +12575,7 @@ def to_dict(self) -> dict: result["error"] = from_union([lambda x: to_class(SessionFSError, x), from_none], self.error) return result +# Experimental: this type is part of an experimental API and may change or be removed. @dataclass class SessionFSSqliteQueryResult: """Query results including rows, columns, and rows affected, or a filesystem error if @@ -12517,6 +12617,7 @@ def to_dict(self) -> dict: result["lastInsertRowid"] = from_union([from_int, from_none], self.last_insert_rowid) return result +# Experimental: this type is part of an experimental API and may change or be removed. @dataclass class SessionFSStatResult: """Filesystem metadata for the requested path, or a filesystem error if the stat failed.""" @@ -12561,6 +12662,7 @@ def to_dict(self) -> dict: result["error"] = from_union([lambda x: to_class(SessionFSError, x), from_none], self.error) return result +# Experimental: this type is part of an experimental API and may change or be removed. @dataclass class SessionFSReaddirWithTypesResult: """Entries in the requested directory paired with file/directory type information, or a @@ -13125,6 +13227,143 @@ def to_dict(self) -> dict: result["commands"] = from_list(lambda x: to_class(SlashCommandInfo, x), self.commands) return result +# Experimental: this type is part of an experimental API and may change or be removed. +@dataclass +class CanvasProviderCloseRequest: + """Canvas close parameters sent to the provider.""" + + canvas_id: str + """Provider-local canvas identifier""" + + extension_id: str + """Owning provider identifier""" + + instance_id: str + """Canvas instance identifier""" + + session_id: str + """Target session identifier""" + + host: CanvasHostContext | None = None + """Host context supplied by the runtime.""" + + @staticmethod + def from_dict(obj: Any) -> 'CanvasProviderCloseRequest': + assert isinstance(obj, dict) + canvas_id = from_str(obj.get("canvasId")) + extension_id = from_str(obj.get("extensionId")) + instance_id = from_str(obj.get("instanceId")) + session_id = from_str(obj.get("sessionId")) + host = from_union([CanvasHostContext.from_dict, from_none], obj.get("host")) + return CanvasProviderCloseRequest(canvas_id, extension_id, instance_id, session_id, host) + + def to_dict(self) -> dict: + result: dict = {} + result["canvasId"] = from_str(self.canvas_id) + result["extensionId"] = from_str(self.extension_id) + result["instanceId"] = from_str(self.instance_id) + result["sessionId"] = from_str(self.session_id) + if self.host is not None: + result["host"] = from_union([lambda x: to_class(CanvasHostContext, x), from_none], self.host) + return result + +# Experimental: this type is part of an experimental API and may change or be removed. +@dataclass +class CanvasProviderInvokeActionRequest: + """Canvas action invocation parameters sent to the provider.""" + + action_name: str + """Action name to invoke""" + + canvas_id: str + """Provider-local canvas identifier""" + + extension_id: str + """Owning provider identifier""" + + instance_id: str + """Canvas instance identifier""" + + session_id: str + """Target session identifier""" + + host: CanvasHostContext | None = None + """Host context supplied by the runtime.""" + + input: Any = None + """Action input""" + + @staticmethod + def from_dict(obj: Any) -> 'CanvasProviderInvokeActionRequest': + assert isinstance(obj, dict) + action_name = from_str(obj.get("actionName")) + canvas_id = from_str(obj.get("canvasId")) + extension_id = from_str(obj.get("extensionId")) + instance_id = from_str(obj.get("instanceId")) + session_id = from_str(obj.get("sessionId")) + host = from_union([CanvasHostContext.from_dict, from_none], obj.get("host")) + input = obj.get("input") + return CanvasProviderInvokeActionRequest(action_name, canvas_id, extension_id, instance_id, session_id, host, input) + + def to_dict(self) -> dict: + result: dict = {} + result["actionName"] = from_str(self.action_name) + result["canvasId"] = from_str(self.canvas_id) + result["extensionId"] = from_str(self.extension_id) + result["instanceId"] = from_str(self.instance_id) + result["sessionId"] = from_str(self.session_id) + if self.host is not None: + result["host"] = from_union([lambda x: to_class(CanvasHostContext, x), from_none], self.host) + if self.input is not None: + result["input"] = self.input + return result + +# Experimental: this type is part of an experimental API and may change or be removed. +@dataclass +class CanvasProviderOpenRequest: + """Canvas open parameters sent to the provider.""" + + canvas_id: str + """Provider-local canvas identifier""" + + extension_id: str + """Owning provider identifier""" + + instance_id: str + """Stable caller-supplied canvas instance identifier""" + + session_id: str + """Target session identifier""" + + host: CanvasHostContext | None = None + """Host context supplied by the runtime.""" + + input: Any = None + """Canvas open input""" + + @staticmethod + def from_dict(obj: Any) -> 'CanvasProviderOpenRequest': + assert isinstance(obj, dict) + canvas_id = from_str(obj.get("canvasId")) + extension_id = from_str(obj.get("extensionId")) + instance_id = from_str(obj.get("instanceId")) + session_id = from_str(obj.get("sessionId")) + host = from_union([CanvasHostContext.from_dict, from_none], obj.get("host")) + input = obj.get("input") + return CanvasProviderOpenRequest(canvas_id, extension_id, instance_id, session_id, host, input) + + def to_dict(self) -> dict: + result: dict = {} + result["canvasId"] = from_str(self.canvas_id) + result["extensionId"] = from_str(self.extension_id) + result["instanceId"] = from_str(self.instance_id) + result["sessionId"] = from_str(self.session_id) + if self.host is not None: + result["host"] = from_union([lambda x: to_class(CanvasHostContext, x), from_none], self.host) + if self.input is not None: + result["input"] = self.input + return result + # Experimental: this type is part of an experimental API and may change or be removed. @dataclass class APIKeyAuthInfo: @@ -13656,10 +13895,13 @@ def to_dict(self) -> dict: # Experimental: this type is part of an experimental API and may change or be removed. @dataclass class SessionEnrichMetadataResult: - """The same metadata records, with summary and context fields backfilled where available.""" - + """The enriched metadata records, with summary and context fields backfilled where + available. Sessions confirmed empty and unnamed are omitted. + """ sessions: list[SessionMetadata] - """Same records, with summary and context backfilled""" + """Enriched records, with summary and context backfilled. Sessions confirmed empty and + unnamed may be omitted. + """ @staticmethod def from_dict(obj: Any) -> 'SessionEnrichMetadataResult': @@ -14617,13 +14859,19 @@ class RPC: auth_info_type: AuthInfoType canvas_action: CanvasAction canvas_close_request: CanvasCloseRequest + canvas_host_context: CanvasHostContext + canvas_host_context_capabilities: CanvasHostContextCapabilities canvas_instance_availability: CanvasInstanceAvailability canvas_invoke_action_request: CanvasInvokeActionRequest - canvas_invoke_action_result: CanvasInvokeActionResult + canvas_invoke_action_result: Any canvas_json_schema: Any canvas_list: CanvasList canvas_list_open_result: CanvasListOpenResult canvas_open_request: CanvasOpenRequest + canvas_provider_close_request: CanvasProviderCloseRequest + canvas_provider_invoke_action_request: CanvasProviderInvokeActionRequest + canvas_provider_open_request: CanvasProviderOpenRequest + canvas_provider_open_result: CanvasProviderOpenResult command_list: CommandList commands_handle_pending_command_request: CommandsHandlePendingCommandRequest commands_handle_pending_command_result: CommandsHandlePendingCommandResult @@ -14756,10 +15004,13 @@ class RPC: mcp_server: MCPServer mcp_server_config: MCPServerConfig mcp_server_config_http: MCPServerConfigHTTP - mcp_server_config_http_auth: MCPServerConfigHTTPAuth + mcp_server_config_http_auth: AuthAuth mcp_server_config_http_oauth_grant_type: MCPServerConfigHTTPOauthGrantType + mcp_server_config_http_oidc: bool | dict[str, Any] mcp_server_config_http_type: MCPServerConfigHTTPType mcp_server_config_stdio: MCPServerConfigStdio + mcp_server_config_stdio_auth: bool | dict[str, Any] + mcp_server_config_stdio_oidc: bool | dict[str, Any] mcp_server_list: MCPServerList mcp_set_env_value_mode_details: MCPSetEnvValueModeDetails mcp_set_env_value_mode_params: MCPSetEnvValueModeParams @@ -15168,13 +15419,19 @@ def from_dict(obj: Any) -> 'RPC': auth_info_type = AuthInfoType(obj.get("AuthInfoType")) canvas_action = CanvasAction.from_dict(obj.get("CanvasAction")) canvas_close_request = CanvasCloseRequest.from_dict(obj.get("CanvasCloseRequest")) + canvas_host_context = CanvasHostContext.from_dict(obj.get("CanvasHostContext")) + canvas_host_context_capabilities = CanvasHostContextCapabilities.from_dict(obj.get("CanvasHostContextCapabilities")) canvas_instance_availability = CanvasInstanceAvailability(obj.get("CanvasInstanceAvailability")) canvas_invoke_action_request = CanvasInvokeActionRequest.from_dict(obj.get("CanvasInvokeActionRequest")) - canvas_invoke_action_result = CanvasInvokeActionResult.from_dict(obj.get("CanvasInvokeActionResult")) + canvas_invoke_action_result = obj.get("CanvasInvokeActionResult") canvas_json_schema = obj.get("CanvasJsonSchema") canvas_list = CanvasList.from_dict(obj.get("CanvasList")) canvas_list_open_result = CanvasListOpenResult.from_dict(obj.get("CanvasListOpenResult")) canvas_open_request = CanvasOpenRequest.from_dict(obj.get("CanvasOpenRequest")) + canvas_provider_close_request = CanvasProviderCloseRequest.from_dict(obj.get("CanvasProviderCloseRequest")) + canvas_provider_invoke_action_request = CanvasProviderInvokeActionRequest.from_dict(obj.get("CanvasProviderInvokeActionRequest")) + canvas_provider_open_request = CanvasProviderOpenRequest.from_dict(obj.get("CanvasProviderOpenRequest")) + canvas_provider_open_result = CanvasProviderOpenResult.from_dict(obj.get("CanvasProviderOpenResult")) command_list = CommandList.from_dict(obj.get("CommandList")) commands_handle_pending_command_request = CommandsHandlePendingCommandRequest.from_dict(obj.get("CommandsHandlePendingCommandRequest")) commands_handle_pending_command_result = CommandsHandlePendingCommandResult.from_dict(obj.get("CommandsHandlePendingCommandResult")) @@ -15307,10 +15564,13 @@ def from_dict(obj: Any) -> 'RPC': mcp_server = MCPServer.from_dict(obj.get("McpServer")) mcp_server_config = MCPServerConfig.from_dict(obj.get("McpServerConfig")) mcp_server_config_http = MCPServerConfigHTTP.from_dict(obj.get("McpServerConfigHttp")) - mcp_server_config_http_auth = MCPServerConfigHTTPAuth.from_dict(obj.get("McpServerConfigHttpAuth")) + mcp_server_config_http_auth = AuthAuth.from_dict(obj.get("McpServerConfigHttpAuth")) mcp_server_config_http_oauth_grant_type = MCPServerConfigHTTPOauthGrantType(obj.get("McpServerConfigHttpOauthGrantType")) + mcp_server_config_http_oidc = from_union([from_bool, lambda x: from_dict(lambda x: x, x)], obj.get("McpServerConfigHttpOidc")) mcp_server_config_http_type = MCPServerConfigHTTPType(obj.get("McpServerConfigHttpType")) mcp_server_config_stdio = MCPServerConfigStdio.from_dict(obj.get("McpServerConfigStdio")) + mcp_server_config_stdio_auth = from_union([from_bool, lambda x: from_dict(lambda x: x, x)], obj.get("McpServerConfigStdioAuth")) + mcp_server_config_stdio_oidc = from_union([from_bool, lambda x: from_dict(lambda x: x, x)], obj.get("McpServerConfigStdioOidc")) mcp_server_list = MCPServerList.from_dict(obj.get("McpServerList")) mcp_set_env_value_mode_details = MCPSetEnvValueModeDetails(obj.get("McpSetEnvValueModeDetails")) mcp_set_env_value_mode_params = MCPSetEnvValueModeParams.from_dict(obj.get("McpSetEnvValueModeParams")) @@ -15698,7 +15958,7 @@ def from_dict(obj: Any) -> 'RPC': session_context_info = from_union([SessionContextInfo.from_dict, from_none], obj.get("SessionContextInfo")) task_progress = from_union([TaskProgress.from_dict, from_none], obj.get("TaskProgress")) workspace_summary = from_union([WorkspaceSummary.from_dict, from_none], obj.get("WorkspaceSummary")) - return RPC(abort_request, abort_result, account_get_quota_request, account_get_quota_result, account_quota_snapshot, agent_get_current_result, agent_info, agent_info_source, agent_list, agent_reload_result, agent_select_request, agent_select_result, api_key_auth_info, auth_info, auth_info_type, canvas_action, canvas_close_request, canvas_instance_availability, canvas_invoke_action_request, canvas_invoke_action_result, canvas_json_schema, canvas_list, canvas_list_open_result, canvas_open_request, command_list, commands_handle_pending_command_request, commands_handle_pending_command_result, commands_invoke_request, commands_list_request, commands_respond_to_queued_command_request, commands_respond_to_queued_command_result, connected_remote_session_metadata, connected_remote_session_metadata_kind, connected_remote_session_metadata_repository, connect_remote_session_params, connect_request, connect_result, content_filter_mode, copilot_api_token_auth_info, copilot_user_response, copilot_user_response_endpoints, copilot_user_response_quota_snapshots, copilot_user_response_quota_snapshots_chat, copilot_user_response_quota_snapshots_completions, copilot_user_response_quota_snapshots_premium_interactions, current_model, discovered_canvas, discovered_mcp_server, discovered_mcp_server_type, enqueue_command_params, enqueue_command_result, env_auth_info, event_log_read_request, event_log_release_interest_result, event_log_tail_result, event_log_types, events_agent_scope, events_cursor_status, events_read_result, execute_command_params, execute_command_result, extension, extension_list, extensions_disable_request, extensions_enable_request, extension_source, extension_status, external_tool_result, external_tool_text_result_for_llm, external_tool_text_result_for_llm_binary_results_for_llm, external_tool_text_result_for_llm_binary_results_for_llm_type, external_tool_text_result_for_llm_content, external_tool_text_result_for_llm_content_audio, external_tool_text_result_for_llm_content_image, external_tool_text_result_for_llm_content_resource, external_tool_text_result_for_llm_content_resource_details, external_tool_text_result_for_llm_content_resource_link, external_tool_text_result_for_llm_content_resource_link_icon, external_tool_text_result_for_llm_content_resource_link_icon_theme, external_tool_text_result_for_llm_content_terminal, external_tool_text_result_for_llm_content_text, filter_mapping, fleet_start_request, fleet_start_result, folder_trust_add_params, folder_trust_check_params, folder_trust_check_result, gh_cli_auth_info, handle_pending_tool_call_request, handle_pending_tool_call_result, history_abort_manual_compaction_result, history_cancel_background_compaction_result, history_compact_context_window, history_compact_request, history_compact_result, history_summarize_for_handoff_result, history_truncate_request, history_truncate_result, hmac_auth_info, installed_plugin, installed_plugin_source, installed_plugin_source_github, installed_plugin_source_local, installed_plugin_source_url, instructions_get_sources_result, instructions_sources, instructions_sources_location, instructions_sources_type, log_request, log_result, lsp_initialize_request, mcp_apps_call_tool_request, mcp_apps_diagnose_capability, mcp_apps_diagnose_request, mcp_apps_diagnose_result, mcp_apps_diagnose_server, mcp_apps_host_context, mcp_apps_host_context_details, mcp_apps_host_context_details_available_display_mode, mcp_apps_host_context_details_display_mode, mcp_apps_host_context_details_platform, mcp_apps_host_context_details_theme, mcp_apps_list_tools_request, mcp_apps_list_tools_result, mcp_apps_read_resource_request, mcp_apps_read_resource_result, mcp_apps_resource_content, mcp_apps_set_host_context_details, mcp_apps_set_host_context_details_available_display_mode, mcp_apps_set_host_context_details_display_mode, mcp_apps_set_host_context_details_platform, mcp_apps_set_host_context_details_theme, mcp_apps_set_host_context_request, mcp_cancel_sampling_execution_params, mcp_cancel_sampling_execution_result, mcp_config_add_request, mcp_config_disable_request, mcp_config_enable_request, mcp_config_list, mcp_config_remove_request, mcp_config_update_request, mcp_disable_request, mcp_discover_request, mcp_discover_result, mcp_enable_request, mcp_execute_sampling_params, mcp_execute_sampling_request, mcp_execute_sampling_result, mcp_oauth_login_request, mcp_oauth_login_result, mcp_remove_git_hub_result, mcp_sampling_execution_action, mcp_sampling_execution_result, mcp_server, mcp_server_config, mcp_server_config_http, mcp_server_config_http_auth, mcp_server_config_http_oauth_grant_type, mcp_server_config_http_type, mcp_server_config_stdio, mcp_server_list, mcp_set_env_value_mode_details, mcp_set_env_value_mode_params, mcp_set_env_value_mode_result, metadata_context_info_request, metadata_context_info_result, metadata_is_processing_result, metadata_recompute_context_tokens_request, metadata_recompute_context_tokens_result, metadata_record_context_change_request, metadata_record_context_change_result, metadata_set_working_directory_request, metadata_set_working_directory_result, metadata_snapshot_current_mode, metadata_snapshot_remote_metadata, metadata_snapshot_remote_metadata_repository, metadata_snapshot_remote_metadata_task_type, model, model_billing, model_billing_token_prices, model_billing_token_prices_long_context, model_capabilities, model_capabilities_limits, model_capabilities_limits_vision, model_capabilities_override, model_capabilities_override_limits, model_capabilities_override_limits_vision, model_capabilities_override_supports, model_capabilities_supports, model_list, model_picker_category, model_picker_price_category, model_policy, model_policy_state, model_set_reasoning_effort_request, model_set_reasoning_effort_result, models_list_request, model_switch_to_request, model_switch_to_result, mode_set_request, name_get_result, name_set_auto_request, name_set_auto_result, name_set_request, open_canvas_instance, options_update_env_value_mode, pending_permission_request, pending_permission_request_list, permission_decision, permission_decision_approved, permission_decision_approved_for_location, permission_decision_approved_for_session, permission_decision_approve_for_location, permission_decision_approve_for_location_approval, permission_decision_approve_for_location_approval_commands, permission_decision_approve_for_location_approval_custom_tool, permission_decision_approve_for_location_approval_extension_management, permission_decision_approve_for_location_approval_extension_permission_access, permission_decision_approve_for_location_approval_mcp, permission_decision_approve_for_location_approval_mcp_sampling, permission_decision_approve_for_location_approval_memory, permission_decision_approve_for_location_approval_read, permission_decision_approve_for_location_approval_write, permission_decision_approve_for_session, permission_decision_approve_for_session_approval, permission_decision_approve_for_session_approval_commands, permission_decision_approve_for_session_approval_custom_tool, permission_decision_approve_for_session_approval_extension_management, permission_decision_approve_for_session_approval_extension_permission_access, permission_decision_approve_for_session_approval_mcp, permission_decision_approve_for_session_approval_mcp_sampling, permission_decision_approve_for_session_approval_memory, permission_decision_approve_for_session_approval_read, permission_decision_approve_for_session_approval_write, permission_decision_approve_once, permission_decision_approve_permanently, permission_decision_cancelled, permission_decision_denied_by_content_exclusion_policy, permission_decision_denied_by_permission_request_hook, permission_decision_denied_by_rules, permission_decision_denied_interactively_by_user, permission_decision_denied_no_approval_rule_and_could_not_request_from_user, permission_decision_reject, permission_decision_request, permission_decision_user_not_available, permission_location_add_tool_approval_params, permission_location_apply_params, permission_location_apply_result, permission_location_resolve_params, permission_location_resolve_result, permission_location_type, permission_paths_add_params, permission_paths_allowed_check_params, permission_paths_allowed_check_result, permission_paths_config, permission_paths_list, permission_paths_update_primary_params, permission_paths_workspace_check_params, permission_paths_workspace_check_result, permission_prompt_shown_notification, permission_request_result, permission_rules_set, permissions_configure_additional_content_exclusion_policy, permissions_configure_additional_content_exclusion_policy_rule, permissions_configure_additional_content_exclusion_policy_rule_source, permissions_configure_additional_content_exclusion_policy_scope, permissions_configure_params, permissions_configure_result, permissions_folder_trust_add_trusted_result, permissions_locations_add_tool_approval_details, permissions_locations_add_tool_approval_details_commands, permissions_locations_add_tool_approval_details_custom_tool, permissions_locations_add_tool_approval_details_extension_management, permissions_locations_add_tool_approval_details_extension_permission_access, permissions_locations_add_tool_approval_details_mcp, permissions_locations_add_tool_approval_details_mcp_sampling, permissions_locations_add_tool_approval_details_memory, permissions_locations_add_tool_approval_details_read, permissions_locations_add_tool_approval_details_write, permissions_locations_add_tool_approval_result, permissions_modify_rules_params, permissions_modify_rules_result, permissions_modify_rules_scope, permissions_notify_prompt_shown_result, permissions_paths_add_result, permissions_paths_list_request, permissions_paths_update_primary_result, permissions_pending_requests_request, permissions_reset_session_approvals_request, permissions_reset_session_approvals_result, permissions_set_approve_all_request, permissions_set_approve_all_result, permissions_set_approve_all_source, permissions_set_required_request, permissions_set_required_result, permissions_urls_set_unrestricted_mode_result, permission_urls_config, permission_urls_set_unrestricted_mode_params, ping_request, ping_result, plan_read_result, plan_update_request, plugin, plugin_list, queued_command_handled, queued_command_not_handled, queued_command_result, queue_pending_items, queue_pending_items_kind, queue_pending_items_result, queue_remove_most_recent_result, register_event_interest_params, register_event_interest_result, release_event_interest_params, remote_enable_request, remote_enable_result, remote_notify_steerable_changed_request, remote_notify_steerable_changed_result, remote_session_connection_result, remote_session_mode, schedule_entry, schedule_list, schedule_stop_request, schedule_stop_result, secrets_add_filter_values_request, secrets_add_filter_values_result, send_agent_mode, send_attachment, send_attachment_blob, send_attachment_directory, send_attachment_file, send_attachment_file_line_range, send_attachment_github_reference, send_attachment_github_reference_type, send_attachment_selection, send_attachment_selection_details, send_attachment_selection_details_end, send_attachment_selection_details_start, send_mode, send_request, send_result, server_skill, server_skill_list, session_auth_status, session_bulk_delete_result, session_context, session_context_host_type, session_enrich_metadata_result, session_fs_append_file_request, session_fs_error, session_fs_error_code, session_fs_exists_request, session_fs_exists_result, session_fs_mkdir_request, session_fs_readdir_request, session_fs_readdir_result, session_fs_readdir_with_types_entry, session_fs_readdir_with_types_entry_type, session_fs_readdir_with_types_request, session_fs_readdir_with_types_result, session_fs_read_file_request, session_fs_read_file_result, session_fs_rename_request, session_fs_rm_request, session_fs_set_provider_capabilities, session_fs_set_provider_conventions, session_fs_set_provider_request, session_fs_set_provider_result, session_fs_sqlite_exists_request, session_fs_sqlite_exists_result, session_fs_sqlite_query_request, session_fs_sqlite_query_result, session_fs_sqlite_query_type, session_fs_stat_request, session_fs_stat_result, session_fs_write_file_request, session_installed_plugin, session_installed_plugin_source, session_installed_plugin_source_github, session_installed_plugin_source_local, session_installed_plugin_source_url, session_list, session_list_filter, session_load_deferred_repo_hooks_result, session_log_level, session_mcp_apps_call_tool_result, session_metadata, session_metadata_snapshot, session_mode, session_prune_result, sessions_bulk_delete_request, sessions_check_in_use_request, sessions_check_in_use_result, sessions_close_request, sessions_close_result, sessions_enrich_metadata_request, session_set_credentials_params, session_set_credentials_result, sessions_find_by_prefix_request, sessions_find_by_prefix_result, sessions_find_by_task_id_request, sessions_find_by_task_id_result, sessions_fork_request, sessions_fork_result, sessions_get_event_file_path_request, sessions_get_event_file_path_result, sessions_get_last_for_context_request, sessions_get_last_for_context_result, sessions_get_persisted_remote_steerable_request, sessions_get_persisted_remote_steerable_result, session_sizes, sessions_list_request, sessions_load_deferred_repo_hooks_request, sessions_prune_old_request, sessions_release_lock_request, sessions_release_lock_result, sessions_reload_plugin_hooks_request, sessions_reload_plugin_hooks_result, sessions_save_request, sessions_save_result, sessions_set_additional_plugins_request, sessions_set_additional_plugins_result, session_update_options_params, session_update_options_result, session_working_directory_context, session_working_directory_context_host_type, shell_exec_request, shell_exec_result, shell_kill_request, shell_kill_result, shell_kill_signal, shutdown_request, skill, skill_list, skills_config_set_disabled_skills_request, skills_disable_request, skills_discover_request, skills_enable_request, skills_get_invoked_result, skills_invoked_skill, skills_load_diagnostics, slash_command_agent_prompt_result, slash_command_completed_result, slash_command_info, slash_command_input, slash_command_input_completion, slash_command_invocation_result, slash_command_kind, slash_command_select_subcommand_option, slash_command_select_subcommand_result, slash_command_text_result, task_agent_info, task_agent_progress, task_execution_mode, task_info, task_list, task_progress_line, tasks_cancel_request, tasks_cancel_result, tasks_get_current_promotable_result, tasks_get_progress_request, tasks_get_progress_result, task_shell_info, task_shell_info_attachment_mode, task_shell_progress, tasks_promote_current_to_background_result, tasks_promote_to_background_request, tasks_promote_to_background_result, tasks_refresh_result, tasks_remove_request, tasks_remove_result, tasks_send_message_request, tasks_send_message_result, tasks_start_agent_request, tasks_start_agent_result, task_status, tasks_wait_for_pending_result, telemetry_set_feature_overrides_request, token_auth_info, tool, tool_list, tools_initialize_and_validate_result, tools_list_request, ui_auto_mode_switch_response, ui_elicitation_array_any_of_field, ui_elicitation_array_any_of_field_items, ui_elicitation_array_any_of_field_items_any_of, ui_elicitation_array_enum_field, ui_elicitation_array_enum_field_items, ui_elicitation_field_value, ui_elicitation_request, ui_elicitation_response, ui_elicitation_response_action, ui_elicitation_response_content, ui_elicitation_result, ui_elicitation_schema, ui_elicitation_schema_property, ui_elicitation_schema_property_boolean, ui_elicitation_schema_property_number, ui_elicitation_schema_property_number_type, ui_elicitation_schema_property_string, ui_elicitation_schema_property_string_format, ui_elicitation_string_enum_field, ui_elicitation_string_one_of_field, ui_elicitation_string_one_of_field_one_of, ui_exit_plan_mode_action, ui_exit_plan_mode_response, ui_handle_pending_auto_mode_switch_request, ui_handle_pending_elicitation_request, ui_handle_pending_exit_plan_mode_request, ui_handle_pending_result, ui_handle_pending_sampling_request, ui_handle_pending_sampling_response, ui_handle_pending_user_input_request, ui_register_direct_auto_mode_switch_handler_result, ui_unregister_direct_auto_mode_switch_handler_request, ui_unregister_direct_auto_mode_switch_handler_result, ui_user_input_response, usage_get_metrics_result, usage_metrics_code_changes, usage_metrics_model_metric, usage_metrics_model_metric_requests, usage_metrics_model_metric_token_detail, usage_metrics_model_metric_usage, usage_metrics_token_detail, user_auth_info, workspace_diff_file_change, workspace_diff_file_change_type, workspace_diff_mode, workspace_diff_result, workspaces_checkpoints, workspaces_create_file_request, workspaces_diff_request, workspaces_get_workspace_result, workspaces_list_checkpoints_result, workspaces_list_files_result, workspaces_read_checkpoint_request, workspaces_read_checkpoint_result, workspaces_read_file_request, workspaces_read_file_result, workspaces_save_large_paste_request, workspaces_save_large_paste_result, workspace_summary_host_type, workspaces_workspace_details_host_type, session_context_info, task_progress, workspace_summary) + return RPC(abort_request, abort_result, account_get_quota_request, account_get_quota_result, account_quota_snapshot, agent_get_current_result, agent_info, agent_info_source, agent_list, agent_reload_result, agent_select_request, agent_select_result, api_key_auth_info, auth_info, auth_info_type, canvas_action, canvas_close_request, canvas_host_context, canvas_host_context_capabilities, canvas_instance_availability, canvas_invoke_action_request, canvas_invoke_action_result, canvas_json_schema, canvas_list, canvas_list_open_result, canvas_open_request, canvas_provider_close_request, canvas_provider_invoke_action_request, canvas_provider_open_request, canvas_provider_open_result, command_list, commands_handle_pending_command_request, commands_handle_pending_command_result, commands_invoke_request, commands_list_request, commands_respond_to_queued_command_request, commands_respond_to_queued_command_result, connected_remote_session_metadata, connected_remote_session_metadata_kind, connected_remote_session_metadata_repository, connect_remote_session_params, connect_request, connect_result, content_filter_mode, copilot_api_token_auth_info, copilot_user_response, copilot_user_response_endpoints, copilot_user_response_quota_snapshots, copilot_user_response_quota_snapshots_chat, copilot_user_response_quota_snapshots_completions, copilot_user_response_quota_snapshots_premium_interactions, current_model, discovered_canvas, discovered_mcp_server, discovered_mcp_server_type, enqueue_command_params, enqueue_command_result, env_auth_info, event_log_read_request, event_log_release_interest_result, event_log_tail_result, event_log_types, events_agent_scope, events_cursor_status, events_read_result, execute_command_params, execute_command_result, extension, extension_list, extensions_disable_request, extensions_enable_request, extension_source, extension_status, external_tool_result, external_tool_text_result_for_llm, external_tool_text_result_for_llm_binary_results_for_llm, external_tool_text_result_for_llm_binary_results_for_llm_type, external_tool_text_result_for_llm_content, external_tool_text_result_for_llm_content_audio, external_tool_text_result_for_llm_content_image, external_tool_text_result_for_llm_content_resource, external_tool_text_result_for_llm_content_resource_details, external_tool_text_result_for_llm_content_resource_link, external_tool_text_result_for_llm_content_resource_link_icon, external_tool_text_result_for_llm_content_resource_link_icon_theme, external_tool_text_result_for_llm_content_terminal, external_tool_text_result_for_llm_content_text, filter_mapping, fleet_start_request, fleet_start_result, folder_trust_add_params, folder_trust_check_params, folder_trust_check_result, gh_cli_auth_info, handle_pending_tool_call_request, handle_pending_tool_call_result, history_abort_manual_compaction_result, history_cancel_background_compaction_result, history_compact_context_window, history_compact_request, history_compact_result, history_summarize_for_handoff_result, history_truncate_request, history_truncate_result, hmac_auth_info, installed_plugin, installed_plugin_source, installed_plugin_source_github, installed_plugin_source_local, installed_plugin_source_url, instructions_get_sources_result, instructions_sources, instructions_sources_location, instructions_sources_type, log_request, log_result, lsp_initialize_request, mcp_apps_call_tool_request, mcp_apps_diagnose_capability, mcp_apps_diagnose_request, mcp_apps_diagnose_result, mcp_apps_diagnose_server, mcp_apps_host_context, mcp_apps_host_context_details, mcp_apps_host_context_details_available_display_mode, mcp_apps_host_context_details_display_mode, mcp_apps_host_context_details_platform, mcp_apps_host_context_details_theme, mcp_apps_list_tools_request, mcp_apps_list_tools_result, mcp_apps_read_resource_request, mcp_apps_read_resource_result, mcp_apps_resource_content, mcp_apps_set_host_context_details, mcp_apps_set_host_context_details_available_display_mode, mcp_apps_set_host_context_details_display_mode, mcp_apps_set_host_context_details_platform, mcp_apps_set_host_context_details_theme, mcp_apps_set_host_context_request, mcp_cancel_sampling_execution_params, mcp_cancel_sampling_execution_result, mcp_config_add_request, mcp_config_disable_request, mcp_config_enable_request, mcp_config_list, mcp_config_remove_request, mcp_config_update_request, mcp_disable_request, mcp_discover_request, mcp_discover_result, mcp_enable_request, mcp_execute_sampling_params, mcp_execute_sampling_request, mcp_execute_sampling_result, mcp_oauth_login_request, mcp_oauth_login_result, mcp_remove_git_hub_result, mcp_sampling_execution_action, mcp_sampling_execution_result, mcp_server, mcp_server_config, mcp_server_config_http, mcp_server_config_http_auth, mcp_server_config_http_oauth_grant_type, mcp_server_config_http_oidc, mcp_server_config_http_type, mcp_server_config_stdio, mcp_server_config_stdio_auth, mcp_server_config_stdio_oidc, mcp_server_list, mcp_set_env_value_mode_details, mcp_set_env_value_mode_params, mcp_set_env_value_mode_result, metadata_context_info_request, metadata_context_info_result, metadata_is_processing_result, metadata_recompute_context_tokens_request, metadata_recompute_context_tokens_result, metadata_record_context_change_request, metadata_record_context_change_result, metadata_set_working_directory_request, metadata_set_working_directory_result, metadata_snapshot_current_mode, metadata_snapshot_remote_metadata, metadata_snapshot_remote_metadata_repository, metadata_snapshot_remote_metadata_task_type, model, model_billing, model_billing_token_prices, model_billing_token_prices_long_context, model_capabilities, model_capabilities_limits, model_capabilities_limits_vision, model_capabilities_override, model_capabilities_override_limits, model_capabilities_override_limits_vision, model_capabilities_override_supports, model_capabilities_supports, model_list, model_picker_category, model_picker_price_category, model_policy, model_policy_state, model_set_reasoning_effort_request, model_set_reasoning_effort_result, models_list_request, model_switch_to_request, model_switch_to_result, mode_set_request, name_get_result, name_set_auto_request, name_set_auto_result, name_set_request, open_canvas_instance, options_update_env_value_mode, pending_permission_request, pending_permission_request_list, permission_decision, permission_decision_approved, permission_decision_approved_for_location, permission_decision_approved_for_session, permission_decision_approve_for_location, permission_decision_approve_for_location_approval, permission_decision_approve_for_location_approval_commands, permission_decision_approve_for_location_approval_custom_tool, permission_decision_approve_for_location_approval_extension_management, permission_decision_approve_for_location_approval_extension_permission_access, permission_decision_approve_for_location_approval_mcp, permission_decision_approve_for_location_approval_mcp_sampling, permission_decision_approve_for_location_approval_memory, permission_decision_approve_for_location_approval_read, permission_decision_approve_for_location_approval_write, permission_decision_approve_for_session, permission_decision_approve_for_session_approval, permission_decision_approve_for_session_approval_commands, permission_decision_approve_for_session_approval_custom_tool, permission_decision_approve_for_session_approval_extension_management, permission_decision_approve_for_session_approval_extension_permission_access, permission_decision_approve_for_session_approval_mcp, permission_decision_approve_for_session_approval_mcp_sampling, permission_decision_approve_for_session_approval_memory, permission_decision_approve_for_session_approval_read, permission_decision_approve_for_session_approval_write, permission_decision_approve_once, permission_decision_approve_permanently, permission_decision_cancelled, permission_decision_denied_by_content_exclusion_policy, permission_decision_denied_by_permission_request_hook, permission_decision_denied_by_rules, permission_decision_denied_interactively_by_user, permission_decision_denied_no_approval_rule_and_could_not_request_from_user, permission_decision_reject, permission_decision_request, permission_decision_user_not_available, permission_location_add_tool_approval_params, permission_location_apply_params, permission_location_apply_result, permission_location_resolve_params, permission_location_resolve_result, permission_location_type, permission_paths_add_params, permission_paths_allowed_check_params, permission_paths_allowed_check_result, permission_paths_config, permission_paths_list, permission_paths_update_primary_params, permission_paths_workspace_check_params, permission_paths_workspace_check_result, permission_prompt_shown_notification, permission_request_result, permission_rules_set, permissions_configure_additional_content_exclusion_policy, permissions_configure_additional_content_exclusion_policy_rule, permissions_configure_additional_content_exclusion_policy_rule_source, permissions_configure_additional_content_exclusion_policy_scope, permissions_configure_params, permissions_configure_result, permissions_folder_trust_add_trusted_result, permissions_locations_add_tool_approval_details, permissions_locations_add_tool_approval_details_commands, permissions_locations_add_tool_approval_details_custom_tool, permissions_locations_add_tool_approval_details_extension_management, permissions_locations_add_tool_approval_details_extension_permission_access, permissions_locations_add_tool_approval_details_mcp, permissions_locations_add_tool_approval_details_mcp_sampling, permissions_locations_add_tool_approval_details_memory, permissions_locations_add_tool_approval_details_read, permissions_locations_add_tool_approval_details_write, permissions_locations_add_tool_approval_result, permissions_modify_rules_params, permissions_modify_rules_result, permissions_modify_rules_scope, permissions_notify_prompt_shown_result, permissions_paths_add_result, permissions_paths_list_request, permissions_paths_update_primary_result, permissions_pending_requests_request, permissions_reset_session_approvals_request, permissions_reset_session_approvals_result, permissions_set_approve_all_request, permissions_set_approve_all_result, permissions_set_approve_all_source, permissions_set_required_request, permissions_set_required_result, permissions_urls_set_unrestricted_mode_result, permission_urls_config, permission_urls_set_unrestricted_mode_params, ping_request, ping_result, plan_read_result, plan_update_request, plugin, plugin_list, queued_command_handled, queued_command_not_handled, queued_command_result, queue_pending_items, queue_pending_items_kind, queue_pending_items_result, queue_remove_most_recent_result, register_event_interest_params, register_event_interest_result, release_event_interest_params, remote_enable_request, remote_enable_result, remote_notify_steerable_changed_request, remote_notify_steerable_changed_result, remote_session_connection_result, remote_session_mode, schedule_entry, schedule_list, schedule_stop_request, schedule_stop_result, secrets_add_filter_values_request, secrets_add_filter_values_result, send_agent_mode, send_attachment, send_attachment_blob, send_attachment_directory, send_attachment_file, send_attachment_file_line_range, send_attachment_github_reference, send_attachment_github_reference_type, send_attachment_selection, send_attachment_selection_details, send_attachment_selection_details_end, send_attachment_selection_details_start, send_mode, send_request, send_result, server_skill, server_skill_list, session_auth_status, session_bulk_delete_result, session_context, session_context_host_type, session_enrich_metadata_result, session_fs_append_file_request, session_fs_error, session_fs_error_code, session_fs_exists_request, session_fs_exists_result, session_fs_mkdir_request, session_fs_readdir_request, session_fs_readdir_result, session_fs_readdir_with_types_entry, session_fs_readdir_with_types_entry_type, session_fs_readdir_with_types_request, session_fs_readdir_with_types_result, session_fs_read_file_request, session_fs_read_file_result, session_fs_rename_request, session_fs_rm_request, session_fs_set_provider_capabilities, session_fs_set_provider_conventions, session_fs_set_provider_request, session_fs_set_provider_result, session_fs_sqlite_exists_request, session_fs_sqlite_exists_result, session_fs_sqlite_query_request, session_fs_sqlite_query_result, session_fs_sqlite_query_type, session_fs_stat_request, session_fs_stat_result, session_fs_write_file_request, session_installed_plugin, session_installed_plugin_source, session_installed_plugin_source_github, session_installed_plugin_source_local, session_installed_plugin_source_url, session_list, session_list_filter, session_load_deferred_repo_hooks_result, session_log_level, session_mcp_apps_call_tool_result, session_metadata, session_metadata_snapshot, session_mode, session_prune_result, sessions_bulk_delete_request, sessions_check_in_use_request, sessions_check_in_use_result, sessions_close_request, sessions_close_result, sessions_enrich_metadata_request, session_set_credentials_params, session_set_credentials_result, sessions_find_by_prefix_request, sessions_find_by_prefix_result, sessions_find_by_task_id_request, sessions_find_by_task_id_result, sessions_fork_request, sessions_fork_result, sessions_get_event_file_path_request, sessions_get_event_file_path_result, sessions_get_last_for_context_request, sessions_get_last_for_context_result, sessions_get_persisted_remote_steerable_request, sessions_get_persisted_remote_steerable_result, session_sizes, sessions_list_request, sessions_load_deferred_repo_hooks_request, sessions_prune_old_request, sessions_release_lock_request, sessions_release_lock_result, sessions_reload_plugin_hooks_request, sessions_reload_plugin_hooks_result, sessions_save_request, sessions_save_result, sessions_set_additional_plugins_request, sessions_set_additional_plugins_result, session_update_options_params, session_update_options_result, session_working_directory_context, session_working_directory_context_host_type, shell_exec_request, shell_exec_result, shell_kill_request, shell_kill_result, shell_kill_signal, shutdown_request, skill, skill_list, skills_config_set_disabled_skills_request, skills_disable_request, skills_discover_request, skills_enable_request, skills_get_invoked_result, skills_invoked_skill, skills_load_diagnostics, slash_command_agent_prompt_result, slash_command_completed_result, slash_command_info, slash_command_input, slash_command_input_completion, slash_command_invocation_result, slash_command_kind, slash_command_select_subcommand_option, slash_command_select_subcommand_result, slash_command_text_result, task_agent_info, task_agent_progress, task_execution_mode, task_info, task_list, task_progress_line, tasks_cancel_request, tasks_cancel_result, tasks_get_current_promotable_result, tasks_get_progress_request, tasks_get_progress_result, task_shell_info, task_shell_info_attachment_mode, task_shell_progress, tasks_promote_current_to_background_result, tasks_promote_to_background_request, tasks_promote_to_background_result, tasks_refresh_result, tasks_remove_request, tasks_remove_result, tasks_send_message_request, tasks_send_message_result, tasks_start_agent_request, tasks_start_agent_result, task_status, tasks_wait_for_pending_result, telemetry_set_feature_overrides_request, token_auth_info, tool, tool_list, tools_initialize_and_validate_result, tools_list_request, ui_auto_mode_switch_response, ui_elicitation_array_any_of_field, ui_elicitation_array_any_of_field_items, ui_elicitation_array_any_of_field_items_any_of, ui_elicitation_array_enum_field, ui_elicitation_array_enum_field_items, ui_elicitation_field_value, ui_elicitation_request, ui_elicitation_response, ui_elicitation_response_action, ui_elicitation_response_content, ui_elicitation_result, ui_elicitation_schema, ui_elicitation_schema_property, ui_elicitation_schema_property_boolean, ui_elicitation_schema_property_number, ui_elicitation_schema_property_number_type, ui_elicitation_schema_property_string, ui_elicitation_schema_property_string_format, ui_elicitation_string_enum_field, ui_elicitation_string_one_of_field, ui_elicitation_string_one_of_field_one_of, ui_exit_plan_mode_action, ui_exit_plan_mode_response, ui_handle_pending_auto_mode_switch_request, ui_handle_pending_elicitation_request, ui_handle_pending_exit_plan_mode_request, ui_handle_pending_result, ui_handle_pending_sampling_request, ui_handle_pending_sampling_response, ui_handle_pending_user_input_request, ui_register_direct_auto_mode_switch_handler_result, ui_unregister_direct_auto_mode_switch_handler_request, ui_unregister_direct_auto_mode_switch_handler_result, ui_user_input_response, usage_get_metrics_result, usage_metrics_code_changes, usage_metrics_model_metric, usage_metrics_model_metric_requests, usage_metrics_model_metric_token_detail, usage_metrics_model_metric_usage, usage_metrics_token_detail, user_auth_info, workspace_diff_file_change, workspace_diff_file_change_type, workspace_diff_mode, workspace_diff_result, workspaces_checkpoints, workspaces_create_file_request, workspaces_diff_request, workspaces_get_workspace_result, workspaces_list_checkpoints_result, workspaces_list_files_result, workspaces_read_checkpoint_request, workspaces_read_checkpoint_result, workspaces_read_file_request, workspaces_read_file_result, workspaces_save_large_paste_request, workspaces_save_large_paste_result, workspace_summary_host_type, workspaces_workspace_details_host_type, session_context_info, task_progress, workspace_summary) def to_dict(self) -> dict: result: dict = {} @@ -15719,13 +15979,19 @@ def to_dict(self) -> dict: result["AuthInfoType"] = to_enum(AuthInfoType, self.auth_info_type) result["CanvasAction"] = to_class(CanvasAction, self.canvas_action) result["CanvasCloseRequest"] = to_class(CanvasCloseRequest, self.canvas_close_request) + result["CanvasHostContext"] = to_class(CanvasHostContext, self.canvas_host_context) + result["CanvasHostContextCapabilities"] = to_class(CanvasHostContextCapabilities, self.canvas_host_context_capabilities) result["CanvasInstanceAvailability"] = to_enum(CanvasInstanceAvailability, self.canvas_instance_availability) result["CanvasInvokeActionRequest"] = to_class(CanvasInvokeActionRequest, self.canvas_invoke_action_request) - result["CanvasInvokeActionResult"] = to_class(CanvasInvokeActionResult, self.canvas_invoke_action_result) + result["CanvasInvokeActionResult"] = self.canvas_invoke_action_result result["CanvasJsonSchema"] = self.canvas_json_schema result["CanvasList"] = to_class(CanvasList, self.canvas_list) result["CanvasListOpenResult"] = to_class(CanvasListOpenResult, self.canvas_list_open_result) result["CanvasOpenRequest"] = to_class(CanvasOpenRequest, self.canvas_open_request) + result["CanvasProviderCloseRequest"] = to_class(CanvasProviderCloseRequest, self.canvas_provider_close_request) + result["CanvasProviderInvokeActionRequest"] = to_class(CanvasProviderInvokeActionRequest, self.canvas_provider_invoke_action_request) + result["CanvasProviderOpenRequest"] = to_class(CanvasProviderOpenRequest, self.canvas_provider_open_request) + result["CanvasProviderOpenResult"] = to_class(CanvasProviderOpenResult, self.canvas_provider_open_result) result["CommandList"] = to_class(CommandList, self.command_list) result["CommandsHandlePendingCommandRequest"] = to_class(CommandsHandlePendingCommandRequest, self.commands_handle_pending_command_request) result["CommandsHandlePendingCommandResult"] = to_class(CommandsHandlePendingCommandResult, self.commands_handle_pending_command_result) @@ -15858,10 +16124,13 @@ def to_dict(self) -> dict: result["McpServer"] = to_class(MCPServer, self.mcp_server) result["McpServerConfig"] = to_class(MCPServerConfig, self.mcp_server_config) result["McpServerConfigHttp"] = to_class(MCPServerConfigHTTP, self.mcp_server_config_http) - result["McpServerConfigHttpAuth"] = to_class(MCPServerConfigHTTPAuth, self.mcp_server_config_http_auth) + result["McpServerConfigHttpAuth"] = to_class(AuthAuth, self.mcp_server_config_http_auth) result["McpServerConfigHttpOauthGrantType"] = to_enum(MCPServerConfigHTTPOauthGrantType, self.mcp_server_config_http_oauth_grant_type) + result["McpServerConfigHttpOidc"] = from_union([from_bool, lambda x: from_dict(lambda x: x, x)], self.mcp_server_config_http_oidc) result["McpServerConfigHttpType"] = to_enum(MCPServerConfigHTTPType, self.mcp_server_config_http_type) result["McpServerConfigStdio"] = to_class(MCPServerConfigStdio, self.mcp_server_config_stdio) + result["McpServerConfigStdioAuth"] = from_union([from_bool, lambda x: from_dict(lambda x: x, x)], self.mcp_server_config_stdio_auth) + result["McpServerConfigStdioOidc"] = from_union([from_bool, lambda x: from_dict(lambda x: x, x)], self.mcp_server_config_stdio_oidc) result["McpServerList"] = to_class(MCPServerList, self.mcp_server_list) result["McpSetEnvValueModeDetails"] = to_enum(MCPSetEnvValueModeDetails, self.mcp_set_env_value_mode_details) result["McpSetEnvValueModeParams"] = to_class(MCPSetEnvValueModeParams, self.mcp_set_env_value_mode_params) @@ -16416,6 +16685,7 @@ def _load_TaskInfo(obj: Any) -> "TaskInfo": case _: raise ValueError(f"Unknown TaskInfo type: {kind!r}") +CanvasInvokeActionResult = Any CanvasJsonSchema = Any ExternalToolResult = ExternalToolTextResultForLlm ExternalToolTextResultForLlmContentResourceLinkIconTheme = Theme @@ -16429,6 +16699,10 @@ def _load_TaskInfo(obj: Any) -> "TaskInfo": McpAppsSetHostContextDetailsTheme = Theme McpExecuteSamplingRequest = dict McpExecuteSamplingResult = dict +McpServerConfigHttpAuth = AuthAuth +McpServerConfigHttpOidc = bool +McpServerConfigStdioAuth = bool +McpServerConfigStdioOidc = bool OptionsUpdateEnvValueMode = MCPSetEnvValueModeDetails SessionContextHostType = HostType SessionMcpAppsCallToolResult = dict @@ -16662,7 +16936,7 @@ async def release_lock(self, params: SessionsReleaseLockRequest, *, timeout: flo return SessionsReleaseLockResult.from_dict(await self._client.request("sessions.releaseLock", params_dict, **_timeout_kwargs(timeout))) async def enrich_metadata(self, params: SessionsEnrichMetadataRequest, *, timeout: float | None = None) -> SessionEnrichMetadataResult: - "Backfills missing summary and context fields on the supplied session metadata records.\n\nArgs:\n params: Session metadata records to enrich with summary and context information.\n\nReturns:\n The same metadata records, with summary and context fields backfilled where available." + "Backfills missing summary and context fields on the supplied session metadata records.\n\nArgs:\n params: Session metadata records to enrich with summary and context information.\n\nReturns:\n The enriched metadata records, with summary and context fields backfilled where available. Sessions confirmed empty and unnamed are omitted." params_dict = {k: v for k, v in params.to_dict().items() if v is not None} return SessionEnrichMetadataResult.from_dict(await self._client.request("sessions.enrichMetadata", params_dict, **_timeout_kwargs(timeout))) @@ -16755,11 +17029,11 @@ async def close(self, params: CanvasCloseRequest, *, timeout: float | None = Non params_dict["sessionId"] = self._session_id await self._client.request("session.canvas.close", params_dict, **_timeout_kwargs(timeout)) - async def invoke_action(self, params: CanvasInvokeActionRequest, *, timeout: float | None = None) -> CanvasInvokeActionResult: + async def invoke_action(self, params: CanvasInvokeActionRequest, *, timeout: float | None = None) -> Any: "Invokes an action on an open canvas instance.\n\nArgs:\n params: Canvas action invocation parameters.\n\nReturns:\n Canvas action invocation result." params_dict: dict[str, Any] = {k: v for k, v in params.to_dict().items() if v is not None} params_dict["sessionId"] = self._session_id - return CanvasInvokeActionResult.from_dict(await self._client.request("session.canvas.invokeAction", params_dict, **_timeout_kwargs(timeout))) + return await self._client.request("session.canvas.invokeAction", params_dict, **_timeout_kwargs(timeout)) # Experimental: this API group is experimental and may change or be removed. @@ -17741,6 +18015,7 @@ async def log(self, params: LogRequest, *, timeout: float | None = None) -> LogR return LogResult.from_dict(await self._client.request("session.log", params_dict, **_timeout_kwargs(timeout))) +# Experimental: this API group is experimental and may change or be removed. class SessionFsHandler(Protocol): async def read_file(self, params: SessionFSReadFileRequest) -> SessionFSReadFileResult: "Reads a file from the client-provided session filesystem.\n\nArgs:\n params: Path of the file to read from the client-provided session filesystem.\n\nReturns:\n File content as a UTF-8 string, or a filesystem error if the read failed." @@ -17779,9 +18054,22 @@ async def sqlite_exists(self, params: SessionFSSqliteExistsRequest) -> SessionFS "Checks whether the per-session SQLite database already exists, without creating it.\n\nArgs:\n params: Identifies the target session.\n\nReturns:\n Indicates whether the per-session SQLite database already exists." pass +# Experimental: this API group is experimental and may change or be removed. +class CanvasHandler(Protocol): + async def open(self, params: CanvasProviderOpenRequest) -> CanvasProviderOpenResult: + "Opens a canvas instance on the provider.\n\nArgs:\n params: Canvas open parameters sent to the provider.\n\nReturns:\n Canvas open result returned by the provider." + pass + async def close(self, params: CanvasProviderCloseRequest) -> None: + "Closes a canvas instance on the provider.\n\nArgs:\n params: Canvas close parameters sent to the provider." + pass + async def invoke_action(self, params: CanvasProviderInvokeActionRequest) -> Any: + "Invokes an action on an open canvas instance via the provider.\n\nArgs:\n params: Canvas action invocation parameters sent to the provider.\n\nReturns:\n Provider-supplied action result." + pass + @dataclass class ClientSessionApiHandlers: session_fs: SessionFsHandler | None = None + canvas: CanvasHandler | None = None def register_client_session_api_handlers( client: "JsonRpcClient", @@ -17872,3 +18160,24 @@ async def handle_session_fs_sqlite_exists(params: dict) -> dict | None: result = await handler.sqlite_exists(request) return result.to_dict() client.set_request_handler("sessionFs.sqliteExists", handle_session_fs_sqlite_exists) + async def handle_canvas_open(params: dict) -> dict | None: + request = CanvasProviderOpenRequest.from_dict(params) + handler = get_handlers(request.session_id).canvas + if handler is None: raise RuntimeError(f"No canvas handler registered for session: {request.session_id}") + result = await handler.open(request) + return result.to_dict() + client.set_request_handler("canvas.open", handle_canvas_open) + async def handle_canvas_close(params: dict) -> dict | None: + request = CanvasProviderCloseRequest.from_dict(params) + handler = get_handlers(request.session_id).canvas + if handler is None: raise RuntimeError(f"No canvas handler registered for session: {request.session_id}") + await handler.close(request) + return None + client.set_request_handler("canvas.close", handle_canvas_close) + async def handle_canvas_invoke_action(params: dict) -> dict | None: + request = CanvasProviderInvokeActionRequest.from_dict(params) + handler = get_handlers(request.session_id).canvas + if handler is None: raise RuntimeError(f"No canvas handler registered for session: {request.session_id}") + result = await handler.invoke_action(request) + return result.value if hasattr(result, 'value') else result + client.set_request_handler("canvas.invokeAction", handle_canvas_invoke_action) diff --git a/python/copilot/session.py b/python/copilot/session.py index 90134a151..2b95778c8 100644 --- a/python/copilot/session.py +++ b/python/copilot/session.py @@ -25,8 +25,15 @@ from ._diagnostics import log_timing from ._jsonrpc import JsonRpcError, ProcessExitedError from ._telemetry import get_trace_context, trace_context -from .canvas import CanvasHandler, OpenCanvasInstance +from .canvas import CanvasError, CanvasHandler, OpenCanvasInstance from .generated.rpc import ( + CanvasHandler as RpcCanvasHandler, +) +from .generated.rpc import ( + CanvasProviderCloseRequest, + CanvasProviderInvokeActionRequest, + CanvasProviderOpenRequest, + CanvasProviderOpenResult, ClientSessionApiHandlers, CommandsHandlePendingCommandRequest, ExternalToolTextResultForLlm, @@ -955,6 +962,43 @@ class ProviderConfig(TypedDict, total=False): SessionEventHandler = Callable[[SessionEvent], None] +class _CanvasHandlerAdapter: + def __init__(self, handler: CanvasHandler) -> None: + self._handler = handler + + async def open(self, params: CanvasProviderOpenRequest) -> CanvasProviderOpenResult: + try: + return await self._handler.on_open(params) + except CanvasError as err: + raise JsonRpcError(-32603, err.message, data=err.to_envelope()) from err + except Exception as err: + raise _canvas_handler_error(err) from err + + async def close(self, params: CanvasProviderCloseRequest) -> None: + try: + await self._handler.on_close(params) + except CanvasError as err: + raise JsonRpcError(-32603, err.message, data=err.to_envelope()) from err + except Exception as err: + raise _canvas_handler_error(err) from err + + async def invoke_action(self, params: CanvasProviderInvokeActionRequest) -> Any: + try: + return await self._handler.on_action(params) + except CanvasError as err: + raise JsonRpcError(-32603, err.message, data=err.to_envelope()) from err + except Exception as err: + raise _canvas_handler_error(err) from err + + +def _canvas_handler_error(err: Exception) -> JsonRpcError: + return JsonRpcError( + -32603, + str(err), + data={"code": "canvas_handler_error", "message": str(err)}, + ) + + class CopilotSession: """ Represents a single conversation session with the Copilot CLI. @@ -1748,6 +1792,11 @@ 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 + self._client_session_apis.canvas = ( + cast(RpcCanvasHandler, _CanvasHandlerAdapter(handler)) + if handler is not None + else None + ) def _get_canvas_handler(self) -> CanvasHandler | None: with self._canvas_handler_lock: diff --git a/python/e2e/test_canvas_e2e.py b/python/e2e/test_canvas_e2e.py new file mode 100644 index 000000000..095ba6d2d --- /dev/null +++ b/python/e2e/test_canvas_e2e.py @@ -0,0 +1,169 @@ +"""E2E tests for canvas RPCs.""" + +from __future__ import annotations + +import pytest + +from copilot import ( + CanvasAction, + CanvasDeclaration, + CanvasHandler, +) +from copilot.generated.rpc import ( + CanvasCloseRequest, + CanvasInvokeActionRequest, + CanvasOpenRequest, + CanvasProviderCloseRequest, + CanvasProviderInvokeActionRequest, + CanvasProviderOpenRequest, + CanvasProviderOpenResult, +) +from copilot.session import CopilotSession, PermissionHandler + +from .testharness import E2ETestContext + +pytestmark = pytest.mark.asyncio(loop_scope="module") + + +class _CounterCanvasHandler(CanvasHandler): + def __init__(self) -> None: + self.open_calls: list[CanvasProviderOpenRequest] = [] + self.action_calls: list[CanvasProviderInvokeActionRequest] = [] + self.close_calls: list[CanvasProviderCloseRequest] = [] + + async def on_open(self, ctx: CanvasProviderOpenRequest) -> CanvasProviderOpenResult: + self.open_calls.append(ctx) + return CanvasProviderOpenResult( + url="https://example.test/counter", + title="Counter Canvas", + status="ready", + ) + + async def on_close(self, ctx: CanvasProviderCloseRequest) -> None: + self.close_calls.append(ctx) + + async def on_action(self, ctx: CanvasProviderInvokeActionRequest) -> dict[str, int]: + self.action_calls.append(ctx) + return {"newValue": 42} + + +def _counter_canvas() -> CanvasDeclaration: + return CanvasDeclaration( + id="counter", + display_name="Counter", + description="A simple counter canvas for e2e testing", + input_schema={ + "type": "object", + "properties": {"startValue": {"type": "number"}}, + }, + actions=[ + CanvasAction( + name="increment", + description="Increment the counter", + input_schema={ + "type": "object", + "properties": {"amount": {"type": "number"}}, + }, + ) + ], + ) + + +async def _create_counter_session( + ctx: E2ETestContext, +) -> tuple[_CounterCanvasHandler, CopilotSession]: + handler = _CounterCanvasHandler() + session = await ctx.client.create_session( + on_permission_request=PermissionHandler.approve_all, + canvases=[_counter_canvas()], + canvas_handler=handler, + ) + return handler, session + + +class TestCanvasRpc: + async def test_should_list_canvases(self, ctx: E2ETestContext): + _handler, session = await _create_counter_session(ctx) + try: + result = await session.rpc.canvas.list() + + assert len(result.canvases) == 1 + assert result.canvases[0].canvas_id == "counter" + assert result.canvases[0].display_name == "Counter" + assert result.canvases[0].description == "A simple counter canvas for e2e testing" + finally: + await session.disconnect() + + async def test_should_round_trip_canvas_open(self, ctx: E2ETestContext): + handler, session = await _create_counter_session(ctx) + try: + result = await session.rpc.canvas.open( + CanvasOpenRequest( + canvas_id="counter", + instance_id="counter-1", + input={"startValue": 10}, + ) + ) + + assert result.url == "https://example.test/counter" + assert result.title == "Counter Canvas" + assert result.status == "ready" + assert len(handler.open_calls) == 1 + assert handler.open_calls[0].canvas_id == "counter" + assert handler.open_calls[0].instance_id == "counter-1" + assert handler.open_calls[0].input == {"startValue": 10} + + open_list = await session.rpc.canvas.list_open() + assert len(open_list.open_canvases) == 1 + assert open_list.open_canvases[0].instance_id == "counter-1" + finally: + await session.disconnect() + + async def test_should_invoke_canvas_action(self, ctx: E2ETestContext): + handler, session = await _create_counter_session(ctx) + try: + await session.rpc.canvas.open( + CanvasOpenRequest( + canvas_id="counter", + instance_id="counter-2", + input={}, + ) + ) + + result = await session.rpc.canvas.invoke_action( + CanvasInvokeActionRequest( + action_name="increment", + instance_id="counter-2", + input={"amount": 5}, + ) + ) + + assert result == {"result": {"newValue": 42}} + assert len(handler.action_calls) == 1 + assert handler.action_calls[0].canvas_id == "counter" + assert handler.action_calls[0].instance_id == "counter-2" + assert handler.action_calls[0].action_name == "increment" + assert handler.action_calls[0].input == {"amount": 5} + finally: + await session.disconnect() + + async def test_should_run_close_lifecycle(self, ctx: E2ETestContext): + handler, session = await _create_counter_session(ctx) + try: + await session.rpc.canvas.open( + CanvasOpenRequest( + canvas_id="counter", + instance_id="counter-3", + input={}, + ) + ) + await session.rpc.canvas.close(CanvasCloseRequest(instance_id="counter-3")) + + assert len(handler.close_calls) == 1 + assert handler.close_calls[0].canvas_id == "counter" + assert handler.close_calls[0].instance_id == "counter-3" + + open_list = await session.rpc.canvas.list_open() + assert open_list.open_canvases == [] + finally: + await session.disconnect() diff --git a/python/test_canvas.py b/python/test_canvas.py index 4c9ab223f..7fb25ba53 100644 --- a/python/test_canvas.py +++ b/python/test_canvas.py @@ -2,27 +2,27 @@ from __future__ import annotations -import threading -from typing import Any +from typing import Any, cast 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 +from copilot.generated.rpc import ( + CanvasInstanceAvailability, + CanvasProviderCloseRequest, + CanvasProviderInvokeActionRequest, + CanvasProviderOpenRequest, + CanvasProviderOpenResult, +) +from copilot.session import CopilotSession def test_canvas_declaration_serializes_camelcase_and_drops_optional(): @@ -61,8 +61,8 @@ def test_extension_info_serializes(): def test_canvas_open_response_drops_none_fields(): - assert CanvasOpenResponse().to_dict() == {} - assert CanvasOpenResponse(url="https://x", status="ok").to_dict() == { + assert CanvasProviderOpenResult().to_dict() == {} + assert CanvasProviderOpenResult(url="https://x", status="ok").to_dict() == { "url": "https://x", "status": "ok", } @@ -83,11 +83,11 @@ def test_canvas_error_envelope_and_factories(): async def test_default_canvas_handler_on_action_raises_no_handler(): class StubHandler(CanvasHandler): - async def on_open(self, ctx: CanvasOpenContext) -> CanvasOpenResponse: - return CanvasOpenResponse() + async def on_open(self, ctx: CanvasProviderOpenRequest) -> CanvasProviderOpenResult: + return CanvasProviderOpenResult() handler = StubHandler() - ctx = CanvasActionContext( + ctx = CanvasProviderInvokeActionRequest( session_id="s", extension_id="e", canvas_id="c", @@ -100,143 +100,101 @@ async def on_open(self, ctx: CanvasOpenContext) -> CanvasOpenResponse: 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(): +async def test_register_canvas_handler_wires_generated_canvas_adapter(): class Handler(CanvasHandler): def __init__(self) -> None: - self.received: CanvasOpenContext | None = None + self.open_calls: list[CanvasProviderOpenRequest] = [] + self.close_calls: list[CanvasProviderCloseRequest] = [] + self.action_calls: list[CanvasProviderInvokeActionRequest] = [] + + async def on_open(self, ctx: CanvasProviderOpenRequest) -> CanvasProviderOpenResult: + self.open_calls.append(ctx) + return CanvasProviderOpenResult( + url="https://canvas.example", title="Hi", status="ready" + ) - async def on_open(self, ctx: CanvasOpenContext) -> CanvasOpenResponse: - self.received = ctx - return CanvasOpenResponse(url="https://canvas.example", title="Hi") + async def on_close(self, ctx: CanvasProviderCloseRequest) -> None: + self.close_calls.append(ctx) - async def on_action(self, ctx: CanvasActionContext) -> Any: + async def on_action(self, ctx: CanvasProviderInvokeActionRequest) -> Any: + self.action_calls.append(ctx) return {"echo": ctx.input} + session = CopilotSession("sess-1", client=None) 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" - + session._register_canvas_handler(handler) -async def test_handle_canvas_open_raises_when_handler_unset(): - session = _StubSession(handler=None) - client = _make_client_with_session("sess-1", session) + adapter = session._client_session_apis.canvas + assert adapter is not None + assert session._get_canvas_handler() is handler - 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" + open_request = CanvasProviderOpenRequest( + canvas_id="c", + extension_id="ext", + instance_id="i", + session_id="sess-1", + input={"q": 1}, + ) + open_result = await adapter.open(open_request) + assert open_result.to_dict() == { + "url": "https://canvas.example", + "title": "Hi", + "status": "ready", + } + assert handler.open_calls == [open_request] + close_request = CanvasProviderCloseRequest( + canvas_id="c", + extension_id="ext", + instance_id="i", + session_id="sess-1", + ) + await adapter.close(close_request) + assert handler.close_calls == [close_request] -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", - } + action_request = CanvasProviderInvokeActionRequest( + action_name="refresh", + canvas_id="c", + extension_id="ext", + instance_id="i", + session_id="sess-1", + input={"value": 1}, ) - assert result == [1, 2, 3] + action_result = await adapter.invoke_action(action_request) + assert action_result == {"echo": {"value": 1}} + assert handler.action_calls == [action_request] -async def test_canvas_request_handler_translates_canvas_error(): - err = CanvasError("bad", "fail") +async def test_canvas_adapter_translates_canvas_error_to_jsonrpc_error(): + class Handler(CanvasHandler): + async def on_open(self, ctx: CanvasProviderOpenRequest) -> CanvasProviderOpenResult: + raise CanvasError("bad", "fail") - async def coro(params: dict) -> Any: - raise err + session = CopilotSession("sess-1", client=None) + session._register_canvas_handler(Handler()) - wrapped = CopilotClient._canvas_request_handler(coro) + adapter = cast(Any, session._client_session_apis.canvas) with pytest.raises(JsonRpcError) as excinfo: - await wrapped({}) + await adapter.open( + CanvasProviderOpenRequest( + canvas_id="c", + extension_id="ext", + instance_id="i", + session_id="sess-1", + ) + ) 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 +def test_register_canvas_handler_can_clear_generated_handler(): + session = CopilotSession("sess-1", client=None) + session._register_canvas_handler(None) + assert session._client_session_apis.canvas is None + +def test_set_open_canvases_round_trip(): inst = OpenCanvasInstance( availability=CanvasInstanceAvailability.READY, canvas_id="c", @@ -244,6 +202,6 @@ def test_set_open_canvases_round_trip(): instance_id="i", reopen=False, ) - session = _StubSession(handler=None) + session = CopilotSession("sess-1", client=None) session._set_open_canvases([inst]) assert session.open_canvases == [inst] diff --git a/rust/src/canvas.rs b/rust/src/canvas.rs index ba13742ff..15cbb031d 100644 --- a/rust/src/canvas.rs +++ b/rust/src/canvas.rs @@ -6,7 +6,6 @@ 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; @@ -54,90 +53,6 @@ impl CanvasDeclaration { } } -/// 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")] @@ -175,8 +90,9 @@ pub type CanvasResult = Result; /// 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`] +/// `canvas.invokeAction` JSON-RPC request the runtime issues for this +/// session and decides — typically by inspecting +/// [`CanvasProviderOpenRequest::canvas_id`](crate::generated::api_types::CanvasProviderOpenRequest::canvas_id) /// — which application-side canvas should handle the call. /// /// The SDK does not maintain a per-canvas registry; multiplexing across @@ -184,104 +100,54 @@ pub type CanvasResult = Result; #[async_trait] pub trait CanvasHandler: Send + Sync { /// Open a new canvas instance. - async fn on_open(&self, ctx: CanvasOpenContext) -> CanvasResult; + async fn on_open( + &self, + ctx: crate::generated::api_types::CanvasProviderOpenRequest, + ) -> CanvasResult; /// Handle a non-lifecycle action declared by the canvas. - async fn on_action(&self, _ctx: CanvasActionContext) -> CanvasResult { + async fn on_action( + &self, + _ctx: crate::generated::api_types::CanvasProviderInvokeActionRequest, + ) -> CanvasResult { Err(CanvasError::no_handler()) } /// Canvas was closed by the user or agent. - async fn on_close(&self, _ctx: CanvasLifecycleContext) -> CanvasResult<()> { + async fn on_close( + &self, + _ctx: crate::generated::api_types::CanvasProviderCloseRequest, + ) -> 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::*; + use crate::generated::api_types::{ + CanvasProviderInvokeActionRequest, CanvasProviderOpenRequest, CanvasProviderOpenResult, + }; + use crate::types::SessionId; struct EchoHandler; #[async_trait] impl CanvasHandler for EchoHandler { - async fn on_open(&self, ctx: CanvasOpenContext) -> CanvasResult { - Ok(CanvasOpenResponse { + async fn on_open( + &self, + ctx: CanvasProviderOpenRequest, + ) -> CanvasResult { + Ok(CanvasProviderOpenResult { 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 { + async fn on_action(&self, ctx: CanvasProviderInvokeActionRequest) -> CanvasResult { Ok(json!({ "echoed": ctx.action_name, "input": ctx.input })) } } @@ -312,12 +178,12 @@ mod tests { async fn handler_on_open_returns_response() { let handler = EchoHandler; let response = handler - .on_open(CanvasOpenContext { + .on_open(CanvasProviderOpenRequest { 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 }), + input: Some(json!({ "x": 1 })), host: None, }) .await @@ -332,13 +198,13 @@ mod tests { async fn handler_on_action_returns_value() { let handler = EchoHandler; let result = handler - .on_action(CanvasActionContext { + .on_action(CanvasProviderInvokeActionRequest { 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"), + input: Some(json!("hi")), host: None, }) .await @@ -353,8 +219,11 @@ mod tests { struct OpenOnly; #[async_trait] impl CanvasHandler for OpenOnly { - async fn on_open(&self, _ctx: CanvasOpenContext) -> CanvasResult { - Ok(CanvasOpenResponse { + async fn on_open( + &self, + _ctx: CanvasProviderOpenRequest, + ) -> CanvasResult { + Ok(CanvasProviderOpenResult { url: None, title: None, status: None, @@ -363,13 +232,13 @@ mod tests { } let err = OpenOnly - .on_action(CanvasActionContext { + .on_action(CanvasProviderInvokeActionRequest { 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, + input: Some(Value::Null), host: None, }) .await diff --git a/rust/src/canvas_dispatch.rs b/rust/src/canvas_dispatch.rs new file mode 100644 index 000000000..aa991320b --- /dev/null +++ b/rust/src/canvas_dispatch.rs @@ -0,0 +1,192 @@ +//! Inbound `canvas.*` JSON-RPC request dispatch helpers. +//! +//! Internal — public-facing trait lives in `crate::canvas`. Each helper +//! deserializes the generated wire request, calls the user-facing +//! [`CanvasHandler`] method, and serializes the result back onto JSON-RPC. + +use std::sync::Arc; + +use serde::Serialize; +use serde_json::Value; +use tracing::warn; + +use crate::canvas::{CanvasError, CanvasHandler}; +use crate::generated::api_types::{ + CanvasProviderCloseRequest, CanvasProviderInvokeActionRequest, CanvasProviderOpenRequest, + rpc_methods, +}; +use crate::{Client, JsonRpcRequest, JsonRpcResponse, error_codes}; + +async fn respond(client: &Client, request_id: u64, result: T) { + let value = match serde_json::to_value(&result) { + Ok(value) => value, + Err(error) => { + warn!(error = %error, "failed to serialize canvas response"); + send_error( + client, + request_id, + error_codes::INTERNAL_ERROR, + "serialization failure", + None, + ) + .await; + return; + } + }; + + let _ = client + .send_response(&JsonRpcResponse { + jsonrpc: "2.0".to_string(), + id: request_id, + result: Some(value), + error: None, + }) + .await; +} + +async fn send_error( + client: &Client, + request_id: u64, + code: i32, + message: &str, + data: Option, +) { + let _ = client + .send_response(&JsonRpcResponse { + jsonrpc: "2.0".to_string(), + id: request_id, + result: None, + error: Some(crate::JsonRpcError { + code, + message: message.to_string(), + data, + }), + }) + .await; +} + +async fn send_canvas_error(client: &Client, request_id: u64, error: CanvasError) { + let message = error.message.clone(); + let data = Some(serde_json::json!({ + "code": error.code, + "message": message, + })); + send_error( + client, + request_id, + error_codes::INTERNAL_ERROR, + &error.message, + data, + ) + .await; +} + +async fn parse_params( + client: &Client, + request: &JsonRpcRequest, +) -> Option { + 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) => { + send_error( + client, + request.id, + error_codes::INVALID_PARAMS, + &format!("invalid params: {error}"), + None, + ) + .await; + None + } + } +} + +fn canvas_handler_or_err( + handler: Option<&Arc>, +) -> Result, CanvasError> { + handler.cloned().ok_or_else(|| { + CanvasError::new( + "canvas_handler_unset", + "No CanvasHandler installed on this session; call SessionConfig::with_canvas_handler before creating the session.", + ) + }) +} + +async fn open(client: &Client, handler: &Arc, request: JsonRpcRequest) { + let Some(params) = parse_params::(client, &request).await else { + return; + }; + + match handler.on_open(params).await { + Ok(result) => respond(client, request.id, result).await, + Err(error) => send_canvas_error(client, request.id, error).await, + } +} + +async fn close(client: &Client, handler: &Arc, request: JsonRpcRequest) { + let Some(params) = parse_params::(client, &request).await else { + return; + }; + + match handler.on_close(params).await { + Ok(()) => respond(client, request.id, Value::Null).await, + Err(error) => send_canvas_error(client, request.id, error).await, + } +} + +async fn invoke_action(client: &Client, handler: &Arc, request: JsonRpcRequest) { + let Some(params) = parse_params::(client, &request).await + else { + return; + }; + + match handler.on_action(params).await { + Ok(result) => respond(client, request.id, result).await, + Err(error) => send_canvas_error(client, request.id, error).await, + } +} + +/// Dispatch a `canvas.*` request to the appropriate handler. Returns `true` +/// if the request was a canvas method, `false` otherwise. +pub(crate) async fn dispatch( + client: &Client, + handler: Option<&Arc>, + request: JsonRpcRequest, +) -> bool { + let method = request.method.as_str(); + if !method.starts_with("canvas.") { + return false; + } + + let handler = match canvas_handler_or_err(handler) { + Ok(handler) => handler, + Err(error) => { + send_canvas_error(client, request.id, error).await; + return true; + } + }; + + match method { + rpc_methods::CANVAS_OPEN => open(client, &handler, request).await, + rpc_methods::CANVAS_CLOSE => close(client, &handler, request).await, + rpc_methods::CANVAS_INVOKEACTION => invoke_action(client, &handler, request).await, + _ => { + warn!(method = %method, "unknown canvas.* method"); + send_error( + client, + request.id, + error_codes::METHOD_NOT_FOUND, + &format!("unknown method: {method}"), + None, + ) + .await; + } + } + + true +} diff --git a/rust/src/generated/api_types.rs b/rust/src/generated/api_types.rs index 9f2da297e..58451ded9 100644 --- a/rust/src/generated/api_types.rs +++ b/rust/src/generated/api_types.rs @@ -402,6 +402,12 @@ pub mod rpc_methods { pub const SESSIONFS_SQLITEQUERY: &str = "sessionFs.sqliteQuery"; /// `sessionFs.sqliteExists` pub const SESSIONFS_SQLITEEXISTS: &str = "sessionFs.sqliteExists"; + /// `canvas.open` + pub const CANVAS_OPEN: &str = "canvas.open"; + /// `canvas.close` + pub const CANVAS_CLOSE: &str = "canvas.close"; + /// `canvas.invokeAction` + pub const CANVAS_INVOKEACTION: &str = "canvas.invokeAction"; } /// Parameters for aborting the current turn @@ -917,6 +923,38 @@ pub struct CanvasCloseRequest { pub instance_id: String, } +/// Host capabilities +/// +///
+/// +/// **Experimental.** This type is part of an experimental wire-protocol surface +/// and may change or be removed in future SDK or CLI releases. +/// +///
+#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CanvasHostContextCapabilities { + /// Whether canvas rendering is supported + #[serde(skip_serializing_if = "Option::is_none")] + pub canvases: Option, +} + +/// Host context supplied by the runtime. +/// +///
+/// +/// **Experimental.** This type is part of an experimental wire-protocol surface +/// and may change or be removed in future SDK or CLI releases. +/// +///
+#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CanvasHostContext { + /// Host capabilities + #[serde(skip_serializing_if = "Option::is_none")] + pub capabilities: Option, +} + /// Canvas action invocation parameters. /// ///
@@ -1074,6 +1112,108 @@ pub struct CanvasOpenRequest { pub instance_id: String, } +/// Canvas close parameters sent to the provider. +/// +///
+/// +/// **Experimental.** This type is part of an experimental wire-protocol surface +/// and may change or be removed in future SDK or CLI releases. +/// +///
+#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CanvasProviderCloseRequest { + /// Target session identifier + pub session_id: SessionId, + /// Owning provider identifier + pub extension_id: String, + /// Provider-local canvas identifier + pub canvas_id: String, + /// Canvas instance identifier + pub instance_id: String, + /// Host context supplied by the runtime. + #[serde(skip_serializing_if = "Option::is_none")] + pub host: Option, +} + +/// Canvas action invocation parameters sent to the provider. +/// +///
+/// +/// **Experimental.** This type is part of an experimental wire-protocol surface +/// and may change or be removed in future SDK or CLI releases. +/// +///
+#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CanvasProviderInvokeActionRequest { + /// Target session identifier + pub session_id: SessionId, + /// Owning provider identifier + pub extension_id: String, + /// Provider-local canvas identifier + pub canvas_id: String, + /// Canvas instance identifier + pub instance_id: String, + /// Action name to invoke + pub action_name: String, + /// Action input + #[serde(skip_serializing_if = "Option::is_none")] + pub input: Option, + /// Host context supplied by the runtime. + #[serde(skip_serializing_if = "Option::is_none")] + pub host: Option, +} + +/// Canvas open parameters sent to the provider. +/// +///
+/// +/// **Experimental.** This type is part of an experimental wire-protocol surface +/// and may change or be removed in future SDK or CLI releases. +/// +///
+#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CanvasProviderOpenRequest { + /// Target session identifier + pub session_id: SessionId, + /// Owning provider identifier + pub extension_id: String, + /// Provider-local canvas identifier + pub canvas_id: String, + /// Stable caller-supplied canvas instance identifier + pub instance_id: String, + /// Canvas open input + #[serde(skip_serializing_if = "Option::is_none")] + pub input: Option, + /// Host context supplied by the runtime. + #[serde(skip_serializing_if = "Option::is_none")] + pub host: Option, +} + +/// Canvas open result returned by the provider. +/// +///
+/// +/// **Experimental.** This type is part of an experimental wire-protocol surface +/// and may change or be removed in future SDK or CLI releases. +/// +///
+#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CanvasProviderOpenResult { + /// Provider-supplied status text + #[serde(skip_serializing_if = "Option::is_none")] + pub status: Option, + /// Provider-supplied title + #[serde(skip_serializing_if = "Option::is_none")] + pub title: Option, + /// URL for web-rendered canvases + #[serde(skip_serializing_if = "Option::is_none")] + pub url: Option, +} + /// Optional unstructured input hint /// ///
@@ -2930,6 +3070,9 @@ pub struct McpServerConfigHttp { /// Whether the configured OAuth client is public and does not require a client secret. #[serde(skip_serializing_if = "Option::is_none")] pub oauth_public_client: Option, + /// OIDC token configuration. When truthy, a token is automatically gathered. + #[serde(skip_serializing_if = "Option::is_none")] + pub oidc: Option, /// Timeout in milliseconds for tool calls to this server. #[serde(skip_serializing_if = "Option::is_none")] pub timeout: Option, @@ -2950,6 +3093,9 @@ pub struct McpServerConfigStdio { /// Command-line arguments passed to the Stdio MCP server process. #[serde(default)] pub args: Vec, + /// Authentication configuration for this server. + #[serde(skip_serializing_if = "Option::is_none")] + pub auth: Option, /// Executable command used to start the Stdio MCP server process. pub command: String, /// Working directory for the Stdio MCP server process. @@ -2964,6 +3110,9 @@ pub struct McpServerConfigStdio { /// Whether this server is a built-in fallback used when the user has not configured their own server. #[serde(skip_serializing_if = "Option::is_none")] pub is_default_server: Option, + /// OIDC token configuration. When truthy, a token is automatically gathered. + #[serde(skip_serializing_if = "Option::is_none")] + pub oidc: Option, /// Timeout in milliseconds for tool calls to this server. #[serde(skip_serializing_if = "Option::is_none")] pub timeout: Option, @@ -5877,7 +6026,7 @@ pub struct SessionMetadata { pub summary: Option, } -/// The same metadata records, with summary and context fields backfilled where available. +/// The enriched metadata records, with summary and context fields backfilled where available. Sessions confirmed empty and unnamed are omitted. /// ///
/// @@ -5888,11 +6037,18 @@ pub struct SessionMetadata { #[derive(Debug, Clone, Default, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct SessionEnrichMetadataResult { - /// Same records, with summary and context backfilled + /// Enriched records, with summary and context backfilled. Sessions confirmed empty and unnamed may be omitted. pub sessions: Vec, } /// File path, content to append, and optional mode for the client-provided session filesystem. +/// +///
+/// +/// **Experimental.** This type is part of an experimental wire-protocol surface +/// and may change or be removed in future SDK or CLI releases. +/// +///
#[derive(Debug, Clone, Default, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct SessionFsAppendFileRequest { @@ -5908,6 +6064,13 @@ pub struct SessionFsAppendFileRequest { } /// Describes a filesystem error. +/// +///
+/// +/// **Experimental.** This type is part of an experimental wire-protocol surface +/// and may change or be removed in future SDK or CLI releases. +/// +///
#[derive(Debug, Clone, Default, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct SessionFsError { @@ -5919,6 +6082,13 @@ pub struct SessionFsError { } /// Path to test for existence in the client-provided session filesystem. +/// +///
+/// +/// **Experimental.** This type is part of an experimental wire-protocol surface +/// and may change or be removed in future SDK or CLI releases. +/// +///
#[derive(Debug, Clone, Default, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct SessionFsExistsRequest { @@ -5929,6 +6099,13 @@ pub struct SessionFsExistsRequest { } /// Indicates whether the requested path exists in the client-provided session filesystem. +/// +///
+/// +/// **Experimental.** This type is part of an experimental wire-protocol surface +/// and may change or be removed in future SDK or CLI releases. +/// +///
#[derive(Debug, Clone, Default, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct SessionFsExistsResult { @@ -5937,6 +6114,13 @@ pub struct SessionFsExistsResult { } /// Directory path to create in the client-provided session filesystem, with options for recursive creation and POSIX mode. +/// +///
+/// +/// **Experimental.** This type is part of an experimental wire-protocol surface +/// and may change or be removed in future SDK or CLI releases. +/// +///
#[derive(Debug, Clone, Default, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct SessionFsMkdirRequest { @@ -5953,6 +6137,13 @@ pub struct SessionFsMkdirRequest { } /// Directory path whose entries should be listed from the client-provided session filesystem. +/// +///
+/// +/// **Experimental.** This type is part of an experimental wire-protocol surface +/// and may change or be removed in future SDK or CLI releases. +/// +///
#[derive(Debug, Clone, Default, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct SessionFsReaddirRequest { @@ -5963,6 +6154,13 @@ pub struct SessionFsReaddirRequest { } /// Names of entries in the requested directory, or a filesystem error if the read failed. +/// +///
+/// +/// **Experimental.** This type is part of an experimental wire-protocol surface +/// and may change or be removed in future SDK or CLI releases. +/// +///
#[derive(Debug, Clone, Default, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct SessionFsReaddirResult { @@ -5974,6 +6172,13 @@ pub struct SessionFsReaddirResult { } /// Schema for the `SessionFsReaddirWithTypesEntry` type. +/// +///
+/// +/// **Experimental.** This type is part of an experimental wire-protocol surface +/// and may change or be removed in future SDK or CLI releases. +/// +///
#[derive(Debug, Clone, Default, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct SessionFsReaddirWithTypesEntry { @@ -5984,6 +6189,13 @@ pub struct SessionFsReaddirWithTypesEntry { } /// Directory path whose entries (with type information) should be listed from the client-provided session filesystem. +/// +///
+/// +/// **Experimental.** This type is part of an experimental wire-protocol surface +/// and may change or be removed in future SDK or CLI releases. +/// +///
#[derive(Debug, Clone, Default, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct SessionFsReaddirWithTypesRequest { @@ -5994,6 +6206,13 @@ pub struct SessionFsReaddirWithTypesRequest { } /// Entries in the requested directory paired with file/directory type information, or a filesystem error if the read failed. +/// +///
+/// +/// **Experimental.** This type is part of an experimental wire-protocol surface +/// and may change or be removed in future SDK or CLI releases. +/// +///
#[derive(Debug, Clone, Default, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct SessionFsReaddirWithTypesResult { @@ -6005,6 +6224,13 @@ pub struct SessionFsReaddirWithTypesResult { } /// Path of the file to read from the client-provided session filesystem. +/// +///
+/// +/// **Experimental.** This type is part of an experimental wire-protocol surface +/// and may change or be removed in future SDK or CLI releases. +/// +///
#[derive(Debug, Clone, Default, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct SessionFsReadFileRequest { @@ -6015,6 +6241,13 @@ pub struct SessionFsReadFileRequest { } /// File content as a UTF-8 string, or a filesystem error if the read failed. +/// +///
+/// +/// **Experimental.** This type is part of an experimental wire-protocol surface +/// and may change or be removed in future SDK or CLI releases. +/// +///
#[derive(Debug, Clone, Default, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct SessionFsReadFileResult { @@ -6026,6 +6259,13 @@ pub struct SessionFsReadFileResult { } /// Source and destination paths for renaming or moving an entry in the client-provided session filesystem. +/// +///
+/// +/// **Experimental.** This type is part of an experimental wire-protocol surface +/// and may change or be removed in future SDK or CLI releases. +/// +///
#[derive(Debug, Clone, Default, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct SessionFsRenameRequest { @@ -6038,6 +6278,13 @@ pub struct SessionFsRenameRequest { } /// Path to remove from the client-provided session filesystem, with options for recursive removal and force. +/// +///
+/// +/// **Experimental.** This type is part of an experimental wire-protocol surface +/// and may change or be removed in future SDK or CLI releases. +/// +///
#[derive(Debug, Clone, Default, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct SessionFsRmRequest { @@ -6086,6 +6333,13 @@ pub struct SessionFsSetProviderResult { } /// Indicates whether the per-session SQLite database already exists. +/// +///
+/// +/// **Experimental.** This type is part of an experimental wire-protocol surface +/// and may change or be removed in future SDK or CLI releases. +/// +///
#[derive(Debug, Clone, Default, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct SessionFsSqliteExistsResult { @@ -6094,6 +6348,13 @@ pub struct SessionFsSqliteExistsResult { } /// SQL query, query type, and optional bind parameters for executing a SQLite query against the per-session database. +/// +///
+/// +/// **Experimental.** This type is part of an experimental wire-protocol surface +/// and may change or be removed in future SDK or CLI releases. +/// +///
#[derive(Debug, Clone, Default, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct SessionFsSqliteQueryRequest { @@ -6109,6 +6370,13 @@ pub struct SessionFsSqliteQueryRequest { } /// Query results including rows, columns, and rows affected, or a filesystem error if execution failed. +/// +///
+/// +/// **Experimental.** This type is part of an experimental wire-protocol surface +/// and may change or be removed in future SDK or CLI releases. +/// +///
#[derive(Debug, Clone, Default, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct SessionFsSqliteQueryResult { @@ -6127,6 +6395,13 @@ pub struct SessionFsSqliteQueryResult { } /// Path whose metadata should be returned from the client-provided session filesystem. +/// +///
+/// +/// **Experimental.** This type is part of an experimental wire-protocol surface +/// and may change or be removed in future SDK or CLI releases. +/// +///
#[derive(Debug, Clone, Default, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct SessionFsStatRequest { @@ -6137,6 +6412,13 @@ pub struct SessionFsStatRequest { } /// Filesystem metadata for the requested path, or a filesystem error if the stat failed. +/// +///
+/// +/// **Experimental.** This type is part of an experimental wire-protocol surface +/// and may change or be removed in future SDK or CLI releases. +/// +///
#[derive(Debug, Clone, Default, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct SessionFsStatResult { @@ -6156,6 +6438,13 @@ pub struct SessionFsStatResult { } /// File path, content to write, and optional mode for the client-provided session filesystem. +/// +///
+/// +/// **Experimental.** This type is part of an experimental wire-protocol surface +/// and may change or be removed in future SDK or CLI releases. +/// +///
#[derive(Debug, Clone, Default, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct SessionFsWriteFileRequest { @@ -9108,7 +9397,7 @@ pub struct SessionsPruneOldResult { pub skipped: Vec, } -/// The same metadata records, with summary and context fields backfilled where available. +/// The enriched metadata records, with summary and context fields backfilled where available. Sessions confirmed empty and unnamed are omitted. /// ///
/// @@ -9119,7 +9408,7 @@ pub struct SessionsPruneOldResult { #[derive(Debug, Clone, Default, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct SessionsEnrichMetadataResult { - /// Same records, with summary and context backfilled + /// Enriched records, with summary and context backfilled. Sessions confirmed empty and unnamed may be omitted. pub sessions: Vec, } @@ -11807,6 +12096,13 @@ pub struct SessionScheduleStopResult { } /// Identifies the target session. +/// +///
+/// +/// **Experimental.** This type is part of an experimental wire-protocol surface +/// and may change or be removed in future SDK or CLI releases. +/// +///
#[derive(Debug, Clone, Default, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct SessionFsSqliteExistsParams { @@ -11814,6 +12110,28 @@ pub struct SessionFsSqliteExistsParams { pub session_id: SessionId, } +/// Canvas open result returned by the provider. +/// +///
+/// +/// **Experimental.** This type is part of an experimental wire-protocol surface +/// and may change or be removed in future SDK or CLI releases. +/// +///
+#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CanvasOpenResult { + /// Provider-supplied status text + #[serde(skip_serializing_if = "Option::is_none")] + pub status: Option, + /// Provider-supplied title + #[serde(skip_serializing_if = "Option::is_none")] + pub title: Option, + /// URL for web-rendered canvases + #[serde(skip_serializing_if = "Option::is_none")] + pub url: Option, +} + /// MCP CreateMessageResult payload (with optional 'tools' extension), present when action='success'. Treated as opaque at the schema layer; consumers should construct/consume it per the MCP CreateMessageResult shape. /// ///
@@ -13522,6 +13840,13 @@ pub enum SessionContextHostType { } /// Error classification +/// +///
+/// +/// **Experimental.** This type is part of an experimental wire-protocol surface +/// and may change or be removed in future SDK or CLI releases. +/// +///
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] pub enum SessionFsErrorCode { /// The requested path does not exist. @@ -13535,6 +13860,13 @@ pub enum SessionFsErrorCode { } /// Entry type +/// +///
+/// +/// **Experimental.** This type is part of an experimental wire-protocol surface +/// and may change or be removed in future SDK or CLI releases. +/// +///
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] pub enum SessionFsReaddirWithTypesEntryType { /// The entry is a file. @@ -13565,6 +13897,13 @@ pub enum SessionFsSetProviderConventions { } /// How to execute the query: 'exec' for DDL/multi-statement (no results), 'query' for SELECT (returns rows), 'run' for INSERT/UPDATE/DELETE (returns rowsAffected) +/// +///
+/// +/// **Experimental.** This type is part of an experimental wire-protocol surface +/// and may change or be removed in future SDK or CLI releases. +/// +///
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] pub enum SessionFsSqliteQueryType { /// Execute DDL or multi-statement SQL without returning rows. diff --git a/rust/src/generated/rpc.rs b/rust/src/generated/rpc.rs index 644e49e86..c0c793fe0 100644 --- a/rust/src/generated/rpc.rs +++ b/rust/src/generated/rpc.rs @@ -902,7 +902,7 @@ impl<'a> ClientRpcSessions<'a> { /// /// # Returns /// - /// The same metadata records, with summary and context fields backfilled where available. + /// The enriched metadata records, with summary and context fields backfilled where available. Sessions confirmed empty and unnamed are omitted. /// ///
/// diff --git a/rust/src/lib.rs b/rust/src/lib.rs index 787697e2e..cad6ee629 100644 --- a/rust/src/lib.rs +++ b/rust/src/lib.rs @@ -5,6 +5,7 @@ /// Canvas declarations, provider callbacks, and host-side canvas RPC types. pub mod canvas; +mod canvas_dispatch; /// 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 f216b866b..57181459c 100644 --- a/rust/src/session.rs +++ b/rust/src/session.rs @@ -4,14 +4,13 @@ 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::canvas::{CanvasHandler, CanvasInvokeParams, CanvasProviderRequestParams}; +use crate::canvas::CanvasHandler; use crate::generated::api_types::{LogRequest, ModelSwitchToRequest, OpenCanvasInstance}; use crate::generated::session_events::{ CommandExecuteData, ElicitationRequestedData, ExternalToolRequestedData, SessionErrorData, @@ -1735,39 +1734,12 @@ async fn handle_request( return; } - 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; - } + if request.method.starts_with("canvas.") { + crate::canvas_dispatch::dispatch(client, canvas_handler, request).await; + return; + } + match request.method.as_str() { "hooks.invoke" => { let params = request.params.as_ref(); let hook_type = params @@ -2000,112 +1972,6 @@ 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 d841096c5..f454e33ed 100644 --- a/rust/src/types.rs +++ b/rust/src/types.rs @@ -1113,7 +1113,7 @@ pub struct SessionConfig { /// 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 + /// `canvas.open` / `canvas.close` / `canvas.invokeAction` requests to /// this handler. Use [`with_canvas_handler`](Self::with_canvas_handler) /// to install one. pub canvas_handler: Option>, diff --git a/rust/tests/e2e.rs b/rust/tests/e2e.rs index 09ece6cf5..12863aff4 100644 --- a/rust/tests/e2e.rs +++ b/rust/tests/e2e.rs @@ -7,6 +7,8 @@ mod abort; mod ask_user; #[path = "e2e/builtin_tools.rs"] mod builtin_tools; +#[path = "e2e/canvas.rs"] +mod canvas; #[path = "e2e/client.rs"] mod client; #[path = "e2e/client_api.rs"] diff --git a/rust/tests/e2e/canvas.rs b/rust/tests/e2e/canvas.rs new file mode 100644 index 000000000..2452f5319 --- /dev/null +++ b/rust/tests/e2e/canvas.rs @@ -0,0 +1,283 @@ +use std::sync::Arc; + +use async_trait::async_trait; +use github_copilot_sdk::canvas::{CanvasDeclaration, CanvasHandler, CanvasResult}; +use github_copilot_sdk::generated::api_types::{ + CanvasAction, CanvasProviderCloseRequest, CanvasProviderInvokeActionRequest, + CanvasProviderOpenRequest, CanvasProviderOpenResult, +}; +use github_copilot_sdk::types::ExtensionInfo; +use parking_lot::Mutex; +use serde_json::{Value, json}; + +use super::support::with_e2e_context; + +struct TestCanvasHandler { + open_calls: Mutex>, + close_calls: Mutex>, + action_calls: Mutex>, +} + +impl TestCanvasHandler { + fn new() -> Self { + Self { + open_calls: Mutex::new(Vec::new()), + close_calls: Mutex::new(Vec::new()), + action_calls: Mutex::new(Vec::new()), + } + } +} + +#[async_trait] +impl CanvasHandler for TestCanvasHandler { + async fn on_open( + &self, + ctx: CanvasProviderOpenRequest, + ) -> CanvasResult { + self.open_calls.lock().push(ctx.clone()); + Ok(CanvasProviderOpenResult { + url: Some(format!("https://example.com/counter/{}", ctx.instance_id)), + title: Some(format!("Counter {}", ctx.instance_id)), + status: Some("ready".to_string()), + }) + } + + async fn on_action(&self, ctx: CanvasProviderInvokeActionRequest) -> CanvasResult { + self.action_calls.lock().push(ctx.clone()); + Ok(json!({ "newValue": 42 })) + } + + async fn on_close(&self, ctx: CanvasProviderCloseRequest) -> CanvasResult<()> { + self.close_calls.lock().push(ctx.clone()); + Ok(()) + } +} + +fn canvas_session_config( + ctx: &super::support::E2eContext, + handler: Arc, +) -> github_copilot_sdk::types::SessionConfig { + let mut decl = CanvasDeclaration::new("counter", "Counter", "Tracks a counter value."); + decl.actions = Some(vec![CanvasAction { + name: "increment".to_string(), + description: Some("Increments the counter.".to_string()), + input_schema: None, + }]); + + ctx.approve_all_session_config() + .with_request_canvas_renderer(true) + .with_request_extensions(true) + .with_extension_info(ExtensionInfo::new("rust-sdk-tests", "canvas-provider")) + .with_canvases([decl]) + .with_canvas_handler(handler) +} + +#[tokio::test] +async fn canvas_list_discovers_declared_canvases() { + with_e2e_context("canvas", "canvas_list_discovers_declared_canvases", |ctx| { + Box::pin(async move { + ctx.set_default_copilot_user(); + let client = ctx.start_client().await; + let handler = Arc::new(TestCanvasHandler::new()); + let session = client + .create_session(canvas_session_config(ctx, handler)) + .await + .expect("create session"); + + let result = session.rpc().canvas().list().await.expect("list canvases"); + + assert_eq!(result.canvases.len(), 1); + assert_eq!(result.canvases[0].canvas_id, "counter"); + assert_eq!(result.canvases[0].display_name, "Counter"); + assert_eq!(result.canvases[0].description, "Tracks a counter value."); + + session.disconnect().await.expect("disconnect session"); + client.stop().await.expect("stop client"); + }) + }) + .await; +} + +#[tokio::test] +async fn canvas_open_round_trip() { + with_e2e_context("canvas", "canvas_open_round_trip", |ctx| { + Box::pin(async move { + ctx.set_default_copilot_user(); + let client = ctx.start_client().await; + let handler = Arc::new(TestCanvasHandler::new()); + let session = client + .create_session(canvas_session_config(ctx, handler.clone())) + .await + .expect("create session"); + + let canvas_list = session.rpc().canvas().list().await.expect("list canvases"); + let canvas = &canvas_list.canvases[0]; + + let open_result = session + .rpc() + .canvas() + .open( + github_copilot_sdk::generated::api_types::CanvasOpenRequest { + canvas_id: "counter".to_string(), + instance_id: "counter-1".to_string(), + extension_id: Some(canvas.extension_id.clone()), + input: Some(json!({ "start": 41 })), + }, + ) + .await + .expect("open canvas"); + + assert_eq!(open_result.instance_id, "counter-1"); + assert_eq!(open_result.title.as_deref(), Some("Counter counter-1")); + assert_eq!(open_result.status.as_deref(), Some("ready")); + assert_eq!( + open_result.url.as_deref(), + Some("https://example.com/counter/counter-1") + ); + + { + let opens = handler.open_calls.lock(); + assert_eq!(opens.len(), 1); + assert_eq!(opens[0].canvas_id, "counter"); + assert_eq!(opens[0].instance_id, "counter-1"); + } + + let open_list = session + .rpc() + .canvas() + .list_open() + .await + .expect("list open canvases"); + assert_eq!(open_list.open_canvases.len(), 1); + assert_eq!(open_list.open_canvases[0].instance_id, "counter-1"); + + session.disconnect().await.expect("disconnect session"); + client.stop().await.expect("stop client"); + }) + }) + .await; +} + +#[tokio::test] +async fn canvas_invoke_action_round_trip() { + with_e2e_context("canvas", "canvas_invoke_action_round_trip", |ctx| { + Box::pin(async move { + ctx.set_default_copilot_user(); + let client = ctx.start_client().await; + let handler = Arc::new(TestCanvasHandler::new()); + let session = client + .create_session(canvas_session_config(ctx, handler.clone())) + .await + .expect("create session"); + + let canvas_list = session.rpc().canvas().list().await.expect("list canvases"); + let canvas = &canvas_list.canvases[0]; + + session + .rpc() + .canvas() + .open( + github_copilot_sdk::generated::api_types::CanvasOpenRequest { + canvas_id: "counter".to_string(), + instance_id: "counter-2".to_string(), + extension_id: Some(canvas.extension_id.clone()), + input: Some(json!({})), + }, + ) + .await + .expect("open canvas"); + + let result = session + .rpc() + .canvas() + .invoke_action( + github_copilot_sdk::generated::api_types::CanvasInvokeActionRequest { + instance_id: "counter-2".to_string(), + action_name: "increment".to_string(), + input: Some(json!({ "delta": 1 })), + }, + ) + .await + .expect("invoke action"); + + assert_eq!(result.result, Some(json!({ "newValue": 42 }))); + + { + let actions = handler.action_calls.lock(); + assert_eq!(actions.len(), 1); + assert_eq!(actions[0].canvas_id, "counter"); + assert_eq!(actions[0].instance_id, "counter-2"); + assert_eq!(actions[0].action_name, "increment"); + assert_eq!(actions[0].input, Some(json!({ "delta": 1 }))); + } + + session.disconnect().await.expect("disconnect session"); + client.stop().await.expect("stop client"); + }) + }) + .await; +} + +#[tokio::test] +async fn canvas_close_round_trip() { + with_e2e_context("canvas", "canvas_close_round_trip", |ctx| { + Box::pin(async move { + ctx.set_default_copilot_user(); + let client = ctx.start_client().await; + let handler = Arc::new(TestCanvasHandler::new()); + let session = client + .create_session(canvas_session_config(ctx, handler.clone())) + .await + .expect("create session"); + + let canvas_list = session.rpc().canvas().list().await.expect("list canvases"); + let canvas = &canvas_list.canvases[0]; + + session + .rpc() + .canvas() + .open( + github_copilot_sdk::generated::api_types::CanvasOpenRequest { + canvas_id: "counter".to_string(), + instance_id: "counter-3".to_string(), + extension_id: Some(canvas.extension_id.clone()), + input: Some(json!({})), + }, + ) + .await + .expect("open canvas"); + + assert!(handler.close_calls.lock().is_empty()); + + session + .rpc() + .canvas() + .close( + github_copilot_sdk::generated::api_types::CanvasCloseRequest { + instance_id: "counter-3".to_string(), + }, + ) + .await + .expect("close canvas"); + + { + let closes = handler.close_calls.lock(); + assert_eq!(closes.len(), 1); + assert_eq!(closes[0].canvas_id, "counter"); + assert_eq!(closes[0].instance_id, "counter-3"); + } + + let open_list = session + .rpc() + .canvas() + .list_open() + .await + .expect("list open canvases"); + assert!(open_list.open_canvases.is_empty()); + + session.disconnect().await.expect("disconnect session"); + client.stop().await.expect("stop client"); + }) + }) + .await; +} diff --git a/rust/tests/session_test.rs b/rust/tests/session_test.rs index 050c5898d..bb4e602e0 100644 --- a/rust/tests/session_test.rs +++ b/rust/tests/session_test.rs @@ -6,11 +6,11 @@ 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::canvas::{CanvasDeclaration, CanvasHandler, CanvasResult}; +use github_copilot_sdk::generated::api_types::{ + CanvasInstanceAvailability, CanvasProviderInvokeActionRequest, CanvasProviderOpenRequest, + CanvasProviderOpenResult, OpenCanvasInstance, }; -use github_copilot_sdk::generated::api_types::{CanvasInstanceAvailability, OpenCanvasInstance}; use github_copilot_sdk::handler::{ ApproveAllHandler, AutoModeSwitchHandler, AutoModeSwitchResponse, ElicitationHandler, ExitPlanModeHandler, ExitPlanModeResult, UserInputHandler, UserInputResponse, @@ -31,15 +31,18 @@ struct TestCanvasHandler; #[async_trait] impl CanvasHandler for TestCanvasHandler { - async fn on_open(&self, ctx: CanvasOpenContext) -> CanvasResult { - Ok(CanvasOpenResponse { + async fn on_open( + &self, + ctx: CanvasProviderOpenRequest, + ) -> CanvasResult { + Ok(CanvasProviderOpenResult { 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 { + async fn on_action(&self, ctx: CanvasProviderInvokeActionRequest) -> CanvasResult { Ok(serde_json::json!({ "actionName": ctx.action_name, "input": ctx.input, @@ -380,7 +383,7 @@ async fn provider_canvas_dispatch_routes_direct_canvas_action_requests() { server .send_request( 42, - "canvas.action.invoke", + "canvas.invokeAction", serde_json::json!({ "sessionId": session.id(), "extensionId": "project:counter", diff --git a/scripts/codegen/csharp.ts b/scripts/codegen/csharp.ts index 13a7a1417..883895cde 100644 --- a/scripts/codegen/csharp.ts +++ b/scripts/codegen/csharp.ts @@ -1452,12 +1452,14 @@ function getCSharpSchemaTypeName(schema: JSONSchema7 | null | undefined, fallbac return getRpcSchemaTypeName(schema, fallback); } -/** Returns the C# type for a method's result, accounting for nullable anyOf wrappers. */ +/** Returns the C# type for a method's result, accounting for nullable anyOf wrappers and opaque JSON. */ function resolvedResultTypeName(method: RpcMethod): string { const schema = getMethodResultSchema(method); if (!schema) return resultTypeName(method); + if (isOpaqueJson(schema)) return "object"; const inner = getNullableInner(schema); if (inner) { + if (isOpaqueJson(inner)) return "object?"; // Nullable wrapper: resolve the inner $ref type name with "?" suffix const innerName = inner.$ref ? typeToClassName(refTypeName(inner.$ref, rpcDefinitions)) @@ -2181,7 +2183,7 @@ function emitClientSessionApiRegistration(clientSchema: Record, for (const { methods } of groups) { for (const method of methods) { const resultSchema = getMethodResultSchema(method); - if (!isVoidSchema(resultSchema)) { + if (!isVoidSchema(resultSchema) && !isOpaqueJson(resultSchema)) { emitRpcResultType(resultTypeName(method), resultSchema!, "public", classes); } diff --git a/scripts/codegen/go.ts b/scripts/codegen/go.ts index 49e537d8e..a8b85dc0b 100644 --- a/scripts/codegen/go.ts +++ b/scripts/codegen/go.ts @@ -34,6 +34,7 @@ import { isIntegerSchemaBoundedToInt32, isNodeFullyDeprecated, isNodeFullyExperimental, + isOpaqueJson, isRpcMethod, isSchemaDeprecated, isSchemaExperimental, @@ -3559,6 +3560,8 @@ async function generateRpc(schemaPath?: string): Promise { if (nullableInner) { // Nullable results (e.g., *SessionFSError) don't need a wrapper type; // the inner type is already in definitions via shared hoisting. + } else if (isOpaqueJson(resultSchema)) { + // Opaque JSON results map to `any` — no named struct needed. } else if (isVoidSchema(resultSchema)) { // Emit an empty struct for void results (forward-compatible with adding fields later) allDefinitions[goResultTypeName(method)] = { @@ -4035,10 +4038,15 @@ function emitClientSessionApiRegistration(lines: string[], clientSchema: Record< } const paramsType = resolveType(goParamsTypeName(method)); const nullableInner = resultSchema ? getNullableInner(resultSchema) : undefined; - const resultType = nullableInner - ? resolveType(goNullableResultTypeName(method, nullableInner)) - : resolveType(goResultTypeName(method)); - const returnType = unionInfos.has(resultType) ? resultType : `*${resultType}`; + let returnType: string; + if (isOpaqueJson(resultSchema)) { + returnType = "any"; + } else { + const resultType = nullableInner + ? resolveType(goNullableResultTypeName(method, nullableInner)) + : resolveType(goResultTypeName(method)); + returnType = unionInfos.has(resultType) ? resultType : `*${resultType}`; + } lines.push(`\t${clientHandlerMethodName(method.rpcMethod)}(request *${paramsType}) (${returnType}, error)`); } lines.push(`}`); diff --git a/scripts/codegen/python.ts b/scripts/codegen/python.ts index 1af315eac..77bbb4813 100644 --- a/scripts/codegen/python.ts +++ b/scripts/codegen/python.ts @@ -18,6 +18,7 @@ import { getRpcSchemaTypeName, getSessionEventsSchemaPath, isObjectSchema, + isOpaqueJson, isVoidSchema, getNullableInner, isRpcMethod, @@ -3225,7 +3226,8 @@ function emitMethod(lines: string[], name: string, method: RpcMethod, isSession: const effectiveResultSchema = nullableInner ?? resultSchema; const hasResult = !isVoidSchema(resultSchema) && !nullableInner; const hasNullableResult = !!nullableInner; - const resultIsObject = isPythonObjectResultSchema(effectiveResultSchema); + const resultIsOpaque = isOpaqueJson(effectiveResultSchema); + const resultIsObject = !resultIsOpaque && isPythonObjectResultSchema(effectiveResultSchema); let resultType: string; if (hasNullableResult) { @@ -3264,7 +3266,11 @@ function emitMethod(lines: string[], name: string, method: RpcMethod, isSession: // Deserialize helper const innerTypeName = hasNullableResult ? resolveType(pythonResultTypeName(method, nullableInner)) : resultType; + const isAnyType = innerTypeName === "Any"; const deserialize = (expr: string) => { + if (resultIsOpaque || isAnyType) { + return expr; + } if (hasNullableResult) { return resultIsObject ? `${innerTypeName}.from_dict(${expr}) if ${expr} is not None else None` diff --git a/scripts/codegen/typescript.ts b/scripts/codegen/typescript.ts index 61e551d68..f3a4bd192 100644 --- a/scripts/codegen/typescript.ts +++ b/scripts/codegen/typescript.ts @@ -846,6 +846,9 @@ function emitClientSessionApiRegistration(clientSchema: Record) const groupExperimental = isNodeFullyExperimental(clientSchema[groupName] as Record); if (groupDeprecated) { lines.push(`/** @deprecated Handler for \`${groupName}\` client session API methods. */`); + } else if (groupExperimental) { + lines.push(`/** Handler for \`${groupName}\` client session API methods. */`); + lines.push(TS_EXPERIMENTAL_JSDOC); } else { lines.push(`/** Handler for \`${groupName}\` client session API methods. */`); } diff --git a/test/snapshots/canvas/canvas_list_discovers_declared_canvases.yaml b/test/snapshots/canvas/canvas_list_discovers_declared_canvases.yaml new file mode 100644 index 000000000..056351ddb --- /dev/null +++ b/test/snapshots/canvas/canvas_list_discovers_declared_canvases.yaml @@ -0,0 +1,3 @@ +models: + - claude-sonnet-4.5 +conversations: []