From 03501b323143805901cd8b807c1a0a274a2aa6a6 Mon Sep 17 00:00:00 2001 From: jmoseley Date: Mon, 29 Jun 2026 20:39:12 -0700 Subject: [PATCH 1/2] Add host-side canvas declaration API and canvasProvider to Java SDK Brings the Java SDK to parity with Rust/Node/.NET for the host-facing canvas API. Previously Java only had the read-side open-canvases snapshot and could not declare canvases, register a provider handler, or supply provider identity. Adds five public types in com.github.copilot.rpc (all @CopilotExperimental): CanvasDeclaration, ExtensionInfo, CanvasProviderIdentity, CanvasHandler, and CanvasException. Wires canvas declaration fields (canvases, requestCanvasRenderer, requestExtensions, extensionSdkPath, extensionInfo, canvasProvider) through SessionConfig/ResumeSessionConfig and the Jackson wire DTOs, mapped by SessionRequestBuilder. Routes inbound canvas.open / canvas.close / canvas.action.invoke provider callbacks in RpcHandlerDispatcher to the registered CanvasHandler on CopilotSession. Also adds the canvasProvider (CanvasProviderIdentity with required id and optional name) field on session.create and session.resume, matching runtime PR #10519 and the sibling Rust/Node/.NET work. It is a hand-registered JSON-RPC field serialized as canvasProvider with nested id/name; name is omitted from the wire when null. Fully optional and back-compatible. Tests: CanvasHostApiTest asserts the create/resume wire JSON (extensionInfo, canvasProvider id/name, name-omitted, fields-omitted-when-unset, canvasHandler never serialized) mirroring the Rust session tests. CanvasIT adds three E2E tests (list, open, invoke action) driving the full canvas round-trip against the live CLI via the replay proxy, with two new handcrafted snapshots. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../com/github/copilot/CopilotSession.java | 89 +++++++ .../com/github/copilot/JsonRpcClient.java | 22 ++ .../github/copilot/RpcHandlerDispatcher.java | 113 +++++++++ .../github/copilot/SessionRequestBuilder.java | 19 ++ .../github/copilot/rpc/CanvasDeclaration.java | 193 ++++++++++++++++ .../github/copilot/rpc/CanvasException.java | 60 +++++ .../com/github/copilot/rpc/CanvasHandler.java | 78 +++++++ .../copilot/rpc/CanvasProviderIdentity.java | 105 +++++++++ .../copilot/rpc/CreateSessionRequest.java | 83 +++++++ .../com/github/copilot/rpc/ExtensionInfo.java | 102 ++++++++ .../copilot/rpc/ResumeSessionConfig.java | 218 ++++++++++++++++++ .../copilot/rpc/ResumeSessionRequest.java | 100 ++++++++ .../com/github/copilot/rpc/SessionConfig.java | 191 +++++++++++++++ .../com/github/copilot/CanvasHostApiTest.java | 177 ++++++++++++++ .../java/com/github/copilot/CanvasIT.java | 186 +++++++++++++++ .../canvas_invoke_action_round_trip.yaml | 3 + .../canvas/canvas_open_round_trip.yaml | 3 + 17 files changed, 1742 insertions(+) create mode 100644 java/src/main/java/com/github/copilot/rpc/CanvasDeclaration.java create mode 100644 java/src/main/java/com/github/copilot/rpc/CanvasException.java create mode 100644 java/src/main/java/com/github/copilot/rpc/CanvasHandler.java create mode 100644 java/src/main/java/com/github/copilot/rpc/CanvasProviderIdentity.java create mode 100644 java/src/main/java/com/github/copilot/rpc/ExtensionInfo.java create mode 100644 java/src/test/java/com/github/copilot/CanvasHostApiTest.java create mode 100644 java/src/test/java/com/github/copilot/CanvasIT.java create mode 100644 test/snapshots/canvas/canvas_invoke_action_round_trip.yaml create mode 100644 test/snapshots/canvas/canvas_open_round_trip.yaml diff --git a/java/src/main/java/com/github/copilot/CopilotSession.java b/java/src/main/java/com/github/copilot/CopilotSession.java index 194ce12773..fb17f8e617 100644 --- a/java/src/main/java/com/github/copilot/CopilotSession.java +++ b/java/src/main/java/com/github/copilot/CopilotSession.java @@ -57,12 +57,18 @@ import com.github.copilot.generated.SessionErrorEvent; import com.github.copilot.generated.SessionEvent; import com.github.copilot.generated.SessionIdleEvent; +import com.github.copilot.generated.rpc.CanvasActionInvokeParams; +import com.github.copilot.generated.rpc.CanvasCloseParams; +import com.github.copilot.generated.rpc.CanvasOpenParams; +import com.github.copilot.generated.rpc.CanvasOpenResult; import com.github.copilot.generated.rpc.OpenCanvasInstance; import com.github.copilot.rpc.AgentInfo; import com.github.copilot.rpc.AutoModeSwitchHandler; import com.github.copilot.rpc.AutoModeSwitchInvocation; import com.github.copilot.rpc.AutoModeSwitchRequest; import com.github.copilot.rpc.AutoModeSwitchResponse; +import com.github.copilot.rpc.CanvasException; +import com.github.copilot.rpc.CanvasHandler; import com.github.copilot.rpc.CommandContext; import com.github.copilot.rpc.CommandDefinition; import com.github.copilot.rpc.CommandHandler; @@ -182,6 +188,7 @@ public final class CopilotSession implements AutoCloseable { private final AtomicReference elicitationHandler = new AtomicReference<>(); private final AtomicReference exitPlanModeHandler = new AtomicReference<>(); private final AtomicReference autoModeSwitchHandler = new AtomicReference<>(); + private final AtomicReference canvasHandler = new AtomicReference<>(); private final AtomicReference hooksHandler = new AtomicReference<>(); private volatile EventErrorHandler eventErrorHandler; private volatile EventErrorPolicy eventErrorPolicy = EventErrorPolicy.PROPAGATE_AND_LOG_ERRORS; @@ -1479,6 +1486,87 @@ void registerAutoModeSwitchHandler(AutoModeSwitchHandler handler) { autoModeSwitchHandler.set(handler); } + /** + * Registers the canvas lifecycle handler for this session. + *

