Skip to content
Open
20 changes: 16 additions & 4 deletions dotnet/src/Session.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1920,8 +1920,13 @@ public async ValueTask DisposeAsync()

try
{
await InvokeRpcAsync<object>(
"session.destroy", [new SessionDestroyRequest() { SessionId = SessionId }], CancellationToken.None);
var response = await InvokeRpcAsync<SessionDetachResponse>(
"session.detach", [new SessionDetachRequest() { SessionId = SessionId }], CancellationToken.None);
if (!response.Success)
{
throw new InvalidOperationException(
$"Failed to detach session {SessionId}: {response.Error ?? "unknown error"}");
Comment thread
jmoseley marked this conversation as resolved.
Outdated
Comment thread
jmoseley marked this conversation as resolved.
Outdated
}
}
catch (ObjectDisposedException)
{
Expand Down Expand Up @@ -1991,11 +1996,17 @@ internal record SessionAbortRequest
public string SessionId { get; init; } = string.Empty;
}

internal record SessionDestroyRequest
internal record SessionDetachRequest
{
public string SessionId { get; init; } = string.Empty;
}

internal record SessionDetachResponse
{
public bool Success { get; init; }
public string? Error { get; init; }
}

internal void ThrowIfDisposed()
{
ObjectDisposedException.ThrowIf(Volatile.Read(ref _isDisposed) != 0, this);
Expand Down Expand Up @@ -2028,7 +2039,8 @@ internal void ThrowIfDisposed()
[JsonSerializable(typeof(SendMessageRequest))]
[JsonSerializable(typeof(SendMessageResponse))]
[JsonSerializable(typeof(SessionAbortRequest))]
[JsonSerializable(typeof(SessionDestroyRequest))]
[JsonSerializable(typeof(SessionDetachRequest))]
[JsonSerializable(typeof(SessionDetachResponse))]
[JsonSerializable(typeof(SessionEndHookInput))]
[JsonSerializable(typeof(SessionEndHookOutput))]
[JsonSerializable(typeof(SessionStartHookInput))]
Expand Down
2 changes: 1 addition & 1 deletion dotnet/test/E2E/ClientLifecycleE2ETests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,7 @@ public async Task Should_Receive_Session_Deleted_Lifecycle_Event_When_Deleted()
}
});

// Do NOT DisposeAsync the session before deleting: dispose sends session.destroy
// Do NOT DisposeAsync the session before deleting: dispose sends session.detach
// which closes in-memory state but does not remove the disk file; calling
// delete afterwards still succeeds, but skipping dispose keeps the test minimal.
await Client.DeleteSessionAsync(sessionId);
Expand Down
2 changes: 1 addition & 1 deletion dotnet/test/Harness/E2ETestBase.cs
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,7 @@ protected static async Task SuspendAndUntrackSessionForResumeAsync(CopilotSessio
{
await session.Rpc.SuspendAsync();

// In-process clients host separate runtimes, while session.destroy removes the
// In-process clients host separate runtimes, while session.detach removes the
// session from the current runtime. Untrack locally to exercise resume without
// either replacing an active wrapper or destroying the session first.
var removeFromClient = typeof(CopilotSession).GetMethod(
Expand Down
2 changes: 1 addition & 1 deletion dotnet/test/Harness/E2ETestContext.cs
Original file line number Diff line number Diff line change
Expand Up @@ -569,7 +569,7 @@ private static async Task StopClientForCleanupAsync(CopilotClient client)
$"Graceful in-process client cleanup exceeded {s_gracefulClientStopTimeout}; forcing shutdown.");
await client.ForceStopAsync();

// Disposing the connection completes any session.destroy RPC that
// Disposing the connection completes any session.detach RPC that
// blocked graceful cleanup. Observe that task before continuing.
await gracefulStop.WaitAsync(s_gracefulClientStopTimeout);
}
Expand Down
6 changes: 3 additions & 3 deletions dotnet/test/Unit/ClientSessionLifetimeTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -847,7 +847,7 @@ private async Task HandleRequestAsync(Stream stream, JsonElement request, Cancel
{
["success"] = true
},
"session.destroy" => await DestroySessionAsync(cancellationToken),
"session.detach" => await DetachSessionAsync(cancellationToken),
"runtime.shutdown" => HandleRuntimeShutdown(),
_ => throw new InvalidOperationException($"Unexpected RPC method '{method}'.")
};
Expand Down Expand Up @@ -884,15 +884,15 @@ private async Task HandleRequestAsync(Stream stream, JsonElement request, Cancel
};
}

