Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 58 additions & 2 deletions dotnet/src/Session.cs
Original file line number Diff line number Diff line change
Expand Up @@ -898,9 +898,16 @@ private async Task ExecuteToolAndRespondAsync(string requestId, string toolName,

if (arguments is JsonElement incomingJsonArgs)
{
foreach (var prop in incomingJsonArgs.EnumerateObject())
if (incomingJsonArgs.ValueKind == JsonValueKind.Object)
{
aiFunctionArgs[prop.Name] = prop.Value;
foreach (var prop in incomingJsonArgs.EnumerateObject())
{
aiFunctionArgs[prop.Name] = prop.Value;
}
}
else
{
aiFunctionArgs[GetSingleParameterName(tool)] = incomingJsonArgs;
}
}

Expand Down Expand Up @@ -941,6 +948,55 @@ private async Task ExecuteToolAndRespondAsync(string requestId, string toolName,
// Connection already disposed — nothing we can do
}
}

static string GetSingleParameterName(AIFunction tool)
{
if (tool.JsonSchema.TryGetProperty("properties", out var properties) &&
properties.ValueKind == JsonValueKind.Object)
{
string? parameterName = null;
foreach (var property in properties.EnumerateObject())
{
if (parameterName is not null)
{
parameterName = null;
break;
}

parameterName = property.Name;
}

if (parameterName is not null)
{
return parameterName;
}

if (tool.JsonSchema.TryGetProperty("required", out var required) &&
required.ValueKind == JsonValueKind.Array)
{
string? requiredParameterName = null;
foreach (var requiredParameter in required.EnumerateArray())
{
if (requiredParameterName is not null)
{
requiredParameterName = null;
break;
}

requiredParameterName = requiredParameter.GetString();
}

if (requiredParameterName is not null &&
properties.TryGetProperty(requiredParameterName, out _))
{
return requiredParameterName;
}
}
}

throw new ArgumentException(
$"Tool '{tool.Name}' received non-object arguments, but its schema does not define exactly one parameter or one required parameter.");
}
}

/// <summary>
Expand Down
165 changes: 165 additions & 0 deletions dotnet/test/Unit/ClientSessionLifetimeTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
using System.Text;
using System.Text.Json;
using GitHub.Copilot.Rpc;
using Microsoft.Extensions.AI;
using Xunit;

namespace GitHub.Copilot.Test.Unit;
Expand Down Expand Up @@ -665,6 +666,166 @@ public async Task SessionRequests_Serialize_Terminal_Tools()
Assert.True(resumeRequest.Params.GetProperty("tools")[0].GetProperty("isTerminal").GetBoolean());
}

[Fact]
public async Task ExternalTool_String_Arguments_Bind_To_Single_Function_Parameter()
{
await using var server = await FakeCopilotServer.StartAsync();
await using var client = new CopilotClient(new CopilotClientOptions { Connection = RuntimeConnection.ForUri(server.Url) });
string? receivedPatch = null;
var tool = CopilotTool.DefineTool(
(string patch) =>
{
receivedPatch = patch;
return "applied";
},
new CopilotToolOptions { OverridesBuiltInTool = true },
new AIFunctionFactoryOptions { Name = "apply_patch" });
await using var session = await client.CreateSessionAsync(new SessionConfig
{
Tools = [tool],
OnPermissionRequest = PermissionHandler.ApproveAll
});
server.ClearRequests();
using var arguments = JsonDocument.Parse("\"*** Begin Patch\\n*** End Patch\"");

DispatchEvent(session, new ExternalToolRequestedEvent
{
Data = new ExternalToolRequestedData
{
Arguments = arguments.RootElement.Clone(),
RequestId = "apply-patch-request",
SessionId = session.SessionId,
ToolCallId = "apply-patch-call",
ToolName = "apply_patch"
}
});

var request = await WaitForRequestAsync(server, "session.tools.handlePendingToolCall");
Assert.Equal("*** Begin Patch\n*** End Patch", receivedPatch);
Assert.False(request.Params.TryGetProperty("error", out _));
Assert.Equal("applied", request.Params.GetProperty("result").GetProperty("textResultForLlm").GetString());
}

