From c439188274054ebe257be256c10fd0d663998fd3 Mon Sep 17 00:00:00 2001 From: jmoseley Date: Mon, 8 Jun 2026 21:36:30 -0700 Subject: [PATCH 1/2] Add open-canvases snapshot tracking to the Java SDK Bring the Java SDK to parity with the other five SDK languages (Rust, Node, Python, Go, .NET) by maintaining an in-memory snapshot of the canvas instances currently open for a session. - CopilotSession now keeps a lock-guarded List and exposes it via getOpenCanvases() (immutable defensive copy). - session.canvas.opened upserts by instanceId (a stale re-emit from a provider unregister replaces the prior entry rather than duplicating it); session.canvas.closed removes by instanceId. Both are validated against the canonical contract and are best-effort so snapshot upkeep never disrupts event delivery. The update runs in handleBroadcastEventAsync alongside the capabilities.changed state update, before user handlers observe the event. - The snapshot is seeded from the session.create / session.resume responses, which already carry openCanvases on the wire. Mirrors PR #1604, which landed the same opened-upsert + closed-remove behavior for Rust/Node/Python/Go/.NET, and the runtime events from copilot-agent-runtime #9489 (CLI 1.0.60). The consumer is github-app's sticky-canvas fix. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../com/github/copilot/CopilotClient.java | 2 + .../com/github/copilot/CopilotSession.java | 133 ++++++++++- .../copilot/rpc/CreateSessionResponse.java | 7 +- .../copilot/rpc/ResumeSessionResponse.java | 7 +- .../copilot/SessionCanvasSnapshotTest.java | 210 ++++++++++++++++++ 5 files changed, 353 insertions(+), 6 deletions(-) create mode 100644 java/src/test/java/com/github/copilot/SessionCanvasSnapshotTest.java diff --git a/java/src/main/java/com/github/copilot/CopilotClient.java b/java/src/main/java/com/github/copilot/CopilotClient.java index 4fe4143f9..50137aefe 100644 --- a/java/src/main/java/com/github/copilot/CopilotClient.java +++ b/java/src/main/java/com/github/copilot/CopilotClient.java @@ -577,6 +577,7 @@ public CompletableFuture createSession(SessionConfig config) { registeredIdHolder[0] = returnedId; session.setWorkspacePath(response.workspacePath()); session.setCapabilities(response.capabilities()); + session.setOpenCanvases(response.openCanvases()); return updateSessionOptionsForMode(session, config.getSkipCustomInstructions().orElse(null), config.getCustomAgentsLocalOnly().orElse(null), @@ -701,6 +702,7 @@ public CompletableFuture resumeSession(String sessionId, ResumeS rpcNanos); session.setWorkspacePath(response.workspacePath()); session.setCapabilities(response.capabilities()); + session.setOpenCanvases(response.openCanvases()); // If the server returned a different sessionId than what was requested, // re-key. String returnedId = response.sessionId(); diff --git a/java/src/main/java/com/github/copilot/CopilotSession.java b/java/src/main/java/com/github/copilot/CopilotSession.java index 1daa3cd9f..48a17434b 100644 --- a/java/src/main/java/com/github/copilot/CopilotSession.java +++ b/java/src/main/java/com/github/copilot/CopilotSession.java @@ -50,9 +50,13 @@ import com.github.copilot.generated.ElicitationRequestedEvent; import com.github.copilot.generated.ExternalToolRequestedEvent; import com.github.copilot.generated.PermissionRequestedEvent; +import com.github.copilot.generated.SessionCanvasClosedEvent; +import com.github.copilot.generated.SessionCanvasOpenedEvent; import com.github.copilot.generated.SessionErrorEvent; import com.github.copilot.generated.SessionEvent; import com.github.copilot.generated.SessionIdleEvent; +import com.github.copilot.generated.rpc.CanvasInstanceAvailability; +import com.github.copilot.generated.rpc.OpenCanvasInstance; import com.github.copilot.rpc.AgentInfo; import com.github.copilot.rpc.AutoModeSwitchHandler; import com.github.copilot.rpc.AutoModeSwitchInvocation; @@ -157,6 +161,8 @@ public final class CopilotSession implements AutoCloseable { private volatile String sessionId; private volatile String workspacePath; private volatile SessionCapabilities capabilities = new SessionCapabilities(); + private final Object openCanvasesLock = new Object(); + private final List openCanvases = new ArrayList<>(); private final SessionUiApi ui; private final JsonRpcClient rpc; private volatile SessionRpc sessionRpc; @@ -761,8 +767,9 @@ public Closeable on(Class eventType, Consumer han * @see #setEventErrorPolicy(EventErrorPolicy) */ void dispatchEvent(SessionEvent event) { - // Handle broadcast request events (protocol v3) before dispatching to user - // handlers. These are fire-and-forget: the response is sent asynchronously. + // Handle broadcast request events (protocol v3) and passive in-memory state + // updates (capabilities, open-canvases snapshot) before dispatching to user + // handlers. Fire-and-forget: any RPC response is sent asynchronously. handleBroadcastEventAsync(event); for (Consumer handler : eventHandlers) { @@ -788,14 +795,24 @@ void dispatchEvent(SessionEvent event) { /** * Handles broadcast request events by executing local handlers and responding - * via RPC (protocol v3). + * via RPC (protocol v3), and applies passive in-memory state updates such as + * the open-canvases snapshot. *

