fix: project copilot tool results into workflows

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
David Kaya
2026-04-01 19:17:47 +02:00
co-authored by Copilot
parent 1dd13588a0
commit 1ceb3d5669
6 changed files with 280 additions and 28 deletions
@@ -185,7 +185,7 @@ public sealed class CopilotAgentBundleTests
}
[Fact]
public void ConvertToolRequestsToFunctionCalls_SkipsNonHandoffToolCalls()
public void ConvertToolRequestsToFunctionCalls_MapsNonHandoffToolCalls()
{
AssistantMessageDataToolRequestsItem[] toolRequests =
{
@@ -197,9 +197,99 @@ public sealed class CopilotAgentBundleTests
IReadOnlyList<FunctionCallContent> result = AryxCopilotAgent.ConvertToolRequestsToFunctionCalls(toolRequests);
FunctionCallContent single = Assert.Single(result);
Assert.Equal("call-003", single.CallId);
Assert.Equal("handoff_to_reviewer", single.Name);
Assert.Collection(
result,
functionCall =>
{
Assert.Equal("call-001", functionCall.CallId);
Assert.Equal("ask_user", functionCall.Name);
},
functionCall =>
{
Assert.Equal("call-002", functionCall.CallId);
Assert.Equal("web_fetch", functionCall.Name);
},
functionCall =>
{
Assert.Equal("call-003", functionCall.CallId);
Assert.Equal("handoff_to_reviewer", functionCall.Name);
},
functionCall =>
{
Assert.Equal("call-004", functionCall.CallId);
Assert.Equal("grep", functionCall.Name);
});
}
[Fact]
public void TryCreateToolResultContent_UsesSdkResultContentForNonHandoffTools()
{
ToolExecutionCompleteEvent toolExecutionComplete = new()
{
Data = new ToolExecutionCompleteData
{
ToolCallId = "call-123",
Success = true,
Result = new ToolExecutionCompleteDataResult
{
Content = "Search complete.",
DetailedContent = "Search complete with extra context.",
},
},
};
FunctionResultContent? toolResult = AryxCopilotAgent.TryCreateToolResultContent(toolExecutionComplete, "rg");
Assert.NotNull(toolResult);
Assert.Equal("call-123", toolResult.CallId);
Assert.Equal("Search complete.", Assert.IsType<string>(toolResult.Result));
Assert.Same(toolExecutionComplete, toolResult.RawRepresentation);
}
[Fact]
public void TryCreateToolResultContent_UsesSdkErrorMessageForFailedTools()
{
ToolExecutionCompleteEvent toolExecutionComplete = new()
{
Data = new ToolExecutionCompleteData
{
ToolCallId = "call-456",
Success = false,
Error = new ToolExecutionCompleteDataError
{
Message = "Permission denied.",
},
},
};
FunctionResultContent? toolResult = AryxCopilotAgent.TryCreateToolResultContent(toolExecutionComplete, "view");
Assert.NotNull(toolResult);
Assert.Equal("call-456", toolResult.CallId);
Assert.Equal("Permission denied.", Assert.IsType<string>(toolResult.Result));
}
[Fact]
public void TryCreateToolResultContent_SkipsHandoffTools()
{
ToolExecutionCompleteEvent toolExecutionComplete = new()
{
Data = new ToolExecutionCompleteData
{
ToolCallId = "call-789",
Success = true,
Result = new ToolExecutionCompleteDataResult
{
Content = "Transferred.",
},
},
};
FunctionResultContent? toolResult = AryxCopilotAgent.TryCreateToolResultContent(
toolExecutionComplete,
"handoff_to_reviewer");
Assert.Null(toolResult);
}
[Fact]
@@ -60,7 +60,7 @@ public sealed class CopilotTurnExecutionStateTests
}
[Fact]
public void ObserveSessionEvent_ToolExecutionStart_TracksToolNameByCallId()
public void ObserveSessionEvent_ToolExecutionStart_TracksToolNameByCallIdAndQueuesToolActivity()
{
RunTurnCommandDto command = CreateCommand();
CopilotTurnExecutionState state = new(command);
@@ -68,22 +68,32 @@ public sealed class CopilotTurnExecutionStateTests
state.ObserveSessionEvent(
command.Pattern.Agents[0],
SessionEvent.FromJson(
"""
{
"type": "tool.execution_start",
"data": {
"toolCallId": "tool-call-1",
"toolName": "view"
},
"id": "33333333-3333-3333-3333-333333333333",
"timestamp": "2026-03-27T00:00:00Z"
}
"""));
"""{"type":"tool.execution_start","data":{"toolCallId":"tool-call-1","toolName":"view"},"id":"33333333-3333-3333-3333-333333333333","timestamp":"2026-03-27T00:00:00Z"}"""));
AgentActivityEventDto toolActivity = Assert.Single(state.DrainPendingEvents().OfType<AgentActivityEventDto>());
Assert.Equal("tool-calling", toolActivity.ActivityType);
Assert.Equal("view", toolActivity.ToolName);
Assert.Equal("tool-call-1", toolActivity.ToolCallId);
Assert.True(state.ToolNamesByCallId.TryGetValue("tool-call-1", out string? toolName));
Assert.Equal("view", toolName);
}
[Fact]
public void ObserveSessionEvent_ToolExecutionStart_DoesNotQueueToolActivityForHandoffTools()
{
RunTurnCommandDto command = CreateCommand();
CopilotTurnExecutionState state = new(command);
state.ObserveSessionEvent(
command.Pattern.Agents[0],
SessionEvent.FromJson(
"""{"type":"tool.execution_start","data":{"toolCallId":"tool-call-1","toolName":"handoff_to_specialist"},"id":"1ce9d1dc-68f1-4df5-9728-f97017233279","timestamp":"2026-03-27T00:00:00Z"}"""));
Assert.Empty(state.DrainPendingEvents().OfType<AgentActivityEventDto>());
Assert.True(state.ToolNamesByCallId.TryGetValue("tool-call-1", out string? toolName));
Assert.Equal("handoff_to_specialist", toolName);
}
[Fact]
public void ObserveSessionEvent_AssistantMessageWithToolRequests_QueuesMessageReclassifiedEvent()
{
@@ -178,6 +188,10 @@ public sealed class CopilotTurnExecutionStateTests
IReadOnlyList<SidecarEventDto> pending = state.DrainPendingEvents();
AgentActivityEventDto[] toolActivities = [.. pending.OfType<AgentActivityEventDto>().Where(activity => activity.ActivityType == "tool-calling")];
Assert.Equal(2, toolActivities.Length);
Assert.Contains(toolActivities, activity => activity.ToolCallId == "tool-call-1" && activity.ToolName == "rg");
Assert.Contains(toolActivities, activity => activity.ToolCallId == "tool-call-2" && activity.ToolName == "view");
MessageReclassifiedEventDto reclassified = Assert.Single(pending.OfType<MessageReclassifiedEventDto>());
Assert.Equal("msg-3", reclassified.MessageId);
Assert.True(state.ToolNamesByCallId.TryGetValue("tool-call-1", out string? firstToolName));
@@ -86,6 +86,26 @@ public sealed class WorkflowRequestInfoInterpreterTests
Assert.Empty(toolNamesByCallId);
}
[Fact]
public void TryCreateActivityFromRequest_SkipsDuplicateTrackedToolCallIds()
{
ConcurrentDictionary<string, string> toolNamesByCallId = new(StringComparer.Ordinal)
{
["call-1"] = "view",
};
RequestInfoEvent requestInfo = CreateRequestInfoEvent(
new FunctionCallContent("call-1", "view", new Dictionary<string, object?>()));
AgentActivityEventDto? activity = WorkflowRequestInfoInterpreter.TryCreateActivityFromRequest(
CreateSingleAgentCommand(),
requestInfo,
new AgentIdentity("agent-1", "Primary"),
toolNamesByCallId);
Assert.Null(activity);
Assert.Equal("view", toolNamesByCallId["call-1"]);
}
[Fact]
public void TryCreateActivityFromRequest_ReturnsHandoffActivityForKnownTargets()
{