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
84 changes: 84 additions & 0 deletions .github/workflows/java-sdk-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,90 @@ jobs:
- name: Verify CLI works
run: node ../nodejs/node_modules/@github/copilot/npm-loader.js --version

- name: Prime Spotless Eclipse formatter cache
if: matrix.test-jdk == '25'
run: |
set -euo pipefail

p2_data="$HOME/.m2/repository/dev/equo/p2-data"
bundle_dir="$p2_data/bundle-pool/https-download.eclipse.org-eclipse-updates-4.33-R-4.33-202409030240-"
query_dir="$p2_data/queries/1.8.1-1468712279"
jna_jar="$bundle_dir/com.sun.jna_5.14.0.v20231211-1200.jar"

mkdir -p "$bundle_dir" "$query_dir"
printf '1' > "$p2_data/queries/version"
printf 'https://download.eclipse.org/eclipse/updates/4.33/R-4.33-202409030240/' > "$bundle_dir/.url"

mvn -q dependency:get -Dartifact=net.java.dev.jna:jna:5.14.0 -Dtransitive=false
cp "$HOME/.m2/repository/net/java/dev/jna/jna/5.14.0/jna-5.14.0.jar" "$jna_jar"

helper_dir="$(mktemp -d)"
trap 'rm -rf "$helper_dir"' EXIT
mkdir -p "$helper_dir/dev/equo/solstice/p2"
cat > "$helper_dir/dev/equo/solstice/p2/P2QueryResult.java" <<'JAVA'
package dev.equo.solstice.p2;

import java.io.File;
import java.io.FileOutputStream;
import java.io.ObjectOutputStream;
import java.io.Serializable;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;

public class P2QueryResult implements Serializable {
private static final long serialVersionUID = 1L;

private final List<String> mavenCoordinates;
private final List<File> downloadedP2Jars;

private P2QueryResult(List<String> mavenCoordinates, List<File> downloadedP2Jars) {
this.mavenCoordinates = mavenCoordinates;
this.downloadedP2Jars = downloadedP2Jars;
}

public static void main(String[] args) throws Exception {
var coordinates = new ArrayList<String>();
Collections.addAll(
coordinates,
"net.java.dev.jna:jna-platform:5.14.0",
"org.apache.felix:org.apache.felix.scr:2.2.12",
"org.eclipse.platform:org.eclipse.core.commands:3.12.200",
"org.eclipse.platform:org.eclipse.core.contenttype:3.9.500",
"org.eclipse.platform:org.eclipse.core.expressions:3.9.400",
"org.eclipse.platform:org.eclipse.core.filesystem:1.11.0",
"org.eclipse.platform:org.eclipse.core.jobs:3.15.400",
"org.eclipse.platform:org.eclipse.core.resources:3.21.0",
"org.eclipse.platform:org.eclipse.core.runtime:3.31.100",
"org.eclipse.platform:org.eclipse.equinox.app:1.7.200",
"org.eclipse.platform:org.eclipse.equinox.common:3.19.100",
"org.eclipse.platform:org.eclipse.equinox.event:1.7.100",
"org.eclipse.platform:org.eclipse.equinox.preferences:3.11.100",
"org.eclipse.platform:org.eclipse.equinox.registry:3.12.100",
"org.eclipse.platform:org.eclipse.equinox.supplement:1.11.0",
"org.eclipse.jdt:org.eclipse.jdt.core:3.39.0",
"org.eclipse.jdt:ecj:3.39.0",
"org.eclipse.platform:org.eclipse.osgi:3.21.0",
"org.eclipse.platform:org.eclipse.text:3.14.100",
"org.osgi:org.osgi.service.cm:1.6.1",
"org.osgi:org.osgi.service.component:1.5.1",
"org.osgi:org.osgi.service.event:1.4.1",
"org.osgi:org.osgi.service.metatype:1.4.1",
"org.osgi:org.osgi.service.prefs:1.1.2",
"org.osgi:org.osgi.util.function:1.2.0",
"org.osgi:org.osgi.util.promise:1.3.0");

var result = new P2QueryResult(coordinates, List.of(new File(args[1])));
try (var out = new ObjectOutputStream(new FileOutputStream(args[0]))) {
out.writeObject(result);
}
}
}
JAVA

javac "$helper_dir/dev/equo/solstice/p2/P2QueryResult.java"
java -cp "$helper_dir" dev.equo.solstice.p2.P2QueryResult "$query_dir/content" "$jna_jar"