private async Task<Dictionary<string, object?>> DestroySessionAsync(CancellationToken cancellationToken)
private async Task<Dictionary<string, object?>> DetachSessionAsync(CancellationToken cancellationToken)
{
if (_delayDestroy)
{
_destroyStarted.TrySetResult();
await _allowDestroy.Task.WaitAsync(cancellationToken);
}

return [];
return new Dictionary<string, object?> { ["success"] = true };
}

private Dictionary<string, object?> HandleRuntimeShutdown()
Expand Down
2 changes: 1 addition & 1 deletion dotnet/test/Unit/GitHubTelemetryTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -350,7 +350,7 @@ private async Task HandleRequestAsync(Stream stream, JsonElement request, Cancel
"session.create" => CaptureCreate(request),
"session.resume" => CaptureResume(request),
"session.send" => new Dictionary<string, object?> { ["messageId"] = "message-1" },
"session.destroy" => new Dictionary<string, object?>(),
"session.detach" => new Dictionary<string, object?> { ["success"] = true },
"session.options.update" => new Dictionary<string, object?> { ["success"] = true },
"runtime.shutdown" => new Dictionary<string, object?>(),
_ => throw new InvalidOperationException($"Unexpected RPC method '{method}'."),
Expand Down
4 changes: 3 additions & 1 deletion go/client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2032,8 +2032,10 @@ func serveInMemoryRuntime(t *testing.T, stdinR *io.PipeReader, stdoutW *io.PipeW
result = map[string]any{"id": "interest-1"}
case "session.options.update":
result = map[string]any{"success": true}
case "session.skills.reload", "session.destroy":
case "session.skills.reload":
result = map[string]any{}
case "session.detach":
result = map[string]any{"success": true}
default:
t.Errorf("unexpected JSON-RPC method %s", request.Method)
return
Expand Down
4 changes: 4 additions & 0 deletions go/internal/e2e/client_options_e2e_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -874,6 +874,10 @@ function handleMessage(message) {
writeResponse(message.id, { sessionId, workspacePath: null, capabilities: null });
return;
}
if (message.method === "session.detach") {
writeResponse(message.id, { success: true });
return;
}
writeResponse(message.id, {});
}

Expand Down
12 changes: 11 additions & 1 deletion go/session.go
Original file line number Diff line number Diff line change
Expand Up @@ -1716,10 +1716,20 @@ func (s *Session) GetEvents(ctx context.Context) ([]SessionEvent, error) {
// log.Printf("Failed to disconnect session: %v", err)
// }
func (s *Session) Disconnect() error {
_, err := s.client.Request(context.Background(), "session.destroy", sessionDestroyRequest{SessionID: s.SessionID})
result, err := s.client.Request(context.Background(), "session.detach", sessionDetachRequest{SessionID: s.SessionID})
if err != nil {
return fmt.Errorf("failed to disconnect session: %w", err)
}
var response sessionDetachResponse
if err := json.Unmarshal(result, &response); err != nil {
return fmt.Errorf("failed to decode session detach response: %w", err)
}
if !response.Success {
if response.Error == "" {
response.Error = "unknown error"
}
return fmt.Errorf("failed to disconnect session: %s", response.Error)
}

s.closeOnce.Do(func() { close(s.eventCh) })

Expand Down
9 changes: 7 additions & 2 deletions go/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -2698,11 +2698,16 @@ type sessionGetMessagesResponse struct {
Events []SessionEvent `json:"events"`
}

// sessionDestroyRequest is the request for session.destroy
type sessionDestroyRequest struct {
// sessionDetachRequest is the request for session.detach
type sessionDetachRequest struct {
SessionID string `json:"sessionId"`
}

type sessionDetachResponse struct {
Success bool `json:"success"`
Error string `json:"error,omitempty"`
}

// sessionAbortRequest is the request for session.abort
type sessionAbortRequest struct {
SessionID string `json:"sessionId"`
Expand Down
25 changes: 20 additions & 5 deletions java/src/main/java/com/github/copilot/CopilotSession.java
Original file line number Diff line number Diff line change
Expand Up @@ -2286,9 +2286,10 @@ private void ensureNotTerminated() {
/**
* Disposes the session and releases all associated resources.
* <p>
* This destroys the session on the server, clears all event handlers, and
* releases tool and permission handlers. After calling this method, the session
* cannot be used again. Subsequent calls to this method have no effect.
* This detaches the session from this client, clears all event handlers, and
* releases tool and permission handlers. Persisted session state remains
* resumable. After calling this method, the session cannot be used again.
* Subsequent calls to this method have no effect.
*/
@Override
public void close() {
Expand All @@ -2302,9 +2303,18 @@ public void close() {
timeoutScheduler.shutdownNow();

try {
rpc.invoke("session.destroy", Map.of("sessionId", sessionId), Void.class).get(5, TimeUnit.SECONDS);
SessionDetachResponse response = rpc
.invoke("session.detach", Map.of("sessionId", sessionId), SessionDetachResponse.class)
.get(5, TimeUnit.SECONDS);
if (response == null || !response.success()) {
LOG.log(Level.FINE, "Failed to detach session {0}: {1}",
new Object[] {
sessionId,
response != null && response.error() != null ? response.error() : "unknown error"
});
Comment thread
jmoseley marked this conversation as resolved.
Outdated
}
} catch (Exception e) {
LOG.log(Level.FINE, "Error destroying session", e);
LOG.log(Level.FINE, "Error detaching session", e);
}

eventHandlers.clear();
Expand All @@ -2320,6 +2330,11 @@ public void close() {

// ===== Internal response types for agent API =====

@JsonIgnoreProperties(ignoreUnknown = true)
private record SessionDetachResponse(@JsonProperty("success") boolean success,
@JsonProperty("error") String error) {
}

@JsonIgnoreProperties(ignoreUnknown = true)
private record AgentListResponse(@JsonProperty("agents") List<AgentInfo> agents) {
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -274,7 +274,8 @@ private void acceptLoop() {
respond(server, id, Map.of("sessionId", params.path("sessionId").asText("resume-1"),
"workspacePath", "/workspace"));
});
server.registerMethodHandler("session.destroy", (id, params) -> respond(server, id, Map.of()));
server.registerMethodHandler("session.detach",
(id, params) -> respond(server, id, Map.of("success", true)));
server.registerMethodHandler("runtime.shutdown", (id, params) -> respond(server, id, Map.of()));
ready.complete(server);
} catch (IOException e) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -452,11 +452,7 @@ void testShouldAcceptDefaultAgentConfigurationOnSessionResume() throws Exception

assertNotNull(session.getSessionId());
String sessionId = session.getSessionId();
// Do not call session.close() here — that invokes session.destroy on the
// server,
// which removes the session and causes the subsequent resumeSession to fail
// with "Session not found". The session handle is simply abandoned and the
// server-side session remains alive for the resume call below.
// Keep the original attachment alive while testing a concurrent resume.

CopilotSession resumedSession = client.resumeSession(sessionId,
new ResumeSessionConfig().setOnPermissionRequest(PermissionHandler.APPROVE_ALL)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -255,7 +255,10 @@ private static JsonNode resultFor(String method, JsonNode params) {
}
case "session.eventLog.registerInterest" -> result.put("id", "interest-1");
case "session.options.update" -> result.put("success", true);
case "session.skills.reload", "session.destroy" -> {
case "session.skills.reload" -> {
}
case "session.detach" -> {
result.put("success", true);
}
default -> throw new IllegalStateException("Unexpected RPC method " + method);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ public int read() throws IOException {
* completed by a stale timeout.
* <p>
* Contract: {@code close()} shuts down the timeout scheduler before the
* blocking {@code session.destroy} RPC call, so any pending timeout task is
* blocking {@code session.detach} RPC call, so any pending timeout task is
* cancelled and the future remains incomplete (not exceptionally completed with
* {@code TimeoutException}).
*/
Expand All @@ -79,7 +79,7 @@ void testTimeoutDoesNotFireAfterSessionClose() throws Exception {

assertFalse(result.isDone(), "Future should be pending before timeout fires");

// close() blocks up to 5s on session.destroy RPC. The 2s timeout
// close() blocks up to 5s on session.detach RPC. The 2s timeout
// fires during that window with the current per-call scheduler.
session.close();

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,8 +31,8 @@ void sendAndWaitWithZeroTimeoutShouldNotTimeOut() throws Exception {
var mockRpc = mock(JsonRpcClient.class);
when(mockRpc.invoke(any(), any(), any())).thenAnswer(invocation -> {
Object method = invocation.getArgument(0);
if ("session.destroy".equals(method)) {
// Make session.close() non-blocking by completing destroy immediately
if ("session.detach".equals(method)) {
// Make session.close() non-blocking by completing detach immediately
return CompletableFuture.completedFuture(null);
}
// For other calls (e.g., message send), return an incomplete future so the
Expand Down
Loading
Loading