diff --git a/dotnet/src/Canvas.cs b/dotnet/src/Canvas.cs index 6a6134e18..b0360056b 100644 --- a/dotnet/src/Canvas.cs +++ b/dotnet/src/Canvas.cs @@ -203,7 +203,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 new file mode 100644 index 000000000..d451e4609 --- /dev/null +++ b/dotnet/test/E2E/CanvasE2ETests.cs @@ -0,0 +1,195 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +using GitHub.Copilot.Rpc; +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) +{ + [Fact] + public async Task DispatchesCanvasOpenToProviderHandler() + { + var opens = new List(); + await using var session = await CreateSessionAsync(CreateCanvasSessionConfig(new RecordingCanvasHandler(opens: opens))); + + var result = await session.Rpc.Canvas.OpenAsync( + canvasId: "counter", + instanceId: "counter-1", + 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 DispatchesCanvasActionInvokeToHandler() + { + 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-2", + actionName: "increment", + 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 DispatchesCanvasCloseToOnCloseHandler() + { + var closes = new List(); + await using var session = await CreateSessionAsync(CreateCanvasSessionConfig(new RecordingCanvasHandler(closes: closes))); + + await session.Rpc.Canvas.OpenAsync(canvasId: "counter", instanceId: "counter-3"); + await session.Rpc.Canvas.CloseAsync(instanceId: "counter-3"); + await Task.Delay(50); + + var close = Assert.Single(closes); + Assert.Equal("counter", close.CanvasId); + Assert.Equal("counter-3", close.InstanceId); + } + + [Fact] + public async Task ReturnsCanvasActionNoHandlerForDeclaredActionWithoutHandler() + { + 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); + } + + [Fact] + public async Task SeedsOpenCanvasesOnResumeFromRuntime() + { + 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 static SessionConfig CreateCanvasSessionConfig(ICanvasHandler handler) => new() + { + 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 + { + Name = "increment", + Description = "Increment the counter", + }, + ], + }; + + private class OpenOnlyCanvasHandler : CanvasHandlerBase + { + public override Task OnOpenAsync(CanvasOpenContext context, CancellationToken cancellationToken) + => Task.FromResult(new CanvasOpenResponse { Url = $"https://example.test/{context.InstanceId}" }); + } + + private sealed class RecordingCanvasHandler( + List? opens = null, + List? closes = null, + List? actions = null) : OpenOnlyCanvasHandler + { + public override Task OnOpenAsync(CanvasOpenContext context, CancellationToken cancellationToken) + { + opens?.Add(CloneOpenContext(context)); + return base.OnOpenAsync(context, cancellationToken); + } + + public override Task OnCloseAsync(CanvasLifecycleContext context, CancellationToken cancellationToken) + { + closes?.Add(context); + return Task.CompletedTask; + } + + public override Task OnActionAsync(CanvasActionContext context, CancellationToken cancellationToken) + { + actions?.Add(CloneActionContext(context)); + return Task.FromResult(new Dictionary + { + ["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 new file mode 100644 index 000000000..bbd91084b --- /dev/null +++ b/go/internal/e2e/canvas_e2e_test.go @@ -0,0 +1,338 @@ +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) { + t.Run("dispatches_canvas_open", func(t *testing.T) { + ctx := newCanvasTestContext(t) + client := ctx.NewClient() + t.Cleanup(func() { client.ForceStop() }) + + 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") + }) + + t.Run("dispatches_canvas_action_invoke", 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-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}}`) + }) + + 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) + } + }) + + 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) + }) +} + +type recordingCanvasE2EHandler struct { + copilot.CanvasHandlerDefaults + + mu sync.Mutex + openCalls []copilot.CanvasOpenContext + closeCalls []copilot.CanvasLifecycleContext + actionCalls []copilot.CanvasActionContext +} + +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 +} + +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 +} + +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 *recordingCanvasE2EHandler) openCallsSnapshot() []copilot.CanvasOpenContext { + h.mu.Lock() + defer h.mu.Unlock() + return append([]copilot.CanvasOpenContext(nil), h.openCalls...) +} + +func (h *recordingCanvasE2EHandler) closeCallsSnapshot() []copilot.CanvasLifecycleContext { + h.mu.Lock() + defer h.mu.Unlock() + return append([]copilot.CanvasLifecycleContext(nil), h.closeCalls...) +} + +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 +} + +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}, + }, + }, + } +} + +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, + }) + if err != nil { + t.Fatalf("Canvas.Open failed: %v", err) + } + return result +} + +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 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 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 assertJSONRPCErrorCode(t *testing.T, err error, wantCode string) { + t.Helper() + rpcErr, ok := err.(*jsonrpc2.Error) + if !ok { + t.Fatalf("expected *jsonrpc2.Error, got %T: %v", err, err) + } + 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) + } +} diff --git a/nodejs/test/e2e/canvas.e2e.test.ts b/nodejs/test/e2e/canvas.e2e.test.ts new file mode 100644 index 000000000..eea107412 --- /dev/null +++ b/nodejs/test/e2e/canvas.e2e.test.ts @@ -0,0 +1,201 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +import { describe, expect, it } from "vitest"; +import { approveAll, createCanvas } from "../../src/index.js"; +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}` }; + }, + onClose: ({ instanceId, canvasId }) => { + record.close?.push({ instanceId, canvasId }); + }, + }); + } + + 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: [makeCounter({ open: opens })], + requestCanvasRenderer: true, + extensionInfo: { source: "github-app", name: "counter-provider" }, + }); + + try { + const result = await session.rpc.canvas.open({ + canvasId: "counter", + instanceId: "counter-1", + input: { seed: 7 }, + }); + + 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("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: [makeCounter({ open: opens, action: actions })], + requestCanvasRenderer: true, + extensionInfo: { source: "github-app", name: "counter-provider" }, + }); + + try { + await session.rpc.canvas.open({ canvasId: "counter", instanceId: "counter-2" }); + + const result = await session.rpc.canvas.invokeAction({ + canvasId: "counter", + instanceId: "counter-2", + actionName: "increment", + input: { amount: 3 }, + }); + + 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("dispatches canvas.close to the provider onClose handler", async () => { + const closes: { instanceId: string; canvasId: string }[] = []; + const session = await client.createSession({ + onPermissionRequest: approveAll, + canvases: [makeCounter({ close: closes })], + requestCanvasRenderer: true, + extensionInfo: { source: "github-app", name: "counter-provider" }, + }); + + try { + await session.rpc.canvas.open({ canvasId: "counter", instanceId: "counter-3" }); + await session.rpc.canvas.close({ canvasId: "counter", instanceId: "counter-3" }); + + // onClose is fire-and-forget on the runtime side; allow a microtask flush. + await new Promise((r) => setTimeout(r, 50)); + + expect(closes).toEqual([{ instanceId: "counter-3", canvasId: "counter" }]); + } finally { + await session.disconnect(); + } + }); + + 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: [makeCounter({})], + requestCanvasRenderer: true, + extensionInfo: { source: "github-app", name: "counter-provider" }, + }); + + 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(); + } + }); + + 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" }, + }); + + 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" }, + }); + + 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 new file mode 100644 index 000000000..939ea7eb1 --- /dev/null +++ b/python/e2e/test_canvas_e2e.py @@ -0,0 +1,225 @@ +"""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, +) + +from .testharness import E2ETestContext + +pytestmark = pytest.mark.asyncio(loop_scope="module") + + +_EXTENSION_INFO = ExtensionInfo(source="github-app", name="counter-provider") + + +def _counter_declaration(*, actions: list[CanvasAction] | None = None) -> CanvasDeclaration: + return CanvasDeclaration( + id="counter", + display_name="Counter", + 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, + 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, + ) + + +class TestCanvas: + async def test_dispatches_canvas_open_to_the_provider_handler(self, ctx: E2ETestContext): + handler = _CounterHandler() + session = await _create_counter_session(ctx, handler) + + try: + result = await session.rpc.canvas.open( + CanvasOpenRequest( + canvas_id="counter", + instance_id="counter-1", + input={"seed": 7}, + ) + ) + + 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_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") + ) + + result = await session.rpc.canvas.invoke_action( + CanvasInvokeActionRequest( + action_name="increment", + instance_id="counter-2", + input={"amount": 3}, + ) + ) + + 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_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-resume", + input={"initial": True}, + ) + ) + + resumed = await ctx.client.resume_session( + session_a.session_id, + canvases=[_counter_declaration()], + request_canvas_renderer=True, + extension_info=_EXTENSION_INFO, + canvas_handler=_CounterHandler(), + ) + + 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_a.disconnect() diff --git a/rust/tests/e2e.rs b/rust/tests/e2e.rs index 09ece6cf5..12863aff4 100644 --- a/rust/tests/e2e.rs +++ b/rust/tests/e2e.rs @@ -7,6 +7,8 @@ mod abort; mod ask_user; #[path = "e2e/builtin_tools.rs"] mod builtin_tools; +#[path = "e2e/canvas.rs"] +mod canvas; #[path = "e2e/client.rs"] mod client; #[path = "e2e/client_api.rs"] diff --git a/rust/tests/e2e/canvas.rs b/rust/tests/e2e/canvas.rs new file mode 100644 index 000000000..86ec2b235 --- /dev/null +++ b/rust/tests/e2e/canvas.rs @@ -0,0 +1,426 @@ +use std::sync::Arc; +use std::time::Duration; + +use async_trait::async_trait; +use github_copilot_sdk::Error; +use github_copilot_sdk::canvas::{ + CanvasDeclaration, CanvasHandler, CanvasOpenContext, CanvasOpenResponse, CanvasResult, +}; +use github_copilot_sdk::generated::api_types::{ + CanvasAction, CanvasCloseRequest, CanvasInstanceAvailability, CanvasInvokeActionRequest, + CanvasOpenRequest, +}; +use github_copilot_sdk::types::{ExtensionInfo, ResumeSessionConfig}; +use parking_lot::Mutex; +use serde_json::{Value, json}; + +use super::support::{DEFAULT_TEST_TOKEN, with_e2e_context}; + +#[derive(Debug, PartialEq)] +struct OpenCall { + canvas_id: String, + instance_id: String, + input: Value, +} + +#[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 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: 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: github_copilot_sdk::canvas::CanvasLifecycleContext, + ) -> CanvasResult<()> { + self.calls.closes.lock().push(CloseCall { + canvas_id: ctx.canvas_id, + instance_id: ctx.instance_id, + }); + Ok(()) + } +} + +struct OpenOnlyHandler { + calls: Arc, +} + +#[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, + }) + } +} + +#[tokio::test] +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(), + }) + .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 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(), + }) + .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(), + 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 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(), + }) + .await + .expect("open canvas"); + session + .rpc() + .canvas() + .close(CanvasCloseRequest { + instance_id: "counter-3".to_string(), + }) + .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: []