[Fact]
public async Task ExternalTool_String_Arguments_Reject_Ambiguous_Function_Parameters()
{
await using var server = await FakeCopilotServer.StartAsync();
await using var client = new CopilotClient(new CopilotClientOptions { Connection = RuntimeConnection.ForUri(server.Url) });
var invoked = false;
var tool = CopilotTool.DefineTool(
(string patch, string explanation) =>
{
invoked = true;
return "applied";
},
new CopilotToolOptions { OverridesBuiltInTool = true },
new AIFunctionFactoryOptions { Name = "apply_patch" });
await using var session = await client.CreateSessionAsync(new SessionConfig
{
Tools = [tool],
OnPermissionRequest = PermissionHandler.ApproveAll
});
server.ClearRequests();
using var arguments = JsonDocument.Parse("\"*** Begin Patch\\n*** End Patch\"");

DispatchEvent(session, new ExternalToolRequestedEvent
{
Data = new ExternalToolRequestedData
{
Arguments = arguments.RootElement.Clone(),
RequestId = "ambiguous-apply-patch-request",
SessionId = session.SessionId,
ToolCallId = "ambiguous-apply-patch-call",
ToolName = "apply_patch"
}
});

var request = await WaitForRequestAsync(server, "session.tools.handlePendingToolCall");
Assert.False(invoked);
Assert.Contains("received non-object arguments", request.Params.GetProperty("error").GetString());
Assert.False(request.Params.TryGetProperty("result", out _));
}

[Fact]
public async Task ExternalTool_Number_Arguments_Bind_To_Single_Function_Parameter()
{
await using var server = await FakeCopilotServer.StartAsync();
await using var client = new CopilotClient(new CopilotClientOptions { Connection = RuntimeConnection.ForUri(server.Url) });
int? receivedLine = null;
var tool = CopilotTool.DefineTool(
(int line) =>
{
receivedLine = line;
return "selected";
},
factoryOptions: new AIFunctionFactoryOptions { Name = "select_line" });
await using var session = await client.CreateSessionAsync(new SessionConfig
{
Tools = [tool],
OnPermissionRequest = PermissionHandler.ApproveAll
});
server.ClearRequests();
using var arguments = JsonDocument.Parse("42");

DispatchEvent(session, new ExternalToolRequestedEvent
{
Data = new ExternalToolRequestedData
{
Arguments = arguments.RootElement.Clone(),
RequestId = "select-line-request",
SessionId = session.SessionId,
ToolCallId = "select-line-call",
ToolName = "select_line"
}
});

var request = await WaitForRequestAsync(server, "session.tools.handlePendingToolCall");
Assert.Equal(42, receivedLine);
Assert.False(request.Params.TryGetProperty("error", out _));
}

[Fact]
public async Task ExternalTool_String_Arguments_Bind_To_Sole_Required_Function_Parameter()
{
await using var server = await FakeCopilotServer.StartAsync();
await using var client = new CopilotClient(new CopilotClientOptions { Connection = RuntimeConnection.ForUri(server.Url) });
string? receivedPatch = null;
string? receivedExplanation = null;
var tool = CopilotTool.DefineTool(
(string patch, string? explanation = null) =>
{
receivedPatch = patch;
receivedExplanation = explanation;
return "applied";
},
new CopilotToolOptions { OverridesBuiltInTool = true },
new AIFunctionFactoryOptions { Name = "apply_patch" });
await using var session = await client.CreateSessionAsync(new SessionConfig
{
Tools = [tool],
OnPermissionRequest = PermissionHandler.ApproveAll
});
server.ClearRequests();
using var arguments = JsonDocument.Parse("\"*** Begin Patch\\n*** End Patch\"");

DispatchEvent(session, new ExternalToolRequestedEvent
{
Data = new ExternalToolRequestedData
{
Arguments = arguments.RootElement.Clone(),
RequestId = "optional-apply-patch-request",
SessionId = session.SessionId,
ToolCallId = "optional-apply-patch-call",
ToolName = "apply_patch"
}
});

var request = await WaitForRequestAsync(server, "session.tools.handlePendingToolCall");
Assert.Equal("*** Begin Patch\n*** End Patch", receivedPatch);
Assert.Null(receivedExplanation);
Assert.False(request.Params.TryGetProperty("error", out _));
}

[Fact]
public async Task EmptyMode_Create_Sends_Empty_IncludedBuiltinSkills()
{
Expand Down Expand Up @@ -1667,6 +1828,10 @@ private async Task HandleRequestAsync(Stream stream, JsonElement request, Cancel
{
["success"] = true
},
"session.tools.handlePendingToolCall" => new Dictionary<string, object?>
{
["success"] = true
},
"session.delete" => new Dictionary<string, object?>
{
["success"] = true
Expand Down
Loading