Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 20 additions & 1 deletion dotnet/src/Session.cs
Original file line number Diff line number Diff line change
Expand Up @@ -132,7 +132,7 @@ public SessionCapabilities Capabilities
/// </summary>
/// <remarks>
/// Populated from the most recent <c>session.resume</c> response and live
/// <c>session.canvas.opened</c> events.
/// <c>session.canvas.opened</c> and <c>session.canvas.closed</c> events.
/// </remarks>
[Experimental(Diagnostics.Experimental)]
public IReadOnlyList<OpenCanvasInstance> OpenCanvases => _openCanvases;
Expand Down Expand Up @@ -892,6 +892,19 @@ internal void SetOpenCanvases(IList<OpenCanvasInstance>? canvases)

private void UpdateOpenCanvasesFromEvent(SessionEvent sessionEvent)
{
if (sessionEvent is SessionCanvasClosedEvent closedEvent)
{
var closedInstanceId = closedEvent.Data.InstanceId;
if (string.IsNullOrEmpty(closedInstanceId))
{
_logger.LogWarning("failed to deserialize session.canvas.closed payload");
return;
}

RemoveOpenCanvas(closedInstanceId);
return;
}

if (sessionEvent is not SessionCanvasOpenedEvent canvasEvent)
return;

Expand Down Expand Up @@ -931,6 +944,12 @@ private void UpsertOpenCanvas(OpenCanvasInstance canvas)
_openCanvases = canvases.AsReadOnly();
}

private void RemoveOpenCanvas(string instanceId)
{
var canvases = _openCanvases.Where(open => open.InstanceId != instanceId).ToList();
_openCanvases = canvases.AsReadOnly();
}

internal void SetCanvasHandler(ICanvasHandler? handler)
{
ClientSessionApis.Canvas = handler is null ? null : new CanvasHandlerAdapter(handler);
Expand Down
87 changes: 87 additions & 0 deletions dotnet/test/Unit/CanvasTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -229,6 +229,93 @@ public void SessionCanvasOpenedEvent_UpdatesOpenCanvasSnapshots()
canvas => Assert.Equal("logs-1", canvas.InstanceId));
}

[Fact]
public void SessionCanvasClosedEvent_RemovesOpenCanvasSnapshots()
{
var session = CreateSession();

DispatchEvent(session, new SessionCanvasOpenedEvent
{
Id = Guid.NewGuid(),
Timestamp = DateTimeOffset.UtcNow,
Data = new SessionCanvasOpenedData
{
Availability = CanvasOpenedAvailability.Ready,
CanvasId = "counter",
ExtensionId = "project:counter",
InstanceId = "counter-1",
Title = "Counter",
Reopen = false,
}
});
DispatchEvent(session, new SessionCanvasOpenedEvent
{
Id = Guid.NewGuid(),
Timestamp = DateTimeOffset.UtcNow,
Data = new SessionCanvasOpenedData
{
Availability = CanvasOpenedAvailability.Ready,
CanvasId = "logs",
ExtensionId = "project:logs",
InstanceId = "logs-1",
Title = "Logs",
Reopen = false,
}
});

Assert.Collection(
session.OpenCanvases,
canvas => Assert.Equal("counter-1", canvas.InstanceId),
canvas => Assert.Equal("logs-1", canvas.InstanceId));

// Closing one instance removes it; the other remains.
DispatchEvent(session, new SessionCanvasClosedEvent
{
Id = Guid.NewGuid(),
Timestamp = DateTimeOffset.UtcNow,
Data = new SessionCanvasClosedData
{
CanvasId = "counter",
ExtensionId = "project:counter",
InstanceId = "counter-1",
}
});

Assert.Collection(
session.OpenCanvases,
canvas => Assert.Equal("logs-1", canvas.InstanceId));

// Closing an absent instance is a no-op (idempotent).
DispatchEvent(session, new SessionCanvasClosedEvent
{
Id = Guid.NewGuid(),
Timestamp = DateTimeOffset.UtcNow,
Data = new SessionCanvasClosedData
{
CanvasId = "counter",
ExtensionId = "project:counter",
InstanceId = "counter-1",
}
});

// A closed event with an empty instance id leaves the snapshot intact.
DispatchEvent(session, new SessionCanvasClosedEvent
{
Id = Guid.NewGuid(),
Timestamp = DateTimeOffset.UtcNow,
Data = new SessionCanvasClosedData
{
CanvasId = "logs",
ExtensionId = "project:logs",
InstanceId = "",
}
});

Assert.Collection(
session.OpenCanvases,
canvas => Assert.Equal("logs-1", canvas.InstanceId));
}

