diff --git a/nodejs/src/canvas.ts b/nodejs/src/canvas.ts new file mode 100644 index 000000000..de20988c9 --- /dev/null +++ b/nodejs/src/canvas.ts @@ -0,0 +1,393 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +/** + * Extension-owned canvases declared via + * `joinSession({ canvases: [createCanvas({...})] })`. + * + * The on-the-wire declaration shape mirrors the runtime's `CanvasDeclaration` + * interface verbatim. The `createCanvas` helper packages the declaration with + * in-process handler closures; the SDK serializes the declaration onto + * `session.create` / `session.resume` and routes incoming + * `canvas.action.invoke` dispatches by `(canvasId, actionName)` back to the + * handlers. + * + * The wire RPC method is `hostExtension.invoke`; inside, + * `method === "canvas.action.invoke"` identifies canvas dispatches. The SDK + * routes purely on `params.canvasId` + `params.actionName`. + */ + +/** + * A single agent-callable action contributed by a canvas. Names MUST NOT + * start with `canvas.` — that prefix is reserved for the lifecycle verbs + * `canvas.{open,focus,close,reload}`. + */ +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?: Record; +} + +/** + * A single toolbar button contributed by a canvas. The host canvas chrome + * renders these and dispatches `actionName` with optional `input` when + * clicked. `actionName` may be a reserved `canvas.*` verb (e.g. + * `canvas.reload`) — the runtime routes those to the matching lifecycle + * method. + */ +export interface CanvasToolbarItemDeclaration { + /** Stable id used by the host to key the button. */ + id: string; + /** User-visible label. */ + label: string; + /** Optional icon identifier; semantics are host-defined. */ + icon?: string; + /** Optional tooltip shown on hover. */ + tooltip?: string; + /** The `agentActions[].name` (or reserved `canvas.*` verb) to dispatch. */ + 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`. The declaring connection becomes the + * live provider for dispatched canvas operations targeting this `id` for the + * lifetime of the connection; re-declaring the same `id` on resume replaces + * the prior declaration. + */ +export interface CanvasDeclaration { + /** Canvas id, unique within the declaring connection. */ + id: string; + /** Human-readable label shown in `discover_canvases` and host UI chrome. */ + displayName?: string; + /** One-line description shown in `discover_canvases` for agent reasoning. */ + description?: string; + /** + * Optional JSON Schema for the `input` payload accepted by `canvas.open`. + * The runtime validates incoming `open_canvas` calls against this; + * handlers never see malformed input. + */ + inputSchema?: Record; + /** Static toolbar items rendered as host chrome. */ + toolbar?: CanvasToolbarItemDeclaration[]; + /** Agent-invocable actions exposed via `invoke_canvas_action`. */ + agentActions?: CanvasAgentActionDeclaration[]; +} + +/** + * Response returned from `onOpen`. The extension's URL is embedded by the + * host in its webview surface when the host advertises the `canvas.webview` + * capability. + */ +export interface CanvasOpenResponse { + /** URL the host should embed. Optional for canvases with no visual surface. */ + url?: string; + /** + * Stable per-instance identifier the extension can correlate with its own + * state. The host echoes this back on subsequent lifecycle calls. + */ + instanceId?: string; +} + +/** + * Identifies an extension canvas instance that the host believes is still open + * across a runtime restart. Supplied via `ResumeSessionConfig.openCanvasInstances` + * so the runtime can re-populate its in-memory instance map without re-invoking + * the extension's `onOpen`. Orphans (no matching extension/canvas in the active + * extension set) trigger a `session.canvas.closed` event with + * `reason: "rehydrate_failed"` so the host can drop the stale UI. + */ +export interface CanvasInstanceRehydrate { + /** Extension id that originally opened the canvas. */ + extensionId: string; + /** Canvas id (matches the declaring `CanvasDeclaration.id`). */ + canvasId: string; + /** Agent-supplied stable instance id from the original open. */ + instanceId: string; + /** Extension-owned URL the host last rendered, if any. */ + url?: string; +} + +/** Context handed to a canvas's `onOpen` handler. */ +export interface CanvasOpenContext { + /** Session that requested the canvas. */ + sessionId: string; + /** Canvas id (matches the declaring `CanvasDeclaration.id`). */ + canvasId: string; + /** + * Agent-supplied stable instance id. Required by the runtime on every + * `canvas.open` invocation; handlers should key their per-instance state + * off this value. + */ + instanceId: string; + /** Validated `input` payload, shaped by `CanvasDeclaration.inputSchema`. */ + input: unknown; + /** Toolbar items declared on the canvas, passed through for convenience. */ + toolbar?: CanvasToolbarItemDeclaration[]; +} + +/** Context handed to a canvas's `onAction` handler. */ +export interface CanvasActionContext { + /** Session that invoked the action. */ + sessionId: 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; +} + +/** Context handed to a canvas's lifecycle hooks (`onFocus`, `onClose`, `onReload`). */ +export interface CanvasLifecycleContext { + /** Session owning the canvas instance. */ + sessionId: string; + /** Canvas id (matches the declaring `CanvasDeclaration.id`). */ + canvasId: string; + /** Instance id this lifecycle event applies to. */ + instanceId: string; +} + +/** + * Structured error returned from canvas handlers. Serialized into the + * `canvas.action.invoke` error envelope. + * + * Reserved codes: + * - `canvas_action_no_handler` — action declared but no `onAction` provided + * - `canvas_input_invalid` — input failed schema validation (runtime emits) + */ +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 + * the SDK invokes on `canvas.action.invoke` dispatch. + */ +export interface CanvasOptions { + /** @see CanvasDeclaration.id */ + id: string; + /** @see CanvasDeclaration.displayName */ + displayName?: string; + /** @see CanvasDeclaration.description */ + description?: string; + /** @see CanvasDeclaration.inputSchema */ + inputSchema?: Record; + /** @see CanvasDeclaration.agentActions */ + agentActions?: CanvasAgentActionDeclaration[]; + /** @see CanvasDeclaration.toolbar */ + toolbar?: CanvasToolbarItemDeclaration[]; + + /** + * Required. Open a new canvas instance. Return its URL (if any) and an + * extension-owned instance id (if any). + */ + 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 (e.g. user hit refresh). */ + onReload?: (ctx: CanvasLifecycleContext) => Promise | void; +} + +/** + * A registered canvas: declarative metadata + in-process handler closures. + * + * Construct via {@link createCanvas}. The {@link declaration} is serialized + * onto the wire (handlers are dropped — they're not transferable); the + * handlers are retained in the SDK's per-session registry and invoked by + * `canvas.action.invoke` dispatch keyed by `(canvasId, actionName)`. + */ +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, + toolbar: options.toolbar, + agentActions: options.agentActions, + }; + 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. Pass the result + * to `joinSession({ canvases: [...] })` (or the client `createSession` / + * `resumeSession` `canvases` field). The SDK serializes + * {@link Canvas.declaration} onto `session.create` / `session.resume` and + * routes incoming `canvas.action.invoke` dispatches back to the handlers. + * + * @example + * ```typescript + * import { joinSession, createCanvas } from "@github/copilot-sdk/extension"; + * + * const counter = createCanvas({ + * id: "counter", + * displayName: "Counter", + * description: "A trivial counter canvas", + * agentActions: [{ name: "increment", description: "Add one" }], + * onOpen: async (ctx) => ({ url: `http://localhost:3000/${ctx.canvasId}` }), + * onAction: async (ctx) => { + * if (ctx.actionName === "increment") return { value: 1 }; + * }, + * }); + * + * await joinSession({ canvases: [counter] }); + * ``` + */ +export function createCanvas(options: CanvasOptions): Canvas { + return new Canvas(options); +} + +// --------------------------------------------------------------------------- +// Internal dispatch helpers (consumed by client.ts / session.ts). +// --------------------------------------------------------------------------- + +/** + * Inner envelope of a `hostExtension.invoke` request when the dispatched + * method is `canvas.action.invoke`. Field names mirror the runtime contract. + * + * @internal + */ +export interface CanvasActionInvokeParams { + canvasId: string; + instanceId?: string; + actionName: string; + input?: unknown; + toolbar?: CanvasToolbarItemDeclaration[]; +} + +/** + * Reserved lifecycle action names. Any other `actionName` routes to + * {@link Canvas.onAction}. + * + * @internal + */ +export const RESERVED_CANVAS_ACTIONS = { + open: "canvas.open", + focus: "canvas.focus", + close: "canvas.close", + reload: "canvas.reload", +} as const; + +/** + * Dispatch a `canvas.action.invoke` payload to the matching {@link Canvas}'s + * handler. Returns the value the handler produced (for `onOpen`/`onAction`) + * or `undefined` (for lifecycle hooks). Throws {@link CanvasError} when the + * canvas declares no handler for the action. + * + * @internal + */ +export async function dispatchCanvasAction( + canvas: Canvas, + sessionId: string, + params: CanvasActionInvokeParams +): Promise { + switch (params.actionName) { + case RESERVED_CANVAS_ACTIONS.open: { + if (!params.instanceId) { + throw new CanvasError( + "canvas_missing_instance_id", + "canvas.open requires an instanceId" + ); + } + const result = await canvas.onOpen({ + sessionId, + canvasId: params.canvasId, + instanceId: params.instanceId, + input: params.input, + toolbar: params.toolbar, + }); + return result ?? {}; + } + case RESERVED_CANVAS_ACTIONS.focus: + case RESERVED_CANVAS_ACTIONS.close: + case RESERVED_CANVAS_ACTIONS.reload: { + if (!params.instanceId) { + throw new CanvasError( + "canvas_missing_instance_id", + `Lifecycle verb '${params.actionName}' requires an instanceId` + ); + } + const hook = + params.actionName === RESERVED_CANVAS_ACTIONS.focus + ? canvas.onFocus + : params.actionName === RESERVED_CANVAS_ACTIONS.close + ? canvas.onClose + : canvas.onReload; + if (!hook) return undefined; + const ctx: CanvasLifecycleContext = { + sessionId, + canvasId: params.canvasId, + instanceId: params.instanceId, + }; + await hook(ctx); + return undefined; + } + default: { + if (!canvas.onAction) { + throw CanvasError.noHandler(); + } + if (!params.instanceId) { + throw new CanvasError( + "canvas_missing_instance_id", + `Action '${params.actionName}' requires an instanceId` + ); + } + return canvas.onAction({ + sessionId, + canvasId: params.canvasId, + instanceId: params.instanceId, + actionName: params.actionName, + input: params.input, + }); + } + } +} diff --git a/nodejs/src/client.ts b/nodejs/src/client.ts index f4b558024..ff4623f7a 100644 --- a/nodejs/src/client.ts +++ b/nodejs/src/client.ts @@ -31,6 +31,7 @@ import { createInternalServerRpc, registerClientSessionApiHandlers, } from "./generated/rpc.js"; +import { type CanvasActionInvokeParams, type CanvasError, dispatchCanvasAction } from "./canvas.js"; import { getSdkProtocolVersion } from "./sdkProtocolVersion.js"; import { CopilotSession, NO_RESULT_PERMISSION_V2_ERROR } from "./session.js"; import { createSessionFsAdapter, type SessionFsProvider } from "./sessionFsProvider.js"; @@ -807,6 +808,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) { @@ -853,6 +855,9 @@ export class CopilotClient { overridesBuiltInTool: tool.overridesBuiltInTool, skipPermission: tool.skipPermission, })), + canvases: config.canvases?.map((c) => c.declaration), + requestCanvasRenderer: config.requestCanvasRenderer, + requestExtensions: config.requestExtensions, commands: config.commands?.map((cmd) => ({ name: cmd.name, description: cmd.description, @@ -941,6 +946,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) { @@ -991,6 +997,9 @@ export class CopilotClient { overridesBuiltInTool: tool.overridesBuiltInTool, skipPermission: tool.skipPermission, })), + canvases: config.canvases?.map((c) => c.declaration), + requestCanvasRenderer: config.requestCanvasRenderer, + requestExtensions: config.requestExtensions, commands: config.commands?.map((cmd) => ({ name: cmd.name, description: cmd.description, @@ -1020,6 +1029,7 @@ export class CopilotClient { infiniteSessions: config.infiniteSessions, suppressResumeEvent: config.suppressResumeEvent, continuePendingWork: config.continuePendingWork, + openCanvasInstances: config.openCanvasInstances, gitHubToken: config.gitHubToken, remoteSession: config.remoteSession, }); @@ -1919,6 +1929,19 @@ export class CopilotClient { await this.handleSystemMessageTransform(params) ); + // Canvas dispatch: the runtime uses the `hostExtension.invoke` wire + // method for canvas dispatches. The inner `method` discriminates; + // we route `canvas.action.invoke` to the per-session canvas registry + // and reject anything else. + this.connection.onRequest( + "hostExtension.invoke", + async (params: { + sessionId: string; + request: { id?: string; method: string; params?: unknown }; + }): Promise<{ id?: string; result?: unknown; error?: CanvasError }> => + await this.handleHostExtensionInvoke(params) + ); + // Register client session API handlers. const sessions = this.sessions; registerClientSessionApiHandlers(this.connection, (sessionId) => { @@ -2123,6 +2146,77 @@ export class CopilotClient { return await session._handleSystemMessageTransform(params.sections); } + private async handleHostExtensionInvoke(params: { + sessionId: string; + request: { id?: string; method: string; params?: unknown }; + }): Promise<{ ok: true; result: unknown } | { ok: false; error: CanvasError }> { + const invalidEnvelope = (message: string) => + ({ + ok: false as const, + error: { + code: "invalid_payload", + message, + name: "CanvasError", + } as CanvasError, + }) satisfies { ok: false; error: CanvasError }; + + if (!params || typeof params.sessionId !== "string" || !params.request) { + return invalidEnvelope("Invalid hostExtension.invoke payload"); + } + const session = this.sessions.get(params.sessionId); + if (!session) { + return { + ok: false, + error: { + code: "session_not_found", + message: `Session not found: ${params.sessionId}`, + name: "CanvasError", + } as CanvasError, + }; + } + const { method, params: inner } = params.request; + // Only `canvas.action.invoke` is accepted as an inner method; reject + // anything else explicitly so misrouted calls don't silently no-op. + if (method !== "canvas.action.invoke") { + return { + ok: false, + error: { + code: "unsupported_method", + message: `hostExtension.invoke only supports canvas.action.invoke, got '${method}'`, + name: "CanvasError", + } as CanvasError, + }; + } + const actionParams = inner as CanvasActionInvokeParams; + if (!actionParams || typeof actionParams.canvasId !== "string") { + return invalidEnvelope("Invalid canvas.action.invoke params: missing canvasId"); + } + const canvas = session.getCanvas(actionParams.canvasId); + if (!canvas) { + return { + ok: false, + error: { + code: "canvas_not_found", + message: `No canvas registered with id "${actionParams.canvasId}"`, + name: "CanvasError", + } as CanvasError, + }; + } + try { + const result = await dispatchCanvasAction(canvas, params.sessionId, actionParams); + return { ok: true, result }; + } catch (e) { + if (e && typeof e === "object" && "code" in e && "message" in e) { + const ce = e as CanvasError; + return { + ok: false, + error: { code: ce.code, message: ce.message, name: ce.name } as CanvasError, + }; + } + throw e; + } + } + // ======================================================================== // Protocol v2 backward-compatibility adapters // ======================================================================== diff --git a/nodejs/src/extension.ts b/nodejs/src/extension.ts index 617052546..269de6c52 100644 --- a/nodejs/src/extension.ts +++ b/nodejs/src/extension.ts @@ -10,6 +10,21 @@ import { type ResumeSessionConfig, } from "./types.js"; +export { + Canvas, + CanvasError, + createCanvas, + type CanvasActionContext, + type CanvasAgentActionDeclaration, + type CanvasDeclaration, + type CanvasInstanceRehydrate, + type CanvasLifecycleContext, + type CanvasOpenContext, + type CanvasOpenResponse, + type CanvasOptions, + type CanvasToolbarItemDeclaration, +} from "./canvas.js"; + export type JoinSessionConfig = Omit & { onPermissionRequest?: PermissionHandler; }; diff --git a/nodejs/src/index.ts b/nodejs/src/index.ts index 6ada0f141..7558ef1e4 100644 --- a/nodejs/src/index.ts +++ b/nodejs/src/index.ts @@ -11,6 +11,20 @@ export { CopilotClient } from "./client.js"; export { RuntimeConnection } from "./types.js"; export { CopilotSession, type AssistantMessageEvent } from "./session.js"; +export { + Canvas, + CanvasError, + createCanvas, + type CanvasActionContext, + type CanvasAgentActionDeclaration, + type CanvasDeclaration, + type CanvasInstanceRehydrate, + type CanvasLifecycleContext, + type CanvasOpenContext, + type CanvasOpenResponse, + type CanvasOptions, + type CanvasToolbarItemDeclaration, +} from "./canvas.js"; export { defineTool, approveAll, diff --git a/nodejs/src/session.ts b/nodejs/src/session.ts index 4baf35c3e..7fee2fae7 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, @@ -105,6 +106,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; @@ -640,6 +642,31 @@ export class CopilotSession { return this.toolHandlers.get(name); } + /** + * Registers canvas declarations + 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 `hostExtension.invoke` 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 fae3418a0..ef6c1b014 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, CanvasInstanceRehydrate } 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"; @@ -1457,6 +1458,38 @@ export interface SessionConfigBase { // eslint-disable-next-line @typescript-eslint/no-explicit-any tools?: Tool[]; + /** + * Canvases contributed by this session participant. The declaring + * connection becomes the live provider for `canvas.open|focus|close|reload` + * and `canvas.action.invoke` dispatches targeting each canvas's `id` for + * the lifetime of the connection. Re-declaring the same id on resume + * replaces the prior declaration. + */ + canvases?: Canvas[]; + + /** + * Renderer-side opt-in: when true, the runtime surfaces canvas agent tools + * (`open_canvas`, `discover_canvases`, `focus_canvas`, `close_canvas`, + * `reload_canvas`) to the model for this connection. Default off — TUI / + * headless / SDK callers stay clean unless they can actually display + * canvases. Independent of provider semantics, which are declared via + * `canvases`. + */ + requestCanvasRenderer?: boolean; + + /** + * Extension surface opt-in: when true, the runtime wires extension + * management tools (`extensions_reload`, `extensions_manage`) and the + * per-extension tool dispatch onto the session for this connection. + * Default off — SDK callers that don't intend to expose the extension + * surface stay clean. + * + * Requires the runtime to have the `EXTENSIONS` experimental feature + * flag enabled. If the flag is off, the runtime silently skips wiring + * even when this is true. + */ + requestExtensions?: boolean; + /** * Slash commands registered for this session. * When the CLI has a TUI, each command appears as `/name` for the user to invoke. @@ -1687,6 +1720,17 @@ export interface ResumeSessionConfig extends SessionConfigBase { * @default false */ continuePendingWork?: boolean; + /** + * Extension canvas instances the host believes are still open from a prior + * runtime process. Supplied on resume so the runtime can re-populate its + * in-memory canvas instance map without re-invoking each extension's + * `onOpen`. Instances whose `(extensionId, canvasId)` don't resolve in the + * active extension set produce a `session.canvas.closed` event with + * `reason: "rehydrate_failed"` so the host can drop the stale UI. Native + * host-implemented canvases (e.g. `host.*` ids) should be omitted — the + * host owns their lifecycle end-to-end without the runtime instance record. + */ + openCanvasInstances?: CanvasInstanceRehydrate[]; } /** diff --git a/rust/src/canvas.rs b/rust/src/canvas.rs new file mode 100644 index 000000000..e075c3a68 --- /dev/null +++ b/rust/src/canvas.rs @@ -0,0 +1,748 @@ +//! Extension-owned canvases declared via `joinSession({ canvases: [...] })`. +//! +//! This module is the Rust mirror of the TypeScript wire shape. +//! +//! The wire RPC method is `hostExtension.invoke`; inside, the inner +//! `method == "canvas.action.invoke"` identifies canvas dispatches. The SDK +//! routes purely on `params.canvasId` + `params.actionName`. + +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; + +/// Declarative metadata for a single canvas, sent over the wire on +/// `session.create` / `session.resume`. +/// +/// Mirrors the TypeScript `CanvasDeclaration` interface verbatim. The +/// `handler` that backs this declaration is held in-process (see [`Canvas`]) +/// and never serialized. +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct CanvasDeclaration { + /// Canvas identifier, unique within the declaring connection. Stable across + /// resumes — re-declaring with the same `id` replaces the prior entry. + pub id: String, + /// Human-readable name shown in host UI / canvas pickers. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub display_name: Option, + /// Long-form description; surfaced in the agent's discovery prompt. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub description: Option, + /// JSON Schema for the `input` payload accepted by `canvas.open`. + /// Runtime validates incoming `open_canvas` calls against this; handlers + /// never see malformed input. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub input_schema: Option, + /// Agent-callable actions this canvas exposes. Names MUST NOT start with + /// `canvas.` (reserved for lifecycle verbs `canvas.{open,focus,close,reload}`). + #[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>, +} + +/// 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. MUST NOT start with + /// `canvas.` — that prefix is reserved for lifecycle verbs. + pub name: String, + /// Description shown to the model when picking an action. + pub description: String, + /// 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. The host canvas chrome +/// renders these and dispatches `actionName` with optional `input` when +/// clicked. +#[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, + /// Optional icon identifier; semantics are host-defined. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub icon: Option, + /// Optional tooltip shown on hover. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub tooltip: Option, + /// The `agentActions[].name` to dispatch when clicked. May also be a + /// reserved `canvas.*` verb (e.g. `canvas.reload`) — runtime routes + /// reserved names to the matching lifecycle method. + 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`]. The extension's URL is +/// embedded by the host in its webview surface when the host advertises +/// the `canvas.webview` capability. +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct CanvasOpenResponse { + /// URL the host should embed (typically a loopback HTTP server owned by + /// the extension). Optional for canvases that have no visual surface. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub url: Option, + /// Stable per-instance identifier the extension can correlate with its + /// own state. The host echoes this back on subsequent lifecycle calls. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub instance_id: Option, +} + +/// Per-instance resume hint sent on `session.resume` to rebuild the runtime's +/// canvas-instance registry. The host persists open canvases across CLI +/// process restarts and hands them back here so subsequent +/// `invoke_canvas_action` dispatches find the existing instance instead of +/// erroring with `canvas_instance_not_found`. +/// +/// The handler's `on_open` is **not** re-invoked on rehydrate — the extension +/// keeps whatever state it had in its own process. Entries the runtime cannot +/// bind to a currently-declared canvas trigger a `session.canvas.closed` +/// event with `reason: "rehydrate_failed"`. +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct CanvasInstanceRehydrate { + /// Canonical extension id that owns the canvas. + pub extension_id: String, + /// Canvas declaration id within that extension. + pub canvas_id: String, + /// Stable instance id the host originally opened the canvas under. + pub instance_id: String, + /// Optional URL recorded at the original open. Populated as-is into the + /// rebuilt instance record; not re-validated by the runtime. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub url: Option, +} + +/// Context handed to [`CanvasHandler::on_open`]. +#[derive(Debug, Clone)] +pub struct CanvasOpenContext { + /// Session that requested the canvas. + pub session_id: SessionId, + /// Canvas id (matches the declaring [`CanvasDeclaration::id`]). + pub canvas_id: String, + /// Agent-supplied stable instance id. Required by the runtime on every + /// `canvas.open` invocation; handlers should key their per-instance state + /// off this value. + pub instance_id: String, + /// Validated `input` payload, shaped by [`CanvasDeclaration::input_schema`]. + pub input: Value, + /// Toolbar items declared on the canvas, passed through for handler + /// convenience (e.g. if the extension wants to mirror them in its own UI). + pub toolbar: Option>, +} + +/// Context handed to [`CanvasHandler::on_action`]. +#[derive(Debug, Clone)] +pub struct CanvasActionContext { + /// Session that invoked the action. + pub session_id: SessionId, + /// 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, shaped by the action's `input_schema`. + pub input: Value, +} + +/// Context handed to lifecycle hooks ([`CanvasHandler::on_focus`], +/// [`CanvasHandler::on_close`], [`CanvasHandler::on_reload`]). +#[derive(Debug, Clone)] +pub struct CanvasLifecycleContext { + /// Session owning the canvas instance. + pub session_id: SessionId, + /// Canvas id (matches the declaring [`CanvasDeclaration::id`]). + pub canvas_id: String, + /// Instance id this lifecycle event applies to. + pub instance_id: String, +} + +/// Structured error returned from canvas handlers. Serialized into the +/// `canvas.action.invoke` error envelope. +#[derive(Debug, Clone, Error, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +#[error("{code}: {message}")] +pub struct CanvasError { + /// Machine-readable error code. Reserved codes: + /// - `canvas_action_no_handler` — action declared but no handler implemented + /// - `canvas_input_invalid` — input failed schema validation (runtime emits) + 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 by [`CanvasHandler::on_action`] when the + /// handler did not override it — i.e. the canvas declared no + /// `agentActions[]` or forgot to wire one. + 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 the lifecycle the runtime dispatches. +/// +/// Each [`Canvas`] owns one `Arc`. The SDK routes incoming +/// `canvas.action.invoke` requests by `(canvas_id, action_name)`: +/// +/// - `canvas.open` → [`Self::on_open`] (required) +/// - `canvas.focus` → [`Self::on_focus`] (default no-op) +/// - `canvas.close` → [`Self::on_close`] (default no-op) +/// - `canvas.reload` → [`Self::on_reload`] (default no-op) +/// - anything else → [`Self::on_action`] (default returns `canvas_action_no_handler`) +/// +/// Implementations may be invoked concurrently — keep them `Send + Sync`. +#[async_trait] +pub trait CanvasHandler: Send + Sync { + /// Required. Open a new canvas instance. Return its URL (if any) and an + /// extension-owned instance id (if any). + async fn on_open(&self, ctx: CanvasOpenContext) -> CanvasResult; + + /// Optional. Handle a non-lifecycle action declared in + /// [`CanvasDeclaration::agent_actions`]. Default returns + /// [`CanvasError::no_handler`] so canvases with no agent actions don't + /// need to think about it. + async fn on_action(&self, _ctx: CanvasActionContext) -> CanvasResult { + Err(CanvasError::no_handler()) + } + + /// Optional. Canvas was brought to the foreground. + async fn on_focus(&self, _ctx: CanvasLifecycleContext) -> CanvasResult<()> { + Ok(()) + } + + /// Optional. Canvas was closed by the user or agent. + async fn on_close(&self, _ctx: CanvasLifecycleContext) -> CanvasResult<()> { + Ok(()) + } + + /// Optional. Host requested a reload (e.g. user hit refresh). + async fn on_reload(&self, _ctx: CanvasLifecycleContext) -> CanvasResult<()> { + Ok(()) + } +} + +/// A registered canvas: declarative metadata + in-process handler. +/// +/// Construct via [`Canvas::builder`]. The declaration is serialized onto the +/// wire (handlers are dropped — they're not transferable); the handler is +/// retained in the SDK's per-session registry and invoked by +/// `canvas.action.invoke` dispatch keyed by `(canvas_id, action_name)`. +#[derive(Clone)] +pub struct Canvas { + declaration: CanvasDeclaration, + handler: Arc, +} + +impl Canvas { + /// Begin building a canvas from its declarative metadata. Call + /// [`CanvasBuilder::handler`] then [`CanvasBuilder::build`]. + 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 arc 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`]. The handler is required; [`Self::build`] panics +/// if called without one (mirrors the Node `createCanvas` requirement that +/// `onOpen` be provided). +pub struct CanvasBuilder { + declaration: CanvasDeclaration, + handler: Option>, +} + +impl CanvasBuilder { + /// Attach the per-canvas handler. Required. + pub fn handler(mut self, handler: Arc) -> Self { + self.handler = Some(handler); + self + } + + /// Finalize into a [`Canvas`]. Panics if [`Self::handler`] was not called. + 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`. +/// +/// Built from a session's `canvases: Vec` at create/resume time and +/// consulted by the JSON-RPC dispatch path when an incoming +/// `canvas.action.invoke` arrives. +pub type CanvasRegistry = HashMap>; + +/// Build a [`CanvasRegistry`] from a session's declared canvases. +/// +/// Duplicate ids: later entries replace earlier ones (matches the runtime's +/// re-declare-replace semantics on `session.resume`). +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 +} + +/// Wire-level params for `canvas.action.invoke` (the inner `method` field of +/// a `hostExtension.invoke` JSON-RPC request). +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CanvasInvokeParams { + /// Canvas id from the declaring [`CanvasDeclaration::id`]. + pub canvas_id: String, + /// Present for every action except `canvas.open` (runtime allocates + /// the instance id after open returns). + #[serde(default)] + pub instance_id: Option, + /// `canvas.{open,focus,close,reload}` for lifecycle verbs; otherwise a + /// custom action name declared in [`CanvasDeclaration::agent_actions`]. + pub action_name: String, + /// Validated `input` payload. Runtime has already checked it against the + /// canvas's `input_schema` / action's `input_schema`. + #[serde(default)] + pub input: Value, + /// Toolbar items declared on the canvas — runtime passes them through on + /// `canvas.open` so handlers don't need to re-derive their own copy. + #[serde(default)] + pub toolbar: Option>, +} + +/// Resolve a `canvas.action.invoke` request against a registry and run the +/// matching handler method. Returns `Ok(result_value)` on success or +/// `Err(canvas_error)` on failure. +/// +/// Reserved verbs (`canvas.{open,focus,close,reload}`) route to the matching +/// lifecycle method; any other `action_name` routes to +/// [`CanvasHandler::on_action`]. +pub async fn dispatch_canvas_invoke( + registry: &CanvasRegistry, + session_id: SessionId, + params: CanvasInvokeParams, +) -> CanvasResult { + let handler = registry.get(¶ms.canvas_id).cloned().ok_or_else(|| { + CanvasError::new( + "canvas_not_registered", + format!( + "No canvas handler registered for id '{}' in this session", + params.canvas_id + ), + ) + })?; + + match params.action_name.as_str() { + "canvas.open" => { + let instance_id = params.instance_id.ok_or_else(|| { + CanvasError::new( + "canvas_missing_instance_id", + "canvas.open requires an instanceId", + ) + })?; + let ctx = CanvasOpenContext { + session_id, + canvas_id: params.canvas_id, + instance_id, + input: params.input, + toolbar: params.toolbar, + }; + let response = handler.on_open(ctx).await?; + Ok(serde_json::to_value(response).unwrap_or(Value::Null)) + } + verb @ ("canvas.focus" | "canvas.close" | "canvas.reload") => { + let instance_id = params.instance_id.ok_or_else(|| { + CanvasError::new( + "canvas_missing_instance_id", + format!("Lifecycle verb '{verb}' requires an instanceId"), + ) + })?; + let ctx = CanvasLifecycleContext { + session_id, + canvas_id: params.canvas_id, + instance_id, + }; + match verb { + "canvas.focus" => handler.on_focus(ctx).await?, + "canvas.close" => handler.on_close(ctx).await?, + "canvas.reload" => handler.on_reload(ctx).await?, + _ => unreachable!(), + } + Ok(Value::Null) + } + other => { + let instance_id = params.instance_id.ok_or_else(|| { + CanvasError::new( + "canvas_missing_instance_id", + format!("Action '{other}' requires an instanceId"), + ) + })?; + let ctx = CanvasActionContext { + session_id, + canvas_id: params.canvas_id, + instance_id, + action_name: other.to_string(), + input: params.input, + }; + handler.on_action(ctx).await + } + } +} + +#[cfg(test)] +mod tests { + use serde_json::json; + + use super::*; + + #[test] + fn declaration_serializes_camel_case_and_skips_none() { + let decl = CanvasDeclaration { + id: "counter".into(), + display_name: Some("Counter".into()), + description: None, + input_schema: None, + agent_actions: Some(vec![CanvasAgentActionDeclaration { + name: "increment".into(), + description: "bump".into(), + input_schema: None, + }]), + toolbar: None, + }; + let v = serde_json::to_value(&decl).unwrap(); + assert_eq!(v["id"], "counter"); + assert_eq!(v["displayName"], "Counter"); + assert!(v.get("description").is_none()); + assert!(v.get("inputSchema").is_none()); + assert_eq!(v["agentActions"][0]["name"], "increment"); + assert!(v.get("toolbar").is_none()); + } + + #[test] + fn toolbar_item_round_trip() { + let item = CanvasToolbarItemDeclaration { + id: "reload".into(), + label: "Reload".into(), + icon: Some("refresh".into()), + tooltip: None, + action_name: "canvas.reload".into(), + input: Some(json!({ "force": true })), + }; + let v = serde_json::to_value(&item).unwrap(); + assert_eq!(v["actionName"], "canvas.reload"); + assert_eq!(v["input"]["force"], true); + let back: CanvasToolbarItemDeclaration = serde_json::from_value(v).unwrap(); + assert_eq!(back, item); + } + + 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)), + instance_id: Some(format!("instance-of-{}", ctx.canvas_id)), + }) + } + + async fn on_action(&self, ctx: CanvasActionContext) -> CanvasResult { + Ok(json!({ "echoed": ctx.action_name, "input": ctx.input })) + } + } + + #[tokio::test] + async fn canvas_serializes_as_declaration() { + let canvas = Canvas::builder(CanvasDeclaration { + id: "echo".into(), + display_name: Some("Echo".into()), + ..Default::default() + }) + .handler(Arc::new(EchoHandler)) + .build(); + let v = serde_json::to_value(&canvas).unwrap(); + assert_eq!(v["id"], "echo"); + assert_eq!(v["displayName"], "Echo"); + assert!(v.get("handler").is_none()); + } + + #[tokio::test] + async fn default_on_action_returns_no_handler() { + // EchoHandler overrides on_action; use a bare handler here to hit the default. + struct OpenOnly; + #[async_trait] + impl CanvasHandler for OpenOnly { + async fn on_open(&self, _ctx: CanvasOpenContext) -> CanvasResult { + Ok(CanvasOpenResponse::default()) + } + } + let canvas = Canvas::builder(CanvasDeclaration { + id: "bare".into(), + ..Default::default() + }) + .handler(Arc::new(OpenOnly)) + .build(); + + let err = canvas + .handler() + .on_action(CanvasActionContext { + session_id: SessionId::from("s1"), + canvas_id: "bare".into(), + instance_id: "i1".into(), + action_name: "noop".into(), + input: Value::Null, + }) + .await + .unwrap_err(); + assert_eq!(err.code, "canvas_action_no_handler"); + } + + #[tokio::test] + async fn default_lifecycle_hooks_are_no_op() { + struct OpenOnly; + #[async_trait] + impl CanvasHandler for OpenOnly { + async fn on_open(&self, _ctx: CanvasOpenContext) -> CanvasResult { + Ok(CanvasOpenResponse::default()) + } + } + let canvas = Canvas::builder(CanvasDeclaration { + id: "bare".into(), + ..Default::default() + }) + .handler(Arc::new(OpenOnly)) + .build(); + + let ctx = CanvasLifecycleContext { + session_id: SessionId::from("s1"), + canvas_id: "bare".into(), + instance_id: "i1".into(), + }; + canvas.handler().on_focus(ctx.clone()).await.unwrap(); + canvas.handler().on_close(ctx.clone()).await.unwrap(); + canvas.handler().on_reload(ctx).await.unwrap(); + } + + #[tokio::test] + async fn dispatch_routes_canvas_open() { + let canvas = Canvas::builder(CanvasDeclaration { + id: "echo".into(), + ..Default::default() + }) + .handler(Arc::new(EchoHandler)) + .build(); + let registry = build_registry(&[canvas]); + + let params = CanvasInvokeParams { + canvas_id: "echo".into(), + instance_id: Some("echo-1".into()), + action_name: "canvas.open".into(), + input: json!({ "x": 1 }), + toolbar: None, + }; + let result = dispatch_canvas_invoke(®istry, SessionId::from("s1"), params) + .await + .unwrap(); + assert_eq!(result["url"], "https://example.test/echo"); + assert_eq!(result["instanceId"], "instance-of-echo"); + } + + #[tokio::test] + async fn dispatch_routes_lifecycle_verbs() { + let canvas = Canvas::builder(CanvasDeclaration { + id: "echo".into(), + ..Default::default() + }) + .handler(Arc::new(EchoHandler)) + .build(); + let registry = build_registry(&[canvas]); + + for verb in ["canvas.focus", "canvas.close", "canvas.reload"] { + let params = CanvasInvokeParams { + canvas_id: "echo".into(), + instance_id: Some("inst-1".into()), + action_name: verb.into(), + input: Value::Null, + toolbar: None, + }; + let result = dispatch_canvas_invoke(®istry, SessionId::from("s1"), params) + .await + .unwrap(); + assert!(result.is_null(), "verb {verb} should return null"); + } + } + + #[tokio::test] + async fn dispatch_routes_custom_action() { + let canvas = Canvas::builder(CanvasDeclaration { + id: "echo".into(), + ..Default::default() + }) + .handler(Arc::new(EchoHandler)) + .build(); + let registry = build_registry(&[canvas]); + + let params = CanvasInvokeParams { + canvas_id: "echo".into(), + instance_id: Some("inst-1".into()), + action_name: "shout".into(), + input: json!("hi"), + toolbar: None, + }; + let result = dispatch_canvas_invoke(®istry, SessionId::from("s1"), params) + .await + .unwrap(); + assert_eq!(result["echoed"], "shout"); + assert_eq!(result["input"], "hi"); + } + + #[tokio::test] + async fn dispatch_unknown_canvas_errors() { + let registry = CanvasRegistry::new(); + let params = CanvasInvokeParams { + canvas_id: "missing".into(), + instance_id: None, + action_name: "canvas.open".into(), + input: Value::Null, + toolbar: None, + }; + let err = dispatch_canvas_invoke(®istry, SessionId::from("s1"), params) + .await + .unwrap_err(); + assert_eq!(err.code, "canvas_not_registered"); + } + + #[tokio::test] + async fn dispatch_lifecycle_without_instance_id_errors() { + let canvas = Canvas::builder(CanvasDeclaration { + id: "echo".into(), + ..Default::default() + }) + .handler(Arc::new(EchoHandler)) + .build(); + let registry = build_registry(&[canvas]); + + let params = CanvasInvokeParams { + canvas_id: "echo".into(), + instance_id: None, + action_name: "canvas.close".into(), + input: Value::Null, + toolbar: None, + }; + let err = dispatch_canvas_invoke(®istry, SessionId::from("s1"), params) + .await + .unwrap_err(); + assert_eq!(err.code, "canvas_missing_instance_id"); + } + + #[tokio::test] + async fn build_registry_replaces_duplicate_ids() { + struct FirstHandler; + #[async_trait] + impl CanvasHandler for FirstHandler { + async fn on_open(&self, _ctx: CanvasOpenContext) -> CanvasResult { + Ok(CanvasOpenResponse { + url: Some("first".into()), + instance_id: None, + }) + } + } + struct SecondHandler; + #[async_trait] + impl CanvasHandler for SecondHandler { + async fn on_open(&self, _ctx: CanvasOpenContext) -> CanvasResult { + Ok(CanvasOpenResponse { + url: Some("second".into()), + instance_id: None, + }) + } + } + let first = Canvas::builder(CanvasDeclaration { + id: "dup".into(), + ..Default::default() + }) + .handler(Arc::new(FirstHandler)) + .build(); + let second = Canvas::builder(CanvasDeclaration { + id: "dup".into(), + ..Default::default() + }) + .handler(Arc::new(SecondHandler)) + .build(); + let registry = build_registry(&[first, second]); + let result = dispatch_canvas_invoke( + ®istry, + SessionId::from("s1"), + CanvasInvokeParams { + canvas_id: "dup".into(), + instance_id: Some("inst-1".into()), + action_name: "canvas.open".into(), + input: Value::Null, + toolbar: None, + }, + ) + .await + .unwrap(); + assert_eq!(result["url"], "second"); + } +} diff --git a/rust/src/generated/api_types.rs b/rust/src/generated/api_types.rs index 0ef378f48..8633ba823 100644 --- a/rust/src/generated/api_types.rs +++ b/rust/src/generated/api_types.rs @@ -201,12 +201,24 @@ pub mod rpc_methods { pub const SESSION_MCP_OAUTH_LOGIN: &str = "session.mcp.oauth.login"; /// `session.plugins.list` pub const SESSION_PLUGINS_LIST: &str = "session.plugins.list"; + /// `session.canvas.open` + pub const SESSION_CANVAS_OPEN: &str = "session.canvas.open"; + /// `session.canvas.focus` + pub const SESSION_CANVAS_FOCUS: &str = "session.canvas.focus"; + /// `session.canvas.close` + pub const SESSION_CANVAS_CLOSE: &str = "session.canvas.close"; + /// `session.canvas.reload` + pub const SESSION_CANVAS_RELOAD: &str = "session.canvas.reload"; + /// `session.canvas.invokeAction` + pub const SESSION_CANVAS_INVOKEACTION: &str = "session.canvas.invokeAction"; /// `session.options.update` pub const SESSION_OPTIONS_UPDATE: &str = "session.options.update"; /// `session.lsp.initialize` pub const SESSION_LSP_INITIALIZE: &str = "session.lsp.initialize"; /// `session.extensions.list` pub const SESSION_EXTENSIONS_LIST: &str = "session.extensions.list"; + /// `session.extensions.discoverCanvases` + pub const SESSION_EXTENSIONS_DISCOVERCANVASES: &str = "session.extensions.discoverCanvases"; /// `session.extensions.enable` pub const SESSION_EXTENSIONS_ENABLE: &str = "session.extensions.enable"; /// `session.extensions.disable` @@ -1354,17 +1366,22 @@ pub struct ExecuteCommandResult { #[derive(Debug, Clone, Default, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct Extension { - /// Source-qualified ID (e.g., 'project:my-ext', 'user:auth-helper') + /// Full extension ID: `..` (or `.` when + /// the manifest has no publisher) pub id: String, - /// Extension name (directory name) + /// Extension name (manifest name or directory name) pub name: String, /// Process ID if the extension is running #[serde(skip_serializing_if = "Option::is_none")] pub pid: Option, - /// Discovery source: project (.github/extensions/) or user (~/.copilot/extensions/) + /// Discovery source: project (.github/extensions/), user (~/.copilot/extensions/), + /// or host registration pub source: ExtensionSource, - /// Current status: running, disabled, failed, or starting + /// Current status: running, disabled, failed, starting, or unavailable pub status: ExtensionStatus, + /// Reason the extension is unavailable, if known + #[serde(skip_serializing_if = "Option::is_none")] + pub unavailable_reason: Option, } /// Extensions discovered for the session, with their current status. @@ -1382,8 +1399,298 @@ pub struct ExtensionList { pub extensions: Vec, } -/// Source-qualified extension identifier to disable for the session. +/// Request parameters for `session.canvas.invokeAction`. +/// +///
+/// +/// **Experimental.** This type is part of an experimental wire-protocol surface +/// and may change or be removed in future SDK or CLI releases. +/// +///
+#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CanvasInvokeActionRequest { + /// Canvas instance id returned from `session.canvas.open` or `open_canvas`. + pub instance_id: String, + /// Extension-defined action name declared in the canvas's `agentActions[]` + /// manifest entry. The lifecycle verbs (`canvas.open` / `canvas.focus` / + /// `canvas.close` / `canvas.reload`) are reserved and rejected. + 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`. +/// +///
+/// +/// **Experimental.** This type is part of an experimental wire-protocol surface +/// and may change or be removed in future SDK or CLI releases. +/// +///
+#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CanvasInvokeActionResult { + /// Provider's action result payload (may be absent for fire-and-forget + /// actions). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub result: Option, +} + +/// Request parameters for `session.canvas.open`. +/// +///
+/// +/// **Experimental.** This type is part of an experimental wire-protocol surface +/// and may change or be removed in future SDK or CLI releases. +/// +///
+#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CanvasOpenRequest { + /// Canonical extension id that owns the canvas (e.g. `host.github.markdown`). + pub extension_id: String, + /// Canvas contribution id declared in the extension's manifest. + pub canvas_id: String, + /// Optional opaque payload forwarded to the extension's `canvas.open` + /// handler. Validated against the canvas's manifest-declared `inputSchema` + /// (when present) at the dispatch boundary; mismatches return the + /// `canvas_input_invalid` error code. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub input: Option, +} + +/// Result returned from `session.canvas.open`. +/// +///
+/// +/// **Experimental.** This type is part of an experimental wire-protocol surface +/// and may change or be removed in future SDK or CLI releases. +/// +///
+#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CanvasOpenResult { + /// Canvas instance id; pass to focus/close/reload or invokeAction. + pub instance_id: String, + /// URL the host should render. Absent for native (hosted-in-app) canvases. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub url: Option, +} + +/// Request parameters for `session.canvas.focus`. +/// +///
+/// +/// **Experimental.** This type is part of an experimental wire-protocol surface +/// and may change or be removed in future SDK or CLI releases. +/// +///
+#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CanvasFocusRequest { + /// Canvas instance id returned from `session.canvas.open` or `open_canvas`. + pub instance_id: String, +} + +/// Request parameters for `session.canvas.close`. +/// +///
+/// +/// **Experimental.** This type is part of an experimental wire-protocol surface +/// and may change or be removed in future SDK or CLI releases. +/// +///
+#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CanvasCloseRequest { + /// Canvas instance id returned from `session.canvas.open` or `open_canvas`. + pub instance_id: String, +} + +/// Request parameters for `session.canvas.reload`. +/// +///
+/// +/// **Experimental.** This type is part of an experimental wire-protocol surface +/// and may change or be removed in future SDK or CLI releases. +/// +///
+#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CanvasReloadRequest { + /// Canvas instance id returned from `session.canvas.open` or `open_canvas`. + pub instance_id: String, +} + +/// +///
+/// +/// **Experimental.** This type is part of an experimental wire-protocol surface +/// and may change or be removed in future SDK or CLI releases. +/// +///
+#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ExtensionsDiscoverCanvasesImplementationNative { + /// Provider ID registered via hostExtension.invoke + pub id: String, +} + +/// +///
+/// +/// **Experimental.** This type is part of an experimental wire-protocol surface +/// and may change or be removed in future SDK or CLI releases. +/// +///
+#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ExtensionsDiscoverCanvasesImplementationUrl { + /// Optional provider ID; ignored by the runtime + #[serde(skip_serializing_if = "Option::is_none")] + pub id: Option, +} + +/// Per-canvas implementation routing. +/// +///
+/// +/// **Experimental.** This type is part of an experimental wire-protocol surface +/// and may change or be removed in future SDK or CLI releases. +/// +///
+#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "lowercase")] +pub enum ExtensionsDiscoverCanvasesImplementation { + /// Host renders the canvas in-process + Native(ExtensionsDiscoverCanvasesImplementationNative), + /// Provider returns a URL from its open handler + Url(ExtensionsDiscoverCanvasesImplementationUrl), +} + +/// Expected canvas.open result shape when known +/// +///
+/// +/// **Experimental.** This type is part of an experimental wire-protocol surface +/// and may change or be removed in future SDK or CLI releases. +/// +///
+#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ExtensionsDiscoverCanvasesOpenResult { + /// Expected open result category + pub kind: ExtensionsDiscoverCanvasesOpenKind, + /// Whether canvas.open must return a URL + #[serde(skip_serializing_if = "Option::is_none")] + pub url_required: Option, +} + +/// +///
+/// +/// **Experimental.** This type is part of an experimental wire-protocol surface +/// and may change or be removed in future SDK or CLI releases. +/// +///
+#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ExtensionsDiscoverCanvases { + /// Backing storage metadata from the manifest + #[serde(default)] + pub backing: HashMap, + /// Canvas contribution ID + pub canvas_id: String, + /// Command metadata from the manifest + #[serde(default)] + pub commands: Vec, + /// Canvas description + #[serde(skip_serializing_if = "Option::is_none")] + pub description: Option, + /// Human-readable canvas display name + pub display_name: String, + /// Extension description + #[serde(skip_serializing_if = "Option::is_none")] + pub extension_description: Option, + /// Human-readable extension display name + #[serde(skip_serializing_if = "Option::is_none")] + pub extension_display_name: Option, + /// Full extension ID that owns the canvas + pub extension_id: String, + /// Extension package name + pub extension_name: String, + /// File association metadata from the manifest + #[serde(default)] + pub file_associations: Vec, + /// Per-canvas implementation routing + #[serde(skip_serializing_if = "Option::is_none")] + pub implementation: Option, + /// How the extension implementation is invoked + #[serde(skip_serializing_if = "Option::is_none")] + pub implementation_kind: Option, + /// Expected canvas.open result shape when known + #[serde(skip_serializing_if = "Option::is_none")] + pub open_result: Option, + /// Presentation metadata from the manifest + #[serde(default)] + pub presentation: HashMap, + /// Qualified canvas ID + pub qualified_id: String, + /// Runtime metadata from the manifest + #[serde(default)] + pub runtime: HashMap, + /// Extension discovery source + pub source: ExtensionsDiscoverCanvasesSource, + /// Parent extension status + pub status: ExtensionsDiscoverCanvasesStatus, + /// Canvas title + #[serde(skip_serializing_if = "Option::is_none")] + pub title: Option, + /// Toolbar metadata from the manifest + #[serde(default)] + pub toolbar: Vec, + /// Reason the canvas is unavailable, if known + #[serde(skip_serializing_if = "Option::is_none")] + pub unavailable_reason: Option, +} + +/// +///
+/// +/// **Experimental.** This type is part of an experimental wire-protocol surface +/// and may change or be removed in future SDK or CLI releases. +/// +///
+#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ExtensionsDiscoverCanvasesRequest { + /// Optional canvas contribution ID or qualified canvas ID + #[serde(skip_serializing_if = "Option::is_none")] + pub canvas_id: Option, + /// Optional full extension ID to filter by + #[serde(skip_serializing_if = "Option::is_none")] + pub extension_id: Option, + /// When false, omit unavailable canvases + #[serde(skip_serializing_if = "Option::is_none")] + pub include_unavailable: Option, +} + +/// +///
+/// +/// **Experimental.** This type is part of an experimental wire-protocol surface +/// and may change or be removed in future SDK or CLI releases. /// +///
+#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ExtensionsDiscoverCanvasesResult { + /// Canvas contributions discovered by the runtime + pub canvases: Vec, +} + ///
/// /// **Experimental.** This type is part of an experimental wire-protocol surface @@ -1393,11 +1700,10 @@ pub struct ExtensionList { #[derive(Debug, Clone, Default, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct ExtensionsDisableRequest { - /// Source-qualified extension ID to disable + /// Full extension ID to disable pub id: String, } -/// Source-qualified extension identifier to enable for the session. /// ///
/// @@ -1408,7 +1714,7 @@ pub struct ExtensionsDisableRequest { #[derive(Debug, Clone, Default, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct ExtensionsEnableRequest { - /// Source-qualified extension ID to enable + /// Full extension ID to enable pub id: String, } @@ -11251,6 +11557,15 @@ pub enum EventsCursorStatus { /// and may change or be removed in future SDK or CLI releases. /// ///
+/// Discovery source: project (.github/extensions/), user (~/.copilot/extensions/), +/// or host registration +/// +///
+/// +/// **Experimental.** This type is part of an experimental wire-protocol surface +/// and may change or be removed in future SDK or CLI releases. +/// +///
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] pub enum ExtensionSource { /// Extension discovered from the current project's .github/extensions directory. @@ -11259,13 +11574,15 @@ pub enum ExtensionSource { /// Extension discovered from the user's ~/.copilot/extensions directory. #[serde(rename = "user")] User, + #[serde(rename = "host")] + Host, /// Unknown variant for forward compatibility. #[default] #[serde(other)] Unknown, } -/// Current status: running, disabled, failed, or starting +/// Current status: running, disabled, failed, starting, or unavailable /// ///
/// @@ -11287,6 +11604,98 @@ pub enum ExtensionStatus { /// The extension process is starting. #[serde(rename = "starting")] Starting, + #[serde(rename = "unavailable")] + Unavailable, + /// Unknown variant for forward compatibility. + #[default] + #[serde(other)] + Unknown, +} + +/// How the extension implementation is invoked +/// +///
+/// +/// **Experimental.** This type is part of an experimental wire-protocol surface +/// and may change or be removed in future SDK or CLI releases. +/// +///
+#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub enum ExtensionsDiscoverCanvasesImplementationKind { + #[serde(rename = "path")] + Path, + #[serde(rename = "host")] + Host, + /// Unknown variant for forward compatibility. + #[default] + #[serde(other)] + Unknown, +} + +/// Expected open result category +/// +///
+/// +/// **Experimental.** This type is part of an experimental wire-protocol surface +/// and may change or be removed in future SDK or CLI releases. +/// +///
+#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub enum ExtensionsDiscoverCanvasesOpenKind { + #[serde(rename = "url")] + Url, + #[serde(rename = "hosted")] + Hosted, + #[serde(rename = "unknown")] + UnknownValue, + /// Unknown variant for forward compatibility. + #[default] + #[serde(other)] + Unknown, +} + +/// Extension discovery source +/// +///
+/// +/// **Experimental.** This type is part of an experimental wire-protocol surface +/// and may change or be removed in future SDK or CLI releases. +/// +///
+#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub enum ExtensionsDiscoverCanvasesSource { + #[serde(rename = "project")] + Project, + #[serde(rename = "user")] + User, + #[serde(rename = "host")] + Host, + /// Unknown variant for forward compatibility. + #[default] + #[serde(other)] + Unknown, +} + +/// Parent extension status +/// +///
+/// +/// **Experimental.** This type is part of an experimental wire-protocol surface +/// and may change or be removed in future SDK or CLI releases. +/// +///
+#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub enum ExtensionsDiscoverCanvasesStatus { + #[serde(rename = "running")] + Running, + #[serde(rename = "disabled")] + Disabled, + #[serde(rename = "failed")] + Failed, + #[serde(rename = "starting")] + Starting, + #[serde(rename = "unavailable")] + Unavailable, /// Unknown variant for forward compatibility. #[default] #[serde(other)] diff --git a/rust/src/generated/rpc.rs b/rust/src/generated/rpc.rs index b5599e09a..0f14b5fc6 100644 --- a/rust/src/generated/rpc.rs +++ b/rust/src/generated/rpc.rs @@ -1137,6 +1137,13 @@ impl<'a> SessionRpc<'a> { } } + /// `session.canvas.*` sub-namespace. + pub fn canvas(&self) -> SessionRpcCanvas<'a> { + SessionRpcCanvas { + session: self.session, + } + } + /// `session.commands.*` sub-namespace. pub fn commands(&self) -> SessionRpcCommands<'a> { SessionRpcCommands { @@ -1901,6 +1908,132 @@ impl<'a> SessionRpcCommands<'a> { } } +/// `session.canvas.*` RPCs. +#[derive(Clone, Copy)] +pub struct SessionRpcCanvas<'a> { + pub(crate) session: &'a Session, +} + +impl<'a> SessionRpcCanvas<'a> { + /// Opens a canvas instance for the given extension/canvas pair. Returns + /// the registry instance id (and the URL the host should render, for + /// URL-kind canvases). + /// + /// Wire method: `session.canvas.open`. + /// + ///
+ /// + /// **Experimental.** This API is part of an experimental wire-protocol + /// surface and may change or be removed in future SDK or CLI releases. + /// Pin both the SDK and CLI versions if your code depends on it. + /// + ///
+ pub async fn open(&self, params: CanvasOpenRequest) -> Result { + let mut wire_params = serde_json::to_value(params)?; + wire_params["sessionId"] = serde_json::Value::String(self.session.id().to_string()); + let _value = self + .session + .client() + .call(rpc_methods::SESSION_CANVAS_OPEN, Some(wire_params)) + .await?; + Ok(serde_json::from_value(_value)?) + } + + /// Focuses a previously opened canvas instance. + /// + /// Wire method: `session.canvas.focus`. + /// + ///
+ /// + /// **Experimental.** This API is part of an experimental wire-protocol + /// surface and may change or be removed in future SDK or CLI releases. + /// Pin both the SDK and CLI versions if your code depends on it. + /// + ///
+ pub async fn focus(&self, params: CanvasFocusRequest) -> Result<(), Error> { + let mut wire_params = serde_json::to_value(params)?; + wire_params["sessionId"] = serde_json::Value::String(self.session.id().to_string()); + let _value = self + .session + .client() + .call(rpc_methods::SESSION_CANVAS_FOCUS, Some(wire_params)) + .await?; + Ok(()) + } + + /// Closes a previously opened canvas instance. + /// + /// Wire method: `session.canvas.close`. + /// + ///
+ /// + /// **Experimental.** This API is part of an experimental wire-protocol + /// surface and may change or be removed in future SDK or CLI releases. + /// Pin both the SDK and CLI versions if your code depends on it. + /// + ///
+ pub async fn close(&self, params: CanvasCloseRequest) -> Result<(), Error> { + let mut wire_params = serde_json::to_value(params)?; + wire_params["sessionId"] = serde_json::Value::String(self.session.id().to_string()); + let _value = self + .session + .client() + .call(rpc_methods::SESSION_CANVAS_CLOSE, Some(wire_params)) + .await?; + Ok(()) + } + + /// Reloads a previously opened canvas instance. + /// + /// Wire method: `session.canvas.reload`. + /// + ///
+ /// + /// **Experimental.** This API is part of an experimental wire-protocol + /// surface and may change or be removed in future SDK or CLI releases. + /// Pin both the SDK and CLI versions if your code depends on it. + /// + ///
+ pub async fn reload(&self, params: CanvasReloadRequest) -> Result<(), Error> { + let mut wire_params = serde_json::to_value(params)?; + wire_params["sessionId"] = serde_json::Value::String(self.session.id().to_string()); + let _value = self + .session + .client() + .call(rpc_methods::SESSION_CANVAS_RELOAD, Some(wire_params)) + .await?; + Ok(()) + } + + /// Invokes a declared `agentActions[]` entry against an open canvas + /// instance. Lifecycle verbs (`canvas.*`) are reserved and rejected by + /// the runtime — use `session.canvas.{open|focus|close|reload}` for + /// those. + /// + /// Wire method: `session.canvas.invokeAction`. + /// + ///
+ /// + /// **Experimental.** This API is part of an experimental wire-protocol + /// surface and may change or be removed in future SDK or CLI releases. + /// Pin both the SDK and CLI versions if your code depends on it. + /// + ///
+ pub async fn invoke_action( + &self, + params: CanvasInvokeActionRequest, + ) -> Result { + let mut wire_params = serde_json::to_value(params)?; + wire_params["sessionId"] = serde_json::Value::String(self.session.id().to_string()); + let _value = self + .session + .client() + .call(rpc_methods::SESSION_CANVAS_INVOKEACTION, Some(wire_params)) + .await?; + Ok(serde_json::from_value(_value)?) + } +} + /// `session.eventLog.*` RPCs. #[derive(Clone, Copy)] pub struct SessionRpcEventLog<'a> { @@ -2068,6 +2201,34 @@ impl<'a> SessionRpcExtensions<'a> { Ok(serde_json::from_value(_value)?) } + /// Calls `session.extensions.discoverCanvases`. + /// + /// Wire method: `session.extensions.discoverCanvases`. + /// + ///
+ /// + /// **Experimental.** This API is part of an experimental wire-protocol surface + /// and may change or be removed in future SDK or CLI releases. Pin both the + /// SDK and CLI versions if your code depends on it. + /// + ///
+ pub async fn discover_canvases( + &self, + params: ExtensionsDiscoverCanvasesRequest, + ) -> Result { + let mut wire_params = serde_json::to_value(params)?; + wire_params["sessionId"] = serde_json::Value::String(self.session.id().to_string()); + let _value = self + .session + .client() + .call( + rpc_methods::SESSION_EXTENSIONS_DISCOVERCANVASES, + Some(wire_params), + ) + .await?; + Ok(serde_json::from_value(_value)?) + } + /// Enables an extension for the session. /// /// Wire method: `session.extensions.enable`. diff --git a/rust/src/generated/session_events.rs b/rust/src/generated/session_events.rs index 1f6334466..0091dbba7 100644 --- a/rust/src/generated/session_events.rs +++ b/rust/src/generated/session_events.rs @@ -1957,6 +1957,12 @@ pub struct PermissionRequestCustomTool { pub tool_description: String, /// Name of the custom tool pub tool_name: String, + /// Extension operation namespace for routing and policy context + #[serde(skip_serializing_if = "Option::is_none")] + pub namespace: Option, + /// Canvas contribution ID for canvas-scoped custom tools + #[serde(skip_serializing_if = "Option::is_none")] + pub canvas_id: Option, } /// Hook confirmation permission request @@ -2147,6 +2153,12 @@ pub struct PermissionPromptRequestCustomTool { pub tool_description: String, /// Name of the custom tool pub tool_name: String, + /// Extension operation namespace for routing and policy context + #[serde(skip_serializing_if = "Option::is_none")] + pub namespace: Option, + /// Canvas contribution ID for canvas-scoped custom tools + #[serde(skip_serializing_if = "Option::is_none")] + pub canvas_id: Option, } /// Path access permission prompt @@ -2604,6 +2616,15 @@ pub struct ExternalToolRequestedData { pub tool_call_id: String, /// Name of the external tool to invoke pub tool_name: String, + /// Extension ID for dispatcher-routed extension tools + #[serde(skip_serializing_if = "Option::is_none")] + pub extension_id: Option, + /// Optional namespace for the external tool. + #[serde(skip_serializing_if = "Option::is_none")] + pub namespace: Option, + /// Canvas ID for dispatcher-routed canvas extension tools + #[serde(skip_serializing_if = "Option::is_none")] + pub canvas_id: Option, /// W3C Trace Context traceparent header for the execute_tool span #[serde(skip_serializing_if = "Option::is_none")] pub traceparent: Option, diff --git a/rust/src/handler.rs b/rust/src/handler.rs index 565b09d56..40a96cf6d 100644 --- a/rust/src/handler.rs +++ b/rust/src/handler.rs @@ -606,6 +606,9 @@ mod tests { session_id: SessionId::from("s1".to_string()), tool_call_id: "tc1".to_string(), tool_name: "missing".to_string(), + extension_id: None, + namespace: None, + canvas_id: None, arguments: Value::Null, traceparent: None, tracestate: None, @@ -643,6 +646,9 @@ mod tests { session_id: SessionId::from("s1".to_string()), tool_call_id: "tc1".to_string(), tool_name: "manual".to_string(), + extension_id: None, + namespace: None, + canvas_id: None, arguments: Value::Null, traceparent: None, tracestate: None, diff --git a/rust/src/lib.rs b/rust/src/lib.rs index 464c599a3..212228f36 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))] +/// Extension-owned canvas declarations and per-canvas handlers. +pub mod canvas; /// Bundled CLI binary extraction and caching. pub mod embeddedcli; /// Event handler traits for session lifecycle. @@ -85,6 +87,10 @@ pub enum Error { code: i32, /// Human-readable error message. message: String, + /// Optional structured error data carried alongside the response. + /// Used by the runtime to convey extension-specific error codes + /// (e.g. `canvas_input_invalid`) and structured details. + data: Option, }, /// Session-scoped error (not found, agent error, timeout, etc.). @@ -1528,6 +1534,7 @@ impl Client { return Err(Error::Rpc { code: err.code, message: err.message, + data: err.data, }); } Ok(response.result.unwrap_or(serde_json::Value::Null)) @@ -2032,6 +2039,7 @@ mod tests { let err = Error::Rpc { code: -1, message: "bad".into(), + data: None, }; assert!(!err.is_transport_failure()); } diff --git a/rust/src/session.rs b/rust/src/session.rs index d533dbc44..043011ac2 100644 --- a/rust/src/session.rs +++ b/rust/src/session.rs @@ -793,6 +793,10 @@ impl Client { .clone() .unwrap_or_else(|| SessionId::from(uuid::Uuid::new_v4().to_string())); config.session_id = Some(session_id.clone()); + let canvas_registry = Arc::new(crate::canvas::build_registry(&config.canvases)); + // `canvases` serializes via `Canvas::serialize` -> `CanvasDeclaration` + // (handler arcs are not part of the wire shape); the registry is + // retained locally to dispatch `canvas.action.invoke` callbacks. let mut params = serde_json::to_value(&config)?; let trace_ctx = self.resolve_trace_context().await; inject_trace_context(&mut params, &trace_ctx); @@ -811,6 +815,7 @@ impl Client { transforms, command_handlers, session_fs_provider, + canvas_registry, channels, idle_waiter.clone(), capabilities.clone(), @@ -920,6 +925,9 @@ impl Client { inject_transform_sections_resume(&mut config, transforms.as_ref()); } let session_id = config.session_id.clone(); + let canvas_registry = Arc::new(crate::canvas::build_registry(&config.canvases)); + // See `create_session` for the rationale: `canvases` serializes via + // `Canvas::serialize` -> `CanvasDeclaration`; handlers are not on the wire. let mut params = serde_json::to_value(&config)?; let trace_ctx = self.resolve_trace_context().await; inject_trace_context(&mut params, &trace_ctx); @@ -938,6 +946,7 @@ impl Client { transforms, command_handlers, session_fs_provider, + canvas_registry, channels, idle_waiter.clone(), capabilities.clone(), @@ -1065,6 +1074,7 @@ fn spawn_event_loop( transforms: Option>, command_handlers: Arc, session_fs_provider: Option>, + canvas_registry: Arc, channels: crate::router::SessionChannels, idle_waiter: Arc>>, capabilities: Arc>, @@ -1099,7 +1109,7 @@ fn spawn_event_loop( } Some(request) = requests.recv() => { handle_request( - &session_id, &client, &handler, hooks.as_deref(), transforms.as_deref(), session_fs_provider.as_ref(), request, + &session_id, &client, &handler, hooks.as_deref(), transforms.as_deref(), session_fs_provider.as_ref(), &canvas_registry, request, ).await; } else => break, @@ -1343,6 +1353,8 @@ async fn handle_notification( PermissionRequestData { kind: None, tool_call_id: None, + namespace: None, + canvas_id: None, extra: notification.event.data.clone(), } }); @@ -1475,6 +1487,9 @@ async fn handle_notification( session_id: sid.clone(), tool_call_id: data.tool_call_id, tool_name: data.tool_name, + extension_id: data.extension_id, + namespace: data.namespace, + canvas_id: data.canvas_id, arguments: data .arguments .unwrap_or(Value::Object(serde_json::Map::new())), @@ -1705,6 +1720,7 @@ async fn handle_notification( } /// Process a JSON-RPC request from the CLI. +#[allow(clippy::too_many_arguments)] async fn handle_request( session_id: &SessionId, client: &Client, @@ -1712,6 +1728,7 @@ async fn handle_request( hooks: Option<&dyn SessionHooks>, transforms: Option<&dyn SystemMessageTransform>, session_fs_provider: Option<&Arc>, + canvas_registry: &Arc, request: crate::JsonRpcRequest, ) { let sid = session_id.clone(); @@ -1797,6 +1814,128 @@ async fn handle_request( let _ = client.send_response(&rpc_response).await; } + "hostExtension.invoke" => { + // Canvas dispatch: the only inner method accepted is + // `canvas.action.invoke`, routed through the canvas registry built + // from `SessionConfig.canvases`. + #[derive(serde::Deserialize)] + #[serde(rename_all = "camelCase")] + struct CanvasInvokeEnvelope { + session_id: SessionId, + request: CanvasInvokeInner, + } + #[derive(serde::Deserialize)] + struct CanvasInvokeInner { + method: String, + params: serde_json::Value, + } + + let envelope: CanvasInvokeEnvelope = match request + .params + .as_ref() + .and_then(|p| serde_json::from_value::(p.clone()).ok()) + { + Some(envelope) => envelope, + None => { + let _ = send_error_response( + client, + request.id, + error_codes::INVALID_PARAMS, + "invalid hostExtension.invoke params", + ) + .await; + return; + } + }; + + if envelope.session_id != sid { + let result = serde_json::json!({ + "ok": false, + "error": { + "code": "session_mismatch", + "message": format!( + "hostExtension.invoke session id '{}' does not match this session '{}'", + envelope.session_id, sid + ), + }, + }); + let rpc_response = JsonRpcResponse { + jsonrpc: "2.0".to_string(), + id: request.id, + result: Some(result), + error: None, + }; + let _ = client.send_response(&rpc_response).await; + return; + } + + if envelope.request.method != "canvas.action.invoke" { + let result = serde_json::json!({ + "ok": false, + "error": { + "code": "unsupported_method", + "message": format!( + "hostExtension.invoke only supports canvas.action.invoke, got '{}'", + envelope.request.method + ), + }, + }); + let rpc_response = JsonRpcResponse { + jsonrpc: "2.0".to_string(), + id: request.id, + result: Some(result), + error: None, + }; + let _ = client.send_response(&rpc_response).await; + return; + } + + let result = match serde_json::from_value::( + envelope.request.params, + ) { + Ok(params) => { + let dispatch_start = Instant::now(); + let canvas_id = params.canvas_id.clone(); + let action_name = params.action_name.clone(); + let response = crate::canvas::dispatch_canvas_invoke( + canvas_registry, + envelope.session_id, + params, + ) + .await; + tracing::debug!( + elapsed_ms = dispatch_start.elapsed().as_millis(), + session_id = %sid, + canvas_id = %canvas_id, + action_name = %action_name, + ok = response.is_ok(), + "canvas.action.invoke dispatch" + ); + match response { + Ok(value) => serde_json::json!({ "ok": true, "result": value }), + Err(err) => serde_json::json!({ + "ok": false, + "error": { "code": err.code, "message": err.message }, + }), + } + } + Err(err) => serde_json::json!({ + "ok": false, + "error": { + "code": "canvas_invalid_params", + "message": format!("invalid canvas.action.invoke params: {err}"), + }, + }), + }; + let rpc_response = JsonRpcResponse { + jsonrpc: "2.0".to_string(), + id: request.id, + result: Some(result), + error: None, + }; + let _ = client.send_response(&rpc_response).await; + } + "userInput.request" => { let params = request.params.as_ref(); let Some(question) = params @@ -1965,6 +2104,8 @@ async fn handle_request( serde_json::from_value(raw_params.clone()).unwrap_or(PermissionRequestData { kind: None, tool_call_id: None, + namespace: None, + canvas_id: None, extra: raw_params, }); diff --git a/rust/src/tool.rs b/rust/src/tool.rs index 3342f4b9f..075ea5f33 100644 --- a/rust/src/tool.rs +++ b/rust/src/tool.rs @@ -202,6 +202,7 @@ pub fn convert_mcp_call_tool_result(value: &serde_json::Value) -> Option()), /// instructions: None, @@ -464,6 +465,7 @@ mod tests { Tool { name: "echo".to_string(), namespaced_name: None, + namespace: None, description: "Echo the input".to_string(), parameters: tool_parameters(serde_json::json!({"type": "object"})), instructions: None, @@ -655,6 +657,9 @@ mod tests { session_id: SessionId::from("s1"), tool_call_id: "tc1".to_string(), tool_name: "echo".to_string(), + extension_id: None, + namespace: None, + canvas_id: None, arguments: serde_json::json!({"msg": "hello"}), traceparent: None, tracestate: None, @@ -695,6 +700,9 @@ mod tests { session_id: SessionId::from("s1"), tool_call_id: "tc1".to_string(), tool_name: "weather".to_string(), + extension_id: None, + namespace: None, + canvas_id: None, arguments: serde_json::json!({"city": "Seattle"}), traceparent: None, tracestate: None, @@ -714,6 +722,7 @@ mod tests { Tool { name: "tool_a".to_string(), namespaced_name: None, + namespace: None, description: "A".to_string(), parameters: HashMap::new(), instructions: None, @@ -733,6 +742,7 @@ mod tests { Tool { name: "tool_b".to_string(), namespaced_name: None, + namespace: None, description: "B".to_string(), parameters: HashMap::new(), instructions: None, @@ -758,6 +768,9 @@ mod tests { session_id: SessionId::from("s1"), tool_call_id: "tc1".to_string(), tool_name: "tool_b".to_string(), + extension_id: None, + namespace: None, + canvas_id: None, arguments: serde_json::json!({}), traceparent: None, tracestate: None, @@ -794,6 +807,9 @@ mod tests { session_id: SessionId::from("s1"), tool_call_id: "tc1".to_string(), tool_name: "unknown".to_string(), + extension_id: None, + namespace: None, + canvas_id: None, arguments: serde_json::json!({}), traceparent: None, tracestate: None, @@ -815,6 +831,7 @@ mod tests { Tool { name: "bad_tool".to_string(), namespaced_name: None, + namespace: None, description: "Always fails".to_string(), parameters: HashMap::new(), instructions: None, @@ -826,6 +843,7 @@ mod tests { Err(Error::Rpc { code: -1, message: "intentional failure".to_string(), + data: None, }) } } @@ -840,6 +858,9 @@ mod tests { session_id: SessionId::from("s1"), tool_call_id: "tc1".to_string(), tool_name: "bad_tool".to_string(), + extension_id: None, + namespace: None, + canvas_id: None, arguments: serde_json::json!({}), traceparent: None, tracestate: None, @@ -897,6 +918,7 @@ mod tests { Tool { name: "ok_tool".to_string(), namespaced_name: None, + namespace: None, description: "ok".to_string(), parameters: HashMap::new(), instructions: None, @@ -920,6 +942,9 @@ mod tests { session_id: SessionId::from("s1"), tool_call_id: "tc1".to_string(), tool_name: "ok_tool".to_string(), + extension_id: None, + namespace: None, + canvas_id: None, arguments: serde_json::json!({}), traceparent: None, tracestate: None, @@ -971,6 +996,7 @@ mod tests { Tool { name: "get_weather".to_string(), namespaced_name: None, + namespace: None, description: "Get weather for a city".to_string(), parameters: tool_parameters(schema_for::()), instructions: None, @@ -1005,6 +1031,9 @@ mod tests { session_id: SessionId::from("s1"), tool_call_id: "tc1".to_string(), tool_name: "get_weather".to_string(), + extension_id: None, + namespace: None, + canvas_id: None, arguments: serde_json::json!({"city": "Seattle", "unit": "celsius"}), traceparent: None, tracestate: None, @@ -1024,6 +1053,9 @@ mod tests { session_id: SessionId::from("s1"), tool_call_id: "tc1".to_string(), tool_name: "get_weather".to_string(), + extension_id: None, + namespace: None, + canvas_id: None, arguments: serde_json::json!({"wrong_field": 42}), traceparent: None, tracestate: None, @@ -1049,6 +1081,9 @@ mod tests { session_id: SessionId::from("s1"), tool_call_id: "tc1".to_string(), tool_name: "get_weather".to_string(), + extension_id: None, + namespace: None, + canvas_id: None, arguments: serde_json::json!({"city": "Portland"}), traceparent: None, tracestate: None, diff --git a/rust/src/types.rs b/rust/src/types.rs index 94e694b8d..890ba6264 100644 --- a/rust/src/types.rs +++ b/rust/src/types.rs @@ -308,6 +308,12 @@ pub struct Tool { /// for MCP tools). #[serde(default, skip_serializing_if = "Option::is_none")] pub namespaced_name: Option, + /// Optional runtime namespace for this external tool definition. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub namespace: Option, + /// Canvas contribution ID for canvas-scoped external tool definitions. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub canvas_id: Option, /// Description of what the tool does. #[serde(default)] pub description: String, @@ -368,6 +374,18 @@ impl Tool { self } + /// Set the runtime namespace for this external tool definition. + pub fn with_namespace(mut self, namespace: impl Into) -> Self { + self.namespace = Some(namespace.into()); + self + } + + /// Set the canvas contribution ID for canvas-scoped external tool definitions. + pub fn with_canvas_id(mut self, canvas_id: impl Into) -> Self { + self.canvas_id = Some(canvas_id.into()); + self + } + /// Set the human-readable description of what the tool does. pub fn with_description(mut self, description: impl Into) -> Self { self.description = description.into(); @@ -965,6 +983,10 @@ fn default_env_value_mode() -> String { "direct".into() } +/// Temporary host-provided extension registration payload for the runtime canvas POC. +/// +/// This mirrors the runtime stdio contract and should be replaced by the upstream +/// `github/copilot-sdk` generated type once protocol parity lands there. /// Configuration for creating a new session via the `session.create` RPC. /// /// All fields are optional — the CLI applies sensible defaults. @@ -1079,6 +1101,25 @@ pub struct SessionConfig { /// Defaults to `Some(true)` via [`SessionConfig::default`]. #[serde(skip_serializing_if = "Option::is_none")] pub request_elicitation: Option, + /// Renderer-side opt-in: when `true`, the runtime surfaces canvas + /// agent tools (`open_canvas`, `discover_canvases`, ...) to the model. + /// Default off — TUI / headless / SDK callers stay clean unless they can + /// actually display canvases. Independent of provider semantics, which + /// are declared via [`canvases`](Self::canvases). + #[serde(skip_serializing_if = "Option::is_none")] + pub request_canvas_renderer: Option, + /// Extension surface opt-in: when `true`, the runtime wires extension + /// management tools (`extensions_reload`, `extensions_manage`) and the + /// per-extension tool dispatch onto the session for this connection. + /// Default off — SDK callers that don't intend to expose the extension + /// surface stay clean. + /// + /// Requires the runtime to have the `EXTENSIONS` experimental feature + /// flag enabled (set via `GITHUB_COPILOT_EXPERIMENTAL_EXTENSIONS=true` + /// or the global Copilot config). If the flag is off the runtime + /// silently skips wiring even when this is `Some(true)`. + #[serde(skip_serializing_if = "Option::is_none")] + pub request_extensions: Option, /// Skill directory paths passed through to the GitHub Copilot CLI. #[serde(skip_serializing_if = "Option::is_none")] pub skill_directories: Option>, @@ -1165,6 +1206,14 @@ pub struct SessionConfig { /// associated [`CommandHandler`] is called when executed. #[serde(skip_serializing_if = "Option::is_none", skip_deserializing)] pub commands: Option>, + /// Canvas declarations. Each entry binds a + /// [`CanvasDeclaration`](crate::canvas::CanvasDeclaration) + + /// [`CanvasHandler`](crate::canvas::CanvasHandler) for this session; the + /// runtime treats the declaring connection as the live provider for every + /// declared canvas id. Serialized as an array of `CanvasDeclaration` on + /// the wire. + #[serde(default, skip_serializing_if = "Vec::is_empty", skip_deserializing)] + pub canvases: Vec, /// Custom session filesystem provider for this session. Required when /// the [`Client`](crate::Client) was started with /// [`ClientOptions::session_fs`](crate::ClientOptions::session_fs) set. @@ -1209,6 +1258,8 @@ impl std::fmt::Debug for SessionConfig { .field("request_exit_plan_mode", &self.request_exit_plan_mode) .field("request_auto_mode_switch", &self.request_auto_mode_switch) .field("request_elicitation", &self.request_elicitation) + .field("request_canvas_renderer", &self.request_canvas_renderer) + .field("request_extensions", &self.request_extensions) .field("skill_directories", &self.skill_directories) .field("instruction_directories", &self.instruction_directories) .field("disabled_skills", &self.disabled_skills) @@ -1233,6 +1284,7 @@ impl std::fmt::Debug for SessionConfig { &self.include_sub_agent_streaming_events, ) .field("commands", &self.commands) + .field("canvases", &self.canvases) .field( "session_fs_provider", &self.session_fs_provider.as_ref().map(|_| ""), @@ -1272,6 +1324,8 @@ impl Default for SessionConfig { request_exit_plan_mode: Some(true), request_auto_mode_switch: Some(true), request_elicitation: Some(true), + request_canvas_renderer: None, + request_extensions: None, skill_directories: None, instruction_directories: None, disabled_skills: None, @@ -1290,6 +1344,7 @@ impl Default for SessionConfig { cloud: None, include_sub_agent_streaming_events: None, commands: None, + canvases: Vec::new(), session_fs_provider: None, handler: None, hooks_handler: None, @@ -1492,6 +1547,20 @@ impl SessionConfig { self } + /// Renderer-side opt-in: surface canvas agent tools to the model. + pub fn with_request_canvas_renderer(mut self, enable: bool) -> Self { + self.request_canvas_renderer = Some(enable); + self + } + + /// Extension surface opt-in: wire extension management tools and per-extension + /// tool dispatch onto the session. Requires the runtime to have the + /// `EXTENSIONS` experimental feature flag enabled. + pub fn with_request_extensions(mut self, enable: bool) -> Self { + self.request_extensions = Some(enable); + self + } + /// Set skill directory paths passed through to the CLI. pub fn with_skill_directories(mut self, paths: I) -> Self where @@ -1681,6 +1750,14 @@ pub struct ResumeSessionConfig { /// Advertise elicitation provider capability on resume. #[serde(skip_serializing_if = "Option::is_none")] pub request_elicitation: Option, + /// Renderer-side opt-in on resume; see + /// [`SessionConfig::request_canvas_renderer`]. + #[serde(skip_serializing_if = "Option::is_none")] + pub request_canvas_renderer: Option, + /// Extension surface opt-in on resume; see + /// [`SessionConfig::request_extensions`]. + #[serde(skip_serializing_if = "Option::is_none")] + pub request_extensions: Option, /// Skill directory paths passed through to the GitHub Copilot CLI on resume. #[serde(skip_serializing_if = "Option::is_none")] pub skill_directories: Option>, @@ -1743,6 +1820,23 @@ pub struct ResumeSessionConfig { /// so the resume payload re-supplies the registration. #[serde(skip_serializing_if = "Option::is_none", skip_deserializing)] pub commands: Option>, + /// Canvas declarations to (re-)register on resume. Same semantics + /// as [`SessionConfig::canvases`]; re-declaring a canvas id replaces + /// the prior entry on the runtime side. + #[serde(default, skip_serializing_if = "Vec::is_empty", skip_deserializing)] + pub canvases: Vec, + /// Host-supplied list of canvas instances that should still be considered + /// open from a prior CLI process run, scoped to this session. The runtime + /// rebuilds its in-memory canvas-instance registry from these entries so + /// subsequent `invoke_canvas_action` dispatches succeed without the host + /// re-issuing `canvas.open`. Handler `on_open` is **not** re-invoked. + /// + /// Entries that fail to bind to a currently-declared canvas (extension + /// not loaded this session, or contribution removed) trigger a + /// `session.canvas.closed` event with `reason: "rehydrate_failed"` so the + /// host can clean up the stale panel. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub open_canvas_instances: Vec, /// Custom session filesystem provider. Required on resume when the /// [`Client`](crate::Client) was started with /// [`ClientOptions::session_fs`](crate::ClientOptions::session_fs). @@ -1791,6 +1885,8 @@ impl std::fmt::Debug for ResumeSessionConfig { .field("request_exit_plan_mode", &self.request_exit_plan_mode) .field("request_auto_mode_switch", &self.request_auto_mode_switch) .field("request_elicitation", &self.request_elicitation) + .field("request_canvas_renderer", &self.request_canvas_renderer) + .field("request_extensions", &self.request_extensions) .field("skill_directories", &self.skill_directories) .field("instruction_directories", &self.instruction_directories) .field("disabled_skills", &self.disabled_skills) @@ -1814,6 +1910,8 @@ impl std::fmt::Debug for ResumeSessionConfig { &self.include_sub_agent_streaming_events, ) .field("commands", &self.commands) + .field("canvases", &self.canvases) + .field("open_canvas_instances", &self.open_canvas_instances) .field( "session_fs_provider", &self.session_fs_provider.as_ref().map(|_| ""), @@ -1853,6 +1951,8 @@ impl ResumeSessionConfig { request_exit_plan_mode: Some(true), request_auto_mode_switch: Some(true), request_elicitation: Some(true), + request_canvas_renderer: None, + request_extensions: None, skill_directories: None, instruction_directories: None, disabled_skills: None, @@ -1870,6 +1970,8 @@ impl ResumeSessionConfig { remote_session: None, include_sub_agent_streaming_events: None, commands: None, + canvases: Vec::new(), + open_canvas_instances: Vec::new(), session_fs_provider: None, disable_resume: None, continue_pending_work: None, @@ -1906,6 +2008,20 @@ impl ResumeSessionConfig { self } + /// Supply the list of canvas instances the host still considers open + /// from a prior CLI process run. The runtime resolves each entry's + /// `(extension_id, canvas_id)` against the canvases declared on this + /// resume and rebuilds its in-memory instance registry, so subsequent + /// `invoke_canvas_action` dispatches succeed without re-issuing + /// `canvas.open`. Handler `on_open` is **not** re-invoked. + pub fn with_open_canvas_instances( + mut self, + instances: Vec, + ) -> Self { + self.open_canvas_instances = instances; + self + } + /// Install a [`SessionFsProvider`] backing the resumed session's /// filesystem. See [`SessionConfig::with_session_fs_provider`]. pub fn with_session_fs_provider(mut self, provider: Arc) -> Self { @@ -2044,6 +2160,19 @@ impl ResumeSessionConfig { self } + /// Renderer-side opt-in on resume: surface canvas agent tools to the model. + pub fn with_request_canvas_renderer(mut self, enable: bool) -> Self { + self.request_canvas_renderer = Some(enable); + self + } + + /// Extension surface opt-in on resume; see + /// [`SessionConfig::with_request_extensions`]. + pub fn with_request_extensions(mut self, enable: bool) -> Self { + self.request_extensions = Some(enable); + self + } + /// Set skill directory paths passed through to the CLI on resume. pub fn with_skill_directories(mut self, paths: I) -> Self where @@ -2834,6 +2963,15 @@ pub struct ToolInvocation { pub tool_call_id: String, /// Name of the tool being invoked. pub tool_name: String, + /// Extension ID for dispatcher-routed extension tools. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub extension_id: Option, + /// Optional namespace for the tool invocation. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub namespace: Option, + /// Canvas ID for dispatcher-routed canvas extension tools. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub canvas_id: Option, /// Tool arguments as JSON. pub arguments: Value, /// W3C Trace Context `traceparent` header propagated from the CLI's @@ -3194,6 +3332,12 @@ pub struct PermissionRequestData { /// to a specific tool invocation. #[serde(default, skip_serializing_if = "Option::is_none")] pub tool_call_id: Option, + /// Optional namespace for runtime-scoped tool permission requests. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub namespace: Option, + /// Optional canvas id for canvas-scoped permission requests. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub canvas_id: Option, /// The full permission request params from the CLI. The shape varies by /// permission type and CLI version, so we preserve it as `Value`. #[serde(flatten)] @@ -3242,9 +3386,10 @@ mod tests { use super::{ Attachment, AttachmentLineRange, AttachmentSelectionPosition, AttachmentSelectionRange, ConnectionState, CustomAgentConfig, DeliveryMode, GitHubReferenceType, - InfiniteSessionConfig, ProviderConfig, ResumeSessionConfig, SessionConfig, SessionEvent, - SessionId, SystemMessageConfig, Tool, ToolBinaryResult, ToolResult, ToolResultExpanded, - ToolResultResponse, ensure_attachment_display_names, + InfiniteSessionConfig, PermissionRequestData, ProviderConfig, ResumeSessionConfig, + SessionConfig, SessionEvent, SessionId, SystemMessageConfig, Tool, ToolBinaryResult, + ToolInvocation, ToolResult, ToolResultExpanded, ToolResultResponse, + ensure_attachment_display_names, }; use crate::generated::session_events::TypedSessionEvent; @@ -3253,6 +3398,8 @@ mod tests { let tool = Tool::new("greet") .with_description("Say hello") .with_namespaced_name("hello/greet") + .with_namespace("tools") + .with_canvas_id("markdown") .with_instructions("Pass the user's name") .with_parameters(json!({ "type": "object", @@ -3264,12 +3411,61 @@ mod tests { assert_eq!(tool.name, "greet"); assert_eq!(tool.description, "Say hello"); assert_eq!(tool.namespaced_name.as_deref(), Some("hello/greet")); + assert_eq!(tool.namespace.as_deref(), Some("tools")); + assert_eq!(tool.canvas_id.as_deref(), Some("markdown")); assert_eq!(tool.instructions.as_deref(), Some("Pass the user's name")); assert_eq!(tool.parameters.get("type").unwrap(), &json!("object")); assert!(tool.overrides_built_in_tool); assert!(tool.skip_permission); } + #[test] + fn external_tool_invocation_uses_namespace_field() { + let invocation: ToolInvocation = serde_json::from_value(json!({ + "sessionId": "session-1", + "toolCallId": "call-1", + "toolName": "increment", + "extensionId": "github.markdown", + "namespace": "canvas.tools", + "canvasId": "markdown", + "arguments": { "amount": 1 } + })) + .expect("deserialize invocation"); + + assert_eq!(invocation.extension_id.as_deref(), Some("github.markdown")); + assert_eq!(invocation.namespace.as_deref(), Some("canvas.tools")); + assert_eq!(invocation.canvas_id.as_deref(), Some("markdown")); + let value = serde_json::to_value(invocation).expect("serialize invocation"); + assert_eq!(value["extensionId"], "github.markdown"); + assert_eq!(value["namespace"], "canvas.tools"); + assert_eq!(value["canvasId"], "markdown"); + assert!(value.get("collection").is_none()); + } + + #[test] + fn permission_request_accepts_namespace_and_canvas_id() { + let request: PermissionRequestData = serde_json::from_value(json!({ + "kind": "custom-tool", + "toolCallId": "call-1", + "namespace": "canvas.hostActions", + "canvasId": "markdown", + "promptRequest": { + "kind": "custom-tool", + "toolName": "open_canvas" + } + })) + .expect("deserialize permission request"); + + assert_eq!(request.namespace.as_deref(), Some("canvas.hostActions")); + assert_eq!(request.canvas_id.as_deref(), Some("markdown")); + assert_eq!( + request.extra["promptRequest"]["toolName"].as_str(), + Some("open_canvas") + ); + assert!(request.extra.get("namespace").is_none()); + assert!(request.extra.get("canvasId").is_none()); + } + #[test] fn custom_agent_config_builder_with_model() { let agent = CustomAgentConfig::new("my-agent", "You are helpful.") diff --git a/rust/tests/api_types_test.rs b/rust/tests/api_types_test.rs index 2a373a3b5..aa2b751fe 100644 --- a/rust/tests/api_types_test.rs +++ b/rust/tests/api_types_test.rs @@ -95,5 +95,6 @@ fn running_extension(id: &str, name: &str) -> Extension { ExtensionSource::Project }, status: ExtensionStatus::Running, + unavailable_reason: None, } }