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
22 changes: 18 additions & 4 deletions dotnet/src/Session.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1962,8 +1962,12 @@ 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)
{
LogSessionDetachFailed(SessionId, response.Error ?? "unknown error");
}
}
catch (ObjectDisposedException)
{
Expand Down Expand Up @@ -2000,6 +2004,9 @@ await InvokeRpcAsync<object>(
[LoggerMessage(Level = LogLevel.Debug, Message = "Failed to fetch tool metadata for {toolName}")]
private partial void LogToolMetadataFetchFailed(Exception exception, string toolName);

[LoggerMessage(Level = LogLevel.Warning, Message = "Failed to detach session {sessionId}: {error}")]
private partial void LogSessionDetachFailed(string sessionId, string error);

[LoggerMessage(Level = LogLevel.Error, Message = "Permission handler or response delivery failed. SessionId={SessionId}, RequestId={RequestId}")]
private partial void LogPermissionHandlerOrDeliveryFailed(Exception exception, string sessionId, string requestId);

Expand Down Expand Up @@ -2037,11 +2044,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 @@ -2074,7 +2087,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
44 changes: 44 additions & 0 deletions dotnet/test/E2E/SessionE2ETests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -295,6 +295,50 @@ public async Task Resumes_A_Persisted_Session_From_A_New_Client_When_An_Mcp_OAut
Assert.Equal(sessionId, session2.SessionId);
}

[Fact]
public async Task Should_Recover_Marker_After_Cold_Resume_With_Explicit_Session_Id()
{
await using var isolatedCtx = await E2ETestContext.CreateAsync();
await isolatedCtx.ConfigureForTestAsync("session", nameof(Should_Recover_Marker_After_Cold_Resume_With_Explicit_Session_Id));

var sessionId = $"e2e-cold-resume-{Guid.NewGuid()}";

var client1 = isolatedCtx.CreateClient();
var session1 = await isolatedCtx.CreateSessionAsync(client1, new SessionConfig
{
SessionId = sessionId,
OnPermissionRequest = PermissionHandler.ApproveAll,
});
Assert.Equal(sessionId, session1.SessionId);

var answer = await session1.SendAndWaitAsync(new MessageOptions
{
Prompt = "Please remember this exact secret marker for later - MARKER-7f3ac21e. Reply with only the single word \"Acknowledged\".",
});
Assert.NotNull(answer);
Assert.Contains("Acknowledged", answer!.Data.Content ?? string.Empty);

await session1.DisposeAsync();
await client1.ForceStopAsync();

var client2 = isolatedCtx.CreateClient();
var session2 = await isolatedCtx.ResumeSessionAsync(client2, sessionId, new ResumeSessionConfig
{
OnPermissionRequest = PermissionHandler.ApproveAll,
});
Assert.Equal(sessionId, session2.SessionId);

var answer2 = await session2.SendAndWaitAsync(new MessageOptions
{
Prompt = "What was the exact secret marker I asked you to remember earlier? Reply with only that marker value and nothing else.",
});
Assert.NotNull(answer2);
Assert.Contains("MARKER-7f3ac21e", answer2!.Data.Content ?? string.Empty);

await session2.DisposeAsync();
await client2.ForceStopAsync();
}

[Fact]
public async Task Should_Throw_Error_When_Resuming_Non_Existent_Session()
{
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 releases 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 @@ -597,7 +597,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 @@ -1671,7 +1671,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 @@ -1708,15 +1708,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 @@ -492,7 +492,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 @@ -2902,8 +2902,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
10 changes: 5 additions & 5 deletions go/github_token_provider_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -48,8 +48,8 @@ func TestGitHubTokenProviderCreateRequestAndCallback(t *testing.T) {
sessionID := sessionIDFromParams(t, params)
return []byte(`{"sessionId":"` + sessionID + `","workspacePath":"/workspace"}`), nil
})
server.SetRequestHandler("session.destroy", func(json.RawMessage) (json.RawMessage, *jsonrpc2.Error) {
return []byte(`{}`), nil
server.SetRequestHandler("session.detach", func(json.RawMessage) (json.RawMessage, *jsonrpc2.Error) {
return []byte(`{"success":true}`), nil
})

var gotArgs GitHubTokenProviderArgs
Expand Down Expand Up @@ -201,8 +201,8 @@ func TestGitHubTokenStringRedactsAccessToken(t *testing.T) {
func TestGitHubTokenProviderCleanupOnDisconnectError(t *testing.T) {
rpcClient, server, _ := newRuntimeShutdownRpcPair(t)
t.Cleanup(server.Stop)
server.SetRequestHandler("session.destroy", func(json.RawMessage) (json.RawMessage, *jsonrpc2.Error) {
return nil, &jsonrpc2.Error{Code: -32000, Message: "destroy failed"}
server.SetRequestHandler("session.detach", func(json.RawMessage) (json.RawMessage, *jsonrpc2.Error) {
return nil, &jsonrpc2.Error{Code: -32000, Message: "detach failed"}
})
client := &Client{}
registrationID := client.registerGitHubTokenProvider(func(GitHubTokenProviderArgs) (*GitHubTokenProviderResult, error) {
Expand All @@ -213,7 +213,7 @@ func TestGitHubTokenProviderCleanupOnDisconnectError(t *testing.T) {
client.unregisterGitHubTokenProvider(registrationID)
})

if err := session.Disconnect(); err == nil || !strings.Contains(err.Error(), "destroy failed") {
if err := session.Disconnect(); err == nil || !strings.Contains(err.Error(), "detach failed") {
t.Fatalf("Disconnect error = %v", err)
}
if len(client.gitHubTokenProviders) != 0 {
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 @@ -869,6 +869,10 @@ function handleMessage(message) {
writeResponse(message.id, { sessionId, workspacePath: null, capabilities: null });
return;
}
if (message.method === "session.detach") {
writeResponse(message.id, { success: true });
return;
}
if (message.method === "session.resume") {
const sessionId = (message.params && message.params.sessionId) || "fake-session";
writeResponse(message.id, { sessionId, workspacePath: null, capabilities: null });
Expand Down
58 changes: 58 additions & 0 deletions go/internal/e2e/session_e2e_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ import (
"testing"
"time"

"github.com/google/uuid"

copilot "github.com/github/copilot-sdk/go"
"github.com/github/copilot-sdk/go/internal/e2e/testharness"
"github.com/github/copilot-sdk/go/rpc"
Expand Down Expand Up @@ -526,6 +528,62 @@ func TestSessionE2E(t *testing.T) {
}
})

t.Run("should recover marker after cold resume with explicit session id", func(t *testing.T) {
ctx.ConfigureForTest(t)

sessionID := "e2e-cold-resume-" + uuid.NewString()

client1 := ctx.NewClient()
session1, err := client1.CreateSession(t.Context(), &copilot.SessionConfig{
OnPermissionRequest: copilot.PermissionHandler.ApproveAll,
SessionID: sessionID,
})
if err != nil {
t.Fatalf("Failed to create session: %v", err)
}
if session1.SessionID != sessionID {
t.Fatalf("Expected explicit session ID %q, got %q", sessionID, session1.SessionID)
}

answer, err := session1.SendAndWait(t.Context(), copilot.MessageOptions{
Prompt: `Please remember this exact secret marker for later - MARKER-7f3ac21e. Reply with only the single word "Acknowledged".`,
})
if err != nil {
t.Fatalf("Failed to send message: %v", err)
}
if ad, ok := answer.Data.(*copilot.AssistantMessageData); !ok || !strings.Contains(ad.Content, "Acknowledged") {
t.Errorf("Expected answer to contain 'Acknowledged', got %v", answer.Data)
}

if err := session1.Disconnect(); err != nil {
t.Fatalf("Failed to disconnect session: %v", err)
}
client1.ForceStop()

client2 := ctx.NewClient()
defer client2.ForceStop()

session2, err := client2.ResumeSession(t.Context(), sessionID, &copilot.ResumeSessionConfig{
OnPermissionRequest: copilot.PermissionHandler.ApproveAll,
})
if err != nil {
t.Fatalf("Failed to resume session: %v", err)
}
if session2.SessionID != sessionID {
t.Errorf("Expected resumed session ID to match, got %q vs %q", session2.SessionID, sessionID)
}

answer2, err := session2.SendAndWait(t.Context(), copilot.MessageOptions{
Prompt: "What was the exact secret marker I asked you to remember earlier? Reply with only that marker value and nothing else.",
})
if err != nil {
t.Fatalf("Failed to send message after resume: %v", err)
}
if ad, ok := answer2.Data.(*copilot.AssistantMessageData); !ok || !strings.Contains(ad.Content, "MARKER-7f3ac21e") {
t.Errorf("Expected resumed answer to contain marker, got %v", answer2.Data)
}
})

t.Run("should throw error when resuming non-existent session", func(t *testing.T) {
ctx.ConfigureForTest(t)

Expand Down
17 changes: 16 additions & 1 deletion go/session.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ package copilot
import (
"context"
"encoding/json"
"errors"
"fmt"
"log"
"sync"
Expand Down Expand Up @@ -1740,8 +1741,22 @@ 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 {
var response sessionDetachResponse
if decodeErr := json.Unmarshal(result, &response); decodeErr != nil {
err = fmt.Errorf("failed to decode session detach response: %w", decodeErr)
} else if !response.Success {
if response.Error == "" {
response.Error = "unknown error"
}
err = errors.New(response.Error)
}
}

// Local cleanup always runs, even if the detach RPC failed, so callers
// don't leak in-memory resources (event goroutines, registered
// providers/handlers) just because the runtime couldn't be reached.
s.stopEventProcessing()
s.releaseGitHubTokenProviderRegistration()

Expand Down
9 changes: 7 additions & 2 deletions go/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -2890,11 +2890,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
22 changes: 20 additions & 2 deletions java/sdk/src/main/java/com/github/copilot/CopilotSession.java
Original file line number Diff line number Diff line change
Expand Up @@ -2318,10 +2318,20 @@ public void close() {
timeoutScheduler.shutdownNow();
releaseGitHubTokenProviderRegistration();

RuntimeException detachFailure = null;
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()) {
String detail = response != null && response.error() != null ? response.error() : "unknown error";
detachFailure = new IllegalStateException("Failed to detach session " + sessionId + ": " + detail);
}
} catch (Exception e) {
LOG.log(Level.FINE, "Error destroying session", e);
if (e instanceof InterruptedException) {
Thread.currentThread().interrupt();
}
detachFailure = new IllegalStateException("Failed to detach session " + sessionId, e);
}

eventHandlers.clear();
Expand All @@ -2333,10 +2343,18 @@ public void close() {
exitPlanModeHandler.set(null);
autoModeSwitchHandler.set(null);
hooksHandler.set(null);

if (detachFailure != null) {
throw detachFailure;
}
}

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

@JsonIgnoreProperties(ignoreUnknown = true)
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 @@ -263,6 +263,8 @@ function resultFor(message) {
return { sessionId: message.params?.sessionId ?? 'fake-session', openCanvases: [] };
case 'session.resume':
return { sessionId: message.params?.sessionId ?? 'fake-session', openCanvases: [] };
case 'session.detach':
return { success: true };
case 'session.options.update':
return { success: true };
default:
Expand Down
Loading
Loading