[Fact]
public void ExtensionInfo_Serializes_SourceAndName()
{
Expand Down
58 changes: 38 additions & 20 deletions go/session.go
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,8 @@ func (s *Session) WorkspacePath() string {

// OpenCanvases returns the open-canvas snapshot last reported by the runtime.
// The snapshot is populated from session.resume and live session.canvas.opened
// events. The returned slice is a copy and is safe to mutate by the caller.
// and session.canvas.closed events. The returned slice is a copy and is safe to
// mutate by the caller.
func (s *Session) OpenCanvases() []rpc.OpenCanvasInstance {
s.openCanvasesMu.RLock()
defer s.openCanvasesMu.RUnlock()
Expand Down Expand Up @@ -130,27 +131,44 @@ func (s *Session) upsertOpenCanvas(canvas rpc.OpenCanvasInstance) {
s.openCanvases = append(s.openCanvases, canvas)
}

func (s *Session) updateOpenCanvasesFromEvent(event SessionEvent) {
data, ok := event.Data.(*SessionCanvasOpenedData)
if !ok {
return
func (s *Session) removeOpenCanvas(instanceID string) {
s.openCanvasesMu.Lock()
defer s.openCanvasesMu.Unlock()
filtered := make([]rpc.OpenCanvasInstance, 0, len(s.openCanvases))
for _, canvas := range s.openCanvases {
if canvas.InstanceID != instanceID {
filtered = append(filtered, canvas)
}
}
if data.InstanceID == "" || data.CanvasID == "" || data.ExtensionID == "" || data.Availability == "" {
fmt.Printf("failed to deserialize session.canvas.opened payload\n")
return
s.openCanvases = filtered
}

func (s *Session) updateOpenCanvasesFromEvent(event SessionEvent) {
switch data := event.Data.(type) {
case *SessionCanvasOpenedData:
if data.InstanceID == "" || data.CanvasID == "" || data.ExtensionID == "" || data.Availability == "" {
fmt.Printf("failed to deserialize session.canvas.opened payload\n")
return
}
s.upsertOpenCanvas(rpc.OpenCanvasInstance{
Availability: rpc.CanvasInstanceAvailability(data.Availability),
CanvasID: data.CanvasID,
ExtensionID: data.ExtensionID,
ExtensionName: data.ExtensionName,
Input: data.Input,
InstanceID: data.InstanceID,
Reopen: data.Reopen,
Status: data.Status,
Title: data.Title,
URL: data.URL,
})
case *SessionCanvasClosedData:
if data.InstanceID == "" {
fmt.Printf("failed to deserialize session.canvas.closed payload\n")
return
}
s.removeOpenCanvas(data.InstanceID)
}
s.upsertOpenCanvas(rpc.OpenCanvasInstance{
Availability: rpc.CanvasInstanceAvailability(data.Availability),
CanvasID: data.CanvasID,
ExtensionID: data.ExtensionID,
ExtensionName: data.ExtensionName,
Input: data.Input,
InstanceID: data.InstanceID,
Reopen: data.Reopen,
Status: data.Status,
Title: data.Title,
URL: data.URL,
})
}

func (s *Session) registerCanvasHandler(handler CanvasHandler) {
Expand Down
66 changes: 66 additions & 0 deletions go/session_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -659,6 +659,72 @@ func TestSession_Capabilities(t *testing.T) {
t.Fatalf("expected stale availability, got %q", open[0].Availability)
}
})

t.Run("session.canvas.closed event removes open canvas snapshots", func(t *testing.T) {
session, cleanup := newTestSession()
defer cleanup()

session.dispatchEvent(SessionEvent{
Data: &SessionCanvasOpenedData{
ExtensionID: "project:counter",
CanvasID: "counter",
InstanceID: "counter-1",
Title: ptr("Counter"),
Availability: CanvasOpenedAvailabilityReady,
},
})
session.dispatchEvent(SessionEvent{
Data: &SessionCanvasOpenedData{
ExtensionID: "project:logs",
CanvasID: "logs",
InstanceID: "logs-1",
Title: ptr("Logs"),
Availability: CanvasOpenedAvailabilityReady,
},
})

if open := session.OpenCanvases(); len(open) != 2 {
t.Fatalf("expected 2 open canvases, got %d", len(open))
}

// Closing one instance removes it; the other remains.
session.dispatchEvent(SessionEvent{
Data: &SessionCanvasClosedData{
ExtensionID: "project:counter",
CanvasID: "counter",
InstanceID: "counter-1",
},
})
open := session.OpenCanvases()
if len(open) != 1 || open[0].InstanceID != "logs-1" {
t.Fatalf("expected only logs-1 to remain, got %+v", open)
}

// Closing an absent instance is a no-op (idempotent).
session.dispatchEvent(SessionEvent{
Data: &SessionCanvasClosedData{
ExtensionID: "project:counter",
CanvasID: "counter",
InstanceID: "counter-1",
},
})
open = session.OpenCanvases()
if len(open) != 1 || open[0].InstanceID != "logs-1" {
t.Fatalf("idempotent close should leave logs-1, got %+v", open)
}

// A closed event missing instanceID leaves the snapshot intact.
session.dispatchEvent(SessionEvent{
Data: &SessionCanvasClosedData{
ExtensionID: "project:logs",
CanvasID: "logs",
},
})
open = session.OpenCanvases()
if len(open) != 1 || open[0].InstanceID != "logs-1" {
t.Fatalf("invalid close should leave logs-1, got %+v", open)
}
})
}

// waitForCapability polls Session.Capabilities() until predicate matches or timeout.
Expand Down
25 changes: 23 additions & 2 deletions nodejs/src/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -507,6 +507,8 @@ export class CopilotSession {
this._capabilities = { ...this._capabilities, ...event.data };
} else if (event.type === "session.canvas.opened") {
this.upsertOpenCanvasFromEvent(event.data);
} else if (event.type === "session.canvas.closed") {
this.removeOpenCanvasFromEvent(event.data);
}
}

Expand All @@ -518,6 +520,25 @@ export class CopilotSession {
this.upsertOpenCanvas(data);
}

private removeOpenCanvasFromEvent(data: unknown): void {
if (
!data ||
typeof data !== "object" ||
typeof (data as { instanceId?: unknown }).instanceId !== "string" ||
(data as { instanceId: string }).instanceId.length === 0
) {
console.warn("failed to deserialize session.canvas.closed payload");
return;
}
this.removeOpenCanvas((data as { instanceId: string }).instanceId);
}

private removeOpenCanvas(instanceId: string): void {
this.openCanvasInstances = this.openCanvasInstances.filter(
(open) => open.instanceId !== instanceId
);
}

private upsertOpenCanvas(instance: OpenCanvasInstance): void {
const index = this.openCanvasInstances.findIndex(
(open) => open.instanceId === instance.instanceId
Expand Down Expand Up @@ -851,8 +872,8 @@ export class CopilotSession {
/**
* Snapshot of canvas instances currently known to be open for this session.
* Populated from the `session.resume` response and live `session.canvas.opened`
* events. Returns a defensive copy — mutating the returned array has no effect
* on the session.
* and `session.canvas.closed` events. Returns a defensive copy — mutating the
* returned array has no effect on the session.
*/
get openCanvases(): OpenCanvasInstance[] {
return [...this.openCanvasInstances];
Expand Down
63 changes: 63 additions & 0 deletions nodejs/test/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -317,6 +317,69 @@ describe("CopilotClient", () => {
warn.mockRestore();
});

it("removes open canvases on live session.canvas.closed events", () => {
const session = new CopilotSession("session-1", {} as any);
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});

(session as any)._dispatchEvent({
type: "session.canvas.opened",
data: {
extensionId: "project:counter",
canvasId: "counter",
instanceId: "counter-1",
title: "Counter",
reopen: false,
availability: "ready",
},
});
(session as any)._dispatchEvent({
type: "session.canvas.opened",
data: {
extensionId: "project:logs",
canvasId: "logs",
instanceId: "logs-1",
title: "Logs",
reopen: false,
availability: "ready",
},
});
expect(session.openCanvases.map((canvas) => canvas.instanceId)).toEqual([
"counter-1",
"logs-1",
]);

// Closing one instance removes it; the other remains.
(session as any)._dispatchEvent({
type: "session.canvas.closed",
data: {
extensionId: "project:counter",
canvasId: "counter",
instanceId: "counter-1",
},
});
expect(session.openCanvases.map((canvas) => canvas.instanceId)).toEqual(["logs-1"]);

// Closing an absent instance is a no-op (idempotent).
(session as any)._dispatchEvent({
type: "session.canvas.closed",
data: {
extensionId: "project:counter",
canvasId: "counter",
instanceId: "counter-1",
},
});
expect(session.openCanvases.map((canvas) => canvas.instanceId)).toEqual(["logs-1"]);

// A closed event missing instanceId warns and leaves the snapshot intact.
(session as any)._dispatchEvent({
type: "session.canvas.closed",
data: { extensionId: "project:logs", canvasId: "logs" },
});
expect(warn).toHaveBeenCalledWith("failed to deserialize session.canvas.closed payload");
expect(session.openCanvases.map((canvas) => canvas.instanceId)).toEqual(["logs-1"]);
warn.mockRestore();
});

it("returns canvas_action_no_handler when no per-action handler is registered", async () => {
const canvas = createCanvas({
id: "counter",
Expand Down
Loading
Loading