Skip to content

Commit 1ff6326

Browse files
joshspicerCopilot
andcommitted
Merge origin/main into joshspicer/managed-approval-required-sdk
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
2 parents 6dba2c5 + 9266cf4 commit 1ff6326

15 files changed

Lines changed: 156 additions & 47 deletions

dotnet/README.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,12 @@ await session.SendAsync(new MessageOptions { Prompt = "What is 2+2?" });
6464
await done.Task;
6565
```
6666

67+
When targeting MCP tools configured through `McpServers`, remember the runtime
68+
tool name is `<server-key>-<tool-name>`. For `AvailableTools` and
69+
`ExcludedTools`, prefer the source-qualified form
70+
`mcp:<server-key>-<tool-name>`. For `CustomAgents[].Tools` and
71+
`DefaultAgent.ExcludedTools`, use `<server-key>-<tool-name>` directly.
72+
6773
## API Reference
6874

6975
### CopilotClient

dotnet/test/E2E/CommandsE2ETests.cs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -202,8 +202,9 @@ public async Task Session_With_Commands_Creates_Successfully()
202202
[Fact]
203203
public async Task Session_With_Commands_Resumes_Successfully()
204204
{
205-
var session1 = await CreateSessionAsync();
205+
await using var session1 = await CreateSessionAsync();
206206
var sessionId = session1.SessionId;
207+
await SuspendAndUntrackSessionForResumeAsync(session1);
207208

208209
var session2 = await ResumeSessionAsync(sessionId, new ResumeSessionConfig
209210
{

dotnet/test/E2E/RpcWorkspaceCheckpointsE2ETests.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ public async Task Should_Return_Null_Or_Empty_Content_For_Unknown_Checkpoint()
2929
{
3030
await using var session = await CreateSessionAsync();
3131

32-
var result = await session.Rpc.Workspaces.ReadCheckpointAsync(long.MaxValue);
32+
var result = await session.Rpc.Workspaces.ReadCheckpointAsync(uint.MaxValue);
3333

3434
Assert.True(string.IsNullOrEmpty(result.Content));
3535
}

dotnet/test/E2E/SessionConfigE2ETests.cs

Lines changed: 24 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -173,9 +173,11 @@ public async Task Should_Apply_All_ReasoningEffort_Values_On_Session_Create(stri
173173
[Trait(E2ETestTraits.Backend, E2ETestTraits.SelfConfiguredBackend)]
174174
public async Task Should_Apply_ReasoningEffort_On_Session_Resume()
175175
{
176-
var originalSession = await CreateSessionAsync();
176+
await using var originalSession = await CreateSessionAsync();
177+
var sessionId = originalSession.SessionId;
178+
await SuspendAndUntrackSessionForResumeAsync(originalSession);
177179
const string reasoningModelId = "custom-reasoning-model";
178-
var resumedSession = await ResumeSessionAsync(originalSession.SessionId, new ResumeSessionConfig
180+
var resumedSession = await ResumeSessionAsync(sessionId, new ResumeSessionConfig
179181
{
180182
Model = reasoningModelId,
181183
Provider = CreateProxyProvider("resume-reasoning"),
@@ -187,7 +189,6 @@ public async Task Should_Apply_ReasoningEffort_On_Session_Resume()
187189
Assert.Equal("high", resumeEvent.Data.ReasoningEffort);
188190

189191
await resumedSession.DisposeAsync();
190-
await originalSession.DisposeAsync();
191192
}
192193

193194
[Fact]
@@ -233,8 +234,9 @@ public async Task Should_Forward_Custom_Provider_Headers_On_Create()
233234
[Trait(E2ETestTraits.Backend, E2ETestTraits.SelfConfiguredBackend)]
234235
public async Task Should_Forward_Custom_Provider_Headers_On_Resume()
235236
{
236-
var session1 = await CreateSessionAsync();
237+
await using var session1 = await CreateSessionAsync();
237238
var sessionId = session1.SessionId;
239+
await SuspendAndUntrackSessionForResumeAsync(session1);
238240

239241
var session2 = await ResumeSessionAsync(sessionId, new ResumeSessionConfig
240242
{
@@ -339,8 +341,9 @@ public async Task Should_Apply_WorkingDirectory_On_Session_Resume()
339341
Directory.CreateDirectory(subDir);
340342
await File.WriteAllTextAsync(Path.Join(subDir, "resume-marker.txt"), "I am in the resume working directory");
341343

342-
var session1 = await CreateSessionAsync();
344+
await using var session1 = await CreateSessionAsync();
343345
var sessionId = session1.SessionId;
346+
await SuspendAndUntrackSessionForResumeAsync(session1);
344347

345348
var session2 = await ResumeSessionAsync(sessionId, new ResumeSessionConfig
346349
{
@@ -360,8 +363,9 @@ public async Task Should_Apply_WorkingDirectory_On_Session_Resume()
360363
[Fact]
361364
public async Task Should_Apply_SystemMessage_On_Session_Resume()
362365
{
363-
var session1 = await CreateSessionAsync();
366+
await using var session1 = await CreateSessionAsync();
364367
var sessionId = session1.SessionId;
368+
await SuspendAndUntrackSessionForResumeAsync(session1);
365369

366370
var resumeInstruction = "End the response with RESUME_SYSTEM_MESSAGE_SENTINEL.";
367371
var session2 = await ResumeSessionAsync(sessionId, new ResumeSessionConfig
@@ -422,11 +426,13 @@ await File.WriteAllTextAsync(
422426
Path.Join(instructionFilesDir, "extra.instructions.md"),
423427
$"Always include {sentinel}.");
424428

425-
var session1 = await CreateSessionAsync(new SessionConfig
429+
await using var session1 = await CreateSessionAsync(new SessionConfig
426430
{
427431
WorkingDirectory = projectDir,
428432
});
429-
var session2 = await ResumeSessionAsync(session1.SessionId, new ResumeSessionConfig
433+
var sessionId = session1.SessionId;
434+
await SuspendAndUntrackSessionForResumeAsync(session1);
435+
var session2 = await ResumeSessionAsync(sessionId, new ResumeSessionConfig
430436
{
431437
WorkingDirectory = projectDir,
432438
InstructionDirectories = [instructionDir],
@@ -438,14 +444,14 @@ await File.WriteAllTextAsync(
438444
Assert.Contains(sentinel, GetSystemMessage(exchange));
439445

440446
await session2.DisposeAsync();
441-
await session1.DisposeAsync();
442447
}
443448

444449
[Fact]
445450
public async Task Should_Apply_AvailableTools_On_Session_Resume()
446451
{
447-
var session1 = await CreateSessionAsync();
452+
await using var session1 = await CreateSessionAsync();
448453
var sessionId = session1.SessionId;
454+
await SuspendAndUntrackSessionForResumeAsync(session1);
449455

450456
var session2 = await ResumeSessionAsync(sessionId, new ResumeSessionConfig
451457
{
@@ -493,8 +499,10 @@ public async Task Should_Apply_Session_Limits_On_Create()
493499
[Fact]
494500
public async Task Should_Apply_Session_Limits_On_Resume()
495501
{
496-
var session1 = await CreateSessionAsync();
497-
var session2 = await ResumeSessionAsync(session1.SessionId, new ResumeSessionConfig
502+
await using var session1 = await CreateSessionAsync();
503+
var sessionId = session1.SessionId;
504+
await SuspendAndUntrackSessionForResumeAsync(session1);
505+
var session2 = await ResumeSessionAsync(sessionId, new ResumeSessionConfig
498506
{
499507
SessionLimits = new SessionLimitsConfig
500508
{
@@ -513,7 +521,6 @@ public async Task Should_Apply_Session_Limits_On_Resume()
513521
finally
514522
{
515523
await session2.DisposeAsync();
516-
await session1.DisposeAsync();
517524
}
518525
}
519526

@@ -558,8 +565,10 @@ public async Task Should_Apply_Excluded_Built_In_Agents_On_Resume()
558565
{
559566
const string excludedAgent = "explore";
560567

561-
var session1 = await CreateSessionAsync();
562-
var session2 = await ResumeSessionAsync(session1.SessionId, new ResumeSessionConfig
568+
await using var session1 = await CreateSessionAsync();
569+
var sessionId = session1.SessionId;
570+
await SuspendAndUntrackSessionForResumeAsync(session1);
571+
var session2 = await ResumeSessionAsync(sessionId, new ResumeSessionConfig
563572
{
564573
ExcludedBuiltInAgents = [excludedAgent],
565574
});
@@ -575,7 +584,6 @@ public async Task Should_Apply_Excluded_Built_In_Agents_On_Resume()
575584
finally
576585
{
577586
await session2.DisposeAsync();
578-
await session1.DisposeAsync();
579587
}
580588
}
581589

dotnet/test/E2E/SessionE2ETests.cs

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -228,7 +228,7 @@ public async Task Should_Create_Session_With_Custom_Tool()
228228
[Fact]
229229
public async Task Should_Reject_Resuming_Active_Session_Using_The_Same_Client()
230230
{
231-
var session1 = await CreateSessionAsync();
231+
await using var session1 = await CreateSessionAsync();
232232
var sessionId = session1.SessionId;
233233

234234
var exception = await Assert.ThrowsAsync<InvalidOperationException>(() =>
@@ -983,8 +983,9 @@ public async Task Should_Create_Session_With_Azure_Provider()
983983
[Trait(E2ETestTraits.Backend, E2ETestTraits.SelfConfiguredBackend)]
984984
public async Task Should_Resume_Session_With_Custom_Provider()
985985
{
986-
var session = await CreateSessionAsync();
986+
await using var session = await CreateSessionAsync();
987987
var sessionId = session.SessionId;
988+
await SuspendAndUntrackSessionForResumeAsync(session);
988989

989990
var session2 = await ResumeSessionAsync(sessionId, new ResumeSessionConfig
990991
{
@@ -1006,7 +1007,5 @@ public async Task Should_Resume_Session_With_Custom_Provider()
10061007
{
10071008
// disconnect may fail since the provider is fake
10081009
}
1009-
1010-
await session.DisposeAsync();
10111010
}
10121011
}

dotnet/test/E2E/SkillsE2ETests.cs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -208,13 +208,14 @@ public async Task Should_Apply_Skill_On_Session_Resume_With_SkillDirectories()
208208
var skillsDir = CreateSkillDir();
209209

210210
// Create a session without skills first
211-
var session1 = await CreateSessionAsync();
211+
await using var session1 = await CreateSessionAsync();
212212
var sessionId = session1.SessionId;
213213

214214
// First message without skill - marker should not appear
215215
var message1 = await session1.SendAndWaitAsync(new MessageOptions { Prompt = "Say hi." });
216216
Assert.NotNull(message1);
217217
Assert.DoesNotContain(SkillMarker, message1!.Data.Content);
218+
await SuspendAndUntrackSessionForResumeAsync(session1);
218219

219220
// Resume with skillDirectories - skill should now be active
220221
var session2 = await ResumeSessionAsync(sessionId, new ResumeSessionConfig

dotnet/test/Harness/E2ETestBase.cs

Lines changed: 31 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ internal static string GetTestName(ITestOutputHelper output)
5959

6060
public async Task InitializeAsync()
6161
{
62+
Ctx.PrepareForTest();
6263
await Ctx.CleanupAfterTestAsync();
6364
await Ctx.ConfigureForTestAsync(_snapshotCategory, _testName);
6465
}
@@ -88,17 +89,40 @@ protected async Task<CopilotSession> ResumeSessionAsync(string sessionId, Resume
8889
config ??= new ResumeSessionConfig();
8990
config.OnPermissionRequest ??= PermissionHandler.ApproveAll;
9091

91-
await Client.StartAsync();
92-
var port = Client.RuntimePort
93-
?? throw new InvalidOperationException("The shared E2E client must use TCP transport to support multi-client resume.");
94-
95-
var client = Ctx.CreateClient(options: new CopilotClientOptions
92+
CopilotClient client;
93+
if (E2ETestContext.UsesInProcessTransport)
94+
{
95+
client = Client;
96+
}
97+
else
9698
{
97-
Connection = RuntimeConnection.ForUri($"localhost:{port}", connectionToken: E2ETestFixture.SharedTcpConnectionToken),
98-
});
99+
await Client.StartAsync();
100+
var port = Client.RuntimePort
101+
?? throw new InvalidOperationException("The shared E2E client must use TCP transport to support multi-client resume.");
102+
103+
client = Ctx.CreateClient(options: new CopilotClientOptions
104+
{
105+
Connection = RuntimeConnection.ForUri($"localhost:{port}", connectionToken: E2ETestFixture.SharedTcpConnectionToken),
106+
});
107+
}
108+
99109
return await Ctx.ResumeSessionAsync(client, sessionId, config);
100110
}
101111

112+
protected static async Task SuspendAndUntrackSessionForResumeAsync(CopilotSession session)
113+
{
114+
await session.Rpc.SuspendAsync();
115+
116+
// In-process clients host separate runtimes, while session.destroy removes the
117+
// session from the current runtime. Untrack locally to exercise resume without
118+
// either replacing an active wrapper or destroying the session first.
119+
var removeFromClient = typeof(CopilotSession).GetMethod(
120+
"RemoveFromClient",
121+
BindingFlags.Instance | BindingFlags.NonPublic)
122+
?? throw new InvalidOperationException("CopilotSession.RemoveFromClient was not found.");
123+
removeFromClient.Invoke(session, null);
124+
}
125+
102126
protected static string GetSystemMessage(ParsedHttpExchange exchange)
103127
{
104128
return exchange.Request.Messages.FirstOrDefault(m => m.Role == "system")?.StringContent ?? string.Empty;

dotnet/test/Harness/E2ETestContext.cs

Lines changed: 25 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ public sealed class E2ETestContext : IAsyncDisposable
1717
public string HomeDir { get; }
1818
public string WorkDir { get; }
1919
public string ProxyUrl { get; }
20+
internal static bool UsesInProcessTransport => IsInProcess(null);
2021

2122
/// <summary>Optional logger injected by tests; applied to all clients created via <see cref="CreateClient"/>.</summary>
2223
public ILogger? Logger { get; set; }
@@ -322,22 +323,8 @@ public CopilotClient CreateClient(
322323

323324
if (IsInProcess(options.Connection))
324325
{
325-
// In-process hosting: runtime code runs host-side in this process (the
326-
// loaded cdylib) and reads the ambient process environment rather than
327-
// the environment passed to copilot_runtime_host_start, so the per-test
328-
// redirects, cleared tokens/HMAC, and isolated home must be mirrored
329-
// onto this process's real environment. Restored after each test by
330-
// InProcessEnvIsolationAttribute.
331-
foreach (var (name, value) in env)
332-
{
333-
InProcessEnvIsolation.Apply(name, value);
334-
}
335-
336-
// A per-client WorkingDirectory is rejected in-process; instead point this
337-
// process's cwd at the desired directory so the worker inherits it at spawn
338-
// (restored after the test by InProcessEnvIsolationAttribute).
339326
options.WorkingDirectory = null;
340-
InProcessEnvIsolation.SetWorkingDirectory(desiredWorkingDirectory);
327+
ApplyInProcessEnvironment(env, desiredWorkingDirectory);
341328
}
342329
else if (options.Connection is ChildProcessRuntimeConnection child)
343330
{
@@ -395,6 +382,29 @@ public Task<CopilotSession> ResumeSessionAsync(
395382
return client.ResumeSessionAsync(sessionId, config);
396383
}
397384

385+
internal void PrepareForTest()
386+
{
387+
if (UsesInProcessTransport)
388+
{
389+
ApplyInProcessEnvironment(GetEnvironment(), WorkDir);
390+
}
391+
}
392+
393+
private static void ApplyInProcessEnvironment(IReadOnlyDictionary<string, string> environment, string workingDirectory)
394+
{
395+
// Runtime code runs host-side in this process and reads its ambient environment,
396+
// so restore the per-test redirects and isolated home after the assembly-level
397+
// isolation attribute reset them at the end of the preceding test.
398+
foreach (var (name, value) in environment)
399+
{
400+
InProcessEnvIsolation.Apply(name, value);
401+
}
402+
403+
// The worker inherits the host process cwd because the native host has no
404+
// per-client working-directory parameter.
405+
InProcessEnvIsolation.SetWorkingDirectory(workingDirectory);
406+
}
407+
398408
public void UntrackClient(CopilotClient client)
399409
{
400410
lock (_clientsLock)

dotnet/test/Harness/E2ETestFixture.cs

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,10 +19,15 @@ public async Task InitializeAsync()
1919
Ctx = await E2ETestContext.CreateAsync();
2020
Client = Ctx.CreateClient(options: new CopilotClientOptions
2121
{
22-
Connection = RuntimeConnection.ForTcp(connectionToken: SharedTcpConnectionToken),
22+
Connection = CreateSharedConnection(E2ETestContext.UsesInProcessTransport),
2323
}, persistent: true);
2424
}
2525

26+
internal static RuntimeConnection CreateSharedConnection(bool useInProcessTransport) =>
27+
useInProcessTransport
28+
? RuntimeConnection.ForInProcess()
29+
: RuntimeConnection.ForTcp(connectionToken: SharedTcpConnectionToken);
30+
2631
public async Task DisposeAsync()
2732
{
2833
await Ctx.DisposeAsync();
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
/*---------------------------------------------------------------------------------------------
2+
* Copyright (c) Microsoft Corporation. All rights reserved.
3+
*--------------------------------------------------------------------------------------------*/
4+
5+
using Xunit;
6+
7+
namespace GitHub.Copilot.Test.Unit;
8+
9+
public class E2ETestFixtureTests
10+
{
11+
[Fact]
12+
public void Shared_Client_Uses_InProcess_Connection_For_InProcess_Tests()
13+
{
14+
var connection = E2ETestFixture.CreateSharedConnection(useInProcessTransport: true);
15+
16+
Assert.IsType<InProcessRuntimeConnection>(connection);
17+
}
18+
19+
[Fact]
20+
public void Shared_Client_Preserves_Tcp_Connection_For_OutOfProcess_Tests()
21+
{
22+
var connection = Assert.IsType<TcpRuntimeConnection>(
23+
E2ETestFixture.CreateSharedConnection(useInProcessTransport: false));
24+
25+
Assert.Equal(E2ETestFixture.SharedTcpConnectionToken, connection.ConnectionToken);
26+
}
27+
}

0 commit comments

Comments
 (0)