diff --git a/dotnet/src/Canvas.cs b/dotnet/src/Canvas.cs index b4e63f1b3..6bf8be984 100644 --- a/dotnet/src/Canvas.cs +++ b/dotnet/src/Canvas.cs @@ -57,6 +57,32 @@ public sealed class ExtensionInfo public string Name { get; set; } = string.Empty; } +/// +/// Stable identity for a host/SDK connection that supplies built-in canvases. +/// +/// +/// When set on session create or resume, the runtime uses +/// verbatim as the agent-facing canvas extension id, so canvases declared on a +/// control connection survive stdio reconnect and CLI process restart instead +/// of being re-keyed to a per-connection id. The id is opaque to the runtime; a +/// per-window-stable value such as app:builtin:<windowId> is +/// recommended. An id beginning with connection: is reserved and ignored +/// by the runtime. +/// +[Experimental(Diagnostics.Experimental)] +public sealed class CanvasProviderIdentity +{ + /// + /// Opaque, stable provider id used verbatim as the canvas extension id. + /// + [JsonPropertyName("id")] + public string Id { get; set; } = string.Empty; + + /// Optional display name surfaced as the canvas extension name. + [JsonPropertyName("name")] + public string? Name { get; set; } +} + /// Structured exception returned from canvas handlers. /// /// Throw this from implementations to surface a diff --git a/dotnet/src/Client.cs b/dotnet/src/Client.cs index 9fbe8c5a7..1136d2744 100644 --- a/dotnet/src/Client.cs +++ b/dotnet/src/Client.cs @@ -1031,6 +1031,7 @@ public async Task CreateSessionAsync(SessionConfig config, Cance RequestExtensions: config.RequestExtensions, ExtensionSdkPath: config.ExtensionSdkPath, ExtensionInfo: config.ExtensionInfo, + CanvasProvider: config.CanvasProvider, Providers: config.Providers, Models: config.Models, ToolFilterPrecedence: toolFilter.ToolFilterPrecedence, @@ -1241,6 +1242,7 @@ public async Task ResumeSessionAsync(string sessionId, ResumeSes RequestExtensions: config.RequestExtensions, ExtensionSdkPath: config.ExtensionSdkPath, ExtensionInfo: config.ExtensionInfo, + CanvasProvider: config.CanvasProvider, OpenCanvases: config.OpenCanvases, Providers: config.Providers, Models: config.Models, @@ -2483,6 +2485,7 @@ internal record CreateSessionRequest( bool? RequestExtensions = null, string? ExtensionSdkPath = null, ExtensionInfo? ExtensionInfo = null, + CanvasProviderIdentity? CanvasProvider = null, IList? Providers = null, IList? Models = null, OptionsUpdateToolFilterPrecedence? ToolFilterPrecedence = null, @@ -2578,6 +2581,7 @@ internal record ResumeSessionRequest( bool? RequestExtensions = null, string? ExtensionSdkPath = null, ExtensionInfo? ExtensionInfo = null, + CanvasProviderIdentity? CanvasProvider = null, IList? OpenCanvases = null, IList? Providers = null, IList? Models = null, diff --git a/dotnet/src/Types.cs b/dotnet/src/Types.cs index ecb277439..e6a66cc16 100644 --- a/dotnet/src/Types.cs +++ b/dotnet/src/Types.cs @@ -2810,6 +2810,7 @@ protected SessionConfigBase(SessionConfigBase? other) RequestExtensions = other.RequestExtensions; ExtensionSdkPath = other.ExtensionSdkPath; ExtensionInfo = other.ExtensionInfo; + CanvasProvider = other.CanvasProvider; CanvasHandler = other.CanvasHandler; #pragma warning restore GHCP001 SkillDirectories = other.SkillDirectories is not null ? [.. other.SkillDirectories] : null; @@ -3238,6 +3239,16 @@ protected SessionConfigBase(SessionConfigBase? other) [Experimental(Diagnostics.Experimental)] public ExtensionInfo? ExtensionInfo { get; set; } + /// + /// Stable identity for a host/SDK connection that supplies built-in + /// canvases. When set, the runtime uses + /// verbatim as the agent-facing canvas extension id, so canvases declared on + /// a control connection survive reconnect and CLI restart. Honored on + /// session create and resume. + /// + [Experimental(Diagnostics.Experimental)] + public CanvasProviderIdentity? CanvasProvider { get; set; } + /// /// Provider-side canvas lifecycle handler. The SDK routes inbound /// canvas.open / canvas.close / canvas.action.invoke @@ -3922,5 +3933,6 @@ public sealed class SystemMessageTransformRpcResponse [JsonSerializable(typeof(CanvasProviderOpenResult))] [JsonSerializable(typeof(CanvasHostContext))] [JsonSerializable(typeof(ExtensionInfo))] +[JsonSerializable(typeof(CanvasProviderIdentity))] #pragma warning restore GHCP001 internal partial class TypesJsonContext : JsonSerializerContext; diff --git a/dotnet/test/Unit/CanvasTests.cs b/dotnet/test/Unit/CanvasTests.cs index 612814d1f..1ad77d5bf 100644 --- a/dotnet/test/Unit/CanvasTests.cs +++ b/dotnet/test/Unit/CanvasTests.cs @@ -313,6 +313,28 @@ public void ExtensionInfo_Serializes_SourceAndName() Assert.Equal("demo", doc.RootElement.GetProperty("name").GetString()); } + [Fact] + public void CanvasProviderIdentity_Serializes_IdAndName() + { + var options = GetSerializerOptions(); + var identity = new CanvasProviderIdentity { Id = "app:builtin:window-1", Name = "Built-in" }; + var json = JsonSerializer.Serialize(identity, options); + using var doc = JsonDocument.Parse(json); + Assert.Equal("app:builtin:window-1", doc.RootElement.GetProperty("id").GetString()); + Assert.Equal("Built-in", doc.RootElement.GetProperty("name").GetString()); + } + + [Fact] + public void CanvasProviderIdentity_OmitsNullName() + { + var options = GetSerializerOptions(); + var identity = new CanvasProviderIdentity { Id = "app:builtin:window-1" }; + var json = JsonSerializer.Serialize(identity, options); + using var doc = JsonDocument.Parse(json); + Assert.Equal("app:builtin:window-1", doc.RootElement.GetProperty("id").GetString()); + Assert.False(doc.RootElement.TryGetProperty("name", out _)); + } + [Fact] public async Task CanvasHandlerBase_DefaultOnClose_Completes() { @@ -348,6 +370,7 @@ public void SessionConfig_Clone_CopiesCanvasFields() RequestCanvasRenderer = true, RequestExtensions = true, ExtensionInfo = new ExtensionInfo { Source = "github-app", Name = "demo" }, + CanvasProvider = new CanvasProviderIdentity { Id = "app:builtin:window-1", Name = "Built-in" }, CanvasHandler = handler }; @@ -360,6 +383,8 @@ public void SessionConfig_Clone_CopiesCanvasFields() Assert.True(clone.RequestExtensions); Assert.NotNull(clone.ExtensionInfo); Assert.Equal("github-app", clone.ExtensionInfo!.Source); + Assert.NotNull(clone.CanvasProvider); + Assert.Equal("app:builtin:window-1", clone.CanvasProvider!.Id); Assert.Same(handler, clone.CanvasHandler); // Mutating the clone's list does not affect the original. @@ -376,6 +401,7 @@ public void ResumeSessionConfig_Clone_CopiesCanvasFields() Canvases = new[] { new CanvasDeclaration { Id = "c1", DisplayName = "C", Description = "d" } }, RequestCanvasRenderer = true, ExtensionInfo = new ExtensionInfo { Source = "s", Name = "n" }, + CanvasProvider = new CanvasProviderIdentity { Id = "app:builtin:window-2" }, CanvasHandler = handler }; @@ -385,6 +411,8 @@ public void ResumeSessionConfig_Clone_CopiesCanvasFields() Assert.Single(clone.Canvases!); Assert.True(clone.RequestCanvasRenderer); Assert.NotNull(clone.ExtensionInfo); + Assert.NotNull(clone.CanvasProvider); + Assert.Equal("app:builtin:window-2", clone.CanvasProvider!.Id); Assert.Same(handler, clone.CanvasHandler); } diff --git a/go/canvas.go b/go/canvas.go index 375f7c6ee..e31598bb1 100644 --- a/go/canvas.go +++ b/go/canvas.go @@ -42,6 +42,24 @@ type ExtensionInfo struct { Name string `json:"name"` } +// CanvasProviderIdentity is the stable identity for a host/SDK connection +// that supplies built-in canvases. +// +// When set on session create or resume, the runtime uses ID verbatim as the +// agent-facing canvas extension id, so host-provided canvases survive +// reconnect and CLI restart. +// +// Experimental: CanvasProviderIdentity is part of an experimental +// wire-protocol surface and may change or be removed in future SDK or CLI +// releases. +type CanvasProviderIdentity struct { + // ID is an opaque, stable provider id used verbatim as the canvas + // extension id. + ID string `json:"id"` + // Name is an optional display name surfaced as the canvas extension name. + Name *string `json:"name,omitempty"` +} + // CanvasError is a structured error returned from canvas handlers. // // Wire envelope: diff --git a/go/client.go b/go/client.go index 9e2819047..2049a6169 100644 --- a/go/client.go +++ b/go/client.go @@ -724,6 +724,8 @@ func (c *Client) CreateSession(ctx context.Context, config *SessionConfig) (*Ses req.RemoteSession = config.RemoteSession req.Cloud = config.Cloud req.Canvases = config.Canvases + req.ExtensionInfo = config.ExtensionInfo + req.CanvasProvider = config.CanvasProvider req.RequestCanvasRenderer = config.RequestCanvasRenderer req.RequestExtensions = config.RequestExtensions req.ExtensionSDKPath = config.ExtensionSDKPath @@ -1077,6 +1079,8 @@ func (c *Client) ResumeSessionWithOptions(ctx context.Context, sessionID string, req.RemoteSession = config.RemoteSession req.Canvases = config.Canvases req.OpenCanvases = config.OpenCanvases + req.ExtensionInfo = config.ExtensionInfo + req.CanvasProvider = config.CanvasProvider req.RequestCanvasRenderer = config.RequestCanvasRenderer req.RequestExtensions = config.RequestExtensions req.ExtensionSDKPath = config.ExtensionSDKPath diff --git a/go/client_test.go b/go/client_test.go index c889ced8d..7fa3ee87e 100644 --- a/go/client_test.go +++ b/go/client_test.go @@ -250,6 +250,82 @@ func TestClient_ForwardsCapiOptionsToSessionRequests(t *testing.T) { assertCapiEnableWebSocketResponses(t, <-resumeParams) } +func TestClient_ForwardsCanvasProviderToSessionRequests(t *testing.T) { + rpcClient, server, _ := newRuntimeShutdownRpcPair(t) + t.Cleanup(server.Stop) + client := &Client{ + client: rpcClient, + RPC: rpc.NewServerRPC(rpcClient), + sessions: make(map[string]*Session), + } + + createParams := make(chan json.RawMessage, 1) + server.SetRequestHandler("session.create", func(params json.RawMessage) (json.RawMessage, *jsonrpc2.Error) { + createParams <- append(json.RawMessage(nil), params...) + sessionID := sessionIDFromParams(t, params) + return []byte(`{"sessionId":"` + sessionID + `","workspacePath":"/workspace"}`), nil + }) + + _, err := client.CreateSession(t.Context(), &SessionConfig{ + ExtensionInfo: &ExtensionInfo{Source: "github-app", Name: "counter-provider"}, + CanvasProvider: &CanvasProviderIdentity{ID: "app:builtin:window-1", Name: String("Built-in")}, + }) + if err != nil { + t.Fatalf("CreateSession failed: %v", err) + } + assertCanvasProviderForwarded(t, <-createParams, "app:builtin:window-1", "Built-in", "counter-provider") + + resumeParams := make(chan json.RawMessage, 1) + server.SetRequestHandler("session.resume", func(params json.RawMessage) (json.RawMessage, *jsonrpc2.Error) { + resumeParams <- append(json.RawMessage(nil), params...) + return []byte(`{"sessionId":"resumed-canvas","workspacePath":"/workspace"}`), nil + }) + + _, err = client.ResumeSessionWithOptions(t.Context(), "resumed-canvas", &ResumeSessionConfig{ + CanvasProvider: &CanvasProviderIdentity{ID: "app:builtin:window-1"}, + }) + if err != nil { + t.Fatalf("ResumeSessionWithOptions failed: %v", err) + } + assertCanvasProviderForwarded(t, <-resumeParams, "app:builtin:window-1", "", "") +} + +// assertCanvasProviderForwarded checks the outbound params carry canvasProvider +// with the expected id. A non-empty wantName asserts the name is present; an +// empty wantName asserts the name key is omitted from the wire. A non-empty +// wantExtensionName asserts extensionInfo.name is forwarded alongside it. +func assertCanvasProviderForwarded(t *testing.T, params json.RawMessage, wantID, wantName, wantExtensionName string) { + t.Helper() + + var decoded map[string]any + if err := json.Unmarshal(params, &decoded); err != nil { + t.Fatalf("failed to unmarshal request params: %v", err) + } + provider, ok := decoded["canvasProvider"].(map[string]any) + if !ok { + t.Fatalf("expected canvasProvider object in request params, got %T", decoded["canvasProvider"]) + } + if provider["id"] != wantID { + t.Fatalf("expected canvasProvider.id=%q, got %v", wantID, provider["id"]) + } + if wantName == "" { + if _, present := provider["name"]; present { + t.Fatalf("expected canvasProvider.name to be omitted, got %v", provider["name"]) + } + } else if provider["name"] != wantName { + t.Fatalf("expected canvasProvider.name=%q, got %v", wantName, provider["name"]) + } + if wantExtensionName != "" { + info, ok := decoded["extensionInfo"].(map[string]any) + if !ok { + t.Fatalf("expected extensionInfo object in request params, got %T", decoded["extensionInfo"]) + } + if info["name"] != wantExtensionName { + t.Fatalf("expected extensionInfo.name=%q, got %v", wantExtensionName, info["name"]) + } + } +} + func assertCapiEnableWebSocketResponses(t *testing.T, params json.RawMessage) { t.Helper() diff --git a/go/types.go b/go/types.go index ffb8af12a..76a1c9162 100644 --- a/go/types.go +++ b/go/types.go @@ -1215,6 +1215,9 @@ type SessionConfig struct { CanvasHandler CanvasHandler `json:"-"` // ExtensionInfo identifies the stable extension providing this session's canvases. ExtensionInfo *ExtensionInfo + // CanvasProvider is the stable identity for a host/SDK connection that + // supplies built-in canvases, so they survive reconnect and CLI restart. + CanvasProvider *CanvasProviderIdentity // ExpAssignments injects ExP assignment ("flight") data for this session, // in the same JSON shape the Copilot CLI fetches from the experimentation // service (CopilotExpAssignmentResponse). When supplied, the runtime feeds @@ -1625,6 +1628,9 @@ type ResumeSessionConfig struct { CanvasHandler CanvasHandler `json:"-"` // ExtensionInfo identifies the stable extension providing this session's canvases. ExtensionInfo *ExtensionInfo + // CanvasProvider is the stable identity for a host/SDK connection that + // supplies built-in canvases. See SessionConfig.CanvasProvider. + CanvasProvider *CanvasProviderIdentity // ExpAssignments injects ExP assignment ("flight") data on resume. See // SessionConfig.ExpAssignments. Re-supply on resume so the runtime // re-applies the assignments after a CLI process restart. @@ -2082,6 +2088,7 @@ type createSessionRequest struct { RequestExtensions *bool `json:"requestExtensions,omitempty"` ExtensionSDKPath *string `json:"extensionSdkPath,omitempty"` ExtensionInfo *ExtensionInfo `json:"extensionInfo,omitempty"` + CanvasProvider *CanvasProviderIdentity `json:"canvasProvider,omitempty"` ExpAssignments any `json:"expAssignments,omitempty"` Traceparent string `json:"traceparent,omitempty"` Tracestate string `json:"tracestate,omitempty"` @@ -2167,6 +2174,7 @@ type resumeSessionRequest struct { RequestExtensions *bool `json:"requestExtensions,omitempty"` ExtensionSDKPath *string `json:"extensionSdkPath,omitempty"` ExtensionInfo *ExtensionInfo `json:"extensionInfo,omitempty"` + CanvasProvider *CanvasProviderIdentity `json:"canvasProvider,omitempty"` ExpAssignments any `json:"expAssignments,omitempty"` Traceparent string `json:"traceparent,omitempty"` Tracestate string `json:"tracestate,omitempty"` diff --git a/nodejs/src/client.ts b/nodejs/src/client.ts index 613985103..f0f988a5a 100644 --- a/nodejs/src/client.ts +++ b/nodejs/src/client.ts @@ -1398,6 +1398,7 @@ export class CopilotClient { requestExtensions: config.requestExtensions, extensionSdkPath: config.extensionSdkPath, extensionInfo: config.extensionInfo, + canvasProvider: config.canvasProvider, commands: config.commands?.map((cmd) => ({ name: cmd.name, description: cmd.description, @@ -1611,6 +1612,7 @@ export class CopilotClient { requestExtensions: config.requestExtensions, extensionSdkPath: config.extensionSdkPath, extensionInfo: config.extensionInfo, + canvasProvider: config.canvasProvider, commands: config.commands?.map((cmd) => ({ name: cmd.name, description: cmd.description, diff --git a/nodejs/src/index.ts b/nodejs/src/index.ts index eebf9add5..a18a2a834 100644 --- a/nodejs/src/index.ts +++ b/nodejs/src/index.ts @@ -51,6 +51,7 @@ export type { CommandContext, CommandDefinition, CommandHandler, + CanvasProviderIdentity, CloudSessionOptions, CloudSessionRepository, AutoModeSwitchHandler, diff --git a/nodejs/src/types.ts b/nodejs/src/types.ts index 4adb35b25..47add232c 100644 --- a/nodejs/src/types.ts +++ b/nodejs/src/types.ts @@ -1695,6 +1695,23 @@ export interface ExtensionInfo { name: string; } +/** + * Stable identity for a host/SDK connection that supplies built-in canvases. + * + * When set on session create or resume, the runtime uses {@link id} verbatim + * as the agent-facing canvas extension id, so canvases declared on a control + * connection survive stdio reconnect and CLI process restart instead of being + * re-keyed to a per-connection id. The id is opaque to the runtime; a + * per-window-stable value such as `app:builtin:` is recommended. An + * id beginning with `connection:` is reserved and ignored by the runtime. + */ +export interface CanvasProviderIdentity { + /** Opaque, stable provider id used verbatim as the canvas extension id. */ + id: string; + /** Optional display name surfaced as the canvas extension name. */ + name?: string; +} + /** * Provider-scoped options for the Copilot API (CAPI). * @@ -1839,6 +1856,14 @@ export interface SessionConfigBase { */ extensionInfo?: ExtensionInfo; + /** + * Stable identity for a host/SDK connection that supplies built-in + * canvases. When set, the runtime uses `id` verbatim as the agent-facing + * canvas extension id, so canvases declared on a control connection survive + * reconnect and CLI restart. Honored on session create and resume. + */ + canvasProvider?: CanvasProviderIdentity; + /** * Slash commands registered for this session. * When the CLI has a TUI, each command appears as `/name` for the user to invoke. diff --git a/nodejs/test/client.test.ts b/nodejs/test/client.test.ts index d02bafbbf..5fcee21b9 100644 --- a/nodejs/test/client.test.ts +++ b/nodejs/test/client.test.ts @@ -289,6 +289,7 @@ describe("CopilotClient", () => { requestCanvasRenderer: true, requestExtensions: true, extensionInfo: { source: "github-app", name: "counter-provider" }, + canvasProvider: { id: "app:builtin:window-1", name: "Built-in" }, }); const payload = spy.mock.calls.find(([method]) => method === "session.create")![1] as any; @@ -306,6 +307,10 @@ describe("CopilotClient", () => { source: "github-app", name: "counter-provider", }); + expect(payload.canvasProvider).toEqual({ + id: "app:builtin:window-1", + name: "Built-in", + }); }); it("forwards canvas declarations in session.resume", async () => { @@ -333,6 +338,7 @@ describe("CopilotClient", () => { requestCanvasRenderer: true, requestExtensions: true, extensionInfo: { source: "github-app", name: "counter-provider" }, + canvasProvider: { id: "app:builtin:window-1" }, }); const payload = spy.mock.calls.find(([method]) => method === "session.resume")![1] as any; @@ -343,6 +349,7 @@ describe("CopilotClient", () => { source: "github-app", name: "counter-provider", }); + expect(payload.canvasProvider).toEqual({ id: "app:builtin:window-1" }); expect(payload.openCanvasInstances).toBeUndefined(); }); diff --git a/python/copilot/__init__.py b/python/copilot/__init__.py index 51be3727a..29ba105b1 100644 --- a/python/copilot/__init__.py +++ b/python/copilot/__init__.py @@ -24,6 +24,7 @@ CanvasHostContext, CanvasHostContextCapabilities, CanvasJsonSchema, + CanvasProviderIdentity, ExtensionInfo, OpenCanvasInstance, ) @@ -196,6 +197,7 @@ "CanvasHostContext", "CanvasHostContextCapabilities", "CanvasJsonSchema", + "CanvasProviderIdentity", "CapiSessionOptions", "ChildProcessRuntimeConnection", "CloudSessionOptions", diff --git a/python/copilot/canvas.py b/python/copilot/canvas.py index ddbc8539a..9b8dec525 100644 --- a/python/copilot/canvas.py +++ b/python/copilot/canvas.py @@ -39,6 +39,7 @@ "CanvasHostContext", "CanvasHostContextCapabilities", "CanvasJsonSchema", + "CanvasProviderIdentity", "ExtensionInfo", "OpenCanvasInstance", ] @@ -66,6 +67,33 @@ def to_dict(self) -> dict[str, Any]: return {"source": self.source, "name": self.name} +@dataclass +class CanvasProviderIdentity: + """Stable identity for a host/SDK connection that supplies built-in canvases. + + Lets a host advertise a stable canvas-provider extension id so host-provided + canvases restore across a cold session resume. Serializes to + ``{"id": ...}`` (with an optional ``"name"``) on the wire. + + .. note:: + + **Experimental.** This type is part of an experimental wire-protocol + surface and may change or be removed in future SDK or CLI releases. + """ + + id: str + """Stable provider identifier, e.g. ``"app:builtin:window-1"``.""" + + name: str | None = None + """Optional human-readable provider name.""" + + def to_dict(self) -> dict[str, Any]: + result: dict[str, Any] = {"id": self.id} + if self.name is not None: + result["name"] = self.name + return result + + @dataclass class CanvasDeclaration: """Declarative metadata for a single canvas, sent on create/resume. diff --git a/python/copilot/client.py b/python/copilot/client.py index 7dade4440..ff259991c 100644 --- a/python/copilot/client.py +++ b/python/copilot/client.py @@ -58,6 +58,7 @@ from .canvas import ( CanvasDeclaration, CanvasHandler, + CanvasProviderIdentity, ExtensionInfo, ) from .copilot_request_handler import CopilotRequestHandler, create_copilot_request_adapter @@ -1711,6 +1712,7 @@ async def create_session( request_extensions: bool | None = None, extension_sdk_path: str | None = None, extension_info: ExtensionInfo | None = None, + canvas_provider: CanvasProviderIdentity | None = None, canvas_handler: CanvasHandler | None = None, exp_assignments: dict[str, Any] | None = None, ) -> CopilotSession: @@ -2098,6 +2100,8 @@ async def create_session( payload["extensionSdkPath"] = extension_sdk_path if extension_info is not None: payload["extensionInfo"] = extension_info.to_dict() + if canvas_provider is not None: + payload["canvasProvider"] = canvas_provider.to_dict() if not self._client: raise RuntimeError("Client not connected") @@ -2340,6 +2344,7 @@ async def resume_session( request_extensions: bool | None = None, extension_sdk_path: str | None = None, extension_info: ExtensionInfo | None = None, + canvas_provider: CanvasProviderIdentity | None = None, canvas_handler: CanvasHandler | None = None, open_canvases: list[OpenCanvasInstance] | None = None, exp_assignments: dict[str, Any] | None = None, @@ -2699,6 +2704,8 @@ async def resume_session( payload["extensionSdkPath"] = extension_sdk_path if extension_info is not None: payload["extensionInfo"] = extension_info.to_dict() + if canvas_provider is not None: + payload["canvasProvider"] = canvas_provider.to_dict() if not self._client: raise RuntimeError("Client not connected") diff --git a/python/test_canvas.py b/python/test_canvas.py index 8bcb79a23..684cef6b7 100644 --- a/python/test_canvas.py +++ b/python/test_canvas.py @@ -14,6 +14,7 @@ CanvasDeclaration, CanvasError, CanvasHandler, + CanvasProviderIdentity, ExtensionInfo, OpenCanvasInstance, ) @@ -67,6 +68,16 @@ def test_extension_info_serializes(): assert info.to_dict() == {"source": "github-app", "name": "my-ext"} +def test_canvas_provider_identity_serializes(): + provider = CanvasProviderIdentity(id="app:builtin:window-1", name="Built-in") + assert provider.to_dict() == {"id": "app:builtin:window-1", "name": "Built-in"} + + +def test_canvas_provider_identity_drops_optional_name(): + provider = CanvasProviderIdentity(id="app:builtin:window-1") + assert provider.to_dict() == {"id": "app:builtin:window-1"} + + def test_canvas_open_response_drops_none_fields(): assert CanvasProviderOpenResult().to_dict() == {} assert CanvasProviderOpenResult(url="https://x", status="ok").to_dict() == { diff --git a/python/test_client.py b/python/test_client.py index db6703c3b..710e27c10 100644 --- a/python/test_client.py +++ b/python/test_client.py @@ -11,8 +11,10 @@ import pytest from copilot import ( + CanvasProviderIdentity, CapiSessionOptions, CopilotClient, + ExtensionInfo, ModelBillingTokenPrices, ModelBillingTokenPricesLongContext, RuntimeConnection, @@ -556,6 +558,49 @@ async def mock_request(method, params, **kwargs): finally: await client.force_stop() + @pytest.mark.asyncio + async def test_create_and_resume_session_forward_canvas_provider(self): + client = CopilotClient(connection=RuntimeConnection.for_stdio(path=CLI_PATH)) + await client.start() + try: + captured = {} + + async def mock_request(method, params, **kwargs): + captured[method] = params + if method in ("session.create", "session.resume"): + result = {"sessionId": params.get("sessionId") or "session-1"} + callback = kwargs.get("on_response_inline") + if callback is not None: + callback(result) + return result + return {} + + client._client.request = mock_request + session = await client.create_session( + on_permission_request=PermissionHandler.approve_all, + extension_info=ExtensionInfo(source="github-app", name="counter"), + canvas_provider=CanvasProviderIdentity(id="app:builtin:window-1", name="Built-in"), + ) + await client.resume_session( + session.session_id, + on_permission_request=PermissionHandler.approve_all, + canvas_provider=CanvasProviderIdentity(id="app:builtin:window-1"), + ) + + assert captured["session.create"]["canvasProvider"] == { + "id": "app:builtin:window-1", + "name": "Built-in", + } + assert captured["session.create"]["extensionInfo"] == { + "source": "github-app", + "name": "counter", + } + assert captured["session.resume"]["canvasProvider"] == { + "id": "app:builtin:window-1", + } + finally: + await client.force_stop() + @pytest.mark.asyncio async def test_create_and_resume_session_forward_capi_options(self): client = CopilotClient(connection=RuntimeConnection.for_stdio(path=CLI_PATH)) diff --git a/rust/src/types.rs b/rust/src/types.rs index 895c2f710..93650fc43 100644 --- a/rust/src/types.rs +++ b/rust/src/types.rs @@ -916,6 +916,42 @@ impl ExtensionInfo { } } +/// Stable identity for a host/SDK connection that supplies built-in canvases. +/// +/// When set on session create or resume, the runtime uses [`id`] verbatim as +/// the agent-facing canvas extension id, so canvases declared on a control +/// connection survive stdio reconnect and CLI process restart instead of being +/// re-keyed to a per-connection id. The id is opaque to the runtime; a +/// per-window-stable value such as `app:builtin:` is recommended. An +/// id beginning with `connection:` is reserved and ignored by the runtime. +/// +/// [`id`]: CanvasProviderIdentity::id +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct CanvasProviderIdentity { + /// Opaque, stable provider id used verbatim as the canvas extension id. + pub id: String, + /// Optional display name surfaced as the canvas extension name. + #[serde(skip_serializing_if = "Option::is_none")] + pub name: Option, +} + +impl CanvasProviderIdentity { + /// Create a canvas provider identity from a stable opaque id. + pub fn new(id: impl Into) -> Self { + Self { + id: id.into(), + name: None, + } + } + + /// Set the optional display name surfaced as the canvas extension name. + pub fn with_name(mut self, name: impl Into) -> Self { + self.name = Some(name.into()); + self + } +} + /// Configuration for a single MCP server. /// /// MCP (Model Context Protocol) servers expose external tools to the @@ -1598,6 +1634,9 @@ pub struct SessionConfig { pub extension_sdk_path: Option, /// Stable extension identity for canvas/tool providers on this connection. pub extension_info: Option, + /// Stable identity for a host/SDK connection that supplies built-in + /// canvases, so they survive reconnect and CLI restart. + pub canvas_provider: Option, /// Allowlist of built-in tool names the agent may use. pub available_tools: Option>, /// Blocklist of built-in tool names the agent must not use. @@ -1837,6 +1876,7 @@ impl std::fmt::Debug for SessionConfig { .field("request_extensions", &self.request_extensions) .field("extension_sdk_path", &self.extension_sdk_path) .field("extension_info", &self.extension_info) + .field("canvas_provider", &self.canvas_provider) .field("available_tools", &self.available_tools) .field("excluded_tools", &self.excluded_tools) .field("mcp_servers", &self.mcp_servers) @@ -1955,6 +1995,7 @@ impl Default for SessionConfig { request_extensions: None, extension_sdk_path: None, extension_info: None, + canvas_provider: None, available_tools: None, excluded_tools: None, mcp_servers: None, @@ -2100,6 +2141,7 @@ impl SessionConfig { request_extensions: self.request_extensions, extension_sdk_path: self.extension_sdk_path, extension_info: self.extension_info, + canvas_provider: self.canvas_provider, available_tools: self.available_tools, excluded_tools: self.excluded_tools, tool_filter_precedence: "excluded", @@ -2370,6 +2412,13 @@ impl SessionConfig { self } + /// Set the canvas provider identity for this connection so host-supplied + /// canvases survive reconnect and CLI restart. + pub fn with_canvas_provider(mut self, canvas_provider: CanvasProviderIdentity) -> Self { + self.canvas_provider = Some(canvas_provider); + self + } + /// Set the allowlist of built-in tool names the agent may use. pub fn with_available_tools(mut self, tools: I) -> Self where @@ -2737,6 +2786,9 @@ pub struct ResumeSessionConfig { pub extension_sdk_path: Option, /// Stable extension identity for canvas/tool providers on this connection. pub extension_info: Option, + /// Stable identity for a host/SDK connection that supplies built-in + /// canvases, so they rehydrate against a stable extension id on resume. + pub canvas_provider: Option, /// Allowlist of tool names the agent may use. pub available_tools: Option>, /// Blocklist of built-in tool names. @@ -2915,6 +2967,7 @@ impl std::fmt::Debug for ResumeSessionConfig { .field("request_extensions", &self.request_extensions) .field("extension_sdk_path", &self.extension_sdk_path) .field("extension_info", &self.extension_info) + .field("canvas_provider", &self.canvas_provider) .field("available_tools", &self.available_tools) .field("excluded_tools", &self.excluded_tools) .field("mcp_servers", &self.mcp_servers) @@ -3068,6 +3121,7 @@ impl ResumeSessionConfig { request_extensions: self.request_extensions, extension_sdk_path: self.extension_sdk_path, extension_info: self.extension_info, + canvas_provider: self.canvas_provider, available_tools: self.available_tools, excluded_tools: self.excluded_tools, tool_filter_precedence: "excluded", @@ -3158,6 +3212,7 @@ impl ResumeSessionConfig { request_extensions: None, extension_sdk_path: None, extension_info: None, + canvas_provider: None, available_tools: None, excluded_tools: None, mcp_servers: None, @@ -3400,6 +3455,13 @@ impl ResumeSessionConfig { self } + /// Set the canvas provider identity for this connection on resume so + /// host-supplied canvases rehydrate against a stable extension id. + pub fn with_canvas_provider(mut self, canvas_provider: CanvasProviderIdentity) -> Self { + self.canvas_provider = Some(canvas_provider); + self + } + /// Set the allowlist of tool names the agent may use. pub fn with_available_tools(mut self, tools: I) -> Self where diff --git a/rust/src/wire.rs b/rust/src/wire.rs index e6dad66d5..260262d6c 100644 --- a/rust/src/wire.rs +++ b/rust/src/wire.rs @@ -24,9 +24,10 @@ use crate::generated::api_types::{ }; use crate::generated::session_events::ReasoningSummary; use crate::types::{ - CapiSessionOptions, CloudSessionOptions, CustomAgentConfig, DefaultAgentConfig, ExtensionInfo, - InfiniteSessionConfig, LargeToolOutputConfig, McpServerConfig, MemoryConfiguration, - NamedProviderConfig, ProviderConfig, ProviderModelConfig, SessionId, SystemMessageConfig, Tool, + CanvasProviderIdentity, CapiSessionOptions, CloudSessionOptions, CustomAgentConfig, + DefaultAgentConfig, ExtensionInfo, InfiniteSessionConfig, LargeToolOutputConfig, + McpServerConfig, MemoryConfiguration, NamedProviderConfig, ProviderConfig, ProviderModelConfig, + SessionId, SystemMessageConfig, Tool, }; /// Wire representation of a slash command (name + description only). The @@ -73,6 +74,8 @@ pub(crate) struct SessionCreateWire { #[serde(skip_serializing_if = "Option::is_none")] pub extension_info: Option, #[serde(skip_serializing_if = "Option::is_none")] + pub canvas_provider: Option, + #[serde(skip_serializing_if = "Option::is_none")] pub available_tools: Option>, #[serde(skip_serializing_if = "Option::is_none")] pub excluded_tools: Option>, @@ -191,6 +194,8 @@ pub(crate) struct SessionResumeWire { #[serde(skip_serializing_if = "Option::is_none")] pub extension_info: Option, #[serde(skip_serializing_if = "Option::is_none")] + pub canvas_provider: Option, + #[serde(skip_serializing_if = "Option::is_none")] pub available_tools: Option>, #[serde(skip_serializing_if = "Option::is_none")] pub excluded_tools: Option>, diff --git a/rust/tests/session_test.rs b/rust/tests/session_test.rs index 31b0cc233..10321296a 100644 --- a/rust/tests/session_test.rs +++ b/rust/tests/session_test.rs @@ -18,10 +18,10 @@ use github_copilot_sdk::rpc::{ }; use github_copilot_sdk::session_events::{McpOauthRequiredData, ReasoningSummary}; use github_copilot_sdk::types::{ - CloudSessionOptions, CloudSessionRepository, CommandContext, CommandDefinition, CommandHandler, - DeliveryMode, ElicitationRequest, ElicitationResult, ExitPlanModeData, ExtensionInfo, - MessageOptions, RequestId, SessionConfig, SessionId, SetModelOptions, Tool, ToolInvocation, - ToolResult, + CanvasProviderIdentity, CloudSessionOptions, CloudSessionRepository, CommandContext, + CommandDefinition, CommandHandler, DeliveryMode, ElicitationRequest, ElicitationResult, + ExitPlanModeData, ExtensionInfo, MessageOptions, RequestId, SessionConfig, SessionId, + SetModelOptions, Tool, ToolInvocation, ToolResult, }; use github_copilot_sdk::{Client, ContextTier, tool}; use serde_json::Value; @@ -637,7 +637,11 @@ async fn create_session_sends_canvas_wire_fields() { .with_canvases([test_canvas("counter")]) .with_request_canvas_renderer(true) .with_request_extensions(true) - .with_extension_info(ExtensionInfo::new("github-app", "counter-provider")), + .with_extension_info(ExtensionInfo::new("github-app", "counter-provider")) + .with_canvas_provider( + CanvasProviderIdentity::new("app:builtin:window-1") + .with_name("Built-in"), + ), ) .await .unwrap() @@ -658,6 +662,11 @@ async fn create_session_sends_canvas_wire_fields() { request["params"]["extensionInfo"]["name"], "counter-provider" ); + assert_eq!( + request["params"]["canvasProvider"]["id"], + "app:builtin:window-1" + ); + assert_eq!(request["params"]["canvasProvider"]["name"], "Built-in"); let id = request["id"].as_u64().unwrap(); let session_id = requested_session_id(&request).to_string(); @@ -2912,6 +2921,7 @@ async fn resume_session_sends_canvas_fields_and_captures_open_canvases() { .with_request_canvas_renderer(true) .with_request_extensions(true) .with_extension_info(ExtensionInfo::new("github-app", "counter-provider")) + .with_canvas_provider(CanvasProviderIdentity::new("app:builtin:window-1")) .with_open_canvases([OpenCanvasInstance { instance_id: "counter-1".to_string(), extension_id: "github-app:counter-provider".to_string(), @@ -2936,6 +2946,14 @@ async fn resume_session_sends_canvas_fields_and_captures_open_canvases() { request["params"]["extensionInfo"]["name"], "counter-provider" ); + assert_eq!( + request["params"]["canvasProvider"]["id"], + "app:builtin:window-1" + ); + assert!( + request["params"]["canvasProvider"].get("name").is_none(), + "name should be omitted from the wire when None, not serialized as null" + ); assert_eq!( request["params"]["openCanvases"][0]["instanceId"], "counter-1"