- * Fire-and-forget: the response is sent asynchronously. + * Fire-and-forget: any RPC response is sent asynchronously. * * @param event * the event to handle */ private void handleBroadcastEventAsync(SessionEvent event) { + // Maintain the in-memory open-canvases snapshot before user handlers run so + // they observe the freshest state. Best-effort: snapshot upkeep must never + // disrupt event delivery, so failures are logged and swallowed. + try { + updateOpenCanvasesFromEvent(event); + } catch (Exception e) { + LOG.log(Level.WARNING, "Failed to update open-canvases snapshot", e); + } + if (event instanceof ExternalToolRequestedEvent toolEvent) { var data = toolEvent.getData(); if (data == null || data.requestId() == null || data.toolName() == null) { @@ -1369,6 +1386,114 @@ void setCapabilities(SessionCapabilities sessionCapabilities) { this.capabilities = sessionCapabilities != null ? sessionCapabilities : new SessionCapabilities(); } + /** + * Returns a snapshot of the canvas instances currently known to be open for + * this session. + *

+ * The snapshot is seeded from the {@code session.create} / + * {@code session.resume} response and kept up to date by + * {@code session.canvas.opened} (upsert) and {@code session.canvas.closed} + * (remove) events. The returned list is an immutable defensive copy; mutating + * it has no effect on the session. + * + * @return an immutable list of the currently open canvas instances, never + * {@code null} + * @since 1.0.0 + */ + public List getOpenCanvases() { + synchronized (openCanvasesLock) { + return List.copyOf(openCanvases); + } + } + + /** + * Replaces the open-canvases snapshot for this session. + *

+ * Called internally after a {@code session.create} / {@code session.resume} + * response to seed the snapshot. {@code null} entries are ignored. + * + * @param instances + * the open canvas instances from the create/resume response, or + * {@code null} to clear the snapshot + */ + void setOpenCanvases(List instances) { + synchronized (openCanvasesLock) { + openCanvases.clear(); + if (instances != null) { + for (OpenCanvasInstance instance : instances) { + if (instance != null) { + openCanvases.add(instance); + } + } + } + } + } + + /** + * Updates the in-memory open-canvases snapshot in response to a session event. + *

+ * {@code session.canvas.opened} upserts by {@code instanceId}; a stale re-emit + * (provider unregister) arrives as another {@code opened} event and replaces + * the prior entry rather than removing it. {@code session.canvas.closed} + * removes the matching entry. Invalid payloads are logged and ignored. + * + * @param event + * the dispatched session event + */ + private void updateOpenCanvasesFromEvent(SessionEvent event) { + if (event instanceof SessionCanvasClosedEvent closedEvent) { + var data = closedEvent.getData(); + if (data == null || isNullOrEmpty(data.instanceId())) { + LOG.warning("failed to deserialize session.canvas.closed payload"); + return; + } + removeOpenCanvas(data.instanceId()); + return; + } + + if (event instanceof SessionCanvasOpenedEvent openedEvent) { + var data = openedEvent.getData(); + if (data == null || isNullOrEmpty(data.instanceId()) || isNullOrEmpty(data.canvasId()) + || isNullOrEmpty(data.extensionId()) || data.availability() == null) { + LOG.warning("failed to deserialize session.canvas.opened payload"); + return; + } + upsertOpenCanvas(new OpenCanvasInstance(data.instanceId(), data.extensionId(), data.extensionName(), + data.canvasId(), data.title(), data.status(), data.url(), data.input(), data.reopen(), + CanvasInstanceAvailability.fromValue(data.availability().getValue()))); + } + } + + /** + * Inserts or replaces a canvas instance in the snapshot, matching by + * {@code instanceId}. + */ + private void upsertOpenCanvas(OpenCanvasInstance instance) { + synchronized (openCanvasesLock) { + for (int i = 0; i < openCanvases.size(); i++) { + if (instance.instanceId().equals(openCanvases.get(i).instanceId())) { + openCanvases.set(i, instance); + return; + } + } + openCanvases.add(instance); + } + } + + /** + * Removes the canvas instance matching {@code instanceId} from the snapshot. + * Idempotent: removing an absent instance is a no-op. + */ + private void removeOpenCanvas(String instanceId) { + synchronized (openCanvasesLock) { + openCanvases.removeIf(open -> instanceId.equals(open.instanceId())); + } + } + + private static boolean isNullOrEmpty(String value) { + return value == null || value.isEmpty(); + } + /** * Handles a user input request from the Copilot CLI. *

diff --git a/java/src/main/java/com/github/copilot/rpc/CreateSessionResponse.java b/java/src/main/java/com/github/copilot/rpc/CreateSessionResponse.java index e10926769..bed52d63a 100644 --- a/java/src/main/java/com/github/copilot/rpc/CreateSessionResponse.java +++ b/java/src/main/java/com/github/copilot/rpc/CreateSessionResponse.java @@ -2,6 +2,8 @@ import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.annotation.JsonProperty; +import com.github.copilot.generated.rpc.OpenCanvasInstance; +import java.util.List; /** * Internal response object from creating a session. @@ -13,10 +15,13 @@ * disabled * @param capabilities * the capabilities reported by the host, or {@code null} + * @param openCanvases + * the canvas instances open for the session, or {@code null} * @since 1.0.0 */ @JsonInclude(JsonInclude.Include.NON_NULL) public record CreateSessionResponse(@JsonProperty("sessionId") String sessionId, @JsonProperty("workspacePath") String workspacePath, - @JsonProperty("capabilities") SessionCapabilities capabilities) { + @JsonProperty("capabilities") SessionCapabilities capabilities, + @JsonProperty("openCanvases") List openCanvases) { } diff --git a/java/src/main/java/com/github/copilot/rpc/ResumeSessionResponse.java b/java/src/main/java/com/github/copilot/rpc/ResumeSessionResponse.java index cd787d37f..a7b506d28 100644 --- a/java/src/main/java/com/github/copilot/rpc/ResumeSessionResponse.java +++ b/java/src/main/java/com/github/copilot/rpc/ResumeSessionResponse.java @@ -2,6 +2,8 @@ import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.annotation.JsonProperty; +import com.github.copilot.generated.rpc.OpenCanvasInstance; +import java.util.List; /** * Internal response object from resuming a session. @@ -13,10 +15,13 @@ * disabled * @param capabilities * the capabilities reported by the host, or {@code null} + * @param openCanvases + * the canvas instances open for the session, or {@code null} * @since 1.0.0 */ @JsonInclude(JsonInclude.Include.NON_NULL) public record ResumeSessionResponse(@JsonProperty("sessionId") String sessionId, @JsonProperty("workspacePath") String workspacePath, - @JsonProperty("capabilities") SessionCapabilities capabilities) { + @JsonProperty("capabilities") SessionCapabilities capabilities, + @JsonProperty("openCanvases") List openCanvases) { } diff --git a/java/src/test/java/com/github/copilot/SessionCanvasSnapshotTest.java b/java/src/test/java/com/github/copilot/SessionCanvasSnapshotTest.java new file mode 100644 index 000000000..6dba350c1 --- /dev/null +++ b/java/src/test/java/com/github/copilot/SessionCanvasSnapshotTest.java @@ -0,0 +1,210 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +package com.github.copilot; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.List; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.github.copilot.generated.CanvasOpenedAvailability; +import com.github.copilot.generated.SessionCanvasClosedEvent; +import com.github.copilot.generated.SessionCanvasClosedEvent.SessionCanvasClosedEventData; +import com.github.copilot.generated.SessionCanvasOpenedEvent; +import com.github.copilot.generated.SessionCanvasOpenedEvent.SessionCanvasOpenedEventData; +import com.github.copilot.generated.rpc.CanvasInstanceAvailability; +import com.github.copilot.generated.rpc.OpenCanvasInstance; +import com.github.copilot.rpc.CreateSessionResponse; +import com.github.copilot.rpc.ResumeSessionResponse; + +/** + * Unit tests for the in-memory open-canvases snapshot maintained by + * {@link CopilotSession}. + *

+ * These are pure unit tests that don't require the Copilot CLI. They drive the + * package-private {@code dispatchEvent} hook directly and assert the resulting + * snapshot exposed by {@link CopilotSession#getOpenCanvases()}. + */ +public class SessionCanvasSnapshotTest { + + private CopilotSession session; + + @BeforeEach + void setup() throws Exception { + var constructor = CopilotSession.class.getDeclaredConstructor(String.class, JsonRpcClient.class, String.class); + constructor.setAccessible(true); + session = constructor.newInstance("test-session-id", null, null); + } + + @Test + void startsEmpty() { + assertTrue(session.getOpenCanvases().isEmpty()); + } + + @Test + void openedUpsertsCanvases() { + session.dispatchEvent(openedEvent("inst-1", "canvas-a", CanvasOpenedAvailability.READY)); + session.dispatchEvent(openedEvent("inst-2", "canvas-b", CanvasOpenedAvailability.READY)); + + var canvases = session.getOpenCanvases(); + assertEquals(2, canvases.size()); + assertEquals(List.of("inst-1", "inst-2"), canvases.stream().map(OpenCanvasInstance::instanceId).toList()); + assertEquals(CanvasInstanceAvailability.READY, canvases.get(0).availability()); + } + + @Test + void closedRemovesMatchingCanvas() { + session.dispatchEvent(openedEvent("inst-1", "canvas-a", CanvasOpenedAvailability.READY)); + session.dispatchEvent(openedEvent("inst-2", "canvas-b", CanvasOpenedAvailability.READY)); + + session.dispatchEvent(closedEvent("inst-1")); + + var canvases = session.getOpenCanvases(); + assertEquals(1, canvases.size()); + assertEquals("inst-2", canvases.get(0).instanceId()); + } + + @Test + void closedForAbsentInstanceIsNoOp() { + session.dispatchEvent(openedEvent("inst-1", "canvas-a", CanvasOpenedAvailability.READY)); + + session.dispatchEvent(closedEvent("does-not-exist")); + + var canvases = session.getOpenCanvases(); + assertEquals(1, canvases.size()); + assertEquals("inst-1", canvases.get(0).instanceId()); + } + + @Test + void closedWithEmptyInstanceIdIsNoOp() { + session.dispatchEvent(openedEvent("inst-1", "canvas-a", CanvasOpenedAvailability.READY)); + + session.dispatchEvent(closedEvent("")); + session.dispatchEvent(closedEvent(null)); + + var canvases = session.getOpenCanvases(); + assertEquals(1, canvases.size()); + assertEquals("inst-1", canvases.get(0).instanceId()); + } + + @Test + void openedWithMissingRequiredFieldsIsIgnored() { + session.dispatchEvent(openedEvent("", "canvas-a", CanvasOpenedAvailability.READY)); + session.dispatchEvent(openedEvent("inst-1", "", CanvasOpenedAvailability.READY)); + session.dispatchEvent(openedEvent("inst-1", "canvas-a", null)); + + assertTrue(session.getOpenCanvases().isEmpty()); + } + + @Test + void staleReemitReplacesInsteadOfDuplicating() { + session.dispatchEvent(openedEvent("inst-1", "canvas-a", CanvasOpenedAvailability.READY)); + + // Provider unregister re-emits the same instance as "stale". + session.dispatchEvent(openedEvent("inst-1", "canvas-a", CanvasOpenedAvailability.STALE)); + + var canvases = session.getOpenCanvases(); + assertEquals(1, canvases.size()); + assertEquals(CanvasInstanceAvailability.STALE, canvases.get(0).availability()); + } + + @Test + void getOpenCanvasesReturnsImmutableCopy() { + session.dispatchEvent(openedEvent("inst-1", "canvas-a", CanvasOpenedAvailability.READY)); + + var canvases = session.getOpenCanvases(); + assertThrows(UnsupportedOperationException.class, () -> canvases.add(new OpenCanvasInstance("x", "ext", null, + "c", null, null, null, null, null, CanvasInstanceAvailability.READY))); + + // Mutating the returned copy must not affect the session snapshot. + assertEquals(1, session.getOpenCanvases().size()); + } + + @Test + void setOpenCanvasesSeedsAndFiltersNulls() { + var seed = new java.util.ArrayList(); + seed.add(new OpenCanvasInstance("inst-1", "ext", null, "canvas-a", null, null, null, null, null, + CanvasInstanceAvailability.READY)); + seed.add(null); + seed.add(new OpenCanvasInstance("inst-2", "ext", null, "canvas-b", null, null, null, null, null, + CanvasInstanceAvailability.STALE)); + + session.setOpenCanvases(seed); + + var canvases = session.getOpenCanvases(); + assertEquals(2, canvases.size()); + assertEquals(List.of("inst-1", "inst-2"), canvases.stream().map(OpenCanvasInstance::instanceId).toList()); + } + + @Test + void setOpenCanvasesWithNullClears() { + session.dispatchEvent(openedEvent("inst-1", "canvas-a", CanvasOpenedAvailability.READY)); + + session.setOpenCanvases(null); + + assertTrue(session.getOpenCanvases().isEmpty()); + } + + @Test + void createSessionResponseDeserializesOpenCanvases() throws Exception { + ObjectMapper mapper = JsonRpcClient.getObjectMapper(); + String json = """ + { + "sessionId": "abc", + "workspacePath": "/tmp/ws", + "capabilities": {}, + "openCanvases": [ + { "instanceId": "inst-1", "extensionId": "ext", "canvasId": "canvas-a", "availability": "ready" } + ] + } + """; + + CreateSessionResponse response = mapper.readValue(json, CreateSessionResponse.class); + + assertNotNull(response.openCanvases()); + assertEquals(1, response.openCanvases().size()); + assertEquals("inst-1", response.openCanvases().get(0).instanceId()); + assertEquals(CanvasInstanceAvailability.READY, response.openCanvases().get(0).availability()); + } + + @Test + void resumeSessionResponseDeserializesOpenCanvases() throws Exception { + ObjectMapper mapper = JsonRpcClient.getObjectMapper(); + String json = """ + { + "sessionId": "abc", + "openCanvases": [ + { "instanceId": "inst-1", "extensionId": "ext", "canvasId": "canvas-a", "availability": "stale" } + ] + } + """; + + ResumeSessionResponse response = mapper.readValue(json, ResumeSessionResponse.class); + + assertNotNull(response.openCanvases()); + assertEquals(1, response.openCanvases().size()); + assertEquals(CanvasInstanceAvailability.STALE, response.openCanvases().get(0).availability()); + } + + private static SessionCanvasOpenedEvent openedEvent(String instanceId, String canvasId, + CanvasOpenedAvailability availability) { + var event = new SessionCanvasOpenedEvent(); + event.setData(new SessionCanvasOpenedEventData(instanceId, "ext-id", "Ext Name", canvasId, "Title", "ok", null, + null, null, availability)); + return event; + } + + private static SessionCanvasClosedEvent closedEvent(String instanceId) { + var event = new SessionCanvasClosedEvent(); + event.setData(new SessionCanvasClosedEventData(instanceId, "ext-id", "canvas-a")); + return event; + } +} From 6a39b6fa01f90c6c322316021cd394d471b7fbb4 Mon Sep 17 00:00:00 2001 From: jmoseley Date: Tue, 9 Jun 2026 07:46:32 -0700 Subject: [PATCH 2/2] Address review: @since versioning for new API and strengthen snapshot test - getOpenCanvases() @since 1.0.0 -> 1.0.1 (new public API) - Note openCanvases component added in 1.0.1 on Create/ResumeSessionResponse (the record types themselves predate this PR, so type-level @since stays 1.0.0) - getOpenCanvasesReturnsImmutableCopy now dispatches a later event and asserts the previously-returned list is unchanged, proving it is a point-in-time snapshot rather than a live view Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../main/java/com/github/copilot/CopilotSession.java | 2 +- .../com/github/copilot/rpc/CreateSessionResponse.java | 5 ++++- .../com/github/copilot/rpc/ResumeSessionResponse.java | 5 ++++- .../com/github/copilot/SessionCanvasSnapshotTest.java | 10 ++++++++-- 4 files changed, 17 insertions(+), 5 deletions(-) diff --git a/java/src/main/java/com/github/copilot/CopilotSession.java b/java/src/main/java/com/github/copilot/CopilotSession.java index 48a17434b..fa080c925 100644 --- a/java/src/main/java/com/github/copilot/CopilotSession.java +++ b/java/src/main/java/com/github/copilot/CopilotSession.java @@ -1398,7 +1398,7 @@ void setCapabilities(SessionCapabilities sessionCapabilities) { * * @return an immutable list of the currently open canvas instances, never * {@code null} - * @since 1.0.0 + * @since 1.0.1 */ public List getOpenCanvases() { synchronized (openCanvasesLock) { diff --git a/java/src/main/java/com/github/copilot/rpc/CreateSessionResponse.java b/java/src/main/java/com/github/copilot/rpc/CreateSessionResponse.java index bed52d63a..e899ce78e 100644 --- a/java/src/main/java/com/github/copilot/rpc/CreateSessionResponse.java +++ b/java/src/main/java/com/github/copilot/rpc/CreateSessionResponse.java @@ -7,6 +7,8 @@ /** * Internal response object from creating a session. + *

+ * The {@code openCanvases} component was added in 1.0.1. * * @param sessionId * the session ID assigned by the server @@ -16,7 +18,8 @@ * @param capabilities * the capabilities reported by the host, or {@code null} * @param openCanvases - * the canvas instances open for the session, or {@code null} + * the canvas instances open for the session, or {@code null} (since + * 1.0.1) * @since 1.0.0 */ @JsonInclude(JsonInclude.Include.NON_NULL) diff --git a/java/src/main/java/com/github/copilot/rpc/ResumeSessionResponse.java b/java/src/main/java/com/github/copilot/rpc/ResumeSessionResponse.java index a7b506d28..0f74eb5ac 100644 --- a/java/src/main/java/com/github/copilot/rpc/ResumeSessionResponse.java +++ b/java/src/main/java/com/github/copilot/rpc/ResumeSessionResponse.java @@ -7,6 +7,8 @@ /** * Internal response object from resuming a session. + *

+ * The {@code openCanvases} component was added in 1.0.1. * * @param sessionId * the session ID @@ -16,7 +18,8 @@ * @param capabilities * the capabilities reported by the host, or {@code null} * @param openCanvases - * the canvas instances open for the session, or {@code null} + * the canvas instances open for the session, or {@code null} (since + * 1.0.1) * @since 1.0.0 */ @JsonInclude(JsonInclude.Include.NON_NULL) diff --git a/java/src/test/java/com/github/copilot/SessionCanvasSnapshotTest.java b/java/src/test/java/com/github/copilot/SessionCanvasSnapshotTest.java index 6dba350c1..c977ef330 100644 --- a/java/src/test/java/com/github/copilot/SessionCanvasSnapshotTest.java +++ b/java/src/test/java/com/github/copilot/SessionCanvasSnapshotTest.java @@ -124,8 +124,14 @@ void getOpenCanvasesReturnsImmutableCopy() { assertThrows(UnsupportedOperationException.class, () -> canvases.add(new OpenCanvasInstance("x", "ext", null, "c", null, null, null, null, null, CanvasInstanceAvailability.READY))); - // Mutating the returned copy must not affect the session snapshot. - assertEquals(1, session.getOpenCanvases().size()); + // The returned list is a point-in-time snapshot, not a live view: a + // subsequent event must not change the previously-returned list. + session.dispatchEvent(openedEvent("inst-2", "canvas-b", CanvasOpenedAvailability.READY)); + assertEquals(1, canvases.size()); + assertEquals("inst-1", canvases.get(0).instanceId()); + + // The session snapshot itself reflects the new event. + assertEquals(2, session.getOpenCanvases().size()); } @Test