diff --git a/dotnet/src/Client.cs b/dotnet/src/Client.cs index 8c61c73..619146a 100644 --- a/dotnet/src/Client.cs +++ b/dotnet/src/Client.cs @@ -343,7 +343,8 @@ public async Task CreateSessionAsync(SessionConfig? config = nul config?.OnPermissionRequest != null ? true : null, config?.Streaming == true ? true : null, config?.McpServers, - config?.CustomAgents); + config?.CustomAgents, + config?.ConfigDir); var response = await connection.Rpc.InvokeWithCancellationAsync( "session.create", [request], cancellationToken); @@ -922,7 +923,8 @@ private record CreateSessionRequest( bool? RequestPermission, bool? Streaming, Dictionary? McpServers, - List? CustomAgents); + List? CustomAgents, + string? ConfigDir); private record ToolDefinition( string Name, diff --git a/dotnet/src/Types.cs b/dotnet/src/Types.cs index a5dc553..0a4bd4f 100644 --- a/dotnet/src/Types.cs +++ b/dotnet/src/Types.cs @@ -293,6 +293,13 @@ public class SessionConfig { public string? SessionId { get; set; } public string? Model { get; set; } + + /// + /// Override the default configuration directory location. + /// When specified, the session will use this directory for storing config and state. + /// + public string? ConfigDir { get; set; } + public ICollection? Tools { get; set; } public SystemMessageConfig? SystemMessage { get; set; } public List? AvailableTools { get; set; } diff --git a/dotnet/test/SessionTests.cs b/dotnet/test/SessionTests.cs index e72fe27..d128ead 100644 --- a/dotnet/test/SessionTests.cs +++ b/dotnet/test/SessionTests.cs @@ -322,4 +322,25 @@ public async Task Should_Receive_Session_Events() await session.DisposeAsync(); } + + [Fact] + public async Task Should_Create_Session_With_Custom_Config_Dir() + { + var customConfigDir = Path.Combine(Ctx.HomeDir, "custom-config"); + var session = await Client.CreateSessionAsync(new SessionConfig { ConfigDir = customConfigDir }); + + Assert.Matches(@"^[a-f0-9-]+$", session.SessionId); + + // Verify config.json was written to the custom config dir + var configPath = Path.Combine(customConfigDir, "github-copilot", "config.json"); + Assert.True(File.Exists(configPath), $"Expected config.json at {configPath}"); + var configContent = System.Text.Json.JsonDocument.Parse(await File.ReadAllTextAsync(configPath)); + Assert.Equal(session.SessionId, configContent.RootElement.GetProperty("sessionId").GetString()); + + // Session should work normally with custom config dir + await session.SendAsync(new MessageOptions { Prompt = "What is 1+1?" }); + var assistantMessage = await TestHelper.GetFinalAssistantMessageAsync(session); + Assert.NotNull(assistantMessage); + Assert.Contains("2", assistantMessage!.Data.Content); + } } diff --git a/go/client.go b/go/client.go index ca06335..4b70c33 100644 --- a/go/client.go +++ b/go/client.go @@ -534,6 +534,10 @@ func (c *Client) CreateSession(config *SessionConfig) (*Session, error) { } params["customAgents"] = customAgents } + // Add config directory override + if config.ConfigDir != "" { + params["configDir"] = config.ConfigDir + } } result, err := c.client.Request("session.create", params) diff --git a/go/e2e/session_test.go b/go/e2e/session_test.go index 02cea5b..9676f41 100644 --- a/go/e2e/session_test.go +++ b/go/e2e/session_test.go @@ -1,6 +1,8 @@ package e2e import ( + "encoding/json" + "os" "regexp" "strings" "testing" @@ -687,6 +689,52 @@ func TestSession(t *testing.T) { t.Errorf("Expected assistant message to contain '300', got %v", assistantMessage.Data.Content) } }) + + t.Run("should create session with custom config dir", func(t *testing.T) { + ctx.ConfigureForTest(t) + + customConfigDir := ctx.HomeDir + "/custom-config" + session, err := client.CreateSession(&copilot.SessionConfig{ + ConfigDir: customConfigDir, + }) + if err != nil { + t.Fatalf("Failed to create session with custom config dir: %v", err) + } + + matched, _ := regexp.MatchString(`^[a-f0-9-]+$`, session.SessionID) + if !matched { + t.Errorf("Expected session ID to match UUID pattern, got %q", session.SessionID) + } + + // Verify config.json was written to the custom config dir + configPath := customConfigDir + "/github-copilot/config.json" + configData, err := os.ReadFile(configPath) + if err != nil { + t.Fatalf("Failed to read config.json at %s: %v", configPath, err) + } + var configContent map[string]interface{} + if err := json.Unmarshal(configData, &configContent); err != nil { + t.Fatalf("Failed to parse config.json: %v", err) + } + if configContent["sessionId"] != session.SessionID { + t.Errorf("Expected config.json sessionId to be %q, got %v", session.SessionID, configContent["sessionId"]) + } + + // Session should work normally with custom config dir + _, err = session.Send(copilot.MessageOptions{Prompt: "What is 1+1?"}) + if err != nil { + t.Fatalf("Failed to send message: %v", err) + } + + assistantMessage, err := testharness.GetFinalAssistantMessage(session, 60*time.Second) + if err != nil { + t.Fatalf("Failed to get assistant message: %v", err) + } + + if assistantMessage.Data.Content == nil || !strings.Contains(*assistantMessage.Data.Content, "2") { + t.Errorf("Expected assistant message to contain '2', got %v", assistantMessage.Data.Content) + } + }) } func getSystemMessage(exchange testharness.ParsedHttpExchange) string { diff --git a/go/types.go b/go/types.go index d488320..7334dcb 100644 --- a/go/types.go +++ b/go/types.go @@ -144,6 +144,9 @@ type SessionConfig struct { SessionID string // Model to use for this session Model string + // ConfigDir overrides the default configuration directory location. + // When specified, the session will use this directory for storing config and state. + ConfigDir string // Tools exposes caller-implemented tools to the CLI Tools []Tool // SystemMessage configures system message customization diff --git a/nodejs/src/client.ts b/nodejs/src/client.ts index f00821a..b7d607a 100644 --- a/nodejs/src/client.ts +++ b/nodejs/src/client.ts @@ -446,6 +446,7 @@ export class CopilotClient { streaming: config.streaming, mcpServers: config.mcpServers, customAgents: config.customAgents, + configDir: config.configDir, }); const sessionId = (response as { sessionId: string }).sessionId; diff --git a/nodejs/src/types.ts b/nodejs/src/types.ts index 6c20cfb..a57a9e5 100644 --- a/nodejs/src/types.ts +++ b/nodejs/src/types.ts @@ -324,6 +324,12 @@ export interface SessionConfig { */ model?: string; + /** + * Override the default configuration directory location. + * When specified, the session will use this directory for storing config and state. + */ + configDir?: string; + /** * Tools exposed to the CLI server */ diff --git a/nodejs/test/e2e/session.test.ts b/nodejs/test/e2e/session.test.ts index a25f00c..a39db1d 100644 --- a/nodejs/test/e2e/session.test.ts +++ b/nodejs/test/e2e/session.test.ts @@ -1,3 +1,4 @@ +import * as fs from "node:fs"; import { describe, expect, it, onTestFinished } from "vitest"; import { ParsedHttpExchange } from "../../../test/harness/replayingCapiProxy.js"; import { CopilotClient } from "../../src/index.js"; @@ -334,6 +335,26 @@ describe("Sessions", async () => { const assistantMessage = await getFinalAssistantMessage(session); expect(assistantMessage.data.content).toContain("300"); }); + + it("should create session with custom config dir", async () => { + const customConfigDir = `${homeDir}/custom-config`; + const session = await client.createSession({ + configDir: customConfigDir, + }); + + expect(session.sessionId).toMatch(/^[a-f0-9-]+$/); + + // Verify config.json was written to the custom config dir + const configPath = `${customConfigDir}/github-copilot/config.json`; + expect(fs.existsSync(configPath)).toBe(true); + const configContent = JSON.parse(fs.readFileSync(configPath, "utf-8")); + expect(configContent).toHaveProperty("sessionId", session.sessionId); + + // Session should work normally with custom config dir + await session.send({ prompt: "What is 1+1?" }); + const assistantMessage = await getFinalAssistantMessage(session); + expect(assistantMessage.data.content).toContain("2"); + }); }); function getSystemMessage(exchange: ParsedHttpExchange): string | undefined { diff --git a/python/copilot/client.py b/python/copilot/client.py index 0828e6e..6aae705 100644 --- a/python/copilot/client.py +++ b/python/copilot/client.py @@ -400,6 +400,11 @@ async def create_session(self, config: Optional[SessionConfig] = None) -> Copilo self._convert_custom_agent_to_wire_format(agent) for agent in custom_agents ] + # Add config directory override if provided + config_dir = cfg.get("config_dir") + if config_dir: + payload["configDir"] = config_dir + if not self._client: raise RuntimeError("Client not connected") response = await self._client.request("session.create", payload) diff --git a/python/copilot/types.py b/python/copilot/types.py index 782bc20..1e1dabc 100644 --- a/python/copilot/types.py +++ b/python/copilot/types.py @@ -216,6 +216,9 @@ class SessionConfig(TypedDict, total=False): mcp_servers: Dict[str, MCPServerConfig] # Custom agent configurations for the session custom_agents: List[CustomAgentConfig] + # Override the default configuration directory location. + # When specified, the session will use this directory for storing config and state. + config_dir: str # Azure-specific provider options diff --git a/python/e2e/test_session.py b/python/e2e/test_session.py index 30d24f6..f47f90c 100644 --- a/python/e2e/test_session.py +++ b/python/e2e/test_session.py @@ -360,6 +360,27 @@ def on_event(event): assistant_message = await get_final_assistant_message(session) assert "300" in assistant_message.data.content + async def test_should_create_session_with_custom_config_dir(self, ctx: E2ETestContext): + import json + import os + + custom_config_dir = os.path.join(ctx.home_dir, "custom-config") + session = await ctx.client.create_session({"config_dir": custom_config_dir}) + + assert session.session_id + + # Verify config.json was written to the custom config dir + config_path = os.path.join(custom_config_dir, "github-copilot", "config.json") + assert os.path.exists(config_path), f"Expected config.json at {config_path}" + with open(config_path) as f: + config_content = json.load(f) + assert config_content.get("sessionId") == session.session_id + + # Session should work normally with custom config dir + await session.send({"prompt": "What is 1+1?"}) + assistant_message = await get_final_assistant_message(session) + assert "2" in assistant_message.data.content + def _get_system_message(exchange: dict) -> str: messages = exchange.get("request", {}).get("messages", []) diff --git a/test/snapshots/session/should_create_session_with_custom_config_dir.yaml b/test/snapshots/session/should_create_session_with_custom_config_dir.yaml new file mode 100644 index 0000000..2504021 --- /dev/null +++ b/test/snapshots/session/should_create_session_with_custom_config_dir.yaml @@ -0,0 +1,10 @@ +models: + - claude-sonnet-4.5 +conversations: + - messages: + - role: system + content: ${system} + - role: user + content: What is 1+1? + - role: assistant + content: 1 + 1 = 2