diff --git a/dotnet/src/Session.cs b/dotnet/src/Session.cs
index bd369867f..095c1abf7 100644
--- a/dotnet/src/Session.cs
+++ b/dotnet/src/Session.cs
@@ -132,7 +132,7 @@ public SessionCapabilities Capabilities
///
///
/// Populated from the most recent session.resume response and live
- /// session.canvas.opened events.
+ /// session.canvas.opened and session.canvas.closed events.
///
[Experimental(Diagnostics.Experimental)]
public IReadOnlyList OpenCanvases => _openCanvases;
@@ -892,6 +892,19 @@ internal void SetOpenCanvases(IList? 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;
@@ -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);
diff --git a/dotnet/test/Unit/CanvasTests.cs b/dotnet/test/Unit/CanvasTests.cs
index 18dec1733..c0cfaee92 100644
--- a/dotnet/test/Unit/CanvasTests.cs
+++ b/dotnet/test/Unit/CanvasTests.cs
@@ -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()
{
diff --git a/go/session.go b/go/session.go
index 3e37a3483..ca67cb2c8 100644
--- a/go/session.go
+++ b/go/session.go
@@ -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()
@@ -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) {
diff --git a/go/session_test.go b/go/session_test.go
index 85bca1a05..15cfbcf57 100644
--- a/go/session_test.go
+++ b/go/session_test.go
@@ -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.
diff --git a/nodejs/src/session.ts b/nodejs/src/session.ts
index 854a1a0d4..8ae19755a 100644
--- a/nodejs/src/session.ts
+++ b/nodejs/src/session.ts
@@ -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);
}
}
@@ -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
@@ -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];
diff --git a/nodejs/test/client.test.ts b/nodejs/test/client.test.ts
index 657ec7c9c..9352eb627 100644
--- a/nodejs/test/client.test.ts
+++ b/nodejs/test/client.test.ts
@@ -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",
diff --git a/python/copilot/session.py b/python/copilot/session.py
index 1ec1451e3..32201870c 100644
--- a/python/copilot/session.py
+++ b/python/copilot/session.py
@@ -67,6 +67,7 @@
ExternalToolRequestedData,
PermissionRequest,
PermissionRequestedData,
+ SessionCanvasClosedData,
SessionCanvasOpenedData,
SessionErrorData,
SessionEvent,
@@ -1564,6 +1565,14 @@ def _handle_broadcast_event(self, event: SessionEvent) -> None:
except Exception as exc:
logger.warning("failed to deserialize session.canvas.opened payload: %s", exc)
+ case SessionCanvasClosedData() as data:
+ try:
+ if not data.instance_id:
+ raise ValueError("missing required closed canvas fields")
+ self._remove_open_canvas(data.instance_id)
+ except Exception as exc:
+ logger.warning("failed to deserialize session.canvas.closed payload: %s", exc)
+
async def _execute_tool_and_respond(
self,
request_id: str,
@@ -1915,11 +1924,18 @@ def _upsert_open_canvas(self, instance: OpenCanvasInstance) -> None:
return
self._open_canvases.append(instance)
+ def _remove_open_canvas(self, instance_id: str) -> None:
+ with self._open_canvases_lock:
+ self._open_canvases = [
+ canvas for canvas in self._open_canvases if canvas.instance_id != instance_id
+ ]
+
@property
def open_canvases(self) -> list[OpenCanvasInstance]:
"""Open canvas instances currently known to be open for this session.
- Populated from ``session.resume`` and live ``session.canvas.opened`` events.
+ Populated from ``session.resume`` and live ``session.canvas.opened`` and
+ ``session.canvas.closed`` events.
"""
with self._open_canvases_lock:
return list(self._open_canvases)
diff --git a/python/test_canvas.py b/python/test_canvas.py
index 4924fd2df..440df5ceb 100644
--- a/python/test_canvas.py
+++ b/python/test_canvas.py
@@ -27,6 +27,7 @@
from copilot.session import CopilotSession
from copilot.session_events import (
CanvasOpenedAvailability,
+ SessionCanvasClosedData,
SessionCanvasOpenedData,
SessionEvent,
SessionEventType,
@@ -303,3 +304,73 @@ def test_session_canvas_opened_updates_open_canvases(caplog: pytest.LogCaptureFi
assert open_canvases[0].reopen is True
assert open_canvases[0].availability == CanvasInstanceAvailability.STALE
assert open_canvases[1].instance_id == "logs-1"
+
+
+def test_session_canvas_closed_removes_open_canvases(caplog: pytest.LogCaptureFixture):
+ session = CopilotSession("sess-1", client=None)
+
+ for canvas_id, instance_id in (("counter", "counter-1"), ("logs", "logs-1")):
+ session._dispatch_event(
+ SessionEvent(
+ data=SessionCanvasOpenedData(
+ availability=CanvasOpenedAvailability.READY,
+ canvas_id=canvas_id,
+ extension_id=f"project:{canvas_id}",
+ instance_id=instance_id,
+ reopen=False,
+ ),
+ id=uuid4(),
+ timestamp=datetime.now(UTC),
+ type=SessionEventType.SESSION_CANVAS_OPENED,
+ )
+ )
+ assert [canvas.instance_id for canvas in session.open_canvases] == [
+ "counter-1",
+ "logs-1",
+ ]
+
+ # Closing one instance removes it; the other remains.
+ session._dispatch_event(
+ SessionEvent(
+ data=SessionCanvasClosedData(
+ canvas_id="counter",
+ extension_id="project:counter",
+ instance_id="counter-1",
+ ),
+ id=uuid4(),
+ timestamp=datetime.now(UTC),
+ type=SessionEventType.SESSION_CANVAS_CLOSED,
+ )
+ )
+ assert [canvas.instance_id for canvas in session.open_canvases] == ["logs-1"]
+
+ # Closing an absent instance is a no-op (idempotent).
+ session._dispatch_event(
+ SessionEvent(
+ data=SessionCanvasClosedData(
+ canvas_id="counter",
+ extension_id="project:counter",
+ instance_id="counter-1",
+ ),
+ id=uuid4(),
+ timestamp=datetime.now(UTC),
+ type=SessionEventType.SESSION_CANVAS_CLOSED,
+ )
+ )
+ assert [canvas.instance_id for canvas in session.open_canvases] == ["logs-1"]
+
+ # A closed event with an empty instance_id warns and leaves the snapshot intact.
+ session._dispatch_event(
+ SessionEvent(
+ data=SessionCanvasClosedData(
+ canvas_id="logs",
+ extension_id="project:logs",
+ instance_id="",
+ ),
+ id=uuid4(),
+ timestamp=datetime.now(UTC),
+ type=SessionEventType.SESSION_CANVAS_CLOSED,
+ )
+ )
+ assert "failed to deserialize session.canvas.closed payload" in caplog.text
+ assert [canvas.instance_id for canvas in session.open_canvases] == ["logs-1"]
diff --git a/rust/src/session.rs b/rust/src/session.rs
index 6fc7a1857..f387b8627 100644
--- a/rust/src/session.rs
+++ b/rust/src/session.rs
@@ -13,8 +13,8 @@ use tracing::{Instrument, warn};
use crate::canvas::CanvasHandler;
use crate::generated::api_types::{LogRequest, ModelSwitchToRequest, OpenCanvasInstance};
use crate::generated::session_events::{
- CommandExecuteData, ElicitationRequestedData, ExternalToolRequestedData, SessionErrorData,
- SessionEventType,
+ CommandExecuteData, ElicitationRequestedData, ExternalToolRequestedData,
+ SessionCanvasClosedData, SessionErrorData, SessionEventType,
};
use crate::handler::{
AutoModeSwitchHandler, AutoModeSwitchResponse, ElicitationHandler, ExitPlanModeHandler,
@@ -1375,6 +1375,10 @@ fn upsert_open_canvas_snapshot(
}
}
+fn remove_open_canvas_snapshot(snapshots: &mut Vec, instance_id: &str) {
+ snapshots.retain(|open| open.instance_id != instance_id);
+}
+
#[allow(clippy::too_many_arguments)]
fn spawn_event_loop(
session_id: SessionId,
@@ -1572,6 +1576,18 @@ async fn handle_notification(
Err(e) => warn!(error = %e, "failed to deserialize session.canvas.opened payload"),
}
}
+ if event_type == SessionEventType::SessionCanvasClosed {
+ match serde_json::from_value::(notification.event.data.clone()) {
+ Ok(closed) => {
+ if closed.instance_id.is_empty() {
+ warn!("failed to deserialize session.canvas.closed payload");
+ } else {
+ remove_open_canvas_snapshot(&mut open_canvases.write(), &closed.instance_id);
+ }
+ }
+ Err(e) => warn!(error = %e, "failed to deserialize session.canvas.closed payload"),
+ }
+ }
// Fan out the event to runtime subscribers (`Session::subscribe`). `send`
// only errors when there are no receivers, which is the normal case
diff --git a/rust/tests/session_test.rs b/rust/tests/session_test.rs
index 2f062a115..244885697 100644
--- a/rust/tests/session_test.rs
+++ b/rust/tests/session_test.rs
@@ -2742,6 +2742,109 @@ async fn session_canvas_opened_updates_open_canvas_snapshots() {
assert_eq!(open[1].instance_id, "logs-1");
}
+#[tokio::test]
+async fn session_canvas_closed_removes_open_canvas_snapshot() {
+ let (session, mut server) = create_session_pair().await;
+ assert!(session.open_canvases().is_empty());
+
+ server
+ .send_event(
+ "session.canvas.opened",
+ serde_json::json!({
+ "extensionId": "project:counter",
+ "canvasId": "counter",
+ "instanceId": "counter-1",
+ "title": "Counter",
+ "reopen": false,
+ "availability": "ready"
+ }),
+ )
+ .await;
+ server
+ .send_event(
+ "session.canvas.opened",
+ serde_json::json!({
+ "extensionId": "project:logs",
+ "canvasId": "logs",
+ "instanceId": "logs-1",
+ "title": "Logs",
+ "reopen": false,
+ "availability": "ready"
+ }),
+ )
+ .await;
+
+ let mut open = Vec::new();
+ for _ in 0..50 {
+ open = session.open_canvases();
+ if open.len() == 2 {
+ break;
+ }
+ tokio::time::sleep(Duration::from_millis(20)).await;
+ }
+ assert_eq!(open.len(), 2);
+
+ // Closing one instance removes it while the other remains.
+ server
+ .send_event(
+ "session.canvas.closed",
+ serde_json::json!({
+ "extensionId": "project:counter",
+ "canvasId": "counter",
+ "instanceId": "counter-1"
+ }),
+ )
+ .await;
+
+ for _ in 0..50 {
+ open = session.open_canvases();
+ if open.len() == 1 {
+ break;
+ }
+ tokio::time::sleep(Duration::from_millis(20)).await;
+ }
+ assert_eq!(open.len(), 1);
+ assert_eq!(open[0].instance_id, "logs-1");
+
+ // Closing an absent instance is a no-op (idempotent).
+ server
+ .send_event(
+ "session.canvas.closed",
+ serde_json::json!({
+ "extensionId": "project:counter",
+ "canvasId": "counter",
+ "instanceId": "counter-1"
+ }),
+ )
+ .await;
+
+ // Give the event loop time to process; the snapshot must stay unchanged.
+ for _ in 0..10 {
+ tokio::time::sleep(Duration::from_millis(20)).await;
+ open = session.open_canvases();
+ assert_eq!(open.len(), 1);
+ }
+ assert_eq!(open[0].instance_id, "logs-1");
+
+ // A closed event with an empty instance_id is ignored and leaves the snapshot intact.
+ server
+ .send_event(
+ "session.canvas.closed",
+ serde_json::json!({
+ "extensionId": "project:logs",
+ "canvasId": "logs",
+ "instanceId": ""
+ }),
+ )
+ .await;
+ for _ in 0..10 {
+ tokio::time::sleep(Duration::from_millis(20)).await;
+ open = session.open_canvases();
+ assert_eq!(open.len(), 1);
+ }
+ assert_eq!(open[0].instance_id, "logs-1");
+}
+
#[tokio::test]
async fn elicitation_methods_fail_without_capability() {
let (session, _server) = create_session_pair().await;