- name: Run spotless check
if: matrix.test-jdk == '25'
run: |
Expand Down
17 changes: 16 additions & 1 deletion dotnet/src/Client.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1802,7 +1802,17 @@ private async Task VerifyProtocolVersionAsync(Connection connection, Cancellatio
_ => null,
};
var connectResponse = await InvokeRpcAsync<ConnectResult>(
connection.Rpc, "connect", [new ConnectRequest { Token = token }], connection.StderrBuffer, cancellationToken);
connection.Rpc,
"connect",
[new ConnectHandshakeRequest(
token,
// Opt in to GitHub telemetry forwarding at the connection level when a
// handler is registered (mirrors the runtime, which reads this flag on the
// `connect` handshake so the first session's un-replayable `session.start`
// event is forwarded). Also sent on session.create/resume for older CLIs.
_options.OnGitHubTelemetry != null ? true : null)],
connection.StderrBuffer,
cancellationToken);
serverVersion = (int)connectResponse.ProtocolVersion;
}
catch (IOException ex) when (ex.InnerException is RemoteRpcException remoteEx && IsUnsupportedConnectMethod(remoteEx))
Expand Down Expand Up @@ -2639,6 +2649,10 @@ internal record GetSessionMetadataRequest(
internal record GetSessionMetadataResponse(
SessionMetadata? Session);

internal record ConnectHandshakeRequest(
string? Token,
[property: JsonPropertyName("enableGitHubTelemetryForwarding")] bool? EnableGitHubTelemetryForwarding = null);

internal record SetForegroundSessionRequest(
string SessionId);

Expand Down Expand Up @@ -2673,6 +2687,7 @@ internal record HooksInvokeResponse(
[JsonSerializable(typeof(ListSessionsResponse))]
[JsonSerializable(typeof(GetSessionMetadataRequest))]
[JsonSerializable(typeof(GetSessionMetadataResponse))]
[JsonSerializable(typeof(ConnectHandshakeRequest))]
[JsonSerializable(typeof(McpOAuthTokenStorageMode))]
[JsonSerializable(typeof(EmbeddingCacheStorageMode))]
[JsonSerializable(typeof(ModelCapabilitiesOverride))]
Expand Down
2 changes: 1 addition & 1 deletion dotnet/test/E2E/GitHubTelemetryForwardingE2ETests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ await TestHelper.WaitForConditionAsync(
timeoutMessage: "Timed out waiting for GitHub telemetry notification.");

Assert.True(notifications.TryPeek(out var notification));
Assert.NotEmpty(notification.SessionId);
Assert.False(string.IsNullOrEmpty(notification.SessionId));
Assert.NotNull(notification.Event);
Assert.NotEmpty(notification.Event.Kind);
Assert.IsType<bool>(notification.Restricted);
Expand Down
2 changes: 1 addition & 1 deletion dotnet/test/E2E/McpOAuthE2ETests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -172,7 +172,7 @@ public async Task Should_Cancel_Pending_MCP_OAuth_Request()
}
});

await WaitForMcpServerStatusAsync(session, serverName, McpServerStatus.Failed);
await WaitForMcpServerStatusAsync(session, serverName, McpServerStatus.NeedsAuth);

Assert.NotNull(observedRequest);
Assert.NotEmpty(observedRequest!.RequestId);
Expand Down
6 changes: 3 additions & 3 deletions dotnet/test/E2E/RpcSessionStateExtrasE2ETests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -64,19 +64,19 @@ public async Task Should_Get_And_Set_AllowAll_Permissions()
var initial = await session.Rpc.Permissions.GetAllowAllAsync();
Assert.False(initial.Enabled, "Allow-all should be disabled on a fresh session.");

var enable = await session.Rpc.Permissions.SetAllowAllAsync(true);
var enable = await session.Rpc.Permissions.SetAllowAllAsync(enabled: true);
Assert.True(enable.Success);
Assert.True(enable.Enabled);
Assert.True((await session.Rpc.Permissions.GetAllowAllAsync()).Enabled);

var disable = await session.Rpc.Permissions.SetAllowAllAsync(false);
var disable = await session.Rpc.Permissions.SetAllowAllAsync(enabled: false);
Assert.True(disable.Success);
Assert.False(disable.Enabled);
Assert.False((await session.Rpc.Permissions.GetAllowAllAsync()).Enabled);
}
finally
{
await session.Rpc.Permissions.SetAllowAllAsync(false);
await session.Rpc.Permissions.SetAllowAllAsync(enabled: false);
}
}

