Skip to content

Commit d210a20

Browse files
authored
Merge branch 'main' into jmoseley/java-open-canvases-snapshot
2 parents 6a39b6f + 543ecfc commit d210a20

746 files changed

Lines changed: 10748 additions & 9568 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

dotnet/src/Generated/Rpc.cs

Lines changed: 473 additions & 346 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

dotnet/src/Generated/SessionEvents.cs

Lines changed: 33 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

dotnet/src/Session.cs

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -132,7 +132,7 @@ public SessionCapabilities Capabilities
132132
/// </summary>
133133
/// <remarks>
134134
/// Populated from the most recent <c>session.resume</c> response and live
135-
/// <c>session.canvas.opened</c> events.
135+
/// <c>session.canvas.opened</c> and <c>session.canvas.closed</c> events.
136136
/// </remarks>
137137
[Experimental(Diagnostics.Experimental)]
138138
public IReadOnlyList<OpenCanvasInstance> OpenCanvases => _openCanvases;
@@ -892,6 +892,19 @@ internal void SetOpenCanvases(IList<OpenCanvasInstance>? canvases)
892892

893893
private void UpdateOpenCanvasesFromEvent(SessionEvent sessionEvent)
894894
{
895+
if (sessionEvent is SessionCanvasClosedEvent closedEvent)
896+
{
897+
var closedInstanceId = closedEvent.Data.InstanceId;
898+
if (string.IsNullOrEmpty(closedInstanceId))
899+
{
900+
_logger.LogWarning("failed to deserialize session.canvas.closed payload");
901+
return;
902+
}
903+
904+
RemoveOpenCanvas(closedInstanceId);
905+
return;
906+
}
907+
895908
if (sessionEvent is not SessionCanvasOpenedEvent canvasEvent)
896909
return;
897910

@@ -931,6 +944,12 @@ private void UpsertOpenCanvas(OpenCanvasInstance canvas)
931944
_openCanvases = canvases.AsReadOnly();
932945
}
933946

