From 1eddcfbd2372189e87fc884d21099d0413b75bd9 Mon Sep 17 00:00:00 2001 From: Tim Rogers Date: Wed, 19 Aug 2026 10:39:51 -0700 Subject: [PATCH] Add customAgentDirectories session config option Add a `customAgentDirectories` option to the session config across all six SDK languages, mirroring the existing `instructionDirectories` / `skillDirectories` passthrough options. It accepts a list of directory paths that the CLI searches for custom agent definition files, forwarded on both session create and resume. - nodejs: SessionConfigBase.customAgentDirectories + create/resume forwarding - go: SessionConfig/ResumeSessionConfig + request structs + forwarding - python: create_session/resume_session custom_agent_directories param - dotnet: SessionConfig.CustomAgentDirectories + Clone + create/resume/update - java: SessionConfig/ResumeSessionConfig + request builders - rust: SessionConfig::with_custom_agent_directories + create/resume wire Unit tests verify the option is forwarded on the session.create and session.resume JSON-RPC requests in every language. End-to-end tests (with handcrafted replay-proxy snapshots) additionally assert that a custom agent placed in a `customAgentDirectories` path is discovered by the CLI and surfaced in the task tool's `agent_type` enum, on both create and resume. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: d4c94c34-dda4-49a1-b876-687fc771d0e0 --- dotnet/src/Client.cs | 4 + dotnet/src/Types.cs | 4 + dotnet/test/E2E/SessionConfigE2ETests.cs | 58 +++++++++++ dotnet/test/Unit/CloneTests.cs | 9 ++ dotnet/test/Unit/SerializationTests.cs | 33 +++++++ go/client.go | 2 + go/client_test.go | 59 ++++++++++++ go/internal/e2e/session_config_e2e_test.go | 95 +++++++++++++++++++ go/types.go | 6 ++ .../github/copilot/SessionRequestBuilder.java | 2 + .../copilot/rpc/CreateSessionRequest.java | 15 +++ .../copilot/rpc/ResumeSessionConfig.java | 25 +++++ .../copilot/rpc/ResumeSessionRequest.java | 15 +++ .../com/github/copilot/rpc/SessionConfig.java | 25 +++++ .../com/github/copilot/ConfigCloneTest.java | 2 + .../github/copilot/SessionConfigE2ETest.java | 56 +++++++++++ .../copilot/SessionRequestBuilderTest.java | 24 +++++ nodejs/src/client.ts | 2 + nodejs/src/types.ts | 5 + nodejs/test/client.test.ts | 46 +++++++++ nodejs/test/e2e/session_config.e2e.test.ts | 57 +++++++++++ python/copilot/client.py | 10 ++ python/e2e/test_session_config_e2e.py | 57 +++++++++++ python/test_client.py | 59 ++++++++++++ rust/src/types.rs | 79 +++++++++++++++ rust/src/wire.rs | 4 + rust/tests/e2e/client_options.rs | 4 + ...ly_custom_agent_directories_on_create.yaml | 10 ++ ...ly_custom_agent_directories_on_resume.yaml | 10 ++ ...pply_customagentdirectories_on_create.yaml | 10 ++ ...pply_customagentdirectories_on_resume.yaml | 10 ++ ...tomagentdirectories_on_session_create.yaml | 10 ++ ...tomagentdirectories_on_session_resume.yaml | 10 ++ 33 files changed, 817 insertions(+) create mode 100644 test/snapshots/session_config/should_apply_custom_agent_directories_on_create.yaml create mode 100644 test/snapshots/session_config/should_apply_custom_agent_directories_on_resume.yaml create mode 100644 test/snapshots/session_config/should_apply_customagentdirectories_on_create.yaml create mode 100644 test/snapshots/session_config/should_apply_customagentdirectories_on_resume.yaml create mode 100644 test/snapshots/session_config/should_apply_customagentdirectories_on_session_create.yaml create mode 100644 test/snapshots/session_config/should_apply_customagentdirectories_on_session_resume.yaml diff --git a/dotnet/src/Client.cs b/dotnet/src/Client.cs index 58c1074c08..2e57814dec 100644 --- a/dotnet/src/Client.cs +++ b/dotnet/src/Client.cs @@ -1225,6 +1225,7 @@ public async Task CreateSessionAsync(SessionConfig config, Cance RemoteSession: config.RemoteSession, Cloud: config.Cloud, InstructionDirectories: config.InstructionDirectories, + CustomAgentDirectories: config.CustomAgentDirectories, PluginDirectories: config.PluginDirectories, DisabledMcpServers: config.DisabledMcpServers, LargeOutput: config.LargeOutput, @@ -1446,6 +1447,7 @@ public async Task ResumeSessionAsync(string sessionId, ResumeSes RemoteSession: config.RemoteSession, ContinuePendingWork: config.ContinuePendingWork, InstructionDirectories: config.InstructionDirectories, + CustomAgentDirectories: config.CustomAgentDirectories, PluginDirectories: config.PluginDirectories, DisabledMcpServers: config.DisabledMcpServers, LargeOutput: config.LargeOutput, @@ -2805,6 +2807,7 @@ internal record CreateSessionRequest( RemoteSessionMode? RemoteSession = null, CloudSessionOptions? Cloud = null, IList? InstructionDirectories = null, + IList? CustomAgentDirectories = null, IList? PluginDirectories = null, [property: JsonPropertyName("disabledMcpServers")] IList? DisabledMcpServers = null, LargeToolOutputConfig? LargeOutput = null, @@ -2920,6 +2923,7 @@ internal record ResumeSessionRequest( RemoteSessionMode? RemoteSession = null, bool? ContinuePendingWork = null, IList? InstructionDirectories = null, + IList? CustomAgentDirectories = null, IList? PluginDirectories = null, [property: JsonPropertyName("disabledMcpServers")] IList? DisabledMcpServers = null, LargeToolOutputConfig? LargeOutput = null, diff --git a/dotnet/src/Types.cs b/dotnet/src/Types.cs index c0810b3870..ed44c9e0b1 100644 --- a/dotnet/src/Types.cs +++ b/dotnet/src/Types.cs @@ -3222,6 +3222,7 @@ protected SessionConfigBase(SessionConfigBase? other) SkillDirectories = other.SkillDirectories is not null ? [.. other.SkillDirectories] : null; PluginDirectories = other.PluginDirectories is not null ? [.. other.PluginDirectories] : null; InstructionDirectories = other.InstructionDirectories is not null ? [.. other.InstructionDirectories] : null; + CustomAgentDirectories = other.CustomAgentDirectories is not null ? [.. other.CustomAgentDirectories] : null; SessionLimits = other.SessionLimits; Streaming = other.Streaming; IncludeSubAgentStreamingEvents = other.IncludeSubAgentStreamingEvents; @@ -3583,6 +3584,9 @@ protected SessionConfigBase(SessionConfigBase? other) /// Additional directories to search for custom instruction files. public IList? InstructionDirectories { get; set; } + /// Additional directories to search for custom agent files. + public IList? CustomAgentDirectories { get; set; } + /// List of skill names to disable. public IList? DisabledSkills { get; set; } diff --git a/dotnet/test/E2E/SessionConfigE2ETests.cs b/dotnet/test/E2E/SessionConfigE2ETests.cs index 1bc4c52eb9..c93deb233e 100644 --- a/dotnet/test/E2E/SessionConfigE2ETests.cs +++ b/dotnet/test/E2E/SessionConfigE2ETests.cs @@ -446,6 +446,64 @@ await File.WriteAllTextAsync( await session2.DisposeAsync(); } + [Fact] + public async Task Should_Apply_CustomAgentDirectories_On_Create() + { + var projectDir = Path.Join(Ctx.WorkDir, "agent-create-project"); + var agentDir = Path.Join(Ctx.WorkDir, "extra-create-agents"); + Directory.CreateDirectory(projectDir); + Directory.CreateDirectory(agentDir); + await File.WriteAllTextAsync( + Path.Join(agentDir, "reviewer.agent.md"), + "---\nname: reviewer\ndescription: Reviews code carefully.\n---\nYou review code carefully."); + + var session = await CreateSessionAsync(new SessionConfig + { + WorkingDirectory = projectDir, + CustomAgentDirectories = [agentDir], + }); + + await session.SendAndWaitAsync(new MessageOptions { Prompt = "What is 1+1?" }); + + var exchanges = await Ctx.GetExchangesAsync(); + Assert.NotEmpty(exchanges); + Assert.Contains("reviewer", GetTaskAgentTypes(exchanges[^1])); + + await session.DisposeAsync(); + } + + [Fact] + public async Task Should_Apply_CustomAgentDirectories_On_Resume() + { + var projectDir = Path.Join(Ctx.WorkDir, "agent-resume-project"); + var agentDir = Path.Join(Ctx.WorkDir, "extra-resume-agents"); + Directory.CreateDirectory(projectDir); + Directory.CreateDirectory(agentDir); + await File.WriteAllTextAsync( + Path.Join(agentDir, "reviewer.agent.md"), + "---\nname: reviewer\ndescription: Reviews code carefully.\n---\nYou review code carefully."); + + await using var session1 = await CreateSessionAsync(new SessionConfig + { + WorkingDirectory = projectDir, + }); + var sessionId = session1.SessionId; + await SuspendAndUntrackSessionForResumeAsync(session1); + var session2 = await ResumeSessionAsync(sessionId, new ResumeSessionConfig + { + WorkingDirectory = projectDir, + CustomAgentDirectories = [agentDir], + }); + + await session2.SendAndWaitAsync(new MessageOptions { Prompt = "What is 1+1?" }); + + var exchanges = await Ctx.GetExchangesAsync(); + Assert.NotEmpty(exchanges); + Assert.Contains("reviewer", GetTaskAgentTypes(exchanges[^1])); + + await session2.DisposeAsync(); + } + [Fact] public async Task Should_Apply_AvailableTools_On_Session_Resume() { diff --git a/dotnet/test/Unit/CloneTests.cs b/dotnet/test/Unit/CloneTests.cs index 4bacdfe33d..98040a1e36 100644 --- a/dotnet/test/Unit/CloneTests.cs +++ b/dotnet/test/Unit/CloneTests.cs @@ -103,6 +103,7 @@ public void SessionConfig_Clone_CopiesAllProperties() DefaultAgent = new DefaultAgentConfig { ExcludedTools = ["hidden-tool"] }, SkillDirectories = ["/skills"], InstructionDirectories = ["/instructions"], + CustomAgentDirectories = ["/agents"], DisabledSkills = ["skill1"], DisabledMcpServers = ["server1"], PluginDirectories = ["/plugins"], @@ -145,6 +146,7 @@ public void SessionConfig_Clone_CopiesAllProperties() Assert.Equal(original.DefaultAgent!.ExcludedTools, clone.DefaultAgent!.ExcludedTools); Assert.Equal(original.SkillDirectories, clone.SkillDirectories); Assert.Equal(original.InstructionDirectories, clone.InstructionDirectories); + Assert.Equal(original.CustomAgentDirectories, clone.CustomAgentDirectories); Assert.Equal(original.DisabledSkills, clone.DisabledSkills); Assert.Equal(original.DisabledMcpServers, clone.DisabledMcpServers); Assert.Equal(original.PluginDirectories, clone.PluginDirectories); @@ -168,6 +170,7 @@ public void SessionConfig_Clone_CollectionsAreIndependent() AdditionalDirectories = ["/shared"], SkillDirectories = ["/skills"], InstructionDirectories = ["/instructions"], + CustomAgentDirectories = ["/agents"], DisabledSkills = ["skill1"], DisabledMcpServers = ["server1"], }; @@ -183,6 +186,7 @@ public void SessionConfig_Clone_CollectionsAreIndependent() clone.AdditionalDirectories!.Add("/generated"); clone.SkillDirectories!.Add("/more"); clone.InstructionDirectories!.Add("/more-instructions"); + clone.CustomAgentDirectories!.Add("/more-agents"); clone.DisabledSkills!.Add("skill99"); clone.DisabledMcpServers!.Add("server99"); @@ -195,6 +199,7 @@ public void SessionConfig_Clone_CollectionsAreIndependent() Assert.Single(original.AdditionalDirectories!); Assert.Single(original.SkillDirectories!); Assert.Single(original.InstructionDirectories!); + Assert.Single(original.CustomAgentDirectories!); Assert.Single(original.DisabledSkills!); Assert.Single(original.DisabledMcpServers!); } @@ -223,6 +228,7 @@ public void ResumeSessionConfig_Clone_CollectionsAreIndependent() AdditionalDirectories = ["/shared"], SkillDirectories = ["/skills"], InstructionDirectories = ["/instructions"], + CustomAgentDirectories = ["/agents"], DisabledSkills = ["skill1"], DisabledMcpServers = ["server1"], }; @@ -238,6 +244,7 @@ public void ResumeSessionConfig_Clone_CollectionsAreIndependent() clone.AdditionalDirectories!.Add("/generated"); clone.SkillDirectories!.Add("/more"); clone.InstructionDirectories!.Add("/more-instructions"); + clone.CustomAgentDirectories!.Add("/more-agents"); clone.DisabledSkills!.Add("skill99"); clone.DisabledMcpServers!.Add("server99"); @@ -250,6 +257,7 @@ public void ResumeSessionConfig_Clone_CollectionsAreIndependent() Assert.Single(original.AdditionalDirectories!); Assert.Single(original.SkillDirectories!); Assert.Single(original.InstructionDirectories!); + Assert.Single(original.CustomAgentDirectories!); Assert.Single(original.DisabledSkills!); Assert.Single(original.DisabledMcpServers!); } @@ -311,6 +319,7 @@ public void Clone_WithNullCollections_ReturnsNullCollections() Assert.Null(clone.CustomAgents); Assert.Null(clone.SkillDirectories); Assert.Null(clone.InstructionDirectories); + Assert.Null(clone.CustomAgentDirectories); Assert.Null(clone.DisabledSkills); Assert.Null(clone.DisabledMcpServers); Assert.Null(clone.Tools); diff --git a/dotnet/test/Unit/SerializationTests.cs b/dotnet/test/Unit/SerializationTests.cs index 6edf168093..29eafe8ce7 100644 --- a/dotnet/test/Unit/SerializationTests.cs +++ b/dotnet/test/Unit/SerializationTests.cs @@ -248,6 +248,39 @@ public void ResumeSessionRequest_CanSerializeInstructionDirectories_WithSdkOptio Assert.Equal("C:\\resume-instructions", root.GetProperty("instructionDirectories")[0].GetString()); } + [Fact] + public void CreateSessionRequest_CanSerializeCustomAgentDirectories_WithSdkOptions() + { + var options = GetSerializerOptions(); + var requestType = GetNestedType(typeof(CopilotClient), "CreateSessionRequest"); + var request = CreateInternalRequest( + requestType, + ("SessionId", "session-id"), + ("CustomAgentDirectories", new List { "C:\\extra-agents", "C:\\more-agents" })); + + var json = JsonSerializer.Serialize(request, requestType, options); + using var document = JsonDocument.Parse(json); + var root = document.RootElement; + Assert.Equal("C:\\extra-agents", root.GetProperty("customAgentDirectories")[0].GetString()); + Assert.Equal("C:\\more-agents", root.GetProperty("customAgentDirectories")[1].GetString()); + } + + [Fact] + public void ResumeSessionRequest_CanSerializeCustomAgentDirectories_WithSdkOptions() + { + var options = GetSerializerOptions(); + var requestType = GetNestedType(typeof(CopilotClient), "ResumeSessionRequest"); + var request = CreateInternalRequest( + requestType, + ("SessionId", "session-id"), + ("CustomAgentDirectories", new List { "C:\\resume-agents" })); + + var json = JsonSerializer.Serialize(request, requestType, options); + using var document = JsonDocument.Parse(json); + var root = document.RootElement; + Assert.Equal("C:\\resume-agents", root.GetProperty("customAgentDirectories")[0].GetString()); + } + [Fact] public void SessionRequests_CanSerializeCapiOptions_WithSdkOptions() { diff --git a/go/client.go b/go/client.go index fb02897f91..0954ab955d 100644 --- a/go/client.go +++ b/go/client.go @@ -840,6 +840,7 @@ func (c *Client) CreateSession(ctx context.Context, config *SessionConfig) (*Ses req.SkillDirectories = config.SkillDirectories req.PluginDirectories = config.PluginDirectories req.InstructionDirectories = config.InstructionDirectories + req.CustomAgentDirectories = config.CustomAgentDirectories req.DisabledSkills = config.DisabledSkills if config.DisabledMCPServers != nil { req.DisabledMCPServers = &config.DisabledMCPServers @@ -1224,6 +1225,7 @@ func (c *Client) ResumeSessionWithOptions(ctx context.Context, sessionID string, req.SkillDirectories = config.SkillDirectories req.PluginDirectories = config.PluginDirectories req.InstructionDirectories = config.InstructionDirectories + req.CustomAgentDirectories = config.CustomAgentDirectories req.DisabledSkills = config.DisabledSkills if config.DisabledMCPServers != nil { req.DisabledMCPServers = &config.DisabledMCPServers diff --git a/go/client_test.go b/go/client_test.go index f21442679b..01810820f5 100644 --- a/go/client_test.go +++ b/go/client_test.go @@ -1584,6 +1584,65 @@ func TestResumeSessionRequest_InstructionDirectories(t *testing.T) { }) } +func TestCreateSessionRequest_CustomAgentDirectories(t *testing.T) { + t.Run("includes customAgentDirectories in JSON when set", func(t *testing.T) { + req := createSessionRequest{CustomAgentDirectories: []string{`C:\extra-agents`, `C:\more-agents`}} + 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) + } + got := m["customAgentDirectories"].([]any) + if len(got) != 2 || got[0] != `C:\extra-agents` || got[1] != `C:\more-agents` { + t.Errorf("Expected customAgentDirectories to be serialized, got %v", got) + } + }) + + t.Run("omits customAgentDirectories 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["customAgentDirectories"]; ok { + t.Error("Expected customAgentDirectories to be omitted when empty") + } + }) +} + +func TestResumeSessionRequest_CustomAgentDirectories(t *testing.T) { + t.Run("includes customAgentDirectories in JSON when set", func(t *testing.T) { + req := resumeSessionRequest{ + SessionID: "s1", + CustomAgentDirectories: []string{`C:\resume-agents`}, + } + 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) + } + got := m["customAgentDirectories"].([]any) + if len(got) != 1 || got[0] != `C:\resume-agents` { + t.Errorf("Expected customAgentDirectories to be serialized, got %v", got) + } + }) + + t.Run("omits customAgentDirectories 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["customAgentDirectories"]; ok { + t.Error("Expected customAgentDirectories to be omitted when empty") + } + }) +} + func TestCreateSessionRequest_MCPOAuthTokenStorage(t *testing.T) { t.Run("includes mcpOAuthTokenStorage in JSON when set", func(t *testing.T) { req := createSessionRequest{MCPOAuthTokenStorage: "in-memory"} diff --git a/go/internal/e2e/session_config_e2e_test.go b/go/internal/e2e/session_config_e2e_test.go index 2ce48e3b33..6e1acc8944 100644 --- a/go/internal/e2e/session_config_e2e_test.go +++ b/go/internal/e2e/session_config_e2e_test.go @@ -955,6 +955,101 @@ func TestSessionConfigExtrasE2E(t *testing.T) { } }) + t.Run("should apply customAgentDirectories on create", func(t *testing.T) { + ctx.ConfigureForTest(t) + + projectDir := filepath.Join(ctx.WorkDir, "agent-create-project") + agentDir := filepath.Join(ctx.WorkDir, "extra-create-agents") + if err := os.MkdirAll(projectDir, 0755); err != nil { + t.Fatalf("MkdirAll failed: %v", err) + } + if err := os.MkdirAll(agentDir, 0755); err != nil { + t.Fatalf("MkdirAll failed: %v", err) + } + if err := os.WriteFile(filepath.Join(agentDir, "reviewer.agent.md"), []byte("---\nname: reviewer\ndescription: Reviews code carefully.\n---\nYou review code carefully."), 0644); err != nil { + t.Fatalf("WriteFile failed: %v", err) + } + + session, err := client.CreateSession(t.Context(), &copilot.SessionConfig{ + OnPermissionRequest: copilot.PermissionHandler.ApproveAll, + WorkingDirectory: projectDir, + CustomAgentDirectories: []string{agentDir}, + }) + if err != nil { + t.Fatalf("CreateSession failed: %v", err) + } + t.Cleanup(func() { _ = session.Disconnect() }) + + _, err = session.SendAndWait(t.Context(), copilot.MessageOptions{Prompt: "What is 1+1?"}) + if err != nil { + t.Fatalf("SendAndWait failed: %v", err) + } + + exchanges, err := ctx.GetExchanges() + if err != nil { + t.Fatalf("GetExchanges failed: %v", err) + } + if len(exchanges) == 0 { + t.Fatalf("Expected at least 1 exchange, got %d", len(exchanges)) + } + agentTypes := getTaskAgentTypes(t, exchanges[len(exchanges)-1]) + if !containsAgentType(agentTypes, "reviewer") { + t.Errorf("Expected task agent_type enum to contain %q, got %v", "reviewer", agentTypes) + } + }) + + t.Run("should apply customAgentDirectories on resume", func(t *testing.T) { + ctx.ConfigureForTest(t) + + projectDir := filepath.Join(ctx.WorkDir, "agent-resume-project") + agentDir := filepath.Join(ctx.WorkDir, "extra-resume-agents") + if err := os.MkdirAll(projectDir, 0755); err != nil { + t.Fatalf("MkdirAll failed: %v", err) + } + if err := os.MkdirAll(agentDir, 0755); err != nil { + t.Fatalf("MkdirAll failed: %v", err) + } + if err := os.WriteFile(filepath.Join(agentDir, "reviewer.agent.md"), []byte("---\nname: reviewer\ndescription: Reviews code carefully.\n---\nYou review code carefully."), 0644); err != nil { + t.Fatalf("WriteFile failed: %v", err) + } + + session1, err := client.CreateSession(t.Context(), &copilot.SessionConfig{ + OnPermissionRequest: copilot.PermissionHandler.ApproveAll, + WorkingDirectory: projectDir, + }) + if err != nil { + t.Fatalf("CreateSession failed: %v", err) + } + t.Cleanup(func() { _ = session1.Disconnect() }) + + session2, err := client.ResumeSession(t.Context(), session1.SessionID, &copilot.ResumeSessionConfig{ + OnPermissionRequest: copilot.PermissionHandler.ApproveAll, + WorkingDirectory: projectDir, + CustomAgentDirectories: []string{agentDir}, + }) + if err != nil { + t.Fatalf("ResumeSession failed: %v", err) + } + t.Cleanup(func() { _ = session2.Disconnect() }) + + _, err = session2.SendAndWait(t.Context(), copilot.MessageOptions{Prompt: "What is 1+1?"}) + if err != nil { + t.Fatalf("SendAndWait failed: %v", err) + } + + exchanges, err := ctx.GetExchanges() + if err != nil { + t.Fatalf("GetExchanges failed: %v", err) + } + if len(exchanges) == 0 { + t.Fatalf("Expected at least 1 exchange, got %d", len(exchanges)) + } + agentTypes := getTaskAgentTypes(t, exchanges[len(exchanges)-1]) + if !containsAgentType(agentTypes, "reviewer") { + t.Errorf("Expected task agent_type enum to contain %q, got %v", "reviewer", agentTypes) + } + }) + t.Run("should apply availableTools on session resume", func(t *testing.T) { ctx.ConfigureForTest(t) diff --git a/go/types.go b/go/types.go index 2241d2b5f0..c4a8c0586c 100644 --- a/go/types.go +++ b/go/types.go @@ -1423,6 +1423,8 @@ type SessionConfig struct { PluginDirectories []string // InstructionDirectories is a list of additional directories to search for custom instruction files InstructionDirectories []string + // CustomAgentDirectories is a list of additional directories to search for custom agent files + CustomAgentDirectories []string // DisabledSkills is a list of skill names to disable DisabledSkills []string // DisabledMCPServers is a list of exact MCP server names to disable for this session. @@ -1960,6 +1962,8 @@ type ResumeSessionConfig struct { PluginDirectories []string // InstructionDirectories is a list of additional directories to search for custom instruction files InstructionDirectories []string + // CustomAgentDirectories is a list of additional directories to search for custom agent files + CustomAgentDirectories []string // DisabledSkills is a list of skill names to disable DisabledSkills []string // DisabledMCPServers is a list of exact MCP server names to disable for this session. @@ -2506,6 +2510,7 @@ type createSessionRequest struct { SkillDirectories []string `json:"skillDirectories,omitempty"` PluginDirectories []string `json:"pluginDirectories,omitempty"` InstructionDirectories []string `json:"instructionDirectories,omitempty"` + CustomAgentDirectories []string `json:"customAgentDirectories,omitempty"` DisabledSkills []string `json:"disabledSkills,omitempty"` DisabledMCPServers *[]string `json:"disabledMcpServers,omitempty"` InfiniteSessions *InfiniteSessionConfig `json:"infiniteSessions,omitempty"` @@ -2604,6 +2609,7 @@ type resumeSessionRequest struct { SkillDirectories []string `json:"skillDirectories,omitempty"` PluginDirectories []string `json:"pluginDirectories,omitempty"` InstructionDirectories []string `json:"instructionDirectories,omitempty"` + CustomAgentDirectories []string `json:"customAgentDirectories,omitempty"` DisabledSkills []string `json:"disabledSkills,omitempty"` DisabledMCPServers *[]string `json:"disabledMcpServers,omitempty"` InfiniteSessions *InfiniteSessionConfig `json:"infiniteSessions,omitempty"` diff --git a/java/sdk/src/main/java/com/github/copilot/SessionRequestBuilder.java b/java/sdk/src/main/java/com/github/copilot/SessionRequestBuilder.java index 4254c04ec4..0ffaeb5654 100644 --- a/java/sdk/src/main/java/com/github/copilot/SessionRequestBuilder.java +++ b/java/sdk/src/main/java/com/github/copilot/SessionRequestBuilder.java @@ -156,6 +156,7 @@ static CreateSessionRequest buildCreateRequest(SessionConfig config, String sess request.setInfiniteSessions(config.getInfiniteSessions()); request.setSkillDirectories(config.getSkillDirectories()); request.setInstructionDirectories(config.getInstructionDirectories()); + request.setCustomAgentDirectories(config.getCustomAgentDirectories()); request.setPluginDirectories(config.getPluginDirectories()); request.setLargeOutput(config.getLargeOutput()); request.setToolSearch(config.getToolSearch()); @@ -308,6 +309,7 @@ static ResumeSessionRequest buildResumeRequest(String sessionId, ResumeSessionCo request.setAgent(config.getAgent()); request.setSkillDirectories(config.getSkillDirectories()); request.setInstructionDirectories(config.getInstructionDirectories()); + request.setCustomAgentDirectories(config.getCustomAgentDirectories()); request.setPluginDirectories(config.getPluginDirectories()); request.setLargeOutput(config.getLargeOutput()); request.setToolSearch(config.getToolSearch()); diff --git a/java/sdk/src/main/java/com/github/copilot/rpc/CreateSessionRequest.java b/java/sdk/src/main/java/com/github/copilot/rpc/CreateSessionRequest.java index 2eab977db1..ba33ded264 100644 --- a/java/sdk/src/main/java/com/github/copilot/rpc/CreateSessionRequest.java +++ b/java/sdk/src/main/java/com/github/copilot/rpc/CreateSessionRequest.java @@ -140,6 +140,9 @@ public final class CreateSessionRequest { @JsonProperty("instructionDirectories") private List instructionDirectories; + @JsonProperty("customAgentDirectories") + private List customAgentDirectories; + @JsonProperty("pluginDirectories") private List pluginDirectories; @@ -672,6 +675,18 @@ public void setInstructionDirectories(List instructionDirectories) { this.instructionDirectories = instructionDirectories; } + /** Gets custom agent directories. @return the custom agent directories */ + public List getCustomAgentDirectories() { + return customAgentDirectories == null ? null : Collections.unmodifiableList(customAgentDirectories); + } + + /** + * Sets custom agent directories. @param customAgentDirectories the directories + */ + public void setCustomAgentDirectories(List customAgentDirectories) { + this.customAgentDirectories = customAgentDirectories; + } + /** Gets plugin directories. @return the plugin directories */ public List getPluginDirectories() { return pluginDirectories == null ? null : Collections.unmodifiableList(pluginDirectories); diff --git a/java/sdk/src/main/java/com/github/copilot/rpc/ResumeSessionConfig.java b/java/sdk/src/main/java/com/github/copilot/rpc/ResumeSessionConfig.java index a188036372..787860cc57 100644 --- a/java/sdk/src/main/java/com/github/copilot/rpc/ResumeSessionConfig.java +++ b/java/sdk/src/main/java/com/github/copilot/rpc/ResumeSessionConfig.java @@ -90,6 +90,7 @@ public class ResumeSessionConfig { private String agent; private List skillDirectories; private List instructionDirectories; + private List customAgentDirectories; private List pluginDirectories; private LargeToolOutputConfig largeOutput; private ToolSearchConfig toolSearch; @@ -1510,6 +1511,27 @@ public ResumeSessionConfig setInstructionDirectories(List instructionDir return this; } + /** + * Gets the additional directories to search for custom agent files. + * + * @return the list of custom agent directory paths + */ + public List getCustomAgentDirectories() { + return customAgentDirectories == null ? null : Collections.unmodifiableList(customAgentDirectories); + } + + /** + * Sets additional directories to search for custom agent files. + * + * @param customAgentDirectories + * the list of custom agent directory paths + * @return this config for method chaining + */ + public ResumeSessionConfig setCustomAgentDirectories(List customAgentDirectories) { + this.customAgentDirectories = customAgentDirectories; + return this; + } + /** * Gets the plugin directories to load Open Plugin definitions from. * @@ -2029,6 +2051,9 @@ public ResumeSessionConfig clone() { copy.instructionDirectories = this.instructionDirectories != null ? new ArrayList<>(this.instructionDirectories) : null; + copy.customAgentDirectories = this.customAgentDirectories != null + ? new ArrayList<>(this.customAgentDirectories) + : null; copy.pluginDirectories = this.pluginDirectories != null ? new ArrayList<>(this.pluginDirectories) : null; copy.largeOutput = this.largeOutput; copy.toolSearch = this.toolSearch; diff --git a/java/sdk/src/main/java/com/github/copilot/rpc/ResumeSessionRequest.java b/java/sdk/src/main/java/com/github/copilot/rpc/ResumeSessionRequest.java index e52892477e..2465e618e0 100644 --- a/java/sdk/src/main/java/com/github/copilot/rpc/ResumeSessionRequest.java +++ b/java/sdk/src/main/java/com/github/copilot/rpc/ResumeSessionRequest.java @@ -180,6 +180,9 @@ public final class ResumeSessionRequest { @JsonProperty("instructionDirectories") private List instructionDirectories; + @JsonProperty("customAgentDirectories") + private List customAgentDirectories; + @JsonProperty("pluginDirectories") private List pluginDirectories; @@ -888,6 +891,18 @@ public void setInstructionDirectories(List instructionDirectories) { this.instructionDirectories = instructionDirectories; } + /** Gets custom agent directories. @return the custom agent directories */ + public List getCustomAgentDirectories() { + return customAgentDirectories == null ? null : Collections.unmodifiableList(customAgentDirectories); + } + + /** + * Sets custom agent directories. @param customAgentDirectories the directories + */ + public void setCustomAgentDirectories(List customAgentDirectories) { + this.customAgentDirectories = customAgentDirectories; + } + /** Gets plugin directories. @return the plugin directories */ public List getPluginDirectories() { return pluginDirectories == null ? null : Collections.unmodifiableList(pluginDirectories); diff --git a/java/sdk/src/main/java/com/github/copilot/rpc/SessionConfig.java b/java/sdk/src/main/java/com/github/copilot/rpc/SessionConfig.java index 1127e6777b..d095a19700 100644 --- a/java/sdk/src/main/java/com/github/copilot/rpc/SessionConfig.java +++ b/java/sdk/src/main/java/com/github/copilot/rpc/SessionConfig.java @@ -80,6 +80,7 @@ public class SessionConfig { private InfiniteSessionConfig infiniteSessions; private List skillDirectories; private List instructionDirectories; + private List customAgentDirectories; private List pluginDirectories; private LargeToolOutputConfig largeOutput; private ToolSearchConfig toolSearch; @@ -1196,6 +1197,27 @@ public SessionConfig setInstructionDirectories(List instructionDirectori return this; } + /** + * Gets the additional directories to search for custom agent files. + * + * @return the list of custom agent directory paths + */ + public List getCustomAgentDirectories() { + return customAgentDirectories == null ? null : Collections.unmodifiableList(customAgentDirectories); + } + + /** + * Sets additional directories to search for custom agent files. + * + * @param customAgentDirectories + * the list of custom agent directory paths + * @return this config instance for method chaining + */ + public SessionConfig setCustomAgentDirectories(List customAgentDirectories) { + this.customAgentDirectories = customAgentDirectories; + return this; + } + /** * Gets the plugin directories to load Open Plugin definitions from. * @@ -2158,6 +2180,9 @@ public SessionConfig clone() { copy.instructionDirectories = this.instructionDirectories != null ? new ArrayList<>(this.instructionDirectories) : null; + copy.customAgentDirectories = this.customAgentDirectories != null + ? new ArrayList<>(this.customAgentDirectories) + : null; copy.pluginDirectories = this.pluginDirectories != null ? new ArrayList<>(this.pluginDirectories) : null; copy.largeOutput = this.largeOutput; copy.toolSearch = this.toolSearch; diff --git a/java/sdk/src/test/java/com/github/copilot/ConfigCloneTest.java b/java/sdk/src/test/java/com/github/copilot/ConfigCloneTest.java index 4c5a3fbef0..79d9d890a6 100644 --- a/java/sdk/src/test/java/com/github/copilot/ConfigCloneTest.java +++ b/java/sdk/src/test/java/com/github/copilot/ConfigCloneTest.java @@ -148,6 +148,7 @@ void sessionConfigListIndependence() { toolList.add("bash"); original.setAvailableTools(toolList); original.setInstructionDirectories(new ArrayList<>(List.of("/path/a", "/path/b"))); + original.setCustomAgentDirectories(new ArrayList<>(List.of("/agents/a", "/agents/b"))); original.setDisabledMcpServers(new ArrayList<>(List.of("local-files"))); SessionConfig cloned = original.clone(); @@ -159,6 +160,7 @@ void sessionConfigListIndependence() { assertEquals(2, cloned.getAvailableTools().size()); assertEquals(3, original.getAvailableTools().size()); assertEquals(List.of("/path/a", "/path/b"), cloned.getInstructionDirectories()); + assertEquals(List.of("/agents/a", "/agents/b"), cloned.getCustomAgentDirectories()); assertEquals(List.of("local-files"), cloned.getDisabledMcpServers()); } diff --git a/java/sdk/src/test/java/com/github/copilot/SessionConfigE2ETest.java b/java/sdk/src/test/java/com/github/copilot/SessionConfigE2ETest.java index 925fd6d873..e258d74589 100644 --- a/java/sdk/src/test/java/com/github/copilot/SessionConfigE2ETest.java +++ b/java/sdk/src/test/java/com/github/copilot/SessionConfigE2ETest.java @@ -119,6 +119,62 @@ void testShouldApplyInstructionDirectoriesOnResume() throws Exception { } } + @Test + void testShouldApplyCustomAgentDirectoriesOnCreate() throws Exception { + ctx.configureForTest("session_config", "should_apply_customagentdirectories_on_create"); + + Path projectDir = ctx.getWorkDir().resolve("agent-create-project"); + Path agentDir = ctx.getWorkDir().resolve("extra-create-agents"); + Files.createDirectories(projectDir); + Files.createDirectories(agentDir); + Files.writeString(agentDir.resolve("reviewer.agent.md"), + "---\nname: reviewer\ndescription: Reviews code carefully.\n---\nYou review code carefully."); + + try (CopilotClient client = ctx.createClient()) { + CopilotSession session = client.createSession(new SessionConfig().setWorkingDirectory(projectDir.toString()) + .setCustomAgentDirectories(List.of(agentDir.toString())) + .setOnPermissionRequest(PermissionHandler.APPROVE_ALL)).get(); + + session.sendAndWait(new MessageOptions().setPrompt("What is 1+1?")).get(60, TimeUnit.SECONDS); + + List> exchanges = ctx.getExchanges(); + assertFalse(exchanges.isEmpty(), "Should have at least one exchange"); + assertTrue(getTaskAgentTypes(exchanges.get(exchanges.size() - 1)).contains("reviewer"), + "Task agent_type enum should contain the discovered custom agent: reviewer"); + } + } + + @Test + void testShouldApplyCustomAgentDirectoriesOnResume() throws Exception { + ctx.configureForTest("session_config", "should_apply_customagentdirectories_on_resume"); + + Path projectDir = ctx.getWorkDir().resolve("agent-resume-project"); + Path agentDir = ctx.getWorkDir().resolve("extra-resume-agents"); + Files.createDirectories(projectDir); + Files.createDirectories(agentDir); + Files.writeString(agentDir.resolve("reviewer.agent.md"), + "---\nname: reviewer\ndescription: Reviews code carefully.\n---\nYou review code carefully."); + + try (CopilotClient client = ctx.createClient()) { + CopilotSession session1 = client.createSession(new SessionConfig() + .setWorkingDirectory(projectDir.toString()).setOnPermissionRequest(PermissionHandler.APPROVE_ALL)) + .get(); + + CopilotSession session2 = client.resumeSession(session1.getSessionId(), + new ResumeSessionConfig().setWorkingDirectory(projectDir.toString()) + .setCustomAgentDirectories(List.of(agentDir.toString())) + .setOnPermissionRequest(PermissionHandler.APPROVE_ALL)) + .get(); + + session2.sendAndWait(new MessageOptions().setPrompt("What is 1+1?")).get(60, TimeUnit.SECONDS); + + List> exchanges = ctx.getExchanges(); + assertFalse(exchanges.isEmpty(), "Should have at least one exchange"); + assertTrue(getTaskAgentTypes(exchanges.get(exchanges.size() - 1)).contains("reviewer"), + "Task agent_type enum should contain the discovered custom agent: reviewer"); + } + } + @Test void testShouldForwardProviderWireModel() throws Exception { ctx.configureForTest("session_config", "should_forward_provider_wire_model"); diff --git a/java/sdk/src/test/java/com/github/copilot/SessionRequestBuilderTest.java b/java/sdk/src/test/java/com/github/copilot/SessionRequestBuilderTest.java index 0525786de6..f3dc39d341 100644 --- a/java/sdk/src/test/java/com/github/copilot/SessionRequestBuilderTest.java +++ b/java/sdk/src/test/java/com/github/copilot/SessionRequestBuilderTest.java @@ -780,6 +780,30 @@ void testBuildResumeRequestPropagatesInstructionDirectories() { assertEquals(dirs, request.getInstructionDirectories()); } + // ========================================================================= + // customAgentDirectories propagation + // ========================================================================= + + @Test + void testBuildCreateRequestPropagatesCustomAgentDirectories() { + var dirs = List.of("/path/to/agents", "/another/agents"); + var config = new SessionConfig().setCustomAgentDirectories(dirs); + + CreateSessionRequest request = SessionRequestBuilder.buildCreateRequest(config); + + assertEquals(dirs, request.getCustomAgentDirectories()); + } + + @Test + void testBuildResumeRequestPropagatesCustomAgentDirectories() { + var dirs = List.of("/resume/agents", "/other/agents"); + var config = new ResumeSessionConfig().setCustomAgentDirectories(dirs); + + ResumeSessionRequest request = SessionRequestBuilder.buildResumeRequest("sid-agents", config); + + assertEquals(dirs, request.getCustomAgentDirectories()); + } + // ========================================================================= // enableSessionTelemetry serialization // ========================================================================= diff --git a/nodejs/src/client.ts b/nodejs/src/client.ts index 196d526420..a2736461ea 100644 --- a/nodejs/src/client.ts +++ b/nodejs/src/client.ts @@ -1625,6 +1625,7 @@ export class CopilotClient { skillDirectories: config.skillDirectories, pluginDirectories: config.pluginDirectories, instructionDirectories: config.instructionDirectories, + customAgentDirectories: config.customAgentDirectories, disabledSkills: config.disabledSkills, disabledMcpServers: config.disabledMcpServers, infiniteSessions: config.infiniteSessions, @@ -1875,6 +1876,7 @@ export class CopilotClient { skillDirectories: config.skillDirectories, pluginDirectories: config.pluginDirectories, instructionDirectories: config.instructionDirectories, + customAgentDirectories: config.customAgentDirectories, disabledSkills: config.disabledSkills, disabledMcpServers: config.disabledMcpServers, infiniteSessions: config.infiniteSessions, diff --git a/nodejs/src/types.ts b/nodejs/src/types.ts index 678cd58633..e8eac54345 100644 --- a/nodejs/src/types.ts +++ b/nodejs/src/types.ts @@ -2668,6 +2668,11 @@ export interface SessionConfigBase { */ instructionDirectories?: string[]; + /** + * Additional directories to search for custom agent files. + */ + customAgentDirectories?: string[]; + /** * List of skill names to disable. */ diff --git a/nodejs/test/client.test.ts b/nodejs/test/client.test.ts index 651debc163..a72e1e3e1f 100644 --- a/nodejs/test/client.test.ts +++ b/nodejs/test/client.test.ts @@ -2230,6 +2230,52 @@ describe("CopilotClient", () => { spy.mockRestore(); }); + it("forwards customAgentDirectories in session.create request", async () => { + const client = new CopilotClient(); + await client.start(); + onTestFinished(() => stopClient(client)); + + const customAgentDirectories = ["C:\\extra-agents", "C:\\more-agents"]; + const spy = vi.spyOn((client as any).connection!, "sendRequest"); + await client.createSession({ + customAgentDirectories, + onPermissionRequest: approveAll, + }); + + expect(spy).toHaveBeenCalledWith( + "session.create", + expect.objectContaining({ customAgentDirectories }) + ); + }); + + it("forwards customAgentDirectories in session.resume request", async () => { + const client = new CopilotClient(); + await client.start(); + onTestFinished(() => stopClient(client)); + + const session = await client.createSession({ onPermissionRequest: approveAll }); + const customAgentDirectories = ["C:\\resume-agents"]; + const spy = vi + .spyOn((client as any).connection!, "sendRequest") + .mockImplementation(async (method: string, params: any) => { + if (method === "session.resume") return { sessionId: params.sessionId }; + throw new Error(`Unexpected method: ${method}`); + }); + await client.resumeSession(session.sessionId, { + customAgentDirectories, + onPermissionRequest: approveAll, + }); + + expect(spy).toHaveBeenCalledWith( + "session.resume", + expect.objectContaining({ + customAgentDirectories, + sessionId: session.sessionId, + }) + ); + spy.mockRestore(); + }); + it("does not request permissions on session.resume when using the default joinSession handler", async () => { const client = new CopilotClient(); await client.start(); diff --git a/nodejs/test/e2e/session_config.e2e.test.ts b/nodejs/test/e2e/session_config.e2e.test.ts index 85137e0ff9..5f58783831 100644 --- a/nodejs/test/e2e/session_config.e2e.test.ts +++ b/nodejs/test/e2e/session_config.e2e.test.ts @@ -691,6 +691,63 @@ describe("Session Configuration", async () => { await session1.disconnect(); }); + it("should apply customAgentDirectories on session create", async () => { + const projectDir = join(workDir, "agent-create-project"); + const agentDir = join(workDir, "extra-create-agents"); + await mkdir(projectDir, { recursive: true }); + await mkdir(agentDir, { recursive: true }); + await writeFile( + join(agentDir, "reviewer.agent.md"), + "---\nname: reviewer\ndescription: Reviews code carefully.\n---\nYou review code carefully." + ); + + const session = await client.createSession({ + onPermissionRequest: approveAll, + workingDirectory: projectDir, + customAgentDirectories: [agentDir], + }); + + await session.sendAndWait({ prompt: "What is 1+1?" }); + + const exchanges = await openAiEndpoint.getExchanges(); + expect(exchanges.length).toBeGreaterThan(0); + const agentTypes = getTaskAgentTypes(exchanges[exchanges.length - 1]); + expect(agentTypes).toContain("reviewer"); + + await session.disconnect(); + }); + + it("should apply customAgentDirectories on session resume", async () => { + const projectDir = join(workDir, "agent-resume-project"); + const agentDir = join(workDir, "extra-resume-agents"); + await mkdir(projectDir, { recursive: true }); + await mkdir(agentDir, { recursive: true }); + await writeFile( + join(agentDir, "reviewer.agent.md"), + "---\nname: reviewer\ndescription: Reviews code carefully.\n---\nYou review code carefully." + ); + + const session1 = await client.createSession({ + onPermissionRequest: approveAll, + workingDirectory: projectDir, + }); + const session2 = await client.resumeSession(session1.sessionId, { + onPermissionRequest: approveAll, + workingDirectory: projectDir, + customAgentDirectories: [agentDir], + }); + + await session2.sendAndWait({ prompt: "What is 1+1?" }); + + const exchanges = await openAiEndpoint.getExchanges(); + expect(exchanges.length).toBeGreaterThan(0); + const agentTypes = getTaskAgentTypes(exchanges[exchanges.length - 1]); + expect(agentTypes).toContain("reviewer"); + + await session2.disconnect(); + await session1.disconnect(); + }); + it("should forward clientName in user-agent", async () => { const session = await client.createSession({ onPermissionRequest: approveAll, diff --git a/python/copilot/client.py b/python/copilot/client.py index 6cdd765c37..baf8e99ed1 100644 --- a/python/copilot/client.py +++ b/python/copilot/client.py @@ -2144,6 +2144,7 @@ async def create_session( skill_directories: list[str] | None = None, plugin_directories: list[str] | None = None, instruction_directories: list[str] | None = None, + custom_agent_directories: list[str] | None = None, disabled_skills: list[str] | None = None, disabled_mcp_servers: list[str] | None = None, infinite_sessions: InfiniteSessionConfig | None = None, @@ -2276,6 +2277,8 @@ async def create_session( skill_directories: Directories to search for skills. instruction_directories: Additional directories to search for custom instruction files. + custom_agent_directories: Additional directories to search for custom + agent files. disabled_skills: Skills to disable. disabled_mcp_servers: Exact MCP server names to disable only for this session. Disabled servers are not started or authenticated on @@ -2597,6 +2600,8 @@ async def create_session( # Add instruction directories configuration if provided if instruction_directories is not None: payload["instructionDirectories"] = instruction_directories + if custom_agent_directories is not None: + payload["customAgentDirectories"] = custom_agent_directories # Add disabled skills configuration if provided if disabled_skills: @@ -2872,6 +2877,7 @@ async def resume_session( skill_directories: list[str] | None = None, plugin_directories: list[str] | None = None, instruction_directories: list[str] | None = None, + custom_agent_directories: list[str] | None = None, disabled_skills: list[str] | None = None, disabled_mcp_servers: list[str] | None = None, infinite_sessions: InfiniteSessionConfig | None = None, @@ -3006,6 +3012,8 @@ async def resume_session( skill_directories: Directories to search for skills. instruction_directories: Additional directories to search for custom instruction files. + custom_agent_directories: Additional directories to search for custom + agent files. disabled_skills: Skills to disable. disabled_mcp_servers: Exact MCP server names to disable only for this session. Disabled servers are not started or authenticated on @@ -3294,6 +3302,8 @@ async def resume_session( payload["pluginDirectories"] = plugin_directories if instruction_directories is not None: payload["instructionDirectories"] = instruction_directories + if custom_agent_directories is not None: + payload["customAgentDirectories"] = custom_agent_directories if disabled_skills: payload["disabledSkills"] = disabled_skills if disabled_mcp_servers is not None: diff --git a/python/e2e/test_session_config_e2e.py b/python/e2e/test_session_config_e2e.py index 62dc671893..b9e7bfbc34 100644 --- a/python/e2e/test_session_config_e2e.py +++ b/python/e2e/test_session_config_e2e.py @@ -664,6 +664,63 @@ async def test_should_apply_instruction_directories_on_resume(self, ctx: E2ETest await session2.disconnect() await session1.disconnect() + async def test_should_apply_custom_agent_directories_on_create(self, ctx: E2ETestContext): + project_dir = os.path.join(ctx.work_dir, "agent-create-project") + agent_dir = os.path.join(ctx.work_dir, "extra-create-agents") + os.makedirs(project_dir, exist_ok=True) + os.makedirs(agent_dir, exist_ok=True) + with open(os.path.join(agent_dir, "reviewer.agent.md"), "w", encoding="utf-8") as f: + f.write( + "---\nname: reviewer\ndescription: Reviews code carefully.\n---\n" + "You review code carefully." + ) + + session = await ctx.client.create_session( + on_permission_request=PermissionHandler.approve_all, + working_directory=project_dir, + custom_agent_directories=[agent_dir], + ) + + await session.send_and_wait("What is 1+1?") + + exchanges = await ctx.get_exchanges() + assert exchanges + assert "reviewer" in _get_task_agent_types(exchanges[-1]) + + await session.disconnect() + + async def test_should_apply_custom_agent_directories_on_resume(self, ctx: E2ETestContext): + project_dir = os.path.join(ctx.work_dir, "agent-resume-project") + agent_dir = os.path.join(ctx.work_dir, "extra-resume-agents") + os.makedirs(project_dir, exist_ok=True) + os.makedirs(agent_dir, exist_ok=True) + with open(os.path.join(agent_dir, "reviewer.agent.md"), "w", encoding="utf-8") as f: + f.write( + "---\nname: reviewer\ndescription: Reviews code carefully.\n---\n" + "You review code carefully." + ) + + session1 = await ctx.client.create_session( + on_permission_request=PermissionHandler.approve_all, + working_directory=project_dir, + ) + + session2 = await ctx.client.resume_session( + session1.session_id, + on_permission_request=PermissionHandler.approve_all, + working_directory=project_dir, + custom_agent_directories=[agent_dir], + ) + + await session2.send_and_wait("What is 1+1?") + + exchanges = await ctx.get_exchanges() + assert exchanges + assert "reviewer" in _get_task_agent_types(exchanges[-1]) + + await session2.disconnect() + await session1.disconnect() + async def test_should_apply_availabletools_on_session_resume(self, ctx: E2ETestContext): session1 = await ctx.client.create_session( on_permission_request=PermissionHandler.approve_all, diff --git a/python/test_client.py b/python/test_client.py index cf4bdf192b..771410f4f7 100644 --- a/python/test_client.py +++ b/python/test_client.py @@ -1662,6 +1662,65 @@ async def mock_request(method, params, **kwargs): finally: await client.force_stop() + @pytest.mark.asyncio + async def test_create_session_sends_custom_agent_directories(self): + client = CopilotClient(connection=RuntimeConnection.for_stdio(path=CLI_PATH)) + await client.start() + + try: + captured = {} + + async def mock_request(method, params, **kwargs): + captured[method] = params + if method == "session.create": + sid = params.get("sessionId") or "session-id" + result = {"sessionId": sid, "workspacePath": None} + callback = kwargs.get("on_response_inline") + if callback is not None: + callback(result) + return result + return {} + + client._client.request = mock_request + + await client.create_session( + on_permission_request=PermissionHandler.approve_all, + custom_agent_directories=["C:\\extra-agents", "C:\\more-agents"], + ) + + assert captured["session.create"]["customAgentDirectories"] == [ + "C:\\extra-agents", + "C:\\more-agents", + ] + finally: + await client.force_stop() + + @pytest.mark.asyncio + async def test_resume_session_sends_custom_agent_directories(self): + client = CopilotClient(connection=RuntimeConnection.for_stdio(path=CLI_PATH)) + await client.start() + + try: + captured = {} + + async def mock_request(method, params, **kwargs): + captured[method] = params + if method == "session.resume": + return {"sessionId": params["sessionId"], "workspacePath": None} + return {} + + client._client.request = mock_request + + await client.resume_session( + "session-id", + on_permission_request=PermissionHandler.approve_all, + custom_agent_directories=["C:\\resume-agents"], + ) + + assert captured["session.resume"]["customAgentDirectories"] == ["C:\\resume-agents"] + finally: + await client.force_stop() + class TestModelBilling: def test_token_prices_round_trip(self): diff --git a/rust/src/types.rs b/rust/src/types.rs index 392e0f840b..8140ed89c4 100644 --- a/rust/src/types.rs +++ b/rust/src/types.rs @@ -2029,6 +2029,9 @@ pub struct SessionConfig { /// Additional directories to search for custom instruction files. /// Forwarded to the CLI; not the same as [`skill_directories`](Self::skill_directories). pub instruction_directories: Option>, + /// Additional directories to search for custom agent files. + /// Forwarded to the CLI; not the same as [`skill_directories`](Self::skill_directories). + pub custom_agent_directories: Option>, /// Open Plugin directory paths passed through to the CLI. pub plugin_directories: Option>, /// Configuration for large tool output handling, forwarded to the CLI. @@ -2272,6 +2275,7 @@ impl std::fmt::Debug for SessionConfig { .field("enable_mcp_apps", &self.enable_mcp_apps) .field("skill_directories", &self.skill_directories) .field("instruction_directories", &self.instruction_directories) + .field("custom_agent_directories", &self.custom_agent_directories) .field("plugin_directories", &self.plugin_directories) .field("large_output", &self.large_output) .field("tool_search", &self.tool_search) @@ -2393,6 +2397,7 @@ impl Default for SessionConfig { github_mcp_tool_config: None, skill_directories: None, instruction_directories: None, + custom_agent_directories: None, plugin_directories: None, large_output: None, tool_search: None, @@ -2558,6 +2563,7 @@ impl SessionConfig { hooks: hooks_flag, skill_directories: self.skill_directories, instruction_directories: self.instruction_directories, + custom_agent_directories: self.custom_agent_directories, plugin_directories: self.plugin_directories, large_output: self.large_output, tool_search: self.tool_search, @@ -2969,6 +2975,18 @@ impl SessionConfig { self } + /// Set additional directories to search for custom agent files. + /// Forwarded to the CLI; not the same as + /// [`with_skill_directories`](Self::with_skill_directories). + pub fn with_custom_agent_directories(mut self, paths: I) -> Self + where + I: IntoIterator, + P: Into, + { + self.custom_agent_directories = Some(paths.into_iter().map(Into::into).collect()); + self + } + /// Set Open Plugin directory paths passed through to the CLI on session create. pub fn with_plugin_directories(mut self, paths: I) -> Self where @@ -3333,6 +3351,9 @@ pub struct ResumeSessionConfig { /// Additional directories to search for custom instruction files on /// resume. Forwarded to the CLI; not the same as [`skill_directories`](Self::skill_directories). pub instruction_directories: Option>, + /// Additional directories to search for custom agent files. + /// Forwarded to the CLI; not the same as [`skill_directories`](Self::skill_directories). + pub custom_agent_directories: Option>, /// Open Plugin directory paths passed through to the CLI on resume. pub plugin_directories: Option>, /// Configuration for large tool output handling, forwarded to the CLI on resume. @@ -3538,6 +3559,7 @@ impl std::fmt::Debug for ResumeSessionConfig { .field("enable_mcp_apps", &self.enable_mcp_apps) .field("skill_directories", &self.skill_directories) .field("instruction_directories", &self.instruction_directories) + .field("custom_agent_directories", &self.custom_agent_directories) .field("plugin_directories", &self.plugin_directories) .field("large_output", &self.large_output) .field("tool_search", &self.tool_search) @@ -3703,6 +3725,7 @@ impl ResumeSessionConfig { hooks: hooks_flag, skill_directories: self.skill_directories, instruction_directories: self.instruction_directories, + custom_agent_directories: self.custom_agent_directories, plugin_directories: self.plugin_directories, large_output: self.large_output, tool_search: self.tool_search, @@ -3800,6 +3823,7 @@ impl ResumeSessionConfig { github_mcp_tool_config: None, skill_directories: None, instruction_directories: None, + custom_agent_directories: None, plugin_directories: None, large_output: None, tool_search: None, @@ -4190,6 +4214,18 @@ impl ResumeSessionConfig { self } + /// Set additional directories to search for custom agent files. + /// Forwarded to the CLI; not the same as + /// [`with_skill_directories`](Self::with_skill_directories). + pub fn with_custom_agent_directories(mut self, paths: I) -> Self + where + I: IntoIterator, + P: Into, + { + self.custom_agent_directories = Some(paths.into_iter().map(Into::into).collect()); + self + } + /// Set Open Plugin directory paths passed through to the CLI on resume. pub fn with_plugin_directories(mut self, paths: I) -> Self where @@ -6962,6 +6998,49 @@ mod tests { assert!(json.get("instructionDirectories").is_none()); } + /// `custom_agent_directories` must serialize to wire as + /// `customAgentDirectories` on `SessionConfig`. + #[test] + fn session_config_serializes_custom_agent_directories_to_camel_case() { + let cfg = + SessionConfig::default().with_custom_agent_directories([PathBuf::from("/tmp/agents")]); + let (wire, _) = cfg + .into_wire(Some(SessionId::from("agents-on"))) + .expect("no duplicate handlers"); + let json = serde_json::to_value(&wire).unwrap(); + assert_eq!( + json["customAgentDirectories"], + serde_json::json!(["/tmp/agents"]) + ); + + // Unset case — skip_serializing_if must omit the field. + let (wire, _) = SessionConfig::default() + .into_wire(Some(SessionId::from("agents-off"))) + .expect("no duplicate handlers"); + let json = serde_json::to_value(&wire).unwrap(); + assert!(json.get("customAgentDirectories").is_none()); + } + + /// Same check on the resume path. Forwarded to the CLI on + /// `session.resume`. + #[test] + fn resume_session_config_serializes_custom_agent_directories_to_camel_case() { + let cfg = ResumeSessionConfig::new(SessionId::from("sess-1")) + .with_custom_agent_directories([PathBuf::from("/tmp/agents")]); + let (wire, _) = cfg.into_wire().expect("no duplicate handlers"); + let json = serde_json::to_value(&wire).unwrap(); + assert_eq!( + json["customAgentDirectories"], + serde_json::json!(["/tmp/agents"]) + ); + + let (wire, _) = ResumeSessionConfig::new(SessionId::from("sess-2")) + .into_wire() + .expect("no duplicate handlers"); + let json = serde_json::to_value(&wire).unwrap(); + assert!(json.get("customAgentDirectories").is_none()); + } + #[test] fn custom_agent_config_builder_composes() { use indexmap::IndexMap; diff --git a/rust/src/wire.rs b/rust/src/wire.rs index 21b61a7f9f..a61ec91be0 100644 --- a/rust/src/wire.rs +++ b/rust/src/wire.rs @@ -122,6 +122,8 @@ pub(crate) struct SessionCreateWire { #[serde(skip_serializing_if = "Option::is_none")] pub instruction_directories: Option>, #[serde(skip_serializing_if = "Option::is_none")] + pub custom_agent_directories: Option>, + #[serde(skip_serializing_if = "Option::is_none")] pub plugin_directories: Option>, #[serde(skip_serializing_if = "Option::is_none")] pub large_output: Option, @@ -272,6 +274,8 @@ pub(crate) struct SessionResumeWire { #[serde(skip_serializing_if = "Option::is_none")] pub instruction_directories: Option>, #[serde(skip_serializing_if = "Option::is_none")] + pub custom_agent_directories: Option>, + #[serde(skip_serializing_if = "Option::is_none")] pub plugin_directories: Option>, #[serde(skip_serializing_if = "Option::is_none")] pub large_output: Option, diff --git a/rust/tests/e2e/client_options.rs b/rust/tests/e2e/client_options.rs index fc1ceebb83..a9b7360487 100644 --- a/rust/tests/e2e/client_options.rs +++ b/rust/tests/e2e/client_options.rs @@ -61,6 +61,7 @@ async fn should_forward_advanced_session_creation_options_to_the_cli() { .with_skill_directories([PathBuf::from("skills")]) .with_plugin_directories([PathBuf::from("plugins")]) .with_instruction_directories([PathBuf::from("instructions")]) + .with_custom_agent_directories([PathBuf::from("agents")]) .with_disabled_skills(["disabled-skill"]) .with_enable_mcp_apps(true) .with_canvases([CanvasDeclaration::new( @@ -127,6 +128,7 @@ async fn should_forward_advanced_session_creation_options_to_the_cli() { assert_eq!(params["skillDirectories"], json!(["skills"])); assert_eq!(params["pluginDirectories"], json!(["plugins"])); assert_eq!(params["instructionDirectories"], json!(["instructions"])); + assert_eq!(params["customAgentDirectories"], json!(["agents"])); assert_eq!(params["disabledSkills"], json!(["disabled-skill"])); assert_eq!(params["sessionLimits"]["maxAiCredits"], json!(42)); assert_eq!( @@ -234,6 +236,7 @@ async fn should_forward_advanced_session_resume_options_to_the_cli() { .with_streaming(true) .with_include_sub_agent_streaming_events(false) .with_github_token("advanced-resume-session-token") + .with_custom_agent_directories([PathBuf::from("resume-agents")]) .with_canvases([CanvasDeclaration::new( "resume-canvas", "Resume Canvas", @@ -313,6 +316,7 @@ async fn should_forward_advanced_session_resume_options_to_the_cli() { params["expAssignments"]["Flights"]["resumeFeature"], json!("enabled") ); + assert_eq!(params["customAgentDirectories"], json!(["resume-agents"])); let update = fake.captured_request("session.options.update"); let update_params = update.params.as_object().expect("options update params"); diff --git a/test/snapshots/session_config/should_apply_custom_agent_directories_on_create.yaml b/test/snapshots/session_config/should_apply_custom_agent_directories_on_create.yaml new file mode 100644 index 0000000000..f9918fa133 --- /dev/null +++ b/test/snapshots/session_config/should_apply_custom_agent_directories_on_create.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 equals 2. diff --git a/test/snapshots/session_config/should_apply_custom_agent_directories_on_resume.yaml b/test/snapshots/session_config/should_apply_custom_agent_directories_on_resume.yaml new file mode 100644 index 0000000000..f9918fa133 --- /dev/null +++ b/test/snapshots/session_config/should_apply_custom_agent_directories_on_resume.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 equals 2. diff --git a/test/snapshots/session_config/should_apply_customagentdirectories_on_create.yaml b/test/snapshots/session_config/should_apply_customagentdirectories_on_create.yaml new file mode 100644 index 0000000000..f9918fa133 --- /dev/null +++ b/test/snapshots/session_config/should_apply_customagentdirectories_on_create.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 equals 2. diff --git a/test/snapshots/session_config/should_apply_customagentdirectories_on_resume.yaml b/test/snapshots/session_config/should_apply_customagentdirectories_on_resume.yaml new file mode 100644 index 0000000000..f9918fa133 --- /dev/null +++ b/test/snapshots/session_config/should_apply_customagentdirectories_on_resume.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 equals 2. diff --git a/test/snapshots/session_config/should_apply_customagentdirectories_on_session_create.yaml b/test/snapshots/session_config/should_apply_customagentdirectories_on_session_create.yaml new file mode 100644 index 0000000000..f9918fa133 --- /dev/null +++ b/test/snapshots/session_config/should_apply_customagentdirectories_on_session_create.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 equals 2. diff --git a/test/snapshots/session_config/should_apply_customagentdirectories_on_session_resume.yaml b/test/snapshots/session_config/should_apply_customagentdirectories_on_session_resume.yaml new file mode 100644 index 0000000000..7c4d469970 --- /dev/null +++ b/test/snapshots/session_config/should_apply_customagentdirectories_on_session_resume.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