+ * Called internally when creating or resuming a session that declares canvases. + * The handler receives inbound {@code canvas.open} / {@code canvas.close} / + * {@code canvas.action.invoke} requests. + * + * @param handler + * the handler to invoke for inbound canvas requests + */ + void registerCanvasHandler(CanvasHandler handler) { + canvasHandler.set(handler); + } + + /** + * Routes an inbound {@code canvas.open} request to the registered + * {@link CanvasHandler}. + *

+ * Called internally by the RPC dispatcher. + * + * @param params + * the open request from the runtime + * @return a future that completes with the open result + */ + CompletableFuture handleCanvasOpen(CanvasOpenParams params) { + CanvasHandler handler = canvasHandler.get(); + if (handler == null) { + return CompletableFuture.failedFuture( + new CanvasException("canvas_no_handler", "No canvas handler registered for this session")); + } + try { + return handler.onOpen(params); + } catch (Exception e) { + return CompletableFuture.failedFuture(e); + } + } + + /** + * Routes an inbound {@code canvas.action.invoke} request to the registered + * {@link CanvasHandler}. + *

+ * Called internally by the RPC dispatcher. + * + * @param params + * the action-invoke request from the runtime + * @return a future that completes with the JSON-serializable action result + */ + CompletableFuture handleCanvasAction(CanvasActionInvokeParams params) { + CanvasHandler handler = canvasHandler.get(); + if (handler == null) { + return CompletableFuture.failedFuture(CanvasException.noHandler()); + } + try { + return handler.onAction(params); + } catch (Exception e) { + return CompletableFuture.failedFuture(e); + } + } + + /** + * Routes an inbound {@code canvas.close} request to the registered + * {@link CanvasHandler}. + *

+ * Called internally by the RPC dispatcher. + * + * @param params + * the close request from the runtime + * @return a future that completes when the close has been handled + */ + CompletableFuture handleCanvasClose(CanvasCloseParams params) { + CanvasHandler handler = canvasHandler.get(); + if (handler == null) { + return CompletableFuture.completedFuture(null); + } + try { + return handler.onClose(params); + } catch (Exception e) { + return CompletableFuture.failedFuture(e); + } + } + /** * Sets the capabilities reported by the host for this session. *

@@ -2245,6 +2333,7 @@ public void close() { elicitationHandler.set(null); exitPlanModeHandler.set(null); autoModeSwitchHandler.set(null); + canvasHandler.set(null); hooksHandler.set(null); } diff --git a/java/src/main/java/com/github/copilot/JsonRpcClient.java b/java/src/main/java/com/github/copilot/JsonRpcClient.java index a7cd0e120d..73ebe7b60e 100644 --- a/java/src/main/java/com/github/copilot/JsonRpcClient.java +++ b/java/src/main/java/com/github/copilot/JsonRpcClient.java @@ -171,12 +171,34 @@ public void sendResponse(Object id, Object result) throws IOException { * Sends a JSON-RPC error response to a server request. */ public void sendErrorResponse(Object id, int code, String message) throws IOException { + sendErrorResponse(id, code, message, null); + } + + /** + * Sends a JSON-RPC error response with structured {@code data} to a server + * request. + * + * @param id + * the request id being responded to + * @param code + * the JSON-RPC error code + * @param message + * the human-readable error message + * @param data + * optional structured error data, or {@code null} to omit + * @throws IOException + * if the response cannot be written + */ + public void sendErrorResponse(Object id, int code, String message, Object data) throws IOException { var response = new JsonRpcResponse(); response.setJsonrpc("2.0"); response.setId(id); var error = new JsonRpcError(); error.setCode(code); error.setMessage(message); + if (data != null) { + error.setData(data); + } response.setError(error); sendMessage(response); } diff --git a/java/src/main/java/com/github/copilot/RpcHandlerDispatcher.java b/java/src/main/java/com/github/copilot/RpcHandlerDispatcher.java index 9a42a8e22d..de9dd68179 100644 --- a/java/src/main/java/com/github/copilot/RpcHandlerDispatcher.java +++ b/java/src/main/java/com/github/copilot/RpcHandlerDispatcher.java @@ -17,7 +17,11 @@ import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import com.github.copilot.generated.SessionEvent; +import com.github.copilot.generated.rpc.CanvasActionInvokeParams; +import com.github.copilot.generated.rpc.CanvasCloseParams; +import com.github.copilot.generated.rpc.CanvasOpenParams; import com.github.copilot.rpc.AutoModeSwitchRequest; +import com.github.copilot.rpc.CanvasException; import com.github.copilot.rpc.ExitPlanModeRequest; import com.github.copilot.rpc.BearerTokenProvider; import com.github.copilot.rpc.ProviderTokenArgs; @@ -92,6 +96,10 @@ void registerHandlers(JsonRpcClient rpc) { (requestId, params) -> handleSystemMessageTransform(rpc, requestId, params)); rpc.registerMethodHandler("providerToken.getToken", (requestId, params) -> handleProviderTokenGetToken(rpc, requestId, params)); + rpc.registerMethodHandler("canvas.open", (requestId, params) -> handleCanvasOpen(rpc, requestId, params)); + rpc.registerMethodHandler("canvas.close", (requestId, params) -> handleCanvasClose(rpc, requestId, params)); + rpc.registerMethodHandler("canvas.action.invoke", + (requestId, params) -> handleCanvasActionInvoke(rpc, requestId, params)); } private void handleSessionEvent(JsonNode params) { @@ -465,6 +473,111 @@ private void handleAutoModeSwitchRequest(JsonRpcClient rpc, String requestId, Js }); } + private void handleCanvasOpen(JsonRpcClient rpc, String requestId, JsonNode params) { + runAsync(() -> { + final long requestIdLong = parseRequestId(requestId, "canvas.open"); + if (requestIdLong == -1) { + return; + } + try { + CanvasOpenParams openParams = MAPPER.treeToValue(params, CanvasOpenParams.class); + CopilotSession session = sessions.get(openParams.sessionId()); + if (session == null) { + rpc.sendErrorResponse(requestIdLong, -32602, "Unknown session " + openParams.sessionId()); + return; + } + session.handleCanvasOpen(openParams).thenAccept(result -> { + try { + rpc.sendResponse(requestIdLong, result); + } catch (IOException e) { + LOG.log(Level.SEVERE, "Error sending canvas open response", e); + } + }).exceptionally(ex -> { + sendCanvasError(rpc, requestIdLong, "canvas.open", ex); + return null; + }); + } catch (Exception e) { + LOG.log(Level.SEVERE, "Error handling canvas open request", e); + } + }); + } + + private void handleCanvasClose(JsonRpcClient rpc, String requestId, JsonNode params) { + runAsync(() -> { + final long requestIdLong = parseRequestId(requestId, "canvas.close"); + if (requestIdLong == -1) { + return; + } + try { + CanvasCloseParams closeParams = MAPPER.treeToValue(params, CanvasCloseParams.class); + CopilotSession session = sessions.get(closeParams.sessionId()); + if (session == null) { + rpc.sendErrorResponse(requestIdLong, -32602, "Unknown session " + closeParams.sessionId()); + return; + } + session.handleCanvasClose(closeParams).thenAccept(ignored -> { + try { + rpc.sendResponse(requestIdLong, null); + } catch (IOException e) { + LOG.log(Level.SEVERE, "Error sending canvas close response", e); + } + }).exceptionally(ex -> { + sendCanvasError(rpc, requestIdLong, "canvas.close", ex); + return null; + }); + } catch (Exception e) { + LOG.log(Level.SEVERE, "Error handling canvas close request", e); + } + }); + } + + private void handleCanvasActionInvoke(JsonRpcClient rpc, String requestId, JsonNode params) { + runAsync(() -> { + final long requestIdLong = parseRequestId(requestId, "canvas.action.invoke"); + if (requestIdLong == -1) { + return; + } + try { + CanvasActionInvokeParams actionParams = MAPPER.treeToValue(params, CanvasActionInvokeParams.class); + CopilotSession session = sessions.get(actionParams.sessionId()); + if (session == null) { + rpc.sendErrorResponse(requestIdLong, -32602, "Unknown session " + actionParams.sessionId()); + return; + } + session.handleCanvasAction(actionParams).thenAccept(result -> { + try { + rpc.sendResponse(requestIdLong, result); + } catch (IOException e) { + LOG.log(Level.SEVERE, "Error sending canvas action response", e); + } + }).exceptionally(ex -> { + sendCanvasError(rpc, requestIdLong, "canvas.action.invoke", ex); + return null; + }); + } catch (Exception e) { + LOG.log(Level.SEVERE, "Error handling canvas action invoke request", e); + } + }); + } + + /** + * Sends a structured error response for a failed canvas callback. A + * {@link CanvasException} carries a machine-readable {@code code}; any other + * exception is wrapped in a generic {@code canvas_handler_error} envelope. + */ + private void sendCanvasError(JsonRpcClient rpc, long requestIdLong, String label, Throwable ex) { + Throwable cause = (ex instanceof java.util.concurrent.CompletionException && ex.getCause() != null) + ? ex.getCause() + : ex; + String code = cause instanceof CanvasException ? ((CanvasException) cause).getCode() : "canvas_handler_error"; + String message = cause.getMessage() != null ? cause.getMessage() : cause.toString(); + try { + rpc.sendErrorResponse(requestIdLong, -32603, message, Map.of("code", code, "message", message)); + } catch (IOException e) { + LOG.log(Level.SEVERE, "Error sending " + label + " error", e); + } + } + private void handleHooksInvoke(JsonRpcClient rpc, String requestId, JsonNode params) { runAsync(() -> { final long requestIdLong = parseRequestId(requestId, "hooks.invoke"); diff --git a/java/src/main/java/com/github/copilot/SessionRequestBuilder.java b/java/src/main/java/com/github/copilot/SessionRequestBuilder.java index 8a4b016e1b..bc98676fa5 100644 --- a/java/src/main/java/com/github/copilot/SessionRequestBuilder.java +++ b/java/src/main/java/com/github/copilot/SessionRequestBuilder.java @@ -182,6 +182,12 @@ static CreateSessionRequest buildCreateRequest(SessionConfig config, String sess request.setRemoteSession(config.getRemoteSession()); request.setCloud(config.getCloud()); request.setExpAssignments(config.getExpAssignments()); + request.setCanvases(config.getCanvases()); + request.setRequestCanvasRenderer(config.getRequestCanvasRenderer()); + request.setRequestExtensions(config.getRequestExtensions()); + request.setExtensionSdkPath(config.getExtensionSdkPath()); + request.setExtensionInfo(config.getExtensionInfo()); + request.setCanvasProvider(config.getCanvasProvider()); return request; } @@ -300,6 +306,13 @@ static ResumeSessionRequest buildResumeRequest(String sessionId, ResumeSessionCo request.setGitHubToken(config.getGitHubToken()); request.setRemoteSession(config.getRemoteSession()); request.setExpAssignments(config.getExpAssignments()); + request.setCanvases(config.getCanvases()); + request.setOpenCanvases(config.getOpenCanvases()); + request.setRequestCanvasRenderer(config.getRequestCanvasRenderer()); + request.setRequestExtensions(config.getRequestExtensions()); + request.setExtensionSdkPath(config.getExtensionSdkPath()); + request.setExtensionInfo(config.getExtensionInfo()); + request.setCanvasProvider(config.getCanvasProvider()); return request; } @@ -349,6 +362,9 @@ static void configureSession(CopilotSession session, SessionConfig config) { if (config.getOnAutoModeSwitch() != null) { session.registerAutoModeSwitchHandler(config.getOnAutoModeSwitch()); } + if (config.getCanvasHandler() != null) { + session.registerCanvasHandler(config.getCanvasHandler()); + } if (config.getOnEvent() != null) { session.on(config.getOnEvent()); } @@ -399,6 +415,9 @@ static void configureSession(CopilotSession session, ResumeSessionConfig config) if (config.getOnAutoModeSwitch() != null) { session.registerAutoModeSwitchHandler(config.getOnAutoModeSwitch()); } + if (config.getCanvasHandler() != null) { + session.registerCanvasHandler(config.getCanvasHandler()); + } if (config.getOnEvent() != null) { session.on(config.getOnEvent()); } diff --git a/java/src/main/java/com/github/copilot/rpc/CanvasDeclaration.java b/java/src/main/java/com/github/copilot/rpc/CanvasDeclaration.java new file mode 100644 index 0000000000..8e9337a7eb --- /dev/null +++ b/java/src/main/java/com/github/copilot/rpc/CanvasDeclaration.java @@ -0,0 +1,193 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +package com.github.copilot.rpc; + +import java.util.List; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; + +import com.github.copilot.CopilotExperimental; +import com.github.copilot.generated.rpc.CanvasAction; + +/** + * Declarative metadata for a single canvas, sent over the wire on + * {@code session.create} / {@code session.resume}. + *

+ * The runtime advertises declared canvases to the agent and routes inbound + * {@code canvas.open} / {@code canvas.close} / {@code canvas.action.invoke} + * requests for any declared canvas to the session's {@link CanvasHandler}. + * Install a handler via {@link SessionConfig#setCanvasHandler(CanvasHandler)} + * and identify the provider via + * {@link SessionConfig#setExtensionInfo(ExtensionInfo)}. All setter methods + * return {@code this} for method chaining. + *

+ * Experimental. Canvas configuration is part of an + * experimental wire-protocol surface and may change or be removed in future SDK + * or CLI releases. + * + *

Example Usage

+ * + *
{@code
+ * var canvas = new CanvasDeclaration().setId("counter").setDisplayName("Counter")
+ * 		.setDescription("Tracks a counter value.")
+ * 		.setActions(List.of(new CanvasAction("increment", "Increments the counter.", null)));
+ * }
+ * + * @see SessionConfig#setCanvases(List) + * @see CanvasHandler + * @since 1.0.0 + */ +@CopilotExperimental +@JsonInclude(JsonInclude.Include.NON_NULL) +public class CanvasDeclaration { + + @JsonProperty("id") + private String id; + + @JsonProperty("displayName") + private String displayName; + + @JsonProperty("description") + private String description; + + @JsonProperty("inputSchema") + private Object inputSchema; + + @JsonProperty("actions") + private List actions; + + /** + * Creates an empty canvas declaration. + */ + public CanvasDeclaration() { + } + + /** + * Creates a canvas declaration with the required fields set. + * + * @param id + * the canvas identifier, unique within the declaring connection + * @param displayName + * the human-readable name shown in host UI and canvas pickers + * @param description + * a short, single-sentence description shown to the agent in canvas + * catalogs + */ + public CanvasDeclaration(String id, String displayName, String description) { + this.id = id; + this.displayName = displayName; + this.description = description; + } + + /** + * Gets the canvas identifier. + * + * @return the canvas id, unique within the declaring connection + */ + public String getId() { + return id; + } + + /** + * Sets the canvas identifier, unique within the declaring connection. + * + * @param id + * the canvas id + * @return this declaration for method chaining + */ + public CanvasDeclaration setId(String id) { + this.id = id; + return this; + } + + /** + * Gets the human-readable display name. + * + * @return the display name shown in host UI and canvas pickers + */ + public String getDisplayName() { + return displayName; + } + + /** + * Sets the human-readable name shown in host UI and canvas pickers. + * + * @param displayName + * the display name + * @return this declaration for method chaining + */ + public CanvasDeclaration setDisplayName(String displayName) { + this.displayName = displayName; + return this; + } + + /** + * Gets the short description shown to the agent. + * + * @return the single-sentence description shown in canvas catalogs + */ + public String getDescription() { + return description; + } + + /** + * Sets the short, single-sentence description shown to the agent in canvas + * catalogs. + * + * @param description + * the description + * @return this declaration for method chaining + */ + public CanvasDeclaration setDescription(String description) { + this.description = description; + return this; + } + + /** + * Gets the JSON Schema for the {@code input} payload accepted by + * {@code canvas.open}. + * + * @return the input schema, or {@code null} if none + */ + public Object getInputSchema() { + return inputSchema; + } + + /** + * Sets the JSON Schema for the {@code input} payload accepted by + * {@code canvas.open}. + * + * @param inputSchema + * the input schema as a JSON-serializable value (e.g. a {@code Map}) + * @return this declaration for method chaining + */ + public CanvasDeclaration setInputSchema(Object inputSchema) { + this.inputSchema = inputSchema; + return this; + } + + /** + * Gets the agent-callable actions this canvas exposes. + * + * @return the actions, or {@code null} if none + */ + public List getActions() { + return actions; + } + + /** + * Sets the agent-callable actions this canvas exposes via + * {@code invoke_canvas_action}. + * + * @param actions + * the actions + * @return this declaration for method chaining + */ + public CanvasDeclaration setActions(List actions) { + this.actions = actions; + return this; + } +} diff --git a/java/src/main/java/com/github/copilot/rpc/CanvasException.java b/java/src/main/java/com/github/copilot/rpc/CanvasException.java new file mode 100644 index 0000000000..d38357192b --- /dev/null +++ b/java/src/main/java/com/github/copilot/rpc/CanvasException.java @@ -0,0 +1,60 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +package com.github.copilot.rpc; + +import com.github.copilot.CopilotExperimental; + +/** + * Structured exception returned from {@link CanvasHandler} callbacks. + *

+ * Throw (or complete a returned future exceptionally with) a + * {@code CanvasException} to surface a machine-readable error code to the + * runtime. Any other exception is wrapped in a generic + * {@code canvas_handler_error} envelope. + *

+ * Experimental. Canvas configuration is part of an + * experimental wire-protocol surface and may change or be removed in future SDK + * or CLI releases. + * + * @since 1.0.0 + */ +@CopilotExperimental +public class CanvasException extends RuntimeException { + + private static final long serialVersionUID = 1L; + + private final String code; + + /** + * Creates a new canvas exception. + * + * @param code + * the machine-readable error code + * @param message + * the human-readable message + */ + public CanvasException(String code, String message) { + super(message); + this.code = code; + } + + /** + * Gets the machine-readable error code. + * + * @return the error code + */ + public String getCode() { + return code; + } + + /** + * Creates the default exception returned when a declared action has no handler. + * + * @return a {@code CanvasException} with code {@code canvas_action_no_handler} + */ + public static CanvasException noHandler() { + return new CanvasException("canvas_action_no_handler", "No handler implemented for this canvas action"); + } +} diff --git a/java/src/main/java/com/github/copilot/rpc/CanvasHandler.java b/java/src/main/java/com/github/copilot/rpc/CanvasHandler.java new file mode 100644 index 0000000000..9603fc6fb5 --- /dev/null +++ b/java/src/main/java/com/github/copilot/rpc/CanvasHandler.java @@ -0,0 +1,78 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +package com.github.copilot.rpc; + +import java.util.concurrent.CompletableFuture; + +import com.github.copilot.CopilotExperimental; +import com.github.copilot.generated.rpc.CanvasActionInvokeParams; +import com.github.copilot.generated.rpc.CanvasCloseParams; +import com.github.copilot.generated.rpc.CanvasOpenParams; +import com.github.copilot.generated.rpc.CanvasOpenResult; + +/** + * Provider-side canvas lifecycle handler. + *

+ * A session installs a single {@code CanvasHandler} via + * {@link SessionConfig#setCanvasHandler(CanvasHandler)}. The handler receives + * every inbound {@code canvas.open} / {@code canvas.close} / + * {@code canvas.action.invoke} request the runtime issues for this session and + * decides — typically by inspecting {@link CanvasOpenParams#canvasId() + * canvasId} — which application-side canvas should handle the call. The + * SDK does not maintain a per-canvas registry; multiplexing across declared + * canvases is the implementor's responsibility. + *

+ * {@link #onClose(CanvasCloseParams)} and + * {@link #onAction(CanvasActionInvokeParams)} have default implementations, so + * implementations only need to provide {@link #onOpen(CanvasOpenParams)}. Throw + * (or fail the returned future with) a {@link CanvasException} to surface a + * machine-readable error code to the runtime. + *

+ * Experimental. Canvas configuration is part of an + * experimental wire-protocol surface and may change or be removed in future SDK + * or CLI releases. + * + * @since 1.0.0 + */ +@CopilotExperimental +public interface CanvasHandler { + + /** + * Opens a new canvas instance. + * + * @param params + * the open request from the runtime + * @return a future that completes with the open result + */ + CompletableFuture onOpen(CanvasOpenParams params); + + /** + * Handles a non-lifecycle action declared by the canvas. + *

+ * The default implementation fails the returned future with + * {@link CanvasException#noHandler()}. + * + * @param params + * the action-invoke request from the runtime + * @return a future that completes with the JSON-serializable action result + */ + default CompletableFuture onAction(CanvasActionInvokeParams params) { + return CompletableFuture.failedFuture(CanvasException.noHandler()); + } + + /** + * Notified when a canvas instance is closed by the user, the agent, or the + * host. + *

+ * The default implementation is a no-op. + * + * @param params + * the close request from the runtime + * @return a future that completes when the close has been handled + */ + default CompletableFuture onClose(CanvasCloseParams params) { + return CompletableFuture.completedFuture(null); + } +} diff --git a/java/src/main/java/com/github/copilot/rpc/CanvasProviderIdentity.java b/java/src/main/java/com/github/copilot/rpc/CanvasProviderIdentity.java new file mode 100644 index 0000000000..e81c191d4a --- /dev/null +++ b/java/src/main/java/com/github/copilot/rpc/CanvasProviderIdentity.java @@ -0,0 +1,105 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +package com.github.copilot.rpc; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; + +import com.github.copilot.CopilotExperimental; + +/** + * Session-level identity for the participant that provides canvases on this + * connection. + *

+ * Supplied as the optional {@code canvasProvider} field on session creation and + * resume. The {@link #getId() id} is opaque and used verbatim as the canvas + * {@code extensionId}; a value such as {@code "app:builtin:"} is + * recommended. The {@link #getName() name} is an optional display name. All + * setter methods return {@code this} for method chaining. + *

+ * Experimental. Canvas configuration is part of an + * experimental wire-protocol surface and may change or be removed in future SDK + * or CLI releases. + * + *

Example Usage

+ * + *
{@code
+ * var provider = new CanvasProviderIdentity().setId("app:builtin:main").setName("My App");
+ * }
+ * + * @see SessionConfig#setCanvasProvider(CanvasProviderIdentity) + * @since 1.0.0 + */ +@CopilotExperimental +@JsonInclude(JsonInclude.Include.NON_NULL) +public class CanvasProviderIdentity { + + @JsonProperty("id") + private String id; + + @JsonProperty("name") + private String name; + + /** + * Creates an empty canvas provider identity. + */ + public CanvasProviderIdentity() { + } + + /** + * Creates a canvas provider identity with the given id. + * + * @param id + * the opaque provider identifier used verbatim as the canvas + * {@code extensionId} + */ + public CanvasProviderIdentity(String id) { + this.id = id; + } + + /** + * Gets the opaque provider identifier. + * + * @return the provider id, used verbatim as the canvas {@code extensionId} + */ + public String getId() { + return id; + } + + /** + * Sets the opaque provider identifier, used verbatim as the canvas + * {@code extensionId}. A value such as {@code "app:builtin:"} is + * recommended. + * + * @param id + * the provider id + * @return this identity for method chaining + */ + public CanvasProviderIdentity setId(String id) { + this.id = id; + return this; + } + + /** + * Gets the optional display name. + * + * @return the display name, or {@code null} if not set + */ + public String getName() { + return name; + } + + /** + * Sets the optional display name for this provider. + * + * @param name + * the display name + * @return this identity for method chaining + */ + public CanvasProviderIdentity setName(String name) { + this.name = name; + return this; + } +} diff --git a/java/src/main/java/com/github/copilot/rpc/CreateSessionRequest.java b/java/src/main/java/com/github/copilot/rpc/CreateSessionRequest.java index 8fc966c6f1..1954d4cdee 100644 --- a/java/src/main/java/com/github/copilot/rpc/CreateSessionRequest.java +++ b/java/src/main/java/com/github/copilot/rpc/CreateSessionRequest.java @@ -199,6 +199,24 @@ public final class CreateSessionRequest { @JsonProperty("expAssignments") private JsonNode expAssignments; + @JsonProperty("canvases") + private List canvases; + + @JsonProperty("requestCanvasRenderer") + private Boolean requestCanvasRenderer; + + @JsonProperty("requestExtensions") + private Boolean requestExtensions; + + @JsonProperty("extensionSdkPath") + private String extensionSdkPath; + + @JsonProperty("extensionInfo") + private ExtensionInfo extensionInfo; + + @JsonProperty("canvasProvider") + private CanvasProviderIdentity canvasProvider; + /** Gets the model name. @return the model */ public String getModel() { return model; @@ -900,4 +918,69 @@ public JsonNode getExpAssignments() { public void setExpAssignments(JsonNode expAssignments) { this.expAssignments = expAssignments; } + + /** Gets the declared canvases. @return the canvas declarations */ + public List getCanvases() { + return canvases; + } + + /** Sets the declared canvases. @param canvases the canvas declarations */ + public void setCanvases(List canvases) { + this.canvases = canvases; + } + + /** Gets the request-canvas-renderer flag. @return the flag */ + public Boolean getRequestCanvasRenderer() { + return requestCanvasRenderer; + } + + /** + * Sets the request-canvas-renderer flag. @param requestCanvasRenderer the flag + */ + public void setRequestCanvasRenderer(Boolean requestCanvasRenderer) { + this.requestCanvasRenderer = requestCanvasRenderer; + } + + /** Gets the request-extensions flag. @return the flag */ + public Boolean getRequestExtensions() { + return requestExtensions; + } + + /** Sets the request-extensions flag. @param requestExtensions the flag */ + public void setRequestExtensions(Boolean requestExtensions) { + this.requestExtensions = requestExtensions; + } + + /** Gets the extension SDK path override. @return the path */ + public String getExtensionSdkPath() { + return extensionSdkPath; + } + + /** Sets the extension SDK path override. @param extensionSdkPath the path */ + public void setExtensionSdkPath(String extensionSdkPath) { + this.extensionSdkPath = extensionSdkPath; + } + + /** Gets the extension identity. @return the extension info */ + public ExtensionInfo getExtensionInfo() { + return extensionInfo; + } + + /** Sets the extension identity. @param extensionInfo the extension info */ + public void setExtensionInfo(ExtensionInfo extensionInfo) { + this.extensionInfo = extensionInfo; + } + + /** Gets the canvas provider identity. @return the canvas provider identity */ + public CanvasProviderIdentity getCanvasProvider() { + return canvasProvider; + } + + /** + * Sets the canvas provider identity. @param canvasProvider the canvas provider + * identity + */ + public void setCanvasProvider(CanvasProviderIdentity canvasProvider) { + this.canvasProvider = canvasProvider; + } } diff --git a/java/src/main/java/com/github/copilot/rpc/ExtensionInfo.java b/java/src/main/java/com/github/copilot/rpc/ExtensionInfo.java new file mode 100644 index 0000000000..7ddbb6e8fe --- /dev/null +++ b/java/src/main/java/com/github/copilot/rpc/ExtensionInfo.java @@ -0,0 +1,102 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +package com.github.copilot.rpc; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; + +import com.github.copilot.CopilotExperimental; + +/** + * Stable extension identity for session participants that provide canvases. + *

+ * Required when {@link SessionConfig#setCanvases(java.util.List) canvases} are + * declared so the runtime can attribute the declared canvases back to this + * provider. All setter methods return {@code this} for method chaining. + *

+ * Experimental. Canvas configuration is part of an + * experimental wire-protocol surface and may change or be removed in future SDK + * or CLI releases. + * + *

Example Usage

+ * + *
{@code
+ * var info = new ExtensionInfo().setSource("github-app").setName("canvas-provider");
+ * }
+ * + * @see SessionConfig#setExtensionInfo(ExtensionInfo) + * @since 1.0.0 + */ +@CopilotExperimental +@JsonInclude(JsonInclude.Include.NON_NULL) +public class ExtensionInfo { + + @JsonProperty("source") + private String source; + + @JsonProperty("name") + private String name; + + /** + * Creates an empty extension identity. + */ + public ExtensionInfo() { + } + + /** + * Creates an extension identity with the given source and name. + * + * @param source + * the extension namespace/source, e.g. {@code "github-app"} + * @param name + * the stable provider name within the source namespace + */ + public ExtensionInfo(String source, String name) { + this.source = source; + this.name = name; + } + + /** + * Gets the extension namespace/source. + * + * @return the extension source, e.g. {@code "github-app"} + */ + public String getSource() { + return source; + } + + /** + * Sets the extension namespace/source, e.g. {@code "github-app"}. + * + * @param source + * the extension source + * @return this identity for method chaining + */ + public ExtensionInfo setSource(String source) { + this.source = source; + return this; + } + + /** + * Gets the stable provider name within the source namespace. + * + * @return the provider name + */ + public String getName() { + return name; + } + + /** + * Sets the stable provider name within the source namespace. + * + * @param name + * the provider name + * @return this identity for method chaining + */ + public ExtensionInfo setName(String name) { + this.name = name; + return this; + } +} diff --git a/java/src/main/java/com/github/copilot/rpc/ResumeSessionConfig.java b/java/src/main/java/com/github/copilot/rpc/ResumeSessionConfig.java index 48e333f05b..a36172866e 100644 --- a/java/src/main/java/com/github/copilot/rpc/ResumeSessionConfig.java +++ b/java/src/main/java/com/github/copilot/rpc/ResumeSessionConfig.java @@ -16,6 +16,7 @@ import com.github.copilot.CopilotExperimental; import com.github.copilot.generated.SessionEvent; +import com.github.copilot.generated.rpc.OpenCanvasInstance; import java.util.Optional; /** @@ -98,6 +99,14 @@ public class ResumeSessionConfig { private String gitHubToken; private String remoteSession; private JsonNode expAssignments; + private List canvases; + private CanvasHandler canvasHandler; + private List openCanvases; + private Boolean requestCanvasRenderer; + private Boolean requestExtensions; + private String extensionSdkPath; + private ExtensionInfo extensionInfo; + private CanvasProviderIdentity canvasProvider; /** * Gets the AI model to use. @@ -1658,6 +1667,207 @@ public ResumeSessionConfig setExpAssignments(JsonNode expAssignments) { return this; } + /** + * Gets the canvas declarations advertised by this connection. + * + * @return the canvas declarations, or {@code null} if none + */ + @CopilotExperimental + public List getCanvases() { + return canvases; + } + + /** + * Sets the canvas declarations advertised by this connection. + *

+ * The runtime forwards these to the agent and routes inbound {@code canvas.*} + * requests for any declared canvas to the + * {@link #setCanvasHandler(CanvasHandler) canvas handler}. Set + * {@link #setExtensionInfo(ExtensionInfo)} so the runtime can attribute the + * declared canvases back to this provider. + * + * @param canvases + * the canvas declarations + * @return this config for method chaining + */ + @CopilotExperimental + public ResumeSessionConfig setCanvases(List canvases) { + this.canvases = canvases; + return this; + } + + /** + * Gets the provider-side canvas lifecycle handler. + * + * @return the canvas handler, or {@code null} if none + */ + @CopilotExperimental + public CanvasHandler getCanvasHandler() { + return canvasHandler; + } + + /** + * Sets the provider-side canvas lifecycle handler. + *

+ * The SDK routes inbound {@code canvas.open} / {@code canvas.close} / + * {@code canvas.action.invoke} requests for declared canvases to this handler. + * The handler stays SDK-side and is not serialized. + * + * @param canvasHandler + * the canvas handler + * @return this config for method chaining + */ + @CopilotExperimental + public ResumeSessionConfig setCanvasHandler(CanvasHandler canvasHandler) { + this.canvasHandler = canvasHandler; + return this; + } + + /** + * Gets the open-canvas snapshot to restore for this resumed session. + * + * @return the open canvases, or {@code null} if none + */ + @CopilotExperimental + public List getOpenCanvases() { + return openCanvases; + } + + /** + * Sets the open-canvas snapshot to restore when resuming the session, so + * canvases that were open before disconnect can be re-presented. + * + * @param openCanvases + * the open canvas instances + * @return this config for method chaining + */ + @CopilotExperimental + public ResumeSessionConfig setOpenCanvases(List openCanvases) { + this.openCanvases = openCanvases; + return this; + } + + /** + * Gets whether host canvas renderer tools are requested for this connection. + * + * @return {@code true} to request canvas renderer tools, or {@code null} if + * unset + */ + @CopilotExperimental + public Boolean getRequestCanvasRenderer() { + return requestCanvasRenderer; + } + + /** + * Requests host canvas renderer tools for this connection. + * + * @param requestCanvasRenderer + * {@code true} to request canvas renderer tools + * @return this config for method chaining + */ + @CopilotExperimental + public ResumeSessionConfig setRequestCanvasRenderer(Boolean requestCanvasRenderer) { + this.requestCanvasRenderer = requestCanvasRenderer; + return this; + } + + /** + * Gets whether extension tools and dispatch are requested for this connection. + * + * @return {@code true} to request extension tools, or {@code null} if unset + */ + @CopilotExperimental + public Boolean getRequestExtensions() { + return requestExtensions; + } + + /** + * Requests extension tools and dispatch for this connection. + * + * @param requestExtensions + * {@code true} to request extension tools + * @return this config for method chaining + */ + @CopilotExperimental + public ResumeSessionConfig setRequestExtensions(Boolean requestExtensions) { + this.requestExtensions = requestExtensions; + return this; + } + + /** + * Gets the override path used to launch extension subprocesses. + * + * @return the extension SDK path, or {@code null} if unset + */ + @CopilotExperimental + public String getExtensionSdkPath() { + return extensionSdkPath; + } + + /** + * Overrides the bundled {@code @github/copilot-sdk} drop injected into + * extension subprocesses for this session. Invalid paths fall back to the + * bundled SDK. + * + * @param extensionSdkPath + * the extension SDK path + * @return this config for method chaining + */ + @CopilotExperimental + public ResumeSessionConfig setExtensionSdkPath(String extensionSdkPath) { + this.extensionSdkPath = extensionSdkPath; + return this; + } + + /** + * Gets the stable extension identity for canvas/tool providers. + * + * @return the extension info, or {@code null} if unset + */ + @CopilotExperimental + public ExtensionInfo getExtensionInfo() { + return extensionInfo; + } + + /** + * Sets the stable extension identity for canvas/tool providers on this + * connection. + * + * @param extensionInfo + * the extension identity + * @return this config for method chaining + */ + @CopilotExperimental + public ResumeSessionConfig setExtensionInfo(ExtensionInfo extensionInfo) { + this.extensionInfo = extensionInfo; + return this; + } + + /** + * Gets the session-level canvas provider identity. + * + * @return the canvas provider identity, or {@code null} if unset + */ + @CopilotExperimental + public CanvasProviderIdentity getCanvasProvider() { + return canvasProvider; + } + + /** + * Sets the session-level identity for the participant that provides canvases on + * this connection. The {@link CanvasProviderIdentity#getId() id} is used + * verbatim as the canvas {@code extensionId}. + * + * @param canvasProvider + * the canvas provider identity + * @return this config for method chaining + */ + @CopilotExperimental + public ResumeSessionConfig setCanvasProvider(CanvasProviderIdentity canvasProvider) { + this.canvasProvider = canvasProvider; + return this; + } + /** * Creates a shallow clone of this {@code ResumeSessionConfig} instance. *

@@ -1727,6 +1937,14 @@ public ResumeSessionConfig clone() { copy.gitHubToken = this.gitHubToken; copy.remoteSession = this.remoteSession; copy.expAssignments = this.expAssignments; + copy.canvases = this.canvases != null ? new ArrayList<>(this.canvases) : null; + copy.canvasHandler = this.canvasHandler; + copy.openCanvases = this.openCanvases != null ? new ArrayList<>(this.openCanvases) : null; + copy.requestCanvasRenderer = this.requestCanvasRenderer; + copy.requestExtensions = this.requestExtensions; + copy.extensionSdkPath = this.extensionSdkPath; + copy.extensionInfo = this.extensionInfo; + copy.canvasProvider = this.canvasProvider; return copy; } } diff --git a/java/src/main/java/com/github/copilot/rpc/ResumeSessionRequest.java b/java/src/main/java/com/github/copilot/rpc/ResumeSessionRequest.java index 2b25875d7f..637a4b04f1 100644 --- a/java/src/main/java/com/github/copilot/rpc/ResumeSessionRequest.java +++ b/java/src/main/java/com/github/copilot/rpc/ResumeSessionRequest.java @@ -13,6 +13,7 @@ import com.fasterxml.jackson.databind.JsonNode; import com.github.copilot.CopilotExperimental; +import com.github.copilot.generated.rpc.OpenCanvasInstance; /** * Internal request object for resuming an existing session. @@ -201,6 +202,27 @@ public final class ResumeSessionRequest { @JsonProperty("expAssignments") private JsonNode expAssignments; + @JsonProperty("canvases") + private List canvases; + + @JsonProperty("openCanvases") + private List openCanvases; + + @JsonProperty("requestCanvasRenderer") + private Boolean requestCanvasRenderer; + + @JsonProperty("requestExtensions") + private Boolean requestExtensions; + + @JsonProperty("extensionSdkPath") + private String extensionSdkPath; + + @JsonProperty("extensionInfo") + private ExtensionInfo extensionInfo; + + @JsonProperty("canvasProvider") + private CanvasProviderIdentity canvasProvider; + /** Gets the session ID. @return the session ID */ public String getSessionId() { return sessionId; @@ -915,4 +937,82 @@ public JsonNode getExpAssignments() { public void setExpAssignments(JsonNode expAssignments) { this.expAssignments = expAssignments; } + + /** Gets the declared canvases. @return the canvas declarations */ + public List getCanvases() { + return canvases; + } + + /** Sets the declared canvases. @param canvases the canvas declarations */ + public void setCanvases(List canvases) { + this.canvases = canvases; + } + + /** Gets the open-canvas snapshot to restore. @return the open canvases */ + public List getOpenCanvases() { + return openCanvases; + } + + /** + * Sets the open-canvas snapshot to restore. @param openCanvases the open + * canvases + */ + public void setOpenCanvases(List openCanvases) { + this.openCanvases = openCanvases; + } + + /** Gets the request-canvas-renderer flag. @return the flag */ + public Boolean getRequestCanvasRenderer() { + return requestCanvasRenderer; + } + + /** + * Sets the request-canvas-renderer flag. @param requestCanvasRenderer the flag + */ + public void setRequestCanvasRenderer(Boolean requestCanvasRenderer) { + this.requestCanvasRenderer = requestCanvasRenderer; + } + + /** Gets the request-extensions flag. @return the flag */ + public Boolean getRequestExtensions() { + return requestExtensions; + } + + /** Sets the request-extensions flag. @param requestExtensions the flag */ + public void setRequestExtensions(Boolean requestExtensions) { + this.requestExtensions = requestExtensions; + } + + /** Gets the extension SDK path override. @return the path */ + public String getExtensionSdkPath() { + return extensionSdkPath; + } + + /** Sets the extension SDK path override. @param extensionSdkPath the path */ + public void setExtensionSdkPath(String extensionSdkPath) { + this.extensionSdkPath = extensionSdkPath; + } + + /** Gets the extension identity. @return the extension info */ + public ExtensionInfo getExtensionInfo() { + return extensionInfo; + } + + /** Sets the extension identity. @param extensionInfo the extension info */ + public void setExtensionInfo(ExtensionInfo extensionInfo) { + this.extensionInfo = extensionInfo; + } + + /** Gets the canvas provider identity. @return the canvas provider identity */ + public CanvasProviderIdentity getCanvasProvider() { + return canvasProvider; + } + + /** + * Sets the canvas provider identity. @param canvasProvider the canvas provider + * identity + */ + public void setCanvasProvider(CanvasProviderIdentity canvasProvider) { + this.canvasProvider = canvasProvider; + } } diff --git a/java/src/main/java/com/github/copilot/rpc/SessionConfig.java b/java/src/main/java/com/github/copilot/rpc/SessionConfig.java index e5e0e629e1..a2da7f189b 100644 --- a/java/src/main/java/com/github/copilot/rpc/SessionConfig.java +++ b/java/src/main/java/com/github/copilot/rpc/SessionConfig.java @@ -99,6 +99,13 @@ public class SessionConfig { private String remoteSession; private CloudSessionOptions cloud; private JsonNode expAssignments; + private List canvases; + private CanvasHandler canvasHandler; + private Boolean requestCanvasRenderer; + private Boolean requestExtensions; + private String extensionSdkPath; + private ExtensionInfo extensionInfo; + private CanvasProviderIdentity canvasProvider; /** * Gets the custom session ID. @@ -1789,6 +1796,183 @@ public SessionConfig setExpAssignments(JsonNode expAssignments) { return this; } + /** + * Gets the canvas declarations advertised by this connection. + * + * @return the canvas declarations, or {@code null} if none + */ + @CopilotExperimental + public List getCanvases() { + return canvases; + } + + /** + * Sets the canvas declarations advertised by this connection. + *

+ * The runtime forwards these to the agent and routes inbound {@code canvas.*} + * requests for any declared canvas to the + * {@link #setCanvasHandler(CanvasHandler) canvas handler}. Set + * {@link #setExtensionInfo(ExtensionInfo)} so the runtime can attribute the + * declared canvases back to this provider. + * + * @param canvases + * the canvas declarations + * @return this config for method chaining + */ + @CopilotExperimental + public SessionConfig setCanvases(List canvases) { + this.canvases = canvases; + return this; + } + + /** + * Gets the provider-side canvas lifecycle handler. + * + * @return the canvas handler, or {@code null} if none + */ + @CopilotExperimental + public CanvasHandler getCanvasHandler() { + return canvasHandler; + } + + /** + * Sets the provider-side canvas lifecycle handler. + *

+ * The SDK routes inbound {@code canvas.open} / {@code canvas.close} / + * {@code canvas.action.invoke} requests for declared canvases to this handler. + * The handler stays SDK-side and is not serialized. + * + * @param canvasHandler + * the canvas handler + * @return this config for method chaining + */ + @CopilotExperimental + public SessionConfig setCanvasHandler(CanvasHandler canvasHandler) { + this.canvasHandler = canvasHandler; + return this; + } + + /** + * Gets whether host canvas renderer tools are requested for this connection. + * + * @return {@code true} to request canvas renderer tools, or {@code null} if + * unset + */ + @CopilotExperimental + public Boolean getRequestCanvasRenderer() { + return requestCanvasRenderer; + } + + /** + * Requests host canvas renderer tools for this connection. + * + * @param requestCanvasRenderer + * {@code true} to request canvas renderer tools + * @return this config for method chaining + */ + @CopilotExperimental + public SessionConfig setRequestCanvasRenderer(Boolean requestCanvasRenderer) { + this.requestCanvasRenderer = requestCanvasRenderer; + return this; + } + + /** + * Gets whether extension tools and dispatch are requested for this connection. + * + * @return {@code true} to request extension tools, or {@code null} if unset + */ + @CopilotExperimental + public Boolean getRequestExtensions() { + return requestExtensions; + } + + /** + * Requests extension tools and dispatch for this connection. + * + * @param requestExtensions + * {@code true} to request extension tools + * @return this config for method chaining + */ + @CopilotExperimental + public SessionConfig setRequestExtensions(Boolean requestExtensions) { + this.requestExtensions = requestExtensions; + return this; + } + + /** + * Gets the override path used to launch extension subprocesses. + * + * @return the extension SDK path, or {@code null} if unset + */ + @CopilotExperimental + public String getExtensionSdkPath() { + return extensionSdkPath; + } + + /** + * Overrides the bundled {@code @github/copilot-sdk} drop injected into + * extension subprocesses for this session. Invalid paths fall back to the + * bundled SDK. + * + * @param extensionSdkPath + * the extension SDK path + * @return this config for method chaining + */ + @CopilotExperimental + public SessionConfig setExtensionSdkPath(String extensionSdkPath) { + this.extensionSdkPath = extensionSdkPath; + return this; + } + + /** + * Gets the stable extension identity for canvas/tool providers. + * + * @return the extension info, or {@code null} if unset + */ + @CopilotExperimental + public ExtensionInfo getExtensionInfo() { + return extensionInfo; + } + + /** + * Sets the stable extension identity for canvas/tool providers on this + * connection. + * + * @param extensionInfo + * the extension identity + * @return this config for method chaining + */ + @CopilotExperimental + public SessionConfig setExtensionInfo(ExtensionInfo extensionInfo) { + this.extensionInfo = extensionInfo; + return this; + } + + /** + * Gets the session-level canvas provider identity. + * + * @return the canvas provider identity, or {@code null} if unset + */ + @CopilotExperimental + public CanvasProviderIdentity getCanvasProvider() { + return canvasProvider; + } + + /** + * Sets the session-level identity for the participant that provides canvases on + * this connection. The {@link CanvasProviderIdentity#getId() id} is used + * verbatim as the canvas {@code extensionId}. + * + * @param canvasProvider + * the canvas provider identity + * @return this config for method chaining + */ + @CopilotExperimental + public SessionConfig setCanvasProvider(CanvasProviderIdentity canvasProvider) { + this.canvasProvider = canvasProvider; + return this; + } + /** * Creates a shallow clone of this {@code SessionConfig} instance. *

@@ -1863,6 +2047,13 @@ public SessionConfig clone() { copy.remoteSession = this.remoteSession; copy.cloud = this.cloud; copy.expAssignments = this.expAssignments; + copy.canvases = this.canvases != null ? new ArrayList<>(this.canvases) : null; + copy.canvasHandler = this.canvasHandler; + copy.requestCanvasRenderer = this.requestCanvasRenderer; + copy.requestExtensions = this.requestExtensions; + copy.extensionSdkPath = this.extensionSdkPath; + copy.extensionInfo = this.extensionInfo; + copy.canvasProvider = this.canvasProvider; return copy; } } diff --git a/java/src/test/java/com/github/copilot/CanvasHostApiTest.java b/java/src/test/java/com/github/copilot/CanvasHostApiTest.java new file mode 100644 index 0000000000..ea39d06e5d --- /dev/null +++ b/java/src/test/java/com/github/copilot/CanvasHostApiTest.java @@ -0,0 +1,177 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +package com.github.copilot; + +import static org.junit.jupiter.api.Assertions.*; + +import java.util.List; +import java.util.Map; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutionException; + +import org.junit.jupiter.api.Test; + +import com.fasterxml.jackson.databind.JsonNode; +import com.github.copilot.generated.rpc.CanvasAction; +import com.github.copilot.generated.rpc.CanvasActionInvokeParams; +import com.github.copilot.generated.rpc.CanvasCloseParams; +import com.github.copilot.generated.rpc.CanvasOpenParams; +import com.github.copilot.generated.rpc.CanvasOpenResult; +import com.github.copilot.generated.rpc.OpenCanvasInstance; +import com.github.copilot.rpc.CanvasDeclaration; +import com.github.copilot.rpc.CanvasException; +import com.github.copilot.rpc.CanvasHandler; +import com.github.copilot.rpc.CanvasProviderIdentity; +import com.github.copilot.rpc.CreateSessionRequest; +import com.github.copilot.rpc.ExtensionInfo; +import com.github.copilot.rpc.ResumeSessionConfig; +import com.github.copilot.rpc.ResumeSessionRequest; +import com.github.copilot.rpc.SessionConfig; + +/** + * Unit tests for the host-side canvas declaration API: wire-JSON mapping on + * {@code session.create} / {@code session.resume}, plus {@link CanvasHandler} + * defaults and {@link CanvasException} behavior. + */ +public class CanvasHostApiTest { + + private static JsonNode toJson(Object request) { + return JsonRpcClient.getObjectMapper().valueToTree(request); + } + + private static CanvasDeclaration counterCanvas() { + return new CanvasDeclaration("counter", "Counter", "Tracks a counter value.") + .setInputSchema(Map.of("type", "object")) + .setActions(List.of(new CanvasAction("increment", "Increments the counter.", null))); + } + + // ========================================================================= + // session.create wire mapping + // ========================================================================= + + @Test + void testCreateRequestSerializesCanvasFields() { + var config = new SessionConfig().setCanvases(List.of(counterCanvas())) + .setCanvasHandler(params -> CompletableFuture.completedFuture(new CanvasOpenResult(null, null, null))) + .setRequestCanvasRenderer(true).setRequestExtensions(true).setExtensionSdkPath("/tmp/sdk") + .setExtensionInfo(new ExtensionInfo("github-app", "canvas-provider")) + .setCanvasProvider(new CanvasProviderIdentity("app:builtin:main").setName("My App")); + + CreateSessionRequest request = SessionRequestBuilder.buildCreateRequest(config, "sess-1"); + JsonNode json = toJson(request); + + assertTrue(json.has("canvases"), "canvases should be serialized"); + JsonNode canvases = json.get("canvases"); + assertEquals(1, canvases.size()); + JsonNode canvas = canvases.get(0); + assertEquals("counter", canvas.get("id").asText()); + assertEquals("Counter", canvas.get("displayName").asText()); + assertEquals("Tracks a counter value.", canvas.get("description").asText()); + assertEquals("object", canvas.get("inputSchema").get("type").asText()); + assertEquals("increment", canvas.get("actions").get(0).get("name").asText()); + + assertTrue(json.get("requestCanvasRenderer").asBoolean()); + assertTrue(json.get("requestExtensions").asBoolean()); + assertEquals("/tmp/sdk", json.get("extensionSdkPath").asText()); + + JsonNode extInfo = json.get("extensionInfo"); + assertEquals("github-app", extInfo.get("source").asText()); + assertEquals("canvas-provider", extInfo.get("name").asText()); + + JsonNode provider = json.get("canvasProvider"); + assertEquals("app:builtin:main", provider.get("id").asText()); + assertEquals("My App", provider.get("name").asText()); + + // The SDK-side handler must never be serialized onto the wire request. + assertFalse(json.has("canvasHandler"), "canvasHandler must not be serialized"); + } + + @Test + void testCanvasProviderOmitsNameWhenNull() { + var config = new SessionConfig().setCanvasProvider(new CanvasProviderIdentity("app:builtin:main")); + + JsonNode json = toJson(SessionRequestBuilder.buildCreateRequest(config, "sess-1")); + JsonNode provider = json.get("canvasProvider"); + assertEquals("app:builtin:main", provider.get("id").asText()); + assertFalse(provider.has("name"), "name should be omitted when null"); + } + + @Test + void testCreateRequestOmitsCanvasFieldsWhenUnset() { + JsonNode json = toJson(SessionRequestBuilder.buildCreateRequest(new SessionConfig(), "sess-1")); + assertFalse(json.has("canvases")); + assertFalse(json.has("requestCanvasRenderer")); + assertFalse(json.has("requestExtensions")); + assertFalse(json.has("extensionSdkPath")); + assertFalse(json.has("extensionInfo")); + assertFalse(json.has("canvasProvider")); + } + + // ========================================================================= + // session.resume wire mapping + // ========================================================================= + + @Test + void testResumeRequestSerializesCanvasFields() { + var openInstance = new OpenCanvasInstance("inst-1", "app:builtin:main", "My App", "counter", "Counter", null, + null, null); + var config = new ResumeSessionConfig().setCanvases(List.of(counterCanvas())) + .setOpenCanvases(List.of(openInstance)).setRequestCanvasRenderer(true).setRequestExtensions(true) + .setExtensionInfo(new ExtensionInfo("github-app", "canvas-provider")) + .setCanvasProvider(new CanvasProviderIdentity("app:builtin:main")); + + ResumeSessionRequest request = SessionRequestBuilder.buildResumeRequest("sess-1", config); + JsonNode json = toJson(request); + + assertEquals("counter", json.get("canvases").get(0).get("id").asText()); + assertEquals("inst-1", json.get("openCanvases").get(0).get("instanceId").asText()); + assertEquals("counter", json.get("openCanvases").get(0).get("canvasId").asText()); + assertTrue(json.get("requestCanvasRenderer").asBoolean()); + assertTrue(json.get("requestExtensions").asBoolean()); + assertEquals("github-app", json.get("extensionInfo").get("source").asText()); + assertEquals("app:builtin:main", json.get("canvasProvider").get("id").asText()); + assertFalse(json.get("canvasProvider").has("name")); + } + + // ========================================================================= + // CanvasHandler defaults + CanvasException + // ========================================================================= + + @Test + void testCanvasHandlerOnActionDefaultFailsWithNoHandler() { + CanvasHandler handler = params -> CompletableFuture.completedFuture(new CanvasOpenResult(null, null, null)); + var params = new CanvasActionInvokeParams("sess-1", "ext", "counter", "inst-1", "increment", null, null, null); + + ExecutionException ex = assertThrows(ExecutionException.class, () -> handler.onAction(params).get()); + assertInstanceOf(CanvasException.class, ex.getCause()); + assertEquals("canvas_action_no_handler", ((CanvasException) ex.getCause()).getCode()); + } + + @Test + void testCanvasHandlerOnCloseDefaultIsNoOp() throws Exception { + CanvasHandler handler = params -> CompletableFuture.completedFuture(new CanvasOpenResult(null, null, null)); + var params = new CanvasCloseParams("sess-1", "ext", "counter", "inst-1", null, null); + + assertNull(handler.onClose(params).get()); + } + + @Test + void testCanvasExceptionNoHandlerCode() { + CanvasException ex = CanvasException.noHandler(); + assertEquals("canvas_action_no_handler", ex.getCode()); + assertNotNull(ex.getMessage()); + } + + @Test + void testCanvasOpenParamsRoundTripsThroughHandler() throws Exception { + CanvasHandler handler = params -> CompletableFuture + .completedFuture(new CanvasOpenResult("https://example/" + params.canvasId(), "Counter", "ready")); + var params = new CanvasOpenParams("sess-1", "ext", "counter", "inst-1", null, null, null); + + CanvasOpenResult result = handler.onOpen(params).get(); + assertEquals("https://example/counter", result.url()); + assertEquals("ready", result.status()); + } +} diff --git a/java/src/test/java/com/github/copilot/CanvasIT.java b/java/src/test/java/com/github/copilot/CanvasIT.java new file mode 100644 index 0000000000..f1c143f38b --- /dev/null +++ b/java/src/test/java/com/github/copilot/CanvasIT.java @@ -0,0 +1,186 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +package com.github.copilot; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.List; +import java.util.Map; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.TimeUnit; + +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +import com.github.copilot.generated.rpc.CanvasAction; +import com.github.copilot.generated.rpc.CanvasActionInvokeParams; +import com.github.copilot.generated.rpc.CanvasCloseParams; +import com.github.copilot.generated.rpc.CanvasOpenParams; +import com.github.copilot.generated.rpc.CanvasOpenResult; +import com.github.copilot.generated.rpc.DiscoveredCanvas; +import com.github.copilot.generated.rpc.SessionCanvasActionInvokeParams; +import com.github.copilot.generated.rpc.SessionCanvasActionInvokeResult; +import com.github.copilot.generated.rpc.SessionCanvasListOpenResult; +import com.github.copilot.generated.rpc.SessionCanvasListResult; +import com.github.copilot.generated.rpc.SessionCanvasOpenParams; +import com.github.copilot.generated.rpc.SessionCanvasOpenResult; +import com.github.copilot.rpc.CanvasDeclaration; +import com.github.copilot.rpc.CanvasHandler; +import com.github.copilot.rpc.ExtensionInfo; +import com.github.copilot.rpc.PermissionHandler; +import com.github.copilot.rpc.SessionConfig; + +/** + * Failsafe integration test that exercises the host-side canvas declaration API + * against the live Copilot CLI via the replay proxy. Mirrors + * {@code rust/tests/e2e/canvas.rs}. + *

+ * Canvas round-trips make no CAPI (model) calls, so the snapshots under + * {@code test/snapshots/canvas/} have empty conversations. + */ +class CanvasIT { + + private static E2ETestContext ctx; + + @BeforeAll + static void setup() throws Exception { + ctx = E2ETestContext.create(); + } + + @AfterAll + static void teardown() throws Exception { + if (ctx != null) { + ctx.close(); + } + } + + /** Records every inbound canvas callback so tests can assert on them. */ + private static final class RecordingCanvasHandler implements CanvasHandler { + final List openCalls = new CopyOnWriteArrayList<>(); + final List actionCalls = new CopyOnWriteArrayList<>(); + final List closeCalls = new CopyOnWriteArrayList<>(); + + @Override + public CompletableFuture onOpen(CanvasOpenParams params) { + openCalls.add(params); + return CompletableFuture.completedFuture(new CanvasOpenResult( + "https://example.com/counter/" + params.instanceId(), "Counter " + params.instanceId(), "ready")); + } + + @Override + public CompletableFuture onAction(CanvasActionInvokeParams params) { + actionCalls.add(params); + return CompletableFuture.completedFuture(Map.of("newValue", 42)); + } + + @Override + public CompletableFuture onClose(CanvasCloseParams params) { + closeCalls.add(params); + return CompletableFuture.completedFuture(null); + } + } + + private static SessionConfig canvasSessionConfig(CanvasHandler handler) { + var declaration = new CanvasDeclaration("counter", "Counter", "Tracks a counter value.") + .setActions(List.of(new CanvasAction("increment", "Increments the counter.", null))); + return new SessionConfig().setOnPermissionRequest(PermissionHandler.APPROVE_ALL).setRequestCanvasRenderer(true) + .setRequestExtensions(true).setExtensionInfo(new ExtensionInfo("java-sdk-tests", "canvas-provider")) + .setCanvases(List.of(declaration)).setCanvasHandler(handler); + } + + @Test + void canvasListDiscoversDeclaredCanvases() throws Exception { + ctx.configureForTest("canvas", "canvas_list_discovers_declared_canvases"); + + try (CopilotClient client = ctx.createClient()) { + var handler = new RecordingCanvasHandler(); + CopilotSession session = client.createSession(canvasSessionConfig(handler)).get(30, TimeUnit.SECONDS); + try { + SessionCanvasListResult result = session.getRpc().canvas.list().get(30, TimeUnit.SECONDS); + + assertNotNull(result.canvases(), "canvases list must not be null"); + assertEquals(1, result.canvases().size()); + DiscoveredCanvas canvas = result.canvases().get(0); + assertEquals("counter", canvas.canvasId()); + assertEquals("Counter", canvas.displayName()); + assertEquals("Tracks a counter value.", canvas.description()); + } finally { + session.close(); + } + } + } + + @Test + void canvasOpenRoundTrip() throws Exception { + ctx.configureForTest("canvas", "canvas_open_round_trip"); + + try (CopilotClient client = ctx.createClient()) { + var handler = new RecordingCanvasHandler(); + CopilotSession session = client.createSession(canvasSessionConfig(handler)).get(30, TimeUnit.SECONDS); + try { + SessionCanvasListResult canvasList = session.getRpc().canvas.list().get(30, TimeUnit.SECONDS); + DiscoveredCanvas canvas = canvasList.canvases().get(0); + + SessionCanvasOpenResult openResult = session.getRpc().canvas.open(new SessionCanvasOpenParams(null, + canvas.extensionId(), "counter", "counter-1", Map.of("start", 41))).get(30, TimeUnit.SECONDS); + + assertEquals("counter-1", openResult.instanceId()); + assertEquals("Counter counter-1", openResult.title()); + assertEquals("ready", openResult.status()); + assertEquals("https://example.com/counter/counter-1", openResult.url()); + + assertEquals(1, handler.openCalls.size()); + assertEquals("counter", handler.openCalls.get(0).canvasId()); + assertEquals("counter-1", handler.openCalls.get(0).instanceId()); + + SessionCanvasListOpenResult openList = session.getRpc().canvas.listOpen().get(30, TimeUnit.SECONDS); + assertEquals(1, openList.openCanvases().size()); + assertEquals("counter-1", openList.openCanvases().get(0).instanceId()); + } finally { + session.close(); + } + } + } + + @Test + void canvasInvokeActionRoundTrip() throws Exception { + ctx.configureForTest("canvas", "canvas_invoke_action_round_trip"); + + try (CopilotClient client = ctx.createClient()) { + var handler = new RecordingCanvasHandler(); + CopilotSession session = client.createSession(canvasSessionConfig(handler)).get(30, TimeUnit.SECONDS); + try { + SessionCanvasListResult canvasList = session.getRpc().canvas.list().get(30, TimeUnit.SECONDS); + DiscoveredCanvas canvas = canvasList.canvases().get(0); + + session.getRpc().canvas + .open(new SessionCanvasOpenParams(null, canvas.extensionId(), "counter", "counter-2", Map.of())) + .get(30, TimeUnit.SECONDS); + + SessionCanvasActionInvokeResult result = session.getRpc().canvas.action + .invoke(new SessionCanvasActionInvokeParams(null, "counter-2", "increment", Map.of("delta", 1))) + .get(30, TimeUnit.SECONDS); + + assertNotNull(result.result()); + assertTrue(result.result() instanceof Map, "action result should be a JSON object"); + @SuppressWarnings("unchecked") + Map resultMap = (Map) result.result(); + assertEquals(42, ((Number) resultMap.get("newValue")).intValue()); + + assertEquals(1, handler.actionCalls.size()); + CanvasActionInvokeParams actionCall = handler.actionCalls.get(0); + assertEquals("counter", actionCall.canvasId()); + assertEquals("counter-2", actionCall.instanceId()); + assertEquals("increment", actionCall.actionName()); + } finally { + session.close(); + } + } + } +} diff --git a/test/snapshots/canvas/canvas_invoke_action_round_trip.yaml b/test/snapshots/canvas/canvas_invoke_action_round_trip.yaml new file mode 100644 index 0000000000..056351ddb4 --- /dev/null +++ b/test/snapshots/canvas/canvas_invoke_action_round_trip.yaml @@ -0,0 +1,3 @@ +models: + - claude-sonnet-4.5 +conversations: [] diff --git a/test/snapshots/canvas/canvas_open_round_trip.yaml b/test/snapshots/canvas/canvas_open_round_trip.yaml new file mode 100644 index 0000000000..056351ddb4 --- /dev/null +++ b/test/snapshots/canvas/canvas_open_round_trip.yaml @@ -0,0 +1,3 @@ +models: + - claude-sonnet-4.5 +conversations: [] From d18619fbce727f28d8c824a1b712d2e6450f68c4 Mon Sep 17 00:00:00 2001 From: jmoseley Date: Mon, 29 Jun 2026 20:52:10 -0700 Subject: [PATCH 2/2] Reply with canvas error when canvas callback fails synchronously Address Cloud Code Review feedback: the canvas.open / canvas.close / canvas.action.invoke handlers caught synchronous failures (e.g. malformed params from treeToValue, or any logic before the CompletableFuture chain is attached) but only logged them, leaving the caller without a response. Send a structured canvas error reply in the catch block so the provider callback always completes instead of hanging. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../src/main/java/com/github/copilot/RpcHandlerDispatcher.java | 3 +++ 1 file changed, 3 insertions(+) diff --git a/java/src/main/java/com/github/copilot/RpcHandlerDispatcher.java b/java/src/main/java/com/github/copilot/RpcHandlerDispatcher.java index de9dd68179..c65d14bb7d 100644 --- a/java/src/main/java/com/github/copilot/RpcHandlerDispatcher.java +++ b/java/src/main/java/com/github/copilot/RpcHandlerDispatcher.java @@ -498,6 +498,7 @@ private void handleCanvasOpen(JsonRpcClient rpc, String requestId, JsonNode para }); } catch (Exception e) { LOG.log(Level.SEVERE, "Error handling canvas open request", e); + sendCanvasError(rpc, requestIdLong, "canvas.open", e); } }); } @@ -527,6 +528,7 @@ private void handleCanvasClose(JsonRpcClient rpc, String requestId, JsonNode par }); } catch (Exception e) { LOG.log(Level.SEVERE, "Error handling canvas close request", e); + sendCanvasError(rpc, requestIdLong, "canvas.close", e); } }); } @@ -556,6 +558,7 @@ private void handleCanvasActionInvoke(JsonRpcClient rpc, String requestId, JsonN }); } catch (Exception e) { LOG.log(Level.SEVERE, "Error handling canvas action invoke request", e); + sendCanvasError(rpc, requestIdLong, "canvas.action.invoke", e); } }); }