From 87cc7f00413cc43948e7db3e19b220670176d9d2 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 20 Feb 2026 05:04:29 +0000 Subject: [PATCH 1/6] Initial plan From e2775526663dffb74516b4f5fda0d3e07e8924b1 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 20 Feb 2026 05:20:55 +0000 Subject: [PATCH 2/6] feat: Support overriding built-in tools (Issue #411) Auto-add user-registered tool names to excludedTools in session.create/resume RPC payloads so that SDK-registered tools override CLI built-in tools. - Node.js: mergeExcludedTools() helper + createSession/resumeSession updates - Python: inline merge logic in create_session/resume_session - Go: mergeExcludedTools() helper + CreateSession/ResumeSessionWithOptions updates - .NET: MergeExcludedTools() helper + CreateSessionAsync/ResumeSessionAsync updates - Tests added for all 4 SDKs - All 4 READMEs updated with "Overriding Built-in Tools" documentation Co-authored-by: patniko <26906478+patniko@users.noreply.github.com> --- dotnet/README.md | 13 ++++ dotnet/src/Client.cs | 12 ++- dotnet/src/GitHub.Copilot.SDK.csproj | 4 + dotnet/test/MergeExcludedToolsTests.cs | 79 +++++++++++++++++++ go/README.md | 11 +++ go/client.go | 27 ++++++- go/client_test.go | 36 +++++++++ nodejs/README.md | 12 +++ nodejs/src/client.ts | 17 ++++- nodejs/test/client.test.ts | 67 ++++++++++++++++ python/README.md | 14 ++++ python/copilot/client.py | 10 ++- python/test_client.py | 102 ++++++++++++++++++++++++- 13 files changed, 395 insertions(+), 9 deletions(-) create mode 100644 dotnet/test/MergeExcludedToolsTests.cs diff --git a/dotnet/README.md b/dotnet/README.md index bda10059d..e9066fb10 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 8c70a4a2b..569b6c785 100644 --- a/dotnet/src/Client.cs +++ b/dotnet/src/Client.cs @@ -382,7 +382,7 @@ public async Task CreateSessionAsync(SessionConfig? config = nul 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, @@ -467,7 +467,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, @@ -852,6 +852,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/go/README.md b/go/README.md index 37cb7ce07..d4d50cbae 100644 --- a/go/README.md +++ b/go/README.md @@ -266,6 +266,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 68f58d859..7894e3902 100644 --- a/go/client.go +++ b/go/client.go @@ -461,7 +461,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 @@ -558,7 +558,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) } @@ -1352,6 +1352,29 @@ func buildFailedToolResult(internalError string) ToolResult { } // buildUnsupportedToolResult creates a failure ToolResult for an unsupported tool. +// 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 +} + func buildUnsupportedToolResult(toolName string) ToolResult { return ToolResult{ TextResultForLLM: fmt.Sprintf("Tool '%s' is not supported by this client instance.", toolName), diff --git a/go/client_test.go b/go/client_test.go index b2e9cdce6..f813d51ac 100644 --- a/go/client_test.go +++ b/go/client_test.go @@ -444,3 +444,39 @@ 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) +} +}) +} 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 7df64e507..f8a710a4c 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) */ @@ -529,7 +542,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, @@ -607,7 +620,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 5d1ed8ac3..4f01c8021 100644 --- a/nodejs/test/client.test.ts +++ b/nodejs/test/client.test.ts @@ -243,4 +243,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/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 90260ffbd..509ad578b 100644 --- a/python/copilot/client.py +++ b/python/copilot/client.py @@ -483,7 +483,10 @@ async def create_session(self, config: Optional[SessionConfig] = None) -> Copilo 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 @@ -655,7 +658,10 @@ async def resume_session( 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/test_client.py b/python/test_client.py index 0bc99ea69..7078cd74f 100644 --- a/python/test_client.py +++ b/python/test_client.py @@ -6,7 +6,7 @@ import pytest -from copilot import CopilotClient +from copilot import CopilotClient, define_tool from e2e.testharness import CLI_PATH @@ -149,6 +149,106 @@ 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): From e43386be757e0cd109af47f3ad9fba36fb69220a Mon Sep 17 00:00:00 2001 From: Patrick Nikoletich Date: Tue, 24 Feb 2026 19:37:59 -0800 Subject: [PATCH 3/6] test: add E2E tests and scenario for tool overrides Add E2E tests across all 4 SDKs verifying that registering a custom tool with the same name as a built-in tool (e.g., 'grep') causes the custom tool to be invoked instead of the built-in. This validates the mergeExcludedTools feature end-to-end. - Add 'overrides built-in tool with custom tool' test to Node, Python, Go, .NET - Add YAML snapshot for the replay proxy - Add test/scenarios/tools/tool-overrides/ with all 4 language implementations Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- dotnet/test/ToolsTests.cs | 23 +++ go/internal/e2e/tools_test.go | 35 +++++ nodejs/test/e2e/tools.test.ts | 20 +++ python/e2e/test_tools.py | 16 ++ test/scenarios/tools/tool-overrides/README.md | 31 ++++ .../tools/tool-overrides/csharp/Program.cs | 39 +++++ .../tools/tool-overrides/csharp/csharp.csproj | 13 ++ test/scenarios/tools/tool-overrides/go/go.mod | 9 ++ test/scenarios/tools/tool-overrides/go/go.sum | 4 + .../scenarios/tools/tool-overrides/go/main.go | 52 +++++++ .../tools/tool-overrides/python/main.py | 45 ++++++ .../tool-overrides/python/requirements.txt | 1 + .../tool-overrides/typescript/package.json | 18 +++ .../tool-overrides/typescript/src/index.ts | 42 ++++++ test/scenarios/tools/tool-overrides/verify.sh | 138 ++++++++++++++++++ ...rrides_built_in_tool_with_custom_tool.yaml | 20 +++ 16 files changed, 506 insertions(+) create mode 100644 test/scenarios/tools/tool-overrides/README.md create mode 100644 test/scenarios/tools/tool-overrides/csharp/Program.cs create mode 100644 test/scenarios/tools/tool-overrides/csharp/csharp.csproj create mode 100644 test/scenarios/tools/tool-overrides/go/go.mod create mode 100644 test/scenarios/tools/tool-overrides/go/go.sum create mode 100644 test/scenarios/tools/tool-overrides/go/main.go create mode 100644 test/scenarios/tools/tool-overrides/python/main.py create mode 100644 test/scenarios/tools/tool-overrides/python/requirements.txt create mode 100644 test/scenarios/tools/tool-overrides/typescript/package.json create mode 100644 test/scenarios/tools/tool-overrides/typescript/src/index.ts create mode 100755 test/scenarios/tools/tool-overrides/verify.sh create mode 100644 test/snapshots/tools/overrides_built_in_tool_with_custom_tool.yaml 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/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/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/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/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..c5cd06f8e --- /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..8aea64eaa --- /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**" From 7a753ebea75de886b3b025f738e68292a26b8520 Mon Sep 17 00:00:00 2001 From: Patrick Nikoletich Date: Thu, 26 Feb 2026 16:30:26 -0800 Subject: [PATCH 4/6] fix: address review findings from PR #523 - Fix Python tests: add missing required argument to create_session() calls - Fix Go: separate misplaced buildUnsupportedToolResult doc comment from mergeExcludedTools - Fix Go sample: whitespace alignment from merge Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- go/client.go | 2 +- python/test_client.py | 4 ++-- test/scenarios/tools/tool-overrides/go/main.go | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/go/client.go b/go/client.go index 761cd53fd..6389a8271 100644 --- a/go/client.go +++ b/go/client.go @@ -1353,7 +1353,6 @@ func buildFailedToolResult(internalError string) ToolResult { } } -// buildUnsupportedToolResult creates a failure ToolResult for an unsupported tool. // 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 { @@ -1377,6 +1376,7 @@ func mergeExcludedTools(excludedTools []string, tools []Tool) []string { return merged } +// buildUnsupportedToolResult creates a failure ToolResult for an unsupported tool. func buildUnsupportedToolResult(toolName string) ToolResult { return ToolResult{ TextResultForLLM: fmt.Sprintf("Tool '%s' is not supported by this client instance.", toolName), diff --git a/python/test_client.py b/python/test_client.py index 505ccbfd2..2a85e90fe 100644 --- a/python/test_client.py +++ b/python/test_client.py @@ -244,7 +244,7 @@ async def mock_request(method, params): return await original_request(method, params) client._client.request = mock_request - await client.create_session() + await client.create_session({}) assert "excludedTools" not in captured["session.create"] finally: await client.force_stop() @@ -255,7 +255,7 @@ async def test_resume_session_adds_tools_to_excluded(self): await client.start() try: - session = await client.create_session() + session = await client.create_session({}) captured = {} original_request = client._client.request diff --git a/test/scenarios/tools/tool-overrides/go/main.go b/test/scenarios/tools/tool-overrides/go/main.go index c5cd06f8e..f2f5119d3 100644 --- a/test/scenarios/tools/tool-overrides/go/main.go +++ b/test/scenarios/tools/tool-overrides/go/main.go @@ -25,7 +25,7 @@ func main() { defer client.Stop() session, err := client.CreateSession(ctx, &copilot.SessionConfig{ - Model: "claude-haiku-4.5", + Model: "claude-haiku-4.5", OnPermissionRequest: copilot.PermissionHandler.ApproveAll, Tools: []copilot.Tool{ copilot.DefineTool("grep", "A custom grep implementation that overrides the built-in", From 8d8a0b60228054d1fe5f79a329a385970a459593 Mon Sep 17 00:00:00 2001 From: Patrick Nikoletich Date: Thu, 26 Feb 2026 16:37:54 -0800 Subject: [PATCH 5/6] style: fix ruff formatting in Python test_client.py Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- python/test_client.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/python/test_client.py b/python/test_client.py index 2a85e90fe..4a139e942 100644 --- a/python/test_client.py +++ b/python/test_client.py @@ -220,10 +220,12 @@ async def mock_request(method, params): def edit_file(params) -> str: return "ok" - await client.create_session({ - "tools": [edit_file], - "excluded_tools": ["edit_file", "other_tool"], - }) + 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 From c4d3541fc162d21414c4cbe290e87acb0a355846 Mon Sep 17 00:00:00 2001 From: Patrick Nikoletich Date: Thu, 26 Feb 2026 18:29:59 -0800 Subject: [PATCH 6/6] chore: update snapshot from E2E run Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../tools/overrides_built_in_tool_with_custom_tool.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 index 8aea64eaa..6865beeb5 100644 --- a/test/snapshots/tools/overrides_built_in_tool_with_custom_tool.yaml +++ b/test/snapshots/tools/overrides_built_in_tool_with_custom_tool.yaml @@ -5,7 +5,7 @@ conversations: - role: system content: ${system} - role: user - content: "Use grep to search for the word 'hello'" + content: Use grep to search for the word 'hello' - role: assistant tool_calls: - id: toolcall_0