Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
2 changes: 2 additions & 0 deletions java/src/main/java/com/github/copilot/CopilotClient.java
Original file line number Diff line number Diff line change
Expand Up @@ -577,6 +577,7 @@ public CompletableFuture<CopilotSession> 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),
Expand Down Expand Up @@ -701,6 +702,7 @@ public CompletableFuture<CopilotSession> 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();
Expand Down
133 changes: 129 additions & 4 deletions java/src/main/java/com/github/copilot/CopilotSession.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<OpenCanvasInstance> openCanvases = new ArrayList<>();
private final SessionUiApi ui;
private final JsonRpcClient rpc;
private volatile SessionRpc sessionRpc;
Expand Down Expand Up @@ -761,8 +767,9 @@ public <T extends SessionEvent> Closeable on(Class<T> eventType, Consumer<T> 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<SessionEvent> handler : eventHandlers) {
Expand All @@ -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.
* <p>
* 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) {
Expand Down Expand Up @@ -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.
* <p>
* 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
*/
Comment thread
jmoseley marked this conversation as resolved.
public List<OpenCanvasInstance> getOpenCanvases() {
synchronized (openCanvasesLock) {
return List.copyOf(openCanvases);
}
}

/**
* Replaces the open-canvases snapshot for this session.
* <p>
* 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<OpenCanvasInstance> 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.
* <p>
* {@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.
* <p>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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
*/
Comment thread
jmoseley marked this conversation as resolved.
@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<OpenCanvasInstance> openCanvases) {
}
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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
*/
Comment thread
jmoseley marked this conversation as resolved.
@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<OpenCanvasInstance> openCanvases) {
}
Loading
Loading