refactor: unify tool call stream tracking

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
David Kaya
2026-04-13 15:33:27 +02:00
co-authored by Copilot
parent 7b9c4d140c
commit 4e34d2abfc
9 changed files with 438 additions and 236 deletions
@@ -326,8 +326,7 @@ public class AgentWorkflowTurnRunner : ITurnWorkflowRunner
command, command,
requestInfo, requestInfo,
state.ActiveAgent, state.ActiveAgent,
state.ToolNamesByCallId, state.ToolCalls);
state.ToolCallHasArgumentsById);
if (activity is null) if (activity is null)
{ {
@@ -70,7 +70,7 @@ internal sealed class CopilotApprovalCoordinator
WorkflowNodeDto agent, WorkflowNodeDto agent,
PermissionRequest request, PermissionRequest request,
PermissionInvocation invocation, PermissionInvocation invocation,
IReadOnlyDictionary<string, string> toolNamesByCallId, ToolCallRegistry toolCalls,
Func<ApprovalRequestedEventDto, Task> onApproval, Func<ApprovalRequestedEventDto, Task> onApproval,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
@@ -79,7 +79,7 @@ internal sealed class CopilotApprovalCoordinator
agent, agent,
request, request,
invocation, invocation,
toolNamesByCallId, toolCalls,
onActivity: null, onActivity: null,
onApproval, onApproval,
cancellationToken) cancellationToken)
@@ -91,12 +91,12 @@ internal sealed class CopilotApprovalCoordinator
WorkflowNodeDto agent, WorkflowNodeDto agent,
PermissionRequest request, PermissionRequest request,
PermissionInvocation invocation, PermissionInvocation invocation,
IReadOnlyDictionary<string, string> toolNamesByCallId, ToolCallRegistry toolCalls,
Func<AgentActivityEventDto, Task>? onActivity, Func<AgentActivityEventDto, Task>? onActivity,
Func<ApprovalRequestedEventDto, Task> onApproval, Func<ApprovalRequestedEventDto, Task> onApproval,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
string? toolName = ResolveApprovalToolName(request, toolNamesByCallId); string? toolName = ResolveApprovalToolName(request, toolCalls);
string? autoApprovedToolName = ResolveAutoApprovedToolName(request); string? autoApprovedToolName = ResolveAutoApprovedToolName(request);
string? mcpServerApprovalKey = ResolveMcpServerApprovalKey(request, command.Tooling?.McpServers); string? mcpServerApprovalKey = ResolveMcpServerApprovalKey(request, command.Tooling?.McpServers);
string? approvalCacheKey = ResolveApprovalCacheKey(toolName, autoApprovedToolName); string? approvalCacheKey = ResolveApprovalCacheKey(toolName, autoApprovedToolName);
@@ -339,15 +339,15 @@ internal sealed class CopilotApprovalCoordinator
internal static bool TryGetApprovalToolName( internal static bool TryGetApprovalToolName(
PermissionRequest request, PermissionRequest request,
IReadOnlyDictionary<string, string>? toolNamesByCallId, ToolCallRegistry? toolCalls,
out string? toolName) out string? toolName)
{ {
toolName = ResolveApprovalToolName(request, toolNamesByCallId); toolName = ResolveApprovalToolName(request, toolCalls);
return toolName is not null; return toolName is not null;
} }
internal static bool TryGetApprovalToolName(PermissionRequest request, out string? toolName) internal static bool TryGetApprovalToolName(PermissionRequest request, out string? toolName)
=> TryGetApprovalToolName(request, toolNamesByCallId: null, out toolName); => TryGetApprovalToolName(request, toolCalls: null, out toolName);
internal void ClearRequestApprovals(string requestId) internal void ClearRequestApprovals(string requestId)
{ {
@@ -404,10 +404,10 @@ internal sealed class CopilotApprovalCoordinator
private static string? ResolveApprovalToolName( private static string? ResolveApprovalToolName(
PermissionRequest request, PermissionRequest request,
IReadOnlyDictionary<string, string>? toolNamesByCallId) ToolCallRegistry? toolCalls)
{ {
return GetDirectToolName(request) return GetDirectToolName(request)
?? ResolveToolNameFromLookup(request, toolNamesByCallId) ?? ResolveToolNameFromLookup(request, toolCalls)
?? GetFallbackToolName(request); ?? GetFallbackToolName(request);
} }
@@ -480,16 +480,16 @@ internal sealed class CopilotApprovalCoordinator
private static string? ResolveToolNameFromLookup( private static string? ResolveToolNameFromLookup(
PermissionRequest request, PermissionRequest request,
IReadOnlyDictionary<string, string>? toolNamesByCallId) ToolCallRegistry? toolCalls)
{ {
if (toolNamesByCallId is null) if (toolCalls is null)
{ {
return null; return null;
} }
string? toolCallId = GetToolCallId(request); string? toolCallId = GetToolCallId(request);
if (toolCallId is null if (toolCallId is null
|| !toolNamesByCallId.TryGetValue(toolCallId, out string? resolvedToolName)) || !toolCalls.TryGetToolName(toolCallId, out string? resolvedToolName))
{ {
return null; return null;
} }
@@ -29,7 +29,7 @@ internal sealed class CopilotTurnRunnerSupport : IProviderTurnSupport
agent, agent,
request, request,
invocation, invocation,
state.ToolNamesByCallId, state.ToolCalls,
activity => AgentWorkflowTurnRunner.EmitActivityAsync(command, state, activity, onEvent), activity => AgentWorkflowTurnRunner.EmitActivityAsync(command, state, activity, onEvent),
onApproval, onApproval,
runCancellation.Token), runCancellation.Token),
@@ -0,0 +1,165 @@
using System.Collections.Concurrent;
using System.Diagnostics.CodeAnalysis;
using Aryx.AgentHost.Contracts;
namespace Aryx.AgentHost.Services;
internal sealed class ToolCallRegistry
{
private readonly ConcurrentDictionary<string, ProviderToolExecutionSnapshot> _toolExecutionsByCallId = new(StringComparer.Ordinal);
public bool TryGetExecution(string? toolCallId, [NotNullWhen(true)] out ProviderToolExecutionSnapshot? snapshot)
{
snapshot = null;
return !string.IsNullOrWhiteSpace(toolCallId)
&& _toolExecutionsByCallId.TryGetValue(toolCallId, out snapshot);
}
public bool TryGetToolName(string? toolCallId, [NotNullWhen(true)] out string? toolName)
{
toolName = null;
return TryGetExecution(toolCallId, out ProviderToolExecutionSnapshot? snapshot)
&& !string.IsNullOrWhiteSpace(snapshot.ToolName)
&& (toolName = snapshot.ToolName) is not null;
}
public bool HasTrackedArguments(string? toolCallId)
{
return TryGetExecution(toolCallId, out ProviderToolExecutionSnapshot? snapshot)
&& snapshot.ToolArguments is { Count: > 0 };
}
public void RecordToolStart(
string toolCallId,
string toolName,
IReadOnlyDictionary<string, object?>? toolArguments)
{
_toolExecutionsByCallId.AddOrUpdate(
toolCallId,
static (id, state) => new ProviderToolExecutionSnapshot
{
ToolCallId = id,
ToolName = state.ToolName,
ToolArguments = state.ToolArguments,
Status = ProviderToolExecutionStatus.Running,
},
static (_, existing, state) => existing with
{
ToolName = state.ToolName,
ToolArguments = state.ToolArguments,
Status = ProviderToolExecutionStatus.Running,
},
(ToolName: toolName, ToolArguments: toolArguments));
}
public bool TryRecordToolRequest(
string? toolCallId,
string toolName,
IReadOnlyDictionary<string, object?>? toolArguments)
{
bool hasToolArguments = toolArguments is { Count: > 0 };
string? normalizedToolCallId = NormalizeOptionalString(toolCallId);
if (normalizedToolCallId is null)
{
return true;
}
if (_toolExecutionsByCallId.TryGetValue(normalizedToolCallId, out ProviderToolExecutionSnapshot? existing))
{
bool trackedHasArguments = existing.ToolArguments is { Count: > 0 };
if (trackedHasArguments || !hasToolArguments)
{
return false;
}
}
_toolExecutionsByCallId.AddOrUpdate(
normalizedToolCallId,
id => new ProviderToolExecutionSnapshot
{
ToolCallId = id,
ToolName = toolName,
ToolArguments = toolArguments,
Status = ProviderToolExecutionStatus.Running,
},
(_, existing) => existing with
{
ToolName = toolName,
ToolArguments = toolArguments,
Status = existing.Status is ProviderToolExecutionStatus.Completed or ProviderToolExecutionStatus.Failed
? existing.Status
: ProviderToolExecutionStatus.Running,
});
return true;
}
public void RecordProgress(string toolCallId, string? progressMessage)
{
string? normalizedProgress = NormalizeOptionalString(progressMessage);
_toolExecutionsByCallId.AddOrUpdate(
toolCallId,
id => new ProviderToolExecutionSnapshot
{
ToolCallId = id,
Status = ProviderToolExecutionStatus.Running,
LatestProgressMessage = normalizedProgress,
},
(_, existing) => existing with
{
Status = existing.Status is ProviderToolExecutionStatus.Completed or ProviderToolExecutionStatus.Failed
? existing.Status
: ProviderToolExecutionStatus.Running,
LatestProgressMessage = normalizedProgress ?? existing.LatestProgressMessage,
});
}
public void RecordPartialResult(string toolCallId, string? partialOutput)
{
if (string.IsNullOrEmpty(partialOutput))
{
return;
}
_toolExecutionsByCallId.AddOrUpdate(
toolCallId,
id => new ProviderToolExecutionSnapshot
{
ToolCallId = id,
Status = ProviderToolExecutionStatus.Running,
PartialOutput = partialOutput,
},
(_, existing) => existing with
{
Status = existing.Status is ProviderToolExecutionStatus.Completed or ProviderToolExecutionStatus.Failed
? existing.Status
: ProviderToolExecutionStatus.Running,
PartialOutput = string.Concat(existing.PartialOutput, partialOutput),
});
}
public void RecordCompletion(ProviderToolExecutionCompleteEvent toolExecution)
{
_toolExecutionsByCallId.AddOrUpdate(
toolExecution.ToolCallId,
id => new ProviderToolExecutionSnapshot
{
ToolCallId = id,
Status = toolExecution.Success ? ProviderToolExecutionStatus.Completed : ProviderToolExecutionStatus.Failed,
ResultContent = toolExecution.ResultContent,
DetailedResultContent = toolExecution.DetailedResultContent,
Error = toolExecution.Error,
},
(_, existing) => existing with
{
Status = toolExecution.Success ? ProviderToolExecutionStatus.Completed : ProviderToolExecutionStatus.Failed,
ResultContent = toolExecution.ResultContent ?? existing.ResultContent,
DetailedResultContent = toolExecution.DetailedResultContent ?? existing.DetailedResultContent,
Error = toolExecution.Error ?? existing.Error,
});
}
private static string? NormalizeOptionalString(string? value)
{
return string.IsNullOrWhiteSpace(value) ? null : value.Trim();
}
}
@@ -15,7 +15,6 @@ internal class TurnExecutionState
private readonly ConcurrentQueue<SidecarEventDto> _pendingEvents = new(); private readonly ConcurrentQueue<SidecarEventDto> _pendingEvents = new();
private readonly ConcurrentQueue<McpOauthRequiredEventDto> _pendingMcpOauthRequests = new(); private readonly ConcurrentQueue<McpOauthRequiredEventDto> _pendingMcpOauthRequests = new();
private readonly ConcurrentDictionary<string, AgentIdentity> _observedAgentsByMessageId = new(StringComparer.Ordinal); private readonly ConcurrentDictionary<string, AgentIdentity> _observedAgentsByMessageId = new(StringComparer.Ordinal);
private readonly ConcurrentDictionary<string, ProviderToolExecutionSnapshot> _toolExecutionsByCallId = new(StringComparer.Ordinal);
private readonly ConcurrentDictionary<string, ProviderReasoningSnapshot> _reasoningById = new(StringComparer.Ordinal); private readonly ConcurrentDictionary<string, ProviderReasoningSnapshot> _reasoningById = new(StringComparer.Ordinal);
private readonly ConcurrentDictionary<string, string> _latestIntentByAgentId = new(StringComparer.Ordinal); private readonly ConcurrentDictionary<string, string> _latestIntentByAgentId = new(StringComparer.Ordinal);
private readonly StreamingTranscriptBuffer _transcriptBuffer = new(); private readonly StreamingTranscriptBuffer _transcriptBuffer = new();
@@ -29,9 +28,7 @@ internal class TurnExecutionState
_agentSubworkflowIndex = AgentIdentityResolver.BuildAgentSubworkflowIndex(command.Workflow, _workflowLibrary); _agentSubworkflowIndex = AgentIdentityResolver.BuildAgentSubworkflowIndex(command.Workflow, _workflowLibrary);
} }
public ConcurrentDictionary<string, string> ToolNamesByCallId { get; } = new(StringComparer.Ordinal); public ToolCallRegistry ToolCalls { get; } = new();
public ConcurrentDictionary<string, bool> ToolCallHasArgumentsById { get; } = new(StringComparer.Ordinal);
public AgentIdentity? ActiveAgent { get; private set; } public AgentIdentity? ActiveAgent { get; private set; }
@@ -166,13 +163,16 @@ internal class TurnExecutionState
case ProviderToolExecutionStartEvent toolExecutionStart: case ProviderToolExecutionStartEvent toolExecutionStart:
string toolCallId = toolExecutionStart.ToolCallId; string toolCallId = toolExecutionStart.ToolCallId;
string toolName = toolExecutionStart.ToolName; string toolName = toolExecutionStart.ToolName;
TrackToolCall(toolCallId, toolName, toolExecutionStart.ToolArguments); bool shouldQueueToolActivity = TrackToolCall(toolCallId, toolName, toolExecutionStart.ToolArguments);
ActiveAgent = agent; ActiveAgent = agent;
AgentActivityEventDto? toolActivity = CreateToolCallingActivity( if (shouldQueueToolActivity)
agent, toolName, toolCallId, toolExecutionStart.ToolArguments);
if (toolActivity is not null)
{ {
_pendingEvents.Enqueue(toolActivity); AgentActivityEventDto? toolActivity = CreateToolCallingActivity(
agent, toolName, toolCallId, toolExecutionStart.ToolArguments);
if (toolActivity is not null)
{
_pendingEvents.Enqueue(toolActivity);
}
} }
QueueMessageReclassifiedIfNeeded(_lastObservedMessageId); QueueMessageReclassifiedIfNeeded(_lastObservedMessageId);
@@ -338,9 +338,7 @@ internal class TurnExecutionState
public bool TryGetToolExecution(string? toolCallId, [NotNullWhen(true)] out ProviderToolExecutionSnapshot? snapshot) public bool TryGetToolExecution(string? toolCallId, [NotNullWhen(true)] out ProviderToolExecutionSnapshot? snapshot)
{ {
snapshot = null; return ToolCalls.TryGetExecution(toolCallId, out snapshot);
return !string.IsNullOrWhiteSpace(toolCallId)
&& _toolExecutionsByCallId.TryGetValue(toolCallId, out snapshot);
} }
public bool TryGetReasoning(string? reasoningId, [NotNullWhen(true)] out ProviderReasoningSnapshot? snapshot) public bool TryGetReasoning(string? reasoningId, [NotNullWhen(true)] out ProviderReasoningSnapshot? snapshot)
@@ -393,102 +391,27 @@ internal class TurnExecutionState
_lastObservedMessageId = messageId; _lastObservedMessageId = messageId;
} }
private void TrackToolCall( private bool TrackToolCall(
string toolCallId, string toolCallId,
string toolName, string toolName,
IReadOnlyDictionary<string, object?>? toolArguments) IReadOnlyDictionary<string, object?>? toolArguments)
{ {
ToolNamesByCallId[toolCallId] = toolName; return ToolCalls.TryRecordToolRequest(toolCallId, toolName, toolArguments);
ToolCallHasArgumentsById[toolCallId] = toolArguments is { Count: > 0 };
TrackToolExecutionStart(toolCallId, toolName, toolArguments);
}
private void TrackToolExecutionStart(
string toolCallId,
string toolName,
IReadOnlyDictionary<string, object?>? toolArguments)
{
_toolExecutionsByCallId.AddOrUpdate(
toolCallId,
static (id, state) => new ProviderToolExecutionSnapshot
{
ToolCallId = id,
ToolName = state.ToolName,
ToolArguments = state.ToolArguments,
Status = ProviderToolExecutionStatus.Running,
},
static (_, existing, state) => existing with
{
ToolName = state.ToolName,
ToolArguments = state.ToolArguments,
Status = ProviderToolExecutionStatus.Running,
},
(ToolName: toolName, ToolArguments: toolArguments));
} }
private void TrackToolExecutionProgress(string toolCallId, string? progressMessage) private void TrackToolExecutionProgress(string toolCallId, string? progressMessage)
{ {
string? normalizedProgress = NormalizeOptionalString(progressMessage); ToolCalls.RecordProgress(toolCallId, progressMessage);
_toolExecutionsByCallId.AddOrUpdate(
toolCallId,
id => new ProviderToolExecutionSnapshot
{
ToolCallId = id,
Status = ProviderToolExecutionStatus.Running,
LatestProgressMessage = normalizedProgress,
},
(_, existing) => existing with
{
Status = existing.Status is ProviderToolExecutionStatus.Completed or ProviderToolExecutionStatus.Failed
? existing.Status
: ProviderToolExecutionStatus.Running,
LatestProgressMessage = normalizedProgress ?? existing.LatestProgressMessage,
});
} }
private void TrackToolExecutionPartialResult(string toolCallId, string? partialOutput) private void TrackToolExecutionPartialResult(string toolCallId, string? partialOutput)
{ {
if (string.IsNullOrEmpty(partialOutput)) ToolCalls.RecordPartialResult(toolCallId, partialOutput);
{
return;
}
_toolExecutionsByCallId.AddOrUpdate(
toolCallId,
id => new ProviderToolExecutionSnapshot
{
ToolCallId = id,
Status = ProviderToolExecutionStatus.Running,
PartialOutput = partialOutput,
},
(_, existing) => existing with
{
Status = existing.Status is ProviderToolExecutionStatus.Completed or ProviderToolExecutionStatus.Failed
? existing.Status
: ProviderToolExecutionStatus.Running,
PartialOutput = string.Concat(existing.PartialOutput, partialOutput),
});
} }
private void TrackToolExecutionComplete(ProviderToolExecutionCompleteEvent toolExecution) private void TrackToolExecutionComplete(ProviderToolExecutionCompleteEvent toolExecution)
{ {
_toolExecutionsByCallId.AddOrUpdate( ToolCalls.RecordCompletion(toolExecution);
toolExecution.ToolCallId,
id => new ProviderToolExecutionSnapshot
{
ToolCallId = id,
Status = toolExecution.Success ? ProviderToolExecutionStatus.Completed : ProviderToolExecutionStatus.Failed,
ResultContent = toolExecution.ResultContent,
DetailedResultContent = toolExecution.DetailedResultContent,
Error = toolExecution.Error,
},
(_, existing) => existing with
{
Status = toolExecution.Success ? ProviderToolExecutionStatus.Completed : ProviderToolExecutionStatus.Failed,
ResultContent = toolExecution.ResultContent ?? existing.ResultContent,
DetailedResultContent = toolExecution.DetailedResultContent ?? existing.DetailedResultContent,
Error = toolExecution.Error ?? existing.Error,
});
} }
private void TrackLatestIntent(string agentId, string? intent) private void TrackLatestIntent(string agentId, string? intent)
@@ -1,4 +1,3 @@
using System.Collections.Concurrent;
using System.Text.Json; using System.Text.Json;
using Aryx.AgentHost.Contracts; using Aryx.AgentHost.Contracts;
using Microsoft.Agents.AI.Workflows; using Microsoft.Agents.AI.Workflows;
@@ -20,8 +19,7 @@ internal static class WorkflowRequestInfoInterpreter
RunTurnCommandDto command, RunTurnCommandDto command,
RequestInfoEvent requestInfo, RequestInfoEvent requestInfo,
AgentIdentity? activeAgent, AgentIdentity? activeAgent,
ConcurrentDictionary<string, string> toolNamesByCallId, ToolCallRegistry toolCalls)
ConcurrentDictionary<string, bool> toolCallHasArgumentsById)
{ {
RequestInterpretation interpretation = InterpretRequest(command, requestInfo); RequestInterpretation interpretation = InterpretRequest(command, requestInfo);
return interpretation switch return interpretation switch
@@ -29,7 +27,7 @@ internal static class WorkflowRequestInfoInterpreter
HandoffRequestInterpretation handoff => HandoffRequestInterpretation handoff =>
CreateHandoffActivity(command, handoff.TargetAgent, activeAgent), CreateHandoffActivity(command, handoff.TargetAgent, activeAgent),
ToolRequestInterpretation tool when activeAgent.HasValue => ToolRequestInterpretation tool when activeAgent.HasValue =>
CreateToolCallingActivity(command, activeAgent.Value, tool, toolNamesByCallId, toolCallHasArgumentsById), CreateToolCallingActivity(command, activeAgent.Value, tool, toolCalls),
_ => null, _ => null,
}; };
} }
@@ -66,22 +64,13 @@ internal static class WorkflowRequestInfoInterpreter
RunTurnCommandDto command, RunTurnCommandDto command,
AgentIdentity activeAgent, AgentIdentity activeAgent,
ToolRequestInterpretation tool, ToolRequestInterpretation tool,
ConcurrentDictionary<string, string> toolNamesByCallId, ToolCallRegistry toolCalls)
ConcurrentDictionary<string, bool> toolCallHasArgumentsById)
{ {
bool hasToolArguments = tool.ToolArguments is { Count: > 0 }; if (!toolCalls.TryRecordToolRequest(tool.ToolCallId, tool.ToolName, tool.ToolArguments))
if (tool.ToolCallId is not null && toolNamesByCallId.ContainsKey(tool.ToolCallId))
{ {
bool trackedHasArguments = toolCallHasArgumentsById.TryGetValue(tool.ToolCallId, out bool hasTrackedArguments) return null;
&& hasTrackedArguments;
if (trackedHasArguments || !hasToolArguments)
{
return null;
}
} }
TrackToolCallId(toolNamesByCallId, toolCallHasArgumentsById, tool.ToolCallId, tool.ToolName, hasToolArguments);
return new AgentActivityEventDto return new AgentActivityEventDto
{ {
Type = "agent-activity", Type = "agent-activity",
@@ -98,20 +87,6 @@ internal static class WorkflowRequestInfoInterpreter
}; };
} }
private static void TrackToolCallId(
ConcurrentDictionary<string, string> toolNamesByCallId,
ConcurrentDictionary<string, bool> toolCallHasArgumentsById,
string? toolCallId,
string toolName,
bool hasToolArguments)
{
if (toolCallId is not null)
{
toolNamesByCallId[toolCallId] = toolName;
toolCallHasArgumentsById[toolCallId] = hasToolArguments;
}
}
private static RequestInterpretation InterpretRequest( private static RequestInterpretation InterpretRequest(
RunTurnCommandDto command, RunTurnCommandDto command,
RequestInfoEvent requestInfo) RequestInfoEvent requestInfo)
@@ -110,10 +110,9 @@ public sealed class CopilotTurnExecutionStateTests
Assert.Equal("tool-call-1", toolActivity.ToolCallId); Assert.Equal("tool-call-1", toolActivity.ToolCallId);
Assert.NotNull(toolActivity.ToolArguments); Assert.NotNull(toolActivity.ToolArguments);
Assert.Equal("/src/main.ts", toolActivity.ToolArguments["path"]); Assert.Equal("/src/main.ts", toolActivity.ToolArguments["path"]);
Assert.True(state.ToolNamesByCallId.TryGetValue("tool-call-1", out string? toolName)); Assert.True(state.ToolCalls.TryGetToolName("tool-call-1", out string? toolName));
Assert.Equal("view", toolName); Assert.Equal("view", toolName);
Assert.True(state.ToolCallHasArgumentsById.TryGetValue("tool-call-1", out bool hasArguments)); Assert.True(state.ToolCalls.HasTrackedArguments("tool-call-1"));
Assert.True(hasArguments);
} }
[Fact] [Fact]
@@ -129,8 +128,7 @@ public sealed class CopilotTurnExecutionStateTests
AgentActivityEventDto toolActivity = Assert.Single(state.DrainPendingEvents().OfType<AgentActivityEventDto>()); AgentActivityEventDto toolActivity = Assert.Single(state.DrainPendingEvents().OfType<AgentActivityEventDto>());
Assert.Null(toolActivity.ToolArguments); Assert.Null(toolActivity.ToolArguments);
Assert.True(state.ToolCallHasArgumentsById.TryGetValue("tool-call-1", out bool hasArguments)); Assert.False(state.ToolCalls.HasTrackedArguments("tool-call-1"));
Assert.False(hasArguments);
} }
[Fact] [Fact]
@@ -253,10 +251,9 @@ public sealed class CopilotTurnExecutionStateTests
"""{"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"}""")); """{"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.Empty(state.DrainPendingEvents().OfType<AgentActivityEventDto>());
Assert.True(state.ToolNamesByCallId.TryGetValue("tool-call-1", out string? toolName)); Assert.True(state.ToolCalls.TryGetToolName("tool-call-1", out string? toolName));
Assert.Equal("handoff_to_specialist", toolName); Assert.Equal("handoff_to_specialist", toolName);
Assert.True(state.ToolCallHasArgumentsById.TryGetValue("tool-call-1", out bool hasArguments)); Assert.False(state.ToolCalls.HasTrackedArguments("tool-call-1"));
Assert.False(hasArguments);
} }
[Fact] [Fact]
@@ -375,14 +372,12 @@ public sealed class CopilotTurnExecutionStateTests
Assert.Contains(toolActivities, activity => activity.ToolCallId == "tool-call-2" && activity.ToolName == "view"); Assert.Contains(toolActivities, activity => activity.ToolCallId == "tool-call-2" && activity.ToolName == "view");
MessageReclassifiedEventDto reclassified = Assert.Single(pending.OfType<MessageReclassifiedEventDto>()); MessageReclassifiedEventDto reclassified = Assert.Single(pending.OfType<MessageReclassifiedEventDto>());
Assert.Equal("msg-3", reclassified.MessageId); Assert.Equal("msg-3", reclassified.MessageId);
Assert.True(state.ToolNamesByCallId.TryGetValue("tool-call-1", out string? firstToolName)); Assert.True(state.ToolCalls.TryGetToolName("tool-call-1", out string? firstToolName));
Assert.Equal("rg", firstToolName); Assert.Equal("rg", firstToolName);
Assert.True(state.ToolNamesByCallId.TryGetValue("tool-call-2", out string? secondToolName)); Assert.True(state.ToolCalls.TryGetToolName("tool-call-2", out string? secondToolName));
Assert.Equal("view", secondToolName); Assert.Equal("view", secondToolName);
Assert.True(state.ToolCallHasArgumentsById.TryGetValue("tool-call-1", out bool firstHasArguments)); Assert.False(state.ToolCalls.HasTrackedArguments("tool-call-1"));
Assert.False(firstHasArguments); Assert.False(state.ToolCalls.HasTrackedArguments("tool-call-2"));
Assert.True(state.ToolCallHasArgumentsById.TryGetValue("tool-call-2", out bool secondHasArguments));
Assert.False(secondHasArguments);
} }
[Fact] [Fact]
@@ -776,8 +776,165 @@ public sealed class CopilotWorkflowRunnerTests
Assert.Equal("tool-call-1", activity.ToolCallId); Assert.Equal("tool-call-1", activity.ToolCallId);
Assert.NotNull(activity.ToolArguments); Assert.NotNull(activity.ToolArguments);
Assert.Equal(@"C:\workspace\README.md", activity.ToolArguments["path"]); Assert.Equal(@"C:\workspace\README.md", activity.ToolArguments["path"]);
Assert.True(state.ToolCallHasArgumentsById.TryGetValue("tool-call-1", out bool hasArguments)); Assert.True(state.ToolCalls.HasTrackedArguments("tool-call-1"));
Assert.True(hasArguments); }
[Fact]
public async Task ObserveSessionEvent_ToolExecutionStart_DoesNotDuplicateTrackedRequestInfoActivity()
{
RunTurnCommandDto command = CreateApprovalCommand();
CopilotTurnExecutionState state = new(command);
WorkflowNodeDto agent = CreateAgent("agent-1", "Primary");
state.ObserveSessionEvent(
agent,
SessionEvent.FromJson(
"""
{
"type": "assistant.message_delta",
"data": {
"messageId": "msg-1",
"deltaContent": "Inspecting"
},
"id": "b61652d1-120e-4a9f-8f0e-1dbf04fb18da",
"timestamp": "2026-03-27T00:00:00Z"
}
"""));
_ = state.DrainPendingEvents();
RequestInfoEvent requestInfo = CreateRequestInfoEvent(
new FunctionCallContent("tool-call-1", "view", new Dictionary<string, object?>
{
["path"] = @"C:\workspace\README.md",
}));
List<AgentActivityEventDto> requestActivities = [];
MethodInfo handleWorkflowEvent = typeof(CopilotWorkflowRunner).GetMethod(
"HandleWorkflowEventAsync",
BindingFlags.NonPublic | BindingFlags.Static)!;
Task<bool> handleTask = (Task<bool>)handleWorkflowEvent.Invoke(
null,
[
command,
requestInfo,
Array.Empty<ChatMessage>(),
state,
(Func<TurnDeltaEventDto, Task>)(_ => Task.CompletedTask),
(Func<SidecarEventDto, Task>)(sidecarEvent =>
{
requestActivities.Add(Assert.IsType<AgentActivityEventDto>(sidecarEvent));
return Task.CompletedTask;
}),
])!;
bool shouldEndTurn = await handleTask;
Assert.False(shouldEndTurn);
AgentActivityEventDto requestActivity = Assert.Single(requestActivities);
Assert.Equal("tool-calling", requestActivity.ActivityType);
Assert.True(state.ToolCalls.HasTrackedArguments("tool-call-1"));
state.ObserveSessionEvent(
agent,
SessionEvent.FromJson(
"""
{
"type": "tool.execution_start",
"data": {
"toolCallId": "tool-call-1",
"toolName": "view",
"arguments": {
"path": "C:\\workspace\\README.md"
}
},
"id": "c61652d1-120e-4a9f-8f0e-1dbf04fb18da",
"timestamp": "2026-03-27T00:00:01Z"
}
"""));
IReadOnlyList<SidecarEventDto> pending = state.DrainPendingEvents();
Assert.DoesNotContain(
pending.OfType<AgentActivityEventDto>(),
activity => activity.ActivityType == "tool-calling");
MessageReclassifiedEventDto reclassified = Assert.Single(pending.OfType<MessageReclassifiedEventDto>());
Assert.Equal("msg-1", reclassified.MessageId);
}
[Fact]
public async Task ObserveSessionEvent_ToolExecutionStart_EmitsEnrichmentWhenRequestInfoWasMissingArguments()
{
RunTurnCommandDto command = CreateApprovalCommand();
CopilotTurnExecutionState state = new(command);
WorkflowNodeDto agent = CreateAgent("agent-1", "Primary");
state.ObserveSessionEvent(
agent,
SessionEvent.FromJson(
"""
{
"type": "assistant.message_delta",
"data": {
"messageId": "msg-2",
"deltaContent": "Inspecting"
},
"id": "d61652d1-120e-4a9f-8f0e-1dbf04fb18da",
"timestamp": "2026-03-27T00:00:00Z"
}
"""));
_ = state.DrainPendingEvents();
RequestInfoEvent requestInfo = CreateRequestInfoEvent(
new FunctionCallContent("tool-call-1", "view", new Dictionary<string, object?>()));
List<AgentActivityEventDto> requestActivities = [];
MethodInfo handleWorkflowEvent = typeof(CopilotWorkflowRunner).GetMethod(
"HandleWorkflowEventAsync",
BindingFlags.NonPublic | BindingFlags.Static)!;
Task<bool> handleTask = (Task<bool>)handleWorkflowEvent.Invoke(
null,
[
command,
requestInfo,
Array.Empty<ChatMessage>(),
state,
(Func<TurnDeltaEventDto, Task>)(_ => Task.CompletedTask),
(Func<SidecarEventDto, Task>)(sidecarEvent =>
{
requestActivities.Add(Assert.IsType<AgentActivityEventDto>(sidecarEvent));
return Task.CompletedTask;
}),
])!;
bool shouldEndTurn = await handleTask;
Assert.False(shouldEndTurn);
AgentActivityEventDto requestActivity = Assert.Single(requestActivities);
Assert.Null(requestActivity.ToolArguments);
Assert.False(state.ToolCalls.HasTrackedArguments("tool-call-1"));
state.ObserveSessionEvent(
agent,
SessionEvent.FromJson(
"""
{
"type": "tool.execution_start",
"data": {
"toolCallId": "tool-call-1",
"toolName": "view",
"arguments": {
"path": "C:\\workspace\\README.md"
}
},
"id": "e61652d1-120e-4a9f-8f0e-1dbf04fb18da",
"timestamp": "2026-03-27T00:00:01Z"
}
"""));
AgentActivityEventDto enrichment = Assert.Single(
state.DrainPendingEvents().OfType<AgentActivityEventDto>(),
activity => activity.ActivityType == "tool-calling");
Assert.NotNull(enrichment.ToolArguments);
Assert.Equal(@"C:\workspace\README.md", enrichment.ToolArguments["path"]);
Assert.True(state.ToolCalls.HasTrackedArguments("tool-call-1"));
} }
[Fact] [Fact]
@@ -1372,14 +1529,12 @@ public sealed class CopilotWorkflowRunnerTests
[Fact] [Fact]
public void TryGetApprovalToolName_UsesToolCallLookupForPermissionCategoriesWithoutDirectToolNames() public void TryGetApprovalToolName_UsesToolCallLookupForPermissionCategoriesWithoutDirectToolNames()
{ {
Dictionary<string, string> toolNamesByCallId = new(StringComparer.Ordinal) ToolCallRegistry toolCalls = CreateToolCallRegistry(
{ ("tool-call-url", "web_fetch"),
["tool-call-url"] = "web_fetch", ("tool-call-shell", "shell"),
["tool-call-shell"] = "shell", ("tool-call-read", "view"),
["tool-call-read"] = "view", ("tool-call-write", "write_file"),
["tool-call-write"] = "write_file", ("tool-call-memory", "store_memory"));
["tool-call-memory"] = "store_memory",
};
Assert.True( Assert.True(
CopilotApprovalCoordinator.TryGetApprovalToolName( CopilotApprovalCoordinator.TryGetApprovalToolName(
@@ -1390,7 +1545,7 @@ public sealed class CopilotWorkflowRunnerTests
Intention = "Fetch the requested page", Intention = "Fetch the requested page",
Url = "https://example.com/docs", Url = "https://example.com/docs",
}, },
toolNamesByCallId, toolCalls,
out string? urlToolName)); out string? urlToolName));
Assert.Equal("web_fetch", urlToolName); Assert.Equal("web_fetch", urlToolName);
@@ -1408,7 +1563,7 @@ public sealed class CopilotWorkflowRunnerTests
HasWriteFileRedirection = false, HasWriteFileRedirection = false,
CanOfferSessionApproval = false, CanOfferSessionApproval = false,
}, },
toolNamesByCallId, toolCalls,
out string? shellToolName)); out string? shellToolName));
Assert.Equal("shell", shellToolName); Assert.Equal("shell", shellToolName);
@@ -1421,7 +1576,7 @@ public sealed class CopilotWorkflowRunnerTests
Intention = "Inspect a file", Intention = "Inspect a file",
Path = "README.md", Path = "README.md",
}, },
toolNamesByCallId, toolCalls,
out string? readToolName)); out string? readToolName));
Assert.Equal("view", readToolName); Assert.Equal("view", readToolName);
@@ -1435,7 +1590,7 @@ public sealed class CopilotWorkflowRunnerTests
FileName = "README.md", FileName = "README.md",
Diff = "@@ -1 +1 @@", Diff = "@@ -1 +1 @@",
}, },
toolNamesByCallId, toolCalls,
out string? writeToolName)); out string? writeToolName));
Assert.Equal("write_file", writeToolName); Assert.Equal("write_file", writeToolName);
@@ -1449,7 +1604,7 @@ public sealed class CopilotWorkflowRunnerTests
Fact = "Use Bun for script execution.", Fact = "Use Bun for script execution.",
Citations = "package.json", Citations = "package.json",
}, },
toolNamesByCallId, toolCalls,
out string? memoryToolName)); out string? memoryToolName));
Assert.Equal("store_memory", memoryToolName); Assert.Equal("store_memory", memoryToolName);
} }
@@ -1960,7 +2115,7 @@ public sealed class CopilotWorkflowRunnerTests
{ {
SessionId = "copilot-session-1", SessionId = "copilot-session-1",
}, },
new Dictionary<string, string>(StringComparer.Ordinal), CreateToolCallRegistry(),
approval => approval =>
{ {
observedApproval = approval; observedApproval = approval;
@@ -2007,10 +2162,7 @@ public sealed class CopilotWorkflowRunnerTests
{ {
SessionId = "copilot-session-1", SessionId = "copilot-session-1",
}, },
new Dictionary<string, string>(StringComparer.Ordinal) CreateToolCallRegistry(("tool-call-write-1", "apply_patch")),
{
["tool-call-write-1"] = "apply_patch",
},
activity => activity =>
{ {
observedActivity = activity; observedActivity = activity;
@@ -2067,7 +2219,7 @@ public sealed class CopilotWorkflowRunnerTests
{ {
SessionId = "copilot-session-1", SessionId = "copilot-session-1",
}, },
new Dictionary<string, string>(StringComparer.Ordinal), CreateToolCallRegistry(),
approval => approval =>
{ {
sawApproval = true; sawApproval = true;
@@ -2104,7 +2256,7 @@ public sealed class CopilotWorkflowRunnerTests
{ {
SessionId = "copilot-session-1", SessionId = "copilot-session-1",
}, },
new Dictionary<string, string>(StringComparer.Ordinal), CreateToolCallRegistry(),
approval => approval =>
{ {
sawApproval = true; sawApproval = true;
@@ -2137,10 +2289,7 @@ public sealed class CopilotWorkflowRunnerTests
{ {
SessionId = "copilot-session-1", SessionId = "copilot-session-1",
}, },
new Dictionary<string, string>(StringComparer.Ordinal) CreateToolCallRegistry(("tool-call-read-1", "view")),
{
["tool-call-read-1"] = "view",
},
approval => approval =>
{ {
firstApproval = approval; firstApproval = approval;
@@ -2178,10 +2327,7 @@ public sealed class CopilotWorkflowRunnerTests
{ {
SessionId = "copilot-session-1", SessionId = "copilot-session-1",
}, },
new Dictionary<string, string>(StringComparer.Ordinal) CreateToolCallRegistry(("tool-call-read-2", "grep")),
{
["tool-call-read-2"] = "grep",
},
approval => approval =>
{ {
sawSecondApproval = true; sawSecondApproval = true;
@@ -2214,10 +2360,7 @@ public sealed class CopilotWorkflowRunnerTests
{ {
SessionId = "copilot-session-1", SessionId = "copilot-session-1",
}, },
new Dictionary<string, string>(StringComparer.Ordinal) CreateToolCallRegistry(("tool-call-read-1", "view")),
{
["tool-call-read-1"] = "view",
},
approval => approval =>
{ {
firstApproval = approval; firstApproval = approval;
@@ -2254,10 +2397,7 @@ public sealed class CopilotWorkflowRunnerTests
{ {
SessionId = "copilot-session-1", SessionId = "copilot-session-1",
}, },
new Dictionary<string, string>(StringComparer.Ordinal) CreateToolCallRegistry(("tool-call-read-2", "grep")),
{
["tool-call-read-2"] = "grep",
},
approval => approval =>
{ {
secondApproval = approval; secondApproval = approval;
@@ -2548,6 +2688,17 @@ public sealed class CopilotWorkflowRunnerTests
}; };
} }
private static ToolCallRegistry CreateToolCallRegistry(params (string ToolCallId, string ToolName)[] toolCalls)
{
ToolCallRegistry registry = new();
foreach ((string toolCallId, string toolName) in toolCalls)
{
registry.RecordToolStart(toolCallId, toolName, toolArguments: null);
}
return registry;
}
private static RunTurnCommandDto CreateRequestPortCommand() private static RunTurnCommandDto CreateRequestPortCommand()
{ {
return new RunTurnCommandDto return new RunTurnCommandDto
@@ -1,5 +1,4 @@
using System.Collections; using System.Collections;
using System.Collections.Concurrent;
using System.Runtime.CompilerServices; using System.Runtime.CompilerServices;
using System.Text.Json; using System.Text.Json;
using Aryx.AgentHost.Contracts; using Aryx.AgentHost.Contracts;
@@ -27,8 +26,7 @@ public sealed class WorkflowRequestInfoInterpreterTests
CreateSingleAgentCommand(), CreateSingleAgentCommand(),
requestInfo, requestInfo,
new AgentIdentity("agent-1", "Primary"), new AgentIdentity("agent-1", "Primary"),
tracking.ToolNamesByCallId, tracking);
tracking.ToolCallHasArgumentsById);
Assert.NotNull(activity); Assert.NotNull(activity);
Assert.Equal("tool-calling", activity.ActivityType); Assert.Equal("tool-calling", activity.ActivityType);
@@ -38,8 +36,9 @@ public sealed class WorkflowRequestInfoInterpreterTests
Assert.NotNull(activity.ToolArguments); Assert.NotNull(activity.ToolArguments);
Assert.Equal(@"C:\workspace\file.txt", activity.ToolArguments["path"]); Assert.Equal(@"C:\workspace\file.txt", activity.ToolArguments["path"]);
Assert.Equal([10, 25], Assert.IsAssignableFrom<IReadOnlyList<object?>>(activity.ToolArguments["viewRange"])); Assert.Equal([10, 25], Assert.IsAssignableFrom<IReadOnlyList<object?>>(activity.ToolArguments["viewRange"]));
Assert.Equal("view", tracking.ToolNamesByCallId["call-1"]); Assert.True(tracking.TryGetToolName("call-1", out string? toolName));
Assert.True(tracking.ToolCallHasArgumentsById["call-1"]); Assert.Equal("view", toolName);
Assert.True(tracking.HasTrackedArguments("call-1"));
} }
[Fact] [Fact]
@@ -61,8 +60,7 @@ public sealed class WorkflowRequestInfoInterpreterTests
CreateSingleAgentCommand(), CreateSingleAgentCommand(),
requestInfo, requestInfo,
new AgentIdentity("agent-1", "Primary"), new AgentIdentity("agent-1", "Primary"),
tracking.ToolNamesByCallId, tracking);
tracking.ToolCallHasArgumentsById);
Assert.NotNull(activity); Assert.NotNull(activity);
Assert.Equal("tool-calling", activity.ActivityType); Assert.Equal("tool-calling", activity.ActivityType);
@@ -70,8 +68,9 @@ public sealed class WorkflowRequestInfoInterpreterTests
Assert.NotNull(activity.ToolArguments); Assert.NotNull(activity.ToolArguments);
Assert.Equal(@"C:\workspace", activity.ToolArguments["path"]); Assert.Equal(@"C:\workspace", activity.ToolArguments["path"]);
Assert.Equal(true, activity.ToolArguments["includeIgnored"]); Assert.Equal(true, activity.ToolArguments["includeIgnored"]);
Assert.Equal("git.status", tracking.ToolNamesByCallId["call-1"]); Assert.True(tracking.TryGetToolName("call-1", out string? toolName));
Assert.True(tracking.ToolCallHasArgumentsById["call-1"]); Assert.Equal("git.status", toolName);
Assert.True(tracking.HasTrackedArguments("call-1"));
} }
[Fact] [Fact]
@@ -85,8 +84,7 @@ public sealed class WorkflowRequestInfoInterpreterTests
CreateSingleAgentCommand(), CreateSingleAgentCommand(),
requestInfo, requestInfo,
new AgentIdentity("agent-1", "Primary"), new AgentIdentity("agent-1", "Primary"),
tracking.ToolNamesByCallId, tracking);
tracking.ToolCallHasArgumentsById);
Assert.NotNull(activity); Assert.NotNull(activity);
Assert.Equal("tool-calling", activity.ActivityType); Assert.Equal("tool-calling", activity.ActivityType);
@@ -95,8 +93,9 @@ public sealed class WorkflowRequestInfoInterpreterTests
Assert.Equal( Assert.Equal(
["print('hello')"], ["print('hello')"],
Assert.IsAssignableFrom<IReadOnlyList<object?>>(activity.ToolArguments["inputs"])); Assert.IsAssignableFrom<IReadOnlyList<object?>>(activity.ToolArguments["inputs"]));
Assert.Equal("code interpreter", tracking.ToolNamesByCallId["call-1"]); Assert.True(tracking.TryGetToolName("call-1", out string? toolName));
Assert.True(tracking.ToolCallHasArgumentsById["call-1"]); Assert.Equal("code interpreter", toolName);
Assert.True(tracking.HasTrackedArguments("call-1"));
} }
[Fact] [Fact]
@@ -109,15 +108,14 @@ public sealed class WorkflowRequestInfoInterpreterTests
CreateSingleAgentCommand(), CreateSingleAgentCommand(),
requestInfo, requestInfo,
new AgentIdentity("agent-1", "Primary"), new AgentIdentity("agent-1", "Primary"),
tracking.ToolNamesByCallId, tracking);
tracking.ToolCallHasArgumentsById);
Assert.NotNull(activity); Assert.NotNull(activity);
Assert.Equal("tool-calling", activity.ActivityType); Assert.Equal("tool-calling", activity.ActivityType);
Assert.Equal("image generation", activity.ToolName); Assert.Equal("image generation", activity.ToolName);
Assert.Null(activity.ToolArguments); Assert.Null(activity.ToolArguments);
Assert.Empty(tracking.ToolNamesByCallId); Assert.False(tracking.TryGetToolName("call-1", out _));
Assert.Empty(tracking.ToolCallHasArgumentsById); Assert.False(tracking.HasTrackedArguments("call-1"));
} }
[Fact] [Fact]
@@ -135,12 +133,11 @@ public sealed class WorkflowRequestInfoInterpreterTests
CreateSingleAgentCommand(), CreateSingleAgentCommand(),
requestInfo, requestInfo,
new AgentIdentity("agent-1", "Primary"), new AgentIdentity("agent-1", "Primary"),
tracking.ToolNamesByCallId, tracking);
tracking.ToolCallHasArgumentsById);
Assert.NotNull(activity); Assert.NotNull(activity);
Assert.Null(activity.ToolArguments); Assert.Null(activity.ToolArguments);
Assert.False(tracking.ToolCallHasArgumentsById["call-1"]); Assert.False(tracking.HasTrackedArguments("call-1"));
} }
[Fact] [Fact]
@@ -160,21 +157,25 @@ public sealed class WorkflowRequestInfoInterpreterTests
CreateSingleAgentCommand(), CreateSingleAgentCommand(),
requestInfo, requestInfo,
new AgentIdentity("agent-1", "Primary"), new AgentIdentity("agent-1", "Primary"),
tracking.ToolNamesByCallId, tracking);
tracking.ToolCallHasArgumentsById);
Assert.NotNull(activity); Assert.NotNull(activity);
Assert.NotNull(activity.ToolArguments); Assert.NotNull(activity.ToolArguments);
Assert.Equal("[truncated]", activity.ToolArguments["command"]); Assert.Equal("[truncated]", activity.ToolArguments["command"]);
Assert.True(tracking.ToolCallHasArgumentsById["call-1"]); Assert.True(tracking.HasTrackedArguments("call-1"));
} }
[Fact] [Fact]
public void TryCreateActivityFromRequest_SkipsDuplicateTrackedToolCallIdsThatAlreadyHaveArguments() public void TryCreateActivityFromRequest_SkipsDuplicateTrackedToolCallIdsThatAlreadyHaveArguments()
{ {
var tracking = CreateToolTracking(); var tracking = CreateToolTracking();
tracking.ToolNamesByCallId["call-1"] = "view"; tracking.RecordToolStart(
tracking.ToolCallHasArgumentsById["call-1"] = true; "call-1",
"view",
new Dictionary<string, object?>
{
["path"] = @"C:\workspace\seed.txt",
});
RequestInfoEvent requestInfo = CreateRequestInfoEvent( RequestInfoEvent requestInfo = CreateRequestInfoEvent(
new FunctionCallContent("call-1", "view", new Dictionary<string, object?> new FunctionCallContent("call-1", "view", new Dictionary<string, object?>
{ {
@@ -185,20 +186,19 @@ public sealed class WorkflowRequestInfoInterpreterTests
CreateSingleAgentCommand(), CreateSingleAgentCommand(),
requestInfo, requestInfo,
new AgentIdentity("agent-1", "Primary"), new AgentIdentity("agent-1", "Primary"),
tracking.ToolNamesByCallId, tracking);
tracking.ToolCallHasArgumentsById);
Assert.Null(activity); Assert.Null(activity);
Assert.Equal("view", tracking.ToolNamesByCallId["call-1"]); Assert.True(tracking.TryGetToolName("call-1", out string? toolName));
Assert.True(tracking.ToolCallHasArgumentsById["call-1"]); Assert.Equal("view", toolName);
Assert.True(tracking.HasTrackedArguments("call-1"));
} }
[Fact] [Fact]
public void TryCreateActivityFromRequest_EmitsEnrichmentWhenTrackedToolCallWasMissingArguments() public void TryCreateActivityFromRequest_EmitsEnrichmentWhenTrackedToolCallWasMissingArguments()
{ {
var tracking = CreateToolTracking(); var tracking = CreateToolTracking();
tracking.ToolNamesByCallId["call-1"] = "view"; tracking.RecordToolStart("call-1", "view", toolArguments: null);
tracking.ToolCallHasArgumentsById["call-1"] = false;
RequestInfoEvent requestInfo = CreateRequestInfoEvent( RequestInfoEvent requestInfo = CreateRequestInfoEvent(
new FunctionCallContent("call-1", "view", new Dictionary<string, object?> new FunctionCallContent("call-1", "view", new Dictionary<string, object?>
{ {
@@ -209,16 +209,16 @@ public sealed class WorkflowRequestInfoInterpreterTests
CreateSingleAgentCommand(), CreateSingleAgentCommand(),
requestInfo, requestInfo,
new AgentIdentity("agent-1", "Primary"), new AgentIdentity("agent-1", "Primary"),
tracking.ToolNamesByCallId, tracking);
tracking.ToolCallHasArgumentsById);
Assert.NotNull(activity); Assert.NotNull(activity);
Assert.Equal("tool-calling", activity.ActivityType); Assert.Equal("tool-calling", activity.ActivityType);
Assert.Equal("call-1", activity.ToolCallId); Assert.Equal("call-1", activity.ToolCallId);
Assert.NotNull(activity.ToolArguments); Assert.NotNull(activity.ToolArguments);
Assert.Equal(@"C:\workspace\file.txt", activity.ToolArguments["path"]); Assert.Equal(@"C:\workspace\file.txt", activity.ToolArguments["path"]);
Assert.Equal("view", tracking.ToolNamesByCallId["call-1"]); Assert.True(tracking.TryGetToolName("call-1", out string? toolName));
Assert.True(tracking.ToolCallHasArgumentsById["call-1"]); Assert.Equal("view", toolName);
Assert.True(tracking.HasTrackedArguments("call-1"));
} }
[Fact] [Fact]
@@ -232,8 +232,7 @@ public sealed class WorkflowRequestInfoInterpreterTests
CreateHandoffCommand(), CreateHandoffCommand(),
requestInfo, requestInfo,
new AgentIdentity("agent-handoff-triage", "Triage"), new AgentIdentity("agent-handoff-triage", "Triage"),
tracking.ToolNamesByCallId, tracking);
tracking.ToolCallHasArgumentsById);
Assert.NotNull(activity); Assert.NotNull(activity);
Assert.Equal("handoff", activity.ActivityType); Assert.Equal("handoff", activity.ActivityType);
@@ -242,8 +241,8 @@ public sealed class WorkflowRequestInfoInterpreterTests
Assert.Equal("agent-handoff-triage", activity.SourceAgentId); Assert.Equal("agent-handoff-triage", activity.SourceAgentId);
Assert.Equal("Triage", activity.SourceAgentName); Assert.Equal("Triage", activity.SourceAgentName);
Assert.Null(activity.ToolName); Assert.Null(activity.ToolName);
Assert.Empty(tracking.ToolNamesByCallId); Assert.False(tracking.TryGetToolName("call-1", out _));
Assert.Empty(tracking.ToolCallHasArgumentsById); Assert.False(tracking.HasTrackedArguments("call-1"));
} }
[Fact] [Fact]
@@ -263,8 +262,7 @@ public sealed class WorkflowRequestInfoInterpreterTests
"agent-reviewer", "agent-reviewer",
"Reviewer", "Reviewer",
new SubworkflowContext("subworkflow-review", "Review Lane")), new SubworkflowContext("subworkflow-review", "Review Lane")),
tracking.ToolNamesByCallId, tracking);
tracking.ToolCallHasArgumentsById);
Assert.NotNull(activity); Assert.NotNull(activity);
Assert.Equal("tool-calling", activity.ActivityType); Assert.Equal("tool-calling", activity.ActivityType);
@@ -283,8 +281,7 @@ public sealed class WorkflowRequestInfoInterpreterTests
CreateHandoffCommandWithReferencedSubworkflow(), CreateHandoffCommandWithReferencedSubworkflow(),
requestInfo, requestInfo,
new AgentIdentity("agent-handoff-triage", "Triage"), new AgentIdentity("agent-handoff-triage", "Triage"),
tracking.ToolNamesByCallId, tracking);
tracking.ToolCallHasArgumentsById);
Assert.NotNull(activity); Assert.NotNull(activity);
Assert.Equal("handoff", activity.ActivityType); Assert.Equal("handoff", activity.ActivityType);
@@ -417,10 +414,7 @@ public sealed class WorkflowRequestInfoInterpreterTests
workflowLibrary: [nestedWorkflow]); workflowLibrary: [nestedWorkflow]);
} }
private static ( private static ToolCallRegistry CreateToolTracking() => new();
ConcurrentDictionary<string, string> ToolNamesByCallId,
ConcurrentDictionary<string, bool> ToolCallHasArgumentsById) CreateToolTracking()
=> (new(StringComparer.Ordinal), new(StringComparer.Ordinal));
private static RunTurnCommandDto CreateCommand( private static RunTurnCommandDto CreateCommand(
string orchestrationMode, string orchestrationMode,