Skip to content
Open
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
57 changes: 43 additions & 14 deletions dotnet/src/Client.cs
Original file line number Diff line number Diff line change
Expand Up @@ -773,8 +773,11 @@ private CopilotSession InitializeSession(
SessionConfigBase config,
Dictionary<string, Func<string, Task<string>>>? transformCallbacks,
bool hasHooks,
string callerName)
string callerName,
bool replaceExisting,
out CopilotSession? replacedSession)
{
replacedSession = null;
var setupTimestamp = Stopwatch.GetTimestamp();
var session = new CopilotSession(
sessionId,
Expand Down Expand Up @@ -807,7 +810,24 @@ private CopilotSession InitializeSession(
ConfigureSessionFsHandlers(session, config.CreateSessionFsProvider);
session.SetCanvasHandler(config.CanvasHandler);
session.RegisterBearerTokenProviders(BuildBearerTokenCallbacks(config));
RegisterSession(session);
if (replaceExisting)
{
CopilotSession? displacedSession = null;
_sessions.AddOrUpdate(
session.SessionId,
session,
(_, current) =>
{
displacedSession = current;
return session;
});
replacedSession = displacedSession;
}
else if (!_sessions.TryAdd(session.SessionId, session))
{
throw new InvalidOperationException($"Session '{session.SessionId}' is already tracked by this client.");
}

session.StartProcessingEvents();
LoggingHelpers.LogTiming(_logger, LogLevel.Debug, null,
callerName + " local setup complete. Elapsed={Elapsed}, SessionId={SessionId}, Tools={ToolsCount}, Commands={CommandsCount}, Hooks={HasHooks}",
Expand Down Expand Up @@ -1119,7 +1139,9 @@ public async Task<CopilotSession> CreateSessionAsync(SessionConfig config, Cance
config,
transformCallbacks,
hasHooks,
"CopilotClient.CreateSessionAsync");
"CopilotClient.CreateSessionAsync",
replaceExisting: false,
out _);
}
try
{
Expand Down Expand Up @@ -1219,7 +1241,9 @@ public async Task<CopilotSession> CreateSessionAsync(SessionConfig config, Cance
config,
transformCallbacks,
hasHooks,
"CopilotClient.CreateSessionAsync");
"CopilotClient.CreateSessionAsync",
replaceExisting: false,
out _);
}
};

