mirror of
https://github.com/davidkaya/aryx.git
synced 2026-08-25 20:33:58 +02:00
Merge branch 'copilot/amused-horse'
This commit is contained in:
@@ -456,6 +456,7 @@ public sealed class AgentActivityEventDto : SidecarEventDto
|
|||||||
public string? SourceAgentName { get; init; }
|
public string? SourceAgentName { get; init; }
|
||||||
public string? ToolName { get; init; }
|
public string? ToolName { get; init; }
|
||||||
public string? ToolCallId { get; init; }
|
public string? ToolCallId { get; init; }
|
||||||
|
public IReadOnlyDictionary<string, object?>? ToolArguments { get; init; }
|
||||||
public IReadOnlyList<ToolCallFileChangeDto>? FileChanges { get; init; }
|
public IReadOnlyList<ToolCallFileChangeDto>? FileChanges { get; init; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ internal static class WorkflowRequestInfoInterpreter
|
|||||||
private const string ToolCallingActivityType = "tool-calling";
|
private const string ToolCallingActivityType = "tool-calling";
|
||||||
private const string CodeInterpreterToolName = "code interpreter";
|
private const string CodeInterpreterToolName = "code interpreter";
|
||||||
private const string ImageGenerationToolName = "image generation";
|
private const string ImageGenerationToolName = "image generation";
|
||||||
|
private const int MaxToolArgumentValueLength = 4000;
|
||||||
|
private const string TruncatedToolArgumentValue = "[truncated]";
|
||||||
private static readonly JsonSerializerOptions JsonOptions = JsonSerialization.CreateWebOptions();
|
private static readonly JsonSerializerOptions JsonOptions = JsonSerialization.CreateWebOptions();
|
||||||
|
|
||||||
public static AgentActivityEventDto? TryCreateActivityFromRequest(
|
public static AgentActivityEventDto? TryCreateActivityFromRequest(
|
||||||
@@ -80,6 +82,7 @@ internal static class WorkflowRequestInfoInterpreter
|
|||||||
AgentName = activeAgent.AgentName,
|
AgentName = activeAgent.AgentName,
|
||||||
ToolName = tool.ToolName,
|
ToolName = tool.ToolName,
|
||||||
ToolCallId = tool.ToolCallId,
|
ToolCallId = tool.ToolCallId,
|
||||||
|
ToolArguments = tool.ToolArguments,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -103,8 +106,8 @@ internal static class WorkflowRequestInfoInterpreter
|
|||||||
return new HandoffRequestInterpretation(handoffAgent);
|
return new HandoffRequestInterpretation(handoffAgent);
|
||||||
}
|
}
|
||||||
|
|
||||||
return TryGetToolRequestInfo(requestInfo, out string toolName, out string? toolCallId)
|
return TryGetToolRequestInfo(requestInfo, out string toolName, out string? toolCallId, out IReadOnlyDictionary<string, object?>? toolArguments)
|
||||||
? new ToolRequestInterpretation(toolName, toolCallId)
|
? new ToolRequestInterpretation(toolName, toolCallId, toolArguments)
|
||||||
: new UnknownRequestInterpretation();
|
: new UnknownRequestInterpretation();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -137,33 +140,38 @@ internal static class WorkflowRequestInfoInterpreter
|
|||||||
private static bool TryGetToolRequestInfo(
|
private static bool TryGetToolRequestInfo(
|
||||||
RequestInfoEvent requestInfo,
|
RequestInfoEvent requestInfo,
|
||||||
out string toolName,
|
out string toolName,
|
||||||
out string? toolCallId)
|
out string? toolCallId,
|
||||||
|
out IReadOnlyDictionary<string, object?>? toolArguments)
|
||||||
{
|
{
|
||||||
return TryGetStableToolRequestInfo(requestInfo.Request.Data, out toolName, out toolCallId)
|
return TryGetStableToolRequestInfo(requestInfo.Request.Data, out toolName, out toolCallId, out toolArguments)
|
||||||
|| TryGetEvaluationToolRequestInfo(requestInfo.Request.Data, out toolName, out toolCallId);
|
|| TryGetEvaluationToolRequestInfo(requestInfo.Request.Data, out toolName, out toolCallId, out toolArguments);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static bool TryGetStableToolRequestInfo(
|
private static bool TryGetStableToolRequestInfo(
|
||||||
PortableValue requestData,
|
PortableValue requestData,
|
||||||
out string toolName,
|
out string toolName,
|
||||||
out string? toolCallId)
|
out string? toolCallId,
|
||||||
|
out IReadOnlyDictionary<string, object?>? toolArguments)
|
||||||
{
|
{
|
||||||
if (requestData.Is<FunctionCallContent>(out FunctionCallContent? functionCall))
|
if (requestData.Is<FunctionCallContent>(out FunctionCallContent? functionCall))
|
||||||
{
|
{
|
||||||
toolName = NormalizeOptionalString(functionCall.Name) ?? "function";
|
toolName = NormalizeOptionalString(functionCall.Name) ?? "function";
|
||||||
toolCallId = NormalizeOptionalString(functionCall.CallId);
|
toolCallId = NormalizeOptionalString(functionCall.CallId);
|
||||||
|
toolArguments = NormalizeToolArguments(functionCall.Arguments);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
toolName = string.Empty;
|
toolName = string.Empty;
|
||||||
toolCallId = null;
|
toolCallId = null;
|
||||||
|
toolArguments = null;
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static bool TryGetEvaluationToolRequestInfo(
|
private static bool TryGetEvaluationToolRequestInfo(
|
||||||
PortableValue requestData,
|
PortableValue requestData,
|
||||||
out string toolName,
|
out string toolName,
|
||||||
out string? toolCallId)
|
out string? toolCallId,
|
||||||
|
out IReadOnlyDictionary<string, object?>? toolArguments)
|
||||||
{
|
{
|
||||||
if (requestData.Is<McpServerToolCallContent>(out McpServerToolCallContent? mcpToolCall))
|
if (requestData.Is<McpServerToolCallContent>(out McpServerToolCallContent? mcpToolCall))
|
||||||
{
|
{
|
||||||
@@ -171,6 +179,7 @@ internal static class WorkflowRequestInfoInterpreter
|
|||||||
?? NormalizeOptionalString(mcpToolCall.ServerName)
|
?? NormalizeOptionalString(mcpToolCall.ServerName)
|
||||||
?? string.Empty;
|
?? string.Empty;
|
||||||
toolCallId = NormalizeOptionalString(mcpToolCall.CallId);
|
toolCallId = NormalizeOptionalString(mcpToolCall.CallId);
|
||||||
|
toolArguments = NormalizeToolArguments(mcpToolCall.Arguments);
|
||||||
return toolName.Length > 0;
|
return toolName.Length > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -178,6 +187,7 @@ internal static class WorkflowRequestInfoInterpreter
|
|||||||
{
|
{
|
||||||
toolName = CodeInterpreterToolName;
|
toolName = CodeInterpreterToolName;
|
||||||
toolCallId = NormalizeOptionalString(codeInterpreterToolCall.CallId);
|
toolCallId = NormalizeOptionalString(codeInterpreterToolCall.CallId);
|
||||||
|
toolArguments = NormalizeCodeInterpreterToolArguments(codeInterpreterToolCall);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -185,14 +195,196 @@ internal static class WorkflowRequestInfoInterpreter
|
|||||||
{
|
{
|
||||||
toolName = ImageGenerationToolName;
|
toolName = ImageGenerationToolName;
|
||||||
toolCallId = null;
|
toolCallId = null;
|
||||||
|
toolArguments = null;
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
toolName = string.Empty;
|
toolName = string.Empty;
|
||||||
toolCallId = null;
|
toolCallId = null;
|
||||||
|
toolArguments = null;
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static IReadOnlyDictionary<string, object?>? NormalizeToolArguments(
|
||||||
|
IEnumerable<KeyValuePair<string, object?>>? arguments)
|
||||||
|
{
|
||||||
|
if (arguments is null)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
Dictionary<string, object?> normalized = new(StringComparer.Ordinal);
|
||||||
|
foreach (KeyValuePair<string, object?> argument in arguments)
|
||||||
|
{
|
||||||
|
string? key = NormalizeOptionalString(argument.Key);
|
||||||
|
if (key is null)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
object? value = NormalizeToolArgumentValue(argument.Value);
|
||||||
|
if (value is null)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
normalized[key] = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalized.Count > 0 ? normalized : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IReadOnlyDictionary<string, object?>? NormalizeCodeInterpreterToolArguments(
|
||||||
|
CodeInterpreterToolCallContent codeInterpreterToolCall)
|
||||||
|
{
|
||||||
|
IList<AIContent>? rawInputs = codeInterpreterToolCall.Inputs;
|
||||||
|
if (rawInputs is not { Count: > 0 })
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
List<object?> inputs = [];
|
||||||
|
foreach (AIContent input in rawInputs)
|
||||||
|
{
|
||||||
|
object? normalized = input switch
|
||||||
|
{
|
||||||
|
TextContent text => NormalizeToolArgumentValue(text.Text),
|
||||||
|
_ => BuildAiContentFallbackValue(input),
|
||||||
|
};
|
||||||
|
|
||||||
|
if (normalized is not null)
|
||||||
|
{
|
||||||
|
inputs.Add(normalized);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return inputs.Count > 0
|
||||||
|
? new Dictionary<string, object?>(StringComparer.Ordinal)
|
||||||
|
{
|
||||||
|
["inputs"] = inputs,
|
||||||
|
}
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static object? NormalizeToolArgumentValue(object? value)
|
||||||
|
{
|
||||||
|
return value switch
|
||||||
|
{
|
||||||
|
null => null,
|
||||||
|
string text => NormalizeToolArgumentText(text),
|
||||||
|
JsonElement element => NormalizeToolArgumentElement(element),
|
||||||
|
bool boolean => boolean,
|
||||||
|
byte number => number,
|
||||||
|
sbyte number => number,
|
||||||
|
short number => number,
|
||||||
|
ushort number => number,
|
||||||
|
int number => number,
|
||||||
|
uint number => number,
|
||||||
|
long number => number,
|
||||||
|
ulong number => number,
|
||||||
|
float number => number,
|
||||||
|
double number => number,
|
||||||
|
decimal number => number,
|
||||||
|
AIContent content => BuildAiContentFallbackValue(content),
|
||||||
|
IEnumerable<KeyValuePair<string, object?>> dictionary => NormalizeToolArguments(dictionary),
|
||||||
|
IEnumerable<object?> sequence => NormalizeToolArgumentSequence(sequence),
|
||||||
|
_ => NormalizeUnknownToolArgumentValue(value),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static object? NormalizeToolArgumentElement(JsonElement element)
|
||||||
|
{
|
||||||
|
return element.ValueKind switch
|
||||||
|
{
|
||||||
|
JsonValueKind.Null or JsonValueKind.Undefined => null,
|
||||||
|
JsonValueKind.String => NormalizeToolArgumentText(element.GetString()),
|
||||||
|
JsonValueKind.True => true,
|
||||||
|
JsonValueKind.False => false,
|
||||||
|
JsonValueKind.Number => element.Deserialize<object?>(JsonOptions),
|
||||||
|
JsonValueKind.Object => NormalizeToolArgumentObject(element),
|
||||||
|
JsonValueKind.Array => NormalizeToolArgumentArray(element),
|
||||||
|
_ => NormalizeToolArgumentText(element.GetRawText()),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IReadOnlyDictionary<string, object?>? NormalizeToolArgumentObject(JsonElement element)
|
||||||
|
{
|
||||||
|
Dictionary<string, object?> normalized = new(StringComparer.Ordinal);
|
||||||
|
foreach (JsonProperty property in element.EnumerateObject())
|
||||||
|
{
|
||||||
|
string? key = NormalizeOptionalString(property.Name);
|
||||||
|
if (key is null)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
object? value = NormalizeToolArgumentElement(property.Value);
|
||||||
|
if (value is not null)
|
||||||
|
{
|
||||||
|
normalized[key] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalized.Count > 0 ? normalized : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IReadOnlyList<object?>? NormalizeToolArgumentArray(JsonElement element)
|
||||||
|
{
|
||||||
|
List<object?> normalized = [];
|
||||||
|
foreach (JsonElement item in element.EnumerateArray())
|
||||||
|
{
|
||||||
|
object? value = NormalizeToolArgumentElement(item);
|
||||||
|
if (value is not null)
|
||||||
|
{
|
||||||
|
normalized.Add(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalized.Count > 0 ? normalized : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IReadOnlyList<object?>? NormalizeToolArgumentSequence(IEnumerable<object?> sequence)
|
||||||
|
{
|
||||||
|
List<object?> normalized = [];
|
||||||
|
foreach (object? item in sequence)
|
||||||
|
{
|
||||||
|
object? value = NormalizeToolArgumentValue(item);
|
||||||
|
if (value is not null)
|
||||||
|
{
|
||||||
|
normalized.Add(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalized.Count > 0 ? normalized : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static object? NormalizeUnknownToolArgumentValue(object value)
|
||||||
|
{
|
||||||
|
string json = JsonSerializer.Serialize(value, value.GetType(), JsonOptions);
|
||||||
|
using JsonDocument document = JsonDocument.Parse(json);
|
||||||
|
return NormalizeToolArgumentElement(document.RootElement);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IReadOnlyDictionary<string, object?> BuildAiContentFallbackValue(AIContent content)
|
||||||
|
{
|
||||||
|
return new Dictionary<string, object?>(StringComparer.Ordinal)
|
||||||
|
{
|
||||||
|
["type"] = content.GetType().Name,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string? NormalizeToolArgumentText(string? value)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(value))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return value.Length > MaxToolArgumentValueLength
|
||||||
|
? TruncatedToolArgumentValue
|
||||||
|
: value;
|
||||||
|
}
|
||||||
|
|
||||||
private static string? NormalizeOptionalString(string? value)
|
private static string? NormalizeOptionalString(string? value)
|
||||||
{
|
{
|
||||||
return string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
return string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
||||||
@@ -208,7 +400,10 @@ internal static class WorkflowRequestInfoInterpreter
|
|||||||
|
|
||||||
private sealed record HandoffRequestInterpretation(AgentIdentity TargetAgent) : RequestInterpretation;
|
private sealed record HandoffRequestInterpretation(AgentIdentity TargetAgent) : RequestInterpretation;
|
||||||
|
|
||||||
private sealed record ToolRequestInterpretation(string ToolName, string? ToolCallId) : RequestInterpretation;
|
private sealed record ToolRequestInterpretation(
|
||||||
|
string ToolName,
|
||||||
|
string? ToolCallId,
|
||||||
|
IReadOnlyDictionary<string, object?>? ToolArguments) : RequestInterpretation;
|
||||||
|
|
||||||
private sealed record UnknownRequestInterpretation : RequestInterpretation;
|
private sealed record UnknownRequestInterpretation : RequestInterpretation;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
using System.Collections;
|
||||||
using System.Collections.Concurrent;
|
using System.Collections.Concurrent;
|
||||||
using System.Runtime.CompilerServices;
|
using System.Runtime.CompilerServices;
|
||||||
using Aryx.AgentHost.Contracts;
|
using Aryx.AgentHost.Contracts;
|
||||||
@@ -15,7 +16,11 @@ public sealed class WorkflowRequestInfoInterpreterTests
|
|||||||
{
|
{
|
||||||
ConcurrentDictionary<string, string> toolNamesByCallId = new(StringComparer.Ordinal);
|
ConcurrentDictionary<string, string> toolNamesByCallId = new(StringComparer.Ordinal);
|
||||||
RequestInfoEvent requestInfo = CreateRequestInfoEvent(
|
RequestInfoEvent requestInfo = CreateRequestInfoEvent(
|
||||||
new FunctionCallContent("call-1", "view", new Dictionary<string, object?>()));
|
new FunctionCallContent("call-1", "view", new Dictionary<string, object?>
|
||||||
|
{
|
||||||
|
["path"] = @"C:\workspace\file.txt",
|
||||||
|
["viewRange"] = new object[] { 10, 25 },
|
||||||
|
}));
|
||||||
|
|
||||||
AgentActivityEventDto? activity = WorkflowRequestInfoInterpreter.TryCreateActivityFromRequest(
|
AgentActivityEventDto? activity = WorkflowRequestInfoInterpreter.TryCreateActivityFromRequest(
|
||||||
CreateSingleAgentCommand(),
|
CreateSingleAgentCommand(),
|
||||||
@@ -28,6 +33,9 @@ public sealed class WorkflowRequestInfoInterpreterTests
|
|||||||
Assert.Equal("agent-1", activity.AgentId);
|
Assert.Equal("agent-1", activity.AgentId);
|
||||||
Assert.Equal("Primary", activity.AgentName);
|
Assert.Equal("Primary", activity.AgentName);
|
||||||
Assert.Equal("view", activity.ToolName);
|
Assert.Equal("view", activity.ToolName);
|
||||||
|
Assert.NotNull(activity.ToolArguments);
|
||||||
|
Assert.Equal(@"C:\workspace\file.txt", activity.ToolArguments["path"]);
|
||||||
|
Assert.Equal([10, 25], Assert.IsAssignableFrom<IReadOnlyList<object?>>(activity.ToolArguments["viewRange"]));
|
||||||
Assert.Equal("view", toolNamesByCallId["call-1"]);
|
Assert.Equal("view", toolNamesByCallId["call-1"]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -36,7 +44,15 @@ public sealed class WorkflowRequestInfoInterpreterTests
|
|||||||
{
|
{
|
||||||
ConcurrentDictionary<string, string> toolNamesByCallId = new(StringComparer.Ordinal);
|
ConcurrentDictionary<string, string> toolNamesByCallId = new(StringComparer.Ordinal);
|
||||||
RequestInfoEvent requestInfo = CreateRequestInfoEvent(
|
RequestInfoEvent requestInfo = CreateRequestInfoEvent(
|
||||||
CreateMcpToolCall("call-1", "git.status", "Git MCP"));
|
CreateMcpToolCall(
|
||||||
|
"call-1",
|
||||||
|
"git.status",
|
||||||
|
"Git MCP",
|
||||||
|
new Dictionary<string, object?>
|
||||||
|
{
|
||||||
|
["path"] = @"C:\workspace",
|
||||||
|
["includeIgnored"] = true,
|
||||||
|
}));
|
||||||
|
|
||||||
AgentActivityEventDto? activity = WorkflowRequestInfoInterpreter.TryCreateActivityFromRequest(
|
AgentActivityEventDto? activity = WorkflowRequestInfoInterpreter.TryCreateActivityFromRequest(
|
||||||
CreateSingleAgentCommand(),
|
CreateSingleAgentCommand(),
|
||||||
@@ -47,6 +63,9 @@ public sealed class WorkflowRequestInfoInterpreterTests
|
|||||||
Assert.NotNull(activity);
|
Assert.NotNull(activity);
|
||||||
Assert.Equal("tool-calling", activity.ActivityType);
|
Assert.Equal("tool-calling", activity.ActivityType);
|
||||||
Assert.Equal("git.status", activity.ToolName);
|
Assert.Equal("git.status", activity.ToolName);
|
||||||
|
Assert.NotNull(activity.ToolArguments);
|
||||||
|
Assert.Equal(@"C:\workspace", activity.ToolArguments["path"]);
|
||||||
|
Assert.Equal(true, activity.ToolArguments["includeIgnored"]);
|
||||||
Assert.Equal("git.status", toolNamesByCallId["call-1"]);
|
Assert.Equal("git.status", toolNamesByCallId["call-1"]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -54,7 +73,8 @@ public sealed class WorkflowRequestInfoInterpreterTests
|
|||||||
public void TryCreateActivityFromRequest_MapsCodeInterpreterCallsToSyntheticToolName()
|
public void TryCreateActivityFromRequest_MapsCodeInterpreterCallsToSyntheticToolName()
|
||||||
{
|
{
|
||||||
ConcurrentDictionary<string, string> toolNamesByCallId = new(StringComparer.Ordinal);
|
ConcurrentDictionary<string, string> toolNamesByCallId = new(StringComparer.Ordinal);
|
||||||
RequestInfoEvent requestInfo = CreateRequestInfoEvent(CreateCodeInterpreterToolCall("call-1"));
|
RequestInfoEvent requestInfo = CreateRequestInfoEvent(
|
||||||
|
CreateCodeInterpreterToolCall("call-1", "print('hello')"));
|
||||||
|
|
||||||
AgentActivityEventDto? activity = WorkflowRequestInfoInterpreter.TryCreateActivityFromRequest(
|
AgentActivityEventDto? activity = WorkflowRequestInfoInterpreter.TryCreateActivityFromRequest(
|
||||||
CreateSingleAgentCommand(),
|
CreateSingleAgentCommand(),
|
||||||
@@ -65,6 +85,10 @@ public sealed class WorkflowRequestInfoInterpreterTests
|
|||||||
Assert.NotNull(activity);
|
Assert.NotNull(activity);
|
||||||
Assert.Equal("tool-calling", activity.ActivityType);
|
Assert.Equal("tool-calling", activity.ActivityType);
|
||||||
Assert.Equal("code interpreter", activity.ToolName);
|
Assert.Equal("code interpreter", activity.ToolName);
|
||||||
|
Assert.NotNull(activity.ToolArguments);
|
||||||
|
Assert.Equal(
|
||||||
|
["print('hello')"],
|
||||||
|
Assert.IsAssignableFrom<IReadOnlyList<object?>>(activity.ToolArguments["inputs"]));
|
||||||
Assert.Equal("code interpreter", toolNamesByCallId["call-1"]);
|
Assert.Equal("code interpreter", toolNamesByCallId["call-1"]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -83,9 +107,55 @@ public sealed class WorkflowRequestInfoInterpreterTests
|
|||||||
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.Empty(toolNamesByCallId);
|
Assert.Empty(toolNamesByCallId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void TryCreateActivityFromRequest_LeavesToolArgumentsNullWhenFunctionCallHasNoUsableArguments()
|
||||||
|
{
|
||||||
|
ConcurrentDictionary<string, string> toolNamesByCallId = new(StringComparer.Ordinal);
|
||||||
|
RequestInfoEvent requestInfo = CreateRequestInfoEvent(
|
||||||
|
new FunctionCallContent("call-1", "view", new Dictionary<string, object?>
|
||||||
|
{
|
||||||
|
["empty"] = " ",
|
||||||
|
["missing"] = null,
|
||||||
|
}));
|
||||||
|
|
||||||
|
AgentActivityEventDto? activity = WorkflowRequestInfoInterpreter.TryCreateActivityFromRequest(
|
||||||
|
CreateSingleAgentCommand(),
|
||||||
|
requestInfo,
|
||||||
|
new AgentIdentity("agent-1", "Primary"),
|
||||||
|
toolNamesByCallId);
|
||||||
|
|
||||||
|
Assert.NotNull(activity);
|
||||||
|
Assert.Null(activity.ToolArguments);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void TryCreateActivityFromRequest_TruncatesOversizedToolArgumentValues()
|
||||||
|
{
|
||||||
|
ConcurrentDictionary<string, string> toolNamesByCallId = new(StringComparer.Ordinal);
|
||||||
|
RequestInfoEvent requestInfo = CreateRequestInfoEvent(
|
||||||
|
new FunctionCallContent(
|
||||||
|
"call-1",
|
||||||
|
"powershell",
|
||||||
|
new Dictionary<string, object?>
|
||||||
|
{
|
||||||
|
["command"] = new string('x', 4001),
|
||||||
|
}));
|
||||||
|
|
||||||
|
AgentActivityEventDto? activity = WorkflowRequestInfoInterpreter.TryCreateActivityFromRequest(
|
||||||
|
CreateSingleAgentCommand(),
|
||||||
|
requestInfo,
|
||||||
|
new AgentIdentity("agent-1", "Primary"),
|
||||||
|
toolNamesByCallId);
|
||||||
|
|
||||||
|
Assert.NotNull(activity);
|
||||||
|
Assert.NotNull(activity.ToolArguments);
|
||||||
|
Assert.Equal("[truncated]", activity.ToolArguments["command"]);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void TryCreateActivityFromRequest_SkipsDuplicateTrackedToolCallIds()
|
public void TryCreateActivityFromRequest_SkipsDuplicateTrackedToolCallIds()
|
||||||
{
|
{
|
||||||
@@ -242,22 +312,50 @@ public sealed class WorkflowRequestInfoInterpreterTests
|
|||||||
return new RequestInfoEvent(request);
|
return new RequestInfoEvent(request);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static object CreateCodeInterpreterToolCall(string callId)
|
private static object CreateCodeInterpreterToolCall(string callId, params string[] inputs)
|
||||||
{
|
{
|
||||||
Type type = Type.GetType(
|
Type type = Type.GetType(
|
||||||
"Microsoft.Extensions.AI.CodeInterpreterToolCallContent, Microsoft.Extensions.AI.Abstractions",
|
"Microsoft.Extensions.AI.CodeInterpreterToolCallContent, Microsoft.Extensions.AI.Abstractions",
|
||||||
throwOnError: true)!;
|
throwOnError: true)!;
|
||||||
object instance = Activator.CreateInstance(type)!;
|
object instance = Activator.CreateInstance(type)!;
|
||||||
type.GetProperty("CallId")!.SetValue(instance, callId);
|
type.GetProperty("CallId")!.SetValue(instance, callId);
|
||||||
|
if (inputs.Length > 0)
|
||||||
|
{
|
||||||
|
Type aiContentType = Type.GetType(
|
||||||
|
"Microsoft.Extensions.AI.AIContent, Microsoft.Extensions.AI.Abstractions",
|
||||||
|
throwOnError: true)!;
|
||||||
|
Type textContentType = Type.GetType(
|
||||||
|
"Microsoft.Extensions.AI.TextContent, Microsoft.Extensions.AI.Abstractions",
|
||||||
|
throwOnError: true)!;
|
||||||
|
IList values = (IList)Activator.CreateInstance(typeof(List<>).MakeGenericType(aiContentType))!;
|
||||||
|
foreach (string input in inputs)
|
||||||
|
{
|
||||||
|
object textContent = Activator.CreateInstance(textContentType, input)!;
|
||||||
|
values.Add(textContent);
|
||||||
|
}
|
||||||
|
|
||||||
|
type.GetProperty("Inputs")!.SetValue(instance, values);
|
||||||
|
}
|
||||||
|
|
||||||
return instance;
|
return instance;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static object CreateMcpToolCall(string callId, string toolName, string serverName)
|
private static object CreateMcpToolCall(
|
||||||
|
string callId,
|
||||||
|
string toolName,
|
||||||
|
string serverName,
|
||||||
|
IReadOnlyDictionary<string, object?>? arguments = null)
|
||||||
{
|
{
|
||||||
Type type = Type.GetType(
|
Type type = Type.GetType(
|
||||||
"Microsoft.Extensions.AI.McpServerToolCallContent, Microsoft.Extensions.AI.Abstractions",
|
"Microsoft.Extensions.AI.McpServerToolCallContent, Microsoft.Extensions.AI.Abstractions",
|
||||||
throwOnError: true)!;
|
throwOnError: true)!;
|
||||||
return Activator.CreateInstance(type, callId, toolName, serverName)!;
|
object instance = Activator.CreateInstance(type, callId, toolName, serverName)!;
|
||||||
|
if (arguments is not null)
|
||||||
|
{
|
||||||
|
type.GetProperty("Arguments")!.SetValue(instance, arguments);
|
||||||
|
}
|
||||||
|
|
||||||
|
return instance;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static object CreateImageGenerationToolCall()
|
private static object CreateImageGenerationToolCall()
|
||||||
|
|||||||
@@ -2543,6 +2543,7 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
|
|||||||
sourceAgentName: event.sourceAgentName,
|
sourceAgentName: event.sourceAgentName,
|
||||||
toolName: event.toolName,
|
toolName: event.toolName,
|
||||||
toolCallId: event.toolCallId,
|
toolCallId: event.toolCallId,
|
||||||
|
toolArguments: event.toolArguments,
|
||||||
fileChanges: event.fileChanges,
|
fileChanges: event.fileChanges,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
@@ -2563,6 +2564,7 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
|
|||||||
sourceAgentName: event.sourceAgentName,
|
sourceAgentName: event.sourceAgentName,
|
||||||
toolName: event.toolName,
|
toolName: event.toolName,
|
||||||
toolCallId: event.toolCallId,
|
toolCallId: event.toolCallId,
|
||||||
|
toolArguments: event.toolArguments,
|
||||||
fileChanges: event.fileChanges,
|
fileChanges: event.fileChanges,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,89 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { ChevronRight } from 'lucide-react';
|
||||||
|
|
||||||
|
import {
|
||||||
|
formatToolCallSummary,
|
||||||
|
formatToolArgumentValue,
|
||||||
|
getDisplayableArguments,
|
||||||
|
} from '@renderer/lib/toolCallSummary';
|
||||||
|
|
||||||
|
export interface ToolCallDetailPanelProps {
|
||||||
|
toolName?: string;
|
||||||
|
toolArguments?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ToolCallDetailPanel({ toolName, toolArguments }: ToolCallDetailPanelProps) {
|
||||||
|
const [expanded, setExpanded] = useState(false);
|
||||||
|
|
||||||
|
const summary = formatToolCallSummary(toolName, toolArguments);
|
||||||
|
const displayArgs = getDisplayableArguments(toolArguments);
|
||||||
|
const hasExpandableContent = displayArgs.length > 0;
|
||||||
|
|
||||||
|
if (!summary && !hasExpandableContent) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mt-0.5">
|
||||||
|
{/* Inline summary — always visible when summary exists */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`group flex max-w-full items-start gap-1 text-left text-[11px] leading-snug ${
|
||||||
|
hasExpandableContent
|
||||||
|
? 'cursor-pointer hover:text-[var(--color-text-secondary)]'
|
||||||
|
: 'cursor-default'
|
||||||
|
}`}
|
||||||
|
onClick={hasExpandableContent ? () => setExpanded((prev) => !prev) : undefined}
|
||||||
|
aria-expanded={hasExpandableContent ? expanded : undefined}
|
||||||
|
aria-label={hasExpandableContent ? `Toggle ${toolName} arguments` : undefined}
|
||||||
|
tabIndex={hasExpandableContent ? 0 : -1}
|
||||||
|
onKeyDown={hasExpandableContent
|
||||||
|
? (e) => { if (e.key === ' ') { e.preventDefault(); setExpanded((prev) => !prev); } }
|
||||||
|
: undefined}
|
||||||
|
>
|
||||||
|
{hasExpandableContent && (
|
||||||
|
<ChevronRight
|
||||||
|
className={`mt-px size-2.5 shrink-0 text-[var(--color-text-muted)] transition-transform duration-150 ${
|
||||||
|
expanded ? 'rotate-90' : ''
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{summary && (
|
||||||
|
<span className="min-w-0 truncate font-mono text-[var(--color-text-muted)] group-hover:text-[var(--color-text-secondary)]">
|
||||||
|
{summary}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Expanded argument list */}
|
||||||
|
{expanded && hasExpandableContent && (
|
||||||
|
<div className="mt-1 overflow-hidden rounded-md border border-[var(--color-border-subtle)] bg-[var(--color-surface-0)]/80">
|
||||||
|
<div className="max-h-48 overflow-auto">
|
||||||
|
{displayArgs.map(([key, value]) => {
|
||||||
|
const formatted = formatToolArgumentValue(value);
|
||||||
|
const isMultiline = formatted.includes('\n') || formatted.length > 120;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={key}
|
||||||
|
className="border-b border-[var(--color-border-subtle)] px-2 py-1 last:border-b-0"
|
||||||
|
>
|
||||||
|
<span className="text-[10px] font-semibold tracking-wide text-[var(--color-accent-purple)]">
|
||||||
|
{key}
|
||||||
|
</span>
|
||||||
|
{isMultiline ? (
|
||||||
|
<pre className="mt-0.5 max-h-32 overflow-auto whitespace-pre-wrap break-all font-mono text-[10px] leading-relaxed text-[var(--color-text-secondary)]">
|
||||||
|
{formatted}
|
||||||
|
</pre>
|
||||||
|
) : (
|
||||||
|
<span className="ml-1.5 font-mono text-[10px] text-[var(--color-text-secondary)]">
|
||||||
|
{formatted}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
|
|
||||||
import { useElapsedTimer } from '@renderer/hooks/useElapsedTimer';
|
import { useElapsedTimer } from '@renderer/hooks/useElapsedTimer';
|
||||||
import { FileChangePreview } from '@renderer/components/chat/FileChangePreview';
|
import { FileChangePreview } from '@renderer/components/chat/FileChangePreview';
|
||||||
|
import { ToolCallDetailPanel } from '@renderer/components/chat/ToolCallDetailPanel';
|
||||||
import { RunChangeSummaryCard } from '@renderer/components/chat/RunChangeSummaryCard';
|
import { RunChangeSummaryCard } from '@renderer/components/chat/RunChangeSummaryCard';
|
||||||
import { formatEventLabel, truncateContent, filterEventsByAgent, summarizeActivity, type ActivitySummary } from '@renderer/lib/runTimelineFormatting';
|
import { formatEventLabel, truncateContent, filterEventsByAgent, summarizeActivity, type ActivitySummary } from '@renderer/lib/runTimelineFormatting';
|
||||||
import type { ChatMessageRecord } from '@shared/domain/session';
|
import type { ChatMessageRecord } from '@shared/domain/session';
|
||||||
@@ -186,6 +187,11 @@ function ActivityTimelineEventRow({ event }: { event: RunTimelineEventRecord })
|
|||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Tool call argument details */}
|
||||||
|
{event.kind === 'tool-call' && (
|
||||||
|
<ToolCallDetailPanel toolName={event.toolName} toolArguments={event.toolArguments} />
|
||||||
|
)}
|
||||||
|
|
||||||
{/* File change preview for tool-call events */}
|
{/* File change preview for tool-call events */}
|
||||||
{event.kind === 'tool-call' && event.fileChanges && event.fileChanges.length > 0 && (
|
{event.kind === 'tool-call' && event.fileChanges && event.fileChanges.length > 0 && (
|
||||||
<div className="mt-1">
|
<div className="mt-1">
|
||||||
|
|||||||
@@ -0,0 +1,116 @@
|
|||||||
|
const MAX_SUMMARY_LENGTH = 80;
|
||||||
|
|
||||||
|
function truncateSummary(value: string): string {
|
||||||
|
const firstLine = value.split('\n')[0] ?? '';
|
||||||
|
const cleaned = firstLine.trim();
|
||||||
|
if (cleaned.length <= MAX_SUMMARY_LENGTH) return cleaned;
|
||||||
|
return `${cleaned.slice(0, MAX_SUMMARY_LENGTH)}…`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function stringArg(args: Record<string, unknown>, key: string): string | undefined {
|
||||||
|
const value = args[key];
|
||||||
|
return typeof value === 'string' && value.trim().length > 0 ? value.trim() : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function summarizePath(args: Record<string, unknown>): string | undefined {
|
||||||
|
const path = stringArg(args, 'path');
|
||||||
|
if (!path) return undefined;
|
||||||
|
const range = args['view_range'] ?? args['viewRange'];
|
||||||
|
if (Array.isArray(range) && range.length === 2) {
|
||||||
|
return truncateSummary(`${path}:${range[0]}-${range[1]}`);
|
||||||
|
}
|
||||||
|
return truncateSummary(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
function summarizeGitHub(toolName: string, args: Record<string, unknown>): string | undefined {
|
||||||
|
const owner = stringArg(args, 'owner');
|
||||||
|
const repo = stringArg(args, 'repo');
|
||||||
|
const query = stringArg(args, 'query');
|
||||||
|
|
||||||
|
if (query) return truncateSummary(query);
|
||||||
|
if (owner && repo) return truncateSummary(`${owner}/${repo}`);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
type SummaryExtractor = (args: Record<string, unknown>, toolName: string) => string | undefined;
|
||||||
|
|
||||||
|
const toolSummarizers: Record<string, SummaryExtractor> = {
|
||||||
|
powershell: (args) => stringArg(args, 'command') ? truncateSummary(stringArg(args, 'command')!) : undefined,
|
||||||
|
view: (args) => summarizePath(args),
|
||||||
|
edit: (args) => summarizePath(args),
|
||||||
|
create: (args) => summarizePath(args),
|
||||||
|
grep: (args) => stringArg(args, 'pattern') ? truncateSummary(stringArg(args, 'pattern')!) : undefined,
|
||||||
|
glob: (args) => stringArg(args, 'pattern') ? truncateSummary(stringArg(args, 'pattern')!) : undefined,
|
||||||
|
lsp: (args) => {
|
||||||
|
const op = stringArg(args, 'operation');
|
||||||
|
const file = stringArg(args, 'file');
|
||||||
|
if (op && file) return truncateSummary(`${op} ${file}`);
|
||||||
|
return op ? truncateSummary(op) : undefined;
|
||||||
|
},
|
||||||
|
web_fetch: (args) => stringArg(args, 'url') ? truncateSummary(stringArg(args, 'url')!) : undefined,
|
||||||
|
sql: (args) => stringArg(args, 'description') ? truncateSummary(stringArg(args, 'description')!) : undefined,
|
||||||
|
task: (args) => stringArg(args, 'description') ? truncateSummary(stringArg(args, 'description')!) : undefined,
|
||||||
|
ask_user: (args) => stringArg(args, 'question') ? truncateSummary(stringArg(args, 'question')!) : undefined,
|
||||||
|
skill: (args) => stringArg(args, 'skill') ? truncateSummary(stringArg(args, 'skill')!) : undefined,
|
||||||
|
report_intent: (args) => stringArg(args, 'intent') ? truncateSummary(stringArg(args, 'intent')!) : undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
function fallbackSummary(args: Record<string, unknown>): string | undefined {
|
||||||
|
for (const value of Object.values(args)) {
|
||||||
|
if (typeof value === 'string' && value.trim().length > 0 && value !== '[truncated]') {
|
||||||
|
return truncateSummary(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatToolCallSummary(
|
||||||
|
toolName: string | undefined,
|
||||||
|
toolArguments: Record<string, unknown> | undefined,
|
||||||
|
): string | undefined {
|
||||||
|
if (!toolName || !toolArguments || Object.keys(toolArguments).length === 0) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for GitHub tools (github-*)
|
||||||
|
if (toolName.startsWith('github-')) {
|
||||||
|
return summarizeGitHub(toolName, toolArguments);
|
||||||
|
}
|
||||||
|
|
||||||
|
const summarizer = toolSummarizers[toolName];
|
||||||
|
if (summarizer) {
|
||||||
|
return summarizer(toolArguments, toolName);
|
||||||
|
}
|
||||||
|
|
||||||
|
return fallbackSummary(toolArguments);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatToolArgumentValue(value: unknown): string {
|
||||||
|
if (value === null || value === undefined) return '';
|
||||||
|
if (typeof value === 'string') return value;
|
||||||
|
if (typeof value === 'boolean' || typeof value === 'number') return String(value);
|
||||||
|
try {
|
||||||
|
return JSON.stringify(value, null, 2);
|
||||||
|
} catch {
|
||||||
|
return String(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Keys that are redundant with the label itself or too noisy to display inline. */
|
||||||
|
const HIDDEN_ARGUMENT_KEYS = new Set([
|
||||||
|
'description', // often duplicates the summary
|
||||||
|
]);
|
||||||
|
|
||||||
|
export function getDisplayableArguments(
|
||||||
|
toolArguments: Record<string, unknown> | undefined,
|
||||||
|
): Array<[string, unknown]> {
|
||||||
|
if (!toolArguments) return [];
|
||||||
|
|
||||||
|
return Object.entries(toolArguments).filter(
|
||||||
|
([key, value]) =>
|
||||||
|
!HIDDEN_ARGUMENT_KEYS.has(key)
|
||||||
|
&& value !== null
|
||||||
|
&& value !== undefined
|
||||||
|
&& value !== '',
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -290,6 +290,7 @@ export interface AgentActivityEvent {
|
|||||||
sourceAgentName?: string;
|
sourceAgentName?: string;
|
||||||
toolName?: string;
|
toolName?: string;
|
||||||
toolCallId?: string;
|
toolCallId?: string;
|
||||||
|
toolArguments?: Record<string, unknown>;
|
||||||
fileChanges?: ToolCallFileChangePreview[];
|
fileChanges?: ToolCallFileChangePreview[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ export interface SessionEventRecord {
|
|||||||
sourceAgentName?: string;
|
sourceAgentName?: string;
|
||||||
toolName?: string;
|
toolName?: string;
|
||||||
toolCallId?: string;
|
toolCallId?: string;
|
||||||
|
toolArguments?: Record<string, unknown>;
|
||||||
fileChanges?: ToolCallFileChangePreview[];
|
fileChanges?: ToolCallFileChangePreview[];
|
||||||
run?: SessionRunRecord;
|
run?: SessionRunRecord;
|
||||||
error?: string;
|
error?: string;
|
||||||
|
|||||||
@@ -60,6 +60,7 @@ export interface RunTimelineEventRecord {
|
|||||||
targetAgentName?: string;
|
targetAgentName?: string;
|
||||||
toolName?: string;
|
toolName?: string;
|
||||||
toolCallId?: string;
|
toolCallId?: string;
|
||||||
|
toolArguments?: Record<string, unknown>;
|
||||||
fileChanges?: ToolCallFileChangePreview[];
|
fileChanges?: ToolCallFileChangePreview[];
|
||||||
approvalId?: string;
|
approvalId?: string;
|
||||||
approvalKind?: ApprovalCheckpointKind;
|
approvalKind?: ApprovalCheckpointKind;
|
||||||
@@ -120,6 +121,7 @@ export interface AppendRunActivityEventInput {
|
|||||||
sourceAgentName?: string;
|
sourceAgentName?: string;
|
||||||
toolName?: string;
|
toolName?: string;
|
||||||
toolCallId?: string;
|
toolCallId?: string;
|
||||||
|
toolArguments?: Record<string, unknown>;
|
||||||
fileChanges?: ToolCallFileChangePreview[];
|
fileChanges?: ToolCallFileChangePreview[];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -824,6 +826,7 @@ export function appendRunActivityEvent(
|
|||||||
agentName: agent.agentName ?? existingEvent?.agentName,
|
agentName: agent.agentName ?? existingEvent?.agentName,
|
||||||
toolName: normalizeOptionalString(input.toolName) ?? existingEvent?.toolName,
|
toolName: normalizeOptionalString(input.toolName) ?? existingEvent?.toolName,
|
||||||
toolCallId,
|
toolCallId,
|
||||||
|
toolArguments: input.toolArguments ?? existingEvent?.toolArguments,
|
||||||
fileChanges: mergeToolCallFileChanges(existingEvent?.fileChanges, input.fileChanges),
|
fileChanges: mergeToolCallFileChanges(existingEvent?.fileChanges, input.fileChanges),
|
||||||
};
|
};
|
||||||
return existingIndex >= 0
|
return existingIndex >= 0
|
||||||
|
|||||||
@@ -0,0 +1,173 @@
|
|||||||
|
import { describe, expect, test } from 'bun:test';
|
||||||
|
|
||||||
|
import {
|
||||||
|
formatToolCallSummary,
|
||||||
|
formatToolArgumentValue,
|
||||||
|
getDisplayableArguments,
|
||||||
|
} from '@renderer/lib/toolCallSummary';
|
||||||
|
|
||||||
|
describe('formatToolCallSummary', () => {
|
||||||
|
test('returns undefined when toolName is missing', () => {
|
||||||
|
expect(formatToolCallSummary(undefined, { command: 'ls' })).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns undefined when toolArguments is missing', () => {
|
||||||
|
expect(formatToolCallSummary('powershell', undefined)).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns undefined when toolArguments is empty', () => {
|
||||||
|
expect(formatToolCallSummary('powershell', {})).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('extracts command for powershell', () => {
|
||||||
|
expect(formatToolCallSummary('powershell', { command: 'git status' })).toBe('git status');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('truncates long powershell commands', () => {
|
||||||
|
const longCommand = 'a'.repeat(100);
|
||||||
|
const result = formatToolCallSummary('powershell', { command: longCommand });
|
||||||
|
expect(result!.length).toBeLessThanOrEqual(81); // 80 + ellipsis
|
||||||
|
expect(result!.endsWith('…')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('extracts path for view tool', () => {
|
||||||
|
expect(formatToolCallSummary('view', { path: '/src/index.ts' })).toBe('/src/index.ts');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('includes view range when present', () => {
|
||||||
|
expect(formatToolCallSummary('view', { path: '/src/index.ts', view_range: [10, 25] }))
|
||||||
|
.toBe('/src/index.ts:10-25');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('supports viewRange camelCase variant', () => {
|
||||||
|
expect(formatToolCallSummary('view', { path: '/src/index.ts', viewRange: [1, 50] }))
|
||||||
|
.toBe('/src/index.ts:1-50');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('extracts path for edit tool', () => {
|
||||||
|
expect(formatToolCallSummary('edit', { path: '/src/utils.ts', old_str: 'foo' }))
|
||||||
|
.toBe('/src/utils.ts');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('extracts path for create tool', () => {
|
||||||
|
expect(formatToolCallSummary('create', { path: '/new-file.ts', file_text: 'content' }))
|
||||||
|
.toBe('/new-file.ts');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('extracts pattern for grep', () => {
|
||||||
|
expect(formatToolCallSummary('grep', { pattern: 'TODO', path: '/src' })).toBe('TODO');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('extracts pattern for glob', () => {
|
||||||
|
expect(formatToolCallSummary('glob', { pattern: '**/*.ts' })).toBe('**/*.ts');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('extracts operation and file for lsp', () => {
|
||||||
|
expect(formatToolCallSummary('lsp', { operation: 'goToDefinition', file: '/src/app.ts' }))
|
||||||
|
.toBe('goToDefinition /src/app.ts');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('extracts just operation when file is missing for lsp', () => {
|
||||||
|
expect(formatToolCallSummary('lsp', { operation: 'workspaceSymbol', query: 'Foo' }))
|
||||||
|
.toBe('workspaceSymbol');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('extracts url for web_fetch', () => {
|
||||||
|
expect(formatToolCallSummary('web_fetch', { url: 'https://example.com' }))
|
||||||
|
.toBe('https://example.com');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('extracts description for sql', () => {
|
||||||
|
expect(formatToolCallSummary('sql', { description: 'Insert todos', query: 'INSERT ...' }))
|
||||||
|
.toBe('Insert todos');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('extracts description for task', () => {
|
||||||
|
expect(formatToolCallSummary('task', { description: 'Run tests' })).toBe('Run tests');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('extracts question for ask_user', () => {
|
||||||
|
expect(formatToolCallSummary('ask_user', { question: 'Which database?' })).toBe('Which database?');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('extracts intent for report_intent', () => {
|
||||||
|
expect(formatToolCallSummary('report_intent', { intent: 'Exploring codebase' }))
|
||||||
|
.toBe('Exploring codebase');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('summarizes github tools with query', () => {
|
||||||
|
expect(formatToolCallSummary('github-search_code', { query: 'FunctionCallContent' }))
|
||||||
|
.toBe('FunctionCallContent');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('summarizes github tools with owner/repo', () => {
|
||||||
|
expect(formatToolCallSummary('github-get_file_contents', { owner: 'octocat', repo: 'hello-world' }))
|
||||||
|
.toBe('octocat/hello-world');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('falls back to first string value for unknown tools', () => {
|
||||||
|
expect(formatToolCallSummary('unknown_tool', { target: 'production', count: 42 }))
|
||||||
|
.toBe('production');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('skips [truncated] values in fallback', () => {
|
||||||
|
expect(formatToolCallSummary('unknown_tool', { data: '[truncated]', label: 'test' }))
|
||||||
|
.toBe('test');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('uses first line only for multiline commands', () => {
|
||||||
|
const result = formatToolCallSummary('powershell', { command: 'echo hello\necho world' });
|
||||||
|
expect(result).toBe('echo hello');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('formatToolArgumentValue', () => {
|
||||||
|
test('formats string values directly', () => {
|
||||||
|
expect(formatToolArgumentValue('hello')).toBe('hello');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('formats numbers', () => {
|
||||||
|
expect(formatToolArgumentValue(42)).toBe('42');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('formats booleans', () => {
|
||||||
|
expect(formatToolArgumentValue(true)).toBe('true');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('formats null as empty string', () => {
|
||||||
|
expect(formatToolArgumentValue(null)).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('formats objects as pretty JSON', () => {
|
||||||
|
const result = formatToolArgumentValue({ a: 1, b: 'two' });
|
||||||
|
expect(result).toContain('"a": 1');
|
||||||
|
expect(result).toContain('"b": "two"');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('formats arrays as pretty JSON', () => {
|
||||||
|
const result = formatToolArgumentValue([1, 2, 3]);
|
||||||
|
expect(result).toContain('1');
|
||||||
|
expect(result).toContain('3');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getDisplayableArguments', () => {
|
||||||
|
test('returns empty array when toolArguments is undefined', () => {
|
||||||
|
expect(getDisplayableArguments(undefined)).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('filters out null and undefined values', () => {
|
||||||
|
const result = getDisplayableArguments({ a: 'value', b: null, c: undefined, d: 'ok' });
|
||||||
|
expect(result).toEqual([['a', 'value'], ['d', 'ok']]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('filters out empty string values', () => {
|
||||||
|
const result = getDisplayableArguments({ a: '', b: 'value' });
|
||||||
|
expect(result).toEqual([['b', 'value']]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('filters out description key', () => {
|
||||||
|
const result = getDisplayableArguments({ description: 'Insert todos', query: 'INSERT ...' });
|
||||||
|
expect(result).toEqual([['query', 'INSERT ...']]);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user