diff --git a/dotnet/README.md b/dotnet/README.md index fe226f77f..93032b798 100644 --- a/dotnet/README.md +++ b/dotnet/README.md @@ -415,6 +415,19 @@ var session = await client.CreateSessionAsync(new SessionConfig When Copilot invokes `lookup_issue`, the client automatically runs your handler and responds to the CLI. Handlers can return any JSON-serializable value (automatically wrapped), or a `ToolResultAIContent` wrapping a `ToolResultObject` for full control over result metadata. +#### Overriding Built-in Tools + +If you register a tool with the same name as a built-in CLI tool (e.g. `edit_file`, `read_file`), your tool takes precedence. The SDK automatically adds the tool name to `ExcludedTools`, so the built-in is disabled and your handler is called instead. This is useful when you need custom behavior for built-in operations. + +```csharp +AIFunctionFactory.Create( + async ([Description("File path")] string path, [Description("New content")] string content) => { + // your logic + }, + "edit_file", + "Custom file editor with project-specific validation") +``` + ### System Message Customization Control the system prompt using `SystemMessage` in session config: diff --git a/dotnet/src/Client.cs b/dotnet/src/Client.cs index 1f3a7fb43..71ca48ee1 100644 --- a/dotnet/src/Client.cs +++ b/dotnet/src/Client.cs @@ -389,7 +389,7 @@ public async Task CreateSessionAsync(SessionConfig config, Cance config.Tools?.Select(ToolDefinition.FromAIFunction).ToList(), config.SystemMessage, config.AvailableTools, - config.ExcludedTools, + MergeExcludedTools(config.ExcludedTools, config.Tools), config.Provider, (bool?)true, config.OnUserInputRequest != null ? true : null, @@ -480,7 +480,7 @@ public async Task ResumeSessionAsync(string sessionId, ResumeSes config.Tools?.Select(ToolDefinition.FromAIFunction).ToList(), config.SystemMessage, config.AvailableTools, - config.ExcludedTools, + MergeExcludedTools(config.ExcludedTools, config.Tools), config.Provider, (bool?)true, config.OnUserInputRequest != null ? true : null, @@ -862,6 +862,14 @@ private void DispatchLifecycleEvent(SessionLifecycleEvent evt) } } + internal static List? MergeExcludedTools(List? excludedTools, ICollection? tools) + { + var toolNames = tools?.Select(t => t.Name).ToList(); + if (toolNames is null or { Count: 0 }) return excludedTools; + if (excludedTools is null or { Count: 0 }) return toolNames; + return excludedTools.Union(toolNames).ToList(); + } + internal static async Task InvokeRpcAsync(JsonRpc rpc, string method, object?[]? args, CancellationToken cancellationToken) { return await InvokeRpcAsync(rpc, method, args, null, cancellationToken); diff --git a/dotnet/src/GitHub.Copilot.SDK.csproj b/dotnet/src/GitHub.Copilot.SDK.csproj index 019788cfa..7a3fdacaf 100644 --- a/dotnet/src/GitHub.Copilot.SDK.csproj +++ b/dotnet/src/GitHub.Copilot.SDK.csproj @@ -17,6 +17,10 @@ true + + + + diff --git a/dotnet/test/MergeExcludedToolsTests.cs b/dotnet/test/MergeExcludedToolsTests.cs new file mode 100644 index 000000000..a5271a4a0 --- /dev/null +++ b/dotnet/test/MergeExcludedToolsTests.cs @@ -0,0 +1,79 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +using Microsoft.Extensions.AI; +using System.ComponentModel; +using Xunit; + +namespace GitHub.Copilot.SDK.Test; + +public class MergeExcludedToolsTests +{ + [Fact] + public void Tool_Names_Are_Added_To_ExcludedTools() + { + var tools = new List + { + AIFunctionFactory.Create(Noop, "my_tool"), + }; + + var result = CopilotClient.MergeExcludedTools(null, tools); + + Assert.NotNull(result); + Assert.Contains("my_tool", result!); + } + + [Fact] + public void Merges_With_Existing_ExcludedTools_And_Deduplicates() + { + var existing = new List { "view", "my_tool" }; + var tools = new List + { + AIFunctionFactory.Create(Noop, "my_tool"), + AIFunctionFactory.Create(Noop, "another_tool"), + }; + + var result = CopilotClient.MergeExcludedTools(existing, tools); + + Assert.NotNull(result); + Assert.Equal(3, result!.Count); + Assert.Contains("view", result); + Assert.Contains("my_tool", result); + Assert.Contains("another_tool", result); + } + + [Fact] + public void Returns_Null_When_No_Tools_Provided() + { + var result = CopilotClient.MergeExcludedTools(null, null); + Assert.Null(result); + } + + [Fact] + public void Returns_ExcludedTools_Unchanged_When_Tools_Empty() + { + var existing = new List { "view" }; + var result = CopilotClient.MergeExcludedTools(existing, new List()); + + Assert.Same(existing, result); + } + + [Fact] + public void Returns_Tool_Names_When_ExcludedTools_Null() + { + var tools = new List + { + AIFunctionFactory.Create(Noop, "my_tool"), + }; + + var result = CopilotClient.MergeExcludedTools(null, tools); + + Assert.NotNull(result); + Assert.Single(result!); + Assert.Equal("my_tool", result[0]); + } + + [Description("No-op")] + static string Noop() => ""; +} diff --git a/dotnet/test/ToolsTests.cs b/dotnet/test/ToolsTests.cs index c6449ec8f..886d9463c 100644 --- a/dotnet/test/ToolsTests.cs +++ b/dotnet/test/ToolsTests.cs @@ -152,6 +152,29 @@ record City(int CountryId, string CityName, int Population); [JsonSerializable(typeof(JsonElement))] private partial class ToolsTestsJsonContext : JsonSerializerContext; + [Fact] + public async Task Overrides_Built_In_Tool_With_Custom_Tool() + { + var session = await CreateSessionAsync(new SessionConfig + { + Tools = [AIFunctionFactory.Create(CustomGrep, "grep")], + OnPermissionRequest = PermissionHandler.ApproveAll, + }); + + await session.SendAsync(new MessageOptions + { + Prompt = "Use grep to search for the word 'hello'" + }); + + var assistantMessage = await TestHelper.GetFinalAssistantMessageAsync(session); + Assert.NotNull(assistantMessage); + Assert.Contains("CUSTOM_GREP_RESULT", assistantMessage!.Data.Content ?? string.Empty); + + [Description("A custom grep implementation that overrides the built-in")] + static string CustomGrep([Description("Search query")] string query) + => $"CUSTOM_GREP_RESULT: {query}"; + } + [Fact(Skip = "Behaves as if no content was in the result. Likely that binary results aren't fully implemented yet.")] public async Task Can_Return_Binary_Result() { diff --git a/go/README.md b/go/README.md index b010fc211..a36c96474 100644 --- a/go/README.md +++ b/go/README.md @@ -267,6 +267,17 @@ session, _ := client.CreateSession(context.Background(), &copilot.SessionConfig{ When the model selects a tool, the SDK automatically runs your handler (in parallel with other calls) and responds to the CLI's `tool.call` with the handler's result. +#### Overriding Built-in Tools + +If you register a tool with the same name as a built-in CLI tool (e.g. `edit_file`, `read_file`), your tool takes precedence. The SDK automatically adds the tool name to `ExcludedTools`, so the built-in is disabled and your handler is called instead. This is useful when you need custom behavior for built-in operations. + +```go +editFile := copilot.DefineTool("edit_file", "Custom file editor with project-specific validation", + func(params EditFileParams, inv copilot.ToolInvocation) (any, error) { + // your logic + }) +``` + ## Streaming Enable streaming to receive assistant response chunks as they're generated: diff --git a/go/client.go b/go/client.go index 50e6b4ccb..6389a8271 100644 --- a/go/client.go +++ b/go/client.go @@ -468,7 +468,7 @@ func (c *Client) CreateSession(ctx context.Context, config *SessionConfig) (*Ses req.Tools = config.Tools req.SystemMessage = config.SystemMessage req.AvailableTools = config.AvailableTools - req.ExcludedTools = config.ExcludedTools + req.ExcludedTools = mergeExcludedTools(config.ExcludedTools, config.Tools) req.Provider = config.Provider req.WorkingDirectory = config.WorkingDirectory req.MCPServers = config.MCPServers @@ -565,7 +565,7 @@ func (c *Client) ResumeSessionWithOptions(ctx context.Context, sessionID string, req.Tools = config.Tools req.Provider = config.Provider req.AvailableTools = config.AvailableTools - req.ExcludedTools = config.ExcludedTools + req.ExcludedTools = mergeExcludedTools(config.ExcludedTools, config.Tools) if config.Streaming { req.Streaming = Bool(true) } @@ -1353,6 +1353,29 @@ func buildFailedToolResult(internalError string) ToolResult { } } +// mergeExcludedTools returns a deduplicated list combining excludedTools with +// the names of any SDK-registered tools, so the CLI won't handle them. +func mergeExcludedTools(excludedTools []string, tools []Tool) []string { + if len(tools) == 0 { + return excludedTools + } + seen := make(map[string]bool, len(excludedTools)+len(tools)) + merged := make([]string, 0, len(excludedTools)+len(tools)) + for _, name := range excludedTools { + if !seen[name] { + seen[name] = true + merged = append(merged, name) + } + } + for _, t := range tools { + if !seen[t.Name] { + seen[t.Name] = true + merged = append(merged, t.Name) + } + } + return merged +} + // buildUnsupportedToolResult creates a failure ToolResult for an unsupported tool. func buildUnsupportedToolResult(toolName string) ToolResult { return ToolResult{ diff --git a/go/client_test.go b/go/client_test.go index 2d198f224..8b8ece1b9 100644 --- a/go/client_test.go +++ b/go/client_test.go @@ -447,6 +447,42 @@ func TestResumeSessionRequest_ClientName(t *testing.T) { }) } +func TestMergeExcludedTools(t *testing.T) { + t.Run("adds tool names to excluded tools", func(t *testing.T) { + tools := []Tool{{Name: "edit_file"}, {Name: "read_file"}} + got := mergeExcludedTools(nil, tools) + want := []string{"edit_file", "read_file"} + if !reflect.DeepEqual(got, want) { + t.Errorf("got %v, want %v", got, want) + } + }) + + t.Run("deduplicates with existing excluded tools", func(t *testing.T) { + excluded := []string{"edit_file", "run_shell"} + tools := []Tool{{Name: "edit_file"}, {Name: "read_file"}} + got := mergeExcludedTools(excluded, tools) + want := []string{"edit_file", "run_shell", "read_file"} + if !reflect.DeepEqual(got, want) { + t.Errorf("got %v, want %v", got, want) + } + }) + + t.Run("returns original list when no tools provided", func(t *testing.T) { + excluded := []string{"edit_file"} + got := mergeExcludedTools(excluded, nil) + if !reflect.DeepEqual(got, excluded) { + t.Errorf("got %v, want %v", got, excluded) + } + }) + + t.Run("returns nil when both inputs are empty", func(t *testing.T) { + got := mergeExcludedTools(nil, nil) + if got != nil { + t.Errorf("got %v, want nil", got) + } + }) +} + func TestClient_CreateSession_RequiresPermissionHandler(t *testing.T) { t.Run("returns error when config is nil", func(t *testing.T) { client := NewClient(nil) diff --git a/go/internal/e2e/tools_test.go b/go/internal/e2e/tools_test.go index e5b93fa25..563c26dd9 100644 --- a/go/internal/e2e/tools_test.go +++ b/go/internal/e2e/tools_test.go @@ -264,6 +264,41 @@ func TestTools(t *testing.T) { } }) + t.Run("overrides built-in tool with custom tool", func(t *testing.T) { + ctx.ConfigureForTest(t) + + type GrepParams struct { + Query string `json:"query" jsonschema:"Search query"` + } + + session, err := client.CreateSession(t.Context(), &copilot.SessionConfig{ + OnPermissionRequest: copilot.PermissionHandler.ApproveAll, + Tools: []copilot.Tool{ + copilot.DefineTool("grep", "A custom grep implementation that overrides the built-in", + func(params GrepParams, inv copilot.ToolInvocation) (string, error) { + return "CUSTOM_GREP_RESULT: " + params.Query, nil + }), + }, + }) + if err != nil { + t.Fatalf("Failed to create session: %v", err) + } + + _, err = session.Send(t.Context(), copilot.MessageOptions{Prompt: "Use grep to search for the word 'hello'"}) + if err != nil { + t.Fatalf("Failed to send message: %v", err) + } + + answer, err := testharness.GetFinalAssistantMessage(t.Context(), session) + if err != nil { + t.Fatalf("Failed to get assistant message: %v", err) + } + + if answer.Data.Content == nil || !strings.Contains(*answer.Data.Content, "CUSTOM_GREP_RESULT") { + t.Errorf("Expected answer to contain 'CUSTOM_GREP_RESULT', got %v", answer.Data.Content) + } + }) + t.Run("invokes custom tool with permission handler", func(t *testing.T) { ctx.ConfigureForTest(t) diff --git a/nodejs/README.md b/nodejs/README.md index 31558b8ab..03ed2f751 100644 --- a/nodejs/README.md +++ b/nodejs/README.md @@ -402,6 +402,18 @@ const session = await client.createSession({ When Copilot invokes `lookup_issue`, the client automatically runs your handler and responds to the CLI. Handlers can return any JSON-serializable value (automatically wrapped), a simple string, or a `ToolResultObject` for full control over result metadata. Raw JSON schemas are also supported if Zod isn't desired. +#### Overriding Built-in Tools + +If you register a tool with the same name as a built-in CLI tool (e.g. `edit_file`, `read_file`), your tool takes precedence. The SDK automatically adds the tool name to `excludedTools`, so the built-in is disabled and your handler is called instead. This is useful when you need custom behavior for built-in operations. + +```ts +defineTool("edit_file", { + description: "Custom file editor with project-specific validation", + parameters: z.object({ path: z.string(), content: z.string() }), + handler: async ({ path, content }) => { /* your logic */ }, +}) +``` + ### System Message Customization Control the system prompt using `systemMessage` in session config: diff --git a/nodejs/src/client.ts b/nodejs/src/client.ts index 6d841c7cc..5d31a5898 100644 --- a/nodejs/src/client.ts +++ b/nodejs/src/client.ts @@ -50,6 +50,19 @@ import type { TypedSessionLifecycleHandler, } from "./types.js"; +/** + * Merge user-provided excludedTools with tool names from config.tools so that + * SDK-registered tools automatically override built-in CLI tools. + */ +function mergeExcludedTools( + excludedTools: string[] | undefined, + tools: Tool[] | undefined +): string[] | undefined { + const toolNames = tools?.map((t) => t.name); + if (!excludedTools?.length && !toolNames?.length) return excludedTools; + return [...new Set([...(excludedTools ?? []), ...(toolNames ?? [])])]; +} + /** * Check if value is a Zod schema (has toJSONSchema method) */ @@ -536,7 +549,7 @@ export class CopilotClient { })), systemMessage: config.systemMessage, availableTools: config.availableTools, - excludedTools: config.excludedTools, + excludedTools: mergeExcludedTools(config.excludedTools, config.tools), provider: config.provider, requestPermission: true, requestUserInput: !!config.onUserInputRequest, @@ -616,7 +629,7 @@ export class CopilotClient { reasoningEffort: config.reasoningEffort, systemMessage: config.systemMessage, availableTools: config.availableTools, - excludedTools: config.excludedTools, + excludedTools: mergeExcludedTools(config.excludedTools, config.tools), tools: config.tools?.map((tool) => ({ name: tool.name, description: tool.description, diff --git a/nodejs/test/client.test.ts b/nodejs/test/client.test.ts index 6fa33e9ec..4dea0de75 100644 --- a/nodejs/test/client.test.ts +++ b/nodejs/test/client.test.ts @@ -267,4 +267,71 @@ describe("CopilotClient", () => { }).toThrow(/githubToken and useLoggedInUser cannot be used with cliUrl/); }); }); + + describe("excludedTools merging with config.tools", () => { + it("adds tool names from config.tools to excludedTools in session.create", async () => { + const client = new CopilotClient(); + await client.start(); + onTestFinished(() => client.forceStop()); + + const spy = vi.spyOn((client as any).connection!, "sendRequest"); + await client.createSession({ + tools: [{ name: "edit_file", description: "edit", handler: async () => "ok" }], + }); + + expect(spy).toHaveBeenCalledWith( + "session.create", + expect.objectContaining({ excludedTools: ["edit_file"] }) + ); + }); + + it("merges and deduplicates with existing excludedTools", async () => { + const client = new CopilotClient(); + await client.start(); + onTestFinished(() => client.forceStop()); + + const spy = vi.spyOn((client as any).connection!, "sendRequest"); + await client.createSession({ + tools: [{ name: "edit_file", description: "edit", handler: async () => "ok" }], + excludedTools: ["edit_file", "run_command"], + }); + + const payload = spy.mock.calls.find((c) => c[0] === "session.create")![1] as any; + expect(payload.excludedTools).toEqual( + expect.arrayContaining(["edit_file", "run_command"]) + ); + expect(payload.excludedTools).toHaveLength(2); + }); + + it("leaves excludedTools unchanged when no tools provided", async () => { + const client = new CopilotClient(); + await client.start(); + onTestFinished(() => client.forceStop()); + + const spy = vi.spyOn((client as any).connection!, "sendRequest"); + await client.createSession({ excludedTools: ["run_command"] }); + + expect(spy).toHaveBeenCalledWith( + "session.create", + expect.objectContaining({ excludedTools: ["run_command"] }) + ); + }); + + it("adds tool names from config.tools to excludedTools in session.resume", 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, { + tools: [{ name: "edit_file", description: "edit", handler: async () => "ok" }], + }); + + expect(spy).toHaveBeenCalledWith( + "session.resume", + expect.objectContaining({ excludedTools: ["edit_file"] }) + ); + }); + }); }); diff --git a/nodejs/test/e2e/tools.test.ts b/nodejs/test/e2e/tools.test.ts index feab2fbfa..d36172e25 100644 --- a/nodejs/test/e2e/tools.test.ts +++ b/nodejs/test/e2e/tools.test.ts @@ -162,6 +162,26 @@ describe("Custom tools", async () => { expect(customToolRequests[0].toolName).toBe("encrypt_string"); }); + it("overrides built-in tool with custom tool", async () => { + const session = await client.createSession({ + onPermissionRequest: approveAll, + tools: [ + defineTool("grep", { + description: "A custom grep implementation that overrides the built-in", + parameters: z.object({ + query: z.string().describe("Search query"), + }), + handler: ({ query }) => `CUSTOM_GREP_RESULT: ${query}`, + }), + ], + }); + + const assistantMessage = await session.sendAndWait({ + prompt: "Use grep to search for the word 'hello'", + }); + expect(assistantMessage?.data.content).toContain("CUSTOM_GREP_RESULT"); + }); + it("denies custom tool when permission denied", async () => { let toolHandlerCalled = false; diff --git a/python/README.md b/python/README.md index aa82e0c34..09d62ae30 100644 --- a/python/README.md +++ b/python/README.md @@ -210,6 +210,20 @@ session = await client.create_session({ The SDK automatically handles `tool.call`, executes your handler (sync or async), and responds with the final result when the tool completes. +#### Overriding Built-in Tools + +If you register a tool with the same name as a built-in CLI tool (e.g. `edit_file`, `read_file`), your tool takes precedence. The SDK automatically adds the tool name to `excluded_tools`, so the built-in is disabled and your handler is called instead. This is useful when you need custom behavior for built-in operations. + +```python +class EditFileParams(BaseModel): + path: str = Field(description="File path") + content: str = Field(description="New file content") + +@define_tool(name="edit_file", description="Custom file editor with project-specific validation") +async def edit_file(params: EditFileParams) -> str: + # your logic +``` + ## Image Support The SDK supports image attachments via the `attachments` parameter. You can attach images by providing their file path: diff --git a/python/copilot/client.py b/python/copilot/client.py index 88b3d97a7..6904d012b 100644 --- a/python/copilot/client.py +++ b/python/copilot/client.py @@ -495,7 +495,10 @@ async def create_session(self, config: SessionConfig) -> CopilotSession: available_tools = cfg.get("available_tools") if available_tools is not None: payload["availableTools"] = available_tools - excluded_tools = cfg.get("excluded_tools") + excluded_tools = list(cfg.get("excluded_tools") or []) + if tools: + tool_names = [t.name for t in tools] + excluded_tools = list(dict.fromkeys(excluded_tools + tool_names)) if excluded_tools: payload["excludedTools"] = excluded_tools @@ -673,7 +676,10 @@ async def resume_session(self, session_id: str, config: ResumeSessionConfig) -> if available_tools is not None: payload["availableTools"] = available_tools - excluded_tools = cfg.get("excluded_tools") + excluded_tools = list(cfg.get("excluded_tools") or []) + if tools: + tool_names = [t.name for t in tools] + excluded_tools = list(dict.fromkeys(excluded_tools + tool_names)) if excluded_tools: payload["excludedTools"] = excluded_tools diff --git a/python/e2e/test_tools.py b/python/e2e/test_tools.py index e4a9f5f06..e25b23744 100644 --- a/python/e2e/test_tools.py +++ b/python/e2e/test_tools.py @@ -133,6 +133,22 @@ def db_query(params: DbQueryParams, invocation: ToolInvocation) -> list[City]: assert "135460" in response_content.replace(",", "") assert "204356" in response_content.replace(",", "") + async def test_overrides_built_in_tool_with_custom_tool(self, ctx: E2ETestContext): + class GrepParams(BaseModel): + query: str = Field(description="Search query") + + @define_tool("grep", description="A custom grep implementation that overrides the built-in") + def custom_grep(params: GrepParams, invocation: ToolInvocation) -> str: + return f"CUSTOM_GREP_RESULT: {params.query}" + + session = await ctx.client.create_session( + {"tools": [custom_grep], "on_permission_request": PermissionHandler.approve_all} + ) + + await session.send({"prompt": "Use grep to search for the word 'hello'"}) + assistant_message = await get_final_assistant_message(session) + assert "CUSTOM_GREP_RESULT" in assistant_message.data.content + async def test_invokes_custom_tool_with_permission_handler(self, ctx: E2ETestContext): class EncryptParams(BaseModel): input: str = Field(description="String to encrypt") diff --git a/python/test_client.py b/python/test_client.py index c6ad027f5..4a139e942 100644 --- a/python/test_client.py +++ b/python/test_client.py @@ -6,7 +6,7 @@ import pytest -from copilot import CopilotClient, PermissionHandler +from copilot import CopilotClient, PermissionHandler, define_tool from e2e.testharness import CLI_PATH @@ -176,6 +176,108 @@ def test_use_logged_in_user_with_cli_url_raises(self): ) +class TestExcludedToolsFromRegisteredTools: + @pytest.mark.asyncio + async def test_tools_added_to_excluded_tools(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 + + @define_tool(description="Edit a file") + def edit_file(params) -> str: + return "ok" + + await client.create_session({"tools": [edit_file]}) + assert "edit_file" in captured["session.create"]["excludedTools"] + finally: + await client.force_stop() + + @pytest.mark.asyncio + async def test_deduplication_with_existing_excluded_tools(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 + + @define_tool(description="Edit a file") + def edit_file(params) -> str: + return "ok" + + await client.create_session( + { + "tools": [edit_file], + "excluded_tools": ["edit_file", "other_tool"], + } + ) + excluded = captured["session.create"]["excludedTools"] + assert excluded.count("edit_file") == 1 + assert "other_tool" in excluded + finally: + await client.force_stop() + + @pytest.mark.asyncio + async def test_no_excluded_tools_when_no_tools(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({}) + assert "excludedTools" not in captured["session.create"] + finally: + await client.force_stop() + + @pytest.mark.asyncio + async def test_resume_session_adds_tools_to_excluded(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 + + @define_tool(description="Edit a file") + def edit_file(params) -> str: + return "ok" + + await client.resume_session(session.session_id, {"tools": [edit_file]}) + assert "edit_file" in captured["session.resume"]["excludedTools"] + finally: + await client.force_stop() + + class TestSessionConfigForwarding: @pytest.mark.asyncio async def test_create_session_forwards_client_name(self): diff --git a/test/scenarios/tools/tool-overrides/README.md b/test/scenarios/tools/tool-overrides/README.md new file mode 100644 index 000000000..f33f22bc3 --- /dev/null +++ b/test/scenarios/tools/tool-overrides/README.md @@ -0,0 +1,31 @@ +# Config Sample: Tool Overrides + +Demonstrates how registering a custom tool with the same name as a built-in tool automatically overrides the built-in. The SDK's `mergeExcludedTools` logic adds custom tool names to `excludedTools`, so the CLI uses your implementation instead. + +## What Each Sample Does + +1. Creates a session with a custom `grep` tool that returns `"CUSTOM_GREP_RESULT: "` +2. Sends: _"Use grep to search for the word 'hello'"_ +3. Prints the response — which should contain `CUSTOM_GREP_RESULT` (proving the custom tool ran, not the built-in) + +## Configuration + +| Option | Value | Effect | +|--------|-------|--------| +| `tools` | Custom `grep` tool | Overrides the built-in `grep` with a custom implementation | + +Behind the scenes, the SDK automatically adds `"grep"` to `excludedTools` so the CLI's built-in grep is disabled. + +## Run + +```bash +./verify.sh +``` + +Requires the `copilot` binary (auto-detected or set `COPILOT_CLI_PATH`) and `GITHUB_TOKEN`. + +## Verification + +The verify script checks that: +- The response contains `CUSTOM_GREP_RESULT` (custom tool was invoked) +- The response does **not** contain typical built-in grep output patterns diff --git a/test/scenarios/tools/tool-overrides/csharp/Program.cs b/test/scenarios/tools/tool-overrides/csharp/Program.cs new file mode 100644 index 000000000..438f53ce6 --- /dev/null +++ b/test/scenarios/tools/tool-overrides/csharp/Program.cs @@ -0,0 +1,39 @@ +using System.ComponentModel; +using GitHub.Copilot.SDK; +using Microsoft.Extensions.AI; + +using var client = new CopilotClient(new CopilotClientOptions +{ + CliPath = Environment.GetEnvironmentVariable("COPILOT_CLI_PATH"), + GitHubToken = Environment.GetEnvironmentVariable("GITHUB_TOKEN"), +}); + +await client.StartAsync(); + +try +{ + await using var session = await client.CreateSessionAsync(new SessionConfig + { + Model = "claude-haiku-4.5", + OnPermissionRequest = PermissionHandler.ApproveAll, + Tools = [AIFunctionFactory.Create(CustomGrep, "grep")], + }); + + var response = await session.SendAndWaitAsync(new MessageOptions + { + Prompt = "Use grep to search for the word 'hello'", + }); + + if (response != null) + { + Console.WriteLine(response.Data?.Content); + } +} +finally +{ + await client.StopAsync(); +} + +[Description("A custom grep implementation that overrides the built-in")] +static string CustomGrep([Description("Search query")] string query) + => $"CUSTOM_GREP_RESULT: {query}"; diff --git a/test/scenarios/tools/tool-overrides/csharp/csharp.csproj b/test/scenarios/tools/tool-overrides/csharp/csharp.csproj new file mode 100644 index 000000000..48e375961 --- /dev/null +++ b/test/scenarios/tools/tool-overrides/csharp/csharp.csproj @@ -0,0 +1,13 @@ + + + Exe + net8.0 + LatestMajor + enable + enable + true + + + + + diff --git a/test/scenarios/tools/tool-overrides/go/go.mod b/test/scenarios/tools/tool-overrides/go/go.mod new file mode 100644 index 000000000..353066761 --- /dev/null +++ b/test/scenarios/tools/tool-overrides/go/go.mod @@ -0,0 +1,9 @@ +module github.com/github/copilot-sdk/samples/tools/tool-overrides/go + +go 1.24 + +require github.com/github/copilot-sdk/go v0.0.0 + +require github.com/google/jsonschema-go v0.4.2 // indirect + +replace github.com/github/copilot-sdk/go => ../../../../../go diff --git a/test/scenarios/tools/tool-overrides/go/go.sum b/test/scenarios/tools/tool-overrides/go/go.sum new file mode 100644 index 000000000..6e171099c --- /dev/null +++ b/test/scenarios/tools/tool-overrides/go/go.sum @@ -0,0 +1,4 @@ +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/jsonschema-go v0.4.2 h1:tmrUohrwoLZZS/P3x7ex0WAVknEkBZM46iALbcqoRA8= +github.com/google/jsonschema-go v0.4.2/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE= diff --git a/test/scenarios/tools/tool-overrides/go/main.go b/test/scenarios/tools/tool-overrides/go/main.go new file mode 100644 index 000000000..f2f5119d3 --- /dev/null +++ b/test/scenarios/tools/tool-overrides/go/main.go @@ -0,0 +1,52 @@ +package main + +import ( + "context" + "fmt" + "log" + "os" + + copilot "github.com/github/copilot-sdk/go" +) + +type GrepParams struct { + Query string `json:"query" jsonschema:"Search query"` +} + +func main() { + client := copilot.NewClient(&copilot.ClientOptions{ + GitHubToken: os.Getenv("GITHUB_TOKEN"), + }) + + ctx := context.Background() + if err := client.Start(ctx); err != nil { + log.Fatal(err) + } + defer client.Stop() + + session, err := client.CreateSession(ctx, &copilot.SessionConfig{ + Model: "claude-haiku-4.5", + OnPermissionRequest: copilot.PermissionHandler.ApproveAll, + Tools: []copilot.Tool{ + copilot.DefineTool("grep", "A custom grep implementation that overrides the built-in", + func(params GrepParams, inv copilot.ToolInvocation) (string, error) { + return "CUSTOM_GREP_RESULT: " + params.Query, nil + }), + }, + }) + if err != nil { + log.Fatal(err) + } + defer session.Destroy() + + response, err := session.SendAndWait(ctx, copilot.MessageOptions{ + Prompt: "Use grep to search for the word 'hello'", + }) + if err != nil { + log.Fatal(err) + } + + if response != nil && response.Data.Content != nil { + fmt.Println(*response.Data.Content) + } +} diff --git a/test/scenarios/tools/tool-overrides/python/main.py b/test/scenarios/tools/tool-overrides/python/main.py new file mode 100644 index 000000000..6e9e870f1 --- /dev/null +++ b/test/scenarios/tools/tool-overrides/python/main.py @@ -0,0 +1,45 @@ +import asyncio +import os + +from pydantic import BaseModel, Field + +from copilot import CopilotClient, PermissionHandler, define_tool + + +class GrepParams(BaseModel): + query: str = Field(description="Search query") + + +@define_tool("grep", description="A custom grep implementation that overrides the built-in") +def custom_grep(params: GrepParams) -> str: + return f"CUSTOM_GREP_RESULT: {params.query}" + + +async def main(): + opts = {"github_token": os.environ.get("GITHUB_TOKEN")} + if os.environ.get("COPILOT_CLI_PATH"): + opts["cli_path"] = os.environ["COPILOT_CLI_PATH"] + client = CopilotClient(opts) + + try: + session = await client.create_session( + { + "model": "claude-haiku-4.5", + "tools": [custom_grep], + "on_permission_request": PermissionHandler.approve_all, + } + ) + + response = await session.send_and_wait( + {"prompt": "Use grep to search for the word 'hello'"} + ) + + if response: + print(response.data.content) + + await session.destroy() + finally: + await client.stop() + + +asyncio.run(main()) diff --git a/test/scenarios/tools/tool-overrides/python/requirements.txt b/test/scenarios/tools/tool-overrides/python/requirements.txt new file mode 100644 index 000000000..f9a8f4d60 --- /dev/null +++ b/test/scenarios/tools/tool-overrides/python/requirements.txt @@ -0,0 +1 @@ +-e ../../../../../python diff --git a/test/scenarios/tools/tool-overrides/typescript/package.json b/test/scenarios/tools/tool-overrides/typescript/package.json new file mode 100644 index 000000000..64e958406 --- /dev/null +++ b/test/scenarios/tools/tool-overrides/typescript/package.json @@ -0,0 +1,18 @@ +{ + "name": "tools-tool-overrides-typescript", + "version": "1.0.0", + "private": true, + "description": "Config sample — custom tool overriding a built-in tool", + "type": "module", + "scripts": { + "build": "esbuild src/index.ts --bundle --platform=node --format=esm --outfile=dist/index.js --banner:js=\"import { createRequire } from 'module'; const require = createRequire(import.meta.url);\"", + "start": "node dist/index.js" + }, + "dependencies": { + "@github/copilot-sdk": "file:../../../../../nodejs" + }, + "devDependencies": { + "@types/node": "^20.0.0", + "esbuild": "^0.24.0" + } +} diff --git a/test/scenarios/tools/tool-overrides/typescript/src/index.ts b/test/scenarios/tools/tool-overrides/typescript/src/index.ts new file mode 100644 index 000000000..a27d89eeb --- /dev/null +++ b/test/scenarios/tools/tool-overrides/typescript/src/index.ts @@ -0,0 +1,42 @@ +import { CopilotClient, defineTool, approveAll } from "@github/copilot-sdk"; +import { z } from "zod"; + +async function main() { + const client = new CopilotClient({ + ...(process.env.COPILOT_CLI_PATH && { cliPath: process.env.COPILOT_CLI_PATH }), + githubToken: process.env.GITHUB_TOKEN, + }); + + try { + const session = await client.createSession({ + model: "claude-haiku-4.5", + onPermissionRequest: approveAll, + tools: [ + defineTool("grep", { + description: "A custom grep implementation that overrides the built-in", + parameters: z.object({ + query: z.string().describe("Search query"), + }), + handler: ({ query }) => `CUSTOM_GREP_RESULT: ${query}`, + }), + ], + }); + + const response = await session.sendAndWait({ + prompt: "Use grep to search for the word 'hello'", + }); + + if (response) { + console.log(response.data.content); + } + + await session.destroy(); + } finally { + await client.stop(); + } +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/test/scenarios/tools/tool-overrides/verify.sh b/test/scenarios/tools/tool-overrides/verify.sh new file mode 100755 index 000000000..b7687de50 --- /dev/null +++ b/test/scenarios/tools/tool-overrides/verify.sh @@ -0,0 +1,138 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +ROOT_DIR="$(cd "$SCRIPT_DIR/../../.." && pwd)" +PASS=0 +FAIL=0 +ERRORS="" +TIMEOUT=60 + +# COPILOT_CLI_PATH is optional — the SDK discovers the bundled CLI automatically. +# Set it only to override with a custom binary path. +if [ -n "${COPILOT_CLI_PATH:-}" ]; then + echo "Using CLI override: $COPILOT_CLI_PATH" +fi + +# Ensure GITHUB_TOKEN is set for auth +if [ -z "${GITHUB_TOKEN:-}" ]; then + if command -v gh &>/dev/null; then + export GITHUB_TOKEN=$(gh auth token 2>/dev/null) + fi +fi +if [ -z "${GITHUB_TOKEN:-}" ]; then + echo "⚠️ GITHUB_TOKEN not set and gh auth not available. E2E runs will fail." +fi +echo "" + +# Use gtimeout on macOS, timeout on Linux +if command -v gtimeout &>/dev/null; then + TIMEOUT_CMD="gtimeout" +elif command -v timeout &>/dev/null; then + TIMEOUT_CMD="timeout" +else + echo "⚠️ No timeout command found. Install coreutils (brew install coreutils)." + echo " Running without timeouts." + TIMEOUT_CMD="" +fi + +check() { + local name="$1" + shift + printf "━━━ %s ━━━\n" "$name" + if output=$("$@" 2>&1); then + echo "$output" + echo "✅ $name passed" + PASS=$((PASS + 1)) + else + echo "$output" + echo "❌ $name failed" + FAIL=$((FAIL + 1)) + ERRORS="$ERRORS\n - $name" + fi + echo "" +} + +run_with_timeout() { + local name="$1" + shift + printf "━━━ %s ━━━\n" "$name" + local output="" + local code=0 + if [ -n "$TIMEOUT_CMD" ]; then + output=$($TIMEOUT_CMD "$TIMEOUT" "$@" 2>&1) && code=0 || code=$? + else + output=$("$@" 2>&1) && code=0 || code=$? + fi + + echo "$output" + + # Check that custom grep tool was used (not built-in) + if [ "$code" -eq 0 ] && [ -n "$output" ]; then + if echo "$output" | grep -q "CUSTOM_GREP_RESULT"; then + echo "✅ $name passed (confirmed custom tool override)" + PASS=$((PASS + 1)) + else + echo "⚠️ $name ran but response doesn't contain CUSTOM_GREP_RESULT" + echo "❌ $name failed (expected pattern not found)" + FAIL=$((FAIL + 1)) + ERRORS="$ERRORS\n - $name" + fi + elif [ "$code" -eq 124 ]; then + echo "❌ $name failed (timed out after ${TIMEOUT}s)" + FAIL=$((FAIL + 1)) + ERRORS="$ERRORS\n - $name (timeout)" + else + echo "❌ $name failed (exit code $code)" + FAIL=$((FAIL + 1)) + ERRORS="$ERRORS\n - $name" + fi + echo "" +} + +echo "══════════════════════════════════════" +echo " Verifying tools/tool-overrides samples" +echo " Phase 1: Build" +echo "══════════════════════════════════════" +echo "" + +# TypeScript: install + compile +check "TypeScript (install)" bash -c "cd '$SCRIPT_DIR/typescript' && npm install --ignore-scripts 2>&1" +check "TypeScript (build)" bash -c "cd '$SCRIPT_DIR/typescript' && npm run build 2>&1" + +# Python: install + syntax +check "Python (install)" bash -c "python3 -c 'import copilot' 2>/dev/null || (cd '$SCRIPT_DIR/python' && pip3 install -r requirements.txt --quiet 2>&1)" +check "Python (syntax)" bash -c "python3 -c \"import ast; ast.parse(open('$SCRIPT_DIR/python/main.py').read()); print('Syntax OK')\"" + +# Go: build +check "Go (build)" bash -c "cd '$SCRIPT_DIR/go' && go build -o tool-overrides-go . 2>&1" + +# C#: build +check "C# (build)" bash -c "cd '$SCRIPT_DIR/csharp' && dotnet build --nologo -v q 2>&1" + + +echo "══════════════════════════════════════" +echo " Phase 2: E2E Run (timeout ${TIMEOUT}s each)" +echo "══════════════════════════════════════" +echo "" + +# TypeScript: run +run_with_timeout "TypeScript (run)" bash -c "cd '$SCRIPT_DIR/typescript' && node dist/index.js" + +# Python: run +run_with_timeout "Python (run)" bash -c "cd '$SCRIPT_DIR/python' && python3 main.py" + +# Go: run +run_with_timeout "Go (run)" bash -c "cd '$SCRIPT_DIR/go' && ./tool-overrides-go" + +# C#: run +run_with_timeout "C# (run)" bash -c "cd '$SCRIPT_DIR/csharp' && dotnet run --no-build 2>&1" + + +echo "══════════════════════════════════════" +echo " Results: $PASS passed, $FAIL failed" +echo "══════════════════════════════════════" +if [ "$FAIL" -gt 0 ]; then + echo -e "Failures:$ERRORS" + exit 1 +fi diff --git a/test/snapshots/tools/overrides_built_in_tool_with_custom_tool.yaml b/test/snapshots/tools/overrides_built_in_tool_with_custom_tool.yaml new file mode 100644 index 000000000..6865beeb5 --- /dev/null +++ b/test/snapshots/tools/overrides_built_in_tool_with_custom_tool.yaml @@ -0,0 +1,20 @@ +models: + - claude-sonnet-4.5 +conversations: + - messages: + - role: system + content: ${system} + - role: user + content: Use grep to search for the word 'hello' + - role: assistant + tool_calls: + - id: toolcall_0 + type: function + function: + name: grep + arguments: '{"query":"hello"}' + - role: tool + tool_call_id: toolcall_0 + content: "CUSTOM_GREP_RESULT: hello" + - role: assistant + content: "The grep result is: **CUSTOM_GREP_RESULT: hello**"