diff --git a/CHANGELOG.md b/CHANGELOG.md
index 0184a553db..54e0c94d66 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,6 +7,10 @@ See [GitHub Releases](https://github.com/github/copilot-sdk/releases) for the fu
## [Unreleased]
+### Feature: declare host identity with client info
+
+Client options now accept optional client info (editor name and version, extension name and version) across all six SDKs, exposed idiomatically per language (`clientInfo` in Node.js, `client_info` in Python and Rust, `ClientInfo` in Go and .NET, `setClientInfo` in Java). When set, the SDK forwards it on the `server.connect` handshake so the telemetry the runtime emits on the connection is attributed to the host editor and its Copilot extension instead of the runtime's own build. All fields are optional, and leaving client info unset keeps the runtime's default attribution. See [Client info](./docs/features/client-info.md).
+
### Feature: Node Agent Factories pagination and run notifications
The experimental Node.js Agent Factories convenience API now supports paginated run history. Existing `session.factory.listRuns()` calls still return the runs array, while calls with `afterSeq`, `beforeSeq`, or `limit` return the full page with cursor and truncation metadata.
diff --git a/docs/features/README.md b/docs/features/README.md
index f97140b784..6350958720 100644
--- a/docs/features/README.md
+++ b/docs/features/README.md
@@ -20,6 +20,7 @@ These guides cover the capabilities you can add to your Copilot SDK application.
| [Image Input](./image-input.md) | Send images to sessions as attachments |
| [Streaming Events](./streaming-events.md) | Subscribe to real-time session events (40+ event types) |
| [Usage and Billing](./usage-and-billing.md) | Read token counts, context-window utilization, AI credit cost, and account quota |
+| [Client info](./client-info.md) | Declare the host editor and extension so runtime telemetry is attributed to your surface |
| [Steering & Queueing](./steering-and-queueing.md) | Control message delivery—immediate steering vs. sequential queueing |
| [Context Clearing](./context-management.md) | Replace conversation context safely with terminal tools |
| [Session Persistence](./session-persistence.md) | Resume sessions across restarts, manage session storage |
diff --git a/docs/features/client-info.md b/docs/features/client-info.md
new file mode 100644
index 0000000000..48ada2d13a
--- /dev/null
+++ b/docs/features/client-info.md
@@ -0,0 +1,239 @@
+# Client info
+
+Client info identifies the editor or extension that hosts your Copilot SDK application. Set the optional `clientInfo` client option to attribute runtime telemetry for that connection to your host instead of the runtime's own build.
+
+## When to set client info
+
+Set client info when you embed the SDK in an editor, an extension, or another application with its own identity.
+
+Leave client info unset for scripts, one-off tools, and back-end jobs that do not represent a distinct host. The runtime then keeps its default attribution.
+
+Client info has four optional string fields. Set the fields you know and omit the rest. The SDK includes client info in the `server.connect` handshake only when at least one field has a non-empty value.
+
+| Field | Example | Meaning |
+|---|---|---|
+| `editorName` | `"vscode"` | Name of the host editor |
+| `editorVersion` | `"1.124.2"` | Version of the host editor |
+| `extensionName` | `"copilot-chat"` | Name of the Copilot extension within the host |
+| `extensionVersion` | `"0.54.0"` | Version of the Copilot extension within the host |
+
+The SDK sends client info once when it establishes the connection. The identity applies for the lifetime of that connection.
+
+## Configure client info
+
+Pass client info when you create the client:
+
+
+TypeScript
+
+
+```typescript
+import { CopilotClient } from "@github/copilot-sdk";
+
+async function main() {
+ const client = new CopilotClient({
+ clientInfo: {
+ editorName: "JetBrains-IU",
+ editorVersion: "2026.1",
+ extensionName: "copilot-intellij",
+ extensionVersion: "1.5.0",
+ },
+ });
+
+ await client.start();
+}
+
+main();
+```
+
+
+```typescript
+import { CopilotClient } from "@github/copilot-sdk";
+
+const client = new CopilotClient({
+ clientInfo: {
+ editorName: "JetBrains-IU",
+ editorVersion: "2026.1",
+ extensionName: "copilot-intellij",
+ extensionVersion: "1.5.0",
+ },
+});
+
+await client.start();
+```
+
+
+
+
+Python
+
+
+```python
+from copilot import CopilotClient
+
+client = CopilotClient(
+ client_info={
+ "editor_name": "JetBrains-IU",
+ "editor_version": "2026.1",
+ "extension_name": "copilot-intellij",
+ "extension_version": "1.5.0",
+ },
+)
+await client.start()
+```
+
+
+
+
+Go
+
+
+```go
+package main
+
+import (
+ "context"
+
+ copilot "github.com/github/copilot-sdk/go"
+)
+
+func main() {
+ ctx := context.Background()
+ client := copilot.NewClient(&copilot.ClientOptions{
+ ClientInfo: &copilot.ClientInfo{
+ EditorName: "JetBrains-IU",
+ EditorVersion: "2026.1",
+ ExtensionName: "copilot-intellij",
+ ExtensionVersion: "1.5.0",
+ },
+ })
+ if err := client.Start(ctx); err != nil {
+ return
+ }
+}
+```
+
+
+```go
+client := copilot.NewClient(&copilot.ClientOptions{
+ ClientInfo: &copilot.ClientInfo{
+ EditorName: "JetBrains-IU",
+ EditorVersion: "2026.1",
+ ExtensionName: "copilot-intellij",
+ ExtensionVersion: "1.5.0",
+ },
+})
+if err := client.Start(ctx); err != nil {
+ return err
+}
+```
+
+
+
+
+.NET
+
+```csharp
+using GitHub.Copilot;
+
+await using var client = new CopilotClient(new CopilotClientOptions
+{
+ ClientInfo = new CopilotClientInfo
+ {
+ EditorName = "JetBrains-IU",
+ EditorVersion = "2026.1",
+ ExtensionName = "copilot-intellij",
+ ExtensionVersion = "1.5.0",
+ },
+});
+
+await client.StartAsync();
+```
+
+
+
+
+Java
+
+
+```java
+import com.github.copilot.CopilotClient;
+import com.github.copilot.rpc.ClientInfo;
+import com.github.copilot.rpc.CopilotClientOptions;
+
+public class ClientInfoExample {
+ public static void main(String[] args) throws Exception {
+ var options = new CopilotClientOptions()
+ .setClientInfo(new ClientInfo()
+ .setEditorName("JetBrains-IU")
+ .setEditorVersion("2026.1")
+ .setExtensionName("copilot-intellij")
+ .setExtensionVersion("1.5.0"));
+
+ var client = new CopilotClient(options);
+ client.start().get();
+ }
+}
+```
+
+
+```java
+var options = new CopilotClientOptions()
+ .setClientInfo(new ClientInfo()
+ .setEditorName("JetBrains-IU")
+ .setEditorVersion("2026.1")
+ .setExtensionName("copilot-intellij")
+ .setExtensionVersion("1.5.0"));
+
+var client = new CopilotClient(options);
+client.start().get();
+```
+
+
+
+
+Rust
+
+
+```rust
+use github_copilot_sdk::{Client, ClientInfo, ClientOptions};
+
+#[tokio::main]
+async fn main() -> Result<(), Box> {
+ let _client = Client::start(
+ ClientOptions::new().with_client_info(
+ ClientInfo::new()
+ .with_editor_name("JetBrains-IU")
+ .with_editor_version("2026.1")
+ .with_extension_name("copilot-intellij")
+ .with_extension_version("1.5.0"),
+ ),
+ )
+ .await?;
+ Ok(())
+}
+```
+
+
+```rust
+use github_copilot_sdk::{Client, ClientInfo, ClientOptions};
+
+let client = Client::start(
+ ClientOptions::new().with_client_info(
+ ClientInfo::new()
+ .with_editor_name("JetBrains-IU")
+ .with_editor_version("2026.1")
+ .with_extension_name("copilot-intellij")
+ .with_extension_version("1.5.0"),
+ ),
+)
+.await?;
+```
+
+
+
+## Notes
+
+* Client info is advisory. The runtime can ignore values that do not match the expected format, such as an invalid version string.
+* Setting client info changes how the runtime attributes its telemetry. It does not change what the runtime records.
+* If every field is unset or empty, the SDK omits client info from the handshake and the runtime keeps its default attribution.
diff --git a/dotnet/src/Client.cs b/dotnet/src/Client.cs
index 141473f535..a23032a905 100644
--- a/dotnet/src/Client.cs
+++ b/dotnet/src/Client.cs
@@ -2182,7 +2182,11 @@ [new ConnectHandshakeRequest(
// 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)],
+ _options.OnGitHubTelemetry != null ? true : null,
+ // Declare the integrating host's identity so the runtime attributes the
+ // telemetry it emits on this connection to a consistent surface instead
+ // of its own build. Null when the app didn't supply it.
+ ConnectHandshakeClientInfo.From(_options.ClientInfo))],
connection.StderrBuffer,
cancellationToken);
serverVersion = (int)connectResponse.ProtocolVersion;
@@ -3187,7 +3191,42 @@ internal record GetSessionMetadataResponse(
internal record ConnectHandshakeRequest(
string? Token,
- [property: JsonPropertyName("enableGitHubTelemetryForwarding")] bool? EnableGitHubTelemetryForwarding = null);
+ [property: JsonPropertyName("enableGitHubTelemetryForwarding")] bool? EnableGitHubTelemetryForwarding = null,
+ [property: JsonPropertyName("clientInfo")] ConnectHandshakeClientInfo? ClientInfo = null);
+
+ internal record ConnectHandshakeClientInfo(
+ [property: JsonPropertyName("editorName")] string? EditorName = null,
+ [property: JsonPropertyName("editorVersion")] string? EditorVersion = null,
+ [property: JsonPropertyName("extensionName")] string? ExtensionName = null,
+ [property: JsonPropertyName("extensionVersion")] string? ExtensionVersion = null)
+ {
+ ///
+ /// Maps the public onto the connect wire
+ /// shape, dropping empty fields. Returns when no
+ /// identity was supplied so the handshake omits clientInfo and the
+ /// runtime keeps its default attribution.
+ ///
+ public static ConnectHandshakeClientInfo? From(CopilotClientInfo? info)
+ {
+ if (info is null)
+ {
+ return null;
+ }
+
+ var editorName = NullIfEmpty(info.EditorName);
+ var editorVersion = NullIfEmpty(info.EditorVersion);
+ var extensionName = NullIfEmpty(info.ExtensionName);
+ var extensionVersion = NullIfEmpty(info.ExtensionVersion);
+ if (editorName is null && editorVersion is null && extensionName is null && extensionVersion is null)
+ {
+ return null;
+ }
+
+ return new ConnectHandshakeClientInfo(editorName, editorVersion, extensionName, extensionVersion);
+ }
+
+ private static string? NullIfEmpty(string? value) => string.IsNullOrEmpty(value) ? null : value;
+ }
internal record BuiltinPluginDirectoriesRequest(
string[] Paths);
@@ -3227,6 +3266,7 @@ internal record HooksInvokeResponse(
[JsonSerializable(typeof(GetSessionMetadataRequest))]
[JsonSerializable(typeof(GetSessionMetadataResponse))]
[JsonSerializable(typeof(ConnectHandshakeRequest))]
+ [JsonSerializable(typeof(ConnectHandshakeClientInfo))]
[JsonSerializable(typeof(BuiltinPluginDirectoriesRequest))]
[JsonSerializable(typeof(McpOAuthTokenStorageMode))]
[JsonSerializable(typeof(EmbeddingCacheStorageMode))]
diff --git a/dotnet/src/Types.cs b/dotnet/src/Types.cs
index 8eb5e9bb2c..ec58000e97 100644
--- a/dotnet/src/Types.cs
+++ b/dotnet/src/Types.cs
@@ -322,6 +322,7 @@ private CopilotClientOptions(CopilotClientOptions? other)
OnGitHubTelemetry = other.OnGitHubTelemetry;
SessionIdleTimeoutSeconds = other.SessionIdleTimeoutSeconds;
EnableRemoteSessions = other.EnableRemoteSessions;
+ ClientInfo = other.ClientInfo;
Mode = other.Mode;
}
@@ -465,6 +466,16 @@ private CopilotClientOptions(CopilotClientOptions? other)
///
public bool EnableRemoteSessions { get; set; }
+ ///
+ /// Declares the integrating host's identity, forwarded to the runtime on the
+ /// server.connect handshake. Declaring it lets the telemetry the
+ /// runtime emits on this connection be attributed to a consistent surface
+ /// (the host editor and its Copilot extension) instead of the runtime's own
+ /// build. All fields are optional; leave it to keep
+ /// the runtime's default attribution.
+ ///
+ public CopilotClientInfo? ClientInfo { get; set; }
+
///
/// Creates a shallow clone of this instance.
///
@@ -531,6 +542,38 @@ public sealed class TelemetryConfig
public bool? CaptureContent { get; set; }
}
+///
+/// Identifies the integrating host on the server.connect handshake.
+///
+///
+/// Declaring it lets the telemetry the runtime emits on the connection be
+/// attributed to a single, consistent surface instead of the runtime's own
+/// build. All properties are optional; an unset property is omitted from the
+/// handshake.
+///
+public sealed class CopilotClientInfo
+{
+ ///
+ /// Name of the host editor, e.g. "vscode".
+ ///
+ public string? EditorName { get; set; }
+
+ ///
+ /// Version of the host editor, e.g. "1.124.2".
+ ///
+ public string? EditorVersion { get; set; }
+
+ ///
+ /// Name of the Copilot extension within the host, e.g. "copilot-chat".
+ ///
+ public string? ExtensionName { get; set; }
+
+ ///
+ /// Version of the Copilot extension within the host, e.g. "0.54.0".
+ ///
+ public string? ExtensionVersion { get; set; }
+}
+
///
/// Configuration for a custom session filesystem provider.
///
diff --git a/dotnet/test/Unit/CloneTests.cs b/dotnet/test/Unit/CloneTests.cs
index 2948ac2129..2d25d4761f 100644
--- a/dotnet/test/Unit/CloneTests.cs
+++ b/dotnet/test/Unit/CloneTests.cs
@@ -23,6 +23,13 @@ public void CopilotClientOptions_Clone_CopiesAllProperties()
BuiltinPluginDirectories = ["/plugins/core", "/plugins/github"],
EnableRemoteSessions = true,
SessionIdleTimeoutSeconds = 600,
+ ClientInfo = new CopilotClientInfo
+ {
+ EditorName = "example-editor",
+ EditorVersion = "1.0.0",
+ ExtensionName = "example-extension",
+ ExtensionVersion = "2.0.0",
+ },
};
var clone = original.Clone();
@@ -38,6 +45,7 @@ public void CopilotClientOptions_Clone_CopiesAllProperties()
Assert.NotSame(original.BuiltinPluginDirectories, clone.BuiltinPluginDirectories);
Assert.Equal(original.EnableRemoteSessions, clone.EnableRemoteSessions);
Assert.Equal(original.SessionIdleTimeoutSeconds, clone.SessionIdleTimeoutSeconds);
+ Assert.Same(original.ClientInfo, clone.ClientInfo);
}
[Fact]
diff --git a/dotnet/test/Unit/GitHubTelemetryTests.cs b/dotnet/test/Unit/GitHubTelemetryTests.cs
index a4a241e38d..708e599d9f 100644
--- a/dotnet/test/Unit/GitHubTelemetryTests.cs
+++ b/dotnet/test/Unit/GitHubTelemetryTests.cs
@@ -140,6 +140,89 @@ public async Task Connect_Does_Not_Opt_In_Without_Handler()
"connect request should omit enableGitHubTelemetryForwarding (or send null) when no handler is registered");
}
+ [Fact]
+ public async Task Connect_Forwards_Declared_ClientInfo()
+ {
+ await using var server = await FakeTelemetryServer.StartAsync();
+ await using var client = new CopilotClient(new CopilotClientOptions
+ {
+ Connection = RuntimeConnection.ForUri(server.Url),
+ ClientInfo = new CopilotClientInfo
+ {
+ EditorName = "JetBrains-IU",
+ EditorVersion = "2026.1",
+ ExtensionName = "copilot-intellij",
+ ExtensionVersion = "1.5.0",
+ },
+ });
+ await client.StartAsync();
+
+ var connectParams = server.LastConnectParams ?? throw new InvalidOperationException("connect was not captured.");
+ Assert.True(connectParams.TryGetProperty("clientInfo", out var clientInfo));
+ Assert.Equal("JetBrains-IU", clientInfo.GetProperty("editorName").GetString());
+ Assert.Equal("2026.1", clientInfo.GetProperty("editorVersion").GetString());
+ Assert.Equal("copilot-intellij", clientInfo.GetProperty("extensionName").GetString());
+ Assert.Equal("1.5.0", clientInfo.GetProperty("extensionVersion").GetString());
+ }
+
+ [Fact]
+ public async Task Connect_Omits_ClientInfo_When_Unset()
+ {
+ 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.");
+ Assert.False(
+ connectParams.TryGetProperty("clientInfo", out _),
+ "connect request should omit clientInfo when none was declared");
+ }
+
+ [Fact]
+ public async Task Connect_Omits_Empty_ClientInfo_Fields()
+ {
+ await using var server = await FakeTelemetryServer.StartAsync();
+ await using var client = new CopilotClient(new CopilotClientOptions
+ {
+ Connection = RuntimeConnection.ForUri(server.Url),
+ ClientInfo = new CopilotClientInfo { EditorName = "example-editor", EditorVersion = "" },
+ });
+ await client.StartAsync();
+
+ var connectParams = server.LastConnectParams ?? throw new InvalidOperationException("connect was not captured.");
+ Assert.True(connectParams.TryGetProperty("clientInfo", out var clientInfo));
+ Assert.Equal("example-editor", clientInfo.GetProperty("editorName").GetString());
+ Assert.False(clientInfo.TryGetProperty("editorVersion", out _));
+ Assert.False(clientInfo.TryGetProperty("extensionName", out _));
+ Assert.False(clientInfo.TryGetProperty("extensionVersion", out _));
+ }
+
+ [Fact]
+ public async Task Connect_Omits_All_Empty_ClientInfo()
+ {
+ await using var server = await FakeTelemetryServer.StartAsync();
+ await using var client = new CopilotClient(new CopilotClientOptions
+ {
+ Connection = RuntimeConnection.ForUri(server.Url),
+ ClientInfo = new CopilotClientInfo
+ {
+ EditorName = "",
+ EditorVersion = "",
+ ExtensionName = "",
+ ExtensionVersion = "",
+ },
+ });
+ await client.StartAsync();
+
+ var connectParams = server.LastConnectParams ?? throw new InvalidOperationException("connect was not captured.");
+ Assert.False(
+ connectParams.TryGetProperty("clientInfo", out _),
+ "connect request should omit an all-empty clientInfo");
+ }
+
[Fact]
public async Task CreateSession_Does_Not_Opt_In_Without_Handler()
{
diff --git a/go/client.go b/go/client.go
index 8f6b41a983..735e8ae414 100644
--- a/go/client.go
+++ b/go/client.go
@@ -1951,6 +1951,10 @@ func (c *Client) verifyProtocolVersion(ctx context.Context) error {
if c.options.OnGitHubTelemetry != nil {
connectReq.EnableGitHubTelemetryForwarding = Bool(true)
}
+ // Declare the integrating host's identity so the runtime attributes the
+ // telemetry it emits on this connection to a consistent surface instead of
+ // its own build. Nil when the app didn't supply it.
+ connectReq.ClientInfo = c.options.ClientInfo.toWire()
rawConnectResult, err := c.client.Request(ctx, "connect", connectReq)
if err != nil {
var rpcErr *jsonrpc2.Error
@@ -1987,8 +1991,9 @@ func (c *Client) verifyProtocolVersion(ctx context.Context) error {
}
type connectHandshakeRequest struct {
- Token *string `json:"token,omitempty"`
- EnableGitHubTelemetryForwarding *bool `json:"enableGitHubTelemetryForwarding,omitempty"`
+ Token *string `json:"token,omitempty"`
+ EnableGitHubTelemetryForwarding *bool `json:"enableGitHubTelemetryForwarding,omitempty"`
+ ClientInfo *rpc.ConnectClientInfo `json:"clientInfo,omitempty"`
}
// stderrBufferSize is the maximum number of bytes kept from the CLI process's
diff --git a/go/client_test.go b/go/client_test.go
index bede29b496..99a21255dc 100644
--- a/go/client_test.go
+++ b/go/client_test.go
@@ -276,6 +276,106 @@ func TestClient_BuiltinPluginDirectories(t *testing.T) {
})
}
+func TestClient_ClientInfo(t *testing.T) {
+ findConnect := func(requests []startupRPCRequest) map[string]any {
+ t.Helper()
+ for _, request := range requests {
+ if request.Method != "connect" {
+ continue
+ }
+ var params map[string]any
+ if err := json.Unmarshal(request.Params, ¶ms); err != nil {
+ t.Fatalf("decode connect params: %v", err)
+ }
+ return params
+ }
+ t.Fatal("connect was not called")
+ return nil
+ }
+
+ t.Run("forwards a declared identity on the connect handshake", func(t *testing.T) {
+ url, requests, cleanup := newStartupRPCServer(t)
+ defer cleanup()
+
+ client := NewClient(&ClientOptions{
+ Connection: URIConnection{URL: url},
+ ClientInfo: &ClientInfo{
+ EditorName: "JetBrains-IU",
+ EditorVersion: "2026.1",
+ ExtensionName: "copilot-intellij",
+ ExtensionVersion: "1.5.0",
+ },
+ })
+ if err := client.Start(t.Context()); err != nil {
+ t.Fatalf("Start failed: %v", err)
+ }
+ defer client.ForceStop()
+
+ params := findConnect(requests())
+ want := map[string]any{
+ "editorName": "JetBrains-IU",
+ "editorVersion": "2026.1",
+ "extensionName": "copilot-intellij",
+ "extensionVersion": "1.5.0",
+ }
+ if !reflect.DeepEqual(params["clientInfo"], want) {
+ t.Fatalf("clientInfo = %v, want %v", params["clientInfo"], want)
+ }
+ })
+
+ t.Run("omits clientInfo when unset", func(t *testing.T) {
+ url, requests, cleanup := newStartupRPCServer(t)
+ defer cleanup()
+
+ client := NewClient(&ClientOptions{Connection: URIConnection{URL: url}})
+ if err := client.Start(t.Context()); err != nil {
+ t.Fatalf("Start failed: %v", err)
+ }
+ defer client.ForceStop()
+
+ if _, ok := findConnect(requests())["clientInfo"]; ok {
+ t.Fatal("clientInfo should be omitted when unset")
+ }
+ })
+
+ t.Run("omits empty fields", func(t *testing.T) {
+ url, requests, cleanup := newStartupRPCServer(t)
+ defer cleanup()
+
+ client := NewClient(&ClientOptions{
+ Connection: URIConnection{URL: url},
+ ClientInfo: &ClientInfo{EditorName: "example-editor"},
+ })
+ if err := client.Start(t.Context()); err != nil {
+ t.Fatalf("Start failed: %v", err)
+ }
+ defer client.ForceStop()
+
+ want := map[string]any{"editorName": "example-editor"}
+ if got := findConnect(requests())["clientInfo"]; !reflect.DeepEqual(got, want) {
+ t.Fatalf("clientInfo = %v, want %v", got, want)
+ }
+ })
+
+ t.Run("omits clientInfo when all fields empty", func(t *testing.T) {
+ url, requests, cleanup := newStartupRPCServer(t)
+ defer cleanup()
+
+ client := NewClient(&ClientOptions{
+ Connection: URIConnection{URL: url},
+ ClientInfo: &ClientInfo{},
+ })
+ if err := client.Start(t.Context()); err != nil {
+ t.Fatalf("Start failed: %v", err)
+ }
+ defer client.ForceStop()
+
+ if _, ok := findConnect(requests())["clientInfo"]; ok {
+ t.Fatal("clientInfo should be omitted when all fields are empty")
+ }
+ })
+}
+
type startupRPCRequest struct {
Method string
Params json.RawMessage
diff --git a/go/types.go b/go/types.go
index 09bc281f22..a1deebd1d7 100644
--- a/go/types.go
+++ b/go/types.go
@@ -193,6 +193,13 @@ type ClientOptions struct {
// directory are accessible from GitHub web and mobile.
// Ignored when connecting to an existing runtime via [URIConnection].
EnableRemoteSessions bool
+ // ClientInfo declares the integrating host's identity, forwarded to the
+ // runtime on the `server.connect` handshake. Declaring it lets the
+ // telemetry the runtime emits on this connection be attributed to a
+ // consistent surface (the host editor and its Copilot extension) instead of
+ // the runtime's own build. All fields are optional; leave it nil to keep the
+ // runtime's default attribution.
+ ClientInfo *ClientInfo
// Mode controls the default tool surface and feature flags presented to
// sessions created by this client. The zero value ([ModeCopilotCli])
// matches legacy CLI defaults. Set to [ModeEmpty] to opt in to
@@ -204,6 +211,55 @@ type ClientOptions struct {
Mode ClientMode
}
+// ClientInfo identifies the integrating host on the `server.connect` handshake.
+//
+// Declaring it lets the telemetry the runtime emits on the connection be
+// attributed to a single, consistent surface instead of the runtime's own
+// build. All fields are optional; an empty field is omitted from the handshake.
+type ClientInfo struct {
+ // EditorName is the name of the host editor, e.g. "vscode".
+ EditorName string
+ // EditorVersion is the version of the host editor, e.g. "1.124.2".
+ EditorVersion string
+ // ExtensionName is the name of the Copilot extension within the host, e.g.
+ // "copilot-chat".
+ ExtensionName string
+ // ExtensionVersion is the version of the Copilot extension within the host,
+ // e.g. "0.54.0".
+ ExtensionVersion string
+}
+
+// toWire maps the public [ClientInfo] onto the generated connect wire shape,
+// omitting empty fields. It returns nil when no identity was supplied so the
+// caller drops the clientInfo field and keeps the runtime's default attribution.
+func (ci *ClientInfo) toWire() *rpc.ConnectClientInfo {
+ if ci == nil {
+ return nil
+ }
+ wire := &rpc.ConnectClientInfo{}
+ populated := false
+ if ci.EditorName != "" {
+ wire.EditorName = &ci.EditorName
+ populated = true
+ }
+ if ci.EditorVersion != "" {
+ wire.EditorVersion = &ci.EditorVersion
+ populated = true
+ }
+ if ci.ExtensionName != "" {
+ wire.ExtensionName = &ci.ExtensionName
+ populated = true
+ }
+ if ci.ExtensionVersion != "" {
+ wire.ExtensionVersion = &ci.ExtensionVersion
+ populated = true
+ }
+ if !populated {
+ return nil
+ }
+ return wire
+}
+
// CloudSessionRepository is GitHub repository metadata associated with a cloud session.
type CloudSessionRepository struct {
Owner string `json:"owner"`
diff --git a/java/sdk/src/main/java/com/github/copilot/CopilotClient.java b/java/sdk/src/main/java/com/github/copilot/CopilotClient.java
index 661fe6dab4..3818c82646 100644
--- a/java/sdk/src/main/java/com/github/copilot/CopilotClient.java
+++ b/java/sdk/src/main/java/com/github/copilot/CopilotClient.java
@@ -642,6 +642,13 @@ private void verifyProtocolVersion(Connection connection) throws Exception {
if (this.options.getOnGitHubTelemetry() != null) {
connectParams.put("enableGitHubTelemetryForwarding", true);
}
+ // Declare the integrating host's identity so the runtime attributes the
+ // telemetry it emits on this connection to a consistent surface instead of
+ // its own build. Omitted when the app didn't supply it (or supplied no fields).
+ var clientInfo = this.options.getClientInfo();
+ if (clientInfo != null && !clientInfo.isEmpty()) {
+ connectParams.put("clientInfo", clientInfo);
+ }
var connectResponse = connection.rpc.invoke("connect", connectParams, ConnectResult.class).get(30,
TimeUnit.SECONDS);
serverVersion = connectResponse.protocolVersion() != null
diff --git a/java/sdk/src/main/java/com/github/copilot/rpc/ClientInfo.java b/java/sdk/src/main/java/com/github/copilot/rpc/ClientInfo.java
new file mode 100644
index 0000000000..ebb54db6db
--- /dev/null
+++ b/java/sdk/src/main/java/com/github/copilot/rpc/ClientInfo.java
@@ -0,0 +1,144 @@
+/*---------------------------------------------------------------------------------------------
+ * Copyright (c) Microsoft Corporation. All rights reserved.
+ *--------------------------------------------------------------------------------------------*/
+
+package com.github.copilot.rpc;
+
+import com.fasterxml.jackson.annotation.JsonIgnore;
+import com.fasterxml.jackson.annotation.JsonInclude;
+import com.fasterxml.jackson.annotation.JsonProperty;
+
+/**
+ * Identity of the integrating host, declared on the {@code server.connect}
+ * handshake.
+ *
+ * Declaring it lets the telemetry the runtime emits on the connection be
+ * attributed to a single, consistent surface (the host editor and its Copilot
+ * extension) instead of the runtime's own build. All fields are optional; an
+ * empty field is omitted from the handshake.
+ *
+ *
Example Usage
+ *
+ * {@code
+ * var options = new CopilotClientOptions().setClientInfo(new ClientInfo().setEditorName("vscode")
+ * .setEditorVersion("1.124.2").setExtensionName("copilot-chat").setExtensionVersion("0.54.0"));
+ * }
+ *
+ * @see CopilotClientOptions#setClientInfo(ClientInfo)
+ * @since 1.6.0
+ */
+@JsonInclude(JsonInclude.Include.NON_EMPTY)
+public class ClientInfo {
+
+ @JsonProperty("editorName")
+ private String editorName;
+
+ @JsonProperty("editorVersion")
+ private String editorVersion;
+
+ @JsonProperty("extensionName")
+ private String extensionName;
+
+ @JsonProperty("extensionVersion")
+ private String extensionVersion;
+
+ /**
+ * Gets the name of the host editor.
+ *
+ * @return the editor name (e.g., {@code "vscode"}), or {@code null}
+ */
+ public String getEditorName() {
+ return editorName;
+ }
+
+ /**
+ * Sets the name of the host editor.
+ *
+ * @param editorName
+ * the editor name (e.g., {@code "vscode"})
+ * @return this client info for method chaining
+ */
+ public ClientInfo setEditorName(String editorName) {
+ this.editorName = editorName;
+ return this;
+ }
+
+ /**
+ * Gets the version of the host editor.
+ *
+ * @return the editor version (e.g., {@code "1.124.2"}), or {@code null}
+ */
+ public String getEditorVersion() {
+ return editorVersion;
+ }
+
+ /**
+ * Sets the version of the host editor.
+ *
+ * @param editorVersion
+ * the editor version (e.g., {@code "1.124.2"})
+ * @return this client info for method chaining
+ */
+ public ClientInfo setEditorVersion(String editorVersion) {
+ this.editorVersion = editorVersion;
+ return this;
+ }
+
+ /**
+ * Gets the name of the Copilot extension within the host.
+ *
+ * @return the extension name (e.g., {@code "copilot-chat"}), or {@code null}
+ */
+ public String getExtensionName() {
+ return extensionName;
+ }
+
+ /**
+ * Sets the name of the Copilot extension within the host.
+ *
+ * @param extensionName
+ * the extension name (e.g., {@code "copilot-chat"})
+ * @return this client info for method chaining
+ */
+ public ClientInfo setExtensionName(String extensionName) {
+ this.extensionName = extensionName;
+ return this;
+ }
+
+ /**
+ * Gets the version of the Copilot extension within the host.
+ *
+ * @return the extension version (e.g., {@code "0.54.0"}), or {@code null}
+ */
+ public String getExtensionVersion() {
+ return extensionVersion;
+ }
+
+ /**
+ * Sets the version of the Copilot extension within the host.
+ *
+ * @param extensionVersion
+ * the extension version (e.g., {@code "0.54.0"})
+ * @return this client info for method chaining
+ */
+ public ClientInfo setExtensionVersion(String extensionVersion) {
+ this.extensionVersion = extensionVersion;
+ return this;
+ }
+
+ /**
+ * Returns whether no field carries a non-empty value, in which case the SDK
+ * omits {@code clientInfo} from the handshake so the runtime keeps its default
+ * attribution.
+ *
+ * @return {@code true} when every field is {@code null} or empty
+ */
+ @JsonIgnore
+ public boolean isEmpty() {
+ return isBlank(editorName) && isBlank(editorVersion) && isBlank(extensionName) && isBlank(extensionVersion);
+ }
+
+ private static boolean isBlank(String value) {
+ return value == null || value.isEmpty();
+ }
+}
diff --git a/java/sdk/src/main/java/com/github/copilot/rpc/CopilotClientOptions.java b/java/sdk/src/main/java/com/github/copilot/rpc/CopilotClientOptions.java
index a9c9cbcda7..3af76da52f 100644
--- a/java/sdk/src/main/java/com/github/copilot/rpc/CopilotClientOptions.java
+++ b/java/sdk/src/main/java/com/github/copilot/rpc/CopilotClientOptions.java
@@ -55,6 +55,7 @@ public class CopilotClientOptions {
private String cliPath;
private String cliUrl;
private RuntimeConnection connection;
+ private ClientInfo clientInfo;
private String copilotHome;
private String cwd;
private Map environment;
@@ -678,6 +679,36 @@ public CopilotClientOptions setTelemetry(TelemetryConfig telemetry) {
return this;
}
+ /**
+ * Gets the integrating host's declared identity.
+ *
+ * @return the client info, or {@code null}
+ * @since 1.6.0
+ */
+ public ClientInfo getClientInfo() {
+ return clientInfo;
+ }
+
+ /**
+ * Declares the integrating host's identity, forwarded to the runtime on the
+ * {@code server.connect} handshake.
+ *
+ * Declaring it lets the telemetry the runtime emits on this connection be
+ * attributed to a consistent surface (the host editor and its Copilot
+ * extension) instead of the runtime's own build. All fields on
+ * {@link ClientInfo} are optional; leave this unset to keep the runtime's
+ * default attribution.
+ *
+ * @param clientInfo
+ * the host identity to declare
+ * @return this options instance for method chaining
+ * @since 1.6.0
+ */
+ public CopilotClientOptions setClientInfo(ClientInfo clientInfo) {
+ this.clientInfo = Objects.requireNonNull(clientInfo, "clientInfo must not be null");
+ return this;
+ }
+
/**
* Gets the server-wide idle timeout for sessions in seconds.
*
@@ -830,6 +861,7 @@ public CopilotClientOptions clone() {
copy.cliPath = this.cliPath;
copy.cliUrl = this.cliUrl;
copy.connection = this.connection;
+ copy.clientInfo = this.clientInfo;
copy.copilotHome = this.copilotHome;
copy.cwd = this.cwd;
copy.environment = this.environment != null ? new java.util.HashMap<>(this.environment) : null;
diff --git a/java/sdk/src/test/java/com/github/copilot/GitHubTelemetryTest.java b/java/sdk/src/test/java/com/github/copilot/GitHubTelemetryTest.java
index 7b0deb9977..af83fc7803 100644
--- a/java/sdk/src/test/java/com/github/copilot/GitHubTelemetryTest.java
+++ b/java/sdk/src/test/java/com/github/copilot/GitHubTelemetryTest.java
@@ -24,6 +24,7 @@
import com.fasterxml.jackson.databind.JsonNode;
import com.github.copilot.generated.rpc.GitHubTelemetryNotification;
+import com.github.copilot.rpc.ClientInfo;
import com.github.copilot.rpc.CopilotClientOptions;
import com.github.copilot.rpc.PermissionHandler;
import com.github.copilot.rpc.ResumeSessionConfig;
@@ -204,6 +205,95 @@ void clientOmitsForwardingWhenNoHandler() throws Exception {
}
}
+ @Test
+ void connectForwardsDeclaredClientInfo() throws Exception {
+ try (var server = new FakeRuntimeServer();
+ var client = new CopilotClient(new CopilotClientOptions().setCliUrl(server.url())
+ .setClientInfo(new ClientInfo().setEditorName("JetBrains-IU").setEditorVersion("2026.1")
+ .setExtensionName("copilot-intellij").setExtensionVersion("1.5.0")))) {
+
+ client.start().get(15, TimeUnit.SECONDS);
+
+ JsonNode connectParams = server.awaitConnect();
+ JsonNode clientInfo = connectParams.path("clientInfo");
+ assertEquals(4, clientInfo.size(), "clientInfo should carry only the four declared fields");
+ assertEquals("JetBrains-IU", clientInfo.path("editorName").asText());
+ assertEquals("2026.1", clientInfo.path("editorVersion").asText());
+ assertEquals("copilot-intellij", clientInfo.path("extensionName").asText());
+ assertEquals("1.5.0", clientInfo.path("extensionVersion").asText());
+ }
+ }
+
+ @Test
+ void connectOmitsClientInfoWhenUnset() throws Exception {
+ try (var server = new FakeRuntimeServer();
+ var client = new CopilotClient(new CopilotClientOptions().setCliUrl(server.url()))) {
+
+ client.start().get(15, TimeUnit.SECONDS);
+
+ JsonNode connectParams = server.awaitConnect();
+ assertFalse(connectParams.has("clientInfo"),
+ "connect request should omit clientInfo when none was declared");
+ }
+ }
+
+ @Test
+ void connectOmitsEmptyClientInfoFields() throws Exception {
+ try (var server = new FakeRuntimeServer();
+ var client = new CopilotClient(new CopilotClientOptions().setCliUrl(server.url())
+ .setClientInfo(new ClientInfo().setEditorName("example-editor")))) {
+
+ client.start().get(15, TimeUnit.SECONDS);
+
+ JsonNode connectParams = server.awaitConnect();
+ JsonNode clientInfo = connectParams.path("clientInfo");
+ assertEquals("example-editor", clientInfo.path("editorName").asText());
+ assertFalse(clientInfo.has("editorVersion"), "unset editorVersion should be omitted");
+ assertFalse(clientInfo.has("extensionName"), "unset extensionName should be omitted");
+ assertFalse(clientInfo.has("extensionVersion"), "unset extensionVersion should be omitted");
+ }
+ }
+
+ @Test
+ void connectDropsEmptyClientInfoFields() throws Exception {
+ try (var server = new FakeRuntimeServer();
+ var client = new CopilotClient(new CopilotClientOptions().setCliUrl(server.url())
+ .setClientInfo(new ClientInfo().setEditorName("vscode").setEditorVersion("")))) {
+
+ client.start().get(15, TimeUnit.SECONDS);
+
+ JsonNode connectParams = server.awaitConnect();
+ JsonNode clientInfo = connectParams.path("clientInfo");
+ assertEquals(1, clientInfo.size(), "clientInfo should carry only the non-empty field");
+ assertEquals("vscode", clientInfo.path("editorName").asText());
+ assertFalse(clientInfo.has("editorVersion"), "empty editorVersion should be dropped");
+ }
+ }
+
+ @Test
+ void connectOmitsAllEmptyClientInfo() throws Exception {
+ try (var server = new FakeRuntimeServer();
+ var client = new CopilotClient(new CopilotClientOptions().setCliUrl(server.url())
+ .setClientInfo(new ClientInfo().setEditorName("").setEditorVersion("").setExtensionName("")
+ .setExtensionVersion("")))) {
+
+ client.start().get(15, TimeUnit.SECONDS);
+
+ JsonNode connectParams = server.awaitConnect();
+ assertFalse(connectParams.has("clientInfo"), "connect request should omit an all-empty clientInfo");
+ }
+ }
+
+ @Test
+ void optionsRetainAndCloneClientInfo() {
+ var info = new ClientInfo().setEditorName("example-editor");
+ var options = new CopilotClientOptions().setClientInfo(info);
+ assertSame(info, options.getClientInfo());
+
+ var copy = options.clone();
+ assertSame(info, copy.getClientInfo());
+ }
+
@Test
void optionsRetainAndCloneTelemetryHandler() {
Function> handler = n -> CompletableFuture
diff --git a/nodejs/src/client.ts b/nodejs/src/client.ts
index acaf67ba85..eb8391e1dd 100644
--- a/nodejs/src/client.ts
+++ b/nodejs/src/client.ts
@@ -34,6 +34,7 @@ import {
registerClientSessionApiHandlers,
} from "./generated/rpc.js";
import type {
+ ConnectClientInfo,
GitHubTelemetryNotification,
GitHubTokenAcquireRequest,
GitHubTokenAcquireResult,
@@ -52,6 +53,7 @@ import { ToolSet } from "./toolSet.js";
import type {
AutoModeSwitchRequest,
AutoModeSwitchResponse,
+ CopilotClientInfo,
CopilotClientMode,
CopilotClientOptions,
CustomAgentConfig,
@@ -259,6 +261,22 @@ function toWireCustomAgents(agents: CustomAgentConfig[] | undefined): unknown[]
});
}
+/**
+ * Map the public {@link CopilotClientInfo} onto the generated connect wire
+ * shape, dropping empty fields. Returns `undefined` when no field carries a
+ * non-empty value so the caller omits `clientInfo` from the handshake and keeps
+ * the runtime's default attribution.
+ */
+function clientInfoToWire(info: CopilotClientInfo | undefined): ConnectClientInfo | undefined {
+ if (info == null) return undefined;
+ const wire: ConnectClientInfo = {};
+ if (info.editorName) wire.editorName = info.editorName;
+ if (info.editorVersion) wire.editorVersion = info.editorVersion;
+ if (info.extensionName) wire.extensionName = info.extensionName;
+ if (info.extensionVersion) wire.extensionVersion = info.extensionVersion;
+ return Object.keys(wire).length > 0 ? wire : undefined;
+}
+
/**
* Convert a {@link LargeToolOutputConfig} from the public API shape
* (`outputDirectory`) to the wire shape (`outputDir`).
@@ -522,6 +540,7 @@ export class CopilotClient {
sessionIdleTimeoutSeconds: number;
enableRemoteSessions: boolean;
mode: CopilotClientMode;
+ clientInfo?: CopilotClientInfo;
};
private isExternalServer: boolean = false;
private forceStopping: boolean = false;
@@ -778,6 +797,7 @@ export class CopilotClient {
sessionIdleTimeoutSeconds: options.sessionIdleTimeoutSeconds ?? 0,
enableRemoteSessions: options.enableRemoteSessions ?? false,
mode: options.mode ?? "copilot-cli",
+ clientInfo: options.clientInfo,
};
// Empty mode: validate at construction time that the app supplied a
@@ -2218,6 +2238,7 @@ export class CopilotClient {
const connectParams: {
token?: string;
enableGitHubTelemetryForwarding?: boolean;
+ clientInfo?: ConnectClientInfo;
} = { token: this.effectiveConnectionToken };
// Opt in to GitHub telemetry forwarding at the connection level when a
// handler is registered (mirrors the runtime, which reads this flag on the
@@ -2226,6 +2247,14 @@ export class CopilotClient {
if (this.onGitHubTelemetry != null) {
connectParams.enableGitHubTelemetryForwarding = true;
}
+ // Declare the integrating host's identity so the runtime attributes
+ // the telemetry it emits on this connection to a consistent surface
+ // instead of its own build. Empty fields are dropped, and an
+ // all-empty identity is omitted entirely.
+ const clientInfo = clientInfoToWire(this.options.clientInfo);
+ if (clientInfo != null) {
+ connectParams.clientInfo = clientInfo;
+ }
const result = await raceAgainstExit(this.internalRpc.connect(connectParams));
serverVersion = result.protocolVersion;
} catch (err) {
diff --git a/nodejs/src/index.ts b/nodejs/src/index.ts
index 11e742bee7..bf6f2195f9 100644
--- a/nodejs/src/index.ts
+++ b/nodejs/src/index.ts
@@ -69,6 +69,7 @@ export type {
UserPromptTransformedHandler,
UserPromptTransformedHookInput,
UserPromptTransformedHookOutput,
+ CopilotClientInfo,
CopilotClientMode,
CopilotClientOptions,
CopilotExpAssignmentResponse,
diff --git a/nodejs/src/types.ts b/nodejs/src/types.ts
index cd211fd9f5..efed11194a 100644
--- a/nodejs/src/types.ts
+++ b/nodejs/src/types.ts
@@ -307,6 +307,37 @@ export type InternalRuntimeConnection = RuntimeConnection | ParentProcessRuntime
*/
export type CopilotClientMode = "empty" | "copilot-cli";
+/**
+ * Identity of the integrating host, declared once on the `server.connect`
+ * handshake so the telemetry the runtime emits on this connection is attributed
+ * to a single, consistent surface rather than to the runtime's own build.
+ *
+ * All fields are optional; omit any of them (or the whole object) to keep the
+ * runtime's default attribution. Version fields are ignored by the runtime
+ * unless they look like a version string.
+ */
+export interface CopilotClientInfo {
+ /**
+ * Name of the host editor, e.g. `"vscode"`.
+ */
+ editorName?: string;
+
+ /**
+ * Version of the host editor, e.g. `"1.124.2"`.
+ */
+ editorVersion?: string;
+
+ /**
+ * Name of the Copilot extension within the host, e.g. `"copilot-chat"`.
+ */
+ extensionName?: string;
+
+ /**
+ * Version of the Copilot extension within the host, e.g. `"0.54.0"`.
+ */
+ extensionVersion?: string;
+}
+
export interface CopilotClientOptions {
/**
* How to connect to the Copilot runtime. When omitted, defaults to
@@ -478,6 +509,16 @@ export interface CopilotClientOptions {
*/
enableRemoteSessions?: boolean;
+ /**
+ * Identity of the integrating host, forwarded to the runtime on the
+ * `server.connect` handshake. Declaring it lets the telemetry the runtime
+ * emits on this connection be attributed to a single, consistent surface
+ * (e.g. the host editor and its Copilot extension) instead of the runtime's
+ * own build. All fields are optional; omit it to keep the default
+ * attribution.
+ */
+ clientInfo?: CopilotClientInfo;
+
/**
* @internal Hook used by `joinSession()` to construct a client that talks
* to its parent process over stdio. Not part of the public API.
diff --git a/nodejs/test/client.test.ts b/nodejs/test/client.test.ts
index 829d69f2e5..cef9cbf490 100644
--- a/nodejs/test/client.test.ts
+++ b/nodejs/test/client.test.ts
@@ -13,6 +13,7 @@ import {
DisableBypassPermissionsModes,
RuntimeConnection,
type CapiSessionOptions,
+ type CopilotClientOptions,
type GitHubTelemetryNotification,
type ManagedSettings,
type ModelInfo,
@@ -4244,3 +4245,76 @@ describe("managedSettings serialization", () => {
});
});
});
+
+describe("connect handshake clientInfo", () => {
+ // Drives verifyProtocolVersion() against a stubbed connection so we can
+ // observe the `connect` params without spawning a runtime. `connect` maps to
+ // connection.sendRequest("connect", params) in the generated internal RPC.
+ async function captureConnectParams(
+ options: Partial> = {}
+ ): Promise> {
+ const client = new CopilotClient({
+ connection: RuntimeConnection.forUri("localhost:1234"),
+ ...options,
+ });
+ const sendRequest = vi.fn(async (method: string, _params?: unknown) => {
+ if (method === "connect") return { protocolVersion: 3 };
+ throw new Error(`Unexpected method: ${method}`);
+ });
+ (client as any).connection = { sendRequest };
+
+ await (client as any).verifyProtocolVersion();
+
+ const connectCall = sendRequest.mock.calls.find(([method]) => method === "connect");
+ expect(connectCall, "connect was not called").toBeTruthy();
+ return connectCall![1] as Record;
+ }
+
+ it("forwards a declared client identity on the connect handshake", async () => {
+ const clientInfo = {
+ editorName: "JetBrains-IU",
+ editorVersion: "2026.1",
+ extensionName: "copilot-intellij",
+ extensionVersion: "1.5.0",
+ };
+
+ const params = await captureConnectParams({ clientInfo });
+
+ expect(params.clientInfo).toEqual(clientInfo);
+ });
+
+ it("omits clientInfo from the handshake when the host declares none", async () => {
+ const params = await captureConnectParams();
+
+ expect(params).not.toHaveProperty("clientInfo");
+ });
+
+ it("drops empty fields and omits an all-empty identity", async () => {
+ const allEmpty = await captureConnectParams({
+ clientInfo: {
+ editorName: "",
+ editorVersion: "",
+ extensionName: "",
+ extensionVersion: "",
+ },
+ });
+ expect(allEmpty).not.toHaveProperty("clientInfo");
+
+ const partial = await captureConnectParams({
+ clientInfo: { editorName: "vscode", editorVersion: "" },
+ });
+ expect(partial.clientInfo).toEqual({ editorName: "vscode" });
+ });
+
+ it("keeps telemetry forwarding alongside a declared identity", async () => {
+ const params = await captureConnectParams({
+ clientInfo: { editorName: "example-editor" },
+ onGitHubTelemetry: () => {},
+ });
+
+ expect(params).toMatchObject({
+ clientInfo: { editorName: "example-editor" },
+ enableGitHubTelemetryForwarding: true,
+ });
+ });
+});
diff --git a/python/copilot/__init__.py b/python/copilot/__init__.py
index 5325b937c5..5d9e3f9500 100644
--- a/python/copilot/__init__.py
+++ b/python/copilot/__init__.py
@@ -33,6 +33,7 @@
AutoTier,
CapiSessionOptions,
ChildProcessRuntimeConnection,
+ ClientInfo,
CloudSessionOptions,
CloudSessionRepository,
CopilotClient,
@@ -244,6 +245,7 @@
"CanvasProviderIdentity",
"CapiSessionOptions",
"ChildProcessRuntimeConnection",
+ "ClientInfo",
"CloudSessionOptions",
"CloudSessionRepository",
"CommandContext",
diff --git a/python/copilot/client.py b/python/copilot/client.py
index a15c1a569e..4db29d1e78 100644
--- a/python/copilot/client.py
+++ b/python/copilot/client.py
@@ -507,6 +507,46 @@ class TelemetryConfig(TypedDict, total=False):
"""Whether to capture message content. Sets OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT.""" # noqa: E501
+class ClientInfo(TypedDict, total=False):
+ """Identity of the integrating host, declared on the ``server.connect`` handshake.
+
+ Declaring it lets the telemetry the runtime emits on this connection be
+ attributed to a single, consistent surface (the host editor and its Copilot
+ extension) instead of the runtime's own build. All fields are optional; omit
+ any of them (or the whole object) to keep the runtime's default attribution.
+ """
+
+ editor_name: str
+ """Name of the host editor, e.g. ``"vscode"``."""
+ editor_version: str
+ """Version of the host editor, e.g. ``"1.124.2"``."""
+ extension_name: str
+ """Name of the Copilot extension within the host, e.g. ``"copilot-chat"``."""
+ extension_version: str
+ """Version of the Copilot extension within the host, e.g. ``"0.54.0"``."""
+
+
+def _client_info_to_wire(client_info: ClientInfo | None) -> dict[str, str] | None:
+ """Map a snake_case :class:`ClientInfo` onto the camelCase connect wire shape.
+
+ Empty fields are dropped. Returns ``None`` when no field carries a non-empty
+ value so the caller omits the ``clientInfo`` field entirely and keeps the
+ runtime's default attribution.
+ """
+ if not client_info:
+ return None
+ wire: dict[str, str] = {}
+ if client_info.get("editor_name"):
+ wire["editorName"] = client_info["editor_name"]
+ if client_info.get("editor_version"):
+ wire["editorVersion"] = client_info["editor_version"]
+ if client_info.get("extension_name"):
+ wire["extensionName"] = client_info["extension_name"]
+ if client_info.get("extension_version"):
+ wire["extensionVersion"] = client_info["extension_version"]
+ return wire or None
+
+
@dataclass
class RuntimeConnection:
"""Discriminated config describing how to reach the Copilot runtime.
@@ -776,6 +816,7 @@ class _CopilotClientOptions:
request_handler: CopilotRequestHandler | None = None
session_idle_timeout_seconds: int | None = None
enable_remote_sessions: bool = False
+ client_info: ClientInfo | None = None
on_list_models: Callable[[], list[ModelInfo] | Awaitable[list[ModelInfo]]] | None = None
on_github_telemetry: Callable[[GitHubTelemetryNotification], None | Awaitable[None]] | None = (
None
@@ -1530,6 +1571,7 @@ def __init__(
request_handler: CopilotRequestHandler | None = None,
session_idle_timeout_seconds: int | None = None,
enable_remote_sessions: bool = False,
+ client_info: ClientInfo | None = None,
on_list_models: Callable[[], list[ModelInfo] | Awaitable[list[ModelInfo]]] | None = None,
on_github_telemetry: Callable[[GitHubTelemetryNotification], None | Awaitable[None]]
| None = None,
@@ -1579,6 +1621,11 @@ def __init__(
Control integration). When ``True``, sessions in a GitHub
repository working directory are accessible from GitHub web
and mobile.
+ client_info: Identity of the integrating host, forwarded to the
+ runtime on the ``server.connect`` handshake. Declaring it lets
+ the telemetry the runtime emits on this connection be attributed
+ to a consistent surface instead of the runtime's own build. All
+ fields are optional; omit it to keep the default attribution.
on_list_models: Custom handler for :meth:`list_models`. When
provided, the handler is called instead of querying the runtime
server.
@@ -1616,6 +1663,7 @@ def __init__(
request_handler=request_handler,
session_idle_timeout_seconds=session_idle_timeout_seconds,
enable_remote_sessions=enable_remote_sessions,
+ client_info=client_info,
on_list_models=on_list_models,
on_github_telemetry=on_github_telemetry,
mode=mode,
@@ -4054,6 +4102,12 @@ async def _verify_protocol_version(self) -> None:
# event is forwarded). Also sent on session.create/resume for older CLIs.
if self._on_github_telemetry is not None:
connect_params["enableGitHubTelemetryForwarding"] = True
+ # Declare the integrating host's identity so the runtime attributes
+ # the telemetry it emits on this connection to a consistent surface
+ # instead of its own build. Omitted when the app didn't supply it.
+ client_info = _client_info_to_wire(self._options.client_info)
+ if client_info is not None:
+ connect_params["clientInfo"] = client_info
connect_result = _ConnectResult.from_dict(
await self._client.request("connect", connect_params)
)
diff --git a/python/test_client.py b/python/test_client.py
index d1c6ebe1d7..197103dd68 100644
--- a/python/test_client.py
+++ b/python/test_client.py
@@ -3151,6 +3151,105 @@ async def request(self, method, params, **kwargs):
await client._verify_protocol_version()
assert "enableGitHubTelemetryForwarding" not in captured["connect"]
+ @pytest.mark.asyncio
+ async def test_connect_forwards_client_info(self):
+ client = CopilotClient(
+ connection=RuntimeConnection.for_stdio(path=CLI_PATH),
+ client_info={
+ "editor_name": "JetBrains-IU",
+ "editor_version": "2026.1",
+ "extension_name": "copilot-intellij",
+ "extension_version": "1.5.0",
+ },
+ )
+ captured = {}
+
+ class _FakeClient:
+ async def request(self, method, params, **kwargs):
+ captured[method] = params
+ return {"ok": True, "protocolVersion": 3, "version": "test"}
+
+ client._client = _FakeClient()
+ await client._verify_protocol_version()
+ assert captured["connect"]["clientInfo"] == {
+ "editorName": "JetBrains-IU",
+ "editorVersion": "2026.1",
+ "extensionName": "copilot-intellij",
+ "extensionVersion": "1.5.0",
+ }
+
+ @pytest.mark.asyncio
+ async def test_connect_omits_client_info_when_unset(self):
+ client = CopilotClient(connection=RuntimeConnection.for_stdio(path=CLI_PATH))
+ captured = {}
+
+ class _FakeClient:
+ async def request(self, method, params, **kwargs):
+ captured[method] = params
+ return {"ok": True, "protocolVersion": 3, "version": "test"}
+
+ client._client = _FakeClient()
+ await client._verify_protocol_version()
+ assert "clientInfo" not in captured["connect"]
+
+ @pytest.mark.asyncio
+ async def test_connect_forwards_partial_client_info_with_forwarding(self):
+ client = CopilotClient(
+ connection=RuntimeConnection.for_stdio(path=CLI_PATH),
+ client_info={"editor_name": "example-editor"},
+ on_github_telemetry=lambda _notification: None,
+ )
+ captured = {}
+
+ class _FakeClient:
+ async def request(self, method, params, **kwargs):
+ captured[method] = params
+ return {"ok": True, "protocolVersion": 3, "version": "test"}
+
+ client._client = _FakeClient()
+ await client._verify_protocol_version()
+ assert captured["connect"]["clientInfo"] == {"editorName": "example-editor"}
+ assert captured["connect"]["enableGitHubTelemetryForwarding"] is True
+
+ @pytest.mark.asyncio
+ async def test_connect_drops_empty_client_info_fields(self):
+ client = CopilotClient(
+ connection=RuntimeConnection.for_stdio(path=CLI_PATH),
+ client_info={"editor_name": "vscode", "editor_version": ""},
+ )
+ captured = {}
+
+ class _FakeClient:
+ async def request(self, method, params, **kwargs):
+ captured[method] = params
+ return {"ok": True, "protocolVersion": 3, "version": "test"}
+
+ client._client = _FakeClient()
+ await client._verify_protocol_version()
+ assert captured["connect"]["clientInfo"] == {"editorName": "vscode"}
+
+ @pytest.mark.asyncio
+ async def test_connect_omits_all_empty_client_info(self):
+ client = CopilotClient(
+ connection=RuntimeConnection.for_stdio(path=CLI_PATH),
+ client_info={
+ "editor_name": "",
+ "editor_version": "",
+ "extension_name": "",
+ "extension_version": "",
+ },
+ )
+ captured = {}
+
+ class _FakeClient:
+ async def request(self, method, params, **kwargs):
+ captured[method] = params
+ return {"ok": True, "protocolVersion": 3, "version": "test"}
+
+ client._client = _FakeClient()
+ await client._verify_protocol_version()
+ assert "clientInfo" not in captured["connect"]
+
@pytest.mark.asyncio
async def test_event_routes_to_handler(self):
from copilot.generated.rpc import GitHubTelemetryNotification
diff --git a/rust/src/lib.rs b/rust/src/lib.rs
index 32d44ddc0f..9734417e20 100644
--- a/rust/src/lib.rs
+++ b/rust/src/lib.rs
@@ -401,6 +401,99 @@ pub struct ClientOptions {
/// (the default) or are stripped to a minimal/safe baseline. See
/// [`ClientMode`] for the contract and trade-offs.
pub mode: ClientMode,
+ /// Declares the integrating host's identity, forwarded to the runtime on
+ /// the `server.connect` handshake. Declaring it lets the telemetry the
+ /// runtime emits on this connection be attributed to a consistent surface
+ /// (the host editor and its Copilot extension) instead of the runtime's own
+ /// build. All fields are optional; leave it `None` to keep the runtime's
+ /// default attribution.
+ pub client_info: Option,
+}
+
+/// Identity of the integrating host, declared on the `server.connect`
+/// handshake.
+///
+/// Declaring it lets the telemetry the runtime emits on the connection be
+/// attributed to a single, consistent surface instead of the runtime's own
+/// build. All fields are optional; an empty field is omitted from the
+/// handshake.
+///
+/// The struct is `#[non_exhaustive]`, so construct it with [`ClientInfo::new`]
+/// and the `with_*` builder methods rather than a struct literal. This lets the
+/// SDK add identity fields in future releases without a breaking change.
+#[derive(Debug, Clone, Default, PartialEq, Eq)]
+#[non_exhaustive]
+pub struct ClientInfo {
+ /// Name of the host editor, e.g. `"vscode"`.
+ pub editor_name: Option,
+ /// Version of the host editor, e.g. `"1.124.2"`.
+ pub editor_version: Option,
+ /// Name of the Copilot extension within the host, e.g. `"copilot-chat"`.
+ pub extension_name: Option,
+ /// Version of the Copilot extension within the host, e.g. `"0.54.0"`.
+ pub extension_version: Option,
+}
+
+impl ClientInfo {
+ /// Create an empty `ClientInfo`. Populate fields with the `with_*` builder
+ /// methods; every field is optional.
+ pub fn new() -> Self {
+ Self::default()
+ }
+
+ /// Set the host editor name, e.g. `"vscode"`.
+ pub fn with_editor_name(mut self, editor_name: impl Into) -> Self {
+ self.editor_name = Some(editor_name.into());
+ self
+ }
+
+ /// Set the host editor version, e.g. `"1.124.2"`.
+ pub fn with_editor_version(mut self, editor_version: impl Into) -> Self {
+ self.editor_version = Some(editor_version.into());
+ self
+ }
+
+ /// Set the Copilot extension name within the host, e.g. `"copilot-chat"`.
+ pub fn with_extension_name(mut self, extension_name: impl Into) -> Self {
+ self.extension_name = Some(extension_name.into());
+ self
+ }
+
+ /// Set the Copilot extension version within the host, e.g. `"0.54.0"`.
+ pub fn with_extension_version(mut self, extension_version: impl Into) -> Self {
+ self.extension_version = Some(extension_version.into());
+ self
+ }
+
+ /// Returns `true` when no field carries a non-empty value, in which case the
+ /// SDK omits `clientInfo` from the handshake and the runtime keeps its
+ /// default attribution.
+ fn is_empty(&self) -> bool {
+ Self::non_empty(&self.editor_name).is_none()
+ && Self::non_empty(&self.editor_version).is_none()
+ && Self::non_empty(&self.extension_name).is_none()
+ && Self::non_empty(&self.extension_version).is_none()
+ }
+
+ /// Clone the field only when it holds a non-empty string, so empty fields are
+ /// dropped from the handshake.
+ fn non_empty(value: &Option) -> Option {
+ value.as_ref().filter(|s| !s.is_empty()).cloned()
+ }
+
+ /// Map onto the generated connect wire shape, dropping empty fields. Returns
+ /// `None` when no field carries a non-empty value.
+ fn to_wire(&self) -> Option {
+ if self.is_empty() {
+ return None;
+ }
+ Some(crate::generated::api_types::ConnectClientInfo {
+ editor_name: Self::non_empty(&self.editor_name),
+ editor_version: Self::non_empty(&self.editor_version),
+ extension_name: Self::non_empty(&self.extension_name),
+ extension_version: Self::non_empty(&self.extension_version),
+ })
+ }
}
impl std::fmt::Debug for ClientOptions {
@@ -452,6 +545,7 @@ impl std::fmt::Debug for ClientOptions {
.field("base_directory", &self.base_directory)
.field("enable_remote_sessions", &self.enable_remote_sessions)
.field("bundled_cli_extract_dir", &self.bundled_cli_extract_dir)
+ .field("client_info", &self.client_info)
.finish()
}
}
@@ -701,6 +795,7 @@ impl Default for ClientOptions {
enable_remote_sessions: false,
bundled_cli_extract_dir: None,
mode: ClientMode::default(),
+ client_info: None,
}
}
}
@@ -931,6 +1026,14 @@ impl ClientOptions {
self.mode = mode;
self
}
+
+ /// Declare the integrating host's identity, forwarded to the runtime on
+ /// the `server.connect` handshake so its telemetry is attributed to a
+ /// consistent surface. See [`Self::client_info`].
+ pub fn with_client_info(mut self, client_info: ClientInfo) -> Self {
+ self.client_info = Some(client_info);
+ self
+ }
}
/// Validate a [`SessionFsConfig`] before sending `sessionFs.setProvider`.
@@ -1100,6 +1203,10 @@ struct ClientInner {
/// `None` for stdio and for external-server transport without an
/// explicit token.
effective_connection_token: Option,
+ /// Host identity forwarded on the `connect` handshake, set from
+ /// [`ClientOptions::client_info`]. `None` keeps the runtime's default
+ /// telemetry attribution.
+ client_info: Option,
/// SDK [`ClientMode`] captured at start time. Drives empty-mode safe
/// defaults inside `create_session` / `resume_session`.
pub(crate) mode: ClientMode,
@@ -1314,6 +1421,7 @@ impl Client {
options.on_github_telemetry,
effective_connection_token.clone(),
options.mode,
+ options.client_info,
)?
}
Transport::Tcp {
@@ -1347,6 +1455,7 @@ impl Client {
options.on_github_telemetry,
effective_connection_token.clone(),
options.mode,
+ options.client_info,
)?
}
Transport::Stdio => {
@@ -1370,6 +1479,7 @@ impl Client {
options.on_github_telemetry,
effective_connection_token.clone(),
options.mode,
+ options.client_info,
)?
}
Transport::InProcess => {
@@ -1437,6 +1547,7 @@ impl Client {
options.on_github_telemetry,
effective_connection_token.clone(),
options.mode,
+ options.client_info,
)?;
*client.inner.ffi_host.lock() = Some(shared);
client
@@ -1579,6 +1690,7 @@ impl Client {
None,
None,
ClientMode::default(),
+ None,
)
}
@@ -1606,6 +1718,7 @@ impl Client {
None,
None,
ClientMode::default(),
+ None,
)
}
@@ -1637,6 +1750,7 @@ impl Client {
None,
None,
ClientMode::default(),
+ None,
)
}
@@ -1664,6 +1778,7 @@ impl Client {
None,
token,
ClientMode::default(),
+ None,
)
}
@@ -1691,6 +1806,7 @@ impl Client {
Some(on_github_telemetry),
None,
ClientMode::default(),
+ None,
)
}
@@ -1704,6 +1820,35 @@ impl Client {
generate_connection_token()
}
+ /// Construct a [`Client`] from raw streams with a preset
+ /// [`ClientInfo`], for integration testing the `connect` handshake's
+ /// host-identity forwarding path.
+ #[doc(hidden)]
+ #[cfg(any(test, feature = "test-support"))]
+ pub fn from_streams_with_client_info(
+ reader: impl AsyncRead + Unpin + Send + 'static,
+ writer: impl AsyncWrite + Unpin + Send + 'static,
+ cwd: PathBuf,
+ client_info: Option,
+ ) -> Result {
+ Self::from_transport(
+ reader,
+ writer,
+ None,
+ None,
+ cwd,
+ None,
+ None,
+ false,
+ false,
+ None,
+ None,
+ None,
+ ClientMode::default(),
+ client_info,
+ )
+ }
+
#[allow(clippy::too_many_arguments)]
fn from_transport(
reader: impl AsyncRead + Unpin + Send + 'static,
@@ -1721,6 +1866,7 @@ impl Client {
on_github_telemetry: Option,
effective_connection_token: Option,
mode: ClientMode,
+ client_info: Option,
) -> Result {
let setup_start = Instant::now();
let (request_tx, request_rx) = mpsc::unbounded_channel::();
@@ -1766,6 +1912,7 @@ impl Client {
on_get_trace_context,
effective_connection_token,
mode,
+ client_info,
startup_timings: OnceLock::new(),
}),
};
@@ -2327,7 +2474,15 @@ impl Client {
.on_github_telemetry
.is_some()
.then_some(true),
- ..Default::default()
+ // Declare the integrating host's identity so the runtime attributes
+ // the telemetry it emits on this connection to a consistent surface
+ // instead of its own build. `None` when the app didn't supply it, and
+ // empty fields are dropped.
+ client_info: self
+ .inner
+ .client_info
+ .as_ref()
+ .and_then(ClientInfo::to_wire),
};
let value = self
.call(
@@ -3485,6 +3640,7 @@ mod tests {
None,
None,
ClientMode::default(),
+ None,
)
.unwrap();
@@ -3595,6 +3751,7 @@ mod tests {
on_get_trace_context: None,
effective_connection_token: None,
mode: ClientMode::default(),
+ client_info: None,
startup_timings: OnceLock::new(),
}),
}
diff --git a/rust/tests/protocol_version_test.rs b/rust/tests/protocol_version_test.rs
index 0d1268c59e..33b6c8d155 100644
--- a/rust/tests/protocol_version_test.rs
+++ b/rust/tests/protocol_version_test.rs
@@ -239,3 +239,153 @@ async fn connect_handshake_forwards_auto_generated_token() {
.unwrap()
.unwrap();
}
+
+/// Positive coverage for host-identity forwarding on the `connect`
+/// handshake. A client constructed with a [`ClientInfo`] MUST serialize it
+/// (camelCase) into the outbound `connect` request's `clientInfo` param so
+/// the runtime attributes this connection's telemetry to the host surface.
+#[tokio::test]
+async fn connect_handshake_forwards_client_info() {
+ let (client_write, server_read) = duplex(8192);
+ let (server_write, client_read) = duplex(8192);
+ let client = Client::from_streams_with_client_info(
+ client_read,
+ client_write,
+ std::env::temp_dir(),
+ Some(
+ github_copilot_sdk::ClientInfo::new()
+ .with_editor_name("JetBrains-IU")
+ .with_editor_version("2026.1")
+ .with_extension_name("copilot-intellij")
+ .with_extension_version("1.5.0"),
+ ),
+ )
+ .unwrap();
+
+ let mut server_read = server_read;
+ let mut server_write = server_write;
+
+ let verify_handle = tokio::spawn({
+ let client = client.clone();
+ async move { client.verify_protocol_version().await }
+ });
+
+ let req = read_framed(&mut server_read).await;
+ assert_eq!(req["method"], "connect");
+ let client_info = &req["params"]["clientInfo"];
+ assert_eq!(client_info["editorName"], "JetBrains-IU");
+ assert_eq!(client_info["editorVersion"], "2026.1");
+ assert_eq!(client_info["extensionName"], "copilot-intellij");
+ assert_eq!(client_info["extensionVersion"], "1.5.0");
+
+ let response = serde_json::json!({
+ "jsonrpc": "2.0",
+ "id": req["id"],
+ "result": { "ok": true, "protocolVersion": 3, "version": "test-1.0.0" },
+ });
+ write_framed(&mut server_write, &serde_json::to_vec(&response).unwrap()).await;
+
+ tokio::time::timeout(std::time::Duration::from_secs(2), verify_handle)
+ .await
+ .unwrap()
+ .unwrap()
+ .unwrap();
+}
+
+/// A [`ClientInfo`] with only some fields set must omit the empty ones from
+/// the wire, and a fully-empty one must drop `clientInfo` entirely so the
+/// runtime keeps its default attribution.
+#[tokio::test]
+async fn connect_handshake_omits_empty_client_info_fields() {
+ let (client_write, server_read) = duplex(8192);
+ let (server_write, client_read) = duplex(8192);
+ let client = Client::from_streams_with_client_info(
+ client_read,
+ client_write,
+ std::env::temp_dir(),
+ Some(
+ github_copilot_sdk::ClientInfo::new()
+ .with_editor_name("example-editor")
+ .with_editor_version(""),
+ ),
+ )
+ .unwrap();
+
+ let mut server_read = server_read;
+ let mut server_write = server_write;
+
+ let verify_handle = tokio::spawn({
+ let client = client.clone();
+ async move { client.verify_protocol_version().await }
+ });
+
+ let req = read_framed(&mut server_read).await;
+ assert_eq!(req["method"], "connect");
+ let client_info = &req["params"]["clientInfo"];
+ assert_eq!(client_info["editorName"], "example-editor");
+ assert!(client_info.get("editorVersion").is_none());
+ assert!(client_info.get("extensionName").is_none());
+ assert!(client_info.get("extensionVersion").is_none());
+
+ let response = serde_json::json!({
+ "jsonrpc": "2.0",
+ "id": req["id"],
+ "result": { "ok": true, "protocolVersion": 3, "version": "test-1.0.0" },
+ });
+ write_framed(&mut server_write, &serde_json::to_vec(&response).unwrap()).await;
+
+ tokio::time::timeout(std::time::Duration::from_secs(2), verify_handle)
+ .await
+ .unwrap()
+ .unwrap()
+ .unwrap();
+}
+
+/// A [`ClientInfo`] whose every field is empty must drop `clientInfo` from the
+/// handshake entirely so the runtime keeps its default attribution.
+#[tokio::test]
+async fn connect_handshake_omits_all_empty_client_info() {
+ let (client_write, server_read) = duplex(8192);
+ let (server_write, client_read) = duplex(8192);
+ let client = Client::from_streams_with_client_info(
+ client_read,
+ client_write,
+ std::env::temp_dir(),
+ Some(
+ github_copilot_sdk::ClientInfo::new()
+ .with_editor_name("")
+ .with_editor_version("")
+ .with_extension_name("")
+ .with_extension_version(""),
+ ),
+ )
+ .unwrap();
+
+ let mut server_read = server_read;
+ let mut server_write = server_write;
+
+ let verify_handle = tokio::spawn({
+ let client = client.clone();
+ async move { client.verify_protocol_version().await }
+ });
+
+ let req = read_framed(&mut server_read).await;
+ assert_eq!(req["method"], "connect");
+ assert!(
+ req["params"].get("clientInfo").is_none(),
+ "an all-empty clientInfo must be omitted from the handshake"
+ );
+
+ let response = serde_json::json!({
+ "jsonrpc": "2.0",
+ "id": req["id"],
+ "result": { "ok": true, "protocolVersion": 3, "version": "test-1.0.0" },
+ });
+ write_framed(&mut server_write, &serde_json::to_vec(&response).unwrap()).await;
+
+ tokio::time::timeout(std::time::Duration::from_secs(2), verify_handle)
+ .await
+ .unwrap()
+ .unwrap()
+ .unwrap();
+}