Expand Down
53 changes: 47 additions & 6 deletions dotnet/test/Unit/GitHubTelemetryTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,39 @@ public async Task ResumeSession_Opts_Into_Forwarding_When_Handler_Provided()
Assert.True(flag.GetBoolean());
}

[Fact]
public async Task Connect_Opts_Into_Forwarding_When_Handler_Provided()
{
await using var server = await FakeTelemetryServer.StartAsync();
await using var client = new CopilotClient(new CopilotClientOptions
{
Connection = RuntimeConnection.ForUri(server.Url),
OnGitHubTelemetry = _ => Task.CompletedTask,
});
await client.StartAsync();

var connectParams = server.LastConnectParams ?? throw new InvalidOperationException("connect was not captured.");
Assert.True(connectParams.TryGetProperty("enableGitHubTelemetryForwarding", out var flag));
Assert.True(flag.GetBoolean());
}

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

var connectParams = server.LastConnectParams ?? throw new InvalidOperationException("connect was not captured.");
var present = connectParams.TryGetProperty("enableGitHubTelemetryForwarding", out var flag);
Assert.True(
!present || flag.ValueKind == JsonValueKind.Null,
"connect request should omit enableGitHubTelemetryForwarding (or send null) when no handler is registered");
}

[Fact]
public async Task CreateSession_Does_Not_Opt_In_Without_Handler()
{
Expand Down Expand Up @@ -187,6 +220,8 @@ public string Url

public JsonElement? LastResumeParams { get; private set; }

public JsonElement? LastConnectParams { get; private set; }

public static Task<FakeTelemetryServer> StartAsync()
{
var listener = new TcpListener(IPAddress.Loopback, 0);
Expand Down Expand Up @@ -267,12 +302,7 @@ private async Task HandleRequestAsync(Stream stream, JsonElement request, Cancel

object? result = method switch
{
"connect" => new Dictionary<string, object?>
{
["ok"] = true,
["protocolVersion"] = 3,
["version"] = "test",
},
"connect" => CaptureConnect(request),
"session.create" => CaptureCreate(request),
"session.resume" => CaptureResume(request),
"session.send" => new Dictionary<string, object?> { ["messageId"] = "message-1" },
Expand All @@ -289,6 +319,17 @@ private async Task HandleRequestAsync(Stream stream, JsonElement request, Cancel
}, cancellationToken);
}

private Dictionary<string, object?> CaptureConnect(JsonElement request)
{
LastConnectParams = request.TryGetProperty("params", out var p) ? p.Clone() : null;
return new Dictionary<string, object?>
{
["ok"] = true,
["protocolVersion"] = 3,
["version"] = "test",
};
}

private Dictionary<string, object?> CaptureCreate(JsonElement request)
{
LastCreateParams = request.TryGetProperty("params", out var p) ? p.Clone() : null;
Expand Down
19 changes: 18 additions & 1 deletion go/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -1685,7 +1685,15 @@ func (c *Client) verifyProtocolVersion(ctx context.Context) error {
t := c.effectiveConnectionToken
tokenPtr = &t
}
connectResult, err := c.internalRPC.Connect(ctx, &rpc.ConnectRequest{Token: tokenPtr})
connectReq := &connectHandshakeRequest{Token: tokenPtr}
// Opt in to GitHub telemetry forwarding at the connection level when a handler is
// registered (mirrors the runtime, which reads this flag on the `connect` handshake
// so the first session's un-replayable `session.start` event is forwarded). Also
// sent on session.create/resume for older CLIs.
if c.options.OnGitHubTelemetry != nil {
connectReq.EnableGitHubTelemetryForwarding = Bool(true)
}
rawConnectResult, err := c.client.Request(ctx, "connect", connectReq)
if err != nil {
var rpcErr *jsonrpc2.Error
if errors.As(err, &rpcErr) && (rpcErr.Code == jsonrpc2.ErrMethodNotFound.Code || rpcErr.Message == "Unhandled method connect") {
Expand All @@ -1700,6 +1708,10 @@ func (c *Client) verifyProtocolVersion(ctx context.Context) error {
return err
}
} else {
var connectResult rpc.ConnectResult
if err := json.Unmarshal(rawConnectResult, &connectResult); err != nil {
return err
}
v := int(connectResult.ProtocolVersion)
serverVersion = &v
}
Expand All @@ -1716,6 +1728,11 @@ func (c *Client) verifyProtocolVersion(ctx context.Context) error {
return nil
}

type connectHandshakeRequest struct {
Token *string `json:"token,omitempty"`
EnableGitHubTelemetryForwarding *bool `json:"enableGitHubTelemetryForwarding,omitempty"`
}

// stderrBufferSize is the maximum number of bytes kept from the CLI process's
// stderr. Only the tail is retained so that memory stays bounded even when the
// process produces a large amount of diagnostic output.
Expand Down
54 changes: 52 additions & 2 deletions go/client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2487,6 +2487,52 @@ func assertForwardingFlagAbsent(t *testing.T, params json.RawMessage) {
}
}

func TestClient_ForwardsGitHubTelemetryForwardingOnConnect(t *testing.T) {
rpcClient, server, _ := newRuntimeShutdownRpcPair(t)
t.Cleanup(server.Stop)
client := &Client{
client: rpcClient,
RPC: rpc.NewServerRPC(rpcClient),
internalRPC: rpc.NewInternalServerRPC(rpcClient),
sessions: make(map[string]*Session),
options: ClientOptions{OnGitHubTelemetry: func(*rpc.GitHubTelemetryNotification) {}},
}

connectParams := make(chan json.RawMessage, 1)
server.SetRequestHandler("connect", func(params json.RawMessage) (json.RawMessage, *jsonrpc2.Error) {
connectParams <- append(json.RawMessage(nil), params...)
return []byte(`{"ok":true,"protocolVersion":3,"version":"test"}`), nil
})

if err := client.verifyProtocolVersion(t.Context()); err != nil {
t.Fatalf("verifyProtocolVersion failed: %v", err)
}
assertForwardingFlagTrue(t, <-connectParams)
}

func TestClient_OmitsGitHubTelemetryForwardingOnConnectWhenNoHandler(t *testing.T) {
rpcClient, server, _ := newRuntimeShutdownRpcPair(t)
t.Cleanup(server.Stop)
client := &Client{
client: rpcClient,
RPC: rpc.NewServerRPC(rpcClient),
internalRPC: rpc.NewInternalServerRPC(rpcClient),
sessions: make(map[string]*Session),
options: ClientOptions{},
}

connectParams := make(chan json.RawMessage, 1)
server.SetRequestHandler("connect", func(params json.RawMessage) (json.RawMessage, *jsonrpc2.Error) {
connectParams <- append(json.RawMessage(nil), params...)
return []byte(`{"ok":true,"protocolVersion":3,"version":"test"}`), nil
})

if err := client.verifyProtocolVersion(t.Context()); err != nil {
t.Fatalf("verifyProtocolVersion failed: %v", err)
}
assertForwardingFlagAbsent(t, <-connectParams)
}

func TestGitHubTelemetryNotificationRoutesToCallback(t *testing.T) {
// The runtime forwards telemetry via a JSON-RPC *notification* (no id).
// Drive a real Content-Length-framed notification through the transport and
Expand Down Expand Up @@ -2547,8 +2593,12 @@ func TestGitHubTelemetryNotificationRoutesToCallback(t *testing.T) {

select {
case n := <-received:
if n.SessionID != "sess-telemetry" {
t.Errorf("session id = %q, want sess-telemetry", n.SessionID)
sessionID := ""
if n.SessionID != nil {
sessionID = *n.SessionID
}
if sessionID != "sess-telemetry" {
t.Errorf("session id = %q, want sess-telemetry", sessionID)
}
if !n.Restricted {
t.Error("expected restricted to be true")
Expand Down
2 changes: 1 addition & 1 deletion go/internal/e2e/github_telemetry_e2e_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ func TestGitHubTelemetryE2E(t *testing.T) {
t.Cleanup(func() { session.Disconnect() })

notification := waitForGitHubTelemetryNotification(t, &mu, &notifications, 30*time.Second)
if notification.SessionID == "" {
if notification.SessionID == nil || *notification.SessionID == "" {
t.Fatal("Expected a non-empty SessionID")
}
if notification.Event.Kind == "" {
Expand Down
2 changes: 1 addition & 1 deletion go/internal/e2e/mcp_oauth_e2e_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -218,7 +218,7 @@ func TestMCPOAuthE2E(t *testing.T) {
}
t.Cleanup(func() { session.Disconnect() })

waitForMCPServerStatus(t, session, serverName, rpc.MCPServerStatusFailed)
waitForMCPServerStatus(t, session, serverName, rpc.MCPServerStatusNeedsAuth)
if observedRequest.ServerName != serverName {
t.Fatalf("Expected serverName %q, got %q", serverName, observedRequest.ServerName)
}
Expand Down
Loading
Loading