Expand Down Expand Up @@ -1285,6 +1309,9 @@ public async Task<CopilotSession> CreateSessionAsync(SessionConfig config, Cance
/// <remarks>
/// This allows you to continue a previous conversation, maintaining all conversation history.
/// The session must have been previously created and not deleted.
/// If this client already tracks the session, the returned instance replaces the previous
/// <see cref="CopilotSession"/> for event and request routing. Existing references to the
/// previous instance remain usable, but no longer receive routed events or requests.
/// </remarks>
/// <example>
/// <code>
Expand Down Expand Up @@ -1331,7 +1358,9 @@ public async Task<CopilotSession> ResumeSessionAsync(string sessionId, ResumeSes
config,
transformCallbacks,
hasHooks,
"CopilotClient.ResumeSessionAsync");
"CopilotClient.ResumeSessionAsync",
replaceExisting: true,
out var previousSession);
try
{
var (traceparent, tracestate) = TelemetryHelpers.GetTraceContext();
Expand Down Expand Up @@ -1430,7 +1459,15 @@ public async Task<CopilotSession> ResumeSessionAsync(string sessionId, ResumeSes
}
catch (Exception ex)
{
session.RemoveFromClient();
if (previousSession is null)
{
session.RemoveFromClient();
}
else
{
_sessions.TryUpdate(sessionId, previousSession, session);
}

if (ex is not OperationCanceledException)
{
LoggingHelpers.LogTiming(_logger, LogLevel.Warning, ex,
Expand Down Expand Up @@ -2475,14 +2512,6 @@ private static JsonSerializerOptions CreateSerializerOptions()
return session;
}

private void RegisterSession(CopilotSession session)
{
if (!_sessions.TryAdd(session.SessionId, session))
{
throw new InvalidOperationException($"Session '{session.SessionId}' is already tracked by this client.");
}
}

private void RemoveSession(string sessionId)
{
_sessions.TryRemove(sessionId, out _);
Expand Down
2 changes: 1 addition & 1 deletion dotnet/test/E2E/RpcWorkspaceCheckpointsE2ETests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ public async Task Should_Return_Null_Or_Empty_Content_For_Unknown_Checkpoint()
{
await using var session = await CreateSessionAsync();

var result = await session.Rpc.Workspaces.ReadCheckpointAsync(long.MaxValue);
var result = await session.Rpc.Workspaces.ReadCheckpointAsync(uint.MaxValue);

Assert.True(string.IsNullOrEmpty(result.Content));
}
Expand Down
17 changes: 10 additions & 7 deletions dotnet/test/E2E/SessionE2ETests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -226,17 +226,20 @@ public async Task Should_Create_Session_With_Custom_Tool()
}

[Fact]
public async Task Should_Reject_Resuming_Active_Session_Using_The_Same_Client()
public async Task Should_Replace_Active_Session_When_Resuming_Using_The_Same_Client()
{
var session1 = await CreateSessionAsync();
var sessionId = session1.SessionId;

var exception = await Assert.ThrowsAsync<InvalidOperationException>(() =>
Client.ResumeSessionAsync(sessionId, new ResumeSessionConfig
{
OnPermissionRequest = PermissionHandler.ApproveAll,
}));
Assert.Contains(sessionId, exception.Message);
await using var session2 = await Client.ResumeSessionAsync(sessionId, new ResumeSessionConfig
{
OnPermissionRequest = PermissionHandler.ApproveAll,
});

Assert.Equal(sessionId, session2.SessionId);
_ = await session1.GetEventsAsync();

await session1.DisposeAsync();
}

[Fact]
Expand Down
24 changes: 17 additions & 7 deletions dotnet/test/Harness/E2ETestBase.cs
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ internal static string GetTestName(ITestOutputHelper output)

public async Task InitializeAsync()
{
Ctx.PrepareForTest();
await Ctx.CleanupAfterTestAsync();
await Ctx.ConfigureForTestAsync(_snapshotCategory, _testName);
}
Expand Down Expand Up @@ -88,14 +89,23 @@ protected async Task<CopilotSession> ResumeSessionAsync(string sessionId, Resume
config ??= new ResumeSessionConfig();
config.OnPermissionRequest ??= PermissionHandler.ApproveAll;

await Client.StartAsync();
var port = Client.RuntimePort
?? throw new InvalidOperationException("The shared E2E client must use TCP transport to support multi-client resume.");

var client = Ctx.CreateClient(options: new CopilotClientOptions
CopilotClient client;
if (E2ETestContext.UsesInProcessTransport)
{
client = Client;
}
else
{
Connection = RuntimeConnection.ForUri($"localhost:{port}", connectionToken: E2ETestFixture.SharedTcpConnectionToken),
});
await Client.StartAsync();
var port = Client.RuntimePort
?? throw new InvalidOperationException("The shared E2E client must use TCP transport to support multi-client resume.");

client = Ctx.CreateClient(options: new CopilotClientOptions
{
Connection = RuntimeConnection.ForUri($"localhost:{port}", connectionToken: E2ETestFixture.SharedTcpConnectionToken),
});
}

return await client.ResumeSessionAsync(sessionId, config);
}

Expand Down
40 changes: 25 additions & 15 deletions dotnet/test/Harness/E2ETestContext.cs
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ public sealed class E2ETestContext : IAsyncDisposable
public string HomeDir { get; }
public string WorkDir { get; }
public string ProxyUrl { get; }
internal static bool UsesInProcessTransport => IsInProcess(null);

/// <summary>Optional logger injected by tests; applied to all clients created via <see cref="CreateClient"/>.</summary>
public ILogger? Logger { get; set; }
Expand Down Expand Up @@ -298,22 +299,8 @@ public CopilotClient CreateClient(

if (IsInProcess(options.Connection))
{
// In-process hosting: runtime code runs host-side in this process (the
// loaded cdylib) and reads the ambient process environment rather than
// the environment passed to copilot_runtime_host_start, so the per-test
// redirects, cleared tokens/HMAC, and isolated home must be mirrored
// onto this process's real environment. Restored after each test by
// InProcessEnvIsolationAttribute.
foreach (var (name, value) in env)
{
InProcessEnvIsolation.Apply(name, value);
}

// A per-client WorkingDirectory is rejected in-process; instead point this
// process's cwd at the desired directory so the worker inherits it at spawn
// (restored after the test by InProcessEnvIsolationAttribute).
options.WorkingDirectory = null;
InProcessEnvIsolation.SetWorkingDirectory(desiredWorkingDirectory);
ApplyInProcessEnvironment(env, desiredWorkingDirectory);
}
else if (options.Connection is ChildProcessRuntimeConnection child)
{
Expand Down Expand Up @@ -352,6 +339,29 @@ public CopilotClient CreateClient(
return client;
}

internal void PrepareForTest()
{
if (UsesInProcessTransport)
{
ApplyInProcessEnvironment(GetEnvironment(), WorkDir);
}
}

private static void ApplyInProcessEnvironment(IReadOnlyDictionary<string, string> environment, string workingDirectory)
{
// Runtime code runs host-side in this process and reads its ambient environment,
// so restore the per-test redirects and isolated home after the assembly-level
// isolation attribute reset them at the end of the preceding test.
foreach (var (name, value) in environment)
{
InProcessEnvIsolation.Apply(name, value);
}

// The worker inherits the host process cwd because the native host has no
// per-client working-directory parameter.
InProcessEnvIsolation.SetWorkingDirectory(workingDirectory);
}

public void UntrackClient(CopilotClient client)
{
lock (_clientsLock)
Expand Down
7 changes: 6 additions & 1 deletion dotnet/test/Harness/E2ETestFixture.cs
Original file line number Diff line number Diff line change
Expand Up @@ -19,10 +19,15 @@ public async Task InitializeAsync()
Ctx = await E2ETestContext.CreateAsync();
Client = Ctx.CreateClient(options: new CopilotClientOptions
{
Connection = RuntimeConnection.ForTcp(connectionToken: SharedTcpConnectionToken),
Connection = CreateSharedConnection(E2ETestContext.UsesInProcessTransport),
}, persistent: true);
}

internal static RuntimeConnection CreateSharedConnection(bool useInProcessTransport) =>
useInProcessTransport
? RuntimeConnection.ForInProcess()
: RuntimeConnection.ForTcp(connectionToken: SharedTcpConnectionToken);

public async Task DisposeAsync()
{
await Ctx.DisposeAsync();
Expand Down
76 changes: 72 additions & 4 deletions dotnet/test/Unit/ClientSessionLifetimeTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -183,25 +183,64 @@ public async Task StopAsync_Keeps_Session_Rooted_Until_Destroy_Completes()
}

[Fact]
public async Task ResumeSessionAsync_Throws_When_Same_Client_Already_Tracks_Session()
public async Task ResumeSessionAsync_Replaces_Session_Tracked_By_Same_Client()
{
await using var server = await FakeCopilotServer.StartAsync();
await using var client = new CopilotClient(new CopilotClientOptions { Connection = RuntimeConnection.ForUri(server.Url) });

var sessionId = "same-session-id";
await using var session = await client.CreateSessionAsync(new SessionConfig
var session = await client.CreateSessionAsync(new SessionConfig
{
SessionId = sessionId,
OnPermissionRequest = PermissionHandler.ApproveAll
});
AssertSessionCount(client, sessions: 1);

var exception = await Assert.ThrowsAsync<InvalidOperationException>(() => client.ResumeSessionAsync(sessionId, new ResumeSessionConfig
var resumedSession = await client.ResumeSessionAsync(sessionId, new ResumeSessionConfig
{
OnPermissionRequest = PermissionHandler.ApproveAll
});

Assert.NotSame(session, resumedSession);
AssertSessionCount(client, sessions: 1);
Assert.Same(resumedSession, GetTrackedSession(client, sessionId));
Assert.Equal("message-1", await session.SendAsync("The previous wrapper remains callable."));
Assert.DoesNotContain(server.Requests, request => request.Method == "session.destroy");

await session.DisposeAsync();
AssertSessionCount(client, sessions: 1);

await resumedSession.DisposeAsync();
AssertSessionCount(client, sessions: 0);
Assert.Equal(2, server.Requests.Count(request => request.Method == "session.destroy"));
}

[Fact]
public async Task Failed_ResumeSessionAsync_Restores_Previous_Registration()
{
await using var server = await FakeCopilotServer.StartAsync();
await using var client = new CopilotClient(new CopilotClientOptions { Connection = RuntimeConnection.ForUri(server.Url) });

var sessionId = "same-session-id";
var session = await client.CreateSessionAsync(new SessionConfig
{
SessionId = sessionId,
OnPermissionRequest = PermissionHandler.ApproveAll
});
server.FailNextResume();

await Assert.ThrowsAsync<IOException>(() => client.ResumeSessionAsync(sessionId, new ResumeSessionConfig
{
OnPermissionRequest = PermissionHandler.ApproveAll
}));
Assert.Contains(sessionId, exception.Message);

AssertSessionCount(client, sessions: 1);
Assert.Same(session, GetTrackedSession(client, sessionId));
Assert.Equal("message-1", await session.SendAsync("The original session remains active."));

await session.DisposeAsync();
AssertSessionCount(client, sessions: 0);
Assert.Single(server.Requests, request => request.Method == "session.destroy");
}

[Fact]
Expand Down Expand Up @@ -387,6 +426,13 @@ private static void AssertSessionCount(CopilotClient client, int sessions)
Assert.Equal(sessions, GetPrivateDictionaryCount(client, "_sessions"));
}

private static CopilotSession? GetTrackedSession(CopilotClient client, string sessionId)
{
var method = typeof(CopilotClient).GetMethod("GetSession", BindingFlags.Instance | BindingFlags.NonPublic)
?? throw new InvalidOperationException("GetSession method was not found.");
return (CopilotSession?)method.Invoke(client, [sessionId]);
}

private static int GetPrivateDictionaryCount(CopilotClient client, string fieldName)
{
var field = typeof(CopilotClient).GetField(fieldName, BindingFlags.Instance | BindingFlags.NonPublic)
Expand Down Expand Up @@ -467,6 +513,7 @@ private sealed class FakeCopilotServer : IAsyncDisposable
private string? _lastSessionId;
private bool _delayDestroy;
private bool _failRuntimeShutdown;
private bool _failNextResume;

private FakeCopilotServer(TcpListener listener)
{
Expand Down Expand Up @@ -528,6 +575,11 @@ public void FailRuntimeShutdown()
_failRuntimeShutdown = true;
}

public void FailNextResume()
{
_failNextResume = true;
}

public async ValueTask DisposeAsync()
{
_allowDestroy.TrySetResult();
Expand Down Expand Up @@ -572,6 +624,22 @@ private async Task HandleRequestAsync(Stream stream, JsonElement request, Cancel

var id = idElement.Clone();
var method = request.GetProperty("method").GetString();
if (method == "session.resume" && _failNextResume)
{
_failNextResume = false;
await WriteMessageAsync(stream, new Dictionary<string, object?>
{
["jsonrpc"] = "2.0",
["id"] = id,
["error"] = new Dictionary<string, object?>
{
["code"] = -32000,
["message"] = "session resume failed"
}
}, cancellationToken);
return;
}

if (method == "runtime.shutdown" && _failRuntimeShutdown)
{
RuntimeShutdownCount++;
Expand Down
Loading
Loading