947+
private void RemoveOpenCanvas(string instanceId)
948+
{
949+
var canvases = _openCanvases.Where(open => open.InstanceId != instanceId).ToList();
950+
_openCanvases = canvases.AsReadOnly();
951+
}
952+
934953
internal void SetCanvasHandler(ICanvasHandler? handler)
935954
{
936955
ClientSessionApis.Canvas = handler is null ? null : new CanvasHandlerAdapter(handler);
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
/*---------------------------------------------------------------------------------------------
2+
* Copyright (c) Microsoft Corporation. All rights reserved.
3+
*--------------------------------------------------------------------------------------------*/
4+
5+
using GitHub.Copilot.Rpc;
6+
using Xunit;
7+
using Xunit.Abstractions;
8+
9+
namespace GitHub.Copilot.Test.E2E;
10+
11+
/// <summary>
12+
/// E2E coverage for the public session-scoped MCP lifecycle RPC methods:
13+
/// listTools, isServerRunning, and stopServer.
14+
/// </summary>
15+
public class RpcMcpLifecycleE2ETests(E2ETestFixture fixture, ITestOutputHelper output)
16+
: E2ETestBase(fixture, "rpc_mcp_lifecycle", output)
17+
{
18+
[Fact]
19+
public async Task Should_List_Tools_And_Report_Running_Status_For_Connected_Server()
20+
{
21+
const string serverName = "rpc-lifecycle-list-server";
22+
await using var session = await CreateSessionAsync(new SessionConfig
23+
{
24+
McpServers = CreateTestMcpServers(serverName),
25+
});
26+
await WaitForMcpServerStatusAsync(session, serverName, McpServerStatus.Connected);
27+
28+
var tools = await session.Rpc.Mcp.ListToolsAsync(serverName);
29+
Assert.NotNull(tools.Tools);
30+
Assert.NotEmpty(tools.Tools);
31+
Assert.All(tools.Tools, tool => Assert.False(string.IsNullOrWhiteSpace(tool.Name)));
32+
33+
// A connected server reports running; a name that was never configured does not.
34+
Assert.True((await session.Rpc.Mcp.IsServerRunningAsync(serverName)).Running);
35+
Assert.False((await session.Rpc.Mcp.IsServerRunningAsync($"missing-{Guid.NewGuid():N}")).Running);
36+
}
37+
38+
[Fact]
39+
public async Task Should_Throw_When_Listing_Tools_For_Unconnected_Server()
40+
{
41+
const string serverName = "rpc-lifecycle-unconnected-host";
42+
await using var session = await CreateSessionAsync(new SessionConfig
43+
{
44+
McpServers = CreateTestMcpServers(serverName),
45+
});
46+
await WaitForMcpServerStatusAsync(session, serverName, McpServerStatus.Connected);
47+
48+
// The MCP host is initialized (a server is connected), but the requested server is not,
49+
// so listTools reaches the runtime and fails with a domain error rather than "Unhandled method".
50+
var ex = await Assert.ThrowsAnyAsync<Exception>(
51+
() => session.Rpc.Mcp.ListToolsAsync($"missing-{Guid.NewGuid():N}"));
52+
var message = ex.ToString();
53+
AssertNotUnhandledMethod(message);
54+
Assert.Contains("not connected", message, StringComparison.OrdinalIgnoreCase);
55+
}
56+
57+
[Fact]
58+
public async Task Should_Stop_Running_Mcp_Server()
59+
{
60+
const string serverName = "rpc-lifecycle-stop-server";
61+
await using var session = await CreateSessionAsync(new SessionConfig
62+
{
63+
McpServers = CreateTestMcpServers(serverName),
64+
});
65+
await WaitForMcpServerStatusAsync(session, serverName, McpServerStatus.Connected);
66+
Assert.True((await session.Rpc.Mcp.IsServerRunningAsync(serverName)).Running);
67+
68+
await session.Rpc.Mcp.StopServerAsync(serverName);
69+
70+
await WaitForMcpRunningAsync(session, serverName, expectedRunning: false);
71+
}
72+
73+
private static Task WaitForMcpRunningAsync(CopilotSession session, string serverName, bool expectedRunning) =>
74+
Harness.TestHelper.WaitForConditionAsync(
75+
async () => (await session.Rpc.Mcp.IsServerRunningAsync(serverName)).Running == expectedRunning,
76+
timeout: TimeSpan.FromSeconds(60),
77+
pollInterval: TimeSpan.FromMilliseconds(200),
78+
timeoutMessage: $"{serverName} running={expectedRunning}");
79+
80+
private static void AssertNotUnhandledMethod(string message)
81+
{
82+
Assert.DoesNotContain("Unhandled method", message, StringComparison.OrdinalIgnoreCase);
83+
}
84+
}
Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
1+
/*---------------------------------------------------------------------------------------------
2+
* Copyright (c) Microsoft Corporation. All rights reserved.
3+
*--------------------------------------------------------------------------------------------*/
4+
5+
using GitHub.Copilot;
6+
using GitHub.Copilot.Rpc;
7+
using Xunit;
8+
using Xunit.Abstractions;
9+
10+
namespace GitHub.Copilot.Test.E2E;
11+
12+
/// <summary>
13+
/// E2E coverage for the remaining miscellaneous server-scoped RPC methods that were previously
14+
/// untested: user.settings.reload, agentRegistry.spawn, runtime.shutdown, sessions.open, and the
15+
/// session-scoped session.extensions.sendAttachmentsToMessage.
16+
///
17+
/// Several of these are intentionally exercised at the wiring/guard boundary because the meaningful
18+
/// "happy path" requires capabilities the SDK host does not expose (a registered agent-registry
19+
/// delegate, an extension-owned connection). For those we assert the method reaches the runtime and
20+
/// enforces its documented guard rather than failing as an unknown method.
21+
/// </summary>
22+
public class RpcServerMiscE2ETests(E2ETestFixture fixture, ITestOutputHelper output)
23+
: E2ETestBase(fixture, "rpc_server_misc", output)
24+
{
25+
[Fact]
26+
public async Task Should_Reload_User_Settings()
27+
{
28+
await Client.StartAsync();
29+
30+
// Drops the runtime's in-memory user-settings cache so the next read observes disk. Returns
31+
// no value; success is simply completing without error.
32+
await Client.Rpc.User.Settings.ReloadAsync();
33+
}
34+
35+
[Fact]
36+
public async Task Should_Report_Agent_Registry_Spawn_Gate_Closed()
37+
{
38+
await Client.StartAsync();
39+
40+
// agentRegistry.spawn is gated off on the SDK host (no spawn delegate is registered). The
41+
// call must still reach the runtime and be rejected by that gate, proving the method is
42+
// wired rather than an unknown method.
43+
var ex = await Assert.ThrowsAnyAsync<Exception>(
44+
() => Client.Rpc.AgentRegistry.SpawnAsync(cwd: Path.GetTempPath()));
45+
46+
var message = ex.ToString();
47+
Assert.DoesNotContain("Unhandled method", message, StringComparison.OrdinalIgnoreCase);
48+
Assert.Contains("agentRegistry.spawn", message, StringComparison.OrdinalIgnoreCase);
49+
Assert.True(
50+
message.Contains("not enabled", StringComparison.OrdinalIgnoreCase)
51+
|| message.Contains("no delegate", StringComparison.OrdinalIgnoreCase),
52+
message);
53+
}
54+
55+
[Fact]
56+
public async Task Should_Shut_Down_Owned_Runtime()
57+
{
58+
// runtime.shutdown must only ever target a dedicated, SDK-owned runtime — never the shared
59+
// fixture client whose process backs every other test.
60+
var client = Ctx.CreateClient();
61+
await client.StartAsync();
62+
63+
try
64+
{
65+
// Confirm the runtime is live before shutting it down.
66+
await client.Rpc.User.Settings.ReloadAsync();
67+
68+
await client.Rpc.Runtime.ShutdownAsync();
69+
70+
// After a graceful shutdown the runtime tears down and stops serving. Poll until a
71+
// follow-up RPC fails rather than asserting on a single immediate call, which could race
72+
// shutdown propagation across the connection.
73+
await Harness.TestHelper.WaitForConditionAsync(
74+
async () =>
75+
{
76+
try { await client.Rpc.User.Settings.ReloadAsync(); return false; }
77+
catch { return true; }
78+
},
79+
timeout: TimeSpan.FromSeconds(15),
80+
pollInterval: TimeSpan.FromMilliseconds(100),
81+
timeoutMessage: "Runtime kept serving RPCs after a graceful shutdown.");
82+
}
83+
finally
84+
{
85+
try { await client.DisposeAsync(); }
86+
catch { /* process is already gone after shutdown */ }
87+
}
88+
}
89+
90+
[Fact]
91+
public async Task Should_Report_Not_Found_When_Opening_Session_Without_Context()
92+
{
93+
// sessions.open with no parameters asks the runtime to resume the last session for the
94+
// (unspecified) context. A fresh runtime with its own empty COPILOT_HOME has no such
95+
// session, so the documented "not_found" outcome is returned deterministically.
96+
var (client, home) = await CreateIsolatedClientAsync();
97+
try
98+
{
99+
var result = await client.Rpc.Sessions.OpenAsync();
100+
101+
Assert.Equal(SessionsOpenStatus.NotFound, result.Status);
102+
Assert.Null(result.SessionId);
103+
}
104+
finally
105+
{
106+
try { await client.DisposeAsync(); } catch { /* best-effort */ }
107+
TryDeleteDirectory(home);
108+
}
109+
}
110+
111+
[Fact]
112+
public async Task Should_Reject_Send_Attachments_From_Non_Extension_Connection()
113+
{
114+
// session.extensions.sendAttachmentsToMessage may only be called over an extension-owned
115+
// connection. A normal SDK session connection has no extensionId, so the runtime rejects the
116+
// push — confirming the method is wired and enforces its ownership guard.
117+
await using var session = await CreateSessionAsync();
118+
119+
var ex = await Assert.ThrowsAnyAsync<Exception>(
120+
() => session.Rpc.Extensions.SendAttachmentsToMessageAsync(new List<PushAttachment>()));
121+
var message = ex.ToString();
122+
Assert.DoesNotContain("Unhandled method", message, StringComparison.OrdinalIgnoreCase);
123+
Assert.Contains("extension", message, StringComparison.OrdinalIgnoreCase);
124+
}
125+
126+
/// <summary>
127+
/// Creates a started client backed by a throwaway COPILOT_HOME so its session store is empty and
128+
/// independent of every other test and of the shared fixture client.
129+
/// </summary>
130+
private async Task<(CopilotClient Client, string Home)> CreateIsolatedClientAsync()
131+
{
132+
var home = Path.Combine(Path.GetTempPath(), "copilot-e2e-misc-home-" + Guid.NewGuid().ToString("N"));
133+
Directory.CreateDirectory(home);
134+
135+
var env = Ctx.GetEnvironment();
136+
env["COPILOT_HOME"] = home;
137+
env["GH_CONFIG_DIR"] = home;
138+
env["XDG_CONFIG_HOME"] = home;
139+
env["XDG_STATE_HOME"] = home;
140+
141+
var client = Ctx.CreateClient(options: new CopilotClientOptions { Environment = env });
142+
await client.StartAsync();
143+
return (client, home);
144+
}
145+
146+
private static void TryDeleteDirectory(string path)
147+
{
148+
try
149+
{
150+
if (Directory.Exists(path))
151+
{
152+
Directory.Delete(path, recursive: true);
153+
}
154+
}
155+
catch
156+
{
157+
// Temp directories are reclaimed by the OS; ignore transient locks on cleanup.
158+
}
159+
}
160+
}

0 commit comments

Comments
 (0)