From 43ac653b84f37b8e81527d763498b927ef3bda7a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 29 Jan 2026 17:46:08 +0000 Subject: [PATCH 1/6] Initial plan From 0d4383c6d785262d53cecdc69caadc5d0e9b2df9 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 29 Jan 2026 17:52:08 +0000 Subject: [PATCH 2/6] Add requiresApproval field to custom tools in Node.js and .NET SDKs Co-authored-by: patniko <26906478+patniko@users.noreply.github.com> --- dotnet/src/Client.cs | 35 +++++++++++++++++++++++++++++++++++ dotnet/src/Session.cs | 35 ++++++++++++++++++++++++++++++++++- dotnet/src/Types.cs | 27 +++++++++++++++++++++++++++ nodejs/src/client.ts | 30 ++++++++++++++++++++++++++++++ nodejs/src/session.ts | 14 ++++++++++++++ nodejs/src/types.ts | 17 ++++++++++++++++- 6 files changed, 156 insertions(+), 2 deletions(-) diff --git a/dotnet/src/Client.cs b/dotnet/src/Client.cs index 112e988e..88f94653 100644 --- a/dotnet/src/Client.cs +++ b/dotnet/src/Client.cs @@ -872,6 +872,41 @@ public async Task OnToolCall(string sessionId, }); } + // Check if tool requires approval + if (session.ToolRequiresApproval(toolName)) + { + try + { + var permissionResult = await session.HandlePermissionRequestAsync( + JsonSerializer.SerializeToElement(new + { + kind = "tool", + toolCallId, + toolName + })); + + if (permissionResult.Kind != "approved") + { + return new ToolCallResponse(new ToolResultObject + { + TextResultForLlm = permissionResult.Kind == "denied-interactively-by-user" + ? "Tool execution was denied by user." + : "Tool execution was denied.", + ResultType = "denied" + }); + } + } + catch + { + // If permission handler fails or is not configured, deny the tool execution + return new ToolCallResponse(new ToolResultObject + { + TextResultForLlm = "Tool execution requires permission but no permission handler is configured.", + ResultType = "denied" + }); + } + } + try { var invocation = new ToolInvocation diff --git a/dotnet/src/Session.cs b/dotnet/src/Session.cs index 7f1cc4e4..0f8bbb06 100644 --- a/dotnet/src/Session.cs +++ b/dotnet/src/Session.cs @@ -45,6 +45,7 @@ public partial class CopilotSession : IAsyncDisposable { private readonly HashSet _eventHandlers = new(); private readonly Dictionary _toolHandlers = new(); + private readonly Dictionary _toolRequiresApproval = new(); private readonly JsonRpc _rpc; private PermissionHandler? _permissionHandler; private readonly SemaphoreSlim _permissionHandlerLock = new(1, 1); @@ -255,12 +256,36 @@ internal void DispatchEvent(SessionEvent sessionEvent) /// Tools allow the assistant to execute custom functions. When the assistant invokes a tool, /// the corresponding handler is called with the tool arguments. /// - internal void RegisterTools(ICollection tools) + internal void RegisterTools(ICollection? tools) { _toolHandlers.Clear(); + _toolRequiresApproval.Clear(); + if (tools == null) return; + foreach (var tool in tools) { _toolHandlers.Add(tool.Name, tool); + _toolRequiresApproval[tool.Name] = false; + } + } + + /// + /// Registers custom tool handlers for this session with requiresApproval support. + /// + /// A collection of CopilotTools that can be invoked by the assistant. + /// + /// Tools allow the assistant to execute custom functions. When the assistant invokes a tool, + /// the corresponding handler is called with the tool arguments. + /// CopilotTools support the RequiresApproval flag for permission handling. + /// + internal void RegisterTools(ICollection tools) + { + _toolHandlers.Clear(); + _toolRequiresApproval.Clear(); + foreach (var tool in tools) + { + _toolHandlers.Add(tool.Function.Name, tool.Function); + _toolRequiresApproval[tool.Function.Name] = tool.RequiresApproval; } } @@ -272,6 +297,14 @@ internal void RegisterTools(ICollection tools) internal AIFunction? GetTool(string name) => _toolHandlers.TryGetValue(name, out var tool) ? tool : null; + /// + /// Checks if a tool requires approval before execution. + /// + /// The name of the tool to check. + /// True if the tool requires approval, false otherwise. + internal bool ToolRequiresApproval(string name) => + _toolRequiresApproval.TryGetValue(name, out var requiresApproval) && requiresApproval; + /// /// Registers a handler for permission requests. /// diff --git a/dotnet/src/Types.cs b/dotnet/src/Types.cs index 24b4fc2e..da3f1a99 100644 --- a/dotnet/src/Types.cs +++ b/dotnet/src/Types.cs @@ -83,6 +83,33 @@ public class ToolInvocation public delegate Task ToolHandler(ToolInvocation invocation); +/// +/// Wraps an AIFunction with additional metadata for the Copilot SDK. +/// +public class CopilotTool +{ + /// + /// The underlying AIFunction that handles tool execution. + /// + public AIFunction Function { get; set; } = null!; + + /// + /// Controls whether the tool requires user approval before execution. + /// When true, the OnPermissionRequest handler will be called before invoking the tool. + /// When false or not specified, the tool executes without requesting permission. + /// + public bool RequiresApproval { get; set; } + + /// + /// Creates a CopilotTool from an AIFunction with optional requiresApproval flag. + /// + /// The AIFunction to wrap. + /// Whether the tool requires approval before execution. + /// A CopilotTool wrapping the provided function. + public static implicit operator CopilotTool(AIFunction function) => + new() { Function = function, RequiresApproval = false }; +} + public class PermissionRequest { [JsonPropertyName("kind")] diff --git a/nodejs/src/client.ts b/nodejs/src/client.ts index a698383a..959db3d3 100644 --- a/nodejs/src/client.ts +++ b/nodejs/src/client.ts @@ -985,6 +985,36 @@ export class CopilotClient { return { result: this.buildUnsupportedToolResult(params.toolName) }; } + // Check if tool requires approval + if (session.toolRequiresApprovalCheck(params.toolName)) { + try { + const permissionResult = await session._handlePermissionRequest({ + kind: "tool", + toolCallId: params.toolCallId, + toolName: params.toolName, + }); + + if (permissionResult.kind !== "approved") { + return { + result: { + textResultForLlm: `Tool execution was ${permissionResult.kind === "denied-interactively-by-user" ? "denied by user" : "denied"}.`, + resultType: "denied", + toolTelemetry: {}, + }, + }; + } + } catch (error) { + // If permission handler fails or is not configured, deny the tool execution + return { + result: { + textResultForLlm: "Tool execution requires permission but no permission handler is configured.", + resultType: "denied", + toolTelemetry: {}, + }, + }; + } + } + return await this.executeToolCall(handler, params); } diff --git a/nodejs/src/session.ts b/nodejs/src/session.ts index e285e7ca..19a1df34 100644 --- a/nodejs/src/session.ts +++ b/nodejs/src/session.ts @@ -50,6 +50,7 @@ export type AssistantMessageEvent = Extract = new Set(); private toolHandlers: Map = new Map(); + private toolRequiresApproval: Map = new Map(); private permissionHandler?: PermissionHandler; /** @@ -238,12 +239,14 @@ export class CopilotSession { */ registerTools(tools?: Tool[]): void { this.toolHandlers.clear(); + this.toolRequiresApproval.clear(); if (!tools) { return; } for (const tool of tools) { this.toolHandlers.set(tool.name, tool.handler); + this.toolRequiresApproval.set(tool.name, tool.requiresApproval ?? false); } } @@ -258,6 +261,17 @@ export class CopilotSession { return this.toolHandlers.get(name); } + /** + * Checks if a tool requires approval before execution. + * + * @param name - The name of the tool to check + * @returns True if the tool requires approval, false otherwise + * @internal This method is for internal use by the SDK. + */ + toolRequiresApprovalCheck(name: string): boolean { + return this.toolRequiresApproval.get(name) ?? false; + } + /** * Registers a handler for permission requests. * diff --git a/nodejs/src/types.ts b/nodejs/src/types.ts index 406fe8d5..e2558557 100644 --- a/nodejs/src/types.ts +++ b/nodejs/src/types.ts @@ -131,6 +131,13 @@ export interface Tool { description?: string; parameters?: ZodSchema | Record; handler: ToolHandler; + /** + * Controls whether the tool requires user approval before execution. + * When true, the OnPermissionRequest handler will be called before invoking the tool. + * When false or undefined, the tool executes without requesting permission. + * @default false + */ + requiresApproval?: boolean; } /** @@ -143,6 +150,13 @@ export function defineTool( description?: string; parameters?: ZodSchema | Record; handler: ToolHandler; + /** + * Controls whether the tool requires user approval before execution. + * When true, the OnPermissionRequest handler will be called before invoking the tool. + * When false or undefined, the tool executes without requesting permission. + * @default false + */ + requiresApproval?: boolean; } ): Tool { return { name, ...config }; @@ -196,8 +210,9 @@ export type SystemMessageConfig = SystemMessageAppendConfig | SystemMessageRepla * Permission request types from the server */ export interface PermissionRequest { - kind: "shell" | "write" | "mcp" | "read" | "url"; + kind: "shell" | "write" | "mcp" | "read" | "url" | "tool"; toolCallId?: string; + toolName?: string; [key: string]: unknown; } From 10f3ca7def914922469bc3ca8d53b607e2ff5474 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 29 Jan 2026 17:54:51 +0000 Subject: [PATCH 3/6] Add requiresApproval field to custom tools in Python and Go SDKs Co-authored-by: patniko <26906478+patniko@users.noreply.github.com> --- go/client.go | 29 ++++++++++++++++++++++++++ go/session.go | 43 +++++++++++++++++++++++++-------------- go/types.go | 5 +++++ python/copilot/client.py | 32 +++++++++++++++++++++++++++++ python/copilot/session.py | 19 +++++++++++++++++ python/copilot/tools.py | 10 +++++++-- python/copilot/types.py | 9 +++++++- 7 files changed, 129 insertions(+), 18 deletions(-) diff --git a/go/client.go b/go/client.go index 95ca7398..74aea363 100644 --- a/go/client.go +++ b/go/client.go @@ -1180,6 +1180,35 @@ func (c *Client) handleToolCallRequest(params map[string]interface{}) (map[strin return map[string]interface{}{"result": buildUnsupportedToolResult(toolName)}, nil } + // Check if tool requires approval + if session.toolRequiresApprovalCheck(toolName) { + permissionRequest := map[string]interface{}{ + "kind": "tool", + "toolCallId": toolCallID, + "toolName": toolName, + } + + permissionResult, err := session.handlePermissionRequest(permissionRequest) + if err != nil || permissionResult.Kind != "approved" { + deniedReason := "denied" + if err == nil { + deniedReason = permissionResult.Kind + } + return map[string]interface{}{ + "result": ToolResult{ + TextResultForLLM: func() string { + if deniedReason == "denied-interactively-by-user" { + return "Tool execution was denied by user." + } + return "Tool execution was denied." + }(), + ResultType: "denied", + ToolTelemetry: map[string]interface{}{}, + }, + }, nil + } + } + arguments := params["arguments"] result := c.executeToolCall(sessionID, toolCallID, toolName, arguments, handler) diff --git a/go/session.go b/go/session.go index d6b0b23a..14fdf5cd 100644 --- a/go/session.go +++ b/go/session.go @@ -46,16 +46,17 @@ type sessionHandler struct { // }) type Session struct { // SessionID is the unique identifier for this session. - SessionID string - workspacePath string - client *JSONRPCClient - handlers []sessionHandler - nextHandlerID uint64 - handlerMutex sync.RWMutex - toolHandlers map[string]ToolHandler - toolHandlersM sync.RWMutex - permissionHandler PermissionHandler - permissionMux sync.RWMutex + SessionID string + workspacePath string + client *JSONRPCClient + handlers []sessionHandler + nextHandlerID uint64 + handlerMutex sync.RWMutex + toolHandlers map[string]ToolHandler + toolRequiresApproval map[string]bool + toolHandlersM sync.RWMutex + permissionHandler PermissionHandler + permissionMux sync.RWMutex } // WorkspacePath returns the path to the session workspace directory when infinite @@ -71,11 +72,12 @@ func (s *Session) WorkspacePath() string { // to create sessions with proper initialization. func NewSession(sessionID string, client *JSONRPCClient, workspacePath string) *Session { return &Session{ - SessionID: sessionID, - workspacePath: workspacePath, - client: client, - handlers: make([]sessionHandler, 0), - toolHandlers: make(map[string]ToolHandler), + SessionID: sessionID, + workspacePath: workspacePath, + client: client, + handlers: make([]sessionHandler, 0), + toolHandlers: make(map[string]ToolHandler), + toolRequiresApproval: make(map[string]bool), } } @@ -262,11 +264,13 @@ func (s *Session) registerTools(tools []Tool) { defer s.toolHandlersM.Unlock() s.toolHandlers = make(map[string]ToolHandler) + s.toolRequiresApproval = make(map[string]bool) for _, tool := range tools { if tool.Name == "" || tool.Handler == nil { continue } s.toolHandlers[tool.Name] = tool.Handler + s.toolRequiresApproval[tool.Name] = tool.RequiresApproval } } @@ -279,6 +283,15 @@ func (s *Session) getToolHandler(name string) (ToolHandler, bool) { return handler, ok } +// toolRequiresApprovalCheck checks if a tool requires approval before execution. +// Returns true if the tool requires approval, false otherwise. +func (s *Session) toolRequiresApprovalCheck(name string) bool { + s.toolHandlersM.RLock() + defer s.toolHandlersM.RUnlock() + requiresApproval, ok := s.toolRequiresApproval[name] + return ok && requiresApproval +} + // registerPermissionHandler registers a permission handler for this session. // // When the assistant needs permission to perform certain actions (e.g., file diff --git a/go/types.go b/go/types.go index 7a420cd6..60d02748 100644 --- a/go/types.go +++ b/go/types.go @@ -78,6 +78,7 @@ type SystemMessageConfig struct { type PermissionRequest struct { Kind string `json:"kind"` ToolCallID string `json:"toolCallId,omitempty"` + ToolName string `json:"toolName,omitempty"` Extra map[string]interface{} `json:"-"` // Additional fields vary by kind } @@ -198,6 +199,10 @@ type Tool struct { Description string // optional Parameters map[string]interface{} Handler ToolHandler + // RequiresApproval controls whether the tool requires user approval before execution. + // When true, the OnPermissionRequest handler will be called before invoking the tool. + // When false (default), the tool executes without requesting permission. + RequiresApproval bool } // ToolInvocation describes a tool call initiated by Copilot diff --git a/python/copilot/client.py b/python/copilot/client.py index 522a2f2b..447dc038 100644 --- a/python/copilot/client.py +++ b/python/copilot/client.py @@ -1054,6 +1054,38 @@ async def _handle_tool_call_request(self, params: dict) -> dict: if not handler: return {"result": self._build_unsupported_tool_result(tool_name)} + # Check if tool requires approval + if session._tool_requires_approval(tool_name): + try: + permission_result = await session._handle_permission_request( + { + "kind": "tool", + "toolCallId": tool_call_id, + "toolName": tool_name, + } + ) + + if permission_result.get("kind") != "approved": + denied_reason = permission_result.get("kind", "denied") + return { + "result": { + "textResultForLlm": "Tool execution was denied by user." + if denied_reason == "denied-interactively-by-user" + else "Tool execution was denied.", + "resultType": "denied", + "toolTelemetry": {}, + } + } + except Exception: # pylint: disable=broad-except + # If permission handler fails or is not configured, deny the tool execution + return { + "result": { + "textResultForLlm": "Tool execution requires permission but no permission handler is configured.", + "resultType": "denied", + "toolTelemetry": {}, + } + } + arguments = params.get("arguments") result = await self._execute_tool_call( session_id, diff --git a/python/copilot/session.py b/python/copilot/session.py index 996b5e9f..22a8cb60 100644 --- a/python/copilot/session.py +++ b/python/copilot/session.py @@ -68,6 +68,7 @@ def __init__(self, session_id: str, client: Any, workspace_path: Optional[str] = self._event_handlers: set[Callable[[SessionEvent], None]] = set() self._event_handlers_lock = threading.Lock() self._tool_handlers: dict[str, ToolHandler] = {} + self._tool_requires_approval: dict[str, bool] = {} self._tool_handlers_lock = threading.Lock() self._permission_handler: Optional[PermissionHandler] = None self._permission_handler_lock = threading.Lock() @@ -250,12 +251,14 @@ def _register_tools(self, tools: Optional[list[Tool]]) -> None: """ with self._tool_handlers_lock: self._tool_handlers.clear() + self._tool_requires_approval.clear() if not tools: return for tool in tools: if not tool.name or not tool.handler: continue self._tool_handlers[tool.name] = tool.handler + self._tool_requires_approval[tool.name] = tool.requires_approval def _get_tool_handler(self, name: str) -> Optional[ToolHandler]: """ @@ -274,6 +277,22 @@ def _get_tool_handler(self, name: str) -> Optional[ToolHandler]: with self._tool_handlers_lock: return self._tool_handlers.get(name) + def _tool_requires_approval(self, name: str) -> bool: + """ + Check if a tool requires approval before execution. + + Note: + This method is internal and should not be called directly. + + Args: + name: The name of the tool to check. + + Returns: + True if the tool requires approval, False otherwise. + """ + with self._tool_handlers_lock: + return self._tool_requires_approval.get(name, False) + def _register_permission_handler(self, handler: Optional[PermissionHandler]) -> None: """ Register a handler for permission requests. diff --git a/python/copilot/tools.py b/python/copilot/tools.py index 43c1ed99..9b606f78 100644 --- a/python/copilot/tools.py +++ b/python/copilot/tools.py @@ -43,6 +43,7 @@ def define_tool( description: str | None = None, handler: Callable[[Any, ToolInvocation], Any] | None = None, params_type: type[BaseModel] | None = None, + requires_approval: bool = False, ) -> Tool | Callable[[Callable[[Any, ToolInvocation], Any]], Tool]: """ Define a tool with automatic JSON schema generation from Pydantic models. @@ -56,7 +57,7 @@ def define_tool( class LookupIssueParams(BaseModel): id: str = Field(description="Issue identifier") - @define_tool(description="Fetch issue details") + @define_tool(description="Fetch issue details", requires_approval=True) def lookup_issue(params: LookupIssueParams) -> str: return fetch_issue(params.id).summary @@ -66,7 +67,8 @@ def lookup_issue(params: LookupIssueParams) -> str: "lookup_issue", description="Fetch issue details", handler=lambda params, inv: fetch_issue(params.id).summary, - params_type=LookupIssueParams + params_type=LookupIssueParams, + requires_approval=True ) Args: @@ -75,6 +77,9 @@ def lookup_issue(params: LookupIssueParams) -> str: handler: Optional handler function (if not using as decorator) params_type: Optional Pydantic model type for parameters (inferred from type hints when using as decorator) + requires_approval: Whether the tool requires user approval before execution. + When True, the on_permission_request handler will be called + before invoking the tool. Defaults to False. Returns: A Tool instance @@ -149,6 +154,7 @@ async def wrapped_handler(invocation: ToolInvocation) -> ToolResult: description=description or "", parameters=schema, handler=wrapped_handler, + requires_approval=requires_approval, ) # If handler is provided, call decorator immediately diff --git a/python/copilot/types.py b/python/copilot/types.py index 14b8e65c..1c336fea 100644 --- a/python/copilot/types.py +++ b/python/copilot/types.py @@ -88,6 +88,12 @@ class Tool: description: str handler: ToolHandler parameters: dict[str, Any] | None = None + requires_approval: bool = False + """ + Controls whether the tool requires user approval before execution. + When True, the on_permission_request handler will be called before invoking the tool. + When False (default), the tool executes without requesting permission. + """ # System message configuration (discriminated union) @@ -121,8 +127,9 @@ class SystemMessageReplaceConfig(TypedDict): class PermissionRequest(TypedDict, total=False): """Permission request from the server""" - kind: Literal["shell", "write", "mcp", "read", "url"] + kind: Literal["shell", "write", "mcp", "read", "url", "tool"] toolCallId: str + toolName: str # Additional fields vary by kind From bddb199d6783aeb64952b43ab3d375d7803fe0e0 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 29 Jan 2026 17:56:11 +0000 Subject: [PATCH 4/6] Add tests for custom tool permission workflow in all SDKs Co-authored-by: patniko <26906478+patniko@users.noreply.github.com> --- dotnet/test/ToolsTests.cs | 130 ++++++++++++++++++++++++++++++++++ nodejs/test/e2e/tools.test.ts | 96 +++++++++++++++++++++++++ python/e2e/test_tools.py | 89 +++++++++++++++++++++++ 3 files changed, 315 insertions(+) diff --git a/dotnet/test/ToolsTests.cs b/dotnet/test/ToolsTests.cs index 3d7741c9..dec7541f 100644 --- a/dotnet/test/ToolsTests.cs +++ b/dotnet/test/ToolsTests.cs @@ -174,4 +174,134 @@ await session.SendAsync(new MessageOptions SessionLog = "Returned an image", }); } + + [Fact] + public async Task Requests_Permission_For_Tools_With_RequiresApproval() + { + var permissionRequested = false; + string? permissionToolName = null; + + var getWeather = new CopilotTool + { + Function = AIFunctionFactory.Create( + ([Description("The city name")] string city) => + { + return new { city, temperature = "72°F", condition = "sunny" }; + }, + "get_weather", + "Get the current weather for a city"), + RequiresApproval = true + }; + + var session = await Client.CreateSessionAsync(new SessionConfig + { + Tools = [getWeather.Function], + OnPermissionRequest = (request, invocation) => + { + if (request.Kind == "tool") + { + permissionRequested = true; + permissionToolName = request.ExtensionData?.GetValueOrDefault("toolName")?.ToString(); + } + return Task.FromResult(new PermissionRequestResult { Kind = "approved" }); + } + }); + + // Register the tool with approval setting + session.RegisterTools([getWeather]); + + await session.SendAsync(new MessageOptions + { + Prompt = "What's the weather in Seattle?" + }); + + var assistantMessage = await TestHelper.GetFinalAssistantMessageAsync(session); + + Assert.True(permissionRequested, "Permission should have been requested"); + Assert.Equal("get_weather", permissionToolName); + Assert.Contains("72", assistantMessage?.Data.Content ?? string.Empty); + } + + [Fact] + public async Task Denies_Tool_Execution_When_Permission_Denied() + { + var deleteFile = new CopilotTool + { + Function = AIFunctionFactory.Create( + ([Description("File path")] string path) => $"Deleted {path}", + "delete_file", + "Deletes a file"), + RequiresApproval = true + }; + + var session = await Client.CreateSessionAsync(new SessionConfig + { + Tools = [deleteFile.Function], + OnPermissionRequest = (request, invocation) => + { + if (request.Kind == "tool") + { + return Task.FromResult(new PermissionRequestResult + { + Kind = "denied-interactively-by-user" + }); + } + return Task.FromResult(new PermissionRequestResult { Kind = "approved" }); + } + }); + + session.RegisterTools([deleteFile]); + + await session.SendAsync(new MessageOptions + { + Prompt = "Delete the file test.txt" + }); + + var assistantMessage = await TestHelper.GetFinalAssistantMessageAsync(session); + var content = assistantMessage?.Data.Content?.ToLowerInvariant() ?? string.Empty; + + Assert.True( + content.Contains("denied") || content.Contains("cannot") || content.Contains("unable"), + "Assistant should indicate the tool was denied"); + } + + [Fact] + public async Task Executes_Tools_Without_Permission_When_RequiresApproval_False() + { + var permissionRequested = false; + + var addNumbers = new CopilotTool + { + Function = AIFunctionFactory.Create( + (int a, int b) => a + b, + "add_numbers", + "Adds two numbers"), + RequiresApproval = false + }; + + var session = await Client.CreateSessionAsync(new SessionConfig + { + Tools = [addNumbers.Function], + OnPermissionRequest = (request, invocation) => + { + if (request.Kind == "tool") + { + permissionRequested = true; + } + return Task.FromResult(new PermissionRequestResult { Kind = "approved" }); + } + }); + + session.RegisterTools([addNumbers]); + + await session.SendAsync(new MessageOptions + { + Prompt = "What is 5 + 3?" + }); + + var assistantMessage = await TestHelper.GetFinalAssistantMessageAsync(session); + + Assert.False(permissionRequested, "Permission should not have been requested"); + Assert.Contains("8", assistantMessage?.Data.Content ?? string.Empty); + } } diff --git a/nodejs/test/e2e/tools.test.ts b/nodejs/test/e2e/tools.test.ts index 85960b83..cc4591ad 100644 --- a/nodejs/test/e2e/tools.test.ts +++ b/nodejs/test/e2e/tools.test.ts @@ -122,4 +122,100 @@ describe("Custom tools", async () => { expect(responseContent.replace(/,/g, "")).toContain("135460"); expect(responseContent.replace(/,/g, "")).toContain("204356"); }); + + it("requests permission for tools with requiresApproval=true", async () => { + let permissionRequested = false; + let permissionToolName: string | undefined; + + const session = await client.createSession({ + tools: [ + defineTool("get_weather", { + description: "Get the current weather for a city", + parameters: z.object({ + city: z.string().describe("The city name"), + }), + handler: ({ city }) => ({ + city, + temperature: "72°F", + condition: "sunny", + }), + requiresApproval: true, + }), + ], + onPermissionRequest: (request) => { + if (request.kind === "tool") { + permissionRequested = true; + permissionToolName = request.toolName; + } + return { kind: "approved" }; + }, + }); + + const assistantMessage = await session.sendAndWait({ + prompt: "What's the weather in Seattle?", + }); + + expect(permissionRequested).toBe(true); + expect(permissionToolName).toBe("get_weather"); + expect(assistantMessage?.data.content).toContain("72"); + }); + + it("denies tool execution when permission is denied", async () => { + const session = await client.createSession({ + tools: [ + defineTool("delete_file", { + description: "Deletes a file", + parameters: z.object({ + path: z.string().describe("File path"), + }), + handler: ({ path }) => `Deleted ${path}`, + requiresApproval: true, + }), + ], + onPermissionRequest: (request) => { + if (request.kind === "tool") { + return { kind: "denied-interactively-by-user" }; + } + return { kind: "approved" }; + }, + }); + + const assistantMessage = await session.sendAndWait({ + prompt: "Delete the file test.txt", + }); + + // The assistant should receive a denial and inform the user + expect(assistantMessage?.data.content?.toLowerCase()).toMatch(/denied|cannot|unable/); + }); + + it("executes tools without permission when requiresApproval=false", async () => { + let permissionRequested = false; + + const session = await client.createSession({ + tools: [ + defineTool("add_numbers", { + description: "Adds two numbers", + parameters: z.object({ + a: z.number(), + b: z.number(), + }), + handler: ({ a, b }) => a + b, + requiresApproval: false, // Explicitly set to false + }), + ], + onPermissionRequest: (request) => { + if (request.kind === "tool") { + permissionRequested = true; + } + return { kind: "approved" }; + }, + }); + + const assistantMessage = await session.sendAndWait({ + prompt: "What is 5 + 3?", + }); + + expect(permissionRequested).toBe(false); + expect(assistantMessage?.data.content).toContain("8"); + }); }); diff --git a/python/e2e/test_tools.py b/python/e2e/test_tools.py index 2e024887..f8b34046 100644 --- a/python/e2e/test_tools.py +++ b/python/e2e/test_tools.py @@ -124,3 +124,92 @@ def db_query(params: DbQueryParams, invocation: ToolInvocation) -> list[City]: assert "San Lorenzo" in response_content assert "135460" in response_content.replace(",", "") assert "204356" in response_content.replace(",", "") + + async def test_requests_permission_for_tools_with_requires_approval(self, ctx: E2ETestContext): + permission_requested = False + permission_tool_name = None + + def on_permission_request( + request: dict, invocation: dict + ) -> dict: + nonlocal permission_requested, permission_tool_name + if request.get("kind") == "tool": + permission_requested = True + permission_tool_name = request.get("toolName") + return {"kind": "approved"} + + class WeatherParams(BaseModel): + city: str = Field(description="The city name") + + @define_tool("get_weather", description="Get the current weather for a city", requires_approval=True) + def get_weather(params: WeatherParams, invocation: ToolInvocation) -> dict: + return { + "city": params.city, + "temperature": "72°F", + "condition": "sunny" + } + + session = await ctx.client.create_session({ + "tools": [get_weather], + "on_permission_request": on_permission_request + }) + + await session.send({"prompt": "What's the weather in Seattle?"}) + assistant_message = await get_final_assistant_message(session) + + assert permission_requested + assert permission_tool_name == "get_weather" + assert "72" in (assistant_message.data.content or "") + + async def test_denies_tool_execution_when_permission_denied(self, ctx: E2ETestContext): + def on_permission_request(request: dict, invocation: dict) -> dict: + if request.get("kind") == "tool": + return {"kind": "denied-interactively-by-user"} + return {"kind": "approved"} + + class DeleteParams(BaseModel): + path: str = Field(description="File path") + + @define_tool("delete_file", description="Deletes a file", requires_approval=True) + def delete_file(params: DeleteParams, invocation: ToolInvocation) -> str: + return f"Deleted {params.path}" + + session = await ctx.client.create_session({ + "tools": [delete_file], + "on_permission_request": on_permission_request + }) + + await session.send({"prompt": "Delete the file test.txt"}) + assistant_message = await get_final_assistant_message(session) + + # The assistant should receive a denial and inform the user + content_lower = (assistant_message.data.content or "").lower() + assert any(word in content_lower for word in ["denied", "cannot", "unable"]) + + async def test_executes_tools_without_permission_when_requires_approval_false(self, ctx: E2ETestContext): + permission_requested = False + + def on_permission_request(request: dict, invocation: dict) -> dict: + nonlocal permission_requested + if request.get("kind") == "tool": + permission_requested = True + return {"kind": "approved"} + + class AddParams(BaseModel): + a: int + b: int + + @define_tool("add_numbers", description="Adds two numbers", requires_approval=False) + def add_numbers(params: AddParams, invocation: ToolInvocation) -> int: + return params.a + params.b + + session = await ctx.client.create_session({ + "tools": [add_numbers], + "on_permission_request": on_permission_request + }) + + await session.send({"prompt": "What is 5 + 3?"}) + assistant_message = await get_final_assistant_message(session) + + assert not permission_requested + assert "8" in (assistant_message.data.content or "") From 221e22bad2117c14e32d6abc9f16aa227ff4243e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 29 Jan 2026 18:02:57 +0000 Subject: [PATCH 5/6] Fix .NET SDK compilation errors and update SessionConfig to accept both AIFunction and CopilotTool Co-authored-by: patniko <26906478+patniko@users.noreply.github.com> --- dotnet/src/Client.cs | 74 +++++++++++++++++++++++++++++++++------ dotnet/src/Types.cs | 14 ++++++-- dotnet/test/ToolsTests.cs | 13 ++----- 3 files changed, 79 insertions(+), 22 deletions(-) diff --git a/dotnet/src/Client.cs b/dotnet/src/Client.cs index 88f94653..681c9681 100644 --- a/dotnet/src/Client.cs +++ b/dotnet/src/Client.cs @@ -330,10 +330,17 @@ public async Task CreateSessionAsync(SessionConfig? config = nul { var connection = await EnsureConnectedAsync(cancellationToken); + // Extract AIFunctions from Tools (which can be either AIFunction or CopilotTool) + var aiFunctions = config?.Tools? + .Select(t => t is CopilotTool ct ? ct.Function : t as AIFunction) + .Where(f => f != null) + .Cast() + .ToList(); + var request = new CreateSessionRequest( config?.Model, config?.SessionId, - config?.Tools?.Select(ToolDefinition.FromAIFunction).ToList(), + aiFunctions?.Select(ToolDefinition.FromAIFunction).ToList(), config?.SystemMessage, config?.AvailableTools, config?.ExcludedTools, @@ -351,7 +358,25 @@ public async Task CreateSessionAsync(SessionConfig? config = nul connection.Rpc, "session.create", [request], cancellationToken); var session = new CopilotSession(response.SessionId, connection.Rpc, response.WorkspacePath); - session.RegisterTools(config?.Tools ?? []); + + // Register tools with their approval settings + if (config?.Tools != null) + { + var copilotTools = new List(); + foreach (var tool in config.Tools) + { + if (tool is CopilotTool ct) + { + copilotTools.Add(ct); + } + else if (tool is AIFunction af) + { + copilotTools.Add(new CopilotTool { Function = af, RequiresApproval = false }); + } + } + session.RegisterTools(copilotTools); + } + if (config?.OnPermissionRequest != null) { session.RegisterPermissionHandler(config.OnPermissionRequest); @@ -393,9 +418,16 @@ public async Task ResumeSessionAsync(string sessionId, ResumeSes { var connection = await EnsureConnectedAsync(cancellationToken); + // Extract AIFunctions from Tools (which can be either AIFunction or CopilotTool) + var aiFunctions = config?.Tools? + .Select(t => t is CopilotTool ct ? ct.Function : t as AIFunction) + .Where(f => f != null) + .Cast() + .ToList(); + var request = new ResumeSessionRequest( sessionId, - config?.Tools?.Select(ToolDefinition.FromAIFunction).ToList(), + aiFunctions?.Select(ToolDefinition.FromAIFunction).ToList(), config?.Provider, config?.OnPermissionRequest != null ? true : null, config?.Streaming == true ? true : null, @@ -408,7 +440,25 @@ public async Task ResumeSessionAsync(string sessionId, ResumeSes connection.Rpc, "session.resume", [request], cancellationToken); var session = new CopilotSession(response.SessionId, connection.Rpc, response.WorkspacePath); - session.RegisterTools(config?.Tools ?? []); + + // Register tools with their approval settings + if (config?.Tools != null) + { + var copilotTools = new List(); + foreach (var tool in config.Tools) + { + if (tool is CopilotTool ct) + { + copilotTools.Add(ct); + } + else if (tool is AIFunction af) + { + copilotTools.Add(new CopilotTool { Function = af, RequiresApproval = false }); + } + } + session.RegisterTools(copilotTools); + } + if (config?.OnPermissionRequest != null) { session.RegisterPermissionHandler(config.OnPermissionRequest); @@ -877,13 +927,17 @@ public async Task OnToolCall(string sessionId, { try { - var permissionResult = await session.HandlePermissionRequestAsync( - JsonSerializer.SerializeToElement(new + // Create permission request as JsonElement manually + var permissionRequestJson = $$""" { - kind = "tool", - toolCallId, - toolName - })); + "kind": "tool", + "toolCallId": "{{toolCallId}}", + "toolName": "{{toolName}}" + } + """; + var permissionRequestElement = JsonDocument.Parse(permissionRequestJson).RootElement; + + var permissionResult = await session.HandlePermissionRequestAsync(permissionRequestElement); if (permissionResult.Kind != "approved") { diff --git a/dotnet/src/Types.cs b/dotnet/src/Types.cs index da3f1a99..d420afd2 100644 --- a/dotnet/src/Types.cs +++ b/dotnet/src/Types.cs @@ -366,7 +366,12 @@ public class SessionConfig /// public string? ConfigDir { get; set; } - public ICollection? Tools { get; set; } + /// + /// Tools that can be invoked by the assistant. + /// Can be either AIFunction instances or CopilotTool instances. + /// Use CopilotTool to specify RequiresApproval for permission handling. + /// + public ICollection? Tools { get; set; } public SystemMessageConfig? SystemMessage { get; set; } public List? AvailableTools { get; set; } public List? ExcludedTools { get; set; } @@ -415,7 +420,12 @@ public class SessionConfig public class ResumeSessionConfig { - public ICollection? Tools { get; set; } + /// + /// Tools that can be invoked by the assistant. + /// Can be either AIFunction instances or CopilotTool instances. + /// Use CopilotTool to specify RequiresApproval for permission handling. + /// + public ICollection? Tools { get; set; } public ProviderConfig? Provider { get; set; } /// diff --git a/dotnet/test/ToolsTests.cs b/dotnet/test/ToolsTests.cs index dec7541f..f7596447 100644 --- a/dotnet/test/ToolsTests.cs +++ b/dotnet/test/ToolsTests.cs @@ -195,7 +195,7 @@ public async Task Requests_Permission_For_Tools_With_RequiresApproval() var session = await Client.CreateSessionAsync(new SessionConfig { - Tools = [getWeather.Function], + Tools = [getWeather], OnPermissionRequest = (request, invocation) => { if (request.Kind == "tool") @@ -207,9 +207,6 @@ public async Task Requests_Permission_For_Tools_With_RequiresApproval() } }); - // Register the tool with approval setting - session.RegisterTools([getWeather]); - await session.SendAsync(new MessageOptions { Prompt = "What's the weather in Seattle?" @@ -236,7 +233,7 @@ public async Task Denies_Tool_Execution_When_Permission_Denied() var session = await Client.CreateSessionAsync(new SessionConfig { - Tools = [deleteFile.Function], + Tools = [deleteFile], OnPermissionRequest = (request, invocation) => { if (request.Kind == "tool") @@ -250,8 +247,6 @@ public async Task Denies_Tool_Execution_When_Permission_Denied() } }); - session.RegisterTools([deleteFile]); - await session.SendAsync(new MessageOptions { Prompt = "Delete the file test.txt" @@ -281,7 +276,7 @@ public async Task Executes_Tools_Without_Permission_When_RequiresApproval_False( var session = await Client.CreateSessionAsync(new SessionConfig { - Tools = [addNumbers.Function], + Tools = [addNumbers], OnPermissionRequest = (request, invocation) => { if (request.Kind == "tool") @@ -292,8 +287,6 @@ public async Task Executes_Tools_Without_Permission_When_RequiresApproval_False( } }); - session.RegisterTools([addNumbers]); - await session.SendAsync(new MessageOptions { Prompt = "What is 5 + 3?" From 41d6edf531713a1665a44ce926ed83065b67747e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 29 Jan 2026 18:07:01 +0000 Subject: [PATCH 6/6] Address code review feedback: fix Python method naming, add .NET validation, fix Node.js docs Co-authored-by: patniko <26906478+patniko@users.noreply.github.com> --- dotnet/src/Client.cs | 8 ++++++++ nodejs/src/types.ts | 4 ++-- python/copilot/client.py | 2 +- python/copilot/session.py | 2 +- 4 files changed, 12 insertions(+), 4 deletions(-) diff --git a/dotnet/src/Client.cs b/dotnet/src/Client.cs index 681c9681..a5b81348 100644 --- a/dotnet/src/Client.cs +++ b/dotnet/src/Client.cs @@ -373,6 +373,10 @@ public async Task CreateSessionAsync(SessionConfig? config = nul { copilotTools.Add(new CopilotTool { Function = af, RequiresApproval = false }); } + else if (tool != null) + { + throw new ArgumentException($"Tool must be either AIFunction or CopilotTool, but was {tool.GetType().Name}"); + } } session.RegisterTools(copilotTools); } @@ -455,6 +459,10 @@ public async Task ResumeSessionAsync(string sessionId, ResumeSes { copilotTools.Add(new CopilotTool { Function = af, RequiresApproval = false }); } + else if (tool != null) + { + throw new ArgumentException($"Tool must be either AIFunction or CopilotTool, but was {tool.GetType().Name}"); + } } session.RegisterTools(copilotTools); } diff --git a/nodejs/src/types.ts b/nodejs/src/types.ts index e2558557..21a4223e 100644 --- a/nodejs/src/types.ts +++ b/nodejs/src/types.ts @@ -133,7 +133,7 @@ export interface Tool { handler: ToolHandler; /** * Controls whether the tool requires user approval before execution. - * When true, the OnPermissionRequest handler will be called before invoking the tool. + * When true, the onPermissionRequest handler will be called before invoking the tool. * When false or undefined, the tool executes without requesting permission. * @default false */ @@ -152,7 +152,7 @@ export function defineTool( handler: ToolHandler; /** * Controls whether the tool requires user approval before execution. - * When true, the OnPermissionRequest handler will be called before invoking the tool. + * When true, the onPermissionRequest handler will be called before invoking the tool. * When false or undefined, the tool executes without requesting permission. * @default false */ diff --git a/python/copilot/client.py b/python/copilot/client.py index 447dc038..8034527e 100644 --- a/python/copilot/client.py +++ b/python/copilot/client.py @@ -1055,7 +1055,7 @@ async def _handle_tool_call_request(self, params: dict) -> dict: return {"result": self._build_unsupported_tool_result(tool_name)} # Check if tool requires approval - if session._tool_requires_approval(tool_name): + if session._check_tool_requires_approval(tool_name): try: permission_result = await session._handle_permission_request( { diff --git a/python/copilot/session.py b/python/copilot/session.py index 22a8cb60..2c1751d3 100644 --- a/python/copilot/session.py +++ b/python/copilot/session.py @@ -277,7 +277,7 @@ def _get_tool_handler(self, name: str) -> Optional[ToolHandler]: with self._tool_handlers_lock: return self._tool_handlers.get(name) - def _tool_requires_approval(self, name: str) -> bool: + def _check_tool_requires_approval(self, name: str) -> bool: """ Check if a tool requires approval before execution.