diff --git a/dotnet/src/Client.cs b/dotnet/src/Client.cs index f000be80..8ae7ddc9 100644 --- a/dotnet/src/Client.cs +++ b/dotnet/src/Client.cs @@ -371,6 +371,7 @@ public async Task CreateSessionAsync(SessionConfig? config = nul var request = new CreateSessionRequest( config?.Model, config?.SessionId, + config?.ClientName, config?.ReasoningEffort, config?.Tools?.Select(ToolDefinition.FromAIFunction).ToList(), config?.SystemMessage, @@ -454,6 +455,7 @@ public async Task ResumeSessionAsync(string sessionId, ResumeSes var request = new ResumeSessionRequest( sessionId, + config?.ClientName, config?.Model, config?.ReasoningEffort, config?.Tools?.Select(ToolDefinition.FromAIFunction).ToList(), @@ -1375,6 +1377,7 @@ public static string Escape(string arg) internal record CreateSessionRequest( string? Model, string? SessionId, + string? ClientName, string? ReasoningEffort, List? Tools, SystemMessageConfig? SystemMessage, @@ -1409,6 +1412,7 @@ internal record CreateSessionResponse( internal record ResumeSessionRequest( string SessionId, + string? ClientName, string? Model, string? ReasoningEffort, List? Tools, diff --git a/dotnet/src/Types.cs b/dotnet/src/Types.cs index 50e39ff7..2afdd20f 100644 --- a/dotnet/src/Types.cs +++ b/dotnet/src/Types.cs @@ -745,6 +745,7 @@ protected SessionConfig(SessionConfig? other) if (other is null) return; AvailableTools = other.AvailableTools is not null ? [.. other.AvailableTools] : null; + ClientName = other.ClientName; ConfigDir = other.ConfigDir; CustomAgents = other.CustomAgents is not null ? [.. other.CustomAgents] : null; DisabledSkills = other.DisabledSkills is not null ? [.. other.DisabledSkills] : null; @@ -768,6 +769,13 @@ protected SessionConfig(SessionConfig? other) } public string? SessionId { get; set; } + + /// + /// Client name to identify the application using the SDK. + /// Included in the User-Agent header for API requests. + /// + public string? ClientName { get; set; } + public string? Model { get; set; } /// @@ -874,6 +882,7 @@ protected ResumeSessionConfig(ResumeSessionConfig? other) if (other is null) return; AvailableTools = other.AvailableTools is not null ? [.. other.AvailableTools] : null; + ClientName = other.ClientName; ConfigDir = other.ConfigDir; CustomAgents = other.CustomAgents is not null ? [.. other.CustomAgents] : null; DisabledSkills = other.DisabledSkills is not null ? [.. other.DisabledSkills] : null; @@ -896,6 +905,12 @@ protected ResumeSessionConfig(ResumeSessionConfig? other) WorkingDirectory = other.WorkingDirectory; } + /// + /// Client name to identify the application using the SDK. + /// Included in the User-Agent header for API requests. + /// + public string? ClientName { get; set; } + /// /// Model to use for this session. Can change the model when resuming. /// diff --git a/dotnet/test/CloneTests.cs b/dotnet/test/CloneTests.cs index 10ad0205..45eaaae1 100644 --- a/dotnet/test/CloneTests.cs +++ b/dotnet/test/CloneTests.cs @@ -78,6 +78,7 @@ public void SessionConfig_Clone_CopiesAllProperties() var original = new SessionConfig { SessionId = "test-session", + ClientName = "my-app", Model = "gpt-4", ReasoningEffort = "high", ConfigDir = "/config", @@ -94,6 +95,7 @@ public void SessionConfig_Clone_CopiesAllProperties() var clone = original.Clone(); Assert.Equal(original.SessionId, clone.SessionId); + Assert.Equal(original.ClientName, clone.ClientName); Assert.Equal(original.Model, clone.Model); Assert.Equal(original.ReasoningEffort, clone.ReasoningEffort); Assert.Equal(original.ConfigDir, clone.ConfigDir); diff --git a/go/client.go b/go/client.go index 77a9eeed..81d9a206 100644 --- a/go/client.go +++ b/go/client.go @@ -455,6 +455,7 @@ func (c *Client) CreateSession(ctx context.Context, config *SessionConfig) (*Ses if config != nil { req.Model = config.Model req.SessionID = config.SessionID + req.ClientName = config.ClientName req.ReasoningEffort = config.ReasoningEffort req.ConfigDir = config.ConfigDir req.Tools = config.Tools @@ -552,6 +553,7 @@ func (c *Client) ResumeSessionWithOptions(ctx context.Context, sessionID string, var req resumeSessionRequest req.SessionID = sessionID if config != nil { + req.ClientName = config.ClientName req.Model = config.Model req.ReasoningEffort = config.ReasoningEffort req.SystemMessage = config.SystemMessage diff --git a/go/client_test.go b/go/client_test.go index 176dad8c..b2e9cdce 100644 --- a/go/client_test.go +++ b/go/client_test.go @@ -1,6 +1,7 @@ package copilot import ( + "encoding/json" "os" "path/filepath" "reflect" @@ -389,3 +390,57 @@ func fileExistsForTest(path string) bool { _, err := os.Stat(path) return err == nil } + +func TestCreateSessionRequest_ClientName(t *testing.T) { + t.Run("includes clientName in JSON when set", func(t *testing.T) { + req := createSessionRequest{ClientName: "my-app"} + data, err := json.Marshal(req) + if err != nil { + t.Fatalf("Failed to marshal: %v", err) + } + var m map[string]any + if err := json.Unmarshal(data, &m); err != nil { + t.Fatalf("Failed to unmarshal: %v", err) + } + if m["clientName"] != "my-app" { + t.Errorf("Expected clientName to be 'my-app', got %v", m["clientName"]) + } + }) + + t.Run("omits clientName from JSON when empty", func(t *testing.T) { + req := createSessionRequest{} + data, _ := json.Marshal(req) + var m map[string]any + json.Unmarshal(data, &m) + if _, ok := m["clientName"]; ok { + t.Error("Expected clientName to be omitted when empty") + } + }) +} + +func TestResumeSessionRequest_ClientName(t *testing.T) { + t.Run("includes clientName in JSON when set", func(t *testing.T) { + req := resumeSessionRequest{SessionID: "s1", ClientName: "my-app"} + data, err := json.Marshal(req) + if err != nil { + t.Fatalf("Failed to marshal: %v", err) + } + var m map[string]any + if err := json.Unmarshal(data, &m); err != nil { + t.Fatalf("Failed to unmarshal: %v", err) + } + if m["clientName"] != "my-app" { + t.Errorf("Expected clientName to be 'my-app', got %v", m["clientName"]) + } + }) + + t.Run("omits clientName from JSON when empty", func(t *testing.T) { + req := resumeSessionRequest{SessionID: "s1"} + data, _ := json.Marshal(req) + var m map[string]any + json.Unmarshal(data, &m) + if _, ok := m["clientName"]; ok { + t.Error("Expected clientName to be omitted when empty") + } + }) +} diff --git a/go/types.go b/go/types.go index b9b649b6..9c6a257a 100644 --- a/go/types.go +++ b/go/types.go @@ -330,6 +330,9 @@ type InfiniteSessionConfig struct { type SessionConfig struct { // SessionID is an optional custom session ID SessionID string + // ClientName identifies the application using the SDK. + // Included in the User-Agent header for API requests. + ClientName string // Model to use for this session Model string // ReasoningEffort level for models that support it. @@ -409,6 +412,9 @@ type ToolResult struct { // ResumeSessionConfig configures options when resuming a session type ResumeSessionConfig struct { + // ClientName identifies the application using the SDK. + // Included in the User-Agent header for API requests. + ClientName string // Model to use for this session. Can change the model when resuming. Model string // Tools exposes caller-implemented tools to the CLI @@ -626,6 +632,7 @@ type permissionRequestResponse struct { type createSessionRequest struct { Model string `json:"model,omitempty"` SessionID string `json:"sessionId,omitempty"` + ClientName string `json:"clientName,omitempty"` ReasoningEffort string `json:"reasoningEffort,omitempty"` Tools []Tool `json:"tools,omitempty"` SystemMessage *SystemMessageConfig `json:"systemMessage,omitempty"` @@ -655,6 +662,7 @@ type createSessionResponse struct { // resumeSessionRequest is the request for session.resume type resumeSessionRequest struct { SessionID string `json:"sessionId"` + ClientName string `json:"clientName,omitempty"` Model string `json:"model,omitempty"` ReasoningEffort string `json:"reasoningEffort,omitempty"` Tools []Tool `json:"tools,omitempty"` diff --git a/nodejs/src/client.ts b/nodejs/src/client.ts index 5d741314..d95e82cc 100644 --- a/nodejs/src/client.ts +++ b/nodejs/src/client.ts @@ -513,6 +513,7 @@ export class CopilotClient { const response = await this.connection!.sendRequest("session.create", { model: config.model, sessionId: config.sessionId, + clientName: config.clientName, reasoningEffort: config.reasoningEffort, tools: config.tools?.map((tool) => ({ name: tool.name, @@ -594,6 +595,7 @@ export class CopilotClient { const response = await this.connection!.sendRequest("session.resume", { sessionId, + clientName: config.clientName, model: config.model, reasoningEffort: config.reasoningEffort, systemMessage: config.systemMessage, diff --git a/nodejs/src/types.ts b/nodejs/src/types.ts index c2806804..16971e30 100644 --- a/nodejs/src/types.ts +++ b/nodejs/src/types.ts @@ -615,6 +615,12 @@ export interface SessionConfig { */ sessionId?: string; + /** + * Client name to identify the application using the SDK. + * Included in the User-Agent header for API requests. + */ + clientName?: string; + /** * Model to use for this session */ @@ -730,6 +736,7 @@ export interface SessionConfig { */ export type ResumeSessionConfig = Pick< SessionConfig, + | "clientName" | "model" | "tools" | "systemMessage" diff --git a/nodejs/test/client.test.ts b/nodejs/test/client.test.ts index 4b9b512c..5d1ed8ac 100644 --- a/nodejs/test/client.test.ts +++ b/nodejs/test/client.test.ts @@ -1,5 +1,5 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ -import { describe, expect, it, onTestFinished } from "vitest"; +import { describe, expect, it, onTestFinished, vi } from "vitest"; import { CopilotClient } from "../src/index.js"; // This file is for unit tests. Where relevant, prefer to add e2e tests in e2e/*.test.ts instead @@ -27,6 +27,35 @@ describe("CopilotClient", () => { }); }); + it("forwards clientName in session.create request", async () => { + const client = new CopilotClient(); + await client.start(); + onTestFinished(() => client.forceStop()); + + const spy = vi.spyOn((client as any).connection!, "sendRequest"); + await client.createSession({ clientName: "my-app" }); + + expect(spy).toHaveBeenCalledWith( + "session.create", + expect.objectContaining({ clientName: "my-app" }) + ); + }); + + it("forwards clientName in session.resume request", async () => { + const client = new CopilotClient(); + await client.start(); + onTestFinished(() => client.forceStop()); + + const session = await client.createSession(); + const spy = vi.spyOn((client as any).connection!, "sendRequest"); + await client.resumeSession(session.sessionId, { clientName: "my-app" }); + + expect(spy).toHaveBeenCalledWith( + "session.resume", + expect.objectContaining({ clientName: "my-app", sessionId: session.sessionId }) + ); + }); + describe("URL parsing", () => { it("should parse port-only URL format", () => { const client = new CopilotClient({ diff --git a/python/copilot/client.py b/python/copilot/client.py index 99154f43..aad27232 100644 --- a/python/copilot/client.py +++ b/python/copilot/client.py @@ -467,6 +467,8 @@ async def create_session(self, config: Optional[SessionConfig] = None) -> Copilo payload["model"] = cfg["model"] if cfg.get("session_id"): payload["sessionId"] = cfg["session_id"] + if cfg.get("client_name"): + payload["clientName"] = cfg["client_name"] if cfg.get("reasoning_effort"): payload["reasoningEffort"] = cfg["reasoning_effort"] if tool_defs: @@ -629,6 +631,11 @@ async def resume_session( payload: dict[str, Any] = {"sessionId": session_id} + # Add client name if provided + client_name = cfg.get("client_name") + if client_name: + payload["clientName"] = client_name + # Add model if provided model = cfg.get("model") if model: diff --git a/python/copilot/types.py b/python/copilot/types.py index 0f127d44..a58051d6 100644 --- a/python/copilot/types.py +++ b/python/copilot/types.py @@ -462,6 +462,9 @@ class SessionConfig(TypedDict, total=False): """Configuration for creating a session""" session_id: str # Optional custom session ID + # Client name to identify the application using the SDK. + # Included in the User-Agent header for API requests. + client_name: str model: str # Model to use for this session. Use client.list_models() to see available models. # Reasoning effort level for models that support it. # Only valid for models where capabilities.supports.reasoning_effort is True. @@ -529,6 +532,9 @@ class ProviderConfig(TypedDict, total=False): class ResumeSessionConfig(TypedDict, total=False): """Configuration for resuming a session""" + # Client name to identify the application using the SDK. + # Included in the User-Agent header for API requests. + client_name: str # Model to use for this session. Can change the model when resuming. model: str tools: list[Tool] diff --git a/python/test_client.py b/python/test_client.py index 7b4af8c0..0bc99ea6 100644 --- a/python/test_client.py +++ b/python/test_client.py @@ -147,3 +147,45 @@ def test_use_logged_in_user_with_cli_url_raises(self): CopilotClient( {"cli_url": "localhost:8080", "use_logged_in_user": False, "log_level": "error"} ) + + +class TestSessionConfigForwarding: + @pytest.mark.asyncio + async def test_create_session_forwards_client_name(self): + client = CopilotClient({"cli_path": CLI_PATH}) + await client.start() + + try: + captured = {} + original_request = client._client.request + + async def mock_request(method, params): + captured[method] = params + return await original_request(method, params) + + client._client.request = mock_request + await client.create_session({"client_name": "my-app"}) + assert captured["session.create"]["clientName"] == "my-app" + finally: + await client.force_stop() + + @pytest.mark.asyncio + async def test_resume_session_forwards_client_name(self): + client = CopilotClient({"cli_path": CLI_PATH}) + await client.start() + + try: + session = await client.create_session() + + captured = {} + original_request = client._client.request + + async def mock_request(method, params): + captured[method] = params + return await original_request(method, params) + + client._client.request = mock_request + await client.resume_session(session.session_id, {"client_name": "my-app"}) + assert captured["session.resume"]["clientName"] == "my-app" + finally: + await client.force_stop()