diff --git a/dotnet/src/Canvas.cs b/dotnet/src/Canvas.cs index 69514edda..0a54f9edc 100644 --- a/dotnet/src/Canvas.cs +++ b/dotnet/src/Canvas.cs @@ -99,7 +99,11 @@ public static LocalRpcInvocationException HandlerError(string message) => Build( "canvas_handler_error", message); - public static LocalRpcInvocationException ToRpcException(CanvasError error) => Build(error.Code, error.Message); + // Code is prefixed into the message because RemoteRpcException does not currently + // surface the JSON-RPC error.data payload to callers, so the structured code (e.g. + // "canvas_action_no_handler") would otherwise be unobservable on the receiving side. + // TODO: plumb error.data through RemoteRpcException and drop the prefix here. + public static LocalRpcInvocationException ToRpcException(CanvasError error) => Build(error.Code, $"{error.Code}: {error.Message}"); private static LocalRpcInvocationException Build(string code, string message) { diff --git a/dotnet/test/E2E/CanvasE2ETests.cs b/dotnet/test/E2E/CanvasE2ETests.cs index a4e60479d..d451e4609 100644 --- a/dotnet/test/E2E/CanvasE2ETests.cs +++ b/dotnet/test/E2E/CanvasE2ETests.cs @@ -3,252 +3,193 @@ *--------------------------------------------------------------------------------------------*/ using GitHub.Copilot.Rpc; -using System.Collections.Generic; -using System.Linq; -using System.Text.Json; -using System.Threading; -using System.Threading.Tasks; +using GitHub.Copilot.Test.Harness; using Xunit; using Xunit.Abstractions; namespace GitHub.Copilot.Test.E2E; -public class CanvasE2ETests(E2ETestFixture fixture, ITestOutputHelper output) - : E2ETestBase(fixture, "canvas", output) +public class CanvasE2ETests(E2ETestFixture fixture, ITestOutputHelper output) : E2ETestBase(fixture, "canvas", output) { [Fact] - public async Task Should_Discover_Canvas_Via_List() + public async Task DispatchesCanvasOpenToProviderHandler() { - var handler = new TestCanvasHandler(); - await using var session = await CreateCanvasSessionAsync(handler); - - var result = await session.Rpc.Canvas.ListAsync(); - - var canvas = Assert.Single(result.Canvases); - Assert.Equal("counter", canvas.CanvasId); - Assert.Equal("Counter", canvas.DisplayName); - Assert.Equal("Tracks a counter value.", canvas.Description); - Assert.Single(canvas.Actions!); - Assert.Equal("increment", canvas.Actions![0].Name); - Assert.Empty(handler.OpenRequests); - } - - [Fact] - public async Task Should_Open_Canvas_Through_The_Handler() - { - var handler = new TestCanvasHandler(); - await using var session = await CreateCanvasSessionAsync(handler); - var canvas = Assert.Single((await session.Rpc.Canvas.ListAsync()).Canvases); + var opens = new List(); + await using var session = await CreateSessionAsync(CreateCanvasSessionConfig(new RecordingCanvasHandler(opens: opens))); - var openResult = await session.Rpc.Canvas.OpenAsync( + var result = await session.Rpc.Canvas.OpenAsync( canvasId: "counter", instanceId: "counter-1", - extensionId: canvas.ExtensionId, - input: new Dictionary { ["start"] = 41 }); - - Assert.Equal("counter", openResult.CanvasId); - Assert.Equal("counter-1", openResult.InstanceId); - Assert.Equal(canvas.ExtensionId, openResult.ExtensionId); - Assert.Equal("Counter counter-1", openResult.Title); - Assert.Equal("ready", openResult.Status); - Assert.Equal("https://example.com/counter/counter-1", openResult.Url); - - var request = Assert.Single(handler.OpenRequests); - Assert.Equal(session.SessionId, request.SessionId); - Assert.Equal(canvas.ExtensionId, request.ExtensionId); - Assert.Equal("counter", request.CanvasId); - Assert.Equal("counter-1", request.InstanceId); - Assert.Equal(41, GetRequiredInt32(request.Input, "start")); - - var openCanvases = await session.Rpc.Canvas.ListOpenAsync(); - Assert.Single(openCanvases.OpenCanvases); - Assert.Equal("counter-1", openCanvases.OpenCanvases[0].InstanceId); + input: new Dictionary { ["seed"] = 7 }); + + var open = Assert.Single(opens); + Assert.Equal("counter", open.CanvasId); + Assert.Equal("counter-1", open.InstanceId); + Assert.Equal(7, open.Input.GetProperty("seed").GetInt32()); + Assert.Equal("counter", result.CanvasId); + Assert.Equal("counter-1", result.InstanceId); + Assert.Equal("https://example.test/counter-1", result.Url); + Assert.Equal(CanvasInstanceAvailability.Ready, result.Availability); } [Fact] - public async Task Should_Invoke_Canvas_Action_Through_The_Handler() + public async Task DispatchesCanvasActionInvokeToHandler() { - var handler = new TestCanvasHandler(); - await using var session = await CreateCanvasSessionAsync(handler); - var canvas = Assert.Single((await session.Rpc.Canvas.ListAsync()).Canvases); - await session.Rpc.Canvas.OpenAsync( - canvasId: "counter", - instanceId: "counter-1", - extensionId: canvas.ExtensionId, - input: new Dictionary { ["start"] = 41 }); + var actions = new List(); + await using var session = await CreateSessionAsync(CreateCanvasSessionConfig(new RecordingCanvasHandler(actions: actions))); + await session.Rpc.Canvas.OpenAsync(canvasId: "counter", instanceId: "counter-2"); var result = await session.Rpc.Canvas.InvokeActionAsync( - instanceId: "counter-1", + instanceId: "counter-2", actionName: "increment", - input: new Dictionary { ["delta"] = 1 }); - - var request = Assert.Single(handler.ActionRequests); - Assert.Equal(session.SessionId, request.SessionId); - Assert.Equal(canvas.ExtensionId, request.ExtensionId); - Assert.Equal("counter", request.CanvasId); - Assert.Equal("counter-1", request.InstanceId); - Assert.Equal("increment", request.ActionName); - Assert.Equal(1, GetRequiredInt32(request.Input, "delta")); - Assert.True(result.Result.HasValue); - Assert.NotEqual(JsonValueKind.Undefined, result.Result.Value.ValueKind); + input: new Dictionary { ["amount"] = 3 }); + + var action = Assert.Single(actions); + Assert.Equal("counter", action.CanvasId); + Assert.Equal("counter-2", action.InstanceId); + Assert.Equal("increment", action.ActionName); + Assert.Equal(3, action.Input.GetProperty("amount").GetInt32()); + + var actionResult = result.Result; + Assert.NotNull(actionResult); + var payload = actionResult!.Value; + Assert.True(payload.GetProperty("ok").GetBoolean()); + Assert.Equal("increment", payload.GetProperty("actionName").GetString()); + Assert.Equal(3, payload.GetProperty("input").GetProperty("amount").GetInt32()); } [Fact] - public async Task Should_Close_Canvas_Through_The_Handler() + public async Task DispatchesCanvasCloseToOnCloseHandler() { - var handler = new TestCanvasHandler(); - await using var session = await CreateCanvasSessionAsync(handler); - var canvas = Assert.Single((await session.Rpc.Canvas.ListAsync()).Canvases); - await session.Rpc.Canvas.OpenAsync( - canvasId: "counter", - instanceId: "counter-1", - extensionId: canvas.ExtensionId, - input: new Dictionary { ["start"] = 41 }); + var closes = new List(); + await using var session = await CreateSessionAsync(CreateCanvasSessionConfig(new RecordingCanvasHandler(closes: closes))); - await session.Rpc.Canvas.CloseAsync("counter-1"); + await session.Rpc.Canvas.OpenAsync(canvasId: "counter", instanceId: "counter-3"); + await session.Rpc.Canvas.CloseAsync(instanceId: "counter-3"); + await Task.Delay(50); - var request = Assert.Single(handler.CloseRequests); - Assert.Equal(session.SessionId, request.SessionId); - Assert.Equal(canvas.ExtensionId, request.ExtensionId); - Assert.Equal("counter", request.CanvasId); - Assert.Equal("counter-1", request.InstanceId); - - var openCanvases = await session.Rpc.Canvas.ListOpenAsync(); - Assert.Empty(openCanvases.OpenCanvases); + var close = Assert.Single(closes); + Assert.Equal("counter", close.CanvasId); + Assert.Equal("counter-3", close.InstanceId); } - private Task CreateCanvasSessionAsync(TestCanvasHandler handler) + [Fact] + public async Task ReturnsCanvasActionNoHandlerForDeclaredActionWithoutHandler() { - return CreateSessionAsync(new SessionConfig - { - OnPermissionRequest = PermissionHandler.ApproveAll, - RequestCanvasRenderer = true, - RequestExtensions = true, - ExtensionInfo = new ExtensionInfo { Source = "dotnet-sdk-tests", Name = "canvas-provider" }, - Canvases = - [ - new CanvasDeclaration - { - Id = "counter", - DisplayName = "Counter", - Description = "Tracks a counter value.", - Actions = - [ - new CanvasAction - { - Name = "increment", - Description = "Increments the counter.", - } - ], - } - ], - CanvasHandler = handler, - }); + await using var session = await CreateSessionAsync(CreateCanvasSessionConfig(new OpenOnlyCanvasHandler())); + + await session.Rpc.Canvas.OpenAsync(canvasId: "counter", instanceId: "counter-4"); + var ex = await Assert.ThrowsAsync(() => session.Rpc.Canvas.InvokeActionAsync( + instanceId: "counter-4", + actionName: "increment", + input: new Dictionary())); + + Assert.Contains("canvas_action_no_handler", ex.Message, StringComparison.Ordinal); } - private static int GetRequiredInt32(JsonElement? element, string propertyName) + [Fact] + public async Task SeedsOpenCanvasesOnResumeFromRuntime() { - Assert.True(element.HasValue); - return element.Value.GetProperty(propertyName).GetInt32(); + await using var sessionA = await CreateSessionAsync(CreateCanvasSessionConfig(new OpenOnlyCanvasHandler())); + + await sessionA.Rpc.Canvas.OpenAsync( + canvasId: "counter", + instanceId: "counter-resume", + input: new Dictionary { ["initial"] = true }); + + await using var resumed = await ResumeSessionAsync(sessionA.SessionId, CreateCanvasResumeConfig(new OpenOnlyCanvasHandler())); + + Assert.NotEmpty(resumed.OpenCanvases); + var match = Assert.Single(resumed.OpenCanvases, canvas => canvas.InstanceId == "counter-resume"); + Assert.Equal("counter", match.CanvasId); } - private sealed class TestCanvasHandler : CanvasHandlerBase + private static SessionConfig CreateCanvasSessionConfig(ICanvasHandler handler) => new() { - public List OpenRequests { get; } = []; - public List CloseRequests { get; } = []; - public List ActionRequests { get; } = []; - - public override Task OnOpenAsync(CanvasProviderOpenRequest context, CancellationToken cancellationToken) - { - OpenRequests.Add(Clone(context)); - return Task.FromResult(new CanvasProviderOpenResult + Canvases = [CreateCounterCanvas()], + CanvasHandler = handler, + RequestCanvasRenderer = true, + ExtensionInfo = new ExtensionInfo { Source = "github-app", Name = "counter-provider" }, + OnPermissionRequest = PermissionHandler.ApproveAll, + }; + + private static ResumeSessionConfig CreateCanvasResumeConfig(ICanvasHandler handler) => new() + { + Canvases = [CreateCounterCanvas()], + CanvasHandler = handler, + RequestCanvasRenderer = true, + ExtensionInfo = new ExtensionInfo { Source = "github-app", Name = "counter-provider" }, + OnPermissionRequest = PermissionHandler.ApproveAll, + }; + + private static CanvasDeclaration CreateCounterCanvas() => new() + { + Id = "counter", + DisplayName = "Counter", + Description = "A test counter canvas", + Actions = + [ + new CanvasAction { - Url = $"https://example.com/counter/{context.InstanceId}", - Title = $"Counter {context.InstanceId}", - Status = "ready", - }); - } + Name = "increment", + Description = "Increment the counter", + }, + ], + }; - public override Task OnCloseAsync(CanvasProviderCloseRequest context, CancellationToken cancellationToken) - { - CloseRequests.Add(Clone(context)); - return Task.CompletedTask; - } + private class OpenOnlyCanvasHandler : CanvasHandlerBase + { + public override Task OnOpenAsync(CanvasOpenContext context, CancellationToken cancellationToken) + => Task.FromResult(new CanvasOpenResponse { Url = $"https://example.test/{context.InstanceId}" }); + } - public override Task OnActionAsync(CanvasProviderInvokeActionRequest context, CancellationToken cancellationToken) + private sealed class RecordingCanvasHandler( + List? opens = null, + List? closes = null, + List? actions = null) : OpenOnlyCanvasHandler + { + public override Task OnOpenAsync(CanvasOpenContext context, CancellationToken cancellationToken) { - ActionRequests.Add(Clone(context)); - var openRequest = OpenRequests.LastOrDefault(request => request.InstanceId == context.InstanceId); - var current = openRequest is not null && openRequest.Input.HasValue - ? openRequest.Input.Value.GetProperty("start").GetInt32() - : 0; - var delta = context.Input.HasValue - ? context.Input.Value.GetProperty("delta").GetInt32() - : 0; - using var document = JsonDocument.Parse($@"{{""count"":{current + delta}}}"); - return Task.FromResult(document.RootElement.Clone()); + opens?.Add(CloneOpenContext(context)); + return base.OnOpenAsync(context, cancellationToken); } - private static CanvasProviderOpenRequest Clone(CanvasProviderOpenRequest request) - => new() - { - SessionId = request.SessionId, - ExtensionId = request.ExtensionId, - CanvasId = request.CanvasId, - InstanceId = request.InstanceId, - Input = Clone(request.Input), - Host = Clone(request.Host), - }; - - private static CanvasProviderCloseRequest Clone(CanvasProviderCloseRequest request) - => new() - { - SessionId = request.SessionId, - ExtensionId = request.ExtensionId, - CanvasId = request.CanvasId, - InstanceId = request.InstanceId, - Host = Clone(request.Host), - }; - - private static CanvasProviderInvokeActionRequest Clone(CanvasProviderInvokeActionRequest request) - => new() - { - SessionId = request.SessionId, - ExtensionId = request.ExtensionId, - CanvasId = request.CanvasId, - InstanceId = request.InstanceId, - ActionName = request.ActionName, - Input = Clone(request.Input), - Host = Clone(request.Host), - }; - - private static JsonElement? Clone(JsonElement? element) + public override Task OnCloseAsync(CanvasLifecycleContext context, CancellationToken cancellationToken) { - if (!element.HasValue) - { - return null; - } - - using var document = JsonDocument.Parse(element.Value.GetRawText()); - return document.RootElement.Clone(); + closes?.Add(context); + return Task.CompletedTask; } - private static CanvasHostContext? Clone(CanvasHostContext? host) + public override Task OnActionAsync(CanvasActionContext context, CancellationToken cancellationToken) { - if (host is null) + actions?.Add(CloneActionContext(context)); + return Task.FromResult(new Dictionary { - return null; - } - - return new CanvasHostContext - { - Capabilities = host.Capabilities is null - ? null - : new CanvasHostContextCapabilities - { - Canvases = host.Capabilities.Canvases, - }, - }; + ["ok"] = true, + ["actionName"] = context.ActionName, + ["input"] = context.Input.Clone(), + }); } } + + private static CanvasOpenContext CloneOpenContext(CanvasOpenContext context) => new() + { + SessionId = context.SessionId, + ExtensionId = context.ExtensionId, + CanvasId = context.CanvasId, + InstanceId = context.InstanceId, + Input = context.Input.Clone(), + Host = context.Host, + }; + + private static CanvasActionContext CloneActionContext(CanvasActionContext context) => new() + { + SessionId = context.SessionId, + ExtensionId = context.ExtensionId, + CanvasId = context.CanvasId, + InstanceId = context.InstanceId, + ActionName = context.ActionName, + Input = context.Input.Clone(), + Host = context.Host, + }; } diff --git a/go/internal/e2e/canvas_e2e_test.go b/go/internal/e2e/canvas_e2e_test.go index 7f7b71544..bbd91084b 100644 --- a/go/internal/e2e/canvas_e2e_test.go +++ b/go/internal/e2e/canvas_e2e_test.go @@ -2,224 +2,337 @@ package e2e import ( "context" + "encoding/json" + "fmt" + "reflect" "sync" "testing" + "time" copilot "github.com/github/copilot-sdk/go" "github.com/github/copilot-sdk/go/internal/e2e/testharness" + "github.com/github/copilot-sdk/go/internal/jsonrpc2" "github.com/github/copilot-sdk/go/rpc" ) func TestCanvasE2E(t *testing.T) { - ctx := testharness.NewTestContext(t) - client := ctx.NewClient() - t.Cleanup(func() { client.ForceStop() }) - - handler := &testCanvasHandler{} - canvasDecl := copilot.CanvasDeclaration{ - ID: "counter", - DisplayName: "Counter", - Description: "A simple counter canvas for e2e testing", - InputSchema: map[string]any{ - "type": "object", - "properties": map[string]any{ - "startValue": map[string]any{"type": "number"}, - }, - }, - Actions: []rpc.CanvasAction{{ - Name: "increment", - Description: copilot.String("Increment the counter"), - InputSchema: map[string]any{ - "type": "object", - "properties": map[string]any{ - "amount": map[string]any{"type": "number"}, - }, - }, - }}, - } + t.Run("dispatches_canvas_open", func(t *testing.T) { + ctx := newCanvasTestContext(t) + client := ctx.NewClient() + t.Cleanup(func() { client.ForceStop() }) - session, err := client.CreateSession(t.Context(), &copilot.SessionConfig{ - OnPermissionRequest: copilot.PermissionHandler.ApproveAll, - Canvases: []copilot.CanvasDeclaration{canvasDecl}, - CanvasHandler: handler, + handler := &recordingCanvasE2EHandler{} + session := createCanvasSession(t, client, ctx, handler) + + result, err := session.RPC.Canvas.Open(t.Context(), &rpc.CanvasOpenRequest{ + CanvasID: "counter", + InstanceID: "counter-1", + Input: json.RawMessage(`{"seed":7}`), + }) + if err != nil { + t.Fatalf("Canvas.Open failed: %v", err) + } + + opens := handler.openCallsSnapshot() + if len(opens) != 1 { + t.Fatalf("expected 1 OnOpen call, got %d", len(opens)) + } + assertOpenCall(t, opens[0], "counter", "counter-1", `{"seed":7}`) + assertOpenCanvasInstance(t, result, "counter", "counter-1", "https://example.test/counter-1") }) - if err != nil { - t.Fatalf("Failed to create session: %v", err) - } - listResult, err := session.RPC.Canvas.List(t.Context()) - if err != nil { - t.Fatalf("Canvas.List failed: %v", err) - } - if len(listResult.Canvases) != 1 { - t.Fatalf("expected 1 canvas, got %d", len(listResult.Canvases)) - } - if listResult.Canvases[0].CanvasID != "counter" { - t.Fatalf("expected canvasId=counter, got %q", listResult.Canvases[0].CanvasID) - } + t.Run("dispatches_canvas_action_invoke", func(t *testing.T) { + ctx := newCanvasTestContext(t) + client := ctx.NewClient() + t.Cleanup(func() { client.ForceStop() }) - openResult, err := session.RPC.Canvas.Open(t.Context(), &rpc.CanvasOpenRequest{ - CanvasID: "counter", - InstanceID: "counter-1", - Input: map[string]any{ - "startValue": float64(3), - }, + handler := &recordingCanvasE2EHandler{} + session := createCanvasSession(t, client, ctx, handler) + openCanvas(t, session, "counter-2", nil) + + result, err := session.RPC.Canvas.InvokeAction(t.Context(), &rpc.CanvasInvokeActionRequest{ + InstanceID: "counter-2", + ActionName: "increment", + Input: json.RawMessage(`{"amount":3}`), + }) + if err != nil { + t.Fatalf("Canvas.InvokeAction failed: %v", err) + } + + actions := handler.actionCallsSnapshot() + if len(actions) != 1 { + t.Fatalf("expected 1 OnAction call, got %d", len(actions)) + } + if actions[0].CanvasID != "counter" { + t.Errorf("expected canvasId counter, got %q", actions[0].CanvasID) + } + if actions[0].InstanceID != "counter-2" { + t.Errorf("expected instanceId counter-2, got %q", actions[0].InstanceID) + } + if actions[0].ActionName != "increment" { + t.Errorf("expected actionName increment, got %q", actions[0].ActionName) + } + assertJSONValue(t, actions[0].Input, `{"amount":3}`) + assertJSONValue(t, result.Result, `{"ok":true,"actionName":"increment","input":{"amount":3}}`) }) - if err != nil { - t.Fatalf("Canvas.Open failed: %v", err) - } - if openResult.CanvasID != "counter" || openResult.InstanceID != "counter-1" { - t.Fatalf("unexpected open result: %+v", openResult) - } - if openResult.URL == nil || *openResult.URL != "https://example.test/counter/counter-1" { - t.Fatalf("unexpected open URL: %+v", openResult.URL) - } - if calls := handler.OpenCalls(); len(calls) != 1 || calls[0].CanvasID != "counter" || calls[0].InstanceID != "counter-1" { - t.Fatalf("unexpected open calls: %+v", calls) - } - actionResult, err := session.RPC.Canvas.InvokeAction(t.Context(), &rpc.CanvasInvokeActionRequest{ - InstanceID: "counter-1", - ActionName: "increment", - Input: map[string]any{ - "amount": float64(2), - }, + t.Run("dispatches_canvas_close", func(t *testing.T) { + ctx := newCanvasTestContext(t) + client := ctx.NewClient() + t.Cleanup(func() { client.ForceStop() }) + + handler := &recordingCanvasE2EHandler{} + session := createCanvasSession(t, client, ctx, handler) + openCanvas(t, session, "counter-3", nil) + + if _, err := session.RPC.Canvas.Close(t.Context(), &rpc.CanvasCloseRequest{InstanceID: "counter-3"}); err != nil { + t.Fatalf("Canvas.Close failed: %v", err) + } + + time.Sleep(50 * time.Millisecond) + closes := handler.closeCallsSnapshot() + if len(closes) != 1 { + t.Fatalf("expected 1 OnClose call, got %d", len(closes)) + } + if closes[0].CanvasID != "counter" { + t.Errorf("expected canvasId counter, got %q", closes[0].CanvasID) + } + if closes[0].InstanceID != "counter-3" { + t.Errorf("expected instanceId counter-3, got %q", closes[0].InstanceID) + } }) - if err != nil { - t.Fatalf("Canvas.InvokeAction failed: %v", err) - } - actionPayload, ok := actionResult.Result.(map[string]any) - if !ok || actionPayload["count"] != float64(5) { - t.Fatalf("unexpected action result: %#v", actionResult.Result) - } - if calls := handler.ActionCalls(); len(calls) != 1 || calls[0].ActionName != "increment" { - t.Fatalf("unexpected action calls: %+v", calls) - } - closeResult, err := session.RPC.Canvas.Close(t.Context(), &rpc.CanvasCloseRequest{ - InstanceID: "counter-1", + t.Run("returns_canvas_action_no_handler", func(t *testing.T) { + ctx := newCanvasTestContext(t) + client := ctx.NewClient() + t.Cleanup(func() { client.ForceStop() }) + + session := createCanvasSession(t, client, ctx, &openOnlyCanvasE2EHandler{}) + openCanvas(t, session, "counter-4", nil) + + _, err := session.RPC.Canvas.InvokeAction(t.Context(), &rpc.CanvasInvokeActionRequest{ + InstanceID: "counter-4", + ActionName: "increment", + Input: json.RawMessage(`{}`), + }) + if err == nil { + t.Fatalf("expected Canvas.InvokeAction to fail") + } + assertJSONRPCErrorCode(t, err, "canvas_action_no_handler") + }) + + t.Run("seeds_open_canvases_on_resume", func(t *testing.T) { + ctx := newCanvasTestContext(t) + client := ctx.NewClient() + t.Cleanup(func() { client.ForceStop() }) + + sessionA := createCanvasSession(t, client, ctx, &recordingCanvasE2EHandler{}) + openCanvas(t, sessionA, "counter-resume", json.RawMessage(`{"initial":true}`)) + + resumed, err := client.ResumeSession(t.Context(), sessionA.SessionID, &copilot.ResumeSessionConfig{ + OnPermissionRequest: copilot.PermissionHandler.ApproveAll, + WorkingDirectory: ctx.WorkDir, + Canvases: counterCanvasDeclarations(), + CanvasHandler: &recordingCanvasE2EHandler{}, + RequestCanvasRenderer: copilot.Bool(true), + ExtensionInfo: counterExtensionInfo(), + }) + if err != nil { + t.Fatalf("ResumeSession failed: %v", err) + } + t.Cleanup(func() { _ = resumed.Disconnect() }) + + seeded := resumed.OpenCanvases() + if len(seeded) == 0 { + t.Fatalf("expected resumed OpenCanvases to contain entries") + } + for _, canvas := range seeded { + if canvas.InstanceID == "counter-resume" { + if canvas.CanvasID != "counter" { + t.Fatalf("expected resumed canvasId counter, got %q", canvas.CanvasID) + } + return + } + } + t.Fatalf("expected resumed OpenCanvases to include counter-resume, got %+v", seeded) }) - if err != nil { - t.Fatalf("Canvas.Close failed: %v", err) - } - if closeResult == nil { - t.Fatal("expected non-nil close result") - } - if calls := handler.CloseCalls(); len(calls) != 1 || calls[0].CanvasID != "counter" || calls[0].InstanceID != "counter-1" { - t.Fatalf("unexpected close calls: %+v", calls) - } } -type testCanvasHandler struct { +type recordingCanvasE2EHandler struct { copilot.CanvasHandlerDefaults mu sync.Mutex - openCalls []canvasOpenCall - closeCalls []canvasCloseCall - actionCalls []canvasActionCall - counts map[string]float64 + openCalls []copilot.CanvasOpenContext + closeCalls []copilot.CanvasLifecycleContext + actionCalls []copilot.CanvasActionContext } -type canvasOpenCall struct { - CanvasID string - InstanceID string - Input any +func (h *recordingCanvasE2EHandler) OnOpen(ctx context.Context, c copilot.CanvasOpenContext) (copilot.CanvasOpenResponse, error) { + h.mu.Lock() + h.openCalls = append(h.openCalls, c) + h.mu.Unlock() + url := fmt.Sprintf("https://example.test/%s", c.InstanceID) + return copilot.CanvasOpenResponse{URL: &url}, nil } -type canvasCloseCall struct { - CanvasID string - InstanceID string +func (h *recordingCanvasE2EHandler) OnClose(ctx context.Context, c copilot.CanvasLifecycleContext) error { + h.mu.Lock() + h.closeCalls = append(h.closeCalls, c) + h.mu.Unlock() + return nil } -type canvasActionCall struct { - CanvasID string - InstanceID string - ActionName string - Input any +func (h *recordingCanvasE2EHandler) OnAction(ctx context.Context, c copilot.CanvasActionContext) (any, error) { + h.mu.Lock() + h.actionCalls = append(h.actionCalls, c) + h.mu.Unlock() + return map[string]any{"ok": true, "actionName": c.ActionName, "input": c.Input}, nil } -func (h *testCanvasHandler) OnOpen(ctx context.Context, req rpc.CanvasProviderOpenRequest) (rpc.CanvasProviderOpenResult, error) { +func (h *recordingCanvasE2EHandler) openCallsSnapshot() []copilot.CanvasOpenContext { h.mu.Lock() defer h.mu.Unlock() - - if h.counts == nil { - h.counts = make(map[string]float64) - } - h.openCalls = append(h.openCalls, canvasOpenCall{ - CanvasID: req.CanvasID, - InstanceID: req.InstanceID, - Input: req.Input, - }) - h.counts[req.InstanceID] = numberField(req.Input, "startValue") - - return rpc.CanvasProviderOpenResult{ - URL: copilot.String("https://example.test/counter/" + req.InstanceID), - Title: copilot.String("Counter"), - Status: copilot.String("ready"), - }, nil + return append([]copilot.CanvasOpenContext(nil), h.openCalls...) } -func (h *testCanvasHandler) OnClose(ctx context.Context, req rpc.CanvasProviderCloseRequest) error { +func (h *recordingCanvasE2EHandler) closeCallsSnapshot() []copilot.CanvasLifecycleContext { h.mu.Lock() defer h.mu.Unlock() - - h.closeCalls = append(h.closeCalls, canvasCloseCall{ - CanvasID: req.CanvasID, - InstanceID: req.InstanceID, - }) - delete(h.counts, req.InstanceID) - return nil + return append([]copilot.CanvasLifecycleContext(nil), h.closeCalls...) } -func (h *testCanvasHandler) OnAction(ctx context.Context, req rpc.CanvasProviderInvokeActionRequest) (any, error) { +func (h *recordingCanvasE2EHandler) actionCallsSnapshot() []copilot.CanvasActionContext { h.mu.Lock() defer h.mu.Unlock() + return append([]copilot.CanvasActionContext(nil), h.actionCalls...) +} + +type openOnlyCanvasE2EHandler struct { + copilot.CanvasHandlerDefaults +} + +func (h *openOnlyCanvasE2EHandler) OnOpen(ctx context.Context, c copilot.CanvasOpenContext) (copilot.CanvasOpenResponse, error) { + url := fmt.Sprintf("https://example.test/%s", c.InstanceID) + return copilot.CanvasOpenResponse{URL: &url}, nil +} + +func newCanvasTestContext(t *testing.T) *testharness.TestContext { + t.Helper() + ctx := testharness.NewTestContext(t) + ctx.ConfigureForTest(t) + return ctx +} - if h.counts == nil { - h.counts = make(map[string]float64) +func createCanvasSession(t *testing.T, client *copilot.Client, ctx *testharness.TestContext, handler copilot.CanvasHandler) *copilot.Session { + t.Helper() + session, err := client.CreateSession(t.Context(), &copilot.SessionConfig{ + OnPermissionRequest: copilot.PermissionHandler.ApproveAll, + WorkingDirectory: ctx.WorkDir, + Canvases: counterCanvasDeclarations(), + CanvasHandler: handler, + RequestCanvasRenderer: copilot.Bool(true), + ExtensionInfo: counterExtensionInfo(), + }) + if err != nil { + t.Fatalf("CreateSession failed: %v", err) + } + t.Cleanup(func() { _ = session.Disconnect() }) + return session +} + +func counterCanvasDeclarations() []copilot.CanvasDeclaration { + description := "Increment the counter" + return []copilot.CanvasDeclaration{ + { + ID: "counter", + DisplayName: "Counter", + Description: "A test counter canvas", + Actions: []rpc.CanvasAction{ + {Name: "increment", Description: &description}, + }, + }, } - h.actionCalls = append(h.actionCalls, canvasActionCall{ - CanvasID: req.CanvasID, - InstanceID: req.InstanceID, - ActionName: req.ActionName, - Input: req.Input, +} + +func counterExtensionInfo() *copilot.ExtensionInfo { + return &copilot.ExtensionInfo{Source: "github-app", Name: "counter-provider"} +} + +func openCanvas(t *testing.T, session *copilot.Session, instanceID string, input any) *rpc.OpenCanvasInstance { + t.Helper() + result, err := session.RPC.Canvas.Open(t.Context(), &rpc.CanvasOpenRequest{ + CanvasID: "counter", + InstanceID: instanceID, + Input: input, }) - h.counts[req.InstanceID] += numberField(req.Input, "amount") - return map[string]any{"count": h.counts[req.InstanceID]}, nil + if err != nil { + t.Fatalf("Canvas.Open failed: %v", err) + } + return result } -func (h *testCanvasHandler) OpenCalls() []canvasOpenCall { - h.mu.Lock() - defer h.mu.Unlock() - out := make([]canvasOpenCall, len(h.openCalls)) - copy(out, h.openCalls) - return out +func assertOpenCall(t *testing.T, got copilot.CanvasOpenContext, canvasID, instanceID, input string) { + t.Helper() + if got.CanvasID != canvasID { + t.Errorf("expected canvasId %q, got %q", canvasID, got.CanvasID) + } + if got.InstanceID != instanceID { + t.Errorf("expected instanceId %q, got %q", instanceID, got.InstanceID) + } + assertJSONValue(t, got.Input, input) } -func (h *testCanvasHandler) CloseCalls() []canvasCloseCall { - h.mu.Lock() - defer h.mu.Unlock() - out := make([]canvasCloseCall, len(h.closeCalls)) - copy(out, h.closeCalls) - return out +func assertOpenCanvasInstance(t *testing.T, got *rpc.OpenCanvasInstance, canvasID, instanceID, url string) { + t.Helper() + if got == nil { + t.Fatalf("expected non-nil OpenCanvasInstance") + } + if got.CanvasID != canvasID { + t.Errorf("expected canvasId %q, got %q", canvasID, got.CanvasID) + } + if got.InstanceID != instanceID { + t.Errorf("expected instanceId %q, got %q", instanceID, got.InstanceID) + } + if got.URL == nil || *got.URL != url { + t.Errorf("expected url %q, got %v", url, got.URL) + } + if got.Availability != rpc.CanvasInstanceAvailabilityReady { + t.Errorf("expected availability ready, got %q", got.Availability) + } } -func (h *testCanvasHandler) ActionCalls() []canvasActionCall { - h.mu.Lock() - defer h.mu.Unlock() - out := make([]canvasActionCall, len(h.actionCalls)) - copy(out, h.actionCalls) - return out +func assertJSONValue(t *testing.T, got any, wantJSON string) { + t.Helper() + var want any + if err := json.Unmarshal([]byte(wantJSON), &want); err != nil { + t.Fatalf("failed to unmarshal expected JSON: %v", err) + } + gotJSON, err := json.Marshal(got) + if err != nil { + t.Fatalf("failed to marshal actual value: %v", err) + } + var normalizedGot any + if err := json.Unmarshal(gotJSON, &normalizedGot); err != nil { + t.Fatalf("failed to normalize actual JSON %s: %v", gotJSON, err) + } + if !reflect.DeepEqual(normalizedGot, want) { + t.Fatalf("JSON mismatch: got %s, want %s", gotJSON, wantJSON) + } } -func numberField(value any, key string) float64 { - m, ok := value.(map[string]any) +func assertJSONRPCErrorCode(t *testing.T, err error, wantCode string) { + t.Helper() + rpcErr, ok := err.(*jsonrpc2.Error) if !ok { - return 0 + t.Fatalf("expected *jsonrpc2.Error, got %T: %v", err, err) } - n, ok := m[key].(float64) - if !ok { - return 0 + var data struct { + Code string `json:"code"` + } + if unmarshalErr := json.Unmarshal(rpcErr.Data, &data); unmarshalErr != nil { + t.Fatalf("failed to unmarshal JSON-RPC error data %s: %v", rpcErr.Data, unmarshalErr) + } + if data.Code != wantCode { + t.Fatalf("expected error code %q, got %q (error: %v)", wantCode, data.Code, err) } - return n } diff --git a/nodejs/test/e2e/canvas.e2e.test.ts b/nodejs/test/e2e/canvas.e2e.test.ts index d08cba9ea..eea107412 100644 --- a/nodejs/test/e2e/canvas.e2e.test.ts +++ b/nodejs/test/e2e/canvas.e2e.test.ts @@ -4,178 +4,198 @@ import { describe, expect, it } from "vitest"; import { approveAll, createCanvas } from "../../src/index.js"; -import { createSdkTestContext } from "./harness/sdkTestContext.js"; - -describe("Canvas RPC", async () => { - const openCalls: Array<{ canvasId: string; instanceId: string; input?: unknown }> = []; - const closeCalls: Array<{ canvasId: string; instanceId: string }> = []; - const actionCalls: Array<{ - canvasId: string; - instanceId: string; - actionName: string; - input?: unknown; - }> = []; - - const counter = createCanvas({ - id: "counter", - displayName: "Counter", - description: "A simple counter canvas for e2e testing", - inputSchema: { - type: "object", - properties: { startValue: { type: "number" } }, - }, - actions: [ - { - name: "increment", - description: "Increment the counter", - inputSchema: { - type: "object", - properties: { amount: { type: "number" } }, - }, - handler: (ctx) => { - actionCalls.push({ - canvasId: ctx.canvasId, - instanceId: ctx.instanceId, - actionName: ctx.actionName, - input: ctx.input, - }); - return { newValue: 42 }; +import { createSdkTestContext } from "./harness/sdkTestContext"; + +// E2E coverage for the canvas SDK ↔ runtime loop. The host-side +// `session.rpc.canvas.{open,close,invokeAction}` RPCs drive the runtime to +// dispatch `canvas.open` / `canvas.close` / `canvas.action.invoke` back to the +// declaring provider (us). These tests do not involve CAPI, so their +// snapshots are empty (`conversations: []`). +describe("Canvas E2E", async () => { + const { copilotClient: client } = await createSdkTestContext(); + + function makeCounter(record: { + open?: { instanceId: string; canvasId: string; input?: unknown }[]; + close?: { instanceId: string; canvasId: string }[]; + action?: { actionName: string; instanceId: string; input?: unknown }[]; + }) { + return createCanvas({ + id: "counter", + displayName: "Counter", + description: "A test counter canvas", + actions: [ + { + name: "increment", + description: "Increment the counter", + handler: ({ actionName, instanceId, input }) => { + record.action?.push({ actionName, instanceId, input }); + return { ok: true, actionName, input }; + }, }, + ], + open: ({ instanceId, canvasId, input }) => { + record.open?.push({ instanceId, canvasId, input }); + return { url: `https://example.test/${instanceId}` }; }, - ], - open: (ctx) => { - openCalls.push({ - canvasId: ctx.canvasId, - instanceId: ctx.instanceId, - input: ctx.input, - }); - return { - url: "https://example.test/counter", - title: "Counter Canvas", - status: "ready", - }; - }, - onClose: (ctx) => { - closeCalls.push({ - canvasId: ctx.canvasId, - instanceId: ctx.instanceId, - }); - }, - }); - - const { copilotClient: client } = await createSdkTestContext(); + onClose: ({ instanceId, canvasId }) => { + record.close?.push({ instanceId, canvasId }); + }, + }); + } - it("discovers declared canvases via session.canvas.list", async () => { + it("dispatches canvas.open to the provider handler", async () => { + const opens: { instanceId: string; canvasId: string; input?: unknown }[] = []; const session = await client.createSession({ onPermissionRequest: approveAll, - canvases: [counter], + canvases: [makeCounter({ open: opens })], + requestCanvasRenderer: true, + extensionInfo: { source: "github-app", name: "counter-provider" }, }); - const result = await session.rpc.canvas.list(); - expect(result.canvases).toHaveLength(1); - expect(result.canvases[0]).toMatchObject({ - canvasId: "counter", - displayName: "Counter", - description: "A simple counter canvas for e2e testing", - }); + try { + const result = await session.rpc.canvas.open({ + canvasId: "counter", + instanceId: "counter-1", + input: { seed: 7 }, + }); - await session.disconnect(); + expect(opens).toEqual([ + { instanceId: "counter-1", canvasId: "counter", input: { seed: 7 } }, + ]); + expect(result).toMatchObject({ + instanceId: "counter-1", + canvasId: "counter", + url: "https://example.test/counter-1", + availability: "ready", + }); + } finally { + await session.disconnect(); + } }); - it("opens a canvas instance via session.canvas.open round-trip", async () => { - openCalls.length = 0; - + it("dispatches canvas.action.invoke to the per-action handler", async () => { + const actions: { actionName: string; instanceId: string; input?: unknown }[] = []; + const opens: { instanceId: string; canvasId: string; input?: unknown }[] = []; const session = await client.createSession({ onPermissionRequest: approveAll, - canvases: [counter], + canvases: [makeCounter({ open: opens, action: actions })], + requestCanvasRenderer: true, + extensionInfo: { source: "github-app", name: "counter-provider" }, }); - const result = await session.rpc.canvas.open({ - canvasId: "counter", - instanceId: "counter-1", - input: { startValue: 10 }, - }); + try { + await session.rpc.canvas.open({ canvasId: "counter", instanceId: "counter-2" }); - expect(result.url).toBe("https://example.test/counter"); - expect(result.title).toBe("Counter Canvas"); - expect(openCalls).toHaveLength(1); - expect(openCalls[0]).toMatchObject({ - canvasId: "counter", - instanceId: "counter-1", - input: { startValue: 10 }, - }); - - // Verify it appears in the open list - const openList = await session.rpc.canvas.listOpen(); - expect(openList.openCanvases).toHaveLength(1); - expect(openList.openCanvases[0]).toMatchObject({ - canvasId: "counter", - instanceId: "counter-1", - }); + const result = await session.rpc.canvas.invokeAction({ + canvasId: "counter", + instanceId: "counter-2", + actionName: "increment", + input: { amount: 3 }, + }); - await session.disconnect(); + expect(actions).toEqual([ + { + actionName: "increment", + instanceId: "counter-2", + input: { amount: 3 }, + }, + ]); + expect(result).toEqual({ + result: { ok: true, actionName: "increment", input: { amount: 3 } }, + }); + } finally { + await session.disconnect(); + } }); - it("invokes an action on an open canvas instance", async () => { - openCalls.length = 0; - actionCalls.length = 0; - + it("dispatches canvas.close to the provider onClose handler", async () => { + const closes: { instanceId: string; canvasId: string }[] = []; const session = await client.createSession({ onPermissionRequest: approveAll, - canvases: [counter], + canvases: [makeCounter({ close: closes })], + requestCanvasRenderer: true, + extensionInfo: { source: "github-app", name: "counter-provider" }, }); - await session.rpc.canvas.open({ - canvasId: "counter", - instanceId: "counter-2", - input: {}, - }); + try { + await session.rpc.canvas.open({ canvasId: "counter", instanceId: "counter-3" }); + await session.rpc.canvas.close({ canvasId: "counter", instanceId: "counter-3" }); - const result = await session.rpc.canvas.invokeAction({ - instanceId: "counter-2", - actionName: "increment", - input: { amount: 5 }, - }); + // onClose is fire-and-forget on the runtime side; allow a microtask flush. + await new Promise((r) => setTimeout(r, 50)); - expect(result.result).toEqual({ newValue: 42 }); - expect(actionCalls).toHaveLength(1); - expect(actionCalls[0]).toMatchObject({ - canvasId: "counter", - instanceId: "counter-2", - actionName: "increment", - input: { amount: 5 }, - }); - - await session.disconnect(); + expect(closes).toEqual([{ instanceId: "counter-3", canvasId: "counter" }]); + } finally { + await session.disconnect(); + } }); - it("closes an open canvas instance via session.canvas.close", async () => { - openCalls.length = 0; - closeCalls.length = 0; - + it("rejects invokeAction for an action the canvas did not declare", async () => { + // The Node `createCanvas` API requires every declared action to ship + // with a `handler`, so the `canvas_action_no_handler` SDK-internal + // error is unreachable via the runtime path here — it's covered by + // unit tests in `client.test.ts`. The runtime, however, pre-validates + // action names against the declaration before dispatching, and that + // user-visible rejection is what we exercise end-to-end. const session = await client.createSession({ onPermissionRequest: approveAll, - canvases: [counter], + canvases: [makeCounter({})], + requestCanvasRenderer: true, + extensionInfo: { source: "github-app", name: "counter-provider" }, }); - await session.rpc.canvas.open({ - canvasId: "counter", - instanceId: "counter-3", - input: {}, - }); - expect(closeCalls).toHaveLength(0); + try { + await session.rpc.canvas.open({ canvasId: "counter", instanceId: "counter-4" }); + + await expect( + session.rpc.canvas.invokeAction({ + canvasId: "counter", + instanceId: "counter-4", + actionName: "ghost", + input: {}, + }) + ).rejects.toThrow(/Unknown action "ghost"/); + } finally { + await session.disconnect(); + } + }); - await session.rpc.canvas.close({ instanceId: "counter-3" }); - expect(closeCalls).toHaveLength(1); - expect(closeCalls[0]).toMatchObject({ - canvasId: "counter", - instanceId: "counter-3", + it("seeds openCanvases on resume from the runtime resume response", async () => { + // Open a canvas in session A, then resume into a fresh session view + // and assert the resumed view's openCanvases() reflects the live + // instance reported by the runtime. + const sessionA = await client.createSession({ + onPermissionRequest: approveAll, + canvases: [makeCounter({})], + requestCanvasRenderer: true, + extensionInfo: { source: "github-app", name: "counter-provider" }, }); - // Verify it's no longer in the open list - const openList = await session.rpc.canvas.listOpen(); - expect(openList.openCanvases).toHaveLength(0); + try { + await sessionA.rpc.canvas.open({ + canvasId: "counter", + instanceId: "counter-resume", + input: { initial: true }, + }); + + const resumed = await client.resumeSession(sessionA.sessionId, { + onPermissionRequest: approveAll, + canvases: [makeCounter({})], + requestCanvasRenderer: true, + extensionInfo: { source: "github-app", name: "counter-provider" }, + }); - await session.disconnect(); + try { + const seeded = resumed.openCanvases; + expect(seeded.length).toBeGreaterThan(0); + const match = seeded.find((c) => c.instanceId === "counter-resume"); + expect(match).toBeDefined(); + expect(match?.canvasId).toBe("counter"); + } finally { + await resumed.disconnect(); + } + } finally { + await sessionA.disconnect(); + } }); }); diff --git a/python/e2e/test_canvas_e2e.py b/python/e2e/test_canvas_e2e.py index 095ba6d2d..939ea7eb1 100644 --- a/python/e2e/test_canvas_e2e.py +++ b/python/e2e/test_canvas_e2e.py @@ -1,169 +1,225 @@ -"""E2E tests for canvas RPCs.""" +"""E2E coverage for canvas runtime dispatch.""" from __future__ import annotations +import asyncio +from typing import Any + import pytest from copilot import ( CanvasAction, + CanvasActionContext, CanvasDeclaration, CanvasHandler, + CanvasLifecycleContext, + CanvasOpenContext, + CanvasOpenResponse, + ExtensionInfo, ) +from copilot._jsonrpc import JsonRpcError from copilot.generated.rpc import ( CanvasCloseRequest, + CanvasInstanceAvailability, CanvasInvokeActionRequest, CanvasOpenRequest, - CanvasProviderCloseRequest, - CanvasProviderInvokeActionRequest, - CanvasProviderOpenRequest, - CanvasProviderOpenResult, ) -from copilot.session import CopilotSession, PermissionHandler from .testharness import E2ETestContext pytestmark = pytest.mark.asyncio(loop_scope="module") -class _CounterCanvasHandler(CanvasHandler): - def __init__(self) -> None: - self.open_calls: list[CanvasProviderOpenRequest] = [] - self.action_calls: list[CanvasProviderInvokeActionRequest] = [] - self.close_calls: list[CanvasProviderCloseRequest] = [] - - async def on_open(self, ctx: CanvasProviderOpenRequest) -> CanvasProviderOpenResult: - self.open_calls.append(ctx) - return CanvasProviderOpenResult( - url="https://example.test/counter", - title="Counter Canvas", - status="ready", - ) - - async def on_close(self, ctx: CanvasProviderCloseRequest) -> None: - self.close_calls.append(ctx) - - async def on_action(self, ctx: CanvasProviderInvokeActionRequest) -> dict[str, int]: - self.action_calls.append(ctx) - return {"newValue": 42} +_EXTENSION_INFO = ExtensionInfo(source="github-app", name="counter-provider") -def _counter_canvas() -> CanvasDeclaration: +def _counter_declaration(*, actions: list[CanvasAction] | None = None) -> CanvasDeclaration: return CanvasDeclaration( id="counter", display_name="Counter", - description="A simple counter canvas for e2e testing", - input_schema={ - "type": "object", - "properties": {"startValue": {"type": "number"}}, - }, - actions=[ - CanvasAction( - name="increment", - description="Increment the counter", - input_schema={ - "type": "object", - "properties": {"amount": {"type": "number"}}, - }, - ) - ], + description="A test counter canvas", + actions=actions, ) +class _CounterHandler(CanvasHandler): + def __init__(self) -> None: + self.opens: list[CanvasOpenContext] = [] + self.closes: list[CanvasLifecycleContext] = [] + self.actions: list[CanvasActionContext] = [] + + async def on_open(self, ctx: CanvasOpenContext) -> CanvasOpenResponse: + self.opens.append(ctx) + return CanvasOpenResponse(url=f"https://example.test/{ctx.instance_id}") + + async def on_close(self, ctx: CanvasLifecycleContext) -> None: + self.closes.append(ctx) + + async def on_action(self, ctx: CanvasActionContext) -> Any: + self.actions.append(ctx) + return {"ok": True, "actionName": ctx.action_name, "input": ctx.input} + + +class _NoActionHandler(CanvasHandler): + async def on_open(self, ctx: CanvasOpenContext) -> CanvasOpenResponse: + return CanvasOpenResponse(url=f"https://example.test/{ctx.instance_id}") + + async def _create_counter_session( ctx: E2ETestContext, -) -> tuple[_CounterCanvasHandler, CopilotSession]: - handler = _CounterCanvasHandler() - session = await ctx.client.create_session( - on_permission_request=PermissionHandler.approve_all, - canvases=[_counter_canvas()], + handler: CanvasHandler, + *, + actions: list[CanvasAction] | None = None, +): + return await ctx.client.create_session( + canvases=[_counter_declaration(actions=actions)], + request_canvas_renderer=True, + extension_info=_EXTENSION_INFO, canvas_handler=handler, ) - return handler, session -class TestCanvasRpc: - async def test_should_list_canvases(self, ctx: E2ETestContext): - _handler, session = await _create_counter_session(ctx) - try: - result = await session.rpc.canvas.list() +class TestCanvas: + async def test_dispatches_canvas_open_to_the_provider_handler(self, ctx: E2ETestContext): + handler = _CounterHandler() + session = await _create_counter_session(ctx, handler) - assert len(result.canvases) == 1 - assert result.canvases[0].canvas_id == "counter" - assert result.canvases[0].display_name == "Counter" - assert result.canvases[0].description == "A simple counter canvas for e2e testing" - finally: - await session.disconnect() - - async def test_should_round_trip_canvas_open(self, ctx: E2ETestContext): - handler, session = await _create_counter_session(ctx) try: result = await session.rpc.canvas.open( CanvasOpenRequest( canvas_id="counter", instance_id="counter-1", - input={"startValue": 10}, + input={"seed": 7}, ) ) - assert result.url == "https://example.test/counter" - assert result.title == "Counter Canvas" - assert result.status == "ready" - assert len(handler.open_calls) == 1 - assert handler.open_calls[0].canvas_id == "counter" - assert handler.open_calls[0].instance_id == "counter-1" - assert handler.open_calls[0].input == {"startValue": 10} - - open_list = await session.rpc.canvas.list_open() - assert len(open_list.open_canvases) == 1 - assert open_list.open_canvases[0].instance_id == "counter-1" + assert len(handler.opens) == 1 + opened = handler.opens[0] + assert opened.canvas_id == "counter" + assert opened.instance_id == "counter-1" + assert opened.input == {"seed": 7} + assert result.canvas_id == "counter" + assert result.instance_id == "counter-1" + assert result.url == "https://example.test/counter-1" + assert result.availability == CanvasInstanceAvailability.READY finally: await session.disconnect() - async def test_should_invoke_canvas_action(self, ctx: E2ETestContext): - handler, session = await _create_counter_session(ctx) + async def test_dispatches_canvas_action_invoke_to_the_per_action_handler( + self, ctx: E2ETestContext + ): + handler = _CounterHandler() + session = await _create_counter_session( + ctx, + handler, + actions=[CanvasAction(name="increment", description="Increment the counter")], + ) try: await session.rpc.canvas.open( - CanvasOpenRequest( - canvas_id="counter", - instance_id="counter-2", - input={}, - ) + CanvasOpenRequest(canvas_id="counter", instance_id="counter-2") ) result = await session.rpc.canvas.invoke_action( CanvasInvokeActionRequest( action_name="increment", instance_id="counter-2", - input={"amount": 5}, + input={"amount": 3}, ) ) - assert result == {"result": {"newValue": 42}} - assert len(handler.action_calls) == 1 - assert handler.action_calls[0].canvas_id == "counter" - assert handler.action_calls[0].instance_id == "counter-2" - assert handler.action_calls[0].action_name == "increment" - assert handler.action_calls[0].input == {"amount": 5} + assert len(handler.actions) == 1 + action = handler.actions[0] + assert action.canvas_id == "counter" + assert action.instance_id == "counter-2" + assert action.action_name == "increment" + assert action.input == {"amount": 3} + assert result.result == { + "ok": True, + "actionName": "increment", + "input": {"amount": 3}, + } finally: await session.disconnect() - async def test_should_run_close_lifecycle(self, ctx: E2ETestContext): - handler, session = await _create_counter_session(ctx) + async def test_dispatches_canvas_close_to_the_provider_on_close_handler( + self, ctx: E2ETestContext + ): + handler = _CounterHandler() + session = await _create_counter_session(ctx, handler) + + try: + await session.rpc.canvas.open( + CanvasOpenRequest(canvas_id="counter", instance_id="counter-3") + ) + await session.rpc.canvas.close(CanvasCloseRequest(instance_id="counter-3")) + await asyncio.sleep(0.05) + + assert len(handler.closes) == 1 + closed = handler.closes[0] + assert closed.canvas_id == "counter" + assert closed.instance_id == "counter-3" + finally: + await session.disconnect() + + async def test_returns_canvas_action_no_handler_when_declared_action_has_no_handler( + self, ctx: E2ETestContext + ): + session = await _create_counter_session( + ctx, + _NoActionHandler(), + actions=[CanvasAction(name="increment", description="Increment the counter")], + ) try: await session.rpc.canvas.open( + CanvasOpenRequest(canvas_id="counter", instance_id="counter-4") + ) + + with pytest.raises(JsonRpcError) as excinfo: + await session.rpc.canvas.invoke_action( + CanvasInvokeActionRequest( + action_name="increment", + instance_id="counter-4", + input={}, + ) + ) + + assert excinfo.value.data == { + "code": "canvas_action_no_handler", + "message": "No handler implemented for this canvas action", + } + finally: + await session.disconnect() + + async def test_seeds_open_canvases_on_resume_from_the_runtime_resume_response( + self, ctx: E2ETestContext + ): + session_a = await _create_counter_session(ctx, _CounterHandler()) + try: + await session_a.rpc.canvas.open( CanvasOpenRequest( canvas_id="counter", - instance_id="counter-3", - input={}, + instance_id="counter-resume", + input={"initial": True}, ) ) - await session.rpc.canvas.close(CanvasCloseRequest(instance_id="counter-3")) - assert len(handler.close_calls) == 1 - assert handler.close_calls[0].canvas_id == "counter" - assert handler.close_calls[0].instance_id == "counter-3" + resumed = await ctx.client.resume_session( + session_a.session_id, + canvases=[_counter_declaration()], + request_canvas_renderer=True, + extension_info=_EXTENSION_INFO, + canvas_handler=_CounterHandler(), + ) - open_list = await session.rpc.canvas.list_open() - assert open_list.open_canvases == [] + try: + matching = [ + canvas + for canvas in resumed.open_canvases + if canvas.instance_id == "counter-resume" + ] + assert len(matching) == 1 + assert matching[0].canvas_id == "counter" + finally: + await resumed.disconnect() finally: - await session.disconnect() + await session_a.disconnect() diff --git a/rust/tests/e2e/canvas.rs b/rust/tests/e2e/canvas.rs index 2452f5319..86ec2b235 100644 --- a/rust/tests/e2e/canvas.rs +++ b/rust/tests/e2e/canvas.rs @@ -1,283 +1,426 @@ use std::sync::Arc; +use std::time::Duration; use async_trait::async_trait; -use github_copilot_sdk::canvas::{CanvasDeclaration, CanvasHandler, CanvasResult}; +use github_copilot_sdk::Error; +use github_copilot_sdk::canvas::{ + CanvasDeclaration, CanvasHandler, CanvasOpenContext, CanvasOpenResponse, CanvasResult, +}; use github_copilot_sdk::generated::api_types::{ - CanvasAction, CanvasProviderCloseRequest, CanvasProviderInvokeActionRequest, - CanvasProviderOpenRequest, CanvasProviderOpenResult, + CanvasAction, CanvasCloseRequest, CanvasInstanceAvailability, CanvasInvokeActionRequest, + CanvasOpenRequest, }; -use github_copilot_sdk::types::ExtensionInfo; +use github_copilot_sdk::types::{ExtensionInfo, ResumeSessionConfig}; use parking_lot::Mutex; use serde_json::{Value, json}; -use super::support::with_e2e_context; +use super::support::{DEFAULT_TEST_TOKEN, with_e2e_context}; -struct TestCanvasHandler { - open_calls: Mutex>, - close_calls: Mutex>, - action_calls: Mutex>, +#[derive(Debug, PartialEq)] +struct OpenCall { + canvas_id: String, + instance_id: String, + input: Value, } -impl TestCanvasHandler { - fn new() -> Self { - Self { - open_calls: Mutex::new(Vec::new()), - close_calls: Mutex::new(Vec::new()), - action_calls: Mutex::new(Vec::new()), - } - } +#[derive(Debug, PartialEq)] +struct ActionCall { + action_name: String, + instance_id: String, + input: Value, +} + +#[derive(Debug, PartialEq)] +struct CloseCall { + canvas_id: String, + instance_id: String, +} + +#[derive(Default)] +struct CanvasCalls { + opens: Mutex>, + actions: Mutex>, + closes: Mutex>, +} + +struct CounterHandler { + calls: Arc, } #[async_trait] -impl CanvasHandler for TestCanvasHandler { - async fn on_open( - &self, - ctx: CanvasProviderOpenRequest, - ) -> CanvasResult { - self.open_calls.lock().push(ctx.clone()); - Ok(CanvasProviderOpenResult { - url: Some(format!("https://example.com/counter/{}", ctx.instance_id)), - title: Some(format!("Counter {}", ctx.instance_id)), - status: Some("ready".to_string()), +impl CanvasHandler for CounterHandler { + async fn on_open(&self, ctx: CanvasOpenContext) -> CanvasResult { + record_open(&self.calls, &ctx); + Ok(CanvasOpenResponse { + url: Some(format!("https://example.test/{}", ctx.instance_id)), + title: None, + status: None, }) } - async fn on_action(&self, ctx: CanvasProviderInvokeActionRequest) -> CanvasResult { - self.action_calls.lock().push(ctx.clone()); - Ok(json!({ "newValue": 42 })) + async fn on_action( + &self, + ctx: github_copilot_sdk::canvas::CanvasActionContext, + ) -> CanvasResult { + self.calls.actions.lock().push(ActionCall { + action_name: ctx.action_name.clone(), + instance_id: ctx.instance_id, + input: ctx.input.clone(), + }); + Ok(json!({ + "ok": true, + "actionName": ctx.action_name, + "input": ctx.input, + })) } - async fn on_close(&self, ctx: CanvasProviderCloseRequest) -> CanvasResult<()> { - self.close_calls.lock().push(ctx.clone()); + async fn on_close( + &self, + ctx: github_copilot_sdk::canvas::CanvasLifecycleContext, + ) -> CanvasResult<()> { + self.calls.closes.lock().push(CloseCall { + canvas_id: ctx.canvas_id, + instance_id: ctx.instance_id, + }); Ok(()) } } -fn canvas_session_config( - ctx: &super::support::E2eContext, - handler: Arc, -) -> github_copilot_sdk::types::SessionConfig { - let mut decl = CanvasDeclaration::new("counter", "Counter", "Tracks a counter value."); - decl.actions = Some(vec![CanvasAction { - name: "increment".to_string(), - description: Some("Increments the counter.".to_string()), - input_schema: None, - }]); - - ctx.approve_all_session_config() - .with_request_canvas_renderer(true) - .with_request_extensions(true) - .with_extension_info(ExtensionInfo::new("rust-sdk-tests", "canvas-provider")) - .with_canvases([decl]) - .with_canvas_handler(handler) +struct OpenOnlyHandler { + calls: Arc, } -#[tokio::test] -async fn canvas_list_discovers_declared_canvases() { - with_e2e_context("canvas", "canvas_list_discovers_declared_canvases", |ctx| { - Box::pin(async move { - ctx.set_default_copilot_user(); - let client = ctx.start_client().await; - let handler = Arc::new(TestCanvasHandler::new()); - let session = client - .create_session(canvas_session_config(ctx, handler)) - .await - .expect("create session"); - - let result = session.rpc().canvas().list().await.expect("list canvases"); - - assert_eq!(result.canvases.len(), 1); - assert_eq!(result.canvases[0].canvas_id, "counter"); - assert_eq!(result.canvases[0].display_name, "Counter"); - assert_eq!(result.canvases[0].description, "Tracks a counter value."); - - session.disconnect().await.expect("disconnect session"); - client.stop().await.expect("stop client"); +#[async_trait] +impl CanvasHandler for OpenOnlyHandler { + async fn on_open(&self, ctx: CanvasOpenContext) -> CanvasResult { + record_open(&self.calls, &ctx); + Ok(CanvasOpenResponse { + url: Some(format!("https://example.test/{}", ctx.instance_id)), + title: None, + status: None, }) - }) - .await; + } } #[tokio::test] -async fn canvas_open_round_trip() { - with_e2e_context("canvas", "canvas_open_round_trip", |ctx| { - Box::pin(async move { - ctx.set_default_copilot_user(); - let client = ctx.start_client().await; - let handler = Arc::new(TestCanvasHandler::new()); - let session = client - .create_session(canvas_session_config(ctx, handler.clone())) - .await - .expect("create session"); - - let canvas_list = session.rpc().canvas().list().await.expect("list canvases"); - let canvas = &canvas_list.canvases[0]; - - let open_result = session - .rpc() - .canvas() - .open( - github_copilot_sdk::generated::api_types::CanvasOpenRequest { +async fn dispatches_canvas_open_to_the_provider_handler() { + with_e2e_context( + "canvas", + "dispatches_canvas_open_to_the_provider_handler", + |ctx| { + Box::pin(async move { + ctx.set_default_copilot_user(); + let calls = Arc::new(CanvasCalls::default()); + let client = ctx.start_client().await; + let session = client + .create_session(canvas_session_config(Arc::new(CounterHandler { + calls: calls.clone(), + }))) + .await + .expect("create session"); + + let result = session + .rpc() + .canvas() + .open(CanvasOpenRequest { canvas_id: "counter".to_string(), + extension_id: None, + input: Some(json!({ "seed": 7 })), instance_id: "counter-1".to_string(), - extension_id: Some(canvas.extension_id.clone()), - input: Some(json!({ "start": 41 })), - }, - ) - .await - .expect("open canvas"); - - assert_eq!(open_result.instance_id, "counter-1"); - assert_eq!(open_result.title.as_deref(), Some("Counter counter-1")); - assert_eq!(open_result.status.as_deref(), Some("ready")); - assert_eq!( - open_result.url.as_deref(), - Some("https://example.com/counter/counter-1") - ); - - { - let opens = handler.open_calls.lock(); - assert_eq!(opens.len(), 1); - assert_eq!(opens[0].canvas_id, "counter"); - assert_eq!(opens[0].instance_id, "counter-1"); - } - - let open_list = session - .rpc() - .canvas() - .list_open() - .await - .expect("list open canvases"); - assert_eq!(open_list.open_canvases.len(), 1); - assert_eq!(open_list.open_canvases[0].instance_id, "counter-1"); - - session.disconnect().await.expect("disconnect session"); - client.stop().await.expect("stop client"); - }) - }) + }) + .await + .expect("open canvas"); + + assert_eq!( + calls.opens.lock().as_slice(), + [OpenCall { + canvas_id: "counter".to_string(), + instance_id: "counter-1".to_string(), + input: json!({ "seed": 7 }), + }] + ); + assert_eq!(result.canvas_id, "counter"); + assert_eq!(result.instance_id, "counter-1"); + assert_eq!( + result.url.as_deref(), + Some("https://example.test/counter-1") + ); + assert_eq!(result.availability, CanvasInstanceAvailability::Ready); + + session.disconnect().await.expect("disconnect session"); + client.stop().await.expect("stop client"); + }) + }, + ) .await; } #[tokio::test] -async fn canvas_invoke_action_round_trip() { - with_e2e_context("canvas", "canvas_invoke_action_round_trip", |ctx| { - Box::pin(async move { - ctx.set_default_copilot_user(); - let client = ctx.start_client().await; - let handler = Arc::new(TestCanvasHandler::new()); - let session = client - .create_session(canvas_session_config(ctx, handler.clone())) - .await - .expect("create session"); - - let canvas_list = session.rpc().canvas().list().await.expect("list canvases"); - let canvas = &canvas_list.canvases[0]; - - session - .rpc() - .canvas() - .open( - github_copilot_sdk::generated::api_types::CanvasOpenRequest { +async fn dispatches_canvas_action_invoke_to_the_per_action_handler() { + with_e2e_context( + "canvas", + "dispatches_canvas_action_invoke_to_the_per_action_handler", + |ctx| { + Box::pin(async move { + ctx.set_default_copilot_user(); + let calls = Arc::new(CanvasCalls::default()); + let client = ctx.start_client().await; + let session = client + .create_session(canvas_session_config(Arc::new(CounterHandler { + calls: calls.clone(), + }))) + .await + .expect("create session"); + + session + .rpc() + .canvas() + .open(CanvasOpenRequest { canvas_id: "counter".to_string(), + extension_id: None, + input: None, instance_id: "counter-2".to_string(), - extension_id: Some(canvas.extension_id.clone()), - input: Some(json!({})), - }, - ) - .await - .expect("open canvas"); - - let result = session - .rpc() - .canvas() - .invoke_action( - github_copilot_sdk::generated::api_types::CanvasInvokeActionRequest { + }) + .await + .expect("open canvas"); + let result = session + .rpc() + .canvas() + .invoke_action(CanvasInvokeActionRequest { + action_name: "increment".to_string(), + input: Some(json!({ "amount": 3 })), instance_id: "counter-2".to_string(), + }) + .await + .expect("invoke action"); + + assert_eq!( + calls.actions.lock().as_slice(), + [ActionCall { action_name: "increment".to_string(), - input: Some(json!({ "delta": 1 })), - }, - ) - .await - .expect("invoke action"); - - assert_eq!(result.result, Some(json!({ "newValue": 42 }))); - - { - let actions = handler.action_calls.lock(); - assert_eq!(actions.len(), 1); - assert_eq!(actions[0].canvas_id, "counter"); - assert_eq!(actions[0].instance_id, "counter-2"); - assert_eq!(actions[0].action_name, "increment"); - assert_eq!(actions[0].input, Some(json!({ "delta": 1 }))); - } - - session.disconnect().await.expect("disconnect session"); - client.stop().await.expect("stop client"); - }) - }) + instance_id: "counter-2".to_string(), + input: json!({ "amount": 3 }), + }] + ); + assert_eq!( + result.result, + Some(json!({ + "ok": true, + "actionName": "increment", + "input": { "amount": 3 }, + })) + ); + + session.disconnect().await.expect("disconnect session"); + client.stop().await.expect("stop client"); + }) + }, + ) .await; } #[tokio::test] -async fn canvas_close_round_trip() { - with_e2e_context("canvas", "canvas_close_round_trip", |ctx| { - Box::pin(async move { - ctx.set_default_copilot_user(); - let client = ctx.start_client().await; - let handler = Arc::new(TestCanvasHandler::new()); - let session = client - .create_session(canvas_session_config(ctx, handler.clone())) - .await - .expect("create session"); - - let canvas_list = session.rpc().canvas().list().await.expect("list canvases"); - let canvas = &canvas_list.canvases[0]; - - session - .rpc() - .canvas() - .open( - github_copilot_sdk::generated::api_types::CanvasOpenRequest { +async fn dispatches_canvas_close_to_the_provider_on_close_handler() { + with_e2e_context( + "canvas", + "dispatches_canvas_close_to_the_provider_on_close_handler", + |ctx| { + Box::pin(async move { + ctx.set_default_copilot_user(); + let calls = Arc::new(CanvasCalls::default()); + let client = ctx.start_client().await; + let session = client + .create_session(canvas_session_config(Arc::new(CounterHandler { + calls: calls.clone(), + }))) + .await + .expect("create session"); + + session + .rpc() + .canvas() + .open(CanvasOpenRequest { canvas_id: "counter".to_string(), + extension_id: None, + input: None, instance_id: "counter-3".to_string(), - extension_id: Some(canvas.extension_id.clone()), - input: Some(json!({})), - }, - ) - .await - .expect("open canvas"); - - assert!(handler.close_calls.lock().is_empty()); - - session - .rpc() - .canvas() - .close( - github_copilot_sdk::generated::api_types::CanvasCloseRequest { + }) + .await + .expect("open canvas"); + session + .rpc() + .canvas() + .close(CanvasCloseRequest { instance_id: "counter-3".to_string(), - }, - ) - .await - .expect("close canvas"); - - { - let closes = handler.close_calls.lock(); - assert_eq!(closes.len(), 1); - assert_eq!(closes[0].canvas_id, "counter"); - assert_eq!(closes[0].instance_id, "counter-3"); - } - - let open_list = session - .rpc() - .canvas() - .list_open() - .await - .expect("list open canvases"); - assert!(open_list.open_canvases.is_empty()); - - session.disconnect().await.expect("disconnect session"); - client.stop().await.expect("stop client"); - }) - }) + }) + .await + .expect("close canvas"); + tokio::time::sleep(Duration::from_millis(50)).await; + + assert_eq!( + calls.closes.lock().as_slice(), + [CloseCall { + canvas_id: "counter".to_string(), + instance_id: "counter-3".to_string(), + }] + ); + + session.disconnect().await.expect("disconnect session"); + client.stop().await.expect("stop client"); + }) + }, + ) + .await; +} + +#[tokio::test] +async fn returns_canvas_action_no_handler_when_the_declared_action_has_no_handler() { + with_e2e_context( + "canvas", + "returns_canvas_action_no_handler_when_the_declared_action_has_no_handler", + |ctx| { + Box::pin(async move { + ctx.set_default_copilot_user(); + let client = ctx.start_client().await; + let session = client + .create_session(canvas_session_config(Arc::new(OpenOnlyHandler { + calls: Arc::new(CanvasCalls::default()), + }))) + .await + .expect("create session"); + + session + .rpc() + .canvas() + .open(CanvasOpenRequest { + canvas_id: "counter".to_string(), + extension_id: None, + input: None, + instance_id: "counter-4".to_string(), + }) + .await + .expect("open canvas"); + let err = session + .rpc() + .canvas() + .invoke_action(CanvasInvokeActionRequest { + action_name: "increment".to_string(), + input: Some(json!({})), + instance_id: "counter-4".to_string(), + }) + .await + .expect_err("invoke action should fail"); + + assert_rpc_error_contains(&err, "No handler implemented for this canvas action"); + + session.disconnect().await.expect("disconnect session"); + client.stop().await.expect("stop client"); + }) + }, + ) + .await; +} + +#[tokio::test] +async fn seeds_open_canvases_on_resume_from_the_runtime_resume_response() { + with_e2e_context( + "canvas", + "seeds_open_canvases_on_resume_from_the_runtime_resume_response", + |ctx| { + Box::pin(async move { + ctx.set_default_copilot_user(); + let client = ctx.start_client().await; + let session = client + .create_session(canvas_session_config(Arc::new(CounterHandler { + calls: Arc::new(CanvasCalls::default()), + }))) + .await + .expect("create session"); + + session + .rpc() + .canvas() + .open(CanvasOpenRequest { + canvas_id: "counter".to_string(), + extension_id: None, + input: Some(json!({ "initial": true })), + instance_id: "counter-resume".to_string(), + }) + .await + .expect("open canvas"); + + let resumed = client + .resume_session( + ResumeSessionConfig::new(session.id().clone()) + .with_canvases([counter_canvas()]) + .with_canvas_handler(Arc::new(CounterHandler { + calls: Arc::new(CanvasCalls::default()), + })) + .with_request_canvas_renderer(true) + .with_extension_info(extension_info()) + .with_github_token(DEFAULT_TEST_TOKEN), + ) + .await + .expect("resume session"); + + let seeded = resumed.open_canvases(); + assert!( + seeded + .iter() + .any(|canvas| canvas.instance_id == "counter-resume" + && canvas.canvas_id == "counter") + ); + + resumed + .disconnect() + .await + .expect("disconnect resumed session"); + session.stop_event_loop().await; + client.stop().await.expect("stop client"); + }) + }, + ) .await; } + +fn record_open(calls: &CanvasCalls, ctx: &CanvasOpenContext) { + calls.opens.lock().push(OpenCall { + canvas_id: ctx.canvas_id.clone(), + instance_id: ctx.instance_id.clone(), + input: ctx.input.clone(), + }); +} + +fn canvas_session_config(handler: Arc) -> github_copilot_sdk::SessionConfig { + github_copilot_sdk::SessionConfig::default() + .with_permission_handler(Arc::new(github_copilot_sdk::handler::ApproveAllHandler)) + .with_github_token(DEFAULT_TEST_TOKEN) + .with_canvases([counter_canvas()]) + .with_canvas_handler(handler) + .with_request_canvas_renderer(true) + .with_extension_info(extension_info()) +} + +fn counter_canvas() -> CanvasDeclaration { + let mut canvas = CanvasDeclaration::new("counter", "Counter", "A test counter canvas"); + canvas.actions = Some(vec![CanvasAction { + name: "increment".to_string(), + description: Some("Increment the counter".to_string()), + input_schema: None, + }]); + canvas +} + +fn extension_info() -> ExtensionInfo { + ExtensionInfo::new("github-app", "counter-provider") +} + +fn assert_rpc_error_contains(err: &Error, expected: &str) { + match err { + Error::Rpc { message, .. } => assert!( + message.contains(expected), + "expected RPC error message to contain {expected:?}, got {message:?}" + ), + other => panic!("expected RPC error, got {other:?}"), + } +} diff --git a/test/snapshots/canvas/dispatches_canvas_action_invoke.yaml b/test/snapshots/canvas/dispatches_canvas_action_invoke.yaml new file mode 100644 index 000000000..056351ddb --- /dev/null +++ b/test/snapshots/canvas/dispatches_canvas_action_invoke.yaml @@ -0,0 +1,3 @@ +models: + - claude-sonnet-4.5 +conversations: [] diff --git a/test/snapshots/canvas/dispatches_canvas_action_invoke_to_the_per_action_handler.yaml b/test/snapshots/canvas/dispatches_canvas_action_invoke_to_the_per_action_handler.yaml new file mode 100644 index 000000000..056351ddb --- /dev/null +++ b/test/snapshots/canvas/dispatches_canvas_action_invoke_to_the_per_action_handler.yaml @@ -0,0 +1,3 @@ +models: + - claude-sonnet-4.5 +conversations: [] diff --git a/test/snapshots/canvas/dispatches_canvas_close.yaml b/test/snapshots/canvas/dispatches_canvas_close.yaml new file mode 100644 index 000000000..056351ddb --- /dev/null +++ b/test/snapshots/canvas/dispatches_canvas_close.yaml @@ -0,0 +1,3 @@ +models: + - claude-sonnet-4.5 +conversations: [] diff --git a/test/snapshots/canvas/dispatches_canvas_close_to_the_provider_on_close_handler.yaml b/test/snapshots/canvas/dispatches_canvas_close_to_the_provider_on_close_handler.yaml new file mode 100644 index 000000000..056351ddb --- /dev/null +++ b/test/snapshots/canvas/dispatches_canvas_close_to_the_provider_on_close_handler.yaml @@ -0,0 +1,3 @@ +models: + - claude-sonnet-4.5 +conversations: [] diff --git a/test/snapshots/canvas/dispatches_canvas_close_to_the_provider_onclose_handler.yaml b/test/snapshots/canvas/dispatches_canvas_close_to_the_provider_onclose_handler.yaml new file mode 100644 index 000000000..056351ddb --- /dev/null +++ b/test/snapshots/canvas/dispatches_canvas_close_to_the_provider_onclose_handler.yaml @@ -0,0 +1,3 @@ +models: + - claude-sonnet-4.5 +conversations: [] diff --git a/test/snapshots/canvas/dispatches_canvas_open.yaml b/test/snapshots/canvas/dispatches_canvas_open.yaml new file mode 100644 index 000000000..056351ddb --- /dev/null +++ b/test/snapshots/canvas/dispatches_canvas_open.yaml @@ -0,0 +1,3 @@ +models: + - claude-sonnet-4.5 +conversations: [] diff --git a/test/snapshots/canvas/dispatches_canvas_open_to_the_provider_handler.yaml b/test/snapshots/canvas/dispatches_canvas_open_to_the_provider_handler.yaml new file mode 100644 index 000000000..056351ddb --- /dev/null +++ b/test/snapshots/canvas/dispatches_canvas_open_to_the_provider_handler.yaml @@ -0,0 +1,3 @@ +models: + - claude-sonnet-4.5 +conversations: [] diff --git a/test/snapshots/canvas/dispatchescanvasactioninvoketohandler.yaml b/test/snapshots/canvas/dispatchescanvasactioninvoketohandler.yaml new file mode 100644 index 000000000..056351ddb --- /dev/null +++ b/test/snapshots/canvas/dispatchescanvasactioninvoketohandler.yaml @@ -0,0 +1,3 @@ +models: + - claude-sonnet-4.5 +conversations: [] diff --git a/test/snapshots/canvas/dispatchescanvasclosetoonclosehandler.yaml b/test/snapshots/canvas/dispatchescanvasclosetoonclosehandler.yaml new file mode 100644 index 000000000..056351ddb --- /dev/null +++ b/test/snapshots/canvas/dispatchescanvasclosetoonclosehandler.yaml @@ -0,0 +1,3 @@ +models: + - claude-sonnet-4.5 +conversations: [] diff --git a/test/snapshots/canvas/dispatchescanvasopentoproviderhandler.yaml b/test/snapshots/canvas/dispatchescanvasopentoproviderhandler.yaml new file mode 100644 index 000000000..056351ddb --- /dev/null +++ b/test/snapshots/canvas/dispatchescanvasopentoproviderhandler.yaml @@ -0,0 +1,3 @@ +models: + - claude-sonnet-4.5 +conversations: [] diff --git a/test/snapshots/canvas/rejects_invokeaction_for_an_action_the_canvas_did_not_declare.yaml b/test/snapshots/canvas/rejects_invokeaction_for_an_action_the_canvas_did_not_declare.yaml new file mode 100644 index 000000000..056351ddb --- /dev/null +++ b/test/snapshots/canvas/rejects_invokeaction_for_an_action_the_canvas_did_not_declare.yaml @@ -0,0 +1,3 @@ +models: + - claude-sonnet-4.5 +conversations: [] diff --git a/test/snapshots/canvas/returns_canvas_action_no_handler.yaml b/test/snapshots/canvas/returns_canvas_action_no_handler.yaml new file mode 100644 index 000000000..056351ddb --- /dev/null +++ b/test/snapshots/canvas/returns_canvas_action_no_handler.yaml @@ -0,0 +1,3 @@ +models: + - claude-sonnet-4.5 +conversations: [] diff --git a/test/snapshots/canvas/returns_canvas_action_no_handler_when_declared_action_has_no_handler.yaml b/test/snapshots/canvas/returns_canvas_action_no_handler_when_declared_action_has_no_handler.yaml new file mode 100644 index 000000000..056351ddb --- /dev/null +++ b/test/snapshots/canvas/returns_canvas_action_no_handler_when_declared_action_has_no_handler.yaml @@ -0,0 +1,3 @@ +models: + - claude-sonnet-4.5 +conversations: [] diff --git a/test/snapshots/canvas/returns_canvas_action_no_handler_when_the_declared_action_has_no_handler.yaml b/test/snapshots/canvas/returns_canvas_action_no_handler_when_the_declared_action_has_no_handler.yaml new file mode 100644 index 000000000..056351ddb --- /dev/null +++ b/test/snapshots/canvas/returns_canvas_action_no_handler_when_the_declared_action_has_no_handler.yaml @@ -0,0 +1,3 @@ +models: + - claude-sonnet-4.5 +conversations: [] diff --git a/test/snapshots/canvas/returnscanvasactionnohandlerfordeclaredactionwithouthandler.yaml b/test/snapshots/canvas/returnscanvasactionnohandlerfordeclaredactionwithouthandler.yaml new file mode 100644 index 000000000..056351ddb --- /dev/null +++ b/test/snapshots/canvas/returnscanvasactionnohandlerfordeclaredactionwithouthandler.yaml @@ -0,0 +1,3 @@ +models: + - claude-sonnet-4.5 +conversations: [] diff --git a/test/snapshots/canvas/seeds_open_canvases_on_resume.yaml b/test/snapshots/canvas/seeds_open_canvases_on_resume.yaml new file mode 100644 index 000000000..056351ddb --- /dev/null +++ b/test/snapshots/canvas/seeds_open_canvases_on_resume.yaml @@ -0,0 +1,3 @@ +models: + - claude-sonnet-4.5 +conversations: [] diff --git a/test/snapshots/canvas/seeds_open_canvases_on_resume_from_the_runtime_resume_response.yaml b/test/snapshots/canvas/seeds_open_canvases_on_resume_from_the_runtime_resume_response.yaml new file mode 100644 index 000000000..056351ddb --- /dev/null +++ b/test/snapshots/canvas/seeds_open_canvases_on_resume_from_the_runtime_resume_response.yaml @@ -0,0 +1,3 @@ +models: + - claude-sonnet-4.5 +conversations: [] diff --git a/test/snapshots/canvas/seeds_opencanvases_on_resume_from_the_runtime_resume_response.yaml b/test/snapshots/canvas/seeds_opencanvases_on_resume_from_the_runtime_resume_response.yaml new file mode 100644 index 000000000..056351ddb --- /dev/null +++ b/test/snapshots/canvas/seeds_opencanvases_on_resume_from_the_runtime_resume_response.yaml @@ -0,0 +1,3 @@ +models: + - claude-sonnet-4.5 +conversations: [] diff --git a/test/snapshots/canvas/seedsopencanvasesonresumefromruntime.yaml b/test/snapshots/canvas/seedsopencanvasesonresumefromruntime.yaml new file mode 100644 index 000000000..056351ddb --- /dev/null +++ b/test/snapshots/canvas/seedsopencanvasesonresumefromruntime.yaml @@ -0,0 +1,3 @@ +models: + - claude-sonnet-4.5 +conversations: []