From 1cffb3e429a663e0a6d4c51aea3baa087d948d57 Mon Sep 17 00:00:00 2001 From: jmoseley Date: Fri, 22 May 2026 14:15:34 -0700 Subject: [PATCH 01/29] Add canvas runtime support to SDK Add Node extension canvas APIs and direct canvas provider callback routing. Add Rust canvas declarations, provider handlers, create/resume wiring, and host session.canvas APIs aligned with the runtime schema. Validation: nodejs typecheck/lint/tests; rust fmt/check/clippy; cargo test --all-features. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- nodejs/src/canvas.ts | 302 ++++++++++++++ nodejs/src/client.ts | 62 ++- nodejs/src/extension.ts | 17 + nodejs/src/index.ts | 16 + nodejs/src/session.ts | 29 ++ nodejs/src/types.ts | 28 ++ nodejs/test/client.test.ts | 118 +++++- nodejs/test/extension.test.ts | 12 +- rust/src/canvas.rs | 727 ++++++++++++++++++++++++++++++++++ rust/src/lib.rs | 2 + rust/src/session.rs | 308 ++++++++++++-- rust/src/types.rs | 118 ++++++ rust/src/wire.rs | 13 + rust/tests/e2e/elicitation.rs | 1 + rust/tests/session_test.rs | 272 +++++++++++++ 15 files changed, 1988 insertions(+), 37 deletions(-) create mode 100644 nodejs/src/canvas.ts create mode 100644 rust/src/canvas.rs diff --git a/nodejs/src/canvas.ts b/nodejs/src/canvas.ts new file mode 100644 index 000000000..2e78f01be --- /dev/null +++ b/nodejs/src/canvas.ts @@ -0,0 +1,302 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +/** + * Extension-owned canvases declared via + * `joinSession({ canvases: [createCanvas({...})] })`. + * + * The runtime sends provider callbacks directly as `canvas.open`, + * `canvas.focus`, `canvas.reload`, `canvas.close`, and + * `canvas.action.invoke` JSON-RPC requests. The SDK routes those requests by + * `canvasId` to the in-process handlers bound by `createCanvas`. + */ + +/** JSON Schema object used for canvas inputs and canvas-scoped tools. */ +export type CanvasJsonSchema = Record; + +/** Tool definition exposed to a canvas instance. */ +export interface CanvasToolDefinition { + name: string; + description: string; + title?: string; + parameters?: CanvasJsonSchema; + overridesBuiltInTool?: boolean; + skipPermission?: boolean; + defer?: "auto" | "never"; +} + +/** + * A single agent-callable action contributed by a canvas. Names MUST NOT + * start with `canvas.` - that prefix is reserved for lifecycle verbs. + */ +export interface CanvasAgentActionDeclaration { + /** Action identifier, unique within the canvas. */ + name: string; + /** Description shown to the model when picking an action. */ + description?: string; + /** Optional JSON Schema for the action's `input` payload. */ + inputSchema?: CanvasJsonSchema; +} + +/** A single toolbar button contributed by a canvas. */ +export interface CanvasToolbarItemDeclaration { + /** Stable id used by the host to key the button. */ + id: string; + /** User-visible label. */ + label: string; + /** The `agentActions[].name` to dispatch when clicked. */ + actionName: string; + /** Optional fixed input payload passed verbatim to the action handler. */ + input?: unknown; +} + +/** + * Declarative metadata for a single canvas, serialized over the wire on + * `session.create` / `session.resume`. + */ +export interface CanvasDeclaration { + /** Canvas id, unique within the declaring connection. */ + id: string; + /** Human-readable label shown in discovery and host UI chrome. */ + displayName: string; + /** One-line description shown in discovery for agent reasoning. */ + description?: string; + /** Optional JSON Schema for the `input` payload accepted by `canvas.open`. */ + inputSchema?: CanvasJsonSchema; + /** Agent-invocable actions exposed via `invoke_canvas_action`. */ + agentActions?: CanvasAgentActionDeclaration[]; + /** Static toolbar items rendered as host chrome. */ + toolbar?: CanvasToolbarItemDeclaration[]; +} + +/** Response returned from `onOpen`. */ +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; + /** Toolbar items for host-rendered chrome. */ + toolbar?: CanvasToolbarItemDeclaration[]; + /** Tools available to the canvas instance. */ + tools?: CanvasToolDefinition[]; +} + +/** Host capabilities passed to canvas callbacks. */ +export interface CanvasHostContext { + capabilities?: { + canvases?: boolean; + }; +} + +/** Context handed to a canvas's `onOpen` 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's `onAction` 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 `CanvasAgentActionDeclaration.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 lifecycle hooks (`onFocus`, `onClose`, `onReload`). */ +export interface CanvasLifecycleContext { + /** Session owning the canvas instance. */ + sessionId: string; + /** Extension id that owns the canvas. */ + extensionId: string; + /** Canvas id (matches the declaring `CanvasDeclaration.id`). */ + canvasId: string; + /** Instance id this lifecycle event applies to. */ + instanceId: string; + /** Host capabilities supplied by the runtime. */ + host?: CanvasHostContext; +} + +/** Structured error returned from canvas handlers. */ +export class CanvasError extends Error { + constructor( + public readonly code: string, + message: string + ) { + super(message); + this.name = "CanvasError"; + } + + /** Default error when an action is declared but no `onAction` is wired. */ + static noHandler(): CanvasError { + return new CanvasError( + "canvas_action_no_handler", + "No handler implemented for this canvas action" + ); + } +} + +/** + * Options accepted by {@link createCanvas}. Combines the declarative + * {@link CanvasDeclaration} fields with the in-process handler closures. + */ +export interface CanvasOptions { + /** @see CanvasDeclaration.id */ + id: string; + /** @see CanvasDeclaration.displayName */ + displayName: string; + /** @see CanvasDeclaration.description */ + description?: string; + /** @see CanvasDeclaration.inputSchema */ + inputSchema?: CanvasJsonSchema; + /** @see CanvasDeclaration.agentActions */ + agentActions?: CanvasAgentActionDeclaration[]; + /** @see CanvasDeclaration.toolbar */ + toolbar?: CanvasToolbarItemDeclaration[]; + + /** Required. Open a new canvas instance. */ + onOpen: (ctx: CanvasOpenContext) => Promise | CanvasOpenResponse; + + /** + * Optional. Handle a non-lifecycle action declared in `agentActions`. + * If omitted, dispatched actions return `canvas_action_no_handler`. + */ + onAction?: (ctx: CanvasActionContext) => Promise | unknown; + + /** Optional. Canvas was brought to the foreground. */ + onFocus?: (ctx: CanvasLifecycleContext) => Promise | void; + + /** Optional. Canvas was closed by the user or agent. */ + onClose?: (ctx: CanvasLifecycleContext) => Promise | void; + + /** Optional. Host requested a reload. */ + onReload?: (ctx: CanvasLifecycleContext) => Promise | void; +} + +/** A registered canvas: declarative metadata + in-process handler closures. */ +export class Canvas { + readonly declaration: CanvasDeclaration; + readonly onOpen: NonNullable; + readonly onAction?: CanvasOptions["onAction"]; + readonly onFocus?: CanvasOptions["onFocus"]; + readonly onClose?: CanvasOptions["onClose"]; + readonly onReload?: CanvasOptions["onReload"]; + + /** @internal */ + constructor(options: CanvasOptions) { + this.declaration = { + id: options.id, + displayName: options.displayName, + description: options.description, + inputSchema: options.inputSchema, + agentActions: options.agentActions, + toolbar: options.toolbar, + }; + this.onOpen = options.onOpen; + this.onAction = options.onAction; + this.onFocus = options.onFocus; + this.onClose = options.onClose; + this.onReload = options.onReload; + } +} + +/** Create a canvas declaration with bound in-process handlers. */ +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.focus" | "canvas.close" | "canvas.reload" | string, + params: CanvasActionInvokeParams | CanvasProviderRequestParams +): Promise { + switch (actionName) { + case "canvas.open": { + const result = await canvas.onOpen({ + sessionId: params.sessionId, + extensionId: params.extensionId, + canvasId: params.canvasId, + instanceId: params.instanceId, + input: params.input, + host: params.host, + }); + return result ?? {}; + } + case "canvas.focus": + case "canvas.close": + case "canvas.reload": { + const hook = + actionName === "canvas.focus" + ? canvas.onFocus + : actionName === "canvas.close" + ? canvas.onClose + : canvas.onReload; + if (!hook) return undefined; + await hook({ + sessionId: params.sessionId, + extensionId: params.extensionId, + canvasId: params.canvasId, + instanceId: params.instanceId, + host: params.host, + }); + return undefined; + } + default: { + if (!canvas.onAction) { + throw CanvasError.noHandler(); + } + return canvas.onAction({ + sessionId: params.sessionId, + extensionId: params.extensionId, + canvasId: params.canvasId, + instanceId: params.instanceId, + actionName, + input: params.input, + host: params.host, + }); + } + } +} diff --git a/nodejs/src/client.ts b/nodejs/src/client.ts index 21563d598..1bb910c6a 100644 --- a/nodejs/src/client.ts +++ b/nodejs/src/client.ts @@ -31,6 +31,11 @@ import { createInternalServerRpc, registerClientSessionApiHandlers, } from "./generated/rpc.js"; +import { + type CanvasActionInvokeParams, + type CanvasProviderRequestParams, + dispatchCanvasProviderRequest, +} from "./canvas.js"; import { getSdkProtocolVersion } from "./sdkProtocolVersion.js"; import { CopilotSession } from "./session.js"; import { createSessionFsAdapter, type SessionFsProvider } from "./sessionFsProvider.js"; @@ -51,6 +56,7 @@ import type { ResumeSessionConfig, SectionTransformFn, SessionConfig, + SessionCapabilities, SessionEvent, SessionFsConfig, SessionLifecycleEvent, @@ -803,6 +809,7 @@ export class CopilotClient { this.onGetTraceContext ); session.registerTools(config.tools); + session.registerCanvases(config.canvases); session.registerCommands(config.commands); session.registerPermissionHandler(config.onPermissionRequest); if (config.onUserInputRequest) { @@ -849,6 +856,9 @@ export class CopilotClient { overridesBuiltInTool: tool.overridesBuiltInTool, skipPermission: tool.skipPermission, })), + canvases: config.canvases?.map((canvas) => canvas.declaration), + requestCanvasRenderer: config.requestCanvasRenderer, + requestExtensions: config.requestExtensions, commands: config.commands?.map((cmd) => ({ name: cmd.name, description: cmd.description, @@ -887,7 +897,7 @@ export class CopilotClient { const { workspacePath, capabilities } = response as { sessionId: string; workspacePath?: string; - capabilities?: { ui?: { elicitation?: boolean } }; + capabilities?: SessionCapabilities; }; session["_workspacePath"] = workspacePath; session.setCapabilities(capabilities); @@ -937,6 +947,7 @@ export class CopilotClient { this.onGetTraceContext ); session.registerTools(config.tools); + session.registerCanvases(config.canvases); session.registerCommands(config.commands); session.registerPermissionHandler(config.onPermissionRequest); if (config.onUserInputRequest) { @@ -987,6 +998,9 @@ export class CopilotClient { overridesBuiltInTool: tool.overridesBuiltInTool, skipPermission: tool.skipPermission, })), + canvases: config.canvases?.map((canvas) => canvas.declaration), + requestCanvasRenderer: config.requestCanvasRenderer, + requestExtensions: config.requestExtensions, commands: config.commands?.map((cmd) => ({ name: cmd.name, description: cmd.description, @@ -1023,7 +1037,7 @@ export class CopilotClient { const { workspacePath, capabilities } = response as { sessionId: string; workspacePath?: string; - capabilities?: { ui?: { elicitation?: boolean } }; + capabilities?: SessionCapabilities; }; session["_workspacePath"] = workspacePath; session.setCapabilities(capabilities); @@ -1880,6 +1894,24 @@ export class CopilotClient { await this.handleSystemMessageTransform(params) ); + this.connection.onRequest("canvas.open", async (params: CanvasProviderRequestParams) => + this.handleCanvasProviderRequest("canvas.open", params) + ); + this.connection.onRequest("canvas.focus", async (params: CanvasProviderRequestParams) => + this.handleCanvasProviderRequest("canvas.focus", params) + ); + this.connection.onRequest("canvas.reload", async (params: CanvasProviderRequestParams) => + this.handleCanvasProviderRequest("canvas.reload", params) + ); + this.connection.onRequest("canvas.close", async (params: CanvasProviderRequestParams) => + this.handleCanvasProviderRequest("canvas.close", params) + ); + this.connection.onRequest( + "canvas.action.invoke", + async (params: CanvasActionInvokeParams) => + this.handleCanvasProviderRequest(params.actionName, params) + ); + // Register client session API handlers. const sessions = this.sessions; registerClientSessionApiHandlers(this.connection, (sessionId) => { @@ -2083,4 +2115,30 @@ export class CopilotClient { return await session._handleSystemMessageTransform(params.sections); } + + private async handleCanvasProviderRequest( + actionName: string, + params: CanvasActionInvokeParams | CanvasProviderRequestParams + ): Promise { + if ( + !params || + typeof params.sessionId !== "string" || + typeof params.canvasId !== "string" || + typeof params.instanceId !== "string" + ) { + 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); + } } diff --git a/nodejs/src/extension.ts b/nodejs/src/extension.ts index 617052546..88faefbe5 100644 --- a/nodejs/src/extension.ts +++ b/nodejs/src/extension.ts @@ -10,6 +10,23 @@ import { type ResumeSessionConfig, } from "./types.js"; +export { + Canvas, + CanvasError, + createCanvas, + type CanvasActionContext, + type CanvasAgentActionDeclaration, + type CanvasDeclaration, + type CanvasHostContext, + type CanvasJsonSchema, + type CanvasLifecycleContext, + type CanvasOpenContext, + type CanvasOpenResponse, + type CanvasOptions, + type CanvasToolDefinition, + type CanvasToolbarItemDeclaration, +} from "./canvas.js"; + export type JoinSessionConfig = Omit & { onPermissionRequest?: PermissionHandler; }; diff --git a/nodejs/src/index.ts b/nodejs/src/index.ts index b92ca38a6..31001ab79 100644 --- a/nodejs/src/index.ts +++ b/nodejs/src/index.ts @@ -11,6 +11,22 @@ export { CopilotClient } from "./client.js"; export { RuntimeConnection } from "./types.js"; export { CopilotSession, type AssistantMessageEvent } from "./session.js"; +export { + Canvas, + CanvasError, + createCanvas, + type CanvasActionContext, + type CanvasAgentActionDeclaration, + type CanvasDeclaration, + type CanvasHostContext, + type CanvasJsonSchema, + type CanvasLifecycleContext, + type CanvasOpenContext, + type CanvasOpenResponse, + type CanvasOptions, + type CanvasToolDefinition, + type CanvasToolbarItemDeclaration, +} from "./canvas.js"; export { defineTool, approveAll, diff --git a/nodejs/src/session.ts b/nodejs/src/session.ts index 6f2a002b1..7ba01441d 100644 --- a/nodejs/src/session.ts +++ b/nodejs/src/session.ts @@ -11,6 +11,7 @@ import type { MessageConnection } from "vscode-jsonrpc/node.js"; import { ConnectionError, ResponseError } from "vscode-jsonrpc/node.js"; import { createSessionRpc } from "./generated/rpc.js"; import type { ClientSessionApiHandlers } from "./generated/rpc.js"; +import type { Canvas } from "./canvas.js"; import { getTraceContext } from "./telemetry.js"; import type { CommandHandler, @@ -100,6 +101,7 @@ export class CopilotSession { private typedEventHandlers: Map void>> = new Map(); private toolHandlers: Map = new Map(); + private canvases: Map = new Map(); private commandHandlers: Map = new Map(); private permissionHandler?: PermissionHandler; private userInputHandler?: UserInputHandler; @@ -635,6 +637,33 @@ export class CopilotSession { return this.toolHandlers.get(name); } + /** + * Registers canvas declarations and handlers for this session. + * + * @param canvases - Canvases created via `createCanvas`, or undefined to clear all canvases + * @internal Called by the SDK when creating/resuming a session with `canvases`. + */ + registerCanvases(canvases?: Canvas[]): void { + this.canvases.clear(); + if (!canvases) { + return; + } + for (const canvas of canvases) { + this.canvases.set(canvas.declaration.id, canvas); + } + } + + /** + * Retrieves a registered canvas by id. + * + * @param canvasId - The id of the canvas to retrieve + * @returns The registered Canvas if found, or undefined + * @internal Used by the SDK's direct `canvas.*` dispatcher. + */ + getCanvas(canvasId: string): Canvas | undefined { + return this.canvases.get(canvasId); + } + /** * Registers command handlers for this session. * diff --git a/nodejs/src/types.ts b/nodejs/src/types.ts index 4f3de000b..d5054f77e 100644 --- a/nodejs/src/types.ts +++ b/nodejs/src/types.ts @@ -7,6 +7,7 @@ */ // Import and re-export generated session event types +import type { Canvas } from "./canvas.js"; import type { SessionFsProvider } from "./sessionFsProvider.js"; import type { SessionEvent as GeneratedSessionEvent } from "./generated/session-events.js"; import type { CopilotSession } from "./session.js"; @@ -543,6 +544,8 @@ export interface SessionCapabilities { ui?: { /** Whether the host supports interactive elicitation dialogs. */ elicitation?: boolean; + /** Whether the host supports canvas rendering. */ + canvases?: boolean; }; } @@ -1462,6 +1465,31 @@ export interface SessionConfigBase { // eslint-disable-next-line @typescript-eslint/no-explicit-any tools?: Tool[]; + /** + * Canvases contributed by this session participant. The declaring + * connection becomes the live provider for `canvas.open|focus|close|reload` + * and `canvas.action.invoke` dispatches targeting each canvas's `id` for + * the lifetime of the connection. Re-declaring the same id on resume + * replaces the prior declaration. + */ + canvases?: Canvas[]; + + /** + * Renderer-side opt-in: when true, the runtime surfaces canvas agent tools + * (`open_canvas`, `discover_canvases`, `focus_canvas`, `close_canvas`, + * `reload_canvas`) to the model for this connection. Default off so SDK + * callers that cannot display canvases stay clean. + */ + requestCanvasRenderer?: boolean; + + /** + * Extension surface opt-in: when true, the runtime wires extension + * management tools and per-extension tool dispatch onto the session for + * this connection. Default off so callers that do not expose extensions + * stay clean. + */ + requestExtensions?: boolean; + /** * Slash commands registered for this session. * When the CLI has a TUI, each command appears as `/name` for the user to invoke. diff --git a/nodejs/test/client.test.ts b/nodejs/test/client.test.ts index b9a34c214..26fc28dcc 100644 --- a/nodejs/test/client.test.ts +++ b/nodejs/test/client.test.ts @@ -1,6 +1,12 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ import { describe, expect, it, onTestFinished, vi } from "vitest"; -import { approveAll, CopilotClient, RuntimeConnection, type ModelInfo } from "../src/index.js"; +import { + approveAll, + CopilotClient, + createCanvas, + RuntimeConnection, + type ModelInfo, +} from "../src/index.js"; import { CopilotSession } from "../src/session.js"; import { defaultJoinSessionPermissionHandler } from "../src/types.js"; @@ -17,6 +23,116 @@ describe("CopilotClient", () => { expect(spy).not.toHaveBeenCalled(); }); + it("forwards canvas declarations and request flags in session.create", async () => { + const client = new CopilotClient(); + await client.start(); + onTestFinished(() => client.forceStop()); + + const canvas = createCanvas({ + id: "counter", + displayName: "Counter", + description: "A counter canvas", + agentActions: [{ name: "increment", description: "Increment the counter" }], + onOpen: () => ({ url: "https://example.test/counter" }), + }); + const spy = vi + .spyOn((client as any).connection!, "sendRequest") + .mockImplementation(async (method: string, params: any) => { + if (method === "session.create") return { sessionId: params.sessionId }; + throw new Error(`Unexpected method: ${method}`); + }); + + await client.createSession({ + onPermissionRequest: approveAll, + canvases: [canvas], + requestCanvasRenderer: true, + requestExtensions: true, + }); + + const payload = spy.mock.calls.find(([method]) => method === "session.create")![1] as any; + expect(payload.canvases).toEqual([ + expect.objectContaining({ + id: "counter", + displayName: "Counter", + description: "A counter canvas", + agentActions: [{ name: "increment", description: "Increment the counter" }], + }), + ]); + expect(payload.requestCanvasRenderer).toBe(true); + expect(payload.requestExtensions).toBe(true); + }); + + it("forwards canvas declarations in session.resume", async () => { + const client = new CopilotClient(); + await client.start(); + onTestFinished(() => client.forceStop()); + + const session = await client.createSession({ onPermissionRequest: approveAll }); + const canvas = createCanvas({ + id: "counter", + displayName: "Counter", + onOpen: () => ({ url: "https://example.test/counter" }), + }); + const spy = vi + .spyOn((client as any).connection!, "sendRequest") + .mockImplementation(async (method: string, params: any) => { + if (method === "session.resume") return { sessionId: params.sessionId }; + throw new Error(`Unexpected method: ${method}`); + }); + + await client.resumeSession(session.sessionId, { + onPermissionRequest: approveAll, + canvases: [canvas], + requestCanvasRenderer: true, + requestExtensions: true, + }); + + const payload = spy.mock.calls.find(([method]) => method === "session.resume")![1] as any; + expect(payload.canvases).toEqual([expect.objectContaining({ id: "counter" })]); + expect(payload.requestCanvasRenderer).toBe(true); + expect(payload.requestExtensions).toBe(true); + expect(payload.openCanvasInstances).toBeUndefined(); + }); + + it("routes direct canvas action requests to registered canvases", async () => { + const canvas = createCanvas({ + id: "counter", + displayName: "Counter", + onOpen: ({ instanceId }) => ({ url: `https://example.test/${instanceId}` }), + onAction: ({ actionName, input }) => ({ actionName, input }), + }); + const session = new CopilotSession("session-1", {} as any); + session.registerCanvases([canvas]); + const client = new CopilotClient(); + (client as any).sessions.set(session.sessionId, session); + + const result = await (client as any).handleCanvasProviderRequest("increment", { + sessionId: session.sessionId, + extensionId: "project:counter", + canvasId: "counter", + instanceId: "counter-1", + actionName: "increment", + input: { amount: 1 }, + }); + + expect(result).toEqual({ actionName: "increment", input: { amount: 1 } }); + }); + + it("throws for unknown direct canvas dispatches", async () => { + const session = new CopilotSession("session-1", {} as any); + const client = new CopilotClient(); + (client as any).sessions.set(session.sessionId, session); + + await expect( + (client as any).handleCanvasProviderRequest("canvas.open", { + sessionId: session.sessionId, + extensionId: "project:missing", + canvasId: "missing", + instanceId: "missing-1", + }) + ).rejects.toThrow('No canvas registered with id "missing"'); + }); + it("forwards clientName in session.create request", async () => { const client = new CopilotClient(); await client.start(); diff --git a/nodejs/test/extension.test.ts b/nodejs/test/extension.test.ts index a522d23d5..6eca9f8e3 100644 --- a/nodejs/test/extension.test.ts +++ b/nodejs/test/extension.test.ts @@ -1,7 +1,7 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { CopilotClient } from "../src/client.js"; import { approveAll } from "../src/index.js"; -import { joinSession } from "../src/extension.js"; +import { createCanvas, joinSession } from "../src/extension.js"; import { defaultJoinSessionPermissionHandler } from "../src/types.js"; describe("joinSession", () => { @@ -46,4 +46,14 @@ describe("joinSession", () => { expect(config.onPermissionRequest).toBe(approveAll); expect(config.suppressResumeEvent).toBe(false); }); + + it("exports the canvas helper from the extension surface", () => { + const canvas = createCanvas({ + id: "counter", + displayName: "Counter", + onOpen: () => ({ url: "https://example.test/counter" }), + }); + + expect(canvas.declaration.id).toBe("counter"); + }); }); diff --git a/rust/src/canvas.rs b/rust/src/canvas.rs new file mode 100644 index 000000000..3d2fe41bc --- /dev/null +++ b/rust/src/canvas.rs @@ -0,0 +1,727 @@ +//! Canvas declarations, provider callbacks, and host-side canvas RPC types. + +use std::collections::HashMap; +use std::sync::Arc; + +use async_trait::async_trait; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use thiserror::Error; + +use crate::types::SessionId; + +/// JSON Schema object used for canvas inputs and canvas-scoped tools. +pub type CanvasJsonSchema = serde_json::Map; + +/// Tool definition exposed to a canvas instance. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct CanvasToolDefinition { + /// Tool name. + pub name: String, + /// Tool description. + pub description: String, + /// Human-readable tool title. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub title: Option, + /// JSON Schema parameters for the tool. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub parameters: Option, + /// Whether this tool overrides a built-in tool. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub overrides_built_in_tool: Option, + /// Whether this tool skips permission prompts. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub skip_permission: Option, + /// Tool deferral behavior. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub defer: Option, +} + +/// Tool deferral behavior for canvas-scoped tools. +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "kebab-case")] +pub enum CanvasToolDefinitionDefer { + /// The tool may be deferred by the runtime. + Auto, + /// The tool is always included in the initial tool list. + Never, +} + +/// Declarative metadata for a single canvas, sent over the wire on +/// `session.create` / `session.resume`. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +#[non_exhaustive] +pub struct CanvasDeclaration { + /// Canvas identifier, unique within the declaring connection. + pub id: String, + /// Human-readable name shown in host UI and canvas pickers. + pub display_name: String, + /// Description surfaced in discovery and agent context. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub description: Option, + /// JSON Schema for the `input` payload accepted by `canvas.open`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub input_schema: Option, + /// Agent-callable actions this canvas exposes. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub agent_actions: Option>, + /// User-facing toolbar buttons rendered by the host canvas chrome. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub toolbar: Option>, +} + +impl CanvasDeclaration { + /// Construct a canvas declaration with the required fields set. + pub fn new(id: impl Into, display_name: impl Into) -> Self { + Self { + id: id.into(), + display_name: display_name.into(), + description: None, + input_schema: None, + agent_actions: None, + toolbar: None, + } + } + + /// Set the description surfaced in discovery and agent context. + pub fn with_description(mut self, description: impl Into) -> Self { + self.description = Some(description.into()); + self + } +} + +/// A single agent-callable action contributed by a canvas. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct CanvasAgentActionDeclaration { + /// Action identifier, unique within the canvas. + pub name: String, + /// Description shown to the model when picking an action. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub description: Option, + /// Optional JSON Schema for the action's `input` payload. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub input_schema: Option, +} + +/// A single toolbar button contributed by a canvas. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct CanvasToolbarItemDeclaration { + /// Stable id used by the host to key the button. + pub id: String, + /// User-visible label. + pub label: String, + /// Action name dispatched when the toolbar item is activated. + pub action_name: String, + /// Optional fixed input payload passed verbatim to the action handler. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub input: Option, +} + +/// 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, + /// Toolbar items for host-rendered chrome. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub toolbar: Option>, + /// Tools available to the canvas instance. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub tools: Option>, +} + +/// Open canvas instance returned by `session.canvas.open`, +/// `session.canvas.listOpen`, and `session.resume`. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +#[non_exhaustive] +pub struct OpenCanvasInstance { + /// Stable caller-supplied canvas instance identifier. + pub instance_id: String, + /// Owning provider identifier. + pub extension_id: String, + /// Owning extension display name, when available. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub extension_name: Option, + /// Provider-local canvas identifier. + pub canvas_id: String, + /// Rendered title. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub title: Option, + /// Provider-supplied status text. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub status: Option, + /// URL for web-rendered canvases. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub url: Option, + /// Toolbar items for host-rendered chrome. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub toolbar: Option>, + /// Tools available to the canvas instance. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub tools: Option>, + /// Input supplied when the instance was opened. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub input: Option, + /// Whether this snapshot came from an idempotent reopen. + pub reopen: bool, +} + +/// Result returned by [`SessionCanvas::discover`](crate::session::SessionCanvas::discover). +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct CanvasDiscoverResult { + /// Declared canvases available in this session. + pub canvases: Vec, +} + +/// Canvas available in the current session. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct DiscoveredCanvas { + /// Owning provider identifier. + pub extension_id: String, + /// Owning extension display name, when available. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub extension_name: Option, + /// Provider-local canvas identifier. + pub canvas_id: String, + /// Human-readable canvas name. + pub display_name: String, + /// Canvas description for discovery. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub description: Option, + /// JSON Schema for canvas open input. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub input_schema: Option, + /// Actions the agent or host may invoke on an open instance. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub agent_actions: Option>, + /// Host-rendered toolbar contribution. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub toolbar: Option>, +} + +/// Result returned by [`SessionCanvas::list_open`](crate::session::SessionCanvas::list_open). +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct CanvasListOpenResult { + /// Currently open canvas instances. + pub open_canvases: Vec, +} + +/// Request parameters for `session.canvas.open`. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct CanvasOpenRequest { + /// Owning provider identifier. + pub extension_id: String, + /// Provider-local canvas identifier. + pub canvas_id: String, + /// Caller-supplied stable instance identifier. + pub instance_id: String, + /// Optional opaque payload forwarded to the canvas provider. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub input: Option, +} + +/// Request parameters for `session.canvas.focus`. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct CanvasFocusRequest { + /// Open canvas instance identifier. + pub instance_id: String, +} + +/// Request parameters for `session.canvas.close`. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct CanvasCloseRequest { + /// Open canvas instance identifier. + pub instance_id: String, +} + +/// Request parameters for `session.canvas.reload`. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct CanvasReloadRequest { + /// Open canvas instance identifier. + pub instance_id: String, +} + +/// Request parameters for `session.canvas.invokeAction`. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct CanvasInvokeActionRequest { + /// Open canvas instance identifier. + pub instance_id: String, + /// Action name to invoke. + pub action_name: String, + /// Optional input forwarded to the extension's action handler. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub input: Option, +} + +/// Result returned from `session.canvas.invokeAction`. +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct CanvasInvokeActionResult { + /// Provider-supplied action result. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub result: 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 [`CanvasAgentActionDeclaration::name`]. + pub action_name: String, + /// Validated input payload. + pub input: Value, + /// Host capabilities supplied by the runtime. + pub host: Option, +} + +/// Context handed to canvas lifecycle hooks. +#[derive(Debug, Clone)] +pub struct CanvasLifecycleContext { + /// Session owning the canvas instance. + pub session_id: SessionId, + /// Owning provider identifier. + pub extension_id: String, + /// Canvas id from the declaring [`CanvasDeclaration`]. + pub canvas_id: String, + /// Instance id this lifecycle event applies to. + pub instance_id: String, + /// Host capabilities supplied by the runtime. + pub host: Option, +} + +/// Structured error returned from canvas handlers. +#[derive(Debug, Clone, Error, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +#[error("{code}: {message}")] +pub struct CanvasError { + /// Machine-readable error code. + pub code: String, + /// Human-readable message. + pub message: String, +} + +impl CanvasError { + /// Construct a new error envelope with the given code and message. + pub fn new(code: impl Into, message: impl Into) -> Self { + Self { + code: code.into(), + message: message.into(), + } + } + + /// Default error returned when a custom action has no handler. + pub fn no_handler() -> Self { + Self::new( + "canvas_action_no_handler", + "No handler implemented for this canvas action", + ) + } +} + +/// Result alias for canvas handler methods. +pub type CanvasResult = Result; + +/// Per-canvas handler implementing provider-side canvas lifecycle callbacks. +#[async_trait] +pub trait CanvasHandler: Send + Sync { + /// Open a new canvas instance. + async fn on_open(&self, ctx: CanvasOpenContext) -> CanvasResult; + + /// Handle a non-lifecycle action declared by the canvas. + async fn on_action(&self, _ctx: CanvasActionContext) -> CanvasResult { + Err(CanvasError::no_handler()) + } + + /// Canvas was brought to the foreground. + async fn on_focus(&self, _ctx: CanvasLifecycleContext) -> CanvasResult<()> { + Ok(()) + } + + /// Canvas was closed by the user or agent. + async fn on_close(&self, _ctx: CanvasLifecycleContext) -> CanvasResult<()> { + Ok(()) + } + + /// Host requested a reload. + async fn on_reload(&self, _ctx: CanvasLifecycleContext) -> CanvasResult<()> { + Ok(()) + } +} + +/// A registered canvas: declarative metadata plus an in-process handler. +#[derive(Clone)] +pub struct Canvas { + declaration: CanvasDeclaration, + handler: Arc, +} + +impl Canvas { + /// Begin building a canvas from its declarative metadata. + pub fn builder(declaration: CanvasDeclaration) -> CanvasBuilder { + CanvasBuilder { + declaration, + handler: None, + } + } + + /// Borrow the declarative metadata serialized onto the wire. + pub fn declaration(&self) -> &CanvasDeclaration { + &self.declaration + } + + /// Clone the in-process handler for dispatch. + pub fn handler(&self) -> Arc { + self.handler.clone() + } +} + +impl Serialize for Canvas { + fn serialize(&self, serializer: S) -> Result { + self.declaration.serialize(serializer) + } +} + +impl std::fmt::Debug for Canvas { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("Canvas") + .field("declaration", &self.declaration) + .field("handler", &"") + .finish() + } +} + +/// Builder for [`Canvas`]. +pub struct CanvasBuilder { + declaration: CanvasDeclaration, + handler: Option>, +} + +impl CanvasBuilder { + /// Attach the per-canvas handler. + pub fn handler(mut self, handler: Arc) -> Self { + self.handler = Some(handler); + self + } + + /// Finalize into a [`Canvas`]. + pub fn build(self) -> Canvas { + let handler = self + .handler + .expect("Canvas::builder().handler(...) must be called before build()"); + Canvas { + declaration: self.declaration, + handler, + } + } +} + +/// Per-session canvas registry, keyed by canvas id. +pub type CanvasRegistry = HashMap>; + +/// Build a [`CanvasRegistry`] from a session's declared canvases. +pub fn build_registry(canvases: &[Canvas]) -> CanvasRegistry { + let mut map = CanvasRegistry::new(); + for canvas in canvases { + map.insert(canvas.declaration.id.clone(), canvas.handler.clone()); + } + map +} + +/// Common fields sent by direct `canvas.*` provider callbacks. +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CanvasProviderRequestParams { + /// Session that requested the canvas operation. + pub session_id: SessionId, + /// Owning provider identifier. + pub extension_id: String, + /// Provider-local canvas identifier. + pub canvas_id: String, + /// Open canvas instance identifier. + pub instance_id: String, + /// Optional provider input payload. + #[serde(default)] + pub input: Value, + /// Host capabilities supplied by the runtime. + #[serde(default)] + pub host: Option, +} + +/// Wire-level params for `canvas.action.invoke`. +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CanvasInvokeParams { + /// Session that requested the canvas operation. + pub session_id: SessionId, + /// Owning provider identifier. + pub extension_id: String, + /// Provider-local canvas identifier. + pub canvas_id: String, + /// Open canvas instance identifier. + pub instance_id: String, + /// Custom action name. + pub action_name: String, + /// Optional provider input payload. + #[serde(default)] + pub input: Value, + /// Host capabilities supplied by the runtime. + #[serde(default)] + pub host: Option, +} + +/// Resolve a direct `canvas.open` request against a registry. +pub async fn dispatch_canvas_open( + registry: &CanvasRegistry, + params: CanvasProviderRequestParams, +) -> CanvasResult { + let handler = canvas_handler(registry, ¶ms.canvas_id)?; + let response = handler + .on_open(CanvasOpenContext { + session_id: params.session_id, + extension_id: params.extension_id, + canvas_id: params.canvas_id, + instance_id: params.instance_id, + input: params.input, + host: params.host, + }) + .await?; + Ok(serde_json::to_value(response).unwrap_or(Value::Null)) +} + +/// Resolve a direct `canvas.focus`, `canvas.reload`, or `canvas.close` request. +pub async fn dispatch_canvas_lifecycle( + registry: &CanvasRegistry, + method: &str, + params: CanvasProviderRequestParams, +) -> CanvasResult { + let handler = canvas_handler(registry, ¶ms.canvas_id)?; + let ctx = CanvasLifecycleContext { + session_id: params.session_id, + extension_id: params.extension_id, + canvas_id: params.canvas_id, + instance_id: params.instance_id, + host: params.host, + }; + match method { + "canvas.focus" => handler.on_focus(ctx).await?, + "canvas.reload" => handler.on_reload(ctx).await?, + "canvas.close" => handler.on_close(ctx).await?, + _ => { + return Err(CanvasError::new( + "unsupported_method", + format!("unsupported canvas lifecycle method: {method}"), + )); + } + } + Ok(Value::Null) +} + +/// Resolve a direct `canvas.action.invoke` request against a registry. +pub async fn dispatch_canvas_action( + registry: &CanvasRegistry, + params: CanvasInvokeParams, +) -> CanvasResult { + let handler = canvas_handler(registry, ¶ms.canvas_id)?; + handler + .on_action(CanvasActionContext { + session_id: params.session_id, + extension_id: params.extension_id, + canvas_id: params.canvas_id, + instance_id: params.instance_id, + action_name: params.action_name, + input: params.input, + host: params.host, + }) + .await +} + +fn canvas_handler( + registry: &CanvasRegistry, + canvas_id: &str, +) -> CanvasResult> { + registry.get(canvas_id).cloned().ok_or_else(|| { + CanvasError::new( + "canvas_not_found", + format!("No canvas registered with id '{canvas_id}'"), + ) + }) +} + +#[cfg(test)] +mod tests { + use serde_json::json; + + use super::*; + + struct EchoHandler; + + #[async_trait] + impl CanvasHandler for EchoHandler { + async fn on_open(&self, ctx: CanvasOpenContext) -> CanvasResult { + Ok(CanvasOpenResponse { + url: Some(format!("https://example.test/{}", ctx.canvas_id)), + title: Some("Echo".to_string()), + status: Some("ready".to_string()), + toolbar: None, + tools: None, + }) + } + + async fn on_action(&self, ctx: CanvasActionContext) -> CanvasResult { + Ok(json!({ "echoed": ctx.action_name, "input": ctx.input })) + } + } + + #[test] + fn declaration_serializes_camel_case_and_skips_none() { + let decl = CanvasDeclaration { + id: "counter".to_string(), + display_name: "Counter".to_string(), + description: None, + input_schema: None, + agent_actions: Some(vec![CanvasAgentActionDeclaration { + name: "increment".to_string(), + description: Some("bump".to_string()), + input_schema: None, + }]), + toolbar: None, + }; + + let value = serde_json::to_value(&decl).unwrap(); + + assert_eq!(value["id"], "counter"); + assert_eq!(value["displayName"], "Counter"); + assert!(value.get("description").is_none()); + assert_eq!(value["agentActions"][0]["name"], "increment"); + } + + #[tokio::test] + async fn dispatch_routes_canvas_open() { + let canvas = Canvas::builder(CanvasDeclaration::new("echo", "Echo")) + .handler(Arc::new(EchoHandler)) + .build(); + let registry = build_registry(&[canvas]); + let params = CanvasProviderRequestParams { + session_id: SessionId::from("s1"), + extension_id: "project:echo".to_string(), + canvas_id: "echo".to_string(), + instance_id: "echo-1".to_string(), + input: json!({ "x": 1 }), + host: None, + }; + + let result = dispatch_canvas_open(®istry, params).await.unwrap(); + + assert_eq!(result["url"], "https://example.test/echo"); + assert_eq!(result["title"], "Echo"); + assert_eq!(result["status"], "ready"); + } + + #[tokio::test] + async fn dispatch_routes_custom_action() { + let canvas = Canvas::builder(CanvasDeclaration::new("echo", "Echo")) + .handler(Arc::new(EchoHandler)) + .build(); + let registry = build_registry(&[canvas]); + + let result = dispatch_canvas_action( + ®istry, + CanvasInvokeParams { + session_id: SessionId::from("s1"), + extension_id: "project:echo".to_string(), + canvas_id: "echo".to_string(), + instance_id: "inst-1".to_string(), + action_name: "shout".to_string(), + input: json!("hi"), + host: None, + }, + ) + .await + .unwrap(); + + assert_eq!(result["echoed"], "shout"); + assert_eq!(result["input"], "hi"); + } + + #[tokio::test] + async fn dispatch_unknown_canvas_errors() { + let err = dispatch_canvas_open( + &CanvasRegistry::new(), + CanvasProviderRequestParams { + session_id: SessionId::from("s1"), + extension_id: "project:missing".to_string(), + canvas_id: "missing".to_string(), + instance_id: "missing-1".to_string(), + input: Value::Null, + host: None, + }, + ) + .await + .unwrap_err(); + + assert_eq!(err.code, "canvas_not_found"); + } +} diff --git a/rust/src/lib.rs b/rust/src/lib.rs index c7294c3c3..787697e2e 100644 --- a/rust/src/lib.rs +++ b/rust/src/lib.rs @@ -3,6 +3,8 @@ #![deny(rustdoc::broken_intra_doc_links)] #![cfg_attr(test, allow(clippy::unwrap_used))] +/// Canvas declarations, provider callbacks, and host-side canvas RPC types. +pub mod canvas; /// Bundled CLI binary extraction and caching. pub(crate) mod embeddedcli; /// Event handler traits for session lifecycle. diff --git a/rust/src/session.rs b/rust/src/session.rs index f8a35ce0c..7697a6361 100644 --- a/rust/src/session.rs +++ b/rust/src/session.rs @@ -4,12 +4,18 @@ use std::sync::Arc; use std::time::{Duration, Instant}; use parking_lot::Mutex as ParkingLotMutex; +use serde::{Serialize, 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::{ + CanvasCloseRequest, CanvasDiscoverResult, CanvasFocusRequest, CanvasInvokeActionRequest, + CanvasInvokeActionResult, CanvasInvokeParams, CanvasListOpenResult, CanvasOpenRequest, + CanvasProviderRequestParams, CanvasRegistry, CanvasReloadRequest, OpenCanvasInstance, +}; use crate::generated::api_types::{LogRequest, ModelSwitchToRequest}; use crate::generated::session_events::{ CommandExecuteData, ElicitationRequestedData, ExternalToolRequestedData, SessionErrorData, @@ -26,9 +32,10 @@ use crate::transforms::SystemMessageTransform; use crate::types::{ CommandContext, CommandDefinition, CommandHandler, CreateSessionResult, ElicitationRequest, ElicitationResult, ExitPlanModeData, GetMessagesResponse, MessageOptions, - PermissionRequestData, RequestId, ResumeSessionConfig, SectionOverride, SessionCapabilities, - SessionConfig, SessionEvent, SessionId, SetModelOptions, SystemMessageConfig, ToolInvocation, - ToolResult, ToolResultExpanded, TraceContext, UiInputOptions, ensure_attachment_display_names, + PermissionRequestData, RequestId, ResumeSessionConfig, ResumeSessionResult, SectionOverride, + SessionCapabilities, SessionConfig, SessionEvent, SessionId, SetModelOptions, + SystemMessageConfig, ToolInvocation, ToolResult, ToolResultExpanded, TraceContext, + UiInputOptions, ensure_attachment_display_names, }; use crate::{Client, Error, JsonRpcResponse, SessionError, SessionEventNotification, error_codes}; @@ -162,6 +169,8 @@ pub struct Session { idle_waiter: Arc>>, /// Capabilities negotiated with the CLI, updated on `capabilities.changed` events. capabilities: Arc>, + /// Canvas instances currently known to be open for this session. + open_canvases: Arc>>, /// Broadcast channel for runtime event subscribers — see [`Session::subscribe`]. event_tx: tokio::sync::broadcast::Sender, } @@ -195,6 +204,17 @@ impl Session { self.capabilities.read().clone() } + /// Canvas host API methods for this session. + pub fn canvas(&self) -> SessionCanvas<'_> { + SessionCanvas { session: self } + } + + /// Open canvas instances reported by the most recent `session.resume` + /// response or calls made through [`SessionCanvas`]. + pub fn open_canvases(&self) -> Vec { + self.open_canvases.read().clone() + } + /// Returns a [`CancellationToken`] that fires when this session shuts /// down (via [`Session::stop_event_loop`], [`Session::destroy`], or /// [`Drop`]). @@ -616,6 +636,119 @@ impl Drop for Session { } } +/// Canvas host sub-API for a [`Session`]. +/// +/// Acquired via [`Session::canvas`]. Methods route to `session.canvas.*` +/// RPCs, except [`list_open`](Self::list_open), which returns the SDK's local +/// snapshot from resume and calls made through this handle. +pub struct SessionCanvas<'a> { + session: &'a Session, +} + +impl<'a> SessionCanvas<'a> { + /// Lists canvases declared for the session. + pub async fn discover(&self) -> Result { + self.call_session_only("session.canvas.discover").await + } + + /// Lists currently open canvas instances for the live session. + pub async fn list_open(&self) -> Result { + let result: CanvasListOpenResult = + self.call_session_only("session.canvas.listOpen").await?; + *self.session.open_canvases.write() = result.open_canvases.clone(); + Ok(result) + } + + /// Opens a canvas instance for the given extension/canvas pair. + pub async fn open(&self, request: CanvasOpenRequest) -> Result { + let result: OpenCanvasInstance = self.call("session.canvas.open", &request).await?; + + let mut open_canvases = self.session.open_canvases.write(); + open_canvases.retain(|canvas| canvas.instance_id != result.instance_id); + open_canvases.push(result.clone()); + + Ok(result) + } + + /// Focuses a previously opened canvas instance. + pub async fn focus(&self, request: CanvasFocusRequest) -> Result<(), Error> { + self.call_unit("session.canvas.focus", &request).await + } + + /// Reloads a previously opened canvas instance. + pub async fn reload(&self, request: CanvasReloadRequest) -> Result<(), Error> { + self.call_unit("session.canvas.reload", &request).await + } + + /// Closes a previously opened canvas instance. + pub async fn close(&self, request: CanvasCloseRequest) -> Result<(), Error> { + let instance_id = request.instance_id.clone(); + self.call_unit("session.canvas.close", &request).await?; + self.session + .open_canvases + .write() + .retain(|canvas| canvas.instance_id != instance_id); + Ok(()) + } + + /// Invokes a declared agent action against an open canvas instance. + pub async fn invoke_action( + &self, + request: CanvasInvokeActionRequest, + ) -> Result { + self.call("session.canvas.invokeAction", &request).await + } + + async fn call(&self, method: &str, request: &T) -> Result + where + T: Serialize, + R: DeserializeOwned, + { + let params = self.params_with_session_id(request)?; + let result = self.session.client.call(method, Some(params)).await?; + Ok(serde_json::from_value(result)?) + } + + async fn call_unit(&self, method: &str, request: &T) -> Result<(), Error> + where + T: Serialize, + { + let params = self.params_with_session_id(request)?; + self.session.client.call(method, Some(params)).await?; + Ok(()) + } + + async fn call_session_only(&self, method: &str) -> Result + where + R: DeserializeOwned, + { + let result = self + .session + .client + .call( + method, + Some(serde_json::json!({ "sessionId": self.session.id })), + ) + .await?; + Ok(serde_json::from_value(result)?) + } + + fn params_with_session_id(&self, request: &T) -> Result + where + T: Serialize, + { + let mut params = serde_json::to_value(request)?; + let object = params + .as_object_mut() + .expect("serializing canvas request should produce an object"); + object.insert( + "sessionId".to_string(), + serde_json::to_value(&self.session.id)?, + ); + Ok(params) + } +} + /// UI sub-API for a [`Session`] — elicitation, confirmation, selection, /// and free-form input. /// @@ -808,6 +941,7 @@ impl Client { let commands_count = runtime.commands.as_ref().map_or(0, Vec::len); let has_hooks = hooks.is_some(); let command_handlers = build_command_handler_map(runtime.commands.as_deref()); + let canvas_registry = Arc::new(std::mem::take(&mut runtime.canvas_registry)); let session_fs_provider = runtime.session_fs_provider.take(); if self.inner.session_fs_configured && session_fs_provider.is_none() { return Err(Error::Session(SessionError::SessionFsProviderRequired)); @@ -840,6 +974,7 @@ impl Client { hooks, transforms, command_handlers, + canvas_registry, session_fs_provider, channels, idle_waiter.clone(), @@ -902,6 +1037,7 @@ impl Client { shutdown, idle_waiter, capabilities, + open_canvases: Arc::new(parking_lot::RwLock::new(Vec::new())), event_tx, }) } @@ -945,6 +1081,7 @@ impl Client { let commands_count = runtime.commands.as_ref().map_or(0, Vec::len); let has_hooks = hooks.is_some(); let command_handlers = build_command_handler_map(runtime.commands.as_deref()); + let canvas_registry = Arc::new(std::mem::take(&mut runtime.canvas_registry)); let session_fs_provider = runtime.session_fs_provider.take(); if self.inner.session_fs_configured && session_fs_provider.is_none() { return Err(Error::Session(SessionError::SessionFsProviderRequired)); @@ -977,6 +1114,7 @@ impl Client { hooks, transforms, command_handlers, + canvas_registry, session_fs_provider, channels, idle_waiter.clone(), @@ -1009,12 +1147,17 @@ impl Client { "Client::resume_session session resume request completed successfully" ); - // The CLI may reassign the session ID on resume. - let cli_session_id: SessionId = result - .get("sessionId") - .and_then(|v| v.as_str()) - .unwrap_or(&session_id) - .into(); + let resume_result: ResumeSessionResult = match serde_json::from_value(result) { + Ok(result) => result, + Err(error) => { + registration.cleanup(event_loop).await; + return Err(error.into()); + } + }; + let cli_session_id = resume_result + .session_id + .clone() + .unwrap_or_else(|| session_id.clone()); if cli_session_id != session_id { registration.cleanup(event_loop).await; return Err(Error::Session(SessionError::SessionIdMismatch { @@ -1023,19 +1166,6 @@ impl Client { })); } - let resume_capabilities: Option = result - .get("capabilities") - .and_then(|v| { - serde_json::from_value(v.clone()) - .map_err(|e| warn!(error = %e, "failed to deserialize capabilities from resume response")) - .ok() - }); - let remote_url = result - .get("remoteUrl") - .or_else(|| result.get("remote_url")) - .and_then(|value| value.as_str()) - .map(ToString::to_string); - // Reload skills after resume (best-effort). let skills_reload_start = Instant::now(); if let Err(e) = self @@ -1059,7 +1189,10 @@ impl Client { ); } - *capabilities.write() = resume_capabilities.unwrap_or_default(); + *capabilities.write() = resume_result.capabilities.unwrap_or_default(); + let open_canvases = Arc::new(parking_lot::RwLock::new( + resume_result.open_canvases.unwrap_or_default(), + )); tracing::debug!( elapsed_ms = total_start.elapsed().as_millis(), @@ -1070,13 +1203,14 @@ impl Client { Ok(Session { id: session_id, cwd: self.cwd().clone(), - workspace_path: None, - remote_url, + workspace_path: resume_result.workspace_path, + remote_url: resume_result.remote_url, client: self.clone(), event_loop: ParkingLotMutex::new(Some(event_loop)), shutdown, idle_waiter, capabilities, + open_canvases, event_tx, }) } @@ -1104,6 +1238,7 @@ fn spawn_event_loop( hooks: Option>, transforms: Option>, command_handlers: Arc, + canvas_registry: Arc, session_fs_provider: Option>, channels: crate::router::SessionChannels, idle_waiter: Arc>>, @@ -1138,9 +1273,15 @@ fn spawn_event_loop( ).await; } Some(request) = requests.recv() => { - handle_request( - &session_id, &client, &handlers, hooks.as_deref(), transforms.as_deref(), session_fs_provider.as_ref(), request, - ).await; + let ctx = RequestDispatchContext { + client: &client, + handlers: &handlers, + hooks: hooks.as_deref(), + transforms: transforms.as_deref(), + canvas_registry: &canvas_registry, + session_fs_provider: session_fs_provider.as_ref(), + }; + handle_request(&session_id, ctx, request).await; } else => break, } @@ -1688,17 +1829,28 @@ async fn handle_notification( } } +struct RequestDispatchContext<'a> { + client: &'a Client, + handlers: &'a SessionHandlers, + hooks: Option<&'a dyn SessionHooks>, + transforms: Option<&'a dyn SystemMessageTransform>, + canvas_registry: &'a CanvasRegistry, + session_fs_provider: Option<&'a Arc>, +} + /// Process a JSON-RPC request from the CLI. async fn handle_request( session_id: &SessionId, - client: &Client, - handlers: &SessionHandlers, - hooks: Option<&dyn SessionHooks>, - transforms: Option<&dyn SystemMessageTransform>, - session_fs_provider: Option<&Arc>, + ctx: RequestDispatchContext<'_>, request: crate::JsonRpcRequest, ) { let sid = session_id.clone(); + let client = ctx.client; + let handlers = ctx.handlers; + let hooks = ctx.hooks; + let transforms = ctx.transforms; + let canvas_registry = ctx.canvas_registry; + let session_fs_provider = ctx.session_fs_provider; if request.method.starts_with("sessionFs.") { crate::session_fs_dispatch::dispatch(client, session_fs_provider, request).await; @@ -1706,6 +1858,39 @@ async fn handle_request( } match request.method.as_str() { + "canvas.open" => { + let Some(params) = + parse_request_params::(client, request.id, &request) + .await + else { + return; + }; + let result = crate::canvas::dispatch_canvas_open(canvas_registry, params).await; + send_canvas_dispatch_response(client, request.id, result).await; + } + + method @ ("canvas.focus" | "canvas.reload" | "canvas.close") => { + let Some(params) = + parse_request_params::(client, request.id, &request) + .await + else { + return; + }; + let result = + crate::canvas::dispatch_canvas_lifecycle(canvas_registry, method, 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 = crate::canvas::dispatch_canvas_action(canvas_registry, params).await; + send_canvas_dispatch_response(client, request.id, result).await; + } + "hooks.invoke" => { let params = request.params.as_ref(); let hook_type = params @@ -1938,6 +2123,63 @@ 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, + })), + }), + }, + }; + let _ = client.send_response(&response).await; +} + async fn send_error_response( client: &Client, id: u64, diff --git a/rust/src/types.rs b/rust/src/types.rs index df5767aa4..5fe206179 100644 --- a/rust/src/types.rs +++ b/rust/src/types.rs @@ -12,6 +12,9 @@ use std::time::Duration; use serde::{Deserialize, Serialize}; use serde_json::Value; +use crate::canvas::{ + Canvas, CanvasDeclaration, CanvasRegistry, OpenCanvasInstance, build_registry, +}; use crate::handler::{ AutoModeSwitchHandler, ElicitationHandler, ExitPlanModeHandler, PermissionHandler, UserInputHandler, @@ -1088,6 +1091,12 @@ pub struct SessionConfig { pub system_message: Option, /// Client-defined tool declarations to expose to the agent. pub tools: Option>, + /// Canvas declarations this connection provides to the runtime. + pub canvases: Option>, + /// Request canvas renderer tools for this connection. + pub request_canvas_renderer: Option, + /// Request extension tools and dispatch for this connection. + pub request_extensions: Option, /// Allowlist of built-in tool names the agent may use. pub available_tools: Option>, /// Blocklist of built-in tool names the agent must not use. @@ -1211,6 +1220,9 @@ impl std::fmt::Debug for SessionConfig { .field("streaming", &self.streaming) .field("system_message", &self.system_message) .field("tools", &self.tools) + .field("canvases", &self.canvases) + .field("request_canvas_renderer", &self.request_canvas_renderer) + .field("request_extensions", &self.request_extensions) .field("available_tools", &self.available_tools) .field("excluded_tools", &self.excluded_tools) .field("mcp_servers", &self.mcp_servers) @@ -1290,6 +1302,9 @@ impl Default for SessionConfig { streaming: None, system_message: None, tools: None, + canvases: None, + request_canvas_renderer: None, + request_extensions: None, available_tools: None, excluded_tools: None, mcp_servers: None, @@ -1340,6 +1355,7 @@ pub(crate) struct SessionConfigRuntime { pub hooks_handler: Option>, pub system_message_transform: Option>, pub tool_handlers: HashMap>, + pub canvas_registry: CanvasRegistry, pub session_fs_provider: Option>, pub commands: Option>, } @@ -1390,6 +1406,15 @@ impl SessionConfig { }) .collect() }); + let wire_canvases: Option> = self + .canvases + .as_deref() + .map(|canvases| canvases.iter().map(|c| c.declaration().clone()).collect()); + let canvas_registry = self + .canvases + .as_deref() + .map(build_registry) + .unwrap_or_default(); let wire = crate::wire::SessionCreateWire { session_id, @@ -1399,6 +1424,9 @@ impl SessionConfig { streaming: self.streaming, system_message: self.system_message, tools: self.tools, + canvases: wire_canvases, + request_canvas_renderer: self.request_canvas_renderer, + request_extensions: self.request_extensions, available_tools: self.available_tools, excluded_tools: self.excluded_tools, mcp_servers: self.mcp_servers, @@ -1439,6 +1467,7 @@ impl SessionConfig { hooks_handler: self.hooks_handler, system_message_transform: self.system_message_transform, tool_handlers, + canvas_registry, session_fs_provider: self.session_fs_provider, commands: self.commands, }; @@ -1589,6 +1618,24 @@ impl SessionConfig { self } + /// Set canvas declarations and provider handlers for this connection. + pub fn with_canvases>(mut self, canvases: I) -> Self { + self.canvases = Some(canvases.into_iter().collect()); + self + } + + /// Request host canvas renderer tools for this connection. + pub fn with_request_canvas_renderer(mut self, request: bool) -> Self { + self.request_canvas_renderer = Some(request); + self + } + + /// Request extension tools and dispatch for this connection. + pub fn with_request_extensions(mut self, request: bool) -> Self { + self.request_extensions = Some(request); + self + } + /// Set the allowlist of built-in tool names the agent may use. pub fn with_available_tools(mut self, tools: I) -> Self where @@ -1773,6 +1820,12 @@ pub struct ResumeSessionConfig { pub system_message: Option, /// Client-defined tool declarations to re-supply on resume. pub tools: Option>, + /// Canvas declarations this connection provides to the runtime. + pub canvases: Option>, + /// Request canvas renderer tools for this connection. + pub request_canvas_renderer: Option, + /// Request extension tools and dispatch for this connection. + pub request_extensions: Option, /// Allowlist of tool names the agent may use. pub available_tools: Option>, /// Blocklist of built-in tool names. @@ -1874,6 +1927,9 @@ impl std::fmt::Debug for ResumeSessionConfig { .field("streaming", &self.streaming) .field("system_message", &self.system_message) .field("tools", &self.tools) + .field("canvases", &self.canvases) + .field("request_canvas_renderer", &self.request_canvas_renderer) + .field("request_extensions", &self.request_extensions) .field("available_tools", &self.available_tools) .field("excluded_tools", &self.excluded_tools) .field("mcp_servers", &self.mcp_servers) @@ -1980,6 +2036,15 @@ impl ResumeSessionConfig { }) .collect() }); + let wire_canvases: Option> = self + .canvases + .as_deref() + .map(|canvases| canvases.iter().map(|c| c.declaration().clone()).collect()); + let canvas_registry = self + .canvases + .as_deref() + .map(build_registry) + .unwrap_or_default(); let wire = crate::wire::SessionResumeWire { session_id: self.session_id, @@ -1988,6 +2053,9 @@ impl ResumeSessionConfig { streaming: self.streaming, system_message: self.system_message, tools: self.tools, + canvases: wire_canvases, + request_canvas_renderer: self.request_canvas_renderer, + request_extensions: self.request_extensions, available_tools: self.available_tools, excluded_tools: self.excluded_tools, mcp_servers: self.mcp_servers, @@ -2029,6 +2097,7 @@ impl ResumeSessionConfig { hooks_handler: self.hooks_handler, system_message_transform: self.system_message_transform, tool_handlers, + canvas_registry, session_fs_provider: self.session_fs_provider, commands: self.commands, }; @@ -2048,6 +2117,9 @@ impl ResumeSessionConfig { streaming: None, system_message: None, tools: None, + canvases: None, + request_canvas_renderer: None, + request_extensions: None, available_tools: None, excluded_tools: None, mcp_servers: None, @@ -2202,6 +2274,24 @@ impl ResumeSessionConfig { self } + /// Re-supply canvas declarations and provider handlers on resume. + pub fn with_canvases>(mut self, canvases: I) -> Self { + self.canvases = Some(canvases.into_iter().collect()); + self + } + + /// Request host canvas renderer tools for this connection on resume. + pub fn with_request_canvas_renderer(mut self, request: bool) -> Self { + self.request_canvas_renderer = Some(request); + self + } + + /// Request extension tools and dispatch for this connection on resume. + pub fn with_request_extensions(mut self, request: bool) -> Self { + self.request_extensions = Some(request); + self + } + /// Set the allowlist of tool names the agent may use. pub fn with_available_tools(mut self, tools: I) -> Self where @@ -2450,6 +2540,31 @@ pub struct CreateSessionResult { pub capabilities: Option, } +/// Response from `session.resume`. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct ResumeSessionResult { + /// The CLI-assigned session ID. Older runtimes may omit this on resume. + #[serde(default)] + pub session_id: Option, + /// Workspace directory for the session (infinite sessions). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub workspace_path: Option, + /// Remote session URL, if the session is running remotely. + #[serde(default, alias = "remote_url")] + pub remote_url: Option, + /// Capabilities negotiated with the CLI for this session. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub capabilities: Option, + /// Canvas instances already open when the session was resumed. + #[serde( + default, + alias = "openCanvasInstances", + skip_serializing_if = "Option::is_none" + )] + pub open_canvases: Option>, +} + /// Severity level for [`Session::log`](crate::session::Session::log) messages. #[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] @@ -3282,6 +3397,9 @@ pub struct UiCapabilities { /// Whether the host supports interactive elicitation dialogs. #[serde(skip_serializing_if = "Option::is_none")] pub elicitation: Option, + /// Host-specific canvas capabilities. + #[serde(skip_serializing_if = "Option::is_none")] + pub canvases: Option, } /// Options for the [`SessionUi::input`](crate::session::SessionUi::input) convenience method. diff --git a/rust/src/wire.rs b/rust/src/wire.rs index bc6af5651..40ced7119 100644 --- a/rust/src/wire.rs +++ b/rust/src/wire.rs @@ -18,6 +18,7 @@ use std::path::PathBuf; use serde::Serialize; +use crate::canvas::CanvasDeclaration; use crate::generated::api_types::{ModelCapabilitiesOverride, RemoteSessionMode}; use crate::types::{ CloudSessionOptions, CustomAgentConfig, DefaultAgentConfig, InfiniteSessionConfig, @@ -53,6 +54,12 @@ pub(crate) struct SessionCreateWire { #[serde(skip_serializing_if = "Option::is_none")] pub tools: Option>, #[serde(skip_serializing_if = "Option::is_none")] + pub canvases: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub request_canvas_renderer: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub request_extensions: Option, + #[serde(skip_serializing_if = "Option::is_none")] pub available_tools: Option>, #[serde(skip_serializing_if = "Option::is_none")] pub excluded_tools: Option>, @@ -119,6 +126,12 @@ pub(crate) struct SessionResumeWire { #[serde(skip_serializing_if = "Option::is_none")] pub tools: Option>, #[serde(skip_serializing_if = "Option::is_none")] + pub canvases: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub request_canvas_renderer: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub request_extensions: Option, + #[serde(skip_serializing_if = "Option::is_none")] pub available_tools: Option>, #[serde(skip_serializing_if = "Option::is_none")] pub excluded_tools: Option>, diff --git a/rust/tests/e2e/elicitation.rs b/rust/tests/e2e/elicitation.rs index 5d38ee132..7f8ab3bed 100644 --- a/rust/tests/e2e/elicitation.rs +++ b/rust/tests/e2e/elicitation.rs @@ -383,6 +383,7 @@ async fn session_capabilities_types_are_properly_structured() { let capabilities = github_copilot_sdk::SessionCapabilities { ui: Some(UiCapabilities { elicitation: Some(true), + canvases: None, }), }; diff --git a/rust/tests/session_test.rs b/rust/tests/session_test.rs index ed3698951..53ed231cb 100644 --- a/rust/tests/session_test.rs +++ b/rust/tests/session_test.rs @@ -6,6 +6,11 @@ use std::sync::atomic::{AtomicUsize, Ordering}; use std::time::Duration; use async_trait::async_trait; +use github_copilot_sdk::canvas::{ + Canvas, CanvasActionContext, CanvasCloseRequest, CanvasDeclaration, CanvasHandler, + CanvasInvokeActionRequest, CanvasOpenContext, CanvasOpenRequest, CanvasOpenResponse, + CanvasResult, +}; use github_copilot_sdk::handler::{ ApproveAllHandler, AutoModeSwitchHandler, AutoModeSwitchResponse, ElicitationHandler, ExitPlanModeHandler, ExitPlanModeResult, UserInputHandler, UserInputResponse, @@ -22,6 +27,36 @@ use tokio::time::timeout; const TIMEOUT: Duration = Duration::from_secs(2); +struct TestCanvasHandler; + +#[async_trait] +impl CanvasHandler for TestCanvasHandler { + async fn on_open(&self, ctx: CanvasOpenContext) -> CanvasResult { + Ok(CanvasOpenResponse { + url: Some(format!("https://example.test/{}", ctx.canvas_id)), + title: Some("Test Canvas".to_string()), + status: Some("ready".to_string()), + toolbar: None, + tools: None, + }) + } + + async fn on_action(&self, ctx: CanvasActionContext) -> CanvasResult { + Ok(serde_json::json!({ + "actionName": ctx.action_name, + "input": ctx.input, + })) + } +} + +fn test_canvas(id: &str) -> Canvas { + Canvas::builder( + CanvasDeclaration::new(id, "Test Canvas").with_description("Test canvas description"), + ) + .handler(Arc::new(TestCanvasHandler)) + .build() +} + async fn write_framed(writer: &mut (impl AsyncWrite + Unpin), body: &[u8]) { let header = format!("Content-Length: {}\r\n\r\n", body.len()); writer.write_all(header.as_bytes()).await.unwrap(); @@ -289,6 +324,186 @@ async fn create_session_sends_correct_rpc() { assert_eq!(session.workspace_path(), Some(Path::new("/ws"))); } +#[tokio::test] +async fn create_session_sends_canvas_wire_fields() { + let (client, mut server_read, mut server_write) = make_client(); + + let create_handle = tokio::spawn({ + let client = client.clone(); + async move { + client + .create_session( + SessionConfig::default() + .with_canvases([test_canvas("counter")]) + .with_request_canvas_renderer(true) + .with_request_extensions(true), + ) + .await + .unwrap() + } + }); + + let request = read_framed(&mut server_read).await; + assert_eq!(request["method"], "session.create"); + assert_eq!(request["params"]["canvases"][0]["id"], "counter"); + assert_eq!( + request["params"]["canvases"][0]["displayName"], + "Test Canvas" + ); + assert_eq!(request["params"]["requestCanvasRenderer"], true); + assert_eq!(request["params"]["requestExtensions"], true); + + let id = request["id"].as_u64().unwrap(); + let session_id = requested_session_id(&request).to_string(); + let response = serde_json::json!({ + "jsonrpc": "2.0", + "id": id, + "result": { "sessionId": session_id }, + }); + write_framed(&mut server_write, &serde_json::to_vec(&response).unwrap()).await; + + timeout(TIMEOUT, create_handle).await.unwrap().unwrap(); +} + +#[tokio::test] +async fn provider_canvas_dispatch_routes_direct_canvas_action_requests() { + let (session, mut server) = + create_session_pair_with_config(|cfg| cfg.with_canvases([test_canvas("counter")])).await; + + server + .send_request( + 42, + "canvas.action.invoke", + serde_json::json!({ + "sessionId": session.id(), + "extensionId": "project:counter", + "canvasId": "counter", + "instanceId": "counter-1", + "actionName": "increment", + "input": { "amount": 1 } + }), + ) + .await; + + let response = timeout(TIMEOUT, server.read_response()).await.unwrap(); + assert_eq!(response["id"], 42); + assert_eq!(response["result"]["actionName"], "increment"); + assert_eq!(response["result"]["input"]["amount"], 1); +} + +#[tokio::test] +async fn session_canvas_host_api_sends_requests_and_tracks_open_instances() { + let (session, mut server) = create_session_pair().await; + + let open_handle = tokio::spawn({ + async move { + let opened = session + .canvas() + .open(CanvasOpenRequest { + extension_id: "project:counter".to_string(), + canvas_id: "counter".to_string(), + instance_id: "counter-1".to_string(), + input: Some(serde_json::json!({ "seed": 1 })), + }) + .await + .unwrap(); + let listed = session.canvas().list_open().await.unwrap(); + let invoked = session + .canvas() + .invoke_action(CanvasInvokeActionRequest { + instance_id: opened.instance_id.clone(), + action_name: "increment".to_string(), + input: Some(serde_json::json!({ "amount": 1 })), + }) + .await + .unwrap(); + session + .canvas() + .close(CanvasCloseRequest { + instance_id: opened.instance_id.clone(), + }) + .await + .unwrap(); + let listed_after_close = session.canvas().list_open().await.unwrap(); + (opened, listed, invoked, listed_after_close) + } + }); + + let open_request = server.read_request().await; + assert_eq!(open_request["method"], "session.canvas.open"); + assert_eq!(open_request["params"]["sessionId"], server.session_id); + assert_eq!(open_request["params"]["extensionId"], "project:counter"); + assert_eq!(open_request["params"]["canvasId"], "counter"); + assert_eq!(open_request["params"]["instanceId"], "counter-1"); + assert_eq!(open_request["params"]["input"]["seed"], 1); + server + .respond( + &open_request, + serde_json::json!({ + "instanceId": "counter-1", + "extensionId": "project:counter", + "canvasId": "counter", + "url": "https://example.test/counter", + "reopen": false + }), + ) + .await; + + let list_request = server.read_request().await; + assert_eq!(list_request["method"], "session.canvas.listOpen"); + server + .respond( + &list_request, + serde_json::json!({ + "openCanvases": [{ + "instanceId": "counter-1", + "extensionId": "project:counter", + "canvasId": "counter", + "url": "https://example.test/counter", + "reopen": false + }] + }), + ) + .await; + + let invoke_request = server.read_request().await; + assert_eq!(invoke_request["method"], "session.canvas.invokeAction"); + assert_eq!(invoke_request["params"]["instanceId"], "counter-1"); + assert_eq!(invoke_request["params"]["actionName"], "increment"); + assert_eq!(invoke_request["params"]["input"]["amount"], 1); + server + .respond( + &invoke_request, + serde_json::json!({ "result": { "count": 2 } }), + ) + .await; + + let close_request = server.read_request().await; + assert_eq!(close_request["method"], "session.canvas.close"); + assert_eq!(close_request["params"]["instanceId"], "counter-1"); + server.respond(&close_request, serde_json::json!({})).await; + + let list_after_close_request = server.read_request().await; + assert_eq!( + list_after_close_request["method"], + "session.canvas.listOpen" + ); + server + .respond( + &list_after_close_request, + serde_json::json!({ "openCanvases": [] }), + ) + .await; + + let (opened, listed, invoked, listed_after_close) = + timeout(TIMEOUT, open_handle).await.unwrap().unwrap(); + assert_eq!(opened.instance_id, "counter-1"); + assert_eq!(listed.open_canvases.len(), 1); + assert_eq!(listed.open_canvases[0].instance_id, "counter-1"); + assert_eq!(invoked.result.unwrap()["count"], 2); + assert!(listed_after_close.open_canvases.is_empty()); +} + #[tokio::test] async fn send_injects_session_id() { let (session, mut server) = create_session_pair().await; @@ -2380,6 +2595,63 @@ async fn env_value_mode_hardcoded_direct_on_create_and_resume() { timeout(TIMEOUT, resume_handle).await.unwrap().unwrap(); } +#[tokio::test] +async fn resume_session_sends_canvas_fields_and_captures_open_canvases() { + use github_copilot_sdk::types::ResumeSessionConfig; + + let (client, mut server_read, mut server_write) = make_client(); + let resume_handle = tokio::spawn({ + let client = client.clone(); + async move { + let cfg = ResumeSessionConfig::new(SessionId::from("canvas-resume")) + .with_canvases([test_canvas("counter")]) + .with_request_canvas_renderer(true) + .with_request_extensions(true); + client.resume_session(cfg).await.unwrap() + } + }); + + let request = read_framed(&mut server_read).await; + assert_eq!(request["method"], "session.resume"); + assert_eq!(request["params"]["canvases"][0]["id"], "counter"); + assert_eq!(request["params"]["requestCanvasRenderer"], true); + assert_eq!(request["params"]["requestExtensions"], true); + assert!(request["params"].get("openCanvasInstances").is_none()); + + let id = request["id"].as_u64().unwrap(); + let response = serde_json::json!({ + "jsonrpc": "2.0", + "id": id, + "result": { + "sessionId": "canvas-resume", + "openCanvases": [{ + "extensionId": "project:counter", + "canvasId": "counter", + "instanceId": "counter-1", + "url": "https://example.test/counter", + "reopen": false + }], + "capabilities": { + "ui": { "canvases": true } + } + }, + }); + write_framed(&mut server_write, &serde_json::to_vec(&response).unwrap()).await; + + let reload = read_framed(&mut server_read).await; + assert_eq!(reload["method"], "session.skills.reload"); + let id = reload["id"].as_u64().unwrap(); + let response = serde_json::json!({ "jsonrpc": "2.0", "id": id, "result": {} }); + write_framed(&mut server_write, &serde_json::to_vec(&response).unwrap()).await; + + let session = timeout(TIMEOUT, resume_handle).await.unwrap().unwrap(); + let open = session.open_canvases(); + assert_eq!(open.len(), 1); + assert_eq!(open[0].instance_id, "counter-1"); + let caps = session.capabilities(); + assert_eq!(caps.ui.unwrap().canvases, Some(true)); +} + #[tokio::test] async fn elicitation_methods_fail_without_capability() { let (session, _server) = create_session_pair().await; From 4d5bd112c1c066509174c8b5ec1ceef9848812db Mon Sep 17 00:00:00 2001 From: jmoseley Date: Fri, 22 May 2026 18:49:37 -0700 Subject: [PATCH 02/29] Add canvas provider RPC tracing Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- rust/src/jsonrpc.rs | 5 +++++ rust/src/router.rs | 6 ++++++ rust/src/session.rs | 33 ++++++++++++++++++++++++++++++++- 3 files changed, 43 insertions(+), 1 deletion(-) diff --git a/rust/src/jsonrpc.rs b/rust/src/jsonrpc.rs index 88a9670cd..cf8d46cdf 100644 --- a/rust/src/jsonrpc.rs +++ b/rust/src/jsonrpc.rs @@ -306,6 +306,11 @@ impl JsonRpcClient { let _ = notification_tx.send(notification); } JsonRpcMessage::Request(request) => { + debug!( + request_id = request.id, + method = %request.method, + "received JSON-RPC request from runtime" + ); if request_tx.send(request).is_err() { warn!("failed to forward JSON-RPC request, channel closed"); } diff --git a/rust/src/router.rs b/rust/src/router.rs index e14630e03..6714b0f98 100644 --- a/rust/src/router.rs +++ b/rust/src/router.rs @@ -157,6 +157,12 @@ impl SessionRouter { guard.get(sid).map(|s| s.requests.clone()) }; if let Some(sender) = sender { + tracing::debug!( + session_id = sid, + request_id = request.id, + method = %request.method, + "routing JSON-RPC request to registered session" + ); let _ = sender.send(request); } else { warn!( diff --git a/rust/src/session.rs b/rust/src/session.rs index 7697a6361..f08d607a1 100644 --- a/rust/src/session.rs +++ b/rust/src/session.rs @@ -1859,6 +1859,11 @@ async fn handle_request( match request.method.as_str() { "canvas.open" => { + tracing::debug!( + session_id = %sid, + request_id = request.id, + "handling canvas.open provider request" + ); let Some(params) = parse_request_params::(client, request.id, &request) .await @@ -1870,6 +1875,12 @@ async fn handle_request( } method @ ("canvas.focus" | "canvas.reload" | "canvas.close") => { + tracing::debug!( + session_id = %sid, + request_id = request.id, + method = method, + "handling canvas lifecycle provider request" + ); let Some(params) = parse_request_params::(client, request.id, &request) .await @@ -1882,6 +1893,11 @@ async fn handle_request( } "canvas.action.invoke" => { + tracing::debug!( + session_id = %sid, + request_id = request.id, + "handling canvas.action.invoke provider request" + ); let Some(params) = parse_request_params::(client, request.id, &request).await else { @@ -2177,7 +2193,22 @@ async fn send_canvas_dispatch_response( }), }, }; - let _ = client.send_response(&response).await; + match client.send_response(&response).await { + Ok(()) => { + tracing::debug!( + request_id = id, + success = response.error.is_none(), + "sent canvas provider response" + ); + } + Err(error) => { + warn!( + request_id = id, + error = %error, + "failed to send canvas provider response" + ); + } + } } async fn send_error_response( From 36397a85a1352822013dc7ce000fae004e4ef6e4 Mon Sep 17 00:00:00 2001 From: jmoseley Date: Fri, 22 May 2026 20:11:33 -0700 Subject: [PATCH 03/29] Add extension info session option Expose stable extension identity metadata on Node and Rust session create/resume options and forward extensionInfo on the wire for canvas providers. Validation: nodejs typecheck/lint/vitest; rust fmt/clippy/test --all-features. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- nodejs/src/client.ts | 2 ++ nodejs/src/extension.ts | 3 ++ nodejs/src/index.ts | 1 + nodejs/src/types.ts | 17 +++++++++++ nodejs/test/client.test.ts | 10 +++++++ rust/src/types.rs | 58 ++++++++++++++++++++++++++++++++++++-- rust/src/wire.rs | 8 ++++-- rust/tests/session_test.rs | 20 ++++++++++--- 8 files changed, 110 insertions(+), 9 deletions(-) diff --git a/nodejs/src/client.ts b/nodejs/src/client.ts index 1bb910c6a..73aa7514b 100644 --- a/nodejs/src/client.ts +++ b/nodejs/src/client.ts @@ -859,6 +859,7 @@ export class CopilotClient { canvases: config.canvases?.map((canvas) => canvas.declaration), requestCanvasRenderer: config.requestCanvasRenderer, requestExtensions: config.requestExtensions, + extensionInfo: config.extensionInfo, commands: config.commands?.map((cmd) => ({ name: cmd.name, description: cmd.description, @@ -1001,6 +1002,7 @@ export class CopilotClient { canvases: config.canvases?.map((canvas) => canvas.declaration), requestCanvasRenderer: config.requestCanvasRenderer, requestExtensions: config.requestExtensions, + extensionInfo: config.extensionInfo, commands: config.commands?.map((cmd) => ({ name: cmd.name, description: cmd.description, diff --git a/nodejs/src/extension.ts b/nodejs/src/extension.ts index 88faefbe5..3a1cb2ad1 100644 --- a/nodejs/src/extension.ts +++ b/nodejs/src/extension.ts @@ -6,6 +6,7 @@ import { CopilotClient } from "./client.js"; import type { CopilotSession } from "./session.js"; import { defaultJoinSessionPermissionHandler, + type ExtensionInfo, type PermissionHandler, type ResumeSessionConfig, } from "./types.js"; @@ -31,6 +32,8 @@ export type JoinSessionConfig = Omit onPermissionRequest?: PermissionHandler; }; +export type { ExtensionInfo }; + /** * Joins the current foreground session. * diff --git a/nodejs/src/index.ts b/nodejs/src/index.ts index 31001ab79..eae56a28c 100644 --- a/nodejs/src/index.ts +++ b/nodejs/src/index.ts @@ -71,6 +71,7 @@ export type { ExitPlanModeHandler, ExitPlanModeRequest, ExitPlanModeResult, + ExtensionInfo, ForegroundSessionInfo, GetAuthStatusResponse, GetStatusResponse, diff --git a/nodejs/src/types.ts b/nodejs/src/types.ts index d5054f77e..3454ec2a1 100644 --- a/nodejs/src/types.ts +++ b/nodejs/src/types.ts @@ -1412,6 +1412,16 @@ export interface InfiniteSessionConfig { */ export type ReasoningEffort = "low" | "medium" | "high" | "xhigh"; +/** + * Stable extension identity for session participants that provide canvases. + */ +export interface ExtensionInfo { + /** Extension namespace/source, e.g. "github-app". */ + source: string; + /** Stable provider name within the source namespace. */ + name: string; +} + /** * Shared configuration fields used by both {@link SessionConfig} (for * creating a new session) and {@link ResumeSessionConfig} (for resuming @@ -1490,6 +1500,13 @@ export interface SessionConfigBase { */ requestExtensions?: boolean; + /** + * Stable extension identity for canvas providers on this connection. When + * set, the runtime uses `${source}:${name}` as the agent-facing extension + * id instead of a reconnect-specific connection id. + */ + extensionInfo?: ExtensionInfo; + /** * Slash commands registered for this session. * When the CLI has a TUI, each command appears as `/name` for the user to invoke. diff --git a/nodejs/test/client.test.ts b/nodejs/test/client.test.ts index 26fc28dcc..69bc49699 100644 --- a/nodejs/test/client.test.ts +++ b/nodejs/test/client.test.ts @@ -47,6 +47,7 @@ describe("CopilotClient", () => { canvases: [canvas], requestCanvasRenderer: true, requestExtensions: true, + extensionInfo: { source: "github-app", name: "counter-provider" }, }); const payload = spy.mock.calls.find(([method]) => method === "session.create")![1] as any; @@ -60,6 +61,10 @@ describe("CopilotClient", () => { ]); expect(payload.requestCanvasRenderer).toBe(true); expect(payload.requestExtensions).toBe(true); + expect(payload.extensionInfo).toEqual({ + source: "github-app", + name: "counter-provider", + }); }); it("forwards canvas declarations in session.resume", async () => { @@ -85,12 +90,17 @@ describe("CopilotClient", () => { canvases: [canvas], requestCanvasRenderer: true, requestExtensions: true, + extensionInfo: { source: "github-app", name: "counter-provider" }, }); const payload = spy.mock.calls.find(([method]) => method === "session.resume")![1] as any; expect(payload.canvases).toEqual([expect.objectContaining({ id: "counter" })]); expect(payload.requestCanvasRenderer).toBe(true); expect(payload.requestExtensions).toBe(true); + expect(payload.extensionInfo).toEqual({ + source: "github-app", + name: "counter-provider", + }); expect(payload.openCanvasInstances).toBeUndefined(); }); diff --git a/rust/src/types.rs b/rust/src/types.rs index 5fe206179..342bbe60f 100644 --- a/rust/src/types.rs +++ b/rust/src/types.rs @@ -776,6 +776,26 @@ impl CloudSessionOptions { } } +/// Stable extension identity for session participants that provide canvases. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct ExtensionInfo { + /// Extension namespace/source, e.g. `"github-app"`. + pub source: String, + /// Stable provider name within the source namespace. + pub name: String, +} + +impl ExtensionInfo { + /// Create stable extension identity metadata. + pub fn new(source: impl Into, name: impl Into) -> Self { + Self { + source: source.into(), + name: name.into(), + } + } +} + /// Configuration for a single MCP server. /// /// MCP (Model Context Protocol) servers expose external tools to the @@ -1097,6 +1117,8 @@ pub struct SessionConfig { pub request_canvas_renderer: Option, /// Request extension tools and dispatch for this connection. pub request_extensions: Option, + /// Stable extension identity for canvas/tool providers on this connection. + pub extension_info: Option, /// Allowlist of built-in tool names the agent may use. pub available_tools: Option>, /// Blocklist of built-in tool names the agent must not use. @@ -1223,6 +1245,7 @@ impl std::fmt::Debug for SessionConfig { .field("canvases", &self.canvases) .field("request_canvas_renderer", &self.request_canvas_renderer) .field("request_extensions", &self.request_extensions) + .field("extension_info", &self.extension_info) .field("available_tools", &self.available_tools) .field("excluded_tools", &self.excluded_tools) .field("mcp_servers", &self.mcp_servers) @@ -1305,6 +1328,7 @@ impl Default for SessionConfig { canvases: None, request_canvas_renderer: None, request_extensions: None, + extension_info: None, available_tools: None, excluded_tools: None, mcp_servers: None, @@ -1427,6 +1451,7 @@ impl SessionConfig { canvases: wire_canvases, request_canvas_renderer: self.request_canvas_renderer, request_extensions: self.request_extensions, + extension_info: self.extension_info, available_tools: self.available_tools, excluded_tools: self.excluded_tools, mcp_servers: self.mcp_servers, @@ -1636,6 +1661,12 @@ impl SessionConfig { self } + /// Set stable extension identity metadata for this connection. + pub fn with_extension_info(mut self, extension_info: ExtensionInfo) -> Self { + self.extension_info = Some(extension_info); + self + } + /// Set the allowlist of built-in tool names the agent may use. pub fn with_available_tools(mut self, tools: I) -> Self where @@ -1826,6 +1857,8 @@ pub struct ResumeSessionConfig { pub request_canvas_renderer: Option, /// Request extension tools and dispatch for this connection. pub request_extensions: Option, + /// Stable extension identity for canvas/tool providers on this connection. + pub extension_info: Option, /// Allowlist of tool names the agent may use. pub available_tools: Option>, /// Blocklist of built-in tool names. @@ -1930,6 +1963,7 @@ impl std::fmt::Debug for ResumeSessionConfig { .field("canvases", &self.canvases) .field("request_canvas_renderer", &self.request_canvas_renderer) .field("request_extensions", &self.request_extensions) + .field("extension_info", &self.extension_info) .field("available_tools", &self.available_tools) .field("excluded_tools", &self.excluded_tools) .field("mcp_servers", &self.mcp_servers) @@ -2056,6 +2090,7 @@ impl ResumeSessionConfig { canvases: wire_canvases, request_canvas_renderer: self.request_canvas_renderer, request_extensions: self.request_extensions, + extension_info: self.extension_info, available_tools: self.available_tools, excluded_tools: self.excluded_tools, mcp_servers: self.mcp_servers, @@ -2120,6 +2155,7 @@ impl ResumeSessionConfig { canvases: None, request_canvas_renderer: None, request_extensions: None, + extension_info: None, available_tools: None, excluded_tools: None, mcp_servers: None, @@ -2292,6 +2328,12 @@ impl ResumeSessionConfig { self } + /// Set stable extension identity metadata for this connection on resume. + pub fn with_extension_info(mut self, extension_info: ExtensionInfo) -> Self { + self.extension_info = Some(extension_info); + self + } + /// Set the allowlist of tool names the agent may use. pub fn with_available_tools(mut self, tools: I) -> Self where @@ -3550,7 +3592,7 @@ mod tests { use super::{ Attachment, AttachmentLineRange, AttachmentSelectionPosition, AttachmentSelectionRange, - ConnectionState, CustomAgentConfig, DeliveryMode, GitHubReferenceType, + ConnectionState, CustomAgentConfig, DeliveryMode, ExtensionInfo, GitHubReferenceType, InfiniteSessionConfig, ProviderConfig, ResumeSessionConfig, SessionConfig, SessionEvent, SessionId, SystemMessageConfig, Tool, ToolBinaryResult, ToolResult, ToolResultExpanded, ToolResultResponse, ensure_attachment_display_names, @@ -3795,7 +3837,8 @@ mod tests { .with_working_directory(PathBuf::from("/tmp/work")) .with_github_token("ghp_test") .with_enable_session_telemetry(false) - .with_include_sub_agent_streaming_events(false); + .with_include_sub_agent_streaming_events(false) + .with_extension_info(ExtensionInfo::new("github-app", "counter")); assert_eq!(cfg.session_id.as_ref().map(|s| s.as_str()), Some("sess-1")); assert_eq!(cfg.model.as_deref(), Some("claude-sonnet-4")); @@ -3827,6 +3870,10 @@ mod tests { assert_eq!(cfg.github_token.as_deref(), Some("ghp_test")); assert_eq!(cfg.enable_session_telemetry, Some(false)); assert_eq!(cfg.include_sub_agent_streaming_events, Some(false)); + assert_eq!( + cfg.extension_info, + Some(ExtensionInfo::new("github-app", "counter")) + ); } #[test] @@ -3850,7 +3897,8 @@ mod tests { .with_enable_session_telemetry(false) .with_include_sub_agent_streaming_events(true) .with_suppress_resume_event(true) - .with_continue_pending_work(true); + .with_continue_pending_work(true) + .with_extension_info(ExtensionInfo::new("github-app", "counter")); assert_eq!(cfg.session_id.as_str(), "sess-2"); assert_eq!(cfg.client_name.as_deref(), Some("test-app")); @@ -3882,6 +3930,10 @@ mod tests { assert_eq!(cfg.include_sub_agent_streaming_events, Some(true)); assert_eq!(cfg.suppress_resume_event, Some(true)); assert_eq!(cfg.continue_pending_work, Some(true)); + assert_eq!( + cfg.extension_info, + Some(ExtensionInfo::new("github-app", "counter")) + ); } /// `continue_pending_work` must serialize to wire as `continuePendingWork` diff --git a/rust/src/wire.rs b/rust/src/wire.rs index 40ced7119..a1cadf318 100644 --- a/rust/src/wire.rs +++ b/rust/src/wire.rs @@ -21,8 +21,8 @@ use serde::Serialize; use crate::canvas::CanvasDeclaration; use crate::generated::api_types::{ModelCapabilitiesOverride, RemoteSessionMode}; use crate::types::{ - CloudSessionOptions, CustomAgentConfig, DefaultAgentConfig, InfiniteSessionConfig, - McpServerConfig, ProviderConfig, SessionId, SystemMessageConfig, Tool, + CloudSessionOptions, CustomAgentConfig, DefaultAgentConfig, ExtensionInfo, + InfiniteSessionConfig, McpServerConfig, ProviderConfig, SessionId, SystemMessageConfig, Tool, }; /// Wire representation of a slash command (name + description only). The @@ -60,6 +60,8 @@ pub(crate) struct SessionCreateWire { #[serde(skip_serializing_if = "Option::is_none")] pub request_extensions: Option, #[serde(skip_serializing_if = "Option::is_none")] + pub extension_info: Option, + #[serde(skip_serializing_if = "Option::is_none")] pub available_tools: Option>, #[serde(skip_serializing_if = "Option::is_none")] pub excluded_tools: Option>, @@ -132,6 +134,8 @@ pub(crate) struct SessionResumeWire { #[serde(skip_serializing_if = "Option::is_none")] pub request_extensions: Option, #[serde(skip_serializing_if = "Option::is_none")] + pub extension_info: Option, + #[serde(skip_serializing_if = "Option::is_none")] pub available_tools: Option>, #[serde(skip_serializing_if = "Option::is_none")] pub excluded_tools: Option>, diff --git a/rust/tests/session_test.rs b/rust/tests/session_test.rs index 53ed231cb..cc3341a55 100644 --- a/rust/tests/session_test.rs +++ b/rust/tests/session_test.rs @@ -17,8 +17,8 @@ use github_copilot_sdk::handler::{ }; use github_copilot_sdk::types::{ CommandContext, CommandDefinition, CommandHandler, DeliveryMode, ElicitationRequest, - ElicitationResult, ExitPlanModeData, MessageOptions, RequestId, SessionConfig, SessionId, Tool, - ToolInvocation, ToolResult, + ElicitationResult, ExitPlanModeData, ExtensionInfo, MessageOptions, RequestId, SessionConfig, + SessionId, Tool, ToolInvocation, ToolResult, }; use github_copilot_sdk::{Client, tool}; use serde_json::Value; @@ -336,7 +336,8 @@ async fn create_session_sends_canvas_wire_fields() { SessionConfig::default() .with_canvases([test_canvas("counter")]) .with_request_canvas_renderer(true) - .with_request_extensions(true), + .with_request_extensions(true) + .with_extension_info(ExtensionInfo::new("github-app", "counter-provider")), ) .await .unwrap() @@ -352,6 +353,11 @@ async fn create_session_sends_canvas_wire_fields() { ); assert_eq!(request["params"]["requestCanvasRenderer"], true); assert_eq!(request["params"]["requestExtensions"], true); + assert_eq!(request["params"]["extensionInfo"]["source"], "github-app"); + assert_eq!( + request["params"]["extensionInfo"]["name"], + "counter-provider" + ); let id = request["id"].as_u64().unwrap(); let session_id = requested_session_id(&request).to_string(); @@ -2606,7 +2612,8 @@ async fn resume_session_sends_canvas_fields_and_captures_open_canvases() { let cfg = ResumeSessionConfig::new(SessionId::from("canvas-resume")) .with_canvases([test_canvas("counter")]) .with_request_canvas_renderer(true) - .with_request_extensions(true); + .with_request_extensions(true) + .with_extension_info(ExtensionInfo::new("github-app", "counter-provider")); client.resume_session(cfg).await.unwrap() } }); @@ -2616,6 +2623,11 @@ async fn resume_session_sends_canvas_fields_and_captures_open_canvases() { assert_eq!(request["params"]["canvases"][0]["id"], "counter"); assert_eq!(request["params"]["requestCanvasRenderer"], true); assert_eq!(request["params"]["requestExtensions"], true); + assert_eq!(request["params"]["extensionInfo"]["source"], "github-app"); + assert_eq!( + request["params"]["extensionInfo"]["name"], + "counter-provider" + ); assert!(request["params"].get("openCanvasInstances").is_none()); let id = request["id"].as_u64().unwrap(); From 458c6f3ddd201bb6e8a342ccdf21afbb2bab3535 Mon Sep 17 00:00:00 2001 From: jmoseley Date: Fri, 22 May 2026 22:08:37 -0700 Subject: [PATCH 04/29] Expose canvas resume durability fields Add CanvasInstanceAvailability, OpenCanvasInstance availability, and resume openCanvases seeding support to the Rust SDK. Validation: cargo +nightly-2026-04-14 fmt --check; cargo clippy --all-features --all-targets -- -D warnings; cargo test --all-features. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- rust/src/canvas.rs | 81 ++++++++++++++++++++++++++++++++++++++ rust/src/types.rs | 14 +++++++ rust/src/wire.rs | 4 +- rust/tests/session_test.rs | 29 +++++++++++--- 4 files changed, 122 insertions(+), 6 deletions(-) diff --git a/rust/src/canvas.rs b/rust/src/canvas.rs index 3d2fe41bc..e8863b101 100644 --- a/rust/src/canvas.rs +++ b/rust/src/canvas.rs @@ -48,6 +48,18 @@ pub enum CanvasToolDefinitionDefer { Never, } +/// Runtime-controlled routing state for an open canvas instance. +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "lowercase")] +pub enum CanvasInstanceAvailability { + /// The owning provider is currently connected and routing calls will be dispatched normally. + Ready, + /// The owning provider is not currently connected; routing calls fail with + /// `canvas_provider_unavailable` until the agent re-issues `open_canvas` or + /// the provider reconnects. + Stale, +} + /// Declarative metadata for a single canvas, sent over the wire on /// `session.create` / `session.resume`. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] @@ -177,6 +189,75 @@ pub struct OpenCanvasInstance { pub input: Option, /// Whether this snapshot came from an idempotent reopen. pub reopen: bool, + /// Runtime-controlled routing state for this instance. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub availability: Option, +} + +impl OpenCanvasInstance { + /// Construct an open canvas instance snapshot with the required fields set. + pub fn new( + instance_id: impl Into, + extension_id: impl Into, + canvas_id: impl Into, + ) -> Self { + Self { + instance_id: instance_id.into(), + extension_id: extension_id.into(), + extension_name: None, + canvas_id: canvas_id.into(), + title: None, + status: None, + url: None, + toolbar: None, + tools: None, + input: None, + reopen: false, + availability: None, + } + } + + /// Set the owning extension display name. + pub fn with_extension_name(mut self, extension_name: impl Into) -> Self { + self.extension_name = Some(extension_name.into()); + self + } + + /// Set the rendered title. + pub fn with_title(mut self, title: impl Into) -> Self { + self.title = Some(title.into()); + self + } + + /// Set the provider-supplied status text. + pub fn with_status(mut self, status: impl Into) -> Self { + self.status = Some(status.into()); + self + } + + /// Set the URL for web-rendered canvases. + pub fn with_url(mut self, url: impl Into) -> Self { + self.url = Some(url.into()); + self + } + + /// Set the input supplied when the instance was opened. + pub fn with_input(mut self, input: Value) -> Self { + self.input = Some(input); + self + } + + /// Set whether this snapshot came from an idempotent reopen. + pub fn with_reopen(mut self, reopen: bool) -> Self { + self.reopen = reopen; + self + } + + /// Set the runtime-controlled routing availability. + pub fn with_availability(mut self, availability: CanvasInstanceAvailability) -> Self { + self.availability = Some(availability); + self + } } /// Result returned by [`SessionCanvas::discover`](crate::session::SessionCanvas::discover). diff --git a/rust/src/types.rs b/rust/src/types.rs index 342bbe60f..a7b4d129e 100644 --- a/rust/src/types.rs +++ b/rust/src/types.rs @@ -1853,6 +1853,8 @@ pub struct ResumeSessionConfig { pub tools: Option>, /// Canvas declarations this connection provides to the runtime. pub canvases: Option>, + /// Open canvas instances the caller knows were open before this resume. + pub open_canvases: Option>, /// Request canvas renderer tools for this connection. pub request_canvas_renderer: Option, /// Request extension tools and dispatch for this connection. @@ -1961,6 +1963,7 @@ impl std::fmt::Debug for ResumeSessionConfig { .field("system_message", &self.system_message) .field("tools", &self.tools) .field("canvases", &self.canvases) + .field("open_canvases", &self.open_canvases) .field("request_canvas_renderer", &self.request_canvas_renderer) .field("request_extensions", &self.request_extensions) .field("extension_info", &self.extension_info) @@ -2088,6 +2091,7 @@ impl ResumeSessionConfig { system_message: self.system_message, tools: self.tools, canvases: wire_canvases, + open_canvases: self.open_canvases, request_canvas_renderer: self.request_canvas_renderer, request_extensions: self.request_extensions, extension_info: self.extension_info, @@ -2153,6 +2157,7 @@ impl ResumeSessionConfig { system_message: None, tools: None, canvases: None, + open_canvases: None, request_canvas_renderer: None, request_extensions: None, extension_info: None, @@ -2316,6 +2321,15 @@ impl ResumeSessionConfig { self } + /// Seed open canvas instances that were visible before resuming. + pub fn with_open_canvases>( + mut self, + open_canvases: I, + ) -> Self { + self.open_canvases = Some(open_canvases.into_iter().collect()); + self + } + /// Request host canvas renderer tools for this connection on resume. pub fn with_request_canvas_renderer(mut self, request: bool) -> Self { self.request_canvas_renderer = Some(request); diff --git a/rust/src/wire.rs b/rust/src/wire.rs index a1cadf318..952632b18 100644 --- a/rust/src/wire.rs +++ b/rust/src/wire.rs @@ -18,7 +18,7 @@ use std::path::PathBuf; use serde::Serialize; -use crate::canvas::CanvasDeclaration; +use crate::canvas::{CanvasDeclaration, OpenCanvasInstance}; use crate::generated::api_types::{ModelCapabilitiesOverride, RemoteSessionMode}; use crate::types::{ CloudSessionOptions, CustomAgentConfig, DefaultAgentConfig, ExtensionInfo, @@ -130,6 +130,8 @@ pub(crate) struct SessionResumeWire { #[serde(skip_serializing_if = "Option::is_none")] pub canvases: Option>, #[serde(skip_serializing_if = "Option::is_none")] + pub open_canvases: Option>, + #[serde(skip_serializing_if = "Option::is_none")] pub request_canvas_renderer: Option, #[serde(skip_serializing_if = "Option::is_none")] pub request_extensions: Option, diff --git a/rust/tests/session_test.rs b/rust/tests/session_test.rs index cc3341a55..426d49a0c 100644 --- a/rust/tests/session_test.rs +++ b/rust/tests/session_test.rs @@ -8,8 +8,8 @@ use std::time::Duration; use async_trait::async_trait; use github_copilot_sdk::canvas::{ Canvas, CanvasActionContext, CanvasCloseRequest, CanvasDeclaration, CanvasHandler, - CanvasInvokeActionRequest, CanvasOpenContext, CanvasOpenRequest, CanvasOpenResponse, - CanvasResult, + CanvasInstanceAvailability, CanvasInvokeActionRequest, CanvasOpenContext, CanvasOpenRequest, + CanvasOpenResponse, CanvasResult, OpenCanvasInstance, }; use github_copilot_sdk::handler::{ ApproveAllHandler, AutoModeSwitchHandler, AutoModeSwitchResponse, ElicitationHandler, @@ -2613,7 +2613,18 @@ async fn resume_session_sends_canvas_fields_and_captures_open_canvases() { .with_canvases([test_canvas("counter")]) .with_request_canvas_renderer(true) .with_request_extensions(true) - .with_extension_info(ExtensionInfo::new("github-app", "counter-provider")); + .with_extension_info(ExtensionInfo::new("github-app", "counter-provider")) + .with_open_canvases([OpenCanvasInstance::new( + "counter-1", + "github-app:counter-provider", + "counter", + ) + .with_extension_name("Counter Provider") + .with_title("Counter") + .with_status("ready") + .with_url("https://example.test/counter") + .with_input(serde_json::json!({ "seed": 1 })) + .with_availability(CanvasInstanceAvailability::Stale)]); client.resume_session(cfg).await.unwrap() } }); @@ -2628,7 +2639,10 @@ async fn resume_session_sends_canvas_fields_and_captures_open_canvases() { request["params"]["extensionInfo"]["name"], "counter-provider" ); - assert!(request["params"].get("openCanvasInstances").is_none()); + assert_eq!( + request["params"]["openCanvases"][0]["availability"], + "stale" + ); let id = request["id"].as_u64().unwrap(); let response = serde_json::json!({ @@ -2641,7 +2655,8 @@ async fn resume_session_sends_canvas_fields_and_captures_open_canvases() { "canvasId": "counter", "instanceId": "counter-1", "url": "https://example.test/counter", - "reopen": false + "reopen": false, + "availability": "ready" }], "capabilities": { "ui": { "canvases": true } @@ -2660,6 +2675,10 @@ async fn resume_session_sends_canvas_fields_and_captures_open_canvases() { let open = session.open_canvases(); assert_eq!(open.len(), 1); assert_eq!(open[0].instance_id, "counter-1"); + assert_eq!( + open[0].availability, + Some(CanvasInstanceAvailability::Ready) + ); let caps = session.capabilities(); assert_eq!(caps.ui.unwrap().canvases, Some(true)); } From 9eb452380a76fe2c8d241c9160b895ba67ba802d Mon Sep 17 00:00:00 2001 From: jmoseley Date: Fri, 22 May 2026 23:08:26 -0700 Subject: [PATCH 05/29] Address canvas SDK review feedback Validate canvas provider request payloads before routing, surface Rust canvas serialization and builder errors, and clarify list_open RPC behavior. Validation: nodejs typecheck/lint/vitest; rust fmt/clippy/test --all-features. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- nodejs/src/client.ts | 46 ++++++++++++++++++++++++++++++-------- nodejs/test/client.test.ts | 38 +++++++++++++++++++++++++++++++ rust/src/canvas.rs | 40 +++++++++++++++++++++++++-------- rust/src/session.rs | 9 ++++---- rust/tests/session_test.rs | 1 + 5 files changed, 112 insertions(+), 22 deletions(-) diff --git a/nodejs/src/client.ts b/nodejs/src/client.ts index 73aa7514b..698cf3b95 100644 --- a/nodejs/src/client.ts +++ b/nodejs/src/client.ts @@ -133,6 +133,32 @@ function toWireCustomAgents(agents: CustomAgentConfig[] | undefined): unknown[] }); } +function isCanvasProviderRequestParams(params: unknown): params is CanvasProviderRequestParams { + if (!params || typeof params !== "object") { + return false; + } + + const request = params as { + sessionId?: unknown; + extensionId?: unknown; + canvasId?: unknown; + instanceId?: unknown; + }; + return ( + typeof request.sessionId === "string" && + typeof request.extensionId === "string" && + typeof request.canvasId === "string" && + typeof request.instanceId === "string" + ); +} + +function isCanvasActionInvokeParams(params: unknown): params is CanvasActionInvokeParams { + return ( + isCanvasProviderRequestParams(params) && + typeof (params as { actionName?: unknown }).actionName === "string" + ); +} + /** * Extract transform callbacks from a system message config and prepare the wire payload. * Function-valued actions are replaced with `{ action: "transform" }` for serialization, @@ -1910,8 +1936,7 @@ export class CopilotClient { ); this.connection.onRequest( "canvas.action.invoke", - async (params: CanvasActionInvokeParams) => - this.handleCanvasProviderRequest(params.actionName, params) + async (params: CanvasActionInvokeParams) => this.handleCanvasActionInvokeRequest(params) ); // Register client session API handlers. @@ -2120,14 +2145,9 @@ export class CopilotClient { private async handleCanvasProviderRequest( actionName: string, - params: CanvasActionInvokeParams | CanvasProviderRequestParams + params: unknown ): Promise { - if ( - !params || - typeof params.sessionId !== "string" || - typeof params.canvasId !== "string" || - typeof params.instanceId !== "string" - ) { + if (!isCanvasProviderRequestParams(params)) { throw new Error("Invalid canvas provider request payload"); } @@ -2143,4 +2163,12 @@ export class CopilotClient { 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/test/client.test.ts b/nodejs/test/client.test.ts index 69bc49699..2865c8e2e 100644 --- a/nodejs/test/client.test.ts +++ b/nodejs/test/client.test.ts @@ -128,6 +128,44 @@ describe("CopilotClient", () => { expect(result).toEqual({ actionName: "increment", input: { amount: 1 } }); }); + 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 onOpen = vi.fn(() => ({ url: "https://example.test/counter" })); + const canvas = createCanvas({ + id: "counter", + displayName: "Counter", + onOpen, + }); + 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(onOpen).not.toHaveBeenCalled(); + }); + it("throws for unknown direct canvas dispatches", async () => { const session = new CopilotSession("session-1", {} as any); const client = new CopilotClient(); diff --git a/rust/src/canvas.rs b/rust/src/canvas.rs index e8863b101..f7dc4d806 100644 --- a/rust/src/canvas.rs +++ b/rust/src/canvas.rs @@ -548,14 +548,20 @@ impl CanvasBuilder { } /// Finalize into a [`Canvas`]. - pub fn build(self) -> Canvas { - let handler = self - .handler - .expect("Canvas::builder().handler(...) must be called before build()"); - Canvas { + /// + /// Returns an error if no handler was attached. + pub fn build(self) -> CanvasResult { + let Some(handler) = self.handler else { + return Err(CanvasError::new( + "canvas_builder_missing_handler", + "Canvas::builder().handler(...) must be called before build()", + )); + }; + + Ok(Canvas { declaration: self.declaration, handler, - } + }) } } @@ -629,7 +635,12 @@ pub async fn dispatch_canvas_open( host: params.host, }) .await?; - Ok(serde_json::to_value(response).unwrap_or(Value::Null)) + serde_json::to_value(response).map_err(|error| { + CanvasError::new( + "canvas_open_response_serialization_failed", + format!("failed to serialize canvas.open response: {error}"), + ) + }) } /// Resolve a direct `canvas.focus`, `canvas.reload`, or `canvas.close` request. @@ -743,7 +754,8 @@ mod tests { async fn dispatch_routes_canvas_open() { let canvas = Canvas::builder(CanvasDeclaration::new("echo", "Echo")) .handler(Arc::new(EchoHandler)) - .build(); + .build() + .unwrap(); let registry = build_registry(&[canvas]); let params = CanvasProviderRequestParams { session_id: SessionId::from("s1"), @@ -765,7 +777,8 @@ mod tests { async fn dispatch_routes_custom_action() { let canvas = Canvas::builder(CanvasDeclaration::new("echo", "Echo")) .handler(Arc::new(EchoHandler)) - .build(); + .build() + .unwrap(); let registry = build_registry(&[canvas]); let result = dispatch_canvas_action( @@ -805,4 +818,13 @@ mod tests { assert_eq!(err.code, "canvas_not_found"); } + + #[test] + fn builder_requires_handler() { + let err = Canvas::builder(CanvasDeclaration::new("echo", "Echo")) + .build() + .unwrap_err(); + + assert_eq!(err.code, "canvas_builder_missing_handler"); + } } diff --git a/rust/src/session.rs b/rust/src/session.rs index f08d607a1..9d8fded7b 100644 --- a/rust/src/session.rs +++ b/rust/src/session.rs @@ -638,9 +638,9 @@ impl Drop for Session { /// Canvas host sub-API for a [`Session`]. /// -/// Acquired via [`Session::canvas`]. Methods route to `session.canvas.*` -/// RPCs, except [`list_open`](Self::list_open), which returns the SDK's local -/// snapshot from resume and calls made through this handle. +/// Acquired via [`Session::canvas`]. Methods route to `session.canvas.*` RPCs. +/// [`list_open`](Self::list_open) calls `session.canvas.listOpen` and refreshes +/// the SDK's local snapshot with the host's response. pub struct SessionCanvas<'a> { session: &'a Session, } @@ -651,7 +651,8 @@ impl<'a> SessionCanvas<'a> { self.call_session_only("session.canvas.discover").await } - /// Lists currently open canvas instances for the live session. + /// Lists currently open canvas instances for the live session and refreshes + /// the SDK's local snapshot. pub async fn list_open(&self) -> Result { let result: CanvasListOpenResult = self.call_session_only("session.canvas.listOpen").await?; diff --git a/rust/tests/session_test.rs b/rust/tests/session_test.rs index 426d49a0c..3a01b3d0c 100644 --- a/rust/tests/session_test.rs +++ b/rust/tests/session_test.rs @@ -55,6 +55,7 @@ fn test_canvas(id: &str) -> Canvas { ) .handler(Arc::new(TestCanvasHandler)) .build() + .unwrap() } async fn write_framed(writer: &mut (impl AsyncWrite + Unpin), body: &[u8]) { From 076a288e77666ccf9c0e501f576962f2303b8629 Mon Sep 17 00:00:00 2001 From: jmoseley Date: Fri, 22 May 2026 23:18:55 -0700 Subject: [PATCH 06/29] Format Rust session imports Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- rust/src/session.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/rust/src/session.rs b/rust/src/session.rs index 9d8fded7b..e400b17a4 100644 --- a/rust/src/session.rs +++ b/rust/src/session.rs @@ -4,7 +4,8 @@ use std::sync::Arc; use std::time::{Duration, Instant}; use parking_lot::Mutex as ParkingLotMutex; -use serde::{Serialize, de::DeserializeOwned}; +use serde::Serialize; +use serde::de::DeserializeOwned; use serde_json::Value; use tokio::sync::oneshot; use tokio::task::JoinHandle; From 68616d87282d6424f2bfdb89a53f2a3708c7efd9 Mon Sep 17 00:00:00 2001 From: jmoseley Date: Sat, 23 May 2026 13:40:58 -0700 Subject: [PATCH 07/29] Sync canvas tool surface docs Remove stale focus/close/reload canvas agent-tool references and cover custom-tool permission payload passthrough for open_canvas. Validation: nodejs typecheck; cargo test --all-features permission_request_data_extracts_typed_kind. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- nodejs/src/types.ts | 6 +++--- rust/tests/session_test.rs | 11 +++++++++++ 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/nodejs/src/types.ts b/nodejs/src/types.ts index 3454ec2a1..521063965 100644 --- a/nodejs/src/types.ts +++ b/nodejs/src/types.ts @@ -1486,9 +1486,9 @@ export interface SessionConfigBase { /** * Renderer-side opt-in: when true, the runtime surfaces canvas agent tools - * (`open_canvas`, `discover_canvases`, `focus_canvas`, `close_canvas`, - * `reload_canvas`) to the model for this connection. Default off so SDK - * callers that cannot display canvases stay clean. + * (`discover_canvases`, `open_canvas`, `invoke_canvas_action`) to the model + * for this connection. Default off so SDK callers that cannot display + * canvases stay clean. */ requestCanvasRenderer?: boolean; diff --git a/rust/tests/session_test.rs b/rust/tests/session_test.rs index 3a01b3d0c..624b540eb 100644 --- a/rust/tests/session_test.rs +++ b/rust/tests/session_test.rs @@ -914,9 +914,20 @@ fn permission_request_data_extracts_typed_kind() { let custom: PermissionRequestData = serde_json::from_value(serde_json::json!({ "kind": "custom-tool", + "toolName": "open_canvas", + "args": { + "extensionId": "github-app:counter-provider", + "canvasId": "counter", + "instanceId": "counter-1" + } })) .unwrap(); assert_eq!(custom.kind, Some(PermissionRequestKind::CustomTool)); + assert_eq!(custom.extra["toolName"], "open_canvas"); + assert_eq!( + custom.extra["args"]["extensionId"], + "github-app:counter-provider" + ); // Unknown kinds fall through to the catch-all variant rather than failing. let unknown: PermissionRequestData = serde_json::from_value(serde_json::json!({ From cc4bccc4ed360c6cd574b1e2af3e1fa3679b7f5c Mon Sep 17 00:00:00 2001 From: jmoseley Date: Sat, 23 May 2026 14:44:39 -0700 Subject: [PATCH 08/29] Require canvas descriptions Align canvas contribution and discovered canvas descriptions with the runtime schema, update canvas tool-surface docs, and cover open_canvas custom-tool permission payloads. Validation: nodejs typecheck/lint/vitest client+extension; rust fmt/clippy; cargo check --all-features --all-targets; targeted canvas and permission tests. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- nodejs/src/canvas.ts | 6 +++--- nodejs/src/types.ts | 6 +++--- nodejs/test/client.test.ts | 3 +++ nodejs/test/extension.test.ts | 1 + rust/src/canvas.rs | 30 ++++++++++++++++-------------- rust/tests/session_test.rs | 8 +++++--- 6 files changed, 31 insertions(+), 23 deletions(-) diff --git a/nodejs/src/canvas.ts b/nodejs/src/canvas.ts index 2e78f01be..ec056228b 100644 --- a/nodejs/src/canvas.ts +++ b/nodejs/src/canvas.ts @@ -60,8 +60,8 @@ export interface CanvasDeclaration { id: string; /** Human-readable label shown in discovery and host UI chrome. */ displayName: string; - /** One-line description shown in discovery for agent reasoning. */ - description?: string; + /** Short, single-sentence description shown to the agent in canvas catalogs. */ + description: string; /** Optional JSON Schema for the `input` payload accepted by `canvas.open`. */ inputSchema?: CanvasJsonSchema; /** Agent-invocable actions exposed via `invoke_canvas_action`. */ @@ -168,7 +168,7 @@ export interface CanvasOptions { /** @see CanvasDeclaration.displayName */ displayName: string; /** @see CanvasDeclaration.description */ - description?: string; + description: string; /** @see CanvasDeclaration.inputSchema */ inputSchema?: CanvasJsonSchema; /** @see CanvasDeclaration.agentActions */ diff --git a/nodejs/src/types.ts b/nodejs/src/types.ts index 521063965..aaba9797a 100644 --- a/nodejs/src/types.ts +++ b/nodejs/src/types.ts @@ -1486,9 +1486,9 @@ export interface SessionConfigBase { /** * Renderer-side opt-in: when true, the runtime surfaces canvas agent tools - * (`discover_canvases`, `open_canvas`, `invoke_canvas_action`) to the model - * for this connection. Default off so SDK callers that cannot display - * canvases stay clean. + * (`list_canvas_capabilities`, `open_canvas`, `invoke_canvas_action`) to + * the model for this connection. Default off so SDK callers that cannot + * display canvases stay clean. */ requestCanvasRenderer?: boolean; diff --git a/nodejs/test/client.test.ts b/nodejs/test/client.test.ts index 2865c8e2e..8f36324ad 100644 --- a/nodejs/test/client.test.ts +++ b/nodejs/test/client.test.ts @@ -76,6 +76,7 @@ describe("CopilotClient", () => { const canvas = createCanvas({ id: "counter", displayName: "Counter", + description: "A counter canvas", onOpen: () => ({ url: "https://example.test/counter" }), }); const spy = vi @@ -108,6 +109,7 @@ describe("CopilotClient", () => { const canvas = createCanvas({ id: "counter", displayName: "Counter", + description: "A counter canvas", onOpen: ({ instanceId }) => ({ url: `https://example.test/${instanceId}` }), onAction: ({ actionName, input }) => ({ actionName, input }), }); @@ -149,6 +151,7 @@ describe("CopilotClient", () => { const canvas = createCanvas({ id: "counter", displayName: "Counter", + description: "A counter canvas", onOpen, }); const session = new CopilotSession("session-1", {} as any); diff --git a/nodejs/test/extension.test.ts b/nodejs/test/extension.test.ts index 6eca9f8e3..bd95b4f42 100644 --- a/nodejs/test/extension.test.ts +++ b/nodejs/test/extension.test.ts @@ -51,6 +51,7 @@ describe("joinSession", () => { const canvas = createCanvas({ id: "counter", displayName: "Counter", + description: "A counter canvas", onOpen: () => ({ url: "https://example.test/counter" }), }); diff --git a/rust/src/canvas.rs b/rust/src/canvas.rs index f7dc4d806..000849de8 100644 --- a/rust/src/canvas.rs +++ b/rust/src/canvas.rs @@ -70,9 +70,8 @@ pub struct CanvasDeclaration { pub id: String, /// Human-readable name shown in host UI and canvas pickers. pub display_name: String, - /// Description surfaced in discovery and agent context. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub description: Option, + /// Short, single-sentence description shown to the agent in canvas catalogs. + pub description: String, /// JSON Schema for the `input` payload accepted by `canvas.open`. #[serde(default, skip_serializing_if = "Option::is_none")] pub input_schema: Option, @@ -86,11 +85,15 @@ pub struct CanvasDeclaration { impl CanvasDeclaration { /// Construct a canvas declaration with the required fields set. - pub fn new(id: impl Into, display_name: impl Into) -> Self { + pub fn new( + id: impl Into, + display_name: impl Into, + description: impl Into, + ) -> Self { Self { id: id.into(), display_name: display_name.into(), - description: None, + description: description.into(), input_schema: None, agent_actions: None, toolbar: None, @@ -99,7 +102,7 @@ impl CanvasDeclaration { /// Set the description surfaced in discovery and agent context. pub fn with_description(mut self, description: impl Into) -> Self { - self.description = Some(description.into()); + self.description = description.into(); self } } @@ -281,9 +284,8 @@ pub struct DiscoveredCanvas { pub canvas_id: String, /// Human-readable canvas name. pub display_name: String, - /// Canvas description for discovery. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub description: Option, + /// Short, single-sentence description shown to the agent in canvas catalogs. + pub description: String, /// JSON Schema for canvas open input. #[serde(default, skip_serializing_if = "Option::is_none")] pub input_schema: Option, @@ -732,7 +734,7 @@ mod tests { let decl = CanvasDeclaration { id: "counter".to_string(), display_name: "Counter".to_string(), - description: None, + description: "Count things".to_string(), input_schema: None, agent_actions: Some(vec![CanvasAgentActionDeclaration { name: "increment".to_string(), @@ -746,13 +748,13 @@ mod tests { assert_eq!(value["id"], "counter"); assert_eq!(value["displayName"], "Counter"); - assert!(value.get("description").is_none()); + assert_eq!(value["description"], "Count things"); assert_eq!(value["agentActions"][0]["name"], "increment"); } #[tokio::test] async fn dispatch_routes_canvas_open() { - let canvas = Canvas::builder(CanvasDeclaration::new("echo", "Echo")) + let canvas = Canvas::builder(CanvasDeclaration::new("echo", "Echo", "Echo values")) .handler(Arc::new(EchoHandler)) .build() .unwrap(); @@ -775,7 +777,7 @@ mod tests { #[tokio::test] async fn dispatch_routes_custom_action() { - let canvas = Canvas::builder(CanvasDeclaration::new("echo", "Echo")) + let canvas = Canvas::builder(CanvasDeclaration::new("echo", "Echo", "Echo values")) .handler(Arc::new(EchoHandler)) .build() .unwrap(); @@ -821,7 +823,7 @@ mod tests { #[test] fn builder_requires_handler() { - let err = Canvas::builder(CanvasDeclaration::new("echo", "Echo")) + let err = Canvas::builder(CanvasDeclaration::new("echo", "Echo", "Echo values")) .build() .unwrap_err(); diff --git a/rust/tests/session_test.rs b/rust/tests/session_test.rs index 624b540eb..dfad05c2b 100644 --- a/rust/tests/session_test.rs +++ b/rust/tests/session_test.rs @@ -50,9 +50,11 @@ impl CanvasHandler for TestCanvasHandler { } fn test_canvas(id: &str) -> Canvas { - Canvas::builder( - CanvasDeclaration::new(id, "Test Canvas").with_description("Test canvas description"), - ) + Canvas::builder(CanvasDeclaration::new( + id, + "Test Canvas", + "Test canvas description", + )) .handler(Arc::new(TestCanvasHandler)) .build() .unwrap() From d0accc4458ca8d96ea638a2cc30dbb7f5df86e68 Mon Sep 17 00:00:00 2001 From: jmoseley Date: Sat, 23 May 2026 15:00:39 -0700 Subject: [PATCH 09/29] Require canvas instance availability Align OpenCanvasInstance with the runtime schema by making availability required and updating canvas host/resume tests. Validation: cargo check --all-features --all-targets; cargo test --all-features canvas; targeted session canvas tests. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- rust/src/canvas.rs | 7 +++---- rust/tests/session_test.rs | 11 +++++------ 2 files changed, 8 insertions(+), 10 deletions(-) diff --git a/rust/src/canvas.rs b/rust/src/canvas.rs index 000849de8..007aa9f36 100644 --- a/rust/src/canvas.rs +++ b/rust/src/canvas.rs @@ -193,8 +193,7 @@ pub struct OpenCanvasInstance { /// Whether this snapshot came from an idempotent reopen. pub reopen: bool, /// Runtime-controlled routing state for this instance. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub availability: Option, + pub availability: CanvasInstanceAvailability, } impl OpenCanvasInstance { @@ -216,7 +215,7 @@ impl OpenCanvasInstance { tools: None, input: None, reopen: false, - availability: None, + availability: CanvasInstanceAvailability::Stale, } } @@ -258,7 +257,7 @@ impl OpenCanvasInstance { /// Set the runtime-controlled routing availability. pub fn with_availability(mut self, availability: CanvasInstanceAvailability) -> Self { - self.availability = Some(availability); + self.availability = availability; self } } diff --git a/rust/tests/session_test.rs b/rust/tests/session_test.rs index dfad05c2b..ba267a33a 100644 --- a/rust/tests/session_test.rs +++ b/rust/tests/session_test.rs @@ -453,7 +453,8 @@ async fn session_canvas_host_api_sends_requests_and_tracks_open_instances() { "extensionId": "project:counter", "canvasId": "counter", "url": "https://example.test/counter", - "reopen": false + "reopen": false, + "availability": "ready" }), ) .await; @@ -469,7 +470,8 @@ async fn session_canvas_host_api_sends_requests_and_tracks_open_instances() { "extensionId": "project:counter", "canvasId": "counter", "url": "https://example.test/counter", - "reopen": false + "reopen": false, + "availability": "ready" }] }), ) @@ -2689,10 +2691,7 @@ async fn resume_session_sends_canvas_fields_and_captures_open_canvases() { let open = session.open_canvases(); assert_eq!(open.len(), 1); assert_eq!(open[0].instance_id, "counter-1"); - assert_eq!( - open[0].availability, - Some(CanvasInstanceAvailability::Ready) - ); + assert_eq!(open[0].availability, CanvasInstanceAvailability::Ready); let caps = session.capabilities(); assert_eq!(caps.ui.unwrap().canvases, Some(true)); } From 5985b5058c44384bf4084c80984645eacabe40c6 Mon Sep 17 00:00:00 2001 From: jmoseley Date: Sat, 23 May 2026 15:53:09 -0700 Subject: [PATCH 10/29] Rename Node canvas open handler Rename the Node canvas provider option from onOpen to open and remove lifecycle handler options from the extension canvas API. Validation: nodejs typecheck; vitest client and extension tests. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- nodejs/src/canvas.ts | 55 ++++------------------------------- nodejs/src/extension.ts | 1 - nodejs/src/index.ts | 1 - nodejs/test/client.test.ts | 12 ++++---- nodejs/test/extension.test.ts | 2 +- 5 files changed, 13 insertions(+), 58 deletions(-) diff --git a/nodejs/src/canvas.ts b/nodejs/src/canvas.ts index ec056228b..39cf4c806 100644 --- a/nodejs/src/canvas.ts +++ b/nodejs/src/canvas.ts @@ -70,7 +70,7 @@ export interface CanvasDeclaration { toolbar?: CanvasToolbarItemDeclaration[]; } -/** Response returned from `onOpen`. */ +/** Response returned from `open`. */ export interface CanvasOpenResponse { /** URL the host should render. Optional for native canvases. */ url?: string; @@ -91,7 +91,7 @@ export interface CanvasHostContext { }; } -/** Context handed to a canvas's `onOpen` handler. */ +/** Context handed to a canvas's `open` handler. */ export interface CanvasOpenContext { /** Session that requested the canvas. */ sessionId: string; @@ -125,20 +125,6 @@ export interface CanvasActionContext { host?: CanvasHostContext; } -/** Context handed to a canvas's lifecycle hooks (`onFocus`, `onClose`, `onReload`). */ -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( @@ -177,32 +163,20 @@ export interface CanvasOptions { toolbar?: CanvasToolbarItemDeclaration[]; /** Required. Open a new canvas instance. */ - onOpen: (ctx: CanvasOpenContext) => Promise | CanvasOpenResponse; + open: (ctx: CanvasOpenContext) => Promise | CanvasOpenResponse; /** * Optional. Handle a non-lifecycle action declared in `agentActions`. * If omitted, dispatched actions return `canvas_action_no_handler`. */ onAction?: (ctx: CanvasActionContext) => Promise | unknown; - - /** Optional. Canvas was brought to the foreground. */ - onFocus?: (ctx: CanvasLifecycleContext) => Promise | void; - - /** Optional. Canvas was closed by the user or agent. */ - onClose?: (ctx: CanvasLifecycleContext) => Promise | void; - - /** Optional. Host requested a reload. */ - onReload?: (ctx: CanvasLifecycleContext) => Promise | void; } /** A registered canvas: declarative metadata + in-process handler closures. */ export class Canvas { readonly declaration: CanvasDeclaration; - readonly onOpen: NonNullable; + readonly open: NonNullable; readonly onAction?: CanvasOptions["onAction"]; - readonly onFocus?: CanvasOptions["onFocus"]; - readonly onClose?: CanvasOptions["onClose"]; - readonly onReload?: CanvasOptions["onReload"]; /** @internal */ constructor(options: CanvasOptions) { @@ -214,11 +188,8 @@ export class Canvas { agentActions: options.agentActions, toolbar: options.toolbar, }; - this.onOpen = options.onOpen; + this.open = options.open; this.onAction = options.onAction; - this.onFocus = options.onFocus; - this.onClose = options.onClose; - this.onReload = options.onReload; } } @@ -255,7 +226,7 @@ export async function dispatchCanvasProviderRequest( ): Promise { switch (actionName) { case "canvas.open": { - const result = await canvas.onOpen({ + const result = await canvas.open({ sessionId: params.sessionId, extensionId: params.extensionId, canvasId: params.canvasId, @@ -268,20 +239,6 @@ export async function dispatchCanvasProviderRequest( case "canvas.focus": case "canvas.close": case "canvas.reload": { - const hook = - actionName === "canvas.focus" - ? canvas.onFocus - : actionName === "canvas.close" - ? canvas.onClose - : canvas.onReload; - if (!hook) return undefined; - await hook({ - sessionId: params.sessionId, - extensionId: params.extensionId, - canvasId: params.canvasId, - instanceId: params.instanceId, - host: params.host, - }); return undefined; } default: { diff --git a/nodejs/src/extension.ts b/nodejs/src/extension.ts index 3a1cb2ad1..906bcfc94 100644 --- a/nodejs/src/extension.ts +++ b/nodejs/src/extension.ts @@ -20,7 +20,6 @@ export { type CanvasDeclaration, type CanvasHostContext, type CanvasJsonSchema, - type CanvasLifecycleContext, type CanvasOpenContext, type CanvasOpenResponse, type CanvasOptions, diff --git a/nodejs/src/index.ts b/nodejs/src/index.ts index eae56a28c..773038ce7 100644 --- a/nodejs/src/index.ts +++ b/nodejs/src/index.ts @@ -20,7 +20,6 @@ export { type CanvasDeclaration, type CanvasHostContext, type CanvasJsonSchema, - type CanvasLifecycleContext, type CanvasOpenContext, type CanvasOpenResponse, type CanvasOptions, diff --git a/nodejs/test/client.test.ts b/nodejs/test/client.test.ts index 8f36324ad..671559c15 100644 --- a/nodejs/test/client.test.ts +++ b/nodejs/test/client.test.ts @@ -33,7 +33,7 @@ describe("CopilotClient", () => { displayName: "Counter", description: "A counter canvas", agentActions: [{ name: "increment", description: "Increment the counter" }], - onOpen: () => ({ url: "https://example.test/counter" }), + open: () => ({ url: "https://example.test/counter" }), }); const spy = vi .spyOn((client as any).connection!, "sendRequest") @@ -77,7 +77,7 @@ describe("CopilotClient", () => { id: "counter", displayName: "Counter", description: "A counter canvas", - onOpen: () => ({ url: "https://example.test/counter" }), + open: () => ({ url: "https://example.test/counter" }), }); const spy = vi .spyOn((client as any).connection!, "sendRequest") @@ -110,7 +110,7 @@ describe("CopilotClient", () => { id: "counter", displayName: "Counter", description: "A counter canvas", - onOpen: ({ instanceId }) => ({ url: `https://example.test/${instanceId}` }), + open: ({ instanceId }) => ({ url: `https://example.test/${instanceId}` }), onAction: ({ actionName, input }) => ({ actionName, input }), }); const session = new CopilotSession("session-1", {} as any); @@ -147,12 +147,12 @@ describe("CopilotClient", () => { }); it("rejects direct canvas provider payloads without extension ids", async () => { - const onOpen = vi.fn(() => ({ url: "https://example.test/counter" })); + const open = vi.fn(() => ({ url: "https://example.test/counter" })); const canvas = createCanvas({ id: "counter", displayName: "Counter", description: "A counter canvas", - onOpen, + open, }); const session = new CopilotSession("session-1", {} as any); session.registerCanvases([canvas]); @@ -166,7 +166,7 @@ describe("CopilotClient", () => { instanceId: "counter-1", }) ).rejects.toThrow("Invalid canvas provider request payload"); - expect(onOpen).not.toHaveBeenCalled(); + expect(open).not.toHaveBeenCalled(); }); it("throws for unknown direct canvas dispatches", async () => { diff --git a/nodejs/test/extension.test.ts b/nodejs/test/extension.test.ts index bd95b4f42..1baa83a3a 100644 --- a/nodejs/test/extension.test.ts +++ b/nodejs/test/extension.test.ts @@ -52,7 +52,7 @@ describe("joinSession", () => { id: "counter", displayName: "Counter", description: "A counter canvas", - onOpen: () => ({ url: "https://example.test/counter" }), + open: () => ({ url: "https://example.test/counter" }), }); expect(canvas.declaration.id).toBe("counter"); From 4ebff844d2e63fc509cd87678f08dfd0a6429e65 Mon Sep 17 00:00:00 2001 From: jmoseley Date: Sat, 23 May 2026 16:17:04 -0700 Subject: [PATCH 11/29] Drop canvas toolbar and focus/reload surface Aligns the SDK canvas contract with copilot-agent-runtime jmoseley/adr-implementation-plan commits 85b23bc264 and acdefc1bc1: - Rename agentActions to actions on CanvasDeclaration and DiscoveredCanvas (Rust + Node). - Drop toolbar from CanvasContribution and CanvasOpenResponse, and remove CanvasToolbarItemDeclaration / CanvasToolbarItem entirely. - Drop SessionCanvas::focus and SessionCanvas::reload host APIs; re-opening with the same instanceId now drives focus via session.canvas.opened { reopen: true }, and reload is renderer-only. - Drop canvas.focus / canvas.reload provider JSON-RPC routes and the matching CanvasHandler::on_focus / on_reload hooks; canvas.close keeps its dedicated dispatch path. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- nodejs/src/canvas.ts | 45 ++++++------------- nodejs/src/client.ts | 6 --- nodejs/src/extension.ts | 1 - nodejs/src/index.ts | 1 - nodejs/test/client.test.ts | 4 +- rust/src/canvas.rs | 88 +++++--------------------------------- rust/src/session.rs | 28 +++++------- rust/tests/session_test.rs | 1 - 8 files changed, 35 insertions(+), 139 deletions(-) diff --git a/nodejs/src/canvas.ts b/nodejs/src/canvas.ts index 39cf4c806..a4a3ce07a 100644 --- a/nodejs/src/canvas.ts +++ b/nodejs/src/canvas.ts @@ -7,9 +7,10 @@ * `joinSession({ canvases: [createCanvas({...})] })`. * * The runtime sends provider callbacks directly as `canvas.open`, - * `canvas.focus`, `canvas.reload`, `canvas.close`, and - * `canvas.action.invoke` JSON-RPC requests. The SDK routes those requests by - * `canvasId` to the in-process handlers bound by `createCanvas`. + * `canvas.close`, and `canvas.action.invoke` JSON-RPC requests. The SDK + * routes those requests by `canvasId` to the in-process handlers bound by + * `createCanvas`. Re-opening with an existing `instanceId` is how the host + * focuses an existing panel; reload is a renderer-only concern. */ /** JSON Schema object used for canvas inputs and canvas-scoped tools. */ @@ -26,8 +27,7 @@ export interface CanvasToolDefinition { defer?: "auto" | "never"; } -/** - * A single agent-callable action contributed by a canvas. Names MUST NOT +/** A single agent-callable action contributed by a canvas. Names MUST NOT * start with `canvas.` - that prefix is reserved for lifecycle verbs. */ export interface CanvasAgentActionDeclaration { @@ -39,18 +39,6 @@ export interface CanvasAgentActionDeclaration { inputSchema?: CanvasJsonSchema; } -/** A single toolbar button contributed by a canvas. */ -export interface CanvasToolbarItemDeclaration { - /** Stable id used by the host to key the button. */ - id: string; - /** User-visible label. */ - label: string; - /** The `agentActions[].name` to dispatch when clicked. */ - actionName: string; - /** Optional fixed input payload passed verbatim to the action handler. */ - input?: unknown; -} - /** * Declarative metadata for a single canvas, serialized over the wire on * `session.create` / `session.resume`. @@ -65,9 +53,7 @@ export interface CanvasDeclaration { /** Optional JSON Schema for the `input` payload accepted by `canvas.open`. */ inputSchema?: CanvasJsonSchema; /** Agent-invocable actions exposed via `invoke_canvas_action`. */ - agentActions?: CanvasAgentActionDeclaration[]; - /** Static toolbar items rendered as host chrome. */ - toolbar?: CanvasToolbarItemDeclaration[]; + actions?: CanvasAgentActionDeclaration[]; } /** Response returned from `open`. */ @@ -78,8 +64,6 @@ export interface CanvasOpenResponse { title?: string; /** Provider-supplied status text shown in host chrome. */ status?: string; - /** Toolbar items for host-rendered chrome. */ - toolbar?: CanvasToolbarItemDeclaration[]; /** Tools available to the canvas instance. */ tools?: CanvasToolDefinition[]; } @@ -157,16 +141,14 @@ export interface CanvasOptions { description: string; /** @see CanvasDeclaration.inputSchema */ inputSchema?: CanvasJsonSchema; - /** @see CanvasDeclaration.agentActions */ - agentActions?: CanvasAgentActionDeclaration[]; - /** @see CanvasDeclaration.toolbar */ - toolbar?: CanvasToolbarItemDeclaration[]; + /** @see CanvasDeclaration.actions */ + actions?: CanvasAgentActionDeclaration[]; /** Required. Open a new canvas instance. */ open: (ctx: CanvasOpenContext) => Promise | CanvasOpenResponse; /** - * Optional. Handle a non-lifecycle action declared in `agentActions`. + * Optional. Handle a non-lifecycle action declared in `actions`. * If omitted, dispatched actions return `canvas_action_no_handler`. */ onAction?: (ctx: CanvasActionContext) => Promise | unknown; @@ -185,8 +167,7 @@ export class Canvas { displayName: options.displayName, description: options.description, inputSchema: options.inputSchema, - agentActions: options.agentActions, - toolbar: options.toolbar, + actions: options.actions, }; this.open = options.open; this.onAction = options.onAction; @@ -221,7 +202,7 @@ export interface CanvasActionInvokeParams extends CanvasProviderRequestParams { */ export async function dispatchCanvasProviderRequest( canvas: Canvas, - actionName: "canvas.open" | "canvas.focus" | "canvas.close" | "canvas.reload" | string, + actionName: "canvas.open" | "canvas.close" | string, params: CanvasActionInvokeParams | CanvasProviderRequestParams ): Promise { switch (actionName) { @@ -236,9 +217,7 @@ export async function dispatchCanvasProviderRequest( }); return result ?? {}; } - case "canvas.focus": - case "canvas.close": - case "canvas.reload": { + case "canvas.close": { return undefined; } default: { diff --git a/nodejs/src/client.ts b/nodejs/src/client.ts index 698cf3b95..713c2eef7 100644 --- a/nodejs/src/client.ts +++ b/nodejs/src/client.ts @@ -1925,12 +1925,6 @@ export class CopilotClient { this.connection.onRequest("canvas.open", async (params: CanvasProviderRequestParams) => this.handleCanvasProviderRequest("canvas.open", params) ); - this.connection.onRequest("canvas.focus", async (params: CanvasProviderRequestParams) => - this.handleCanvasProviderRequest("canvas.focus", params) - ); - this.connection.onRequest("canvas.reload", async (params: CanvasProviderRequestParams) => - this.handleCanvasProviderRequest("canvas.reload", params) - ); this.connection.onRequest("canvas.close", async (params: CanvasProviderRequestParams) => this.handleCanvasProviderRequest("canvas.close", params) ); diff --git a/nodejs/src/extension.ts b/nodejs/src/extension.ts index 906bcfc94..16ddce8dc 100644 --- a/nodejs/src/extension.ts +++ b/nodejs/src/extension.ts @@ -24,7 +24,6 @@ export { type CanvasOpenResponse, type CanvasOptions, type CanvasToolDefinition, - type CanvasToolbarItemDeclaration, } from "./canvas.js"; export type JoinSessionConfig = Omit & { diff --git a/nodejs/src/index.ts b/nodejs/src/index.ts index 773038ce7..fb8938552 100644 --- a/nodejs/src/index.ts +++ b/nodejs/src/index.ts @@ -24,7 +24,6 @@ export { type CanvasOpenResponse, type CanvasOptions, type CanvasToolDefinition, - type CanvasToolbarItemDeclaration, } from "./canvas.js"; export { defineTool, diff --git a/nodejs/test/client.test.ts b/nodejs/test/client.test.ts index 671559c15..d8b3b982f 100644 --- a/nodejs/test/client.test.ts +++ b/nodejs/test/client.test.ts @@ -32,7 +32,7 @@ describe("CopilotClient", () => { id: "counter", displayName: "Counter", description: "A counter canvas", - agentActions: [{ name: "increment", description: "Increment the counter" }], + actions: [{ name: "increment", description: "Increment the counter" }], open: () => ({ url: "https://example.test/counter" }), }); const spy = vi @@ -56,7 +56,7 @@ describe("CopilotClient", () => { id: "counter", displayName: "Counter", description: "A counter canvas", - agentActions: [{ name: "increment", description: "Increment the counter" }], + actions: [{ name: "increment", description: "Increment the counter" }], }), ]); expect(payload.requestCanvasRenderer).toBe(true); diff --git a/rust/src/canvas.rs b/rust/src/canvas.rs index 007aa9f36..965bf1fd5 100644 --- a/rust/src/canvas.rs +++ b/rust/src/canvas.rs @@ -77,10 +77,7 @@ pub struct CanvasDeclaration { pub input_schema: Option, /// Agent-callable actions this canvas exposes. #[serde(default, skip_serializing_if = "Option::is_none")] - pub agent_actions: Option>, - /// User-facing toolbar buttons rendered by the host canvas chrome. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub toolbar: Option>, + pub actions: Option>, } impl CanvasDeclaration { @@ -95,8 +92,7 @@ impl CanvasDeclaration { display_name: display_name.into(), description: description.into(), input_schema: None, - agent_actions: None, - toolbar: None, + actions: None, } } @@ -121,21 +117,6 @@ pub struct CanvasAgentActionDeclaration { pub input_schema: Option, } -/// A single toolbar button contributed by a canvas. -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "camelCase")] -pub struct CanvasToolbarItemDeclaration { - /// Stable id used by the host to key the button. - pub id: String, - /// User-visible label. - pub label: String, - /// Action name dispatched when the toolbar item is activated. - pub action_name: String, - /// Optional fixed input payload passed verbatim to the action handler. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub input: Option, -} - /// Response returned from [`CanvasHandler::on_open`]. #[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "camelCase")] @@ -149,9 +130,6 @@ pub struct CanvasOpenResponse { /// Provider-supplied status text shown in host chrome. #[serde(default, skip_serializing_if = "Option::is_none")] pub status: Option, - /// Toolbar items for host-rendered chrome. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub toolbar: Option>, /// Tools available to the canvas instance. #[serde(default, skip_serializing_if = "Option::is_none")] pub tools: Option>, @@ -181,9 +159,6 @@ pub struct OpenCanvasInstance { /// URL for web-rendered canvases. #[serde(default, skip_serializing_if = "Option::is_none")] pub url: Option, - /// Toolbar items for host-rendered chrome. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub toolbar: Option>, /// Tools available to the canvas instance. #[serde(default, skip_serializing_if = "Option::is_none")] pub tools: Option>, @@ -211,7 +186,6 @@ impl OpenCanvasInstance { title: None, status: None, url: None, - toolbar: None, tools: None, input: None, reopen: false, @@ -290,10 +264,7 @@ pub struct DiscoveredCanvas { pub input_schema: Option, /// Actions the agent or host may invoke on an open instance. #[serde(default, skip_serializing_if = "Option::is_none")] - pub agent_actions: Option>, - /// Host-rendered toolbar contribution. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub toolbar: Option>, + pub actions: Option>, } /// Result returned by [`SessionCanvas::list_open`](crate::session::SessionCanvas::list_open). @@ -319,14 +290,6 @@ pub struct CanvasOpenRequest { pub input: Option, } -/// Request parameters for `session.canvas.focus`. -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "camelCase")] -pub struct CanvasFocusRequest { - /// Open canvas instance identifier. - pub instance_id: String, -} - /// Request parameters for `session.canvas.close`. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] @@ -335,14 +298,6 @@ pub struct CanvasCloseRequest { pub instance_id: String, } -/// Request parameters for `session.canvas.reload`. -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "camelCase")] -pub struct CanvasReloadRequest { - /// Open canvas instance identifier. - pub instance_id: String, -} - /// Request parameters for `session.canvas.invokeAction`. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "camelCase")] @@ -419,7 +374,7 @@ pub struct CanvasActionContext { pub host: Option, } -/// Context handed to canvas lifecycle hooks. +/// Context handed to a canvas's close lifecycle hook. #[derive(Debug, Clone)] pub struct CanvasLifecycleContext { /// Session owning the canvas instance. @@ -477,20 +432,10 @@ pub trait CanvasHandler: Send + Sync { Err(CanvasError::no_handler()) } - /// Canvas was brought to the foreground. - async fn on_focus(&self, _ctx: CanvasLifecycleContext) -> CanvasResult<()> { - Ok(()) - } - /// Canvas was closed by the user or agent. async fn on_close(&self, _ctx: CanvasLifecycleContext) -> CanvasResult<()> { Ok(()) } - - /// Host requested a reload. - async fn on_reload(&self, _ctx: CanvasLifecycleContext) -> CanvasResult<()> { - Ok(()) - } } /// A registered canvas: declarative metadata plus an in-process handler. @@ -644,10 +589,9 @@ pub async fn dispatch_canvas_open( }) } -/// Resolve a direct `canvas.focus`, `canvas.reload`, or `canvas.close` request. -pub async fn dispatch_canvas_lifecycle( +/// Resolve a direct `canvas.close` request. +pub async fn dispatch_canvas_close( registry: &CanvasRegistry, - method: &str, params: CanvasProviderRequestParams, ) -> CanvasResult { let handler = canvas_handler(registry, ¶ms.canvas_id)?; @@ -658,17 +602,7 @@ pub async fn dispatch_canvas_lifecycle( instance_id: params.instance_id, host: params.host, }; - match method { - "canvas.focus" => handler.on_focus(ctx).await?, - "canvas.reload" => handler.on_reload(ctx).await?, - "canvas.close" => handler.on_close(ctx).await?, - _ => { - return Err(CanvasError::new( - "unsupported_method", - format!("unsupported canvas lifecycle method: {method}"), - )); - } - } + handler.on_close(ctx).await?; Ok(Value::Null) } @@ -718,7 +652,6 @@ mod tests { url: Some(format!("https://example.test/{}", ctx.canvas_id)), title: Some("Echo".to_string()), status: Some("ready".to_string()), - toolbar: None, tools: None, }) } @@ -735,12 +668,11 @@ mod tests { display_name: "Counter".to_string(), description: "Count things".to_string(), input_schema: None, - agent_actions: Some(vec![CanvasAgentActionDeclaration { + actions: Some(vec![CanvasAgentActionDeclaration { name: "increment".to_string(), description: Some("bump".to_string()), input_schema: None, }]), - toolbar: None, }; let value = serde_json::to_value(&decl).unwrap(); @@ -748,7 +680,9 @@ mod tests { assert_eq!(value["id"], "counter"); assert_eq!(value["displayName"], "Counter"); assert_eq!(value["description"], "Count things"); - assert_eq!(value["agentActions"][0]["name"], "increment"); + assert_eq!(value["actions"][0]["name"], "increment"); + assert!(value.get("agentActions").is_none()); + assert!(value.get("toolbar").is_none()); } #[tokio::test] diff --git a/rust/src/session.rs b/rust/src/session.rs index e400b17a4..c2d978a69 100644 --- a/rust/src/session.rs +++ b/rust/src/session.rs @@ -13,9 +13,9 @@ use tokio_util::sync::CancellationToken; use tracing::{Instrument, warn}; use crate::canvas::{ - CanvasCloseRequest, CanvasDiscoverResult, CanvasFocusRequest, CanvasInvokeActionRequest, - CanvasInvokeActionResult, CanvasInvokeParams, CanvasListOpenResult, CanvasOpenRequest, - CanvasProviderRequestParams, CanvasRegistry, CanvasReloadRequest, OpenCanvasInstance, + CanvasCloseRequest, CanvasDiscoverResult, CanvasInvokeActionRequest, CanvasInvokeActionResult, + CanvasInvokeParams, CanvasListOpenResult, CanvasOpenRequest, CanvasProviderRequestParams, + CanvasRegistry, OpenCanvasInstance, }; use crate::generated::api_types::{LogRequest, ModelSwitchToRequest}; use crate::generated::session_events::{ @@ -662,6 +662,10 @@ impl<'a> SessionCanvas<'a> { } /// Opens a canvas instance for the given extension/canvas pair. + /// + /// Re-opening with an existing `instance_id` is the host-facing way to + /// focus a panel: the runtime re-emits `session.canvas.opened` with + /// `reopen: true` rather than dispatching a separate focus call. pub async fn open(&self, request: CanvasOpenRequest) -> Result { let result: OpenCanvasInstance = self.call("session.canvas.open", &request).await?; @@ -672,16 +676,6 @@ impl<'a> SessionCanvas<'a> { Ok(result) } - /// Focuses a previously opened canvas instance. - pub async fn focus(&self, request: CanvasFocusRequest) -> Result<(), Error> { - self.call_unit("session.canvas.focus", &request).await - } - - /// Reloads a previously opened canvas instance. - pub async fn reload(&self, request: CanvasReloadRequest) -> Result<(), Error> { - self.call_unit("session.canvas.reload", &request).await - } - /// Closes a previously opened canvas instance. pub async fn close(&self, request: CanvasCloseRequest) -> Result<(), Error> { let instance_id = request.instance_id.clone(); @@ -1876,12 +1870,11 @@ async fn handle_request( send_canvas_dispatch_response(client, request.id, result).await; } - method @ ("canvas.focus" | "canvas.reload" | "canvas.close") => { + "canvas.close" => { tracing::debug!( session_id = %sid, request_id = request.id, - method = method, - "handling canvas lifecycle provider request" + "handling canvas.close provider request" ); let Some(params) = parse_request_params::(client, request.id, &request) @@ -1889,8 +1882,7 @@ async fn handle_request( else { return; }; - let result = - crate::canvas::dispatch_canvas_lifecycle(canvas_registry, method, params).await; + let result = crate::canvas::dispatch_canvas_close(canvas_registry, params).await; send_canvas_dispatch_response(client, request.id, result).await; } diff --git a/rust/tests/session_test.rs b/rust/tests/session_test.rs index ba267a33a..e2c59114c 100644 --- a/rust/tests/session_test.rs +++ b/rust/tests/session_test.rs @@ -36,7 +36,6 @@ impl CanvasHandler for TestCanvasHandler { url: Some(format!("https://example.test/{}", ctx.canvas_id)), title: Some("Test Canvas".to_string()), status: Some("ready".to_string()), - toolbar: None, tools: None, }) } From efd7aeff52f5d0704cab961d19554a72951ca816 Mon Sep 17 00:00:00 2001 From: jmoseley Date: Sat, 23 May 2026 16:53:04 -0700 Subject: [PATCH 12/29] Restore optional onClose handler on Node canvas options Lets extension authors observe canvas instance close events without adding back the dropped onFocus/onReload hooks. Fire-and-forget: the handler's return value is ignored and the provider response is still undefined. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- nodejs/src/canvas.ts | 32 ++++++++++++++++++++++++++++++++ nodejs/src/extension.ts | 1 + nodejs/src/index.ts | 1 + 3 files changed, 34 insertions(+) diff --git a/nodejs/src/canvas.ts b/nodejs/src/canvas.ts index a4a3ce07a..6fdb5d626 100644 --- a/nodejs/src/canvas.ts +++ b/nodejs/src/canvas.ts @@ -109,6 +109,20 @@ export interface CanvasActionContext { 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( @@ -152,6 +166,13 @@ export interface CanvasOptions { * If omitted, dispatched actions return `canvas_action_no_handler`. */ onAction?: (ctx: CanvasActionContext) => Promise | unknown; + + /** + * Optional. Notified when a canvas instance is closed by the user, the + * agent, or the host. Fire-and-forget: the return value is ignored and + * errors are logged but not surfaced to the runtime. + */ + onClose?: (ctx: CanvasLifecycleContext) => Promise | void; } /** A registered canvas: declarative metadata + in-process handler closures. */ @@ -159,6 +180,7 @@ export class Canvas { readonly declaration: CanvasDeclaration; readonly open: NonNullable; readonly onAction?: CanvasOptions["onAction"]; + readonly onClose?: CanvasOptions["onClose"]; /** @internal */ constructor(options: CanvasOptions) { @@ -171,6 +193,7 @@ export class Canvas { }; this.open = options.open; this.onAction = options.onAction; + this.onClose = options.onClose; } } @@ -218,6 +241,15 @@ export async function dispatchCanvasProviderRequest( 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: { diff --git a/nodejs/src/extension.ts b/nodejs/src/extension.ts index 16ddce8dc..8c074d708 100644 --- a/nodejs/src/extension.ts +++ b/nodejs/src/extension.ts @@ -20,6 +20,7 @@ export { type CanvasDeclaration, type CanvasHostContext, type CanvasJsonSchema, + type CanvasLifecycleContext, type CanvasOpenContext, type CanvasOpenResponse, type CanvasOptions, diff --git a/nodejs/src/index.ts b/nodejs/src/index.ts index fb8938552..181f1f466 100644 --- a/nodejs/src/index.ts +++ b/nodejs/src/index.ts @@ -20,6 +20,7 @@ export { type CanvasDeclaration, type CanvasHostContext, type CanvasJsonSchema, + type CanvasLifecycleContext, type CanvasOpenContext, type CanvasOpenResponse, type CanvasOptions, From 6710b30854a4f607586ceaed3b09260584518fae Mon Sep 17 00:00:00 2001 From: jmoseley Date: Sat, 23 May 2026 16:59:04 -0700 Subject: [PATCH 13/29] Support per-action handlers on Node canvas actions Each entry in createCanvas({ actions }) may now carry its own optional handler, co-located with the action's metadata. The top-level onAction remains as a fallback for actions that don't define their own handler. Dispatch order: 1. Per-action handler when set. 2. Top-level onAction otherwise. 3. canvas_action_no_handler if neither is wired. The handler closure is stripped from the wire CanvasDeclaration sent on session.create / session.resume; only the action's name, description, and inputSchema reach the runtime. A new CanvasAction authoring type sits on top of the existing CanvasAgentActionDeclaration wire type. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- nodejs/src/canvas.ts | 60 ++++++++++++++++++++++++++++++-------- nodejs/src/extension.ts | 1 + nodejs/src/index.ts | 1 + nodejs/test/client.test.ts | 43 +++++++++++++++++++++++++++ 4 files changed, 93 insertions(+), 12 deletions(-) diff --git a/nodejs/src/canvas.ts b/nodejs/src/canvas.ts index 6fdb5d626..99ee42058 100644 --- a/nodejs/src/canvas.ts +++ b/nodejs/src/canvas.ts @@ -27,8 +27,9 @@ export interface CanvasToolDefinition { defer?: "auto" | "never"; } -/** A single agent-callable action contributed by a canvas. Names MUST NOT - * start with `canvas.` - that prefix is reserved for lifecycle verbs. +/** A single agent-callable action contributed by a canvas, as serialized over + * the wire. Names MUST NOT start with `canvas.` - that prefix is reserved for + * lifecycle verbs. */ export interface CanvasAgentActionDeclaration { /** Action identifier, unique within the canvas. */ @@ -39,6 +40,18 @@ export interface CanvasAgentActionDeclaration { inputSchema?: CanvasJsonSchema; } +/** + * Authoring shape for an action passed to {@link createCanvas}. Extends the + * wire {@link CanvasAgentActionDeclaration} with an optional per-action + * `handler`. When set, the handler is preferred over the top-level + * {@link CanvasOptions.onAction} for matching `actionName` dispatches; it is + * stripped before the declaration is sent on the wire. + */ +export interface CanvasAction extends CanvasAgentActionDeclaration { + /** Optional per-action dispatch handler. */ + handler?: (ctx: CanvasActionContext) => Promise | unknown; +} + /** * Declarative metadata for a single canvas, serialized over the wire on * `session.create` / `session.resume`. @@ -155,15 +168,20 @@ export interface CanvasOptions { description: string; /** @see CanvasDeclaration.inputSchema */ inputSchema?: CanvasJsonSchema; - /** @see CanvasDeclaration.actions */ - actions?: CanvasAgentActionDeclaration[]; + /** + * Agent-invocable actions exposed via `invoke_canvas_action`. Each action + * may carry its own optional `handler`; the action's wire metadata + * (`name`, `description`, `inputSchema`) is what reaches the runtime. + */ + actions?: CanvasAction[]; /** Required. Open a new canvas instance. */ open: (ctx: CanvasOpenContext) => Promise | CanvasOpenResponse; /** - * Optional. Handle a non-lifecycle action declared in `actions`. - * If omitted, dispatched actions return `canvas_action_no_handler`. + * Optional. Fallback handler invoked when an action has no per-action + * `handler`. If neither is wired for the dispatched action, the SDK + * returns `canvas_action_no_handler`. */ onAction?: (ctx: CanvasActionContext) => Promise | unknown; @@ -181,19 +199,32 @@ export class Canvas { readonly open: NonNullable; readonly onAction?: CanvasOptions["onAction"]; readonly onClose?: CanvasOptions["onClose"]; + /** @internal */ + readonly actionHandlers: Map>; /** @internal */ constructor(options: CanvasOptions) { + const actionHandlers = new Map>(); + const wireActions: CanvasAgentActionDeclaration[] | undefined = options.actions?.map( + ({ handler, ...wire }) => { + if (handler) { + actionHandlers.set(wire.name, handler); + } + return wire; + } + ); + this.declaration = { id: options.id, displayName: options.displayName, description: options.description, inputSchema: options.inputSchema, - actions: options.actions, + actions: wireActions, }; this.open = options.open; this.onAction = options.onAction; this.onClose = options.onClose; + this.actionHandlers = actionHandlers; } } @@ -253,10 +284,7 @@ export async function dispatchCanvasProviderRequest( return undefined; } default: { - if (!canvas.onAction) { - throw CanvasError.noHandler(); - } - return canvas.onAction({ + const actionCtx: CanvasActionContext = { sessionId: params.sessionId, extensionId: params.extensionId, canvasId: params.canvasId, @@ -264,7 +292,15 @@ export async function dispatchCanvasProviderRequest( actionName, input: params.input, host: params.host, - }); + }; + const perAction = canvas.actionHandlers.get(actionName); + if (perAction) { + return perAction(actionCtx); + } + if (canvas.onAction) { + return canvas.onAction(actionCtx); + } + throw CanvasError.noHandler(); } } } diff --git a/nodejs/src/extension.ts b/nodejs/src/extension.ts index 8c074d708..496cfca5c 100644 --- a/nodejs/src/extension.ts +++ b/nodejs/src/extension.ts @@ -15,6 +15,7 @@ export { Canvas, CanvasError, createCanvas, + type CanvasAction, type CanvasActionContext, type CanvasAgentActionDeclaration, type CanvasDeclaration, diff --git a/nodejs/src/index.ts b/nodejs/src/index.ts index 181f1f466..c841a9569 100644 --- a/nodejs/src/index.ts +++ b/nodejs/src/index.ts @@ -15,6 +15,7 @@ export { Canvas, CanvasError, createCanvas, + type CanvasAction, type CanvasActionContext, type CanvasAgentActionDeclaration, type CanvasDeclaration, diff --git a/nodejs/test/client.test.ts b/nodejs/test/client.test.ts index d8b3b982f..a3253281d 100644 --- a/nodejs/test/client.test.ts +++ b/nodejs/test/client.test.ts @@ -130,6 +130,49 @@ describe("CopilotClient", () => { expect(result).toEqual({ actionName: "increment", input: { amount: 1 } }); }); + it("prefers per-action handlers over the top-level onAction fallback", async () => { + const perAction = vi.fn(({ input }) => ({ source: "per-action", input })); + const onAction = vi.fn(() => ({ source: "onAction" })); + const canvas = createCanvas({ + id: "counter", + displayName: "Counter", + description: "A counter canvas", + open: () => ({ url: "https://example.test/counter" }), + actions: [{ name: "increment", handler: perAction }, { name: "reset" }], + onAction, + }); + // `handler` is authoring-only; the wire declaration must omit it. + expect(canvas.declaration.actions).toEqual([{ name: "increment" }, { name: "reset" }]); + + const session = new CopilotSession("session-1", {} as any); + session.registerCanvases([canvas]); + const client = new CopilotClient(); + (client as any).sessions.set(session.sessionId, session); + + const perActionResult = await (client as any).handleCanvasProviderRequest("increment", { + sessionId: session.sessionId, + extensionId: "project:counter", + canvasId: "counter", + instanceId: "counter-1", + actionName: "increment", + input: { amount: 2 }, + }); + expect(perActionResult).toEqual({ source: "per-action", input: { amount: 2 } }); + expect(perAction).toHaveBeenCalledTimes(1); + expect(onAction).not.toHaveBeenCalled(); + + const fallbackResult = await (client as any).handleCanvasProviderRequest("reset", { + sessionId: session.sessionId, + extensionId: "project:counter", + canvasId: "counter", + instanceId: "counter-1", + actionName: "reset", + input: undefined, + }); + expect(fallbackResult).toEqual({ source: "onAction" }); + expect(onAction).toHaveBeenCalledTimes(1); + }); + it("rejects malformed direct canvas action payloads", async () => { const client = new CopilotClient(); From cdd4a5e677d6d0a6fd6598051ace527642753922 Mon Sep 17 00:00:00 2001 From: jmoseley Date: Sat, 23 May 2026 17:03:44 -0700 Subject: [PATCH 14/29] Drop top-level onAction fallback on Node canvases Per-action handlers are now the only dispatch path. Declared actions without a handler fall through to canvas_action_no_handler. Keeps the action's metadata and behavior co-located and removes a second indirection that always boiled down to a switch on actionName. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- nodejs/src/canvas.ts | 37 ++++++++++------------------- nodejs/test/client.test.ts | 48 +++++++++++++++----------------------- 2 files changed, 31 insertions(+), 54 deletions(-) diff --git a/nodejs/src/canvas.ts b/nodejs/src/canvas.ts index 99ee42058..d41782984 100644 --- a/nodejs/src/canvas.ts +++ b/nodejs/src/canvas.ts @@ -42,10 +42,10 @@ export interface CanvasAgentActionDeclaration { /** * Authoring shape for an action passed to {@link createCanvas}. Extends the - * wire {@link CanvasAgentActionDeclaration} with an optional per-action - * `handler`. When set, the handler is preferred over the top-level - * {@link CanvasOptions.onAction} for matching `actionName` dispatches; it is - * stripped before the declaration is sent on the wire. + * wire {@link CanvasAgentActionDeclaration} with an optional `handler` + * closure; the handler is stripped before the declaration is sent on the + * wire so only the action's `name`, `description`, and `inputSchema` reach + * the runtime. */ export interface CanvasAction extends CanvasAgentActionDeclaration { /** Optional per-action dispatch handler. */ @@ -104,7 +104,7 @@ export interface CanvasOpenContext { host?: CanvasHostContext; } -/** Context handed to a canvas's `onAction` handler. */ +/** Context handed to a canvas action handler. */ export interface CanvasActionContext { /** Session that invoked the action. */ sessionId: string; @@ -146,7 +146,7 @@ export class CanvasError extends Error { this.name = "CanvasError"; } - /** Default error when an action is declared but no `onAction` is wired. */ + /** Default error when an action is declared but no `handler` is wired. */ static noHandler(): CanvasError { return new CanvasError( "canvas_action_no_handler", @@ -178,13 +178,6 @@ export interface CanvasOptions { /** Required. Open a new canvas instance. */ open: (ctx: CanvasOpenContext) => Promise | CanvasOpenResponse; - /** - * Optional. Fallback handler invoked when an action has no per-action - * `handler`. If neither is wired for the dispatched action, the SDK - * returns `canvas_action_no_handler`. - */ - onAction?: (ctx: CanvasActionContext) => Promise | unknown; - /** * 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 @@ -197,7 +190,6 @@ export interface CanvasOptions { export class Canvas { readonly declaration: CanvasDeclaration; readonly open: NonNullable; - readonly onAction?: CanvasOptions["onAction"]; readonly onClose?: CanvasOptions["onClose"]; /** @internal */ readonly actionHandlers: Map>; @@ -222,7 +214,6 @@ export class Canvas { actions: wireActions, }; this.open = options.open; - this.onAction = options.onAction; this.onClose = options.onClose; this.actionHandlers = actionHandlers; } @@ -284,7 +275,11 @@ export async function dispatchCanvasProviderRequest( return undefined; } default: { - const actionCtx: CanvasActionContext = { + const perAction = canvas.actionHandlers.get(actionName); + if (!perAction) { + throw CanvasError.noHandler(); + } + return perAction({ sessionId: params.sessionId, extensionId: params.extensionId, canvasId: params.canvasId, @@ -292,15 +287,7 @@ export async function dispatchCanvasProviderRequest( actionName, input: params.input, host: params.host, - }; - const perAction = canvas.actionHandlers.get(actionName); - if (perAction) { - return perAction(actionCtx); - } - if (canvas.onAction) { - return canvas.onAction(actionCtx); - } - throw CanvasError.noHandler(); + }); } } } diff --git a/nodejs/test/client.test.ts b/nodejs/test/client.test.ts index a3253281d..808925b22 100644 --- a/nodejs/test/client.test.ts +++ b/nodejs/test/client.test.ts @@ -111,7 +111,12 @@ describe("CopilotClient", () => { displayName: "Counter", description: "A counter canvas", open: ({ instanceId }) => ({ url: `https://example.test/${instanceId}` }), - onAction: ({ actionName, input }) => ({ actionName, input }), + actions: [ + { + name: "increment", + handler: ({ actionName, input }) => ({ actionName, input }), + }, + ], }); const session = new CopilotSession("session-1", {} as any); session.registerCanvases([canvas]); @@ -130,47 +135,32 @@ describe("CopilotClient", () => { expect(result).toEqual({ actionName: "increment", input: { amount: 1 } }); }); - it("prefers per-action handlers over the top-level onAction fallback", async () => { - const perAction = vi.fn(({ input }) => ({ source: "per-action", input })); - const onAction = vi.fn(() => ({ source: "onAction" })); + it("returns canvas_action_no_handler when no per-action handler is registered", async () => { const canvas = createCanvas({ id: "counter", displayName: "Counter", description: "A counter canvas", open: () => ({ url: "https://example.test/counter" }), - actions: [{ name: "increment", handler: perAction }, { name: "reset" }], - onAction, + actions: [{ name: "reset" }], }); // `handler` is authoring-only; the wire declaration must omit it. - expect(canvas.declaration.actions).toEqual([{ name: "increment" }, { name: "reset" }]); + expect(canvas.declaration.actions).toEqual([{ name: "reset" }]); const session = new CopilotSession("session-1", {} as any); session.registerCanvases([canvas]); const client = new CopilotClient(); (client as any).sessions.set(session.sessionId, session); - const perActionResult = await (client as any).handleCanvasProviderRequest("increment", { - sessionId: session.sessionId, - extensionId: "project:counter", - canvasId: "counter", - instanceId: "counter-1", - actionName: "increment", - input: { amount: 2 }, - }); - expect(perActionResult).toEqual({ source: "per-action", input: { amount: 2 } }); - expect(perAction).toHaveBeenCalledTimes(1); - expect(onAction).not.toHaveBeenCalled(); - - const fallbackResult = await (client as any).handleCanvasProviderRequest("reset", { - sessionId: session.sessionId, - extensionId: "project:counter", - canvasId: "counter", - instanceId: "counter-1", - actionName: "reset", - input: undefined, - }); - expect(fallbackResult).toEqual({ source: "onAction" }); - expect(onAction).toHaveBeenCalledTimes(1); + await expect( + (client as any).handleCanvasProviderRequest("reset", { + sessionId: session.sessionId, + extensionId: "project:counter", + canvasId: "counter", + instanceId: "counter-1", + actionName: "reset", + input: undefined, + }) + ).rejects.toMatchObject({ code: "canvas_action_no_handler" }); }); it("rejects malformed direct canvas action payloads", async () => { From b60c9cbe8931243a96b45fab43aedcb38444aa3a Mon Sep 17 00:00:00 2001 From: jmoseley Date: Sat, 23 May 2026 19:49:17 -0700 Subject: [PATCH 15/29] Normalize JSDoc style on CanvasAgentActionDeclaration Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- nodejs/src/canvas.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/nodejs/src/canvas.ts b/nodejs/src/canvas.ts index d41782984..2fd869b62 100644 --- a/nodejs/src/canvas.ts +++ b/nodejs/src/canvas.ts @@ -27,8 +27,9 @@ export interface CanvasToolDefinition { defer?: "auto" | "never"; } -/** A single agent-callable action contributed by a canvas, as serialized over - * the wire. Names MUST NOT start with `canvas.` - that prefix is reserved for +/** + * A single agent-callable action contributed by a canvas, as serialized over + * the wire. Names MUST NOT start with `canvas.` — that prefix is reserved for * lifecycle verbs. */ export interface CanvasAgentActionDeclaration { From 86b2cc97fbfd309794c40e2556351b4203c7f5dc Mon Sep 17 00:00:00 2001 From: jmoseley Date: Sat, 23 May 2026 19:52:03 -0700 Subject: [PATCH 16/29] Drop stale wire-rename guards from canvas declaration test Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- rust/src/canvas.rs | 2 -- 1 file changed, 2 deletions(-) diff --git a/rust/src/canvas.rs b/rust/src/canvas.rs index 965bf1fd5..9084a07d8 100644 --- a/rust/src/canvas.rs +++ b/rust/src/canvas.rs @@ -681,8 +681,6 @@ mod tests { assert_eq!(value["displayName"], "Counter"); assert_eq!(value["description"], "Count things"); assert_eq!(value["actions"][0]["name"], "increment"); - assert!(value.get("agentActions").is_none()); - assert!(value.get("toolbar").is_none()); } #[tokio::test] From 0f597e9a95947d05ccefa7a796633020a0ffdb17 Mon Sep 17 00:00:00 2001 From: jmoseley Date: Sat, 23 May 2026 20:00:27 -0700 Subject: [PATCH 17/29] Document canvas action dispatch divergence in Rust SDK Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- rust/README.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/rust/README.md b/rust/README.md index 4ed046230..f4d80fefd 100644 --- a/rust/README.md +++ b/rust/README.md @@ -698,6 +698,15 @@ let session = client See [`examples/session_fs.rs`](examples/session_fs.rs) for a complete in-memory provider implementation. +- **Canvas action dispatch is a single trait method, not per-action closures.** + The Node SDK binds an optional `handler` closure on each entry of a canvas's + `actions[]`. The Rust SDK exposes + [`CanvasHandler::on_action`](crate::canvas::CanvasHandler::on_action) and expects the implementor to match on + `ctx.action_name`. Same reasoning as `SessionFsProvider`: per-callback + `Box` fields fight `Send + Sync + 'static` and skip exhaustiveness + checks, and the SDK prefers trait + default-impl methods for handler-shaped + extension points. + ### Rust-only API A handful of conveniences exist only on the Rust SDK as of 0.1.0. These From f5c76f8d08545c2a3562a18f9cabb9d1fbe05bdc Mon Sep 17 00:00:00 2001 From: jmoseley Date: Sat, 23 May 2026 20:02:17 -0700 Subject: [PATCH 18/29] Require handler on canvas actions Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- nodejs/src/canvas.ts | 14 ++++++-------- nodejs/test/client.test.ts | 7 ++----- 2 files changed, 8 insertions(+), 13 deletions(-) diff --git a/nodejs/src/canvas.ts b/nodejs/src/canvas.ts index 2fd869b62..2a2f7cb74 100644 --- a/nodejs/src/canvas.ts +++ b/nodejs/src/canvas.ts @@ -49,8 +49,8 @@ export interface CanvasAgentActionDeclaration { * the runtime. */ export interface CanvasAction extends CanvasAgentActionDeclaration { - /** Optional per-action dispatch handler. */ - handler?: (ctx: CanvasActionContext) => Promise | unknown; + /** Required per-action dispatch handler. */ + handler: (ctx: CanvasActionContext) => Promise | unknown; } /** @@ -171,7 +171,7 @@ export interface CanvasOptions { inputSchema?: CanvasJsonSchema; /** * Agent-invocable actions exposed via `invoke_canvas_action`. Each action - * may carry its own optional `handler`; the action's wire metadata + * carries its own required `handler`; the action's wire metadata * (`name`, `description`, `inputSchema`) is what reaches the runtime. */ actions?: CanvasAction[]; @@ -193,16 +193,14 @@ export class Canvas { readonly open: NonNullable; readonly onClose?: CanvasOptions["onClose"]; /** @internal */ - readonly actionHandlers: Map>; + readonly actionHandlers: Map; /** @internal */ constructor(options: CanvasOptions) { - const actionHandlers = new Map>(); + const actionHandlers = new Map(); const wireActions: CanvasAgentActionDeclaration[] | undefined = options.actions?.map( ({ handler, ...wire }) => { - if (handler) { - actionHandlers.set(wire.name, handler); - } + actionHandlers.set(wire.name, handler); return wire; } ); diff --git a/nodejs/test/client.test.ts b/nodejs/test/client.test.ts index 808925b22..ff46c75b3 100644 --- a/nodejs/test/client.test.ts +++ b/nodejs/test/client.test.ts @@ -141,10 +141,7 @@ describe("CopilotClient", () => { displayName: "Counter", description: "A counter canvas", open: () => ({ url: "https://example.test/counter" }), - actions: [{ name: "reset" }], }); - // `handler` is authoring-only; the wire declaration must omit it. - expect(canvas.declaration.actions).toEqual([{ name: "reset" }]); const session = new CopilotSession("session-1", {} as any); session.registerCanvases([canvas]); @@ -152,12 +149,12 @@ describe("CopilotClient", () => { (client as any).sessions.set(session.sessionId, session); await expect( - (client as any).handleCanvasProviderRequest("reset", { + (client as any).handleCanvasProviderRequest("ghost", { sessionId: session.sessionId, extensionId: "project:counter", canvasId: "counter", instanceId: "counter-1", - actionName: "reset", + actionName: "ghost", input: undefined, }) ).rejects.toMatchObject({ code: "canvas_action_no_handler" }); From 37c8d36cd1aeb403a3541a5067193e8d81516722 Mon Sep 17 00:00:00 2001 From: jmoseley Date: Sat, 23 May 2026 20:05:15 -0700 Subject: [PATCH 19/29] Collapse CanvasAgentActionDeclaration into CanvasAction Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- nodejs/src/canvas.ts | 26 ++++++++++---------------- nodejs/src/extension.ts | 1 - nodejs/src/index.ts | 1 - 3 files changed, 10 insertions(+), 18 deletions(-) diff --git a/nodejs/src/canvas.ts b/nodejs/src/canvas.ts index 2a2f7cb74..c6c0df931 100644 --- a/nodejs/src/canvas.ts +++ b/nodejs/src/canvas.ts @@ -28,27 +28,21 @@ export interface CanvasToolDefinition { } /** - * A single agent-callable action contributed by a canvas, as serialized over - * the wire. Names MUST NOT start with `canvas.` — that prefix is reserved for + * A single agent-callable action contributed by a canvas. The metadata + * (`name`, `description`, `inputSchema`) is serialized over the wire on + * `session.create` / `session.resume`; the `handler` closure is stripped + * before the declaration is sent and dispatched in-process by the SDK. + * + * Names MUST NOT start with `canvas.` — that prefix is reserved for * lifecycle verbs. */ -export interface CanvasAgentActionDeclaration { +export interface CanvasAction { /** Action identifier, unique within the canvas. */ name: string; /** Description shown to the model when picking an action. */ description?: string; /** Optional JSON Schema for the action's `input` payload. */ inputSchema?: CanvasJsonSchema; -} - -/** - * Authoring shape for an action passed to {@link createCanvas}. Extends the - * wire {@link CanvasAgentActionDeclaration} with an optional `handler` - * closure; the handler is stripped before the declaration is sent on the - * wire so only the action's `name`, `description`, and `inputSchema` reach - * the runtime. - */ -export interface CanvasAction extends CanvasAgentActionDeclaration { /** Required per-action dispatch handler. */ handler: (ctx: CanvasActionContext) => Promise | unknown; } @@ -67,7 +61,7 @@ export interface CanvasDeclaration { /** Optional JSON Schema for the `input` payload accepted by `canvas.open`. */ inputSchema?: CanvasJsonSchema; /** Agent-invocable actions exposed via `invoke_canvas_action`. */ - actions?: CanvasAgentActionDeclaration[]; + actions?: Omit[]; } /** Response returned from `open`. */ @@ -115,7 +109,7 @@ export interface CanvasActionContext { canvasId: string; /** Instance id targeted by the action. */ instanceId: string; - /** Action name from `CanvasAgentActionDeclaration.name`. */ + /** Action name from `CanvasAction.name`. */ actionName: string; /** Validated `input` payload, shaped by the action's `inputSchema`. */ input: unknown; @@ -198,7 +192,7 @@ export class Canvas { /** @internal */ constructor(options: CanvasOptions) { const actionHandlers = new Map(); - const wireActions: CanvasAgentActionDeclaration[] | undefined = options.actions?.map( + const wireActions: Omit[] | undefined = options.actions?.map( ({ handler, ...wire }) => { actionHandlers.set(wire.name, handler); return wire; diff --git a/nodejs/src/extension.ts b/nodejs/src/extension.ts index 496cfca5c..360df06db 100644 --- a/nodejs/src/extension.ts +++ b/nodejs/src/extension.ts @@ -17,7 +17,6 @@ export { createCanvas, type CanvasAction, type CanvasActionContext, - type CanvasAgentActionDeclaration, type CanvasDeclaration, type CanvasHostContext, type CanvasJsonSchema, diff --git a/nodejs/src/index.ts b/nodejs/src/index.ts index c841a9569..13bf2bad3 100644 --- a/nodejs/src/index.ts +++ b/nodejs/src/index.ts @@ -17,7 +17,6 @@ export { createCanvas, type CanvasAction, type CanvasActionContext, - type CanvasAgentActionDeclaration, type CanvasDeclaration, type CanvasHostContext, type CanvasJsonSchema, From 5115ce800b6921f51d4647ed1df27d42f3b9dd67 Mon Sep 17 00:00:00 2001 From: jmoseley Date: Sat, 23 May 2026 20:10:45 -0700 Subject: [PATCH 20/29] Drop debug log for inbound JSON-RPC requests Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- rust/src/jsonrpc.rs | 5 ----- 1 file changed, 5 deletions(-) diff --git a/rust/src/jsonrpc.rs b/rust/src/jsonrpc.rs index cf8d46cdf..88a9670cd 100644 --- a/rust/src/jsonrpc.rs +++ b/rust/src/jsonrpc.rs @@ -306,11 +306,6 @@ impl JsonRpcClient { let _ = notification_tx.send(notification); } JsonRpcMessage::Request(request) => { - debug!( - request_id = request.id, - method = %request.method, - "received JSON-RPC request from runtime" - ); if request_tx.send(request).is_err() { warn!("failed to forward JSON-RPC request, channel closed"); } From c1146c07756d695871f6fc4eb6c5e139b6fa7423 Mon Sep 17 00:00:00 2001 From: jmoseley Date: Sat, 23 May 2026 20:14:24 -0700 Subject: [PATCH 21/29] Drop canvas debug logs Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- rust/src/router.rs | 6 ------ rust/src/session.rs | 36 ++++++------------------------------ 2 files changed, 6 insertions(+), 36 deletions(-) diff --git a/rust/src/router.rs b/rust/src/router.rs index 6714b0f98..e14630e03 100644 --- a/rust/src/router.rs +++ b/rust/src/router.rs @@ -157,12 +157,6 @@ impl SessionRouter { guard.get(sid).map(|s| s.requests.clone()) }; if let Some(sender) = sender { - tracing::debug!( - session_id = sid, - request_id = request.id, - method = %request.method, - "routing JSON-RPC request to registered session" - ); let _ = sender.send(request); } else { warn!( diff --git a/rust/src/session.rs b/rust/src/session.rs index c2d978a69..7db6a1658 100644 --- a/rust/src/session.rs +++ b/rust/src/session.rs @@ -1855,11 +1855,6 @@ async fn handle_request( match request.method.as_str() { "canvas.open" => { - tracing::debug!( - session_id = %sid, - request_id = request.id, - "handling canvas.open provider request" - ); let Some(params) = parse_request_params::(client, request.id, &request) .await @@ -1871,11 +1866,6 @@ async fn handle_request( } "canvas.close" => { - tracing::debug!( - session_id = %sid, - request_id = request.id, - "handling canvas.close provider request" - ); let Some(params) = parse_request_params::(client, request.id, &request) .await @@ -1887,11 +1877,6 @@ async fn handle_request( } "canvas.action.invoke" => { - tracing::debug!( - session_id = %sid, - request_id = request.id, - "handling canvas.action.invoke provider request" - ); let Some(params) = parse_request_params::(client, request.id, &request).await else { @@ -2187,21 +2172,12 @@ async fn send_canvas_dispatch_response( }), }, }; - match client.send_response(&response).await { - Ok(()) => { - tracing::debug!( - request_id = id, - success = response.error.is_none(), - "sent canvas provider response" - ); - } - Err(error) => { - warn!( - request_id = id, - error = %error, - "failed to send canvas provider response" - ); - } + if let Err(error) = client.send_response(&response).await { + warn!( + request_id = id, + error = %error, + "failed to send canvas provider response" + ); } } From 3475390c5324b92c3cc33be3a535faa6c03e3f2f Mon Sep 17 00:00:00 2001 From: jmoseley Date: Sat, 23 May 2026 20:24:45 -0700 Subject: [PATCH 22/29] Remove canvas tools field Drop CanvasToolDefinition, CanvasToolDefinitionDefer, and the CanvasOpenResponse.tools / OpenCanvasInstance.tools fields from both the Node and Rust SDKs. The CLI side is being removed in lockstep, so the wire contract no longer carries this field. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- nodejs/src/canvas.ts | 15 +------------ nodejs/src/extension.ts | 1 - nodejs/src/index.ts | 1 - rust/src/canvas.rs | 43 -------------------------------------- rust/tests/session_test.rs | 1 - 5 files changed, 1 insertion(+), 60 deletions(-) diff --git a/nodejs/src/canvas.ts b/nodejs/src/canvas.ts index c6c0df931..7d40bd966 100644 --- a/nodejs/src/canvas.ts +++ b/nodejs/src/canvas.ts @@ -13,20 +13,9 @@ * focuses an existing panel; reload is a renderer-only concern. */ -/** JSON Schema object used for canvas inputs and canvas-scoped tools. */ +/** JSON Schema object used for canvas inputs. */ export type CanvasJsonSchema = Record; -/** Tool definition exposed to a canvas instance. */ -export interface CanvasToolDefinition { - name: string; - description: string; - title?: string; - parameters?: CanvasJsonSchema; - overridesBuiltInTool?: boolean; - skipPermission?: boolean; - defer?: "auto" | "never"; -} - /** * A single agent-callable action contributed by a canvas. The metadata * (`name`, `description`, `inputSchema`) is serialized over the wire on @@ -72,8 +61,6 @@ export interface CanvasOpenResponse { title?: string; /** Provider-supplied status text shown in host chrome. */ status?: string; - /** Tools available to the canvas instance. */ - tools?: CanvasToolDefinition[]; } /** Host capabilities passed to canvas callbacks. */ diff --git a/nodejs/src/extension.ts b/nodejs/src/extension.ts index 360df06db..95346dec4 100644 --- a/nodejs/src/extension.ts +++ b/nodejs/src/extension.ts @@ -24,7 +24,6 @@ export { type CanvasOpenContext, type CanvasOpenResponse, type CanvasOptions, - type CanvasToolDefinition, } from "./canvas.js"; export type JoinSessionConfig = Omit & { diff --git a/nodejs/src/index.ts b/nodejs/src/index.ts index 13bf2bad3..42498c58f 100644 --- a/nodejs/src/index.ts +++ b/nodejs/src/index.ts @@ -24,7 +24,6 @@ export { type CanvasOpenContext, type CanvasOpenResponse, type CanvasOptions, - type CanvasToolDefinition, } from "./canvas.js"; export { defineTool, diff --git a/rust/src/canvas.rs b/rust/src/canvas.rs index 9084a07d8..b19ed0d5d 100644 --- a/rust/src/canvas.rs +++ b/rust/src/canvas.rs @@ -13,41 +13,6 @@ use crate::types::SessionId; /// JSON Schema object used for canvas inputs and canvas-scoped tools. pub type CanvasJsonSchema = serde_json::Map; -/// Tool definition exposed to a canvas instance. -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "camelCase")] -pub struct CanvasToolDefinition { - /// Tool name. - pub name: String, - /// Tool description. - pub description: String, - /// Human-readable tool title. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub title: Option, - /// JSON Schema parameters for the tool. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub parameters: Option, - /// Whether this tool overrides a built-in tool. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub overrides_built_in_tool: Option, - /// Whether this tool skips permission prompts. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub skip_permission: Option, - /// Tool deferral behavior. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub defer: Option, -} - -/// Tool deferral behavior for canvas-scoped tools. -#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "kebab-case")] -pub enum CanvasToolDefinitionDefer { - /// The tool may be deferred by the runtime. - Auto, - /// The tool is always included in the initial tool list. - Never, -} - /// Runtime-controlled routing state for an open canvas instance. #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "lowercase")] @@ -130,9 +95,6 @@ pub struct CanvasOpenResponse { /// Provider-supplied status text shown in host chrome. #[serde(default, skip_serializing_if = "Option::is_none")] pub status: Option, - /// Tools available to the canvas instance. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub tools: Option>, } /// Open canvas instance returned by `session.canvas.open`, @@ -159,9 +121,6 @@ pub struct OpenCanvasInstance { /// URL for web-rendered canvases. #[serde(default, skip_serializing_if = "Option::is_none")] pub url: Option, - /// Tools available to the canvas instance. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub tools: Option>, /// Input supplied when the instance was opened. #[serde(default, skip_serializing_if = "Option::is_none")] pub input: Option, @@ -186,7 +145,6 @@ impl OpenCanvasInstance { title: None, status: None, url: None, - tools: None, input: None, reopen: false, availability: CanvasInstanceAvailability::Stale, @@ -652,7 +610,6 @@ mod tests { url: Some(format!("https://example.test/{}", ctx.canvas_id)), title: Some("Echo".to_string()), status: Some("ready".to_string()), - tools: None, }) } diff --git a/rust/tests/session_test.rs b/rust/tests/session_test.rs index e2c59114c..4399b6e38 100644 --- a/rust/tests/session_test.rs +++ b/rust/tests/session_test.rs @@ -36,7 +36,6 @@ impl CanvasHandler for TestCanvasHandler { url: Some(format!("https://example.test/{}", ctx.canvas_id)), title: Some("Test Canvas".to_string()), status: Some("ready".to_string()), - tools: None, }) } From 3c0850d432d046650aa86cc4ec790bfb6ed9bbaf Mon Sep 17 00:00:00 2001 From: jmoseley Date: Sun, 24 May 2026 07:52:09 -0700 Subject: [PATCH 23/29] rust: slim canvas surface to wire types + CanvasHandler Move per-canvas registry, Canvas builder, dispatch helpers, and the SessionCanvas host helper out of the SDK. The Rust canvas surface now matches the other typed extension points (PermissionHandler / UserInputHandler / HookHandler): SessionConfig .with_canvases([CanvasDeclaration, ...]) .with_canvas_handler(Arc::new(MyHandler)) Removed: - canvas::Canvas, CanvasBuilder (declaration+handler bundle) - canvas::CanvasRegistry, build_registry, dispatch_canvas_* - session::SessionCanvas + Session::canvas() accessor (callers move to session.rpc().canvas().*) Kept (the wire boundary + typed extension point): - All wire types (CanvasDeclaration, OpenCanvasInstance, ...) - CanvasHandler trait + on_open/on_action/on_close - SessionConfig/ResumeSessionConfig.canvases (now Vec) - SessionConfig/ResumeSessionConfig.canvas_handler handle_request dispatches canvas.open/close/action.invoke directly to the handler; the per-canvas registry now lives in the app layer. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- rust/src/canvas.rs | 316 +++++++++++-------------------------- rust/src/session.rs | 185 +++++++--------------- rust/src/types.rs | 77 +++++---- rust/tests/session_test.rs | 142 ++--------------- 4 files changed, 207 insertions(+), 513 deletions(-) diff --git a/rust/src/canvas.rs b/rust/src/canvas.rs index b19ed0d5d..618b268f8 100644 --- a/rust/src/canvas.rs +++ b/rust/src/canvas.rs @@ -1,8 +1,5 @@ //! Canvas declarations, provider callbacks, and host-side canvas RPC types. -use std::collections::HashMap; -use std::sync::Arc; - use async_trait::async_trait; use serde::{Deserialize, Serialize}; use serde_json::Value; @@ -194,7 +191,7 @@ impl OpenCanvasInstance { } } -/// Result returned by [`SessionCanvas::discover`](crate::session::SessionCanvas::discover). +/// Result returned by the `session.canvas.discover` RPC. #[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "camelCase")] pub struct CanvasDiscoverResult { @@ -225,7 +222,7 @@ pub struct DiscoveredCanvas { pub actions: Option>, } -/// Result returned by [`SessionCanvas::list_open`](crate::session::SessionCanvas::list_open). +/// Result returned by the `session.canvas.listOpen` RPC. #[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "camelCase")] pub struct CanvasListOpenResult { @@ -379,7 +376,17 @@ impl CanvasError { /// Result alias for canvas handler methods. pub type CanvasResult = Result; -/// Per-canvas handler implementing provider-side canvas lifecycle callbacks. +/// Provider-side canvas lifecycle handler. +/// +/// A session installs a single [`CanvasHandler`] (via +/// [`SessionConfig::with_canvas_handler`](crate::types::SessionConfig::with_canvas_handler)). +/// The handler receives every inbound `canvas.open` / `canvas.close` / +/// `canvas.action.invoke` JSON-RPC request the runtime issues for this +/// session and decides — typically by inspecting [`CanvasOpenContext::canvas_id`] +/// — which application-side canvas should handle the call. +/// +/// The SDK does not maintain a per-canvas registry; multiplexing across +/// declared canvases is the implementor's responsibility. #[async_trait] pub trait CanvasHandler: Send + Sync { /// Open a new canvas instance. @@ -396,107 +403,16 @@ pub trait CanvasHandler: Send + Sync { } } -/// A registered canvas: declarative metadata plus an in-process handler. -#[derive(Clone)] -pub struct Canvas { - declaration: CanvasDeclaration, - handler: Arc, -} - -impl Canvas { - /// Begin building a canvas from its declarative metadata. - pub fn builder(declaration: CanvasDeclaration) -> CanvasBuilder { - CanvasBuilder { - declaration, - handler: None, - } - } - - /// Borrow the declarative metadata serialized onto the wire. - pub fn declaration(&self) -> &CanvasDeclaration { - &self.declaration - } - - /// Clone the in-process handler for dispatch. - pub fn handler(&self) -> Arc { - self.handler.clone() - } -} - -impl Serialize for Canvas { - fn serialize(&self, serializer: S) -> Result { - self.declaration.serialize(serializer) - } -} - -impl std::fmt::Debug for Canvas { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.debug_struct("Canvas") - .field("declaration", &self.declaration) - .field("handler", &"") - .finish() - } -} - -/// Builder for [`Canvas`]. -pub struct CanvasBuilder { - declaration: CanvasDeclaration, - handler: Option>, -} - -impl CanvasBuilder { - /// Attach the per-canvas handler. - pub fn handler(mut self, handler: Arc) -> Self { - self.handler = Some(handler); - self - } - - /// Finalize into a [`Canvas`]. - /// - /// Returns an error if no handler was attached. - pub fn build(self) -> CanvasResult { - let Some(handler) = self.handler else { - return Err(CanvasError::new( - "canvas_builder_missing_handler", - "Canvas::builder().handler(...) must be called before build()", - )); - }; - - Ok(Canvas { - declaration: self.declaration, - handler, - }) - } -} - -/// Per-session canvas registry, keyed by canvas id. -pub type CanvasRegistry = HashMap>; - -/// Build a [`CanvasRegistry`] from a session's declared canvases. -pub fn build_registry(canvases: &[Canvas]) -> CanvasRegistry { - let mut map = CanvasRegistry::new(); - for canvas in canvases { - map.insert(canvas.declaration.id.clone(), canvas.handler.clone()); - } - map -} - /// Common fields sent by direct `canvas.*` provider callbacks. #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "camelCase")] -pub struct CanvasProviderRequestParams { - /// Session that requested the canvas operation. +pub(crate) struct CanvasProviderRequestParams { pub session_id: SessionId, - /// Owning provider identifier. pub extension_id: String, - /// Provider-local canvas identifier. pub canvas_id: String, - /// Open canvas instance identifier. pub instance_id: String, - /// Optional provider input payload. #[serde(default)] pub input: Value, - /// Host capabilities supplied by the runtime. #[serde(default)] pub host: Option, } @@ -504,95 +420,53 @@ pub struct CanvasProviderRequestParams { /// Wire-level params for `canvas.action.invoke`. #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "camelCase")] -pub struct CanvasInvokeParams { - /// Session that requested the canvas operation. +pub(crate) struct CanvasInvokeParams { pub session_id: SessionId, - /// Owning provider identifier. pub extension_id: String, - /// Provider-local canvas identifier. pub canvas_id: String, - /// Open canvas instance identifier. pub instance_id: String, - /// Custom action name. pub action_name: String, - /// Optional provider input payload. #[serde(default)] pub input: Value, - /// Host capabilities supplied by the runtime. #[serde(default)] pub host: Option, } -/// Resolve a direct `canvas.open` request against a registry. -pub async fn dispatch_canvas_open( - registry: &CanvasRegistry, - params: CanvasProviderRequestParams, -) -> CanvasResult { - let handler = canvas_handler(registry, ¶ms.canvas_id)?; - let response = handler - .on_open(CanvasOpenContext { - session_id: params.session_id, - extension_id: params.extension_id, - canvas_id: params.canvas_id, - instance_id: params.instance_id, - input: params.input, - host: params.host, - }) - .await?; - serde_json::to_value(response).map_err(|error| { - CanvasError::new( - "canvas_open_response_serialization_failed", - format!("failed to serialize canvas.open response: {error}"), - ) - }) -} - -/// Resolve a direct `canvas.close` request. -pub async fn dispatch_canvas_close( - registry: &CanvasRegistry, - params: CanvasProviderRequestParams, -) -> CanvasResult { - let handler = canvas_handler(registry, ¶ms.canvas_id)?; - let ctx = CanvasLifecycleContext { - session_id: params.session_id, - extension_id: params.extension_id, - canvas_id: params.canvas_id, - instance_id: params.instance_id, - host: params.host, - }; - handler.on_close(ctx).await?; - Ok(Value::Null) -} +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, + } + } -/// Resolve a direct `canvas.action.invoke` request against a registry. -pub async fn dispatch_canvas_action( - registry: &CanvasRegistry, - params: CanvasInvokeParams, -) -> CanvasResult { - let handler = canvas_handler(registry, ¶ms.canvas_id)?; - handler - .on_action(CanvasActionContext { - session_id: params.session_id, - extension_id: params.extension_id, - canvas_id: params.canvas_id, - instance_id: params.instance_id, - action_name: params.action_name, - input: params.input, - host: params.host, - }) - .await + 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, + } + } } -fn canvas_handler( - registry: &CanvasRegistry, - canvas_id: &str, -) -> CanvasResult> { - registry.get(canvas_id).cloned().ok_or_else(|| { - CanvasError::new( - "canvas_not_found", - format!("No canvas registered with id '{canvas_id}'"), - ) - }) +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)] @@ -641,39 +515,30 @@ mod tests { } #[tokio::test] - async fn dispatch_routes_canvas_open() { - let canvas = Canvas::builder(CanvasDeclaration::new("echo", "Echo", "Echo values")) - .handler(Arc::new(EchoHandler)) - .build() + async fn handler_on_open_returns_response() { + let handler = EchoHandler; + let response = handler + .on_open(CanvasOpenContext { + session_id: SessionId::from("s1"), + extension_id: "project:echo".to_string(), + canvas_id: "echo".to_string(), + instance_id: "echo-1".to_string(), + input: json!({ "x": 1 }), + host: None, + }) + .await .unwrap(); - let registry = build_registry(&[canvas]); - let params = CanvasProviderRequestParams { - session_id: SessionId::from("s1"), - extension_id: "project:echo".to_string(), - canvas_id: "echo".to_string(), - instance_id: "echo-1".to_string(), - input: json!({ "x": 1 }), - host: None, - }; - let result = dispatch_canvas_open(®istry, params).await.unwrap(); - - assert_eq!(result["url"], "https://example.test/echo"); - assert_eq!(result["title"], "Echo"); - assert_eq!(result["status"], "ready"); + assert_eq!(response.url.as_deref(), Some("https://example.test/echo")); + assert_eq!(response.title.as_deref(), Some("Echo")); + assert_eq!(response.status.as_deref(), Some("ready")); } #[tokio::test] - async fn dispatch_routes_custom_action() { - let canvas = Canvas::builder(CanvasDeclaration::new("echo", "Echo", "Echo values")) - .handler(Arc::new(EchoHandler)) - .build() - .unwrap(); - let registry = build_registry(&[canvas]); - - let result = dispatch_canvas_action( - ®istry, - CanvasInvokeParams { + async fn handler_on_action_returns_value() { + let handler = EchoHandler; + let result = handler + .on_action(CanvasActionContext { session_id: SessionId::from("s1"), extension_id: "project:echo".to_string(), canvas_id: "echo".to_string(), @@ -681,40 +546,41 @@ mod tests { action_name: "shout".to_string(), input: json!("hi"), host: None, - }, - ) - .await - .unwrap(); + }) + .await + .unwrap(); assert_eq!(result["echoed"], "shout"); assert_eq!(result["input"], "hi"); } #[tokio::test] - async fn dispatch_unknown_canvas_errors() { - let err = dispatch_canvas_open( - &CanvasRegistry::new(), - CanvasProviderRequestParams { + async fn default_on_action_returns_no_handler_error() { + struct OpenOnly; + #[async_trait] + impl CanvasHandler for OpenOnly { + async fn on_open(&self, _ctx: CanvasOpenContext) -> CanvasResult { + Ok(CanvasOpenResponse { + url: None, + title: None, + status: None, + }) + } + } + + let err = OpenOnly + .on_action(CanvasActionContext { session_id: SessionId::from("s1"), - extension_id: "project:missing".to_string(), - canvas_id: "missing".to_string(), - instance_id: "missing-1".to_string(), + extension_id: "project:open-only".to_string(), + canvas_id: "x".to_string(), + instance_id: "x-1".to_string(), + action_name: "anything".to_string(), input: Value::Null, host: None, - }, - ) - .await - .unwrap_err(); - - assert_eq!(err.code, "canvas_not_found"); - } - - #[test] - fn builder_requires_handler() { - let err = Canvas::builder(CanvasDeclaration::new("echo", "Echo", "Echo values")) - .build() + }) + .await .unwrap_err(); - assert_eq!(err.code, "canvas_builder_missing_handler"); + assert_eq!(err.code, "canvas_action_no_handler"); } } diff --git a/rust/src/session.rs b/rust/src/session.rs index 7db6a1658..d05c94223 100644 --- a/rust/src/session.rs +++ b/rust/src/session.rs @@ -4,7 +4,6 @@ use std::sync::Arc; use std::time::{Duration, Instant}; use parking_lot::Mutex as ParkingLotMutex; -use serde::Serialize; use serde::de::DeserializeOwned; use serde_json::Value; use tokio::sync::oneshot; @@ -13,9 +12,7 @@ use tokio_util::sync::CancellationToken; use tracing::{Instrument, warn}; use crate::canvas::{ - CanvasCloseRequest, CanvasDiscoverResult, CanvasInvokeActionRequest, CanvasInvokeActionResult, - CanvasInvokeParams, CanvasListOpenResult, CanvasOpenRequest, CanvasProviderRequestParams, - CanvasRegistry, OpenCanvasInstance, + CanvasHandler, CanvasInvokeParams, CanvasProviderRequestParams, OpenCanvasInstance, }; use crate::generated::api_types::{LogRequest, ModelSwitchToRequest}; use crate::generated::session_events::{ @@ -205,13 +202,8 @@ impl Session { self.capabilities.read().clone() } - /// Canvas host API methods for this session. - pub fn canvas(&self) -> SessionCanvas<'_> { - SessionCanvas { session: self } - } - /// Open canvas instances reported by the most recent `session.resume` - /// response or calls made through [`SessionCanvas`]. + /// response or surfaced by inbound `canvas.opened` events. pub fn open_canvases(&self) -> Vec { self.open_canvases.read().clone() } @@ -637,114 +629,6 @@ impl Drop for Session { } } -/// Canvas host sub-API for a [`Session`]. -/// -/// Acquired via [`Session::canvas`]. Methods route to `session.canvas.*` RPCs. -/// [`list_open`](Self::list_open) calls `session.canvas.listOpen` and refreshes -/// the SDK's local snapshot with the host's response. -pub struct SessionCanvas<'a> { - session: &'a Session, -} - -impl<'a> SessionCanvas<'a> { - /// Lists canvases declared for the session. - pub async fn discover(&self) -> Result { - self.call_session_only("session.canvas.discover").await - } - - /// Lists currently open canvas instances for the live session and refreshes - /// the SDK's local snapshot. - pub async fn list_open(&self) -> Result { - let result: CanvasListOpenResult = - self.call_session_only("session.canvas.listOpen").await?; - *self.session.open_canvases.write() = result.open_canvases.clone(); - Ok(result) - } - - /// Opens a canvas instance for the given extension/canvas pair. - /// - /// Re-opening with an existing `instance_id` is the host-facing way to - /// focus a panel: the runtime re-emits `session.canvas.opened` with - /// `reopen: true` rather than dispatching a separate focus call. - pub async fn open(&self, request: CanvasOpenRequest) -> Result { - let result: OpenCanvasInstance = self.call("session.canvas.open", &request).await?; - - let mut open_canvases = self.session.open_canvases.write(); - open_canvases.retain(|canvas| canvas.instance_id != result.instance_id); - open_canvases.push(result.clone()); - - Ok(result) - } - - /// Closes a previously opened canvas instance. - pub async fn close(&self, request: CanvasCloseRequest) -> Result<(), Error> { - let instance_id = request.instance_id.clone(); - self.call_unit("session.canvas.close", &request).await?; - self.session - .open_canvases - .write() - .retain(|canvas| canvas.instance_id != instance_id); - Ok(()) - } - - /// Invokes a declared agent action against an open canvas instance. - pub async fn invoke_action( - &self, - request: CanvasInvokeActionRequest, - ) -> Result { - self.call("session.canvas.invokeAction", &request).await - } - - async fn call(&self, method: &str, request: &T) -> Result - where - T: Serialize, - R: DeserializeOwned, - { - let params = self.params_with_session_id(request)?; - let result = self.session.client.call(method, Some(params)).await?; - Ok(serde_json::from_value(result)?) - } - - async fn call_unit(&self, method: &str, request: &T) -> Result<(), Error> - where - T: Serialize, - { - let params = self.params_with_session_id(request)?; - self.session.client.call(method, Some(params)).await?; - Ok(()) - } - - async fn call_session_only(&self, method: &str) -> Result - where - R: DeserializeOwned, - { - let result = self - .session - .client - .call( - method, - Some(serde_json::json!({ "sessionId": self.session.id })), - ) - .await?; - Ok(serde_json::from_value(result)?) - } - - fn params_with_session_id(&self, request: &T) -> Result - where - T: Serialize, - { - let mut params = serde_json::to_value(request)?; - let object = params - .as_object_mut() - .expect("serializing canvas request should produce an object"); - object.insert( - "sessionId".to_string(), - serde_json::to_value(&self.session.id)?, - ); - Ok(params) - } -} - /// UI sub-API for a [`Session`] — elicitation, confirmation, selection, /// and free-form input. /// @@ -937,7 +821,7 @@ impl Client { let commands_count = runtime.commands.as_ref().map_or(0, Vec::len); let has_hooks = hooks.is_some(); let command_handlers = build_command_handler_map(runtime.commands.as_deref()); - let canvas_registry = Arc::new(std::mem::take(&mut runtime.canvas_registry)); + let canvas_handler = runtime.canvas_handler.take(); let session_fs_provider = runtime.session_fs_provider.take(); if self.inner.session_fs_configured && session_fs_provider.is_none() { return Err(Error::Session(SessionError::SessionFsProviderRequired)); @@ -970,7 +854,7 @@ impl Client { hooks, transforms, command_handlers, - canvas_registry, + canvas_handler, session_fs_provider, channels, idle_waiter.clone(), @@ -1077,7 +961,7 @@ impl Client { let commands_count = runtime.commands.as_ref().map_or(0, Vec::len); let has_hooks = hooks.is_some(); let command_handlers = build_command_handler_map(runtime.commands.as_deref()); - let canvas_registry = Arc::new(std::mem::take(&mut runtime.canvas_registry)); + let canvas_handler = runtime.canvas_handler.take(); let session_fs_provider = runtime.session_fs_provider.take(); if self.inner.session_fs_configured && session_fs_provider.is_none() { return Err(Error::Session(SessionError::SessionFsProviderRequired)); @@ -1110,7 +994,7 @@ impl Client { hooks, transforms, command_handlers, - canvas_registry, + canvas_handler, session_fs_provider, channels, idle_waiter.clone(), @@ -1234,7 +1118,7 @@ fn spawn_event_loop( hooks: Option>, transforms: Option>, command_handlers: Arc, - canvas_registry: Arc, + canvas_handler: Option>, session_fs_provider: Option>, channels: crate::router::SessionChannels, idle_waiter: Arc>>, @@ -1274,7 +1158,7 @@ fn spawn_event_loop( handlers: &handlers, hooks: hooks.as_deref(), transforms: transforms.as_deref(), - canvas_registry: &canvas_registry, + canvas_handler: canvas_handler.as_ref(), session_fs_provider: session_fs_provider.as_ref(), }; handle_request(&session_id, ctx, request).await; @@ -1830,7 +1714,7 @@ struct RequestDispatchContext<'a> { handlers: &'a SessionHandlers, hooks: Option<&'a dyn SessionHooks>, transforms: Option<&'a dyn SystemMessageTransform>, - canvas_registry: &'a CanvasRegistry, + canvas_handler: Option<&'a Arc>, session_fs_provider: Option<&'a Arc>, } @@ -1845,7 +1729,7 @@ async fn handle_request( let handlers = ctx.handlers; let hooks = ctx.hooks; let transforms = ctx.transforms; - let canvas_registry = ctx.canvas_registry; + let canvas_handler = ctx.canvas_handler; let session_fs_provider = ctx.session_fs_provider; if request.method.starts_with("sessionFs.") { @@ -1861,7 +1745,7 @@ async fn handle_request( else { return; }; - let result = crate::canvas::dispatch_canvas_open(canvas_registry, params).await; + let result = dispatch_canvas_open(canvas_handler, params).await; send_canvas_dispatch_response(client, request.id, result).await; } @@ -1872,7 +1756,7 @@ async fn handle_request( else { return; }; - let result = crate::canvas::dispatch_canvas_close(canvas_registry, params).await; + let result = dispatch_canvas_close(canvas_handler, params).await; send_canvas_dispatch_response(client, request.id, result).await; } @@ -1882,7 +1766,7 @@ async fn handle_request( else { return; }; - let result = crate::canvas::dispatch_canvas_action(canvas_registry, params).await; + let result = dispatch_canvas_action(canvas_handler, params).await; send_canvas_dispatch_response(client, request.id, result).await; } @@ -2181,6 +2065,49 @@ async fn send_canvas_dispatch_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 a7b4d129e..94533984b 100644 --- a/rust/src/types.rs +++ b/rust/src/types.rs @@ -12,9 +12,7 @@ use std::time::Duration; use serde::{Deserialize, Serialize}; use serde_json::Value; -use crate::canvas::{ - Canvas, CanvasDeclaration, CanvasRegistry, OpenCanvasInstance, build_registry, -}; +use crate::canvas::{CanvasDeclaration, CanvasHandler, OpenCanvasInstance}; use crate::handler::{ AutoModeSwitchHandler, ElicitationHandler, ExitPlanModeHandler, PermissionHandler, UserInputHandler, @@ -1112,7 +1110,12 @@ pub struct SessionConfig { /// Client-defined tool declarations to expose to the agent. pub tools: Option>, /// Canvas declarations this connection provides to the runtime. - pub canvases: Option>, + pub canvases: Option>, + /// Provider-side canvas lifecycle handler. The SDK routes inbound + /// `canvas.open` / `canvas.close` / `canvas.action.invoke` requests to + /// this handler. Use [`with_canvas_handler`](Self::with_canvas_handler) + /// to install one. + pub canvas_handler: Option>, /// Request canvas renderer tools for this connection. pub request_canvas_renderer: Option, /// Request extension tools and dispatch for this connection. @@ -1243,6 +1246,10 @@ impl std::fmt::Debug for SessionConfig { .field("system_message", &self.system_message) .field("tools", &self.tools) .field("canvases", &self.canvases) + .field( + "canvas_handler", + &self.canvas_handler.as_ref().map(|_| ""), + ) .field("request_canvas_renderer", &self.request_canvas_renderer) .field("request_extensions", &self.request_extensions) .field("extension_info", &self.extension_info) @@ -1326,6 +1333,7 @@ impl Default for SessionConfig { system_message: None, tools: None, canvases: None, + canvas_handler: None, request_canvas_renderer: None, request_extensions: None, extension_info: None, @@ -1379,7 +1387,7 @@ pub(crate) struct SessionConfigRuntime { pub hooks_handler: Option>, pub system_message_transform: Option>, pub tool_handlers: HashMap>, - pub canvas_registry: CanvasRegistry, + pub canvas_handler: Option>, pub session_fs_provider: Option>, pub commands: Option>, } @@ -1430,15 +1438,8 @@ impl SessionConfig { }) .collect() }); - let wire_canvases: Option> = self - .canvases - .as_deref() - .map(|canvases| canvases.iter().map(|c| c.declaration().clone()).collect()); - let canvas_registry = self - .canvases - .as_deref() - .map(build_registry) - .unwrap_or_default(); + let wire_canvases = self.canvases.clone(); + let canvas_handler = self.canvas_handler.clone(); let wire = crate::wire::SessionCreateWire { session_id, @@ -1492,7 +1493,7 @@ impl SessionConfig { hooks_handler: self.hooks_handler, system_message_transform: self.system_message_transform, tool_handlers, - canvas_registry, + canvas_handler, session_fs_provider: self.session_fs_provider, commands: self.commands, }; @@ -1643,12 +1644,21 @@ impl SessionConfig { self } - /// Set canvas declarations and provider handlers for this connection. - pub fn with_canvases>(mut self, canvases: I) -> Self { + /// Set canvas declarations for this connection. The runtime advertises + /// these to the agent; install a [`CanvasHandler`] via + /// [`with_canvas_handler`](Self::with_canvas_handler) to receive the + /// resulting provider callbacks. + pub fn with_canvases>(mut self, canvases: I) -> Self { self.canvases = Some(canvases.into_iter().collect()); self } + /// Install the provider-side [`CanvasHandler`] for this session. + pub fn with_canvas_handler(mut self, handler: Arc) -> Self { + self.canvas_handler = Some(handler); + self + } + /// Request host canvas renderer tools for this connection. pub fn with_request_canvas_renderer(mut self, request: bool) -> Self { self.request_canvas_renderer = Some(request); @@ -1852,7 +1862,10 @@ pub struct ResumeSessionConfig { /// Client-defined tool declarations to re-supply on resume. pub tools: Option>, /// Canvas declarations this connection provides to the runtime. - pub canvases: Option>, + pub canvases: Option>, + /// Provider-side canvas lifecycle handler. See + /// [`SessionConfig::canvas_handler`]. + pub canvas_handler: Option>, /// Open canvas instances the caller knows were open before this resume. pub open_canvases: Option>, /// Request canvas renderer tools for this connection. @@ -1963,6 +1976,10 @@ impl std::fmt::Debug for ResumeSessionConfig { .field("system_message", &self.system_message) .field("tools", &self.tools) .field("canvases", &self.canvases) + .field( + "canvas_handler", + &self.canvas_handler.as_ref().map(|_| ""), + ) .field("open_canvases", &self.open_canvases) .field("request_canvas_renderer", &self.request_canvas_renderer) .field("request_extensions", &self.request_extensions) @@ -2073,15 +2090,8 @@ impl ResumeSessionConfig { }) .collect() }); - let wire_canvases: Option> = self - .canvases - .as_deref() - .map(|canvases| canvases.iter().map(|c| c.declaration().clone()).collect()); - let canvas_registry = self - .canvases - .as_deref() - .map(build_registry) - .unwrap_or_default(); + let wire_canvases = self.canvases.clone(); + let canvas_handler = self.canvas_handler.clone(); let wire = crate::wire::SessionResumeWire { session_id: self.session_id, @@ -2136,7 +2146,7 @@ impl ResumeSessionConfig { hooks_handler: self.hooks_handler, system_message_transform: self.system_message_transform, tool_handlers, - canvas_registry, + canvas_handler, session_fs_provider: self.session_fs_provider, commands: self.commands, }; @@ -2157,6 +2167,7 @@ impl ResumeSessionConfig { system_message: None, tools: None, canvases: None, + canvas_handler: None, open_canvases: None, request_canvas_renderer: None, request_extensions: None, @@ -2315,12 +2326,18 @@ impl ResumeSessionConfig { self } - /// Re-supply canvas declarations and provider handlers on resume. - pub fn with_canvases>(mut self, canvases: I) -> Self { + /// Re-supply canvas declarations on resume. + pub fn with_canvases>(mut self, canvases: I) -> Self { self.canvases = Some(canvases.into_iter().collect()); self } + /// Install the provider-side [`CanvasHandler`] for the resumed session. + pub fn with_canvas_handler(mut self, handler: Arc) -> Self { + self.canvas_handler = Some(handler); + self + } + /// Seed open canvas instances that were visible before resuming. pub fn with_open_canvases>( mut self, diff --git a/rust/tests/session_test.rs b/rust/tests/session_test.rs index 4399b6e38..925f57fd0 100644 --- a/rust/tests/session_test.rs +++ b/rust/tests/session_test.rs @@ -7,9 +7,8 @@ use std::time::Duration; use async_trait::async_trait; use github_copilot_sdk::canvas::{ - Canvas, CanvasActionContext, CanvasCloseRequest, CanvasDeclaration, CanvasHandler, - CanvasInstanceAvailability, CanvasInvokeActionRequest, CanvasOpenContext, CanvasOpenRequest, - CanvasOpenResponse, CanvasResult, OpenCanvasInstance, + CanvasActionContext, CanvasDeclaration, CanvasHandler, CanvasInstanceAvailability, + CanvasOpenContext, CanvasOpenResponse, CanvasResult, OpenCanvasInstance, }; use github_copilot_sdk::handler::{ ApproveAllHandler, AutoModeSwitchHandler, AutoModeSwitchResponse, ElicitationHandler, @@ -47,15 +46,12 @@ impl CanvasHandler for TestCanvasHandler { } } -fn test_canvas(id: &str) -> Canvas { - Canvas::builder(CanvasDeclaration::new( - id, - "Test Canvas", - "Test canvas description", - )) - .handler(Arc::new(TestCanvasHandler)) - .build() - .unwrap() +fn test_canvas(id: &str) -> CanvasDeclaration { + CanvasDeclaration::new(id, "Test Canvas", "Test canvas description") +} + +fn test_canvas_handler() -> Arc { + Arc::new(TestCanvasHandler) } async fn write_framed(writer: &mut (impl AsyncWrite + Unpin), body: &[u8]) { @@ -374,8 +370,11 @@ async fn create_session_sends_canvas_wire_fields() { #[tokio::test] async fn provider_canvas_dispatch_routes_direct_canvas_action_requests() { - let (session, mut server) = - create_session_pair_with_config(|cfg| cfg.with_canvases([test_canvas("counter")])).await; + let (session, mut server) = create_session_pair_with_config(|cfg| { + cfg.with_canvases([test_canvas("counter")]) + .with_canvas_handler(test_canvas_handler()) + }) + .await; server .send_request( @@ -398,121 +397,6 @@ async fn provider_canvas_dispatch_routes_direct_canvas_action_requests() { assert_eq!(response["result"]["input"]["amount"], 1); } -#[tokio::test] -async fn session_canvas_host_api_sends_requests_and_tracks_open_instances() { - let (session, mut server) = create_session_pair().await; - - let open_handle = tokio::spawn({ - async move { - let opened = session - .canvas() - .open(CanvasOpenRequest { - extension_id: "project:counter".to_string(), - canvas_id: "counter".to_string(), - instance_id: "counter-1".to_string(), - input: Some(serde_json::json!({ "seed": 1 })), - }) - .await - .unwrap(); - let listed = session.canvas().list_open().await.unwrap(); - let invoked = session - .canvas() - .invoke_action(CanvasInvokeActionRequest { - instance_id: opened.instance_id.clone(), - action_name: "increment".to_string(), - input: Some(serde_json::json!({ "amount": 1 })), - }) - .await - .unwrap(); - session - .canvas() - .close(CanvasCloseRequest { - instance_id: opened.instance_id.clone(), - }) - .await - .unwrap(); - let listed_after_close = session.canvas().list_open().await.unwrap(); - (opened, listed, invoked, listed_after_close) - } - }); - - let open_request = server.read_request().await; - assert_eq!(open_request["method"], "session.canvas.open"); - assert_eq!(open_request["params"]["sessionId"], server.session_id); - assert_eq!(open_request["params"]["extensionId"], "project:counter"); - assert_eq!(open_request["params"]["canvasId"], "counter"); - assert_eq!(open_request["params"]["instanceId"], "counter-1"); - assert_eq!(open_request["params"]["input"]["seed"], 1); - server - .respond( - &open_request, - serde_json::json!({ - "instanceId": "counter-1", - "extensionId": "project:counter", - "canvasId": "counter", - "url": "https://example.test/counter", - "reopen": false, - "availability": "ready" - }), - ) - .await; - - let list_request = server.read_request().await; - assert_eq!(list_request["method"], "session.canvas.listOpen"); - server - .respond( - &list_request, - serde_json::json!({ - "openCanvases": [{ - "instanceId": "counter-1", - "extensionId": "project:counter", - "canvasId": "counter", - "url": "https://example.test/counter", - "reopen": false, - "availability": "ready" - }] - }), - ) - .await; - - let invoke_request = server.read_request().await; - assert_eq!(invoke_request["method"], "session.canvas.invokeAction"); - assert_eq!(invoke_request["params"]["instanceId"], "counter-1"); - assert_eq!(invoke_request["params"]["actionName"], "increment"); - assert_eq!(invoke_request["params"]["input"]["amount"], 1); - server - .respond( - &invoke_request, - serde_json::json!({ "result": { "count": 2 } }), - ) - .await; - - let close_request = server.read_request().await; - assert_eq!(close_request["method"], "session.canvas.close"); - assert_eq!(close_request["params"]["instanceId"], "counter-1"); - server.respond(&close_request, serde_json::json!({})).await; - - let list_after_close_request = server.read_request().await; - assert_eq!( - list_after_close_request["method"], - "session.canvas.listOpen" - ); - server - .respond( - &list_after_close_request, - serde_json::json!({ "openCanvases": [] }), - ) - .await; - - let (opened, listed, invoked, listed_after_close) = - timeout(TIMEOUT, open_handle).await.unwrap().unwrap(); - assert_eq!(opened.instance_id, "counter-1"); - assert_eq!(listed.open_canvases.len(), 1); - assert_eq!(listed.open_canvases[0].instance_id, "counter-1"); - assert_eq!(invoked.result.unwrap()["count"], 2); - assert!(listed_after_close.open_canvases.is_empty()); -} - #[tokio::test] async fn send_injects_session_id() { let (session, mut server) = create_session_pair().await; From cd0f66f494874533ebbd2b2678bebb812daac098 Mon Sep 17 00:00:00 2001 From: jmoseley Date: Sun, 24 May 2026 08:08:25 -0700 Subject: [PATCH 24/29] rust: drop canvas wire types duplicated by codegen Removed CanvasInstanceAvailability, OpenCanvasInstance, CanvasAgentActionDeclaration (-> CanvasAction), CanvasDiscoverResult, DiscoveredCanvas, CanvasListOpenResult, CanvasOpenRequest, CanvasCloseRequest, CanvasInvokeActionRequest, and CanvasInvokeActionResult from canvas.rs; consumers import these from crate::generated::api_types directly. The remaining hand-written types (CanvasDeclaration, CanvasOpenResponse, handler trait, contexts, CanvasError) are genuinely additive provider-authoring contracts. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- rust/src/canvas.rs | 214 +------------------------------------ rust/src/session.rs | 6 +- rust/src/types.rs | 3 +- rust/src/wire.rs | 6 +- rust/tests/session_test.rs | 28 ++--- 5 files changed, 27 insertions(+), 230 deletions(-) diff --git a/rust/src/canvas.rs b/rust/src/canvas.rs index 618b268f8..23d3bbf20 100644 --- a/rust/src/canvas.rs +++ b/rust/src/canvas.rs @@ -5,26 +5,15 @@ use serde::{Deserialize, Serialize}; use serde_json::Value; use thiserror::Error; +use crate::generated::api_types::CanvasAction; use crate::types::SessionId; /// JSON Schema object used for canvas inputs and canvas-scoped tools. pub type CanvasJsonSchema = serde_json::Map; -/// Runtime-controlled routing state for an open canvas instance. -#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "lowercase")] -pub enum CanvasInstanceAvailability { - /// The owning provider is currently connected and routing calls will be dispatched normally. - Ready, - /// The owning provider is not currently connected; routing calls fail with - /// `canvas_provider_unavailable` until the agent re-issues `open_canvas` or - /// the provider reconnects. - Stale, -} - /// Declarative metadata for a single canvas, sent over the wire on /// `session.create` / `session.resume`. -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] #[non_exhaustive] pub struct CanvasDeclaration { @@ -39,7 +28,7 @@ pub struct CanvasDeclaration { pub input_schema: Option, /// Agent-callable actions this canvas exposes. #[serde(default, skip_serializing_if = "Option::is_none")] - pub actions: Option>, + pub actions: Option>, } impl CanvasDeclaration { @@ -65,20 +54,6 @@ impl CanvasDeclaration { } } -/// A single agent-callable action contributed by a canvas. -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "camelCase")] -pub struct CanvasAgentActionDeclaration { - /// Action identifier, unique within the canvas. - pub name: String, - /// Description shown to the model when picking an action. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub description: Option, - /// Optional JSON Schema for the action's `input` payload. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub input_schema: Option, -} - /// Response returned from [`CanvasHandler::on_open`]. #[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "camelCase")] @@ -94,187 +69,6 @@ pub struct CanvasOpenResponse { pub status: Option, } -/// Open canvas instance returned by `session.canvas.open`, -/// `session.canvas.listOpen`, and `session.resume`. -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "camelCase")] -#[non_exhaustive] -pub struct OpenCanvasInstance { - /// Stable caller-supplied canvas instance identifier. - pub instance_id: String, - /// Owning provider identifier. - pub extension_id: String, - /// Owning extension display name, when available. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub extension_name: Option, - /// Provider-local canvas identifier. - pub canvas_id: String, - /// Rendered title. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub title: Option, - /// Provider-supplied status text. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub status: Option, - /// URL for web-rendered canvases. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub url: Option, - /// Input supplied when the instance was opened. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub input: Option, - /// Whether this snapshot came from an idempotent reopen. - pub reopen: bool, - /// Runtime-controlled routing state for this instance. - pub availability: CanvasInstanceAvailability, -} - -impl OpenCanvasInstance { - /// Construct an open canvas instance snapshot with the required fields set. - pub fn new( - instance_id: impl Into, - extension_id: impl Into, - canvas_id: impl Into, - ) -> Self { - Self { - instance_id: instance_id.into(), - extension_id: extension_id.into(), - extension_name: None, - canvas_id: canvas_id.into(), - title: None, - status: None, - url: None, - input: None, - reopen: false, - availability: CanvasInstanceAvailability::Stale, - } - } - - /// Set the owning extension display name. - pub fn with_extension_name(mut self, extension_name: impl Into) -> Self { - self.extension_name = Some(extension_name.into()); - self - } - - /// Set the rendered title. - pub fn with_title(mut self, title: impl Into) -> Self { - self.title = Some(title.into()); - self - } - - /// Set the provider-supplied status text. - pub fn with_status(mut self, status: impl Into) -> Self { - self.status = Some(status.into()); - self - } - - /// Set the URL for web-rendered canvases. - pub fn with_url(mut self, url: impl Into) -> Self { - self.url = Some(url.into()); - self - } - - /// Set the input supplied when the instance was opened. - pub fn with_input(mut self, input: Value) -> Self { - self.input = Some(input); - self - } - - /// Set whether this snapshot came from an idempotent reopen. - pub fn with_reopen(mut self, reopen: bool) -> Self { - self.reopen = reopen; - self - } - - /// Set the runtime-controlled routing availability. - pub fn with_availability(mut self, availability: CanvasInstanceAvailability) -> Self { - self.availability = availability; - self - } -} - -/// Result returned by the `session.canvas.discover` RPC. -#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "camelCase")] -pub struct CanvasDiscoverResult { - /// Declared canvases available in this session. - pub canvases: Vec, -} - -/// Canvas available in the current session. -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "camelCase")] -pub struct DiscoveredCanvas { - /// Owning provider identifier. - pub extension_id: String, - /// Owning extension display name, when available. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub extension_name: Option, - /// Provider-local canvas identifier. - pub canvas_id: String, - /// Human-readable canvas name. - pub display_name: String, - /// Short, single-sentence description shown to the agent in canvas catalogs. - pub description: String, - /// JSON Schema for canvas open input. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub input_schema: Option, - /// Actions the agent or host may invoke on an open instance. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub actions: Option>, -} - -/// Result returned by the `session.canvas.listOpen` RPC. -#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "camelCase")] -pub struct CanvasListOpenResult { - /// Currently open canvas instances. - pub open_canvases: Vec, -} - -/// Request parameters for `session.canvas.open`. -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "camelCase")] -pub struct CanvasOpenRequest { - /// Owning provider identifier. - pub extension_id: String, - /// Provider-local canvas identifier. - pub canvas_id: String, - /// Caller-supplied stable instance identifier. - pub instance_id: String, - /// Optional opaque payload forwarded to the canvas provider. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub input: Option, -} - -/// Request parameters for `session.canvas.close`. -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "camelCase")] -pub struct CanvasCloseRequest { - /// Open canvas instance identifier. - pub instance_id: String, -} - -/// Request parameters for `session.canvas.invokeAction`. -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "camelCase")] -pub struct CanvasInvokeActionRequest { - /// Open canvas instance identifier. - pub instance_id: String, - /// Action name to invoke. - pub action_name: String, - /// Optional input forwarded to the extension's action handler. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub input: Option, -} - -/// Result returned from `session.canvas.invokeAction`. -#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "camelCase")] -pub struct CanvasInvokeActionResult { - /// Provider-supplied action result. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub result: Option, -} - /// Host capabilities passed to canvas provider callbacks. #[derive(Debug, Clone, Default, Deserialize)] #[serde(rename_all = "camelCase")] @@ -499,7 +293,7 @@ mod tests { display_name: "Counter".to_string(), description: "Count things".to_string(), input_schema: None, - actions: Some(vec![CanvasAgentActionDeclaration { + actions: Some(vec![CanvasAction { name: "increment".to_string(), description: Some("bump".to_string()), input_schema: None, diff --git a/rust/src/session.rs b/rust/src/session.rs index d05c94223..f216b866b 100644 --- a/rust/src/session.rs +++ b/rust/src/session.rs @@ -11,10 +11,8 @@ use tokio::task::JoinHandle; use tokio_util::sync::CancellationToken; use tracing::{Instrument, warn}; -use crate::canvas::{ - CanvasHandler, CanvasInvokeParams, CanvasProviderRequestParams, OpenCanvasInstance, -}; -use crate::generated::api_types::{LogRequest, ModelSwitchToRequest}; +use crate::canvas::{CanvasHandler, CanvasInvokeParams, CanvasProviderRequestParams}; +use crate::generated::api_types::{LogRequest, ModelSwitchToRequest, OpenCanvasInstance}; use crate::generated::session_events::{ CommandExecuteData, ElicitationRequestedData, ExternalToolRequestedData, SessionErrorData, SessionEventType, diff --git a/rust/src/types.rs b/rust/src/types.rs index 94533984b..d841096c5 100644 --- a/rust/src/types.rs +++ b/rust/src/types.rs @@ -12,7 +12,8 @@ use std::time::Duration; use serde::{Deserialize, Serialize}; use serde_json::Value; -use crate::canvas::{CanvasDeclaration, CanvasHandler, OpenCanvasInstance}; +use crate::canvas::{CanvasDeclaration, CanvasHandler}; +use crate::generated::api_types::OpenCanvasInstance; use crate::handler::{ AutoModeSwitchHandler, ElicitationHandler, ExitPlanModeHandler, PermissionHandler, UserInputHandler, diff --git a/rust/src/wire.rs b/rust/src/wire.rs index 952632b18..b97aea261 100644 --- a/rust/src/wire.rs +++ b/rust/src/wire.rs @@ -18,8 +18,10 @@ use std::path::PathBuf; use serde::Serialize; -use crate::canvas::{CanvasDeclaration, OpenCanvasInstance}; -use crate::generated::api_types::{ModelCapabilitiesOverride, RemoteSessionMode}; +use crate::canvas::CanvasDeclaration; +use crate::generated::api_types::{ + ModelCapabilitiesOverride, OpenCanvasInstance, RemoteSessionMode, +}; use crate::types::{ CloudSessionOptions, CustomAgentConfig, DefaultAgentConfig, ExtensionInfo, InfiniteSessionConfig, McpServerConfig, ProviderConfig, SessionId, SystemMessageConfig, Tool, diff --git a/rust/tests/session_test.rs b/rust/tests/session_test.rs index 925f57fd0..050c5898d 100644 --- a/rust/tests/session_test.rs +++ b/rust/tests/session_test.rs @@ -7,9 +7,10 @@ use std::time::Duration; use async_trait::async_trait; use github_copilot_sdk::canvas::{ - CanvasActionContext, CanvasDeclaration, CanvasHandler, CanvasInstanceAvailability, - CanvasOpenContext, CanvasOpenResponse, CanvasResult, OpenCanvasInstance, + CanvasActionContext, CanvasDeclaration, CanvasHandler, CanvasOpenContext, CanvasOpenResponse, + CanvasResult, }; +use github_copilot_sdk::generated::api_types::{CanvasInstanceAvailability, OpenCanvasInstance}; use github_copilot_sdk::handler::{ ApproveAllHandler, AutoModeSwitchHandler, AutoModeSwitchResponse, ElicitationHandler, ExitPlanModeHandler, ExitPlanModeResult, UserInputHandler, UserInputResponse, @@ -2512,17 +2513,18 @@ async fn resume_session_sends_canvas_fields_and_captures_open_canvases() { .with_request_canvas_renderer(true) .with_request_extensions(true) .with_extension_info(ExtensionInfo::new("github-app", "counter-provider")) - .with_open_canvases([OpenCanvasInstance::new( - "counter-1", - "github-app:counter-provider", - "counter", - ) - .with_extension_name("Counter Provider") - .with_title("Counter") - .with_status("ready") - .with_url("https://example.test/counter") - .with_input(serde_json::json!({ "seed": 1 })) - .with_availability(CanvasInstanceAvailability::Stale)]); + .with_open_canvases([OpenCanvasInstance { + instance_id: "counter-1".to_string(), + extension_id: "github-app:counter-provider".to_string(), + extension_name: Some("Counter Provider".to_string()), + canvas_id: "counter".to_string(), + title: Some("Counter".to_string()), + status: Some("ready".to_string()), + url: Some("https://example.test/counter".to_string()), + input: Some(serde_json::json!({ "seed": 1 })), + reopen: false, + availability: CanvasInstanceAvailability::Stale, + }]); client.resume_session(cfg).await.unwrap() } }); From f47d4c5d4ba9cab8e9aea1c356c8ccfc1f51d0d8 Mon Sep 17 00:00:00 2001 From: jmoseley Date: Sun, 24 May 2026 08:24:00 -0700 Subject: [PATCH 25/29] Fix broken intra-doc link to renamed CanvasAction type The canvas wire types were deduplicated against generated/api_types.rs, renaming CanvasAgentActionDeclaration to CanvasAction. A doc comment in canvas.rs still referenced the old name, which broke cargo doc on CI (broken_intra_doc_links is denied). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- rust/src/canvas.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/rust/src/canvas.rs b/rust/src/canvas.rs index 23d3bbf20..ba13742ff 100644 --- a/rust/src/canvas.rs +++ b/rust/src/canvas.rs @@ -115,7 +115,7 @@ pub struct CanvasActionContext { pub canvas_id: String, /// Instance id targeted by the action. pub instance_id: String, - /// Action name from [`CanvasAgentActionDeclaration::name`]. + /// Action name from [`crate::generated::api_types::CanvasAction::name`]. pub action_name: String, /// Validated input payload. pub input: Value, From 2dc1f90802fc65d9db10ee070d14679289173448 Mon Sep 17 00:00:00 2001 From: jmoseley Date: Sun, 24 May 2026 09:14:42 -0700 Subject: [PATCH 26/29] go: add canvas runtime support Mirrors the Rust SDK canvas surface in rust/src/canvas.rs: - CanvasDeclaration, CanvasOpenResponse, CanvasHostContext, CanvasOpenContext / CanvasActionContext / CanvasLifecycleContext, CanvasError, CanvasHandler interface + CanvasHandlerDefaults, and ExtensionInfo. - SessionConfig / ResumeSessionConfig: Canvases, RequestCanvasRenderer, RequestExtensions, CanvasHandler, ExtensionInfo. - Inbound JSON-RPC dispatch for canvas.open, canvas.close, and canvas.action.invoke, with a canvas_handler_unset error envelope when no handler is installed and a canvas_handler_error envelope when a handler returns a non-CanvasError error. - Session.OpenCanvases() surfaces the openCanvases snapshot from the session.resume response. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- go/canvas.go | 232 +++++++++++++++++++++++++++++++++++++++ go/canvas_test.go | 268 ++++++++++++++++++++++++++++++++++++++++++++++ go/client.go | 104 ++++++++++++++++++ go/session.go | 36 +++++++ go/types.go | 41 ++++++- 5 files changed, 678 insertions(+), 3 deletions(-) create mode 100644 go/canvas.go create mode 100644 go/canvas_test.go diff --git a/go/canvas.go b/go/canvas.go new file mode 100644 index 000000000..2d122db29 --- /dev/null +++ b/go/canvas.go @@ -0,0 +1,232 @@ +// Canvas declarations, provider callbacks, and host-side canvas RPC types. +// +// This file mirrors rust/src/canvas.rs. The SDK does not maintain a per-canvas +// registry; multiplexing across declared canvases is the CanvasHandler +// implementor's responsibility (typically by switching on CanvasOpenContext.CanvasID). + +package copilot + +import ( + "context" + + "github.com/github/copilot-sdk/go/rpc" +) + +// CanvasDeclaration is the declarative metadata for a single canvas, sent over +// the wire on `session.create` / `session.resume`. +type CanvasDeclaration struct { + // ID is the canvas identifier, unique within the declaring connection. + ID string `json:"id"` + // DisplayName is the human-readable name shown in host UI and canvas pickers. + DisplayName string `json:"displayName"` + // Description is a short, single-sentence description shown to the agent in canvas catalogs. + Description string `json:"description"` + // InputSchema is the JSON Schema for the `input` payload accepted by `canvas.open`. + InputSchema map[string]any `json:"inputSchema,omitempty"` + // Actions are the agent-callable actions this canvas exposes. + Actions []rpc.CanvasAction `json:"actions,omitempty"` +} + +// CanvasOpenResponse is the response returned from CanvasHandler.OnOpen. +type CanvasOpenResponse struct { + // URL the host should render. Optional for canvases with no visual surface. + URL *string `json:"url,omitempty"` + // Title is the provider-supplied title shown in host chrome. + Title *string `json:"title,omitempty"` + // Status is the provider-supplied status text shown in host chrome. + Status *string `json:"status,omitempty"` +} + +// CanvasHostContext carries host capability hints passed to canvas provider callbacks. +type CanvasHostContext struct { + // Capabilities describes host feature support relevant to canvases. + Capabilities CanvasHostCapabilities `json:"capabilities"` +} + +// CanvasHostCapabilities describes host capability details passed to canvas provider callbacks. +type CanvasHostCapabilities struct { + // Canvases indicates whether the host supports canvas rendering. + Canvases bool `json:"canvases"` +} + +// CanvasOpenContext is the context handed to CanvasHandler.OnOpen. +type CanvasOpenContext struct { + // SessionID is the session that requested the canvas. + SessionID string + // ExtensionID is the owning provider identifier. + ExtensionID string + // CanvasID is the canvas id from the declaring CanvasDeclaration. + CanvasID string + // InstanceID is the stable instance id supplied by the runtime. + InstanceID string + // Input is the validated input payload. + Input any + // Host carries host capabilities supplied by the runtime. + Host *CanvasHostContext +} + +// CanvasActionContext is the context handed to CanvasHandler.OnAction. +type CanvasActionContext struct { + // SessionID is the session that invoked the action. + SessionID string + // ExtensionID is the owning provider identifier. + ExtensionID string + // CanvasID is the canvas id targeted by the action. + CanvasID string + // InstanceID is the instance id targeted by the action. + InstanceID string + // ActionName is the action name from CanvasAction.Name. + ActionName string + // Input is the validated input payload. + Input any + // Host carries host capabilities supplied by the runtime. + Host *CanvasHostContext +} + +// CanvasLifecycleContext is the context handed to a canvas's close lifecycle hook. +type CanvasLifecycleContext struct { + // SessionID is the session owning the canvas instance. + SessionID string + // ExtensionID is the owning provider identifier. + ExtensionID string + // CanvasID is the canvas id from the declaring CanvasDeclaration. + CanvasID string + // InstanceID is the instance id this lifecycle event applies to. + InstanceID string + // Host carries host capabilities supplied by the runtime. + Host *CanvasHostContext +} + +// CanvasError is a structured error returned from canvas handlers. +// +// Wire envelope: +// +// { "code": "", "message": "" } +type CanvasError struct { + // Code is the machine-readable error code. + Code string `json:"code"` + // Message is the human-readable message. + Message string `json:"message"` +} + +// Error implements the error interface. +func (e *CanvasError) Error() string { + return e.Code + ": " + e.Message +} + +// NewCanvasError constructs a new error envelope with the given code and message. +func NewCanvasError(code, message string) *CanvasError { + return &CanvasError{Code: code, Message: message} +} + +// CanvasErrorNoHandler is the default error returned when a custom action has no handler. +func CanvasErrorNoHandler() *CanvasError { + return NewCanvasError( + "canvas_action_no_handler", + "No handler implemented for this canvas action", + ) +} + +// CanvasHandler is the provider-side canvas lifecycle handler. +// +// A session installs a single CanvasHandler (via SessionConfig.CanvasHandler). +// The handler receives every inbound `canvas.open` / `canvas.close` / +// `canvas.action.invoke` JSON-RPC request the runtime issues for this session +// and decides — typically by inspecting CanvasOpenContext.CanvasID — which +// application-side canvas should handle the call. +// +// The SDK does not maintain a per-canvas registry; multiplexing across declared +// canvases is the implementor's responsibility. +// +// Embed CanvasHandlerDefaults to inherit no-op defaults for OnClose and a +// "no handler" error for OnAction. +type CanvasHandler interface { + OnOpen(ctx context.Context, c CanvasOpenContext) (CanvasOpenResponse, error) + OnClose(ctx context.Context, c CanvasLifecycleContext) error + OnAction(ctx context.Context, c CanvasActionContext) (any, error) +} + +// CanvasHandlerDefaults supplies default OnClose / OnAction implementations +// that consumers can inherit by embedding it in their CanvasHandler. +// +// Example: +// +// type myHandler struct { +// copilot.CanvasHandlerDefaults +// } +// func (h *myHandler) OnOpen(ctx context.Context, c copilot.CanvasOpenContext) (copilot.CanvasOpenResponse, error) { ... } +type CanvasHandlerDefaults struct{} + +// OnClose returns nil by default. +func (CanvasHandlerDefaults) OnClose(ctx context.Context, c CanvasLifecycleContext) error { + return nil +} + +// OnAction returns CanvasErrorNoHandler() by default. +func (CanvasHandlerDefaults) OnAction(ctx context.Context, c CanvasActionContext) (any, error) { + return nil, CanvasErrorNoHandler() +} + +// canvasProviderRequestParams is the wire shape of the common fields sent by +// direct `canvas.*` provider callbacks (canvas.open / canvas.close). +type canvasProviderRequestParams struct { + SessionID string `json:"sessionId"` + ExtensionID string `json:"extensionId"` + CanvasID string `json:"canvasId"` + InstanceID string `json:"instanceId"` + Input any `json:"input,omitempty"` + Host *CanvasHostContext `json:"host,omitempty"` +} + +func (p *canvasProviderRequestParams) toOpenContext() CanvasOpenContext { + return CanvasOpenContext{ + SessionID: p.SessionID, + ExtensionID: p.ExtensionID, + CanvasID: p.CanvasID, + InstanceID: p.InstanceID, + Input: p.Input, + Host: p.Host, + } +} + +func (p *canvasProviderRequestParams) toLifecycleContext() CanvasLifecycleContext { + return CanvasLifecycleContext{ + SessionID: p.SessionID, + ExtensionID: p.ExtensionID, + CanvasID: p.CanvasID, + InstanceID: p.InstanceID, + Host: p.Host, + } +} + +// canvasInvokeParams is the wire shape for `canvas.action.invoke`. +type canvasInvokeParams struct { + SessionID string `json:"sessionId"` + ExtensionID string `json:"extensionId"` + CanvasID string `json:"canvasId"` + InstanceID string `json:"instanceId"` + ActionName string `json:"actionName"` + Input any `json:"input,omitempty"` + Host *CanvasHostContext `json:"host,omitempty"` +} + +func (p *canvasInvokeParams) toActionContext() CanvasActionContext { + return CanvasActionContext{ + SessionID: p.SessionID, + ExtensionID: p.ExtensionID, + CanvasID: p.CanvasID, + InstanceID: p.InstanceID, + ActionName: p.ActionName, + Input: p.Input, + Host: p.Host, + } +} + +// ExtensionInfo carries stable extension identity for session participants +// that provide canvases. +type ExtensionInfo struct { + // Source is the extension namespace/source, e.g. "github-app". + Source string `json:"source"` + // Name is the stable provider name within the source namespace. + Name string `json:"name"` +} diff --git a/go/canvas_test.go b/go/canvas_test.go new file mode 100644 index 000000000..2eaaf44a3 --- /dev/null +++ b/go/canvas_test.go @@ -0,0 +1,268 @@ +package copilot + +import ( + "context" + "encoding/json" + "errors" + "testing" + + "github.com/github/copilot-sdk/go/internal/jsonrpc2" + "github.com/github/copilot-sdk/go/rpc" +) + +func TestCanvasDeclaration_JSONShape(t *testing.T) { + desc := "bump" + decl := CanvasDeclaration{ + ID: "counter", + DisplayName: "Counter", + Description: "Count things", + Actions: []rpc.CanvasAction{ + {Name: "increment", Description: &desc}, + }, + } + + data, err := json.Marshal(decl) + if err != nil { + t.Fatalf("marshal failed: %v", err) + } + + var decoded map[string]any + if err := json.Unmarshal(data, &decoded); err != nil { + t.Fatalf("unmarshal failed: %v", err) + } + + if decoded["id"] != "counter" { + t.Fatalf("expected id=counter, got %v", decoded["id"]) + } + if decoded["displayName"] != "Counter" { + t.Fatalf("expected displayName=Counter, got %v", decoded["displayName"]) + } + if decoded["description"] != "Count things" { + t.Fatalf("expected description, got %v", decoded["description"]) + } + if _, present := decoded["inputSchema"]; present { + t.Fatalf("inputSchema should be omitted when nil, got %v", decoded["inputSchema"]) + } + actions, ok := decoded["actions"].([]any) + if !ok || len(actions) != 1 { + t.Fatalf("expected actions array of length 1, got %v", decoded["actions"]) + } + first, _ := actions[0].(map[string]any) + if first["name"] != "increment" { + t.Fatalf("expected first action name=increment, got %v", first["name"]) + } +} + +func TestCanvasDeclaration_OmitsEmptyActions(t *testing.T) { + decl := CanvasDeclaration{ID: "x", DisplayName: "X", Description: "y"} + data, err := json.Marshal(decl) + if err != nil { + t.Fatalf("marshal failed: %v", err) + } + var decoded map[string]any + _ = json.Unmarshal(data, &decoded) + if _, present := decoded["actions"]; present { + t.Fatalf("actions should be omitted when nil, got %v", decoded["actions"]) + } +} + +func TestCanvasHandlerDefaults_OnAction_ReturnsNoHandler(t *testing.T) { + d := CanvasHandlerDefaults{} + _, err := d.OnAction(context.Background(), CanvasActionContext{}) + if err == nil { + t.Fatalf("expected error from default OnAction") + } + cerr, ok := err.(*CanvasError) + if !ok { + t.Fatalf("expected *CanvasError, got %T", err) + } + if cerr.Code != "canvas_action_no_handler" { + t.Fatalf("expected code=canvas_action_no_handler, got %q", cerr.Code) + } +} + +func TestCanvasHandlerDefaults_OnClose_ReturnsNil(t *testing.T) { + d := CanvasHandlerDefaults{} + if err := d.OnClose(context.Background(), CanvasLifecycleContext{}); err != nil { + t.Fatalf("expected nil from default OnClose, got %v", err) + } +} + +func TestCanvasError_ErrorString(t *testing.T) { + e := NewCanvasError("foo_code", "bar message") + if got := e.Error(); got != "foo_code: bar message" { + t.Fatalf("unexpected Error() output: %q", got) + } +} + +// recordingCanvasHandler captures calls for assertion. +type recordingCanvasHandler struct { + CanvasHandlerDefaults + openCtx *CanvasOpenContext + openResult CanvasOpenResponse + openErr error +} + +func (h *recordingCanvasHandler) OnOpen(ctx context.Context, c CanvasOpenContext) (CanvasOpenResponse, error) { + h.openCtx = &c + return h.openResult, h.openErr +} + +func TestClient_HandleCanvasOpen_DispatchesToHandler(t *testing.T) { + title := "Echo" + url := "https://example.test/echo" + handler := &recordingCanvasHandler{ + openResult: CanvasOpenResponse{URL: &url, Title: &title}, + } + + session := &Session{SessionID: "s1"} + session.registerCanvasHandler(handler) + + c := &Client{sessions: map[string]*Session{"s1": session}} + + params := canvasProviderRequestParams{ + SessionID: "s1", + ExtensionID: "project:echo", + CanvasID: "echo", + InstanceID: "echo-1", + Input: map[string]any{"x": float64(1)}, + } + resp, rpcErr := c.handleCanvasOpen(params) + if rpcErr != nil { + t.Fatalf("unexpected rpc error: %+v", rpcErr) + } + if handler.openCtx == nil { + t.Fatalf("handler.OnOpen was not called") + } + if handler.openCtx.CanvasID != "echo" || handler.openCtx.InstanceID != "echo-1" { + t.Fatalf("unexpected ctx: %+v", handler.openCtx) + } + if resp.URL == nil || *resp.URL != url { + t.Fatalf("response URL not propagated: %+v", resp) + } +} + +func TestClient_HandleCanvasOpen_NoHandler_ReturnsUnsetError(t *testing.T) { + session := &Session{SessionID: "s1"} + c := &Client{sessions: map[string]*Session{"s1": session}} + + _, rpcErr := c.handleCanvasOpen(canvasProviderRequestParams{SessionID: "s1"}) + if rpcErr == nil { + t.Fatalf("expected error when no canvas handler installed") + } + if rpcErr.Code != -32603 { + t.Fatalf("expected internal-error code, got %d", rpcErr.Code) + } + var data map[string]string + if err := json.Unmarshal(rpcErr.Data, &data); err != nil { + t.Fatalf("invalid error data: %v", err) + } + if data["code"] != "canvas_handler_unset" { + t.Fatalf("expected code=canvas_handler_unset, got %q", data["code"]) + } +} + +func TestClient_HandleCanvasOpen_HandlerCanvasError_Wired(t *testing.T) { + handler := &recordingCanvasHandler{ + openErr: NewCanvasError("permission_denied", "nope"), + } + session := &Session{SessionID: "s1"} + session.registerCanvasHandler(handler) + c := &Client{sessions: map[string]*Session{"s1": session}} + + _, rpcErr := c.handleCanvasOpen(canvasProviderRequestParams{SessionID: "s1"}) + if rpcErr == nil { + t.Fatalf("expected error") + } + var data map[string]string + _ = json.Unmarshal(rpcErr.Data, &data) + if data["code"] != "permission_denied" { + t.Fatalf("expected propagated code, got %q", data["code"]) + } +} + +func TestClient_HandleCanvasOpen_HandlerGenericError_WrappedAsCanvasHandlerError(t *testing.T) { + handler := &recordingCanvasHandler{openErr: errors.New("boom")} + session := &Session{SessionID: "s1"} + session.registerCanvasHandler(handler) + c := &Client{sessions: map[string]*Session{"s1": session}} + + _, rpcErr := c.handleCanvasOpen(canvasProviderRequestParams{SessionID: "s1"}) + if rpcErr == nil { + t.Fatalf("expected error") + } + var data map[string]string + _ = json.Unmarshal(rpcErr.Data, &data) + if data["code"] != "canvas_handler_error" { + t.Fatalf("expected code=canvas_handler_error, got %q", data["code"]) + } + if data["message"] != "boom" { + t.Fatalf("expected message=boom, got %q", data["message"]) + } +} + +// Ensure the JSON-RPC inbound parsing wires through RequestHandlerFor correctly. +func TestClient_HandleCanvasOpen_RawJSONRoundTrip(t *testing.T) { + handler := &recordingCanvasHandler{ + openResult: CanvasOpenResponse{Status: strPtr("ready")}, + } + session := &Session{SessionID: "s1"} + session.registerCanvasHandler(handler) + c := &Client{sessions: map[string]*Session{"s1": session}} + + rpcHandler := jsonrpc2.RequestHandlerFor(c.handleCanvasOpen) + raw := []byte(`{"sessionId":"s1","extensionId":"ext","canvasId":"echo","instanceId":"i1","input":{"k":"v"},"host":{"capabilities":{"canvases":true}}}`) + out, rpcErr := rpcHandler(raw) + if rpcErr != nil { + t.Fatalf("unexpected rpc error: %v", rpcErr) + } + if handler.openCtx == nil { + t.Fatalf("handler not invoked") + } + if handler.openCtx.Host == nil || !handler.openCtx.Host.Capabilities.Canvases { + t.Fatalf("host capabilities not parsed: %+v", handler.openCtx.Host) + } + var decoded map[string]any + if err := json.Unmarshal(out, &decoded); err != nil { + t.Fatalf("bad output JSON: %v", err) + } + if decoded["status"] != "ready" { + t.Fatalf("expected status=ready, got %v", decoded["status"]) + } +} + +func TestResumeSessionResponse_OpenCanvasesParse(t *testing.T) { + raw := []byte(`{ + "sessionId": "s1", + "workspacePath": "/tmp/ws", + "openCanvases": [ + { + "availability": "ready", + "canvasId": "echo", + "extensionId": "project:echo", + "instanceId": "echo-1", + "reopen": false + } + ] + }`) + + var resp resumeSessionResponse + if err := json.Unmarshal(raw, &resp); err != nil { + t.Fatalf("unmarshal failed: %v", err) + } + if len(resp.OpenCanvases) != 1 { + t.Fatalf("expected 1 open canvas, got %d", len(resp.OpenCanvases)) + } + if resp.OpenCanvases[0].CanvasID != "echo" { + t.Fatalf("unexpected canvasId: %q", resp.OpenCanvases[0].CanvasID) + } + + session := &Session{SessionID: "s1"} + session.setOpenCanvases(resp.OpenCanvases) + got := session.OpenCanvases() + if len(got) != 1 || got[0].InstanceID != "echo-1" { + t.Fatalf("OpenCanvases did not surface snapshot: %+v", got) + } +} + +func strPtr(s string) *string { return &s } diff --git a/go/client.go b/go/client.go index 9491eb199..ac34816c3 100644 --- a/go/client.go +++ b/go/client.go @@ -626,6 +626,10 @@ func (c *Client) CreateSession(ctx context.Context, config *SessionConfig) (*Ses req.GitHubToken = config.GitHubToken req.RemoteSession = config.RemoteSession req.Cloud = config.Cloud + req.Canvases = config.Canvases + req.RequestCanvasRenderer = config.RequestCanvasRenderer + req.RequestExtensions = config.RequestExtensions + req.ExtensionInfo = config.ExtensionInfo if len(config.Commands) > 0 { cmds := make([]wireCommand, 0, len(config.Commands)) @@ -708,6 +712,9 @@ func (c *Client) CreateSession(ctx context.Context, config *SessionConfig) (*Ses if config.OnAutoModeSwitchRequest != nil { session.registerAutoModeSwitchHandler(config.OnAutoModeSwitchRequest) } + if config.CanvasHandler != nil { + session.registerCanvasHandler(config.CanvasHandler) + } c.sessionsMux.Lock() c.sessions[sessionID] = session @@ -841,6 +848,10 @@ func (c *Client) ResumeSessionWithOptions(ctx context.Context, sessionID string, req.InfiniteSessions = config.InfiniteSessions req.GitHubToken = config.GitHubToken req.RemoteSession = config.RemoteSession + req.Canvases = config.Canvases + req.RequestCanvasRenderer = config.RequestCanvasRenderer + req.RequestExtensions = config.RequestExtensions + req.ExtensionInfo = config.ExtensionInfo if config.OnPermissionRequest != nil { req.RequestPermission = Bool(true) } @@ -896,6 +907,9 @@ func (c *Client) ResumeSessionWithOptions(ctx context.Context, sessionID string, if config.OnAutoModeSwitchRequest != nil { session.registerAutoModeSwitchHandler(config.OnAutoModeSwitchRequest) } + if config.CanvasHandler != nil { + session.registerCanvasHandler(config.CanvasHandler) + } c.sessionsMux.Lock() c.sessions[sessionID] = session @@ -938,6 +952,7 @@ func (c *Client) ResumeSessionWithOptions(ctx context.Context, sessionID string, session.workspacePath = response.WorkspacePath session.setCapabilities(response.Capabilities) + session.setOpenCanvases(response.OpenCanvases) return session, nil } @@ -1746,6 +1761,9 @@ func (c *Client) setupNotificationHandler() { c.client.SetRequestHandler("autoModeSwitch.request", jsonrpc2.RequestHandlerFor(c.handleAutoModeSwitchRequest)) c.client.SetRequestHandler("hooks.invoke", jsonrpc2.RequestHandlerFor(c.handleHooksInvoke)) c.client.SetRequestHandler("systemMessage.transform", jsonrpc2.RequestHandlerFor(c.handleSystemMessageTransform)) + c.client.SetRequestHandler("canvas.open", jsonrpc2.RequestHandlerFor(c.handleCanvasOpen)) + c.client.SetRequestHandler("canvas.close", jsonrpc2.RequestHandlerFor(c.handleCanvasClose)) + c.client.SetRequestHandler("canvas.action.invoke", jsonrpc2.RequestHandlerFor(c.handleCanvasActionInvoke)) rpc.RegisterClientSessionApiHandlers(c.client, func(sessionID string) *rpc.ClientSessionApiHandlers { c.sessionsMux.Lock() defer c.sessionsMux.Unlock() @@ -1894,3 +1912,89 @@ func (c *Client) handleSystemMessageTransform(req systemMessageTransformRequest) } return resp, nil } + +// canvasJSONRPCError converts a CanvasError into the structured JSON-RPC error +// envelope used by all canvas.* dispatch responses. +func canvasJSONRPCError(cerr *CanvasError) *jsonrpc2.Error { + data, _ := json.Marshal(map[string]string{ + "code": cerr.Code, + "message": cerr.Message, + }) + return &jsonrpc2.Error{ + Code: -32603, + Message: cerr.Message, + Data: data, + } +} + +// resolveCanvasSession looks up a session and its installed CanvasHandler, +// returning the canvas_handler_unset error envelope if either is missing. +func (c *Client) resolveCanvasSession(sessionID string) (*Session, CanvasHandler, *jsonrpc2.Error) { + c.sessionsMux.Lock() + session, ok := c.sessions[sessionID] + c.sessionsMux.Unlock() + if !ok { + return nil, nil, canvasJSONRPCError(NewCanvasError( + "canvas_handler_unset", + fmt.Sprintf("unknown session %s", sessionID), + )) + } + handler := session.getCanvasHandler() + if handler == nil { + return session, nil, canvasJSONRPCError(NewCanvasError( + "canvas_handler_unset", + "No CanvasHandler installed on this session; install one via SessionConfig.CanvasHandler before creating the session.", + )) + } + return session, handler, nil +} + +// canvasResultError normalizes any error returned from a CanvasHandler method +// into the structured JSON-RPC error envelope. +func canvasResultError(err error) *jsonrpc2.Error { + if err == nil { + return nil + } + if cerr, ok := err.(*CanvasError); ok { + return canvasJSONRPCError(cerr) + } + return canvasJSONRPCError(NewCanvasError("canvas_handler_error", err.Error())) +} + +// handleCanvasOpen dispatches an inbound canvas.open request to the session's CanvasHandler. +func (c *Client) handleCanvasOpen(params canvasProviderRequestParams) (CanvasOpenResponse, *jsonrpc2.Error) { + _, handler, rpcErr := c.resolveCanvasSession(params.SessionID) + if rpcErr != nil { + return CanvasOpenResponse{}, rpcErr + } + resp, err := handler.OnOpen(context.Background(), params.toOpenContext()) + if err != nil { + return CanvasOpenResponse{}, canvasResultError(err) + } + return resp, nil +} + +// handleCanvasClose dispatches an inbound canvas.close request to the session's CanvasHandler. +func (c *Client) handleCanvasClose(params canvasProviderRequestParams) (any, *jsonrpc2.Error) { + _, handler, rpcErr := c.resolveCanvasSession(params.SessionID) + if rpcErr != nil { + return nil, rpcErr + } + if err := handler.OnClose(context.Background(), params.toLifecycleContext()); err != nil { + return nil, canvasResultError(err) + } + return nil, nil +} + +// handleCanvasActionInvoke dispatches an inbound canvas.action.invoke request to the session's CanvasHandler. +func (c *Client) handleCanvasActionInvoke(params canvasInvokeParams) (any, *jsonrpc2.Error) { + _, handler, rpcErr := c.resolveCanvasSession(params.SessionID) + if rpcErr != nil { + return nil, rpcErr + } + result, err := handler.OnAction(context.Background(), params.toActionContext()) + if err != nil { + return nil, canvasResultError(err) + } + return result, nil +} diff --git a/go/session.go b/go/session.go index f38b4be17..eca928c19 100644 --- a/go/session.go +++ b/go/session.go @@ -75,6 +75,10 @@ type Session struct { commandHandlersMu sync.RWMutex elicitationHandler ElicitationHandler elicitationMu sync.RWMutex + canvasHandler CanvasHandler + canvasMu sync.RWMutex + openCanvases []rpc.OpenCanvasInstance + openCanvasesMu sync.RWMutex capabilities SessionCapabilities capabilitiesMu sync.RWMutex @@ -94,6 +98,38 @@ func (s *Session) WorkspacePath() string { return s.workspacePath } +// OpenCanvases returns the open-canvas snapshot last reported by the runtime +// (currently populated from the session.resume response). The returned slice +// is a copy and is safe to mutate by the caller. +func (s *Session) OpenCanvases() []rpc.OpenCanvasInstance { + s.openCanvasesMu.RLock() + defer s.openCanvasesMu.RUnlock() + if len(s.openCanvases) == 0 { + return nil + } + out := make([]rpc.OpenCanvasInstance, len(s.openCanvases)) + copy(out, s.openCanvases) + return out +} + +func (s *Session) setOpenCanvases(canvases []rpc.OpenCanvasInstance) { + s.openCanvasesMu.Lock() + defer s.openCanvasesMu.Unlock() + s.openCanvases = canvases +} + +func (s *Session) registerCanvasHandler(handler CanvasHandler) { + s.canvasMu.Lock() + defer s.canvasMu.Unlock() + s.canvasHandler = handler +} + +func (s *Session) getCanvasHandler() CanvasHandler { + s.canvasMu.RLock() + defer s.canvasMu.RUnlock() + return s.canvasHandler +} + // newSession creates a new session wrapper with the given session ID and client. func newSession(sessionID string, client *jsonrpc2.Client, workspacePath string) *Session { s := &Session{ diff --git a/go/types.go b/go/types.go index be86a326c..86d053083 100644 --- a/go/types.go +++ b/go/types.go @@ -927,6 +927,21 @@ type SessionConfig struct { // Cloud creates a remote session in the cloud instead of a local session. // The optional repository is associated with the cloud session. Cloud *CloudSessionOptions + // Canvases declares canvases this session provides. Sent over the wire on + // `session.create`. CanvasHandler must be set when this is non-empty (the + // SDK does not enforce this — declarations without a handler will surface + // canvas RPCs that return a canvas_handler_unset error envelope). + Canvases []CanvasDeclaration + // RequestCanvasRenderer asks the host to enable canvas rendering for this session. + RequestCanvasRenderer *bool + // RequestExtensions asks the host to surface declared canvases as agent-visible extensions. + RequestExtensions *bool + // CanvasHandler receives inbound canvas.open / canvas.close / canvas.action.invoke + // requests for this session. The SDK does not maintain a per-canvas registry; + // the handler must dispatch on CanvasOpenContext.CanvasID itself. + CanvasHandler CanvasHandler `json:"-"` + // ExtensionInfo identifies the stable extension providing this session's canvases. + ExtensionInfo *ExtensionInfo } type Tool struct { Name string `json:"name"` @@ -1175,6 +1190,17 @@ type ResumeSessionConfig struct { // OnAutoModeSwitchRequest is a handler for auto-mode-switch requests from the server. // See SessionConfig.OnAutoModeSwitchRequest. OnAutoModeSwitchRequest AutoModeSwitchRequestHandler + // Canvases declares canvases this session provides. Sent over the wire on + // `session.resume`. See SessionConfig.Canvases. + Canvases []CanvasDeclaration + // RequestCanvasRenderer asks the host to enable canvas rendering for this session. + RequestCanvasRenderer *bool + // RequestExtensions asks the host to surface declared canvases as agent-visible extensions. + RequestExtensions *bool + // CanvasHandler receives inbound canvas.* requests for this session. See SessionConfig.CanvasHandler. + CanvasHandler CanvasHandler `json:"-"` + // ExtensionInfo identifies the stable extension providing this session's canvases. + ExtensionInfo *ExtensionInfo } type ProviderConfig struct { // Type is the provider type: "openai", "azure", or "anthropic". Defaults to "openai". @@ -1399,6 +1425,10 @@ type createSessionRequest struct { GitHubToken string `json:"gitHubToken,omitempty"` RemoteSession rpc.RemoteSessionMode `json:"remoteSession,omitempty"` Cloud *CloudSessionOptions `json:"cloud,omitempty"` + Canvases []CanvasDeclaration `json:"canvases,omitempty"` + RequestCanvasRenderer *bool `json:"requestCanvasRenderer,omitempty"` + RequestExtensions *bool `json:"requestExtensions,omitempty"` + ExtensionInfo *ExtensionInfo `json:"extensionInfo,omitempty"` Traceparent string `json:"traceparent,omitempty"` Tracestate string `json:"tracestate,omitempty"` } @@ -1454,15 +1484,20 @@ type resumeSessionRequest struct { RequestElicitation *bool `json:"requestElicitation,omitempty"` GitHubToken string `json:"gitHubToken,omitempty"` RemoteSession rpc.RemoteSessionMode `json:"remoteSession,omitempty"` + Canvases []CanvasDeclaration `json:"canvases,omitempty"` + RequestCanvasRenderer *bool `json:"requestCanvasRenderer,omitempty"` + RequestExtensions *bool `json:"requestExtensions,omitempty"` + ExtensionInfo *ExtensionInfo `json:"extensionInfo,omitempty"` Traceparent string `json:"traceparent,omitempty"` Tracestate string `json:"tracestate,omitempty"` } // resumeSessionResponse is the response from session.resume type resumeSessionResponse struct { - SessionID string `json:"sessionId"` - WorkspacePath string `json:"workspacePath"` - Capabilities *SessionCapabilities `json:"capabilities,omitempty"` + SessionID string `json:"sessionId"` + WorkspacePath string `json:"workspacePath"` + Capabilities *SessionCapabilities `json:"capabilities,omitempty"` + OpenCanvases []rpc.OpenCanvasInstance `json:"openCanvases,omitempty"` } type hooksInvokeRequest struct { From c6d1f0042a4aa704f28cfee8fade2aa433c5e370 Mon Sep 17 00:00:00 2001 From: jmoseley Date: Sun, 24 May 2026 09:16:10 -0700 Subject: [PATCH 27/29] python: add canvas runtime support Mirrors the Rust SDK design: callers declare canvases on session.create / session.resume, install a single CanvasHandler, and the SDK dispatches inbound canvas.open / canvas.close / canvas.action.invoke JSON-RPC requests to that handler. Resume populates session.open_canvases from the response. JSON-RPC dispatch was loosened to allow handlers to return any JSON value (canvas.action.invoke result is arbitrary JSON). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- python/copilot/__init__.py | 26 ++++ python/copilot/_jsonrpc.py | 9 +- python/copilot/canvas.py | 312 +++++++++++++++++++++++++++++++++++++ python/copilot/client.py | 184 ++++++++++++++++++++++ python/copilot/session.py | 28 ++++ python/test_canvas.py | 249 +++++++++++++++++++++++++++++ 6 files changed, 805 insertions(+), 3 deletions(-) create mode 100644 python/copilot/canvas.py create mode 100644 python/test_canvas.py diff --git a/python/copilot/__init__.py b/python/copilot/__init__.py index ee53264d2..874267c9f 100644 --- a/python/copilot/__init__.py +++ b/python/copilot/__init__.py @@ -4,6 +4,20 @@ JSON-RPC based SDK for programmatic control of GitHub Copilot CLI """ +from .canvas import ( + CanvasAction, + CanvasActionContext, + CanvasDeclaration, + CanvasError, + CanvasHandler, + CanvasHostCapabilities, + CanvasHostContext, + CanvasLifecycleContext, + CanvasOpenContext, + CanvasOpenResponse, + ExtensionInfo, + OpenCanvasInstance, +) from .client import ( ChildProcessRuntimeConnection, CloudSessionOptions, @@ -130,6 +144,16 @@ "AutoModeSwitchHandler", "AutoModeSwitchRequest", "AutoModeSwitchResponse", + "CanvasAction", + "CanvasActionContext", + "CanvasDeclaration", + "CanvasError", + "CanvasHandler", + "CanvasHostCapabilities", + "CanvasHostContext", + "CanvasLifecycleContext", + "CanvasOpenContext", + "CanvasOpenResponse", "ChildProcessRuntimeConnection", "CloudSessionOptions", "CloudSessionRepository", @@ -148,6 +172,7 @@ "ExitPlanModeHandler", "ExitPlanModeRequest", "ExitPlanModeResult", + "ExtensionInfo", "GetAuthStatusResponse", "GetStatusResponse", "InfiniteSessionConfig", @@ -167,6 +192,7 @@ "ModelSupportsOverride", "ModelVisionLimits", "ModelVisionLimitsOverride", + "OpenCanvasInstance", "PermissionHandler", "PermissionNoResult", "PermissionRequest", diff --git a/python/copilot/_jsonrpc.py b/python/copilot/_jsonrpc.py index ecae75b6b..df84a1c5d 100644 --- a/python/copilot/_jsonrpc.py +++ b/python/copilot/_jsonrpc.py @@ -400,9 +400,12 @@ async def _dispatch_request(self, message: dict, handler: RequestHandler): outcome = handler(params) if inspect.isawaitable(outcome): outcome = await outcome - if outcome is not None and not isinstance(outcome, dict): + if outcome is not None and not isinstance( + outcome, dict | list | str | int | float | bool + ): raise ValueError( - f"Request handler must return a dict, got {type(outcome).__name__}" + "Request handler must return a JSON-serializable value, " + f"got {type(outcome).__name__}" ) await self._send_response(message["id"], outcome) except JsonRpcError as exc: @@ -419,7 +422,7 @@ async def _dispatch_request(self, message: dict, handler: RequestHandler): ) await self._send_error_response(message["id"], -32603, str(exc), None) - async def _send_response(self, request_id: str, result: dict | None): + async def _send_response(self, request_id: str, result: Any): response = { "jsonrpc": "2.0", "id": request_id, diff --git a/python/copilot/canvas.py b/python/copilot/canvas.py new file mode 100644 index 000000000..58c5b297d --- /dev/null +++ b/python/copilot/canvas.py @@ -0,0 +1,312 @@ +""" +Canvas declarations, provider callbacks, and host-side canvas RPC types. + +The Copilot CLI runtime sends inbound JSON-RPC requests (``canvas.open``, +``canvas.close``, ``canvas.action.invoke``) to any session that declares +canvases. The SDK forwards every such request to a single user-supplied +:class:`CanvasHandler`; multiplexing across multiple declared canvases is +the implementor's responsibility (e.g. by switching on +:attr:`CanvasOpenContext.canvas_id`). +""" + +from __future__ import annotations + +from abc import ABC, abstractmethod +from dataclasses import dataclass, field +from typing import Any + +from .generated.rpc import CanvasAction, OpenCanvasInstance + +__all__ = [ + "CanvasAction", + "CanvasActionContext", + "CanvasDeclaration", + "CanvasError", + "CanvasHandler", + "CanvasHostCapabilities", + "CanvasHostContext", + "CanvasLifecycleContext", + "CanvasOpenContext", + "CanvasOpenResponse", + "ExtensionInfo", + "OpenCanvasInstance", +] + + +@dataclass +class ExtensionInfo: + """Stable extension identity for session participants that provide canvases. + + Serializes to ``{"source": ..., "name": ...}`` on the wire. + """ + + source: str + """Extension namespace/source, e.g. ``"github-app"``.""" + + name: str + """Stable provider name within the source namespace.""" + + def to_dict(self) -> dict[str, Any]: + return {"source": self.source, "name": self.name} + + +@dataclass +class CanvasDeclaration: + """Declarative metadata for a single canvas, sent on + ``session.create`` / ``session.resume``. + """ + + id: str + """Canvas identifier, unique within the declaring connection.""" + + display_name: str + """Human-readable name shown in host UI and canvas pickers.""" + + description: str + """Short, single-sentence description shown to the agent in canvas catalogs.""" + + input_schema: dict[str, Any] | None = None + """JSON Schema for the ``input`` payload accepted by ``canvas.open``.""" + + actions: list[CanvasAction] | None = None + """Agent-callable actions this canvas exposes.""" + + def to_dict(self) -> dict[str, Any]: + result: dict[str, Any] = { + "id": self.id, + "displayName": self.display_name, + "description": self.description, + } + if self.input_schema is not None: + result["inputSchema"] = self.input_schema + if self.actions is not None: + result["actions"] = [action.to_dict() for action in self.actions] + return result + + +@dataclass +class CanvasOpenResponse: + """Response returned from :meth:`CanvasHandler.on_open`.""" + + url: str | None = None + """URL the host should render. Optional for canvases with no visual surface.""" + + title: str | None = None + """Provider-supplied title shown in host chrome.""" + + status: str | None = None + """Provider-supplied status text shown in host chrome.""" + + def to_dict(self) -> dict[str, Any]: + result: dict[str, Any] = {} + if self.url is not None: + result["url"] = self.url + if self.title is not None: + result["title"] = self.title + if self.status is not None: + result["status"] = self.status + return result + + +@dataclass +class CanvasHostCapabilities: + """Host capability details passed to canvas provider callbacks.""" + + canvases: bool = False + """Whether the host supports canvas rendering.""" + + @staticmethod + def from_dict(obj: Any) -> CanvasHostCapabilities: + if not isinstance(obj, dict): + return CanvasHostCapabilities() + return CanvasHostCapabilities(canvases=bool(obj.get("canvases", False))) + + +@dataclass +class CanvasHostContext: + """Host capabilities passed to canvas provider callbacks.""" + + capabilities: CanvasHostCapabilities = field(default_factory=CanvasHostCapabilities) + """Host capability details.""" + + @staticmethod + def from_dict(obj: Any) -> CanvasHostContext: + if not isinstance(obj, dict): + return CanvasHostContext() + return CanvasHostContext( + capabilities=CanvasHostCapabilities.from_dict(obj.get("capabilities")) + ) + + +@dataclass +class CanvasOpenContext: + """Context handed to :meth:`CanvasHandler.on_open`.""" + + session_id: str + """Session that requested the canvas.""" + + extension_id: str + """Owning provider identifier.""" + + canvas_id: str + """Canvas id from the declaring :class:`CanvasDeclaration`.""" + + instance_id: str + """Stable instance id supplied by the runtime.""" + + input: Any + """Validated input payload.""" + + host: CanvasHostContext | None = None + """Host capabilities supplied by the runtime.""" + + +@dataclass +class CanvasActionContext: + """Context handed to :meth:`CanvasHandler.on_action`.""" + + session_id: str + """Session that invoked the action.""" + + extension_id: str + """Owning provider identifier.""" + + canvas_id: str + """Canvas id targeted by the action.""" + + instance_id: str + """Instance id targeted by the action.""" + + action_name: str + """Action name from :attr:`CanvasAction.name`.""" + + input: Any + """Validated input payload.""" + + host: CanvasHostContext | None = None + """Host capabilities supplied by the runtime.""" + + +@dataclass +class CanvasLifecycleContext: + """Context handed to a canvas's close lifecycle hook.""" + + session_id: str + """Session owning the canvas instance.""" + + extension_id: str + """Owning provider identifier.""" + + canvas_id: str + """Canvas id from the declaring :class:`CanvasDeclaration`.""" + + instance_id: str + """Instance id this lifecycle event applies to.""" + + host: CanvasHostContext | None = None + """Host capabilities supplied by the runtime.""" + + +class CanvasError(Exception): + """Structured error returned from canvas handlers. + + The serialized envelope is ``{"code": ..., "message": ...}``. The SDK + surfaces this through the JSON-RPC error's ``data`` field while sending + a standard ``-32603`` (internal error) wire code. + """ + + def __init__(self, code: str, message: str) -> None: + self.code = code + self.message = message + super().__init__(f"{code}: {message}") + + def to_envelope(self) -> dict[str, str]: + return {"code": self.code, "message": self.message} + + @classmethod + def no_handler(cls) -> CanvasError: + """Default error returned when a custom action has no handler.""" + return cls( + "canvas_action_no_handler", + "No handler implemented for this canvas action", + ) + + @classmethod + def handler_unset(cls) -> CanvasError: + """Error returned when a canvas RPC arrives but no handler is installed.""" + return cls( + "canvas_handler_unset", + "No CanvasHandler installed on this session; " + "install one via SessionConfig.canvas_handler before creating the session.", + ) + + +class CanvasHandler(ABC): + """Provider-side canvas lifecycle handler. + + A session installs a single :class:`CanvasHandler` via the + ``canvas_handler=`` argument to + :meth:`copilot.CopilotClient.create_session` / + :meth:`copilot.CopilotClient.resume_session`. The handler receives every + inbound ``canvas.open`` / ``canvas.close`` / ``canvas.action.invoke`` + JSON-RPC request the runtime issues for this session and decides — + typically by inspecting :attr:`CanvasOpenContext.canvas_id` — which + application-side canvas should handle the call. + + The SDK does not maintain a per-canvas registry; multiplexing across + declared canvases is the implementor's responsibility. + """ + + @abstractmethod + async def on_open(self, ctx: CanvasOpenContext) -> CanvasOpenResponse: + """Open a new canvas instance. + + May raise :class:`CanvasError` to surface a structured failure to + the host. + """ + + async def on_close(self, ctx: CanvasLifecycleContext) -> None: + """Canvas was closed by the user or agent. Default: no-op.""" + + async def on_action(self, ctx: CanvasActionContext) -> Any: + """Handle a non-lifecycle action declared by the canvas. + + Default raises :meth:`CanvasError.no_handler`. + """ + raise CanvasError.no_handler() + + +# ----- Internal helpers for inbound RPC dispatch (not part of the public API). ----- + + +def _open_context_from_params(params: dict[str, Any]) -> CanvasOpenContext: + return CanvasOpenContext( + session_id=params["sessionId"], + extension_id=params["extensionId"], + canvas_id=params["canvasId"], + instance_id=params["instanceId"], + input=params.get("input"), + host=CanvasHostContext.from_dict(params.get("host")) if params.get("host") else None, + ) + + +def _lifecycle_context_from_params(params: dict[str, Any]) -> CanvasLifecycleContext: + return CanvasLifecycleContext( + session_id=params["sessionId"], + extension_id=params["extensionId"], + canvas_id=params["canvasId"], + instance_id=params["instanceId"], + host=CanvasHostContext.from_dict(params.get("host")) if params.get("host") else None, + ) + + +def _action_context_from_params(params: dict[str, Any]) -> CanvasActionContext: + return CanvasActionContext( + session_id=params["sessionId"], + extension_id=params["extensionId"], + canvas_id=params["canvasId"], + instance_id=params["instanceId"], + action_name=params["actionName"], + input=params.get("input"), + host=CanvasHostContext.from_dict(params.get("host")) if params.get("host") else None, + ) diff --git a/python/copilot/client.py b/python/copilot/client.py index a52b8711f..5e795bdde 100644 --- a/python/copilot/client.py +++ b/python/copilot/client.py @@ -36,8 +36,18 @@ from ._jsonrpc import JsonRpcClient, JsonRpcError, ProcessExitedError from ._sdk_protocol_version import get_sdk_protocol_version from ._telemetry import get_trace_context +from .canvas import ( + CanvasDeclaration, + CanvasError, + CanvasHandler, + ExtensionInfo, + _action_context_from_params, + _lifecycle_context_from_params, + _open_context_from_params, +) from .generated.rpc import ( ClientSessionApiHandlers, + OpenCanvasInstance, RemoteSessionMode, ServerRpc, _ConnectRequest, @@ -1544,6 +1554,11 @@ async def create_session( github_token: str | None = None, remote_session: RemoteSessionMode | None = None, cloud: CloudSessionOptions | None = None, + canvases: list[CanvasDeclaration] | None = None, + request_canvas_renderer: bool | None = None, + request_extensions: bool | None = None, + extension_info: ExtensionInfo | None = None, + canvas_handler: CanvasHandler | None = None, ) -> CopilotSession: """ Create a new conversation session with the Copilot CLI. @@ -1782,6 +1797,15 @@ async def create_session( ] payload["infiniteSessions"] = wire_config + if canvases: + payload["canvases"] = [c.to_dict() for c in canvases] + if request_canvas_renderer is not None: + payload["requestCanvasRenderer"] = request_canvas_renderer + if request_extensions is not None: + payload["requestExtensions"] = request_extensions + if extension_info is not None: + payload["extensionInfo"] = extension_info.to_dict() + if not self._client: raise RuntimeError("Client not connected") @@ -1825,6 +1849,8 @@ async def create_session( session._register_exit_plan_mode_handler(on_exit_plan_mode_request) if on_auto_mode_switch_request: session._register_auto_mode_switch_handler(on_auto_mode_switch_request) + if canvas_handler is not None: + session._register_canvas_handler(canvas_handler) if hooks: session._register_hooks(hooks) if transform_callbacks: @@ -1919,6 +1945,12 @@ async def resume_session( github_token: str | None = None, remote_session: RemoteSessionMode | None = None, continue_pending_work: bool | None = None, + canvases: list[CanvasDeclaration] | None = None, + request_canvas_renderer: bool | None = None, + request_extensions: bool | None = None, + extension_info: ExtensionInfo | None = None, + canvas_handler: CanvasHandler | None = None, + open_canvases: list[OpenCanvasInstance] | None = None, ) -> CopilotSession: """ Resume an existing conversation session by its ID. @@ -2135,6 +2167,17 @@ async def resume_session( ] payload["infiniteSessions"] = wire_config + if canvases: + payload["canvases"] = [c.to_dict() for c in canvases] + if open_canvases: + payload["openCanvases"] = [inst.to_dict() for inst in open_canvases] + if request_canvas_renderer is not None: + payload["requestCanvasRenderer"] = request_canvas_renderer + if request_extensions is not None: + payload["requestExtensions"] = request_extensions + if extension_info is not None: + payload["extensionInfo"] = extension_info.to_dict() + if not self._client: raise RuntimeError("Client not connected") @@ -2175,6 +2218,8 @@ async def resume_session( session._register_exit_plan_mode_handler(on_exit_plan_mode_request) if on_auto_mode_switch_request: session._register_auto_mode_switch_handler(on_auto_mode_switch_request) + if canvas_handler is not None: + session._register_canvas_handler(canvas_handler) if hooks: session._register_hooks(hooks) if transform_callbacks: @@ -2207,6 +2252,11 @@ async def resume_session( session._workspace_path = response.get("workspacePath") capabilities = response.get("capabilities") session._set_capabilities(capabilities) + open_canvases_raw = response.get("openCanvases") + if isinstance(open_canvases_raw, list): + session._set_open_canvases( + [OpenCanvasInstance.from_dict(inst) for inst in open_canvases_raw] + ) except BaseException as exc: with self._sessions_lock: self._sessions.pop(session_id, None) @@ -2988,6 +3038,18 @@ def handle_notification(method: str, params: dict): self._client.set_request_handler( "systemMessage.transform", self._handle_system_message_transform ) + self._client.set_request_handler( + "canvas.open", + self._canvas_request_handler(self._handle_canvas_open), + ) + self._client.set_request_handler( + "canvas.close", + self._canvas_request_handler(self._handle_canvas_close), + ) + self._client.set_request_handler( + "canvas.action.invoke", + self._canvas_request_handler(self._handle_canvas_action_invoke), + ) register_client_session_api_handlers(self._client, self._get_client_session_handlers) # Start listening for messages @@ -3107,6 +3169,18 @@ def handle_notification(method: str, params: dict): self._client.set_request_handler( "systemMessage.transform", self._handle_system_message_transform ) + self._client.set_request_handler( + "canvas.open", + self._canvas_request_handler(self._handle_canvas_open), + ) + self._client.set_request_handler( + "canvas.close", + self._canvas_request_handler(self._handle_canvas_close), + ) + self._client.set_request_handler( + "canvas.action.invoke", + self._canvas_request_handler(self._handle_canvas_action_invoke), + ) register_client_session_api_handlers(self._client, self._get_client_session_handlers) # Start listening for messages @@ -3236,3 +3310,113 @@ async def _handle_system_message_transform(self, params: dict) -> dict: raise ValueError(f"unknown session {session_id}") return await session._handle_system_message_transform(sections) + + def _resolve_canvas_handler(self, session_id: str) -> CanvasHandler: + """Look up the canvas handler for ``session_id`` or raise CanvasError.""" + with self._sessions_lock: + session = self._sessions.get(session_id) + if session is None: + raise CanvasError( + "canvas_handler_unset", + f"No session registered for {session_id}; cannot dispatch canvas RPC.", + ) + handler = session._get_canvas_handler() + if handler is None: + raise CanvasError.handler_unset() + return handler + + async def _handle_canvas_open(self, params: dict) -> dict: + """Handle an inbound ``canvas.open`` request from the CLI runtime.""" + try: + session_id = params["sessionId"] + except KeyError as exc: + raise CanvasError( + "canvas_invalid_request", "canvas.open params missing sessionId" + ) from exc + handler = self._resolve_canvas_handler(session_id) + try: + ctx = _open_context_from_params(params) + except KeyError as exc: + raise CanvasError( + "canvas_invalid_request", f"canvas.open params missing field: {exc.args[0]}" + ) from exc + try: + response = await handler.on_open(ctx) + except CanvasError: + raise + except Exception as exc: + raise CanvasError( + "canvas_open_handler_failed", + f"canvas.open handler raised: {exc}", + ) from exc + return response.to_dict() + + async def _handle_canvas_close(self, params: dict) -> None: + """Handle an inbound ``canvas.close`` request from the CLI runtime.""" + try: + session_id = params["sessionId"] + except KeyError as exc: + raise CanvasError( + "canvas_invalid_request", "canvas.close params missing sessionId" + ) from exc + handler = self._resolve_canvas_handler(session_id) + try: + ctx = _lifecycle_context_from_params(params) + except KeyError as exc: + raise CanvasError( + "canvas_invalid_request", f"canvas.close params missing field: {exc.args[0]}" + ) from exc + try: + await handler.on_close(ctx) + except CanvasError: + raise + except Exception as exc: + raise CanvasError( + "canvas_close_handler_failed", + f"canvas.close handler raised: {exc}", + ) from exc + return None + + async def _handle_canvas_action_invoke(self, params: dict) -> Any: + """Handle an inbound ``canvas.action.invoke`` request from the CLI runtime.""" + try: + session_id = params["sessionId"] + except KeyError as exc: + raise CanvasError( + "canvas_invalid_request", + "canvas.action.invoke params missing sessionId", + ) from exc + handler = self._resolve_canvas_handler(session_id) + try: + ctx = _action_context_from_params(params) + except KeyError as exc: + raise CanvasError( + "canvas_invalid_request", + f"canvas.action.invoke params missing field: {exc.args[0]}", + ) from exc + try: + return await handler.on_action(ctx) + except CanvasError: + raise + except Exception as exc: + raise CanvasError( + "canvas_action_handler_failed", + f"canvas.action.invoke handler raised: {exc}", + ) from exc + + @staticmethod + def _canvas_request_handler( + coro: Callable[[dict], Awaitable[Any]], + ) -> Callable[[dict], Awaitable[Any]]: + """Wrap a canvas RPC coroutine so ``CanvasError`` becomes a JSON-RPC error + with the structured envelope in the error's ``data`` field, matching the + Rust SDK wire shape. + """ + + async def wrapper(params: dict) -> Any: + try: + return await coro(params) + except CanvasError as err: + raise JsonRpcError(-32603, err.message, data=err.to_envelope()) from err + + return wrapper diff --git a/python/copilot/session.py b/python/copilot/session.py index c775ef58e..90134a151 100644 --- a/python/copilot/session.py +++ b/python/copilot/session.py @@ -25,6 +25,7 @@ from ._diagnostics import log_timing from ._jsonrpc import JsonRpcError, ProcessExitedError from ._telemetry import get_trace_context, trace_context +from .canvas import CanvasHandler, OpenCanvasInstance from .generated.rpc import ( ClientSessionApiHandlers, CommandsHandlePendingCommandRequest, @@ -1023,6 +1024,10 @@ def __init__( self._elicitation_handler_lock = threading.Lock() self._capabilities: SessionCapabilities = {} self._client_session_apis = ClientSessionApiHandlers() + self._canvas_handler: CanvasHandler | None = None + self._canvas_handler_lock = threading.Lock() + self._open_canvases: list[OpenCanvasInstance] = [] + self._open_canvases_lock = threading.Lock() self._rpc: SessionRpc | None = None self._destroyed = False @@ -1739,6 +1744,29 @@ def _register_auto_mode_switch_handler(self, handler: AutoModeSwitchHandler | No with self._auto_mode_switch_handler_lock: self._auto_mode_switch_handler = handler + def _register_canvas_handler(self, handler: CanvasHandler | None) -> None: + """Register the canvas handler for this session.""" + with self._canvas_handler_lock: + self._canvas_handler = handler + + def _get_canvas_handler(self) -> CanvasHandler | None: + with self._canvas_handler_lock: + return self._canvas_handler + + def _set_open_canvases(self, instances: list[OpenCanvasInstance]) -> None: + with self._open_canvases_lock: + self._open_canvases = list(instances) + + @property + def open_canvases(self) -> list[OpenCanvasInstance]: + """Open canvas instances reported by the most recent ``session.resume``. + + Returns an empty list for sessions created via ``session.create`` or + when the server did not include any open canvases on resume. + """ + with self._open_canvases_lock: + return list(self._open_canvases) + def _set_capabilities(self, capabilities: SessionCapabilities | None) -> None: """Set the host capabilities for this session. diff --git a/python/test_canvas.py b/python/test_canvas.py new file mode 100644 index 000000000..4c9ab223f --- /dev/null +++ b/python/test_canvas.py @@ -0,0 +1,249 @@ +"""Unit tests for the canvas SDK surface.""" + +from __future__ import annotations + +import threading +from typing import Any + +import pytest + +from copilot._jsonrpc import JsonRpcError +from copilot.canvas import ( + CanvasAction, + CanvasActionContext, + CanvasDeclaration, + CanvasError, + CanvasHandler, + CanvasOpenContext, + CanvasOpenResponse, + ExtensionInfo, + OpenCanvasInstance, + _action_context_from_params, + _lifecycle_context_from_params, + _open_context_from_params, +) +from copilot.client import CopilotClient + + +def test_canvas_declaration_serializes_camelcase_and_drops_optional(): + decl = CanvasDeclaration( + id="my-canvas", + display_name="My Canvas", + description="Does the thing", + ) + assert decl.to_dict() == { + "id": "my-canvas", + "displayName": "My Canvas", + "description": "Does the thing", + } + + +def test_canvas_declaration_serializes_input_schema_and_actions(): + action = CanvasAction( + name="refresh", + description="Refresh the canvas", + ) + decl = CanvasDeclaration( + id="c", + display_name="C", + description="D", + input_schema={"type": "object"}, + actions=[action], + ) + payload = decl.to_dict() + assert payload["inputSchema"] == {"type": "object"} + assert payload["actions"] == [action.to_dict()] + + +def test_extension_info_serializes(): + info = ExtensionInfo(source="github-app", name="my-ext") + assert info.to_dict() == {"source": "github-app", "name": "my-ext"} + + +def test_canvas_open_response_drops_none_fields(): + assert CanvasOpenResponse().to_dict() == {} + assert CanvasOpenResponse(url="https://x", status="ok").to_dict() == { + "url": "https://x", + "status": "ok", + } + + +def test_canvas_error_envelope_and_factories(): + err = CanvasError("oops", "something broke") + assert err.code == "oops" + assert err.message == "something broke" + assert err.to_envelope() == {"code": "oops", "message": "something broke"} + + no_handler = CanvasError.no_handler() + assert no_handler.code == "canvas_action_no_handler" + + unset = CanvasError.handler_unset() + assert unset.code == "canvas_handler_unset" + + +async def test_default_canvas_handler_on_action_raises_no_handler(): + class StubHandler(CanvasHandler): + async def on_open(self, ctx: CanvasOpenContext) -> CanvasOpenResponse: + return CanvasOpenResponse() + + handler = StubHandler() + ctx = CanvasActionContext( + session_id="s", + extension_id="e", + canvas_id="c", + instance_id="i", + action_name="any", + input=None, + ) + with pytest.raises(CanvasError) as excinfo: + await handler.on_action(ctx) + assert excinfo.value.code == "canvas_action_no_handler" + + +def test_context_helpers_parse_params(): + base = { + "sessionId": "s", + "extensionId": "e", + "canvasId": "c", + "instanceId": "i", + "input": {"foo": 1}, + "host": {"capabilities": {"canvases": True}}, + } + open_ctx = _open_context_from_params(base) + assert open_ctx.session_id == "s" + assert open_ctx.canvas_id == "c" + assert open_ctx.input == {"foo": 1} + assert open_ctx.host is not None and open_ctx.host.capabilities.canvases is True + + close_ctx = _lifecycle_context_from_params(base) + assert close_ctx.canvas_id == "c" + assert close_ctx.instance_id == "i" + + action_ctx = _action_context_from_params({**base, "actionName": "refresh"}) + assert action_ctx.action_name == "refresh" + + +class _StubSession: + """Minimal CopilotSession stand-in for the inbound dispatch tests.""" + + def __init__(self, handler: CanvasHandler | None) -> None: + self._handler = handler + self._open_canvases: list[OpenCanvasInstance] = [] + self._open_canvases_lock = threading.Lock() + + def _get_canvas_handler(self) -> CanvasHandler | None: + return self._handler + + def _set_open_canvases(self, instances: list[OpenCanvasInstance]) -> None: + with self._open_canvases_lock: + self._open_canvases = list(instances) + + @property + def open_canvases(self) -> list[OpenCanvasInstance]: + with self._open_canvases_lock: + return list(self._open_canvases) + + +def _make_client_with_session(session_id: str, session: Any) -> CopilotClient: + """Construct a CopilotClient skeleton sufficient for testing the inbound + canvas dispatch helpers without actually launching the CLI.""" + client = CopilotClient.__new__(CopilotClient) + client._sessions = {session_id: session} + client._sessions_lock = threading.Lock() + return client + + +async def test_handle_canvas_open_dispatches_to_handler(): + class Handler(CanvasHandler): + def __init__(self) -> None: + self.received: CanvasOpenContext | None = None + + async def on_open(self, ctx: CanvasOpenContext) -> CanvasOpenResponse: + self.received = ctx + return CanvasOpenResponse(url="https://canvas.example", title="Hi") + + async def on_action(self, ctx: CanvasActionContext) -> Any: + return {"echo": ctx.input} + + handler = Handler() + session = _StubSession(handler) + client = _make_client_with_session("sess-1", session) + + result = await client._handle_canvas_open( + { + "sessionId": "sess-1", + "extensionId": "ext", + "canvasId": "c", + "instanceId": "i", + "input": {"q": 1}, + } + ) + assert result == {"url": "https://canvas.example", "title": "Hi"} + assert handler.received is not None + assert handler.received.canvas_id == "c" + + +async def test_handle_canvas_open_raises_when_handler_unset(): + session = _StubSession(handler=None) + client = _make_client_with_session("sess-1", session) + + with pytest.raises(CanvasError) as excinfo: + await client._handle_canvas_open( + { + "sessionId": "sess-1", + "extensionId": "ext", + "canvasId": "c", + "instanceId": "i", + } + ) + assert excinfo.value.code == "canvas_handler_unset" + + +async def test_handle_canvas_action_returns_arbitrary_value(): + class Handler(CanvasHandler): + async def on_open(self, ctx: CanvasOpenContext) -> CanvasOpenResponse: + return CanvasOpenResponse() + + async def on_action(self, ctx: CanvasActionContext) -> Any: + return [1, 2, 3] + + client = _make_client_with_session("sess-1", _StubSession(Handler())) + result = await client._handle_canvas_action_invoke( + { + "sessionId": "sess-1", + "extensionId": "ext", + "canvasId": "c", + "instanceId": "i", + "actionName": "do", + } + ) + assert result == [1, 2, 3] + + +async def test_canvas_request_handler_translates_canvas_error(): + err = CanvasError("bad", "fail") + + async def coro(params: dict) -> Any: + raise err + + wrapped = CopilotClient._canvas_request_handler(coro) + with pytest.raises(JsonRpcError) as excinfo: + await wrapped({}) + assert excinfo.value.code == -32603 + assert excinfo.value.message == "fail" + assert excinfo.value.data == {"code": "bad", "message": "fail"} + + +def test_set_open_canvases_round_trip(): + from copilot.generated.rpc import CanvasInstanceAvailability + + inst = OpenCanvasInstance( + availability=CanvasInstanceAvailability.READY, + canvas_id="c", + extension_id="e", + instance_id="i", + reopen=False, + ) + session = _StubSession(handler=None) + session._set_open_canvases([inst]) + assert session.open_canvases == [inst] From 97dc6127b5047448981dab3024e5ca9a906a4797 Mon Sep 17 00:00:00 2001 From: jmoseley Date: Sun, 24 May 2026 09:42:52 -0700 Subject: [PATCH 28/29] dotnet: add canvas runtime support Ports the canvas runtime surface from the Rust SDK to the .NET SDK so .NET hosts can declare canvases on session create/resume, advertise an extension identity, and handle inbound canvas.open / canvas.close / canvas.action.invoke RPC calls. * New public Canvas.cs surface (CanvasDeclaration, ExtensionInfo, CanvasOpenResponse, CanvasHostContext, lifecycle/action/open contexts, CanvasError, ICanvasHandler, CanvasHandlerBase). All marked [Experimental(GHCP001)]. * SessionConfigBase gains Canvases, RequestCanvasRenderer, RequestExtensions, ExtensionInfo, CanvasHandler. * CreateSession/ResumeSession requests forward the new fields and surface OpenCanvases on the response. CopilotSession exposes the returned canvases via OpenCanvases. * CopilotClient registers canvas.open / canvas.close / canvas.action.invoke handlers and dispatches them to the session, which invokes the user's ICanvasHandler and returns structured CanvasError data via a new JsonRpc LocalRpcInvocationException path. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- dotnet/src/Canvas.cs | 288 ++++++++++++++++++++++++++++++++ dotnet/src/Client.cs | 86 +++++++++- dotnet/src/JsonRpc.cs | 38 ++++- dotnet/src/Session.cs | 138 +++++++++++++++ dotnet/src/Types.cs | 55 ++++++ dotnet/test/Unit/CanvasTests.cs | 160 ++++++++++++++++++ 6 files changed, 756 insertions(+), 9 deletions(-) create mode 100644 dotnet/src/Canvas.cs create mode 100644 dotnet/test/Unit/CanvasTests.cs diff --git a/dotnet/src/Canvas.cs b/dotnet/src/Canvas.cs new file mode 100644 index 000000000..6a6134e18 --- /dev/null +++ b/dotnet/src/Canvas.cs @@ -0,0 +1,288 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Threading; +using System.Threading.Tasks; +using GitHub.Copilot.Rpc; + +namespace GitHub.Copilot; + +/// +/// Declarative metadata for a single canvas, sent over the wire on +/// session.create / session.resume. +/// +[Experimental(Diagnostics.Experimental)] +public sealed class CanvasDeclaration +{ + /// Canvas identifier, unique within the declaring connection. + [JsonPropertyName("id")] + public string Id { get; set; } = string.Empty; + + /// Human-readable name shown in host UI and canvas pickers. + [JsonPropertyName("displayName")] + public string DisplayName { get; set; } = string.Empty; + + /// Short, single-sentence description shown to the agent in canvas catalogs. + [JsonPropertyName("description")] + public string Description { get; set; } = string.Empty; + + /// JSON Schema for the input payload accepted by canvas.open. + [JsonPropertyName("inputSchema")] + public JsonElement? InputSchema { get; set; } + + /// Agent-callable actions this canvas exposes. + [JsonPropertyName("actions")] + public IList? Actions { get; set; } +} + +/// +/// Stable extension identity for session participants that provide canvases. +/// +[Experimental(Diagnostics.Experimental)] +public sealed class ExtensionInfo +{ + /// Extension namespace/source, e.g. "github-app". + [JsonPropertyName("source")] + public string Source { get; set; } = string.Empty; + + /// Stable provider name within the source namespace. + [JsonPropertyName("name")] + public string Name { get; set; } = string.Empty; +} + +/// Response returned from . +[Experimental(Diagnostics.Experimental)] +public sealed class CanvasOpenResponse +{ + /// URL the host should render. Optional for canvases with no visual surface. + [JsonPropertyName("url")] + public string? Url { get; set; } + + /// Provider-supplied title shown in host chrome. + [JsonPropertyName("title")] + public string? Title { get; set; } + + /// Provider-supplied status text shown in host chrome. + [JsonPropertyName("status")] + public string? Status { get; set; } +} + +/// Host capabilities passed to canvas provider callbacks. +[Experimental(Diagnostics.Experimental)] +public sealed class CanvasHostContext +{ + /// Host capability details. + [JsonPropertyName("capabilities")] + public CanvasHostCapabilities Capabilities { get; set; } = new(); +} + +/// Host capability details passed to canvas provider callbacks. +[Experimental(Diagnostics.Experimental)] +public sealed class CanvasHostCapabilities +{ + /// Whether the host supports canvas rendering. + [JsonPropertyName("canvases")] + public bool Canvases { get; set; } +} + +/// Context handed to . +[Experimental(Diagnostics.Experimental)] +public sealed class CanvasOpenContext +{ + /// Session that requested the canvas. + public string SessionId { get; init; } = string.Empty; + + /// Owning provider identifier. + public string ExtensionId { get; init; } = string.Empty; + + /// Canvas id from the declaring . + public string CanvasId { get; init; } = string.Empty; + + /// Stable instance id supplied by the runtime. + public string InstanceId { get; init; } = string.Empty; + + /// Validated input payload. + public JsonElement Input { get; init; } + + /// Host capabilities supplied by the runtime. + public CanvasHostContext? Host { get; init; } +} + +/// Context handed to . +[Experimental(Diagnostics.Experimental)] +public sealed class CanvasActionContext +{ + /// Session that invoked the action. + public string SessionId { get; init; } = string.Empty; + + /// Owning provider identifier. + public string ExtensionId { get; init; } = string.Empty; + + /// Canvas id targeted by the action. + public string CanvasId { get; init; } = string.Empty; + + /// Instance id targeted by the action. + public string InstanceId { get; init; } = string.Empty; + + /// Action name from . + public string ActionName { get; init; } = string.Empty; + + /// Validated input payload. + public JsonElement Input { get; init; } + + /// Host capabilities supplied by the runtime. + public CanvasHostContext? Host { get; init; } +} + +/// Context handed to a canvas's close lifecycle hook. +[Experimental(Diagnostics.Experimental)] +public sealed class CanvasLifecycleContext +{ + /// Session owning the canvas instance. + public string SessionId { get; init; } = string.Empty; + + /// Owning provider identifier. + public string ExtensionId { get; init; } = string.Empty; + + /// Canvas id from the declaring . + public string CanvasId { get; init; } = string.Empty; + + /// Instance id this lifecycle event applies to. + public string InstanceId { get; init; } = string.Empty; + + /// Host capabilities supplied by the runtime. + public CanvasHostContext? Host { get; init; } +} + +/// Structured error returned from canvas handlers. +/// +/// Throw this from implementations to surface a +/// machine-readable error code to the runtime. Any other exception is wrapped +/// in a generic canvas_handler_error envelope. +/// +[Experimental(Diagnostics.Experimental)] +public sealed class CanvasError : Exception +{ + /// Initializes a new . + /// Machine-readable error code. + /// Human-readable message. + public CanvasError(string code, string message) : base(message) + { + Code = code; + } + + /// Machine-readable error code. + public string Code { get; } + + /// + /// Default error returned when a custom action has no handler. + /// + public static CanvasError NoHandler() => new( + "canvas_action_no_handler", + "No handler implemented for this canvas action"); +} + +/// +/// Internal helpers used by the session runtime to translate +/// (and other handler-thrown exceptions) into structured JSON-RPC error responses. +/// +internal static class CanvasErrorHelpers +{ + private const int InternalError = -32603; + + public static LocalRpcInvocationException HandlerUnset() => Build( + "canvas_handler_unset", + "No canvas handler is registered on this session"); + + public static LocalRpcInvocationException HandlerError(string message) => Build( + "canvas_handler_error", + message); + + public static LocalRpcInvocationException ToRpcException(CanvasError error) => Build(error.Code, error.Message); + + private static LocalRpcInvocationException Build(string code, string message) + { + var json = JsonSerializer.Serialize( + new CanvasErrorPayload { Code = code, Message = message }, + CanvasJsonContext.Default.CanvasErrorPayload); + using var doc = JsonDocument.Parse(json); + return new LocalRpcInvocationException(InternalError, message, doc.RootElement.Clone()); + } + + internal sealed class CanvasErrorPayload + { + [JsonPropertyName("code")] + public string Code { get; set; } = string.Empty; + + [JsonPropertyName("message")] + public string Message { get; set; } = string.Empty; + } +} + +[JsonSourceGenerationOptions(PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase)] +[JsonSerializable(typeof(CanvasErrorHelpers.CanvasErrorPayload))] +internal partial class CanvasJsonContext : JsonSerializerContext; + +/// +/// Provider-side canvas lifecycle handler. +/// +/// +/// A session installs a single via +/// SessionConfigBase.CanvasHandler. The handler receives every +/// inbound canvas.open / canvas.close / canvas.action.invoke +/// JSON-RPC request the runtime issues for this session and decides — typically +/// by inspecting — which +/// application-side canvas should handle the call. +/// +/// The SDK does not maintain a per-canvas registry; multiplexing across +/// declared canvases is the implementor's responsibility. +/// +/// +/// Implementations targeting netstandard2.0 cannot rely on default +/// interface methods; derive from to inherit +/// sensible defaults for and . +/// +/// +[Experimental(Diagnostics.Experimental)] +public interface ICanvasHandler +{ + /// Open a new canvas instance. + Task OnOpenAsync(CanvasOpenContext context, CancellationToken cancellationToken); + + /// Canvas was closed by the user or agent. Default: no-op. + Task OnCloseAsync(CanvasLifecycleContext context, CancellationToken cancellationToken); + + /// + /// Handle a non-lifecycle action declared by the canvas. + /// Default: throws . + /// + Task OnActionAsync(CanvasActionContext context, CancellationToken cancellationToken); +} + +/// +/// Convenience base class for that supplies +/// default no-op / no-handler implementations of the optional callbacks. +/// +[Experimental(Diagnostics.Experimental)] +public abstract class CanvasHandlerBase : ICanvasHandler +{ + /// + public abstract Task OnOpenAsync(CanvasOpenContext context, CancellationToken cancellationToken); + + /// + public virtual Task OnCloseAsync(CanvasLifecycleContext context, CancellationToken cancellationToken) +#if NET8_0_OR_GREATER + => Task.CompletedTask; +#else + => Task.FromResult(null); +#endif + + /// + public virtual Task OnActionAsync(CanvasActionContext context, CancellationToken cancellationToken) + => Task.FromException(CanvasError.NoHandler()); +} diff --git a/dotnet/src/Client.cs b/dotnet/src/Client.cs index a5cc62354..5666d7b3a 100644 --- a/dotnet/src/Client.cs +++ b/dotnet/src/Client.cs @@ -567,6 +567,7 @@ public async Task CreateSessionAsync(SessionConfig config, Cance session.On(config.OnEvent); } ConfigureSessionFsHandlers(session, config.CreateSessionFsProvider); + session.SetCanvasHandler(config.CanvasHandler); RegisterSession(session); session.StartProcessingEvents(); LoggingHelpers.LogTiming(_logger, LogLevel.Debug, null, @@ -618,7 +619,11 @@ public async Task CreateSessionAsync(SessionConfig config, Cance GitHubToken: config.GitHubToken, RemoteSession: config.RemoteSession, Cloud: config.Cloud, - InstructionDirectories: config.InstructionDirectories); + InstructionDirectories: config.InstructionDirectories, + Canvases: config.Canvases, + RequestCanvasRenderer: config.RequestCanvasRenderer, + RequestExtensions: config.RequestExtensions, + ExtensionInfo: config.ExtensionInfo); var rpcTimestamp = Stopwatch.GetTimestamp(); var response = await InvokeRpcAsync( @@ -630,6 +635,7 @@ public async Task CreateSessionAsync(SessionConfig config, Cance session.WorkspacePath = response.WorkspacePath; session.SetCapabilities(response.Capabilities); + session.SetOpenCanvases(response.OpenCanvases); } catch (Exception ex) { @@ -726,6 +732,7 @@ public async Task ResumeSessionAsync(string sessionId, ResumeSes session.On(config.OnEvent); } ConfigureSessionFsHandlers(session, config.CreateSessionFsProvider); + session.SetCanvasHandler(config.CanvasHandler); RegisterSession(session); session.StartProcessingEvents(); LoggingHelpers.LogTiming(_logger, LogLevel.Debug, null, @@ -778,7 +785,11 @@ public async Task ResumeSessionAsync(string sessionId, ResumeSes GitHubToken: config.GitHubToken, RemoteSession: config.RemoteSession, ContinuePendingWork: config.ContinuePendingWork, - InstructionDirectories: config.InstructionDirectories); + InstructionDirectories: config.InstructionDirectories, + Canvases: config.Canvases, + RequestCanvasRenderer: config.RequestCanvasRenderer, + RequestExtensions: config.RequestExtensions, + ExtensionInfo: config.ExtensionInfo); var rpcTimestamp = Stopwatch.GetTimestamp(); var response = await InvokeRpcAsync( @@ -790,6 +801,7 @@ public async Task ResumeSessionAsync(string sessionId, ResumeSes session.WorkspacePath = response.WorkspacePath; session.SetCapabilities(response.Capabilities); + session.SetOpenCanvases(response.OpenCanvases); } catch (Exception ex) { @@ -1612,6 +1624,9 @@ private async Task ConnectToServerAsync(Process? cliProcess, string? rpc.SetLocalRpcMethod("autoModeSwitch.request", handler.OnAutoModeSwitchRequest); rpc.SetLocalRpcMethod("hooks.invoke", handler.OnHooksInvoke); rpc.SetLocalRpcMethod("systemMessage.transform", handler.OnSystemMessageTransform); + rpc.SetLocalRpcMethod("canvas.open", handler.OnCanvasOpen); + rpc.SetLocalRpcMethod("canvas.close", handler.OnCanvasClose); + rpc.SetLocalRpcMethod("canvas.action.invoke", handler.OnCanvasInvokeAction); ClientSessionApiRegistration.RegisterClientSessionApiHandlers(rpc, sessionId => { var session = GetSession(sessionId) ?? throw new ArgumentException($"Unknown session {sessionId}"); @@ -1804,6 +1819,47 @@ public async ValueTask OnSystemMessageTransfo var session = client.GetSession(sessionId) ?? throw new ArgumentException($"Unknown session {sessionId}"); return await session.HandleSystemMessageTransformAsync(sections); } + +#pragma warning disable GHCP001 + public ValueTask OnCanvasOpen( + string sessionId, + string extensionId, + string canvasId, + string instanceId, + JsonElement? input = null, + CanvasHostContext? host = null) + { + var session = client.GetSession(sessionId) ?? throw new ArgumentException($"Unknown session {sessionId}"); + return session.HandleCanvasOpenAsync( + extensionId, canvasId, instanceId, input ?? default, host); + } + + public async ValueTask OnCanvasClose( + string sessionId, + string extensionId, + string canvasId, + string instanceId, + JsonElement? input = null, + CanvasHostContext? host = null) + { + var session = client.GetSession(sessionId) ?? throw new ArgumentException($"Unknown session {sessionId}"); + await session.HandleCanvasCloseAsync(extensionId, canvasId, instanceId, host); + } + + public ValueTask OnCanvasInvokeAction( + string sessionId, + string extensionId, + string canvasId, + string instanceId, + string actionName, + JsonElement? input = null, + CanvasHostContext? host = null) + { + var session = client.GetSession(sessionId) ?? throw new ArgumentException($"Unknown session {sessionId}"); + return session.HandleCanvasActionAsync( + extensionId, canvasId, instanceId, actionName, input ?? default, host); + } +#pragma warning restore GHCP001 } private class Connection( @@ -1866,7 +1922,13 @@ internal record CreateSessionRequest( string? GitHubToken = null, RemoteSessionMode? RemoteSession = null, CloudSessionOptions? Cloud = null, - IList? InstructionDirectories = null); + IList? InstructionDirectories = null, +#pragma warning disable GHCP001 + IList? Canvases = null, + bool? RequestCanvasRenderer = null, + bool? RequestExtensions = null, + ExtensionInfo? ExtensionInfo = null); +#pragma warning restore GHCP001 internal record ToolDefinition( string Name, @@ -1888,7 +1950,10 @@ public static ToolDefinition FromAIFunction(AIFunctionDeclaration function) internal record CreateSessionResponse( string SessionId, string? WorkspacePath, - SessionCapabilities? Capabilities = null); + SessionCapabilities? Capabilities = null, +#pragma warning disable GHCP001 + IList? OpenCanvases = null); +#pragma warning restore GHCP001 internal record ResumeSessionRequest( string SessionId, @@ -1928,12 +1993,21 @@ internal record ResumeSessionRequest( string? GitHubToken = null, RemoteSessionMode? RemoteSession = null, bool? ContinuePendingWork = null, - IList? InstructionDirectories = null); + IList? InstructionDirectories = null, +#pragma warning disable GHCP001 + IList? Canvases = null, + bool? RequestCanvasRenderer = null, + bool? RequestExtensions = null, + ExtensionInfo? ExtensionInfo = null); +#pragma warning restore GHCP001 internal record ResumeSessionResponse( string SessionId, string? WorkspacePath, - SessionCapabilities? Capabilities = null); + SessionCapabilities? Capabilities = null, +#pragma warning disable GHCP001 + IList? OpenCanvases = null); +#pragma warning restore GHCP001 internal record CommandWireDefinition( string Name, diff --git a/dotnet/src/JsonRpc.cs b/dotnet/src/JsonRpc.cs index 866bb868f..df7170373 100644 --- a/dotnet/src/JsonRpc.cs +++ b/dotnet/src/JsonRpc.cs @@ -486,13 +486,21 @@ private async Task HandleIncomingMethodAsync(string methodName, JsonElement mess } catch (Exception ex) when (ex is not OperationCanceledException) { + var actual = ex is TargetInvocationException tie && tie.InnerException != null ? tie.InnerException : ex; if (_logger.IsEnabled(LogLevel.Debug)) { - _logger.LogDebug("Error handling JSON-RPC method {Method}: {Error}", methodName, ex.Message); + _logger.LogDebug("Error handling JSON-RPC method {Method}: {Error}", methodName, actual.Message); } if (requestId.HasValue) { - await SendErrorResponseAsync(requestId.Value, ErrorCodeInternalError, ex.Message, cancellationToken).ConfigureAwait(false); + if (actual is LocalRpcInvocationException lre) + { + await SendErrorResponseAsync(requestId.Value, lre.Code, lre.Message, lre.Data, cancellationToken).ConfigureAwait(false); + } + else + { + await SendErrorResponseAsync(requestId.Value, ErrorCodeInternalError, actual.Message, cancellationToken).ConfigureAwait(false); + } } } } @@ -718,13 +726,16 @@ await SendMessageAsync(new JsonRpcResponse } private async Task SendErrorResponseAsync(JsonElement id, int code, string message, CancellationToken cancellationToken) + => await SendErrorResponseAsync(id, code, message, data: null, cancellationToken).ConfigureAwait(false); + + private async Task SendErrorResponseAsync(JsonElement id, int code, string message, JsonElement? data, CancellationToken cancellationToken) { try { await SendMessageAsync(new JsonRpcErrorResponse { Id = id, - Error = new JsonRpcError { Code = code, Message = message }, + Error = new JsonRpcError { Code = code, Message = message, Data = data }, }, JsonRpcWireContext.Default.JsonRpcErrorResponse, cancellationToken).ConfigureAwait(false); } catch (Exception ex) when (ex is IOException or ObjectDisposedException or OperationCanceledException) @@ -852,6 +863,10 @@ private sealed class JsonRpcError [JsonPropertyName("message")] public string Message { get; set; } = string.Empty; + + [JsonPropertyName("data")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public JsonElement? Data { get; set; } } private sealed class JsonRpcNotification @@ -891,3 +906,20 @@ internal sealed class RemoteRpcException(string message, int errorCode, Exceptio public int ErrorCode { get; } = errorCode; } + +/// +/// Allows handler methods registered via JsonRpcConnection.SetLocalRpcMethod +/// to surface a structured JSON-RPC error response (code, message, and optional +/// data payload) instead of the default ErrorCodeInternalError envelope. +/// +internal sealed class LocalRpcInvocationException : Exception +{ + public LocalRpcInvocationException(int code, string message, JsonElement? data = null) : base(message) + { + Code = code; + Data = data; + } + + public int Code { get; } + public new JsonElement? Data { get; } +} diff --git a/dotnet/src/Session.cs b/dotnet/src/Session.cs index 6ad8e14d9..bd2309187 100644 --- a/dotnet/src/Session.cs +++ b/dotnet/src/Session.cs @@ -7,6 +7,7 @@ using Microsoft.Extensions.Logging; using System.Collections.Immutable; using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; using System.Text.Json; using System.Text.Json.Nodes; using System.Text.Json.Serialization; @@ -75,6 +76,11 @@ private sealed record EventSubscription(Type EventType, Action Han private Dictionary>>? _transformCallbacks; private readonly SemaphoreSlim _transformCallbacksLock = new(1, 1); +#pragma warning disable GHCP001 + private volatile ICanvasHandler? _canvasHandler; + private IReadOnlyList _openCanvases = Array.Empty(); +#pragma warning restore GHCP001 + private int _isDisposed; /// @@ -121,6 +127,19 @@ public SessionCapabilities Capabilities private set; } +#pragma warning disable GHCP001 + /// + /// Canvas instances currently known to be open for this session. + /// + /// + /// Populated from the most recent session.create / session.resume + /// response. This snapshot is not refreshed automatically when canvases open or + /// close after the session is established. + /// + [Experimental(Diagnostics.Experimental)] + public IReadOnlyList OpenCanvases => _openCanvases; +#pragma warning restore GHCP001 + /// /// Gets the UI API for eliciting information from the user during this session. /// @@ -861,6 +880,125 @@ internal void SetCapabilities(SessionCapabilities? capabilities) Capabilities = capabilities ?? new SessionCapabilities(); } +#pragma warning disable GHCP001 + internal void SetOpenCanvases(IList? canvases) + { + _openCanvases = canvases is { Count: > 0 } + ? new List(canvases).AsReadOnly() + : Array.Empty(); + } + + internal void SetCanvasHandler(ICanvasHandler? handler) + { + _canvasHandler = handler; + } + + internal async ValueTask HandleCanvasOpenAsync( + string extensionId, + string canvasId, + string instanceId, + JsonElement input, + CanvasHostContext? host) + { + var handler = _canvasHandler ?? throw CanvasErrorHelpers.HandlerUnset(); + var ctx = new CanvasOpenContext + { + SessionId = SessionId, + ExtensionId = extensionId, + CanvasId = canvasId, + InstanceId = instanceId, + Input = input, + Host = host, + }; + try + { + return await handler.OnOpenAsync(ctx, CancellationToken.None).ConfigureAwait(false); + } + catch (CanvasError ce) + { + throw CanvasErrorHelpers.ToRpcException(ce); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + throw CanvasErrorHelpers.HandlerError(ex.Message); + } + } + + internal async ValueTask HandleCanvasCloseAsync( + string extensionId, + string canvasId, + string instanceId, + CanvasHostContext? host) + { + var handler = _canvasHandler ?? throw CanvasErrorHelpers.HandlerUnset(); + var ctx = new CanvasLifecycleContext + { + SessionId = SessionId, + ExtensionId = extensionId, + CanvasId = canvasId, + InstanceId = instanceId, + Host = host, + }; + try + { + await handler.OnCloseAsync(ctx, CancellationToken.None).ConfigureAwait(false); + } + catch (CanvasError ce) + { + throw CanvasErrorHelpers.ToRpcException(ce); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + throw CanvasErrorHelpers.HandlerError(ex.Message); + } + } + + internal async ValueTask HandleCanvasActionAsync( + string extensionId, + string canvasId, + string instanceId, + string actionName, + JsonElement input, + CanvasHostContext? host) + { + var handler = _canvasHandler ?? throw CanvasErrorHelpers.HandlerUnset(); + var ctx = new CanvasActionContext + { + SessionId = SessionId, + ExtensionId = extensionId, + CanvasId = canvasId, + InstanceId = instanceId, + ActionName = actionName, + Input = input, + Host = host, + }; + try + { + var result = await handler.OnActionAsync(ctx, CancellationToken.None).ConfigureAwait(false); + return SerializeActionResult(result); + } + catch (CanvasError ce) + { + throw CanvasErrorHelpers.ToRpcException(ce); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + throw CanvasErrorHelpers.HandlerError(ex.Message); + } + } + + private static JsonElement SerializeActionResult(object? value) + { + var element = CopilotClient.ToJsonElementForWire(value); + if (element.HasValue) + { + return element.Value; + } + using var doc = JsonDocument.Parse("null"); + return doc.RootElement.Clone(); + } +#pragma warning restore GHCP001 + /// /// Dispatches a command.execute event to the registered handler and /// responds via the commands.handlePendingCommand RPC. diff --git a/dotnet/src/Types.cs b/dotnet/src/Types.cs index 29fceb40c..4df36a140 100644 --- a/dotnet/src/Types.cs +++ b/dotnet/src/Types.cs @@ -2218,6 +2218,13 @@ protected SessionConfigBase(SessionConfigBase? other) CreateSessionFsProvider = other.CreateSessionFsProvider; GitHubToken = other.GitHubToken; RemoteSession = other.RemoteSession; +#pragma warning disable GHCP001 + Canvases = other.Canvases is not null ? [.. other.Canvases] : null; + RequestCanvasRenderer = other.RequestCanvasRenderer; + RequestExtensions = other.RequestExtensions; + ExtensionInfo = other.ExtensionInfo; + CanvasHandler = other.CanvasHandler; +#pragma warning restore GHCP001 SkillDirectories = other.SkillDirectories is not null ? [.. other.SkillDirectories] : null; InstructionDirectories = other.InstructionDirectories is not null ? [.. other.InstructionDirectories] : null; Streaming = other.Streaming; @@ -2400,6 +2407,47 @@ protected SessionConfigBase(SessionConfigBase? other) /// /// public RemoteSessionMode? RemoteSession { get; set; } + +#pragma warning disable GHCP001 + /// + /// Canvas declarations advertised by this connection. The runtime forwards + /// these to the agent and routes inbound canvas.* requests for any + /// declared canvas to . + /// + [Experimental(Diagnostics.Experimental)] + public IList? Canvases { get; set; } + + /// + /// When , asks the host to expose canvas renderer tools + /// for this session. The host typically grants this only to trusted clients. + /// + [Experimental(Diagnostics.Experimental)] + public bool? RequestCanvasRenderer { get; set; } + + /// + /// When , asks the host to expose extension-discovery + /// tools for this session. The host typically grants this only to trusted clients. + /// + [Experimental(Diagnostics.Experimental)] + public bool? RequestExtensions { get; set; } + + /// + /// Stable extension identity for canvas/tool providers on this connection. + /// Required when is set so the runtime can attribute + /// declared canvases back to this provider. + /// + [Experimental(Diagnostics.Experimental)] + public ExtensionInfo? ExtensionInfo { get; set; } + + /// + /// Provider-side canvas lifecycle handler. The SDK routes inbound + /// canvas.open / canvas.close / canvas.action.invoke + /// requests to this handler. + /// + [Experimental(Diagnostics.Experimental)] + [JsonIgnore] + public ICanvasHandler? CanvasHandler { get; set; } +#pragma warning restore GHCP001 } /// @@ -3029,4 +3077,11 @@ public sealed class SystemMessageTransformRpcResponse [JsonSerializable(typeof(object))] [JsonSerializable(typeof(Dictionary))] [JsonSerializable(typeof(string[]))] +#pragma warning disable GHCP001 +[JsonSerializable(typeof(CanvasDeclaration))] +[JsonSerializable(typeof(CanvasOpenResponse))] +[JsonSerializable(typeof(CanvasHostContext))] +[JsonSerializable(typeof(CanvasHostCapabilities))] +[JsonSerializable(typeof(ExtensionInfo))] +#pragma warning restore GHCP001 internal partial class TypesJsonContext : JsonSerializerContext; diff --git a/dotnet/test/Unit/CanvasTests.cs b/dotnet/test/Unit/CanvasTests.cs new file mode 100644 index 000000000..fc5db84aa --- /dev/null +++ b/dotnet/test/Unit/CanvasTests.cs @@ -0,0 +1,160 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +using System; +using System.Reflection; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using GitHub.Copilot; +using Xunit; + +namespace GitHub.Copilot.Test.Unit; + +public class CanvasTests +{ + private static JsonSerializerOptions GetSerializerOptions() + { + var prop = typeof(CopilotClient).GetProperty( + "SerializerOptionsForMessageFormatter", + BindingFlags.NonPublic | BindingFlags.Static); + var options = (JsonSerializerOptions?)prop?.GetValue(null); + Assert.NotNull(options); + return options!; + } + + [Fact] + public void CanvasDeclaration_Serializes_CamelCase_SkippingNulls() + { + var options = GetSerializerOptions(); + var decl = new CanvasDeclaration + { + Id = "report", + DisplayName = "Quarterly Report", + Description = "Renders the latest report", + }; + + var json = JsonSerializer.Serialize(decl, options); + using var doc = JsonDocument.Parse(json); + var root = doc.RootElement; + + Assert.Equal("report", root.GetProperty("id").GetString()); + Assert.Equal("Quarterly Report", root.GetProperty("displayName").GetString()); + Assert.Equal("Renders the latest report", root.GetProperty("description").GetString()); + Assert.False(root.TryGetProperty("inputSchema", out _)); + Assert.False(root.TryGetProperty("actions", out _)); + } + + [Fact] + public void CanvasOpenResponse_Roundtrips_WithCamelCaseFields() + { + var options = GetSerializerOptions(); + var response = new CanvasOpenResponse + { + Url = "https://example.com/c/1", + Title = "Demo", + Status = "ready" + }; + + var json = JsonSerializer.Serialize(response, options); + var parsed = JsonSerializer.Deserialize(json, options); + + Assert.NotNull(parsed); + Assert.Equal("https://example.com/c/1", parsed!.Url); + Assert.Equal("Demo", parsed.Title); + Assert.Equal("ready", parsed.Status); + } + + [Fact] + public void ExtensionInfo_Serializes_SourceAndName() + { + var options = GetSerializerOptions(); + var info = new ExtensionInfo { Source = "github-app", Name = "demo" }; + var json = JsonSerializer.Serialize(info, options); + using var doc = JsonDocument.Parse(json); + Assert.Equal("github-app", doc.RootElement.GetProperty("source").GetString()); + Assert.Equal("demo", doc.RootElement.GetProperty("name").GetString()); + } + + [Fact] + public async Task CanvasHandlerBase_DefaultOnClose_Completes() + { + var handler = new TestHandler(); + await handler.OnCloseAsync(new CanvasLifecycleContext(), CancellationToken.None); + } + + [Fact] + public async Task CanvasHandlerBase_DefaultOnAction_ThrowsNoHandlerCanvasError() + { + var handler = new TestHandler(); + var ex = await Assert.ThrowsAsync( + () => handler.OnActionAsync(new CanvasActionContext(), CancellationToken.None)); + Assert.Equal("canvas_action_no_handler", ex.Code); + } + + [Fact] + public void CanvasError_NoHandler_HasExpectedCode() + { + var err = CanvasError.NoHandler(); + Assert.Equal("canvas_action_no_handler", err.Code); + Assert.False(string.IsNullOrEmpty(err.Message)); + } + + [Fact] + public void SessionConfig_Clone_CopiesCanvasFields() + { + var handler = new TestHandler(); + var declaration = new CanvasDeclaration { Id = "c1", DisplayName = "C", Description = "d" }; + var config = new SessionConfig + { + Canvases = new[] { declaration }, + RequestCanvasRenderer = true, + RequestExtensions = true, + ExtensionInfo = new ExtensionInfo { Source = "github-app", Name = "demo" }, + CanvasHandler = handler + }; + + var clone = config.Clone(); + + Assert.NotNull(clone.Canvases); + Assert.Single(clone.Canvases!); + Assert.Equal("c1", clone.Canvases![0].Id); + Assert.True(clone.RequestCanvasRenderer); + Assert.True(clone.RequestExtensions); + Assert.NotNull(clone.ExtensionInfo); + Assert.Equal("github-app", clone.ExtensionInfo!.Source); + Assert.Same(handler, clone.CanvasHandler); + + // Mutating the clone's list does not affect the original. + clone.Canvases!.Add(new CanvasDeclaration { Id = "c2", DisplayName = "C2", Description = "d2" }); + Assert.Single(config.Canvases!); + } + + [Fact] + public void ResumeSessionConfig_Clone_CopiesCanvasFields() + { + var handler = new TestHandler(); + var config = new ResumeSessionConfig + { + Canvases = new[] { new CanvasDeclaration { Id = "c1", DisplayName = "C", Description = "d" } }, + RequestCanvasRenderer = true, + ExtensionInfo = new ExtensionInfo { Source = "s", Name = "n" }, + CanvasHandler = handler + }; + + var clone = config.Clone(); + + Assert.NotNull(clone.Canvases); + Assert.Single(clone.Canvases!); + Assert.True(clone.RequestCanvasRenderer); + Assert.NotNull(clone.ExtensionInfo); + Assert.Same(handler, clone.CanvasHandler); + } + + private sealed class TestHandler : CanvasHandlerBase + { + public override Task OnOpenAsync(CanvasOpenContext context, CancellationToken cancellationToken) + => Task.FromResult(new CanvasOpenResponse { Url = "https://example.com" }); + } +} From 04fc748113f0fec32fcc8595a7b36cb95b387d7a Mon Sep 17 00:00:00 2001 From: jmoseley Date: Sun, 24 May 2026 10:40:10 -0700 Subject: [PATCH 29/29] Address PR review: openCanvases parity + Node API divergence note - Node: add openCanvases accessor on CopilotSession and OpenCanvases field on ResumeSessionConfig so callers can both rehydrate from the resume response and pre-populate canvas state on resume. - Node: document why createCanvas/Canvas intentionally diverges from the per-session CanvasHandler pattern used by Rust/Python/Go/.NET. - Go: add ResumeSessionConfig.OpenCanvases, thread through to the resume request wire payload, and add a serialization test. - .NET: add ResumeSessionConfig.OpenCanvases, thread through to the internal ResumeSessionRequest record, and add a serialization test. Mirrors what Rust and Python already do, fixing wire-protocol parity across SDKs. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- dotnet/src/Client.cs | 6 ++- dotnet/src/Types.cs | 11 ++++++ dotnet/test/Unit/SerializationTests.cs | 28 ++++++++++++++ go/canvas_test.go | 51 ++++++++++++++++++++++++++ go/client.go | 1 + go/types.go | 5 +++ nodejs/src/canvas.ts | 17 ++++++++- nodejs/src/client.ts | 6 ++- nodejs/src/session.ts | 22 +++++++++++ nodejs/src/types.ts | 7 ++++ 10 files changed, 149 insertions(+), 5 deletions(-) diff --git a/dotnet/src/Client.cs b/dotnet/src/Client.cs index 5666d7b3a..0e7730690 100644 --- a/dotnet/src/Client.cs +++ b/dotnet/src/Client.cs @@ -789,7 +789,8 @@ public async Task ResumeSessionAsync(string sessionId, ResumeSes Canvases: config.Canvases, RequestCanvasRenderer: config.RequestCanvasRenderer, RequestExtensions: config.RequestExtensions, - ExtensionInfo: config.ExtensionInfo); + ExtensionInfo: config.ExtensionInfo, + OpenCanvases: config.OpenCanvases); var rpcTimestamp = Stopwatch.GetTimestamp(); var response = await InvokeRpcAsync( @@ -1998,7 +1999,8 @@ internal record ResumeSessionRequest( IList? Canvases = null, bool? RequestCanvasRenderer = null, bool? RequestExtensions = null, - ExtensionInfo? ExtensionInfo = null); + ExtensionInfo? ExtensionInfo = null, + IList? OpenCanvases = null); #pragma warning restore GHCP001 internal record ResumeSessionResponse( diff --git a/dotnet/src/Types.cs b/dotnet/src/Types.cs index 4df36a140..a02a5db3a 100644 --- a/dotnet/src/Types.cs +++ b/dotnet/src/Types.cs @@ -2510,6 +2510,7 @@ private ResumeSessionConfig(ResumeSessionConfig? other) : base(other) SuppressResumeEvent = other.SuppressResumeEvent; ContinuePendingWork = other.ContinuePendingWork; + OpenCanvases = other.OpenCanvases is not null ? [.. other.OpenCanvases] : null; } /// @@ -2532,6 +2533,16 @@ private ResumeSessionConfig(ResumeSessionConfig? other) : base(other) /// public bool? ContinuePendingWork { get; set; } +#pragma warning disable GHCP001 + /// + /// Snapshot of canvases that were already open when the session was suspended. + /// When provided on resume, the runtime can rehydrate canvas state so consumers + /// do not need to re-open canvases that were active before the previous shutdown. + /// + [Experimental(Diagnostics.Experimental)] + public IList? OpenCanvases { get; set; } +#pragma warning restore GHCP001 + /// /// Creates a shallow clone of this instance. /// diff --git a/dotnet/test/Unit/SerializationTests.cs b/dotnet/test/Unit/SerializationTests.cs index a95bd7ce2..f225fe4ee 100644 --- a/dotnet/test/Unit/SerializationTests.cs +++ b/dotnet/test/Unit/SerializationTests.cs @@ -203,6 +203,34 @@ public void ResumeSessionRequest_CanSerializeEnableSessionTelemetry_WithSdkOptio Assert.False(root.GetProperty("enableSessionTelemetry").GetBoolean()); } + [Fact] + public void ResumeSessionRequest_CanSerializeOpenCanvases_WithSdkOptions() + { + var options = GetSerializerOptions(); + var requestType = GetNestedType(typeof(CopilotClient), "ResumeSessionRequest"); + var instances = new List + { + new() + { + CanvasId = "canvas-id", + ExtensionId = "ext-id", + InstanceId = "instance-1", + Availability = CanvasInstanceAvailability.Ready, + }, + }; + var request = CreateInternalRequest( + requestType, + ("SessionId", "session-id"), + ("OpenCanvases", instances)); + + var json = JsonSerializer.Serialize(request, requestType, options); + using var document = JsonDocument.Parse(json); + var root = document.RootElement; + var openCanvases = root.GetProperty("openCanvases"); + Assert.Equal(1, openCanvases.GetArrayLength()); + Assert.Equal("canvas-id", openCanvases[0].GetProperty("canvasId").GetString()); + } + [Fact] public void ResumeSessionRequest_CanSerializeModeRequestFlags_WithSdkOptions() { diff --git a/go/canvas_test.go b/go/canvas_test.go index 2eaaf44a3..be0538d58 100644 --- a/go/canvas_test.go +++ b/go/canvas_test.go @@ -265,4 +265,55 @@ func TestResumeSessionResponse_OpenCanvasesParse(t *testing.T) { } } +func TestResumeSessionRequest_OpenCanvasesWireShape(t *testing.T) { + req := resumeSessionRequest{ + SessionID: "s1", + OpenCanvases: []rpc.OpenCanvasInstance{ + { + Availability: "ready", + CanvasID: "echo", + ExtensionID: "project:echo", + InstanceID: "echo-1", + Reopen: false, + }, + }, + } + + data, err := json.Marshal(req) + if err != nil { + t.Fatalf("marshal failed: %v", err) + } + + var decoded map[string]any + if err := json.Unmarshal(data, &decoded); err != nil { + t.Fatalf("unmarshal failed: %v", err) + } + + raw, ok := decoded["openCanvases"].([]any) + if !ok || len(raw) != 1 { + t.Fatalf("expected openCanvases array of length 1, got %v", decoded["openCanvases"]) + } + first, _ := raw[0].(map[string]any) + if first["canvasId"] != "echo" { + t.Fatalf("expected canvasId=echo, got %v", first["canvasId"]) + } + if first["instanceId"] != "echo-1" { + t.Fatalf("expected instanceId=echo-1, got %v", first["instanceId"]) + } + + // Omitted when nil + empty := resumeSessionRequest{SessionID: "s1"} + emptyData, err := json.Marshal(empty) + if err != nil { + t.Fatalf("marshal empty failed: %v", err) + } + var emptyDecoded map[string]any + if err := json.Unmarshal(emptyData, &emptyDecoded); err != nil { + t.Fatalf("unmarshal empty failed: %v", err) + } + if _, present := emptyDecoded["openCanvases"]; present { + t.Fatalf("openCanvases should be omitted when nil") + } +} + func strPtr(s string) *string { return &s } diff --git a/go/client.go b/go/client.go index ac34816c3..6e7557b0b 100644 --- a/go/client.go +++ b/go/client.go @@ -849,6 +849,7 @@ func (c *Client) ResumeSessionWithOptions(ctx context.Context, sessionID string, req.GitHubToken = config.GitHubToken req.RemoteSession = config.RemoteSession req.Canvases = config.Canvases + req.OpenCanvases = config.OpenCanvases req.RequestCanvasRenderer = config.RequestCanvasRenderer req.RequestExtensions = config.RequestExtensions req.ExtensionInfo = config.ExtensionInfo diff --git a/go/types.go b/go/types.go index 86d053083..fe7f9d93c 100644 --- a/go/types.go +++ b/go/types.go @@ -1193,6 +1193,10 @@ type ResumeSessionConfig struct { // Canvases declares canvases this session provides. Sent over the wire on // `session.resume`. See SessionConfig.Canvases. Canvases []CanvasDeclaration + // OpenCanvases declares canvas instances the caller knows were open before + // this resume so the runtime can re-attach them. Sent over the wire on + // `session.resume` as `openCanvases`. + OpenCanvases []rpc.OpenCanvasInstance // RequestCanvasRenderer asks the host to enable canvas rendering for this session. RequestCanvasRenderer *bool // RequestExtensions asks the host to surface declared canvases as agent-visible extensions. @@ -1485,6 +1489,7 @@ type resumeSessionRequest struct { GitHubToken string `json:"gitHubToken,omitempty"` RemoteSession rpc.RemoteSessionMode `json:"remoteSession,omitempty"` Canvases []CanvasDeclaration `json:"canvases,omitempty"` + OpenCanvases []rpc.OpenCanvasInstance `json:"openCanvases,omitempty"` RequestCanvasRenderer *bool `json:"requestCanvasRenderer,omitempty"` RequestExtensions *bool `json:"requestExtensions,omitempty"` ExtensionInfo *ExtensionInfo `json:"extensionInfo,omitempty"` diff --git a/nodejs/src/canvas.ts b/nodejs/src/canvas.ts index 7d40bd966..738dfc851 100644 --- a/nodejs/src/canvas.ts +++ b/nodejs/src/canvas.ts @@ -168,7 +168,14 @@ export interface CanvasOptions { onClose?: (ctx: CanvasLifecycleContext) => Promise | void; } -/** A registered canvas: declarative metadata + in-process handler closures. */ +/** A registered canvas: declarative metadata + in-process handler closures. + * + * Node intentionally uses a per-canvas factory pattern (mirroring + * {@link https://github.com/github/copilot-sdk | `DefineTool`}'s co-location + * ergonomics) where other SDKs (Rust, Python, Go, .NET) expose a single + * `CanvasHandler` per session that switches on `canvasId`. Both shapes target + * the same JSON-RPC wire protocol; the divergence is API ergonomics only. + */ export class Canvas { readonly declaration: CanvasDeclaration; readonly open: NonNullable; @@ -199,7 +206,13 @@ export class Canvas { } } -/** Create a canvas declaration with bound in-process handlers. */ +/** Create a canvas declaration with bound in-process handlers. + * + * Node intentionally uses this per-canvas factory pattern (mirroring + * `DefineTool`'s co-location ergonomics) where other SDKs (Rust, Python, Go, + * .NET) expose a single `CanvasHandler` per session that switches on + * `canvasId`. Both shapes target the same JSON-RPC wire protocol. + */ export function createCanvas(options: CanvasOptions): Canvas { return new Canvas(options); } diff --git a/nodejs/src/client.ts b/nodejs/src/client.ts index 713c2eef7..3e8a4cfa3 100644 --- a/nodejs/src/client.ts +++ b/nodejs/src/client.ts @@ -31,6 +31,7 @@ import { createInternalServerRpc, registerClientSessionApiHandlers, } from "./generated/rpc.js"; +import type { OpenCanvasInstance } from "./generated/rpc.js"; import { type CanvasActionInvokeParams, type CanvasProviderRequestParams, @@ -1060,15 +1061,18 @@ export class CopilotClient { continuePendingWork: config.continuePendingWork, gitHubToken: config.gitHubToken, remoteSession: config.remoteSession, + openCanvases: config.openCanvases, }); - const { workspacePath, capabilities } = response as { + const { workspacePath, capabilities, openCanvases } = response as { sessionId: string; workspacePath?: string; capabilities?: SessionCapabilities; + openCanvases?: OpenCanvasInstance[]; }; session["_workspacePath"] = workspacePath; session.setCapabilities(capabilities); + session.setOpenCanvases(openCanvases ?? []); } catch (e) { this.sessions.delete(sessionId); throw e; diff --git a/nodejs/src/session.ts b/nodejs/src/session.ts index 7ba01441d..74823602e 100644 --- a/nodejs/src/session.ts +++ b/nodejs/src/session.ts @@ -12,6 +12,7 @@ import { ConnectionError, ResponseError } from "vscode-jsonrpc/node.js"; import { createSessionRpc } from "./generated/rpc.js"; import type { ClientSessionApiHandlers } from "./generated/rpc.js"; import type { Canvas } from "./canvas.js"; +import type { OpenCanvasInstance } from "./generated/rpc.js"; import { getTraceContext } from "./telemetry.js"; import type { CommandHandler, @@ -113,6 +114,7 @@ export class CopilotSession { private _rpc: ReturnType | null = null; private traceContextProvider?: TraceContextProvider; private _capabilities: SessionCapabilities = {}; + private openCanvasInstances: OpenCanvasInstance[] = []; /** @internal Client session API handlers, populated by CopilotClient during create/resume. */ clientSessionApis: ClientSessionApiHandlers = {}; @@ -774,6 +776,26 @@ export class CopilotSession { this._capabilities = capabilities ?? {}; } + /** + * Snapshot of canvas instances that were already open when the session was + * resumed. Populated from the `session.resume` response; empty for freshly + * created sessions. Returns a defensive copy — mutating the returned array + * has no effect on the session. + */ + get openCanvases(): OpenCanvasInstance[] { + return [...this.openCanvasInstances]; + } + + /** + * Sets the open-canvas snapshot for this session. + * + * @param instances - The `openCanvases` array from the `session.resume` response. + * @internal This method is typically called internally when resuming a session. + */ + setOpenCanvases(instances: OpenCanvasInstance[]): void { + this.openCanvasInstances = [...instances]; + } + private assertElicitation(): void { if (!this._capabilities.ui?.elicitation) { throw new Error( diff --git a/nodejs/src/types.ts b/nodejs/src/types.ts index aaba9797a..623a4cabd 100644 --- a/nodejs/src/types.ts +++ b/nodejs/src/types.ts @@ -12,6 +12,7 @@ import type { SessionFsProvider } from "./sessionFsProvider.js"; import type { SessionEvent as GeneratedSessionEvent } from "./generated/session-events.js"; import type { CopilotSession } from "./session.js"; import type { RemoteSessionMode } from "./generated/rpc.js"; +import type { OpenCanvasInstance } from "./generated/rpc.js"; export type { RemoteSessionMode } from "./generated/rpc.js"; export type SessionEvent = GeneratedSessionEvent; export type { SessionFsProvider } from "./sessionFsProvider.js"; @@ -1737,6 +1738,12 @@ export interface ResumeSessionConfig extends SessionConfigBase { * @default false */ continuePendingWork?: boolean; + /** + * Snapshot of canvases that were already open when the session was suspended. + * When provided on resume, the runtime can rehydrate canvas state so consumers + * do not need to re-open canvases that were active before the previous shutdown. + */ + openCanvases?: OpenCanvasInstance[]; } /**