diff --git a/sidecar/src/Aryx.AgentHost/Contracts/ProtocolModels.cs b/sidecar/src/Aryx.AgentHost/Contracts/ProtocolModels.cs index 8bf620b..90c6549 100644 --- a/sidecar/src/Aryx.AgentHost/Contracts/ProtocolModels.cs +++ b/sidecar/src/Aryx.AgentHost/Contracts/ProtocolModels.cs @@ -456,6 +456,7 @@ public sealed class AgentActivityEventDto : SidecarEventDto public string? SourceAgentName { get; init; } public string? ToolName { get; init; } public string? ToolCallId { get; init; } + public IReadOnlyDictionary? ToolArguments { get; init; } public IReadOnlyList? FileChanges { get; init; } } diff --git a/sidecar/src/Aryx.AgentHost/Services/WorkflowRequestInfoInterpreter.cs b/sidecar/src/Aryx.AgentHost/Services/WorkflowRequestInfoInterpreter.cs index f6bb163..7f0f502 100644 --- a/sidecar/src/Aryx.AgentHost/Services/WorkflowRequestInfoInterpreter.cs +++ b/sidecar/src/Aryx.AgentHost/Services/WorkflowRequestInfoInterpreter.cs @@ -12,6 +12,8 @@ internal static class WorkflowRequestInfoInterpreter private const string ToolCallingActivityType = "tool-calling"; private const string CodeInterpreterToolName = "code interpreter"; private const string ImageGenerationToolName = "image generation"; + private const int MaxToolArgumentValueLength = 4000; + private const string TruncatedToolArgumentValue = "[truncated]"; private static readonly JsonSerializerOptions JsonOptions = JsonSerialization.CreateWebOptions(); public static AgentActivityEventDto? TryCreateActivityFromRequest( @@ -80,6 +82,7 @@ internal static class WorkflowRequestInfoInterpreter AgentName = activeAgent.AgentName, ToolName = tool.ToolName, ToolCallId = tool.ToolCallId, + ToolArguments = tool.ToolArguments, }; } @@ -103,8 +106,8 @@ internal static class WorkflowRequestInfoInterpreter return new HandoffRequestInterpretation(handoffAgent); } - return TryGetToolRequestInfo(requestInfo, out string toolName, out string? toolCallId) - ? new ToolRequestInterpretation(toolName, toolCallId) + return TryGetToolRequestInfo(requestInfo, out string toolName, out string? toolCallId, out IReadOnlyDictionary? toolArguments) + ? new ToolRequestInterpretation(toolName, toolCallId, toolArguments) : new UnknownRequestInterpretation(); } @@ -137,33 +140,38 @@ internal static class WorkflowRequestInfoInterpreter private static bool TryGetToolRequestInfo( RequestInfoEvent requestInfo, out string toolName, - out string? toolCallId) + out string? toolCallId, + out IReadOnlyDictionary? toolArguments) { - return TryGetStableToolRequestInfo(requestInfo.Request.Data, out toolName, out toolCallId) - || TryGetEvaluationToolRequestInfo(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, out toolArguments); } private static bool TryGetStableToolRequestInfo( PortableValue requestData, out string toolName, - out string? toolCallId) + out string? toolCallId, + out IReadOnlyDictionary? toolArguments) { if (requestData.Is(out FunctionCallContent? functionCall)) { toolName = NormalizeOptionalString(functionCall.Name) ?? "function"; toolCallId = NormalizeOptionalString(functionCall.CallId); + toolArguments = NormalizeToolArguments(functionCall.Arguments); return true; } toolName = string.Empty; toolCallId = null; + toolArguments = null; return false; } private static bool TryGetEvaluationToolRequestInfo( PortableValue requestData, out string toolName, - out string? toolCallId) + out string? toolCallId, + out IReadOnlyDictionary? toolArguments) { if (requestData.Is(out McpServerToolCallContent? mcpToolCall)) { @@ -171,6 +179,7 @@ internal static class WorkflowRequestInfoInterpreter ?? NormalizeOptionalString(mcpToolCall.ServerName) ?? string.Empty; toolCallId = NormalizeOptionalString(mcpToolCall.CallId); + toolArguments = NormalizeToolArguments(mcpToolCall.Arguments); return toolName.Length > 0; } @@ -178,6 +187,7 @@ internal static class WorkflowRequestInfoInterpreter { toolName = CodeInterpreterToolName; toolCallId = NormalizeOptionalString(codeInterpreterToolCall.CallId); + toolArguments = NormalizeCodeInterpreterToolArguments(codeInterpreterToolCall); return true; } @@ -185,14 +195,196 @@ internal static class WorkflowRequestInfoInterpreter { toolName = ImageGenerationToolName; toolCallId = null; + toolArguments = null; return true; } toolName = string.Empty; toolCallId = null; + toolArguments = null; return false; } + private static IReadOnlyDictionary? NormalizeToolArguments( + IEnumerable>? arguments) + { + if (arguments is null) + { + return null; + } + + Dictionary normalized = new(StringComparer.Ordinal); + foreach (KeyValuePair 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? NormalizeCodeInterpreterToolArguments( + CodeInterpreterToolCallContent codeInterpreterToolCall) + { + IList? rawInputs = codeInterpreterToolCall.Inputs; + if (rawInputs is not { Count: > 0 }) + { + return null; + } + + List 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(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> dictionary => NormalizeToolArguments(dictionary), + IEnumerable 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(JsonOptions), + JsonValueKind.Object => NormalizeToolArgumentObject(element), + JsonValueKind.Array => NormalizeToolArgumentArray(element), + _ => NormalizeToolArgumentText(element.GetRawText()), + }; + } + + private static IReadOnlyDictionary? NormalizeToolArgumentObject(JsonElement element) + { + Dictionary 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? NormalizeToolArgumentArray(JsonElement element) + { + List 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? NormalizeToolArgumentSequence(IEnumerable sequence) + { + List 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 BuildAiContentFallbackValue(AIContent content) + { + return new Dictionary(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) { 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 ToolRequestInterpretation(string ToolName, string? ToolCallId) : RequestInterpretation; + private sealed record ToolRequestInterpretation( + string ToolName, + string? ToolCallId, + IReadOnlyDictionary? ToolArguments) : RequestInterpretation; private sealed record UnknownRequestInterpretation : RequestInterpretation; } diff --git a/sidecar/tests/Aryx.AgentHost.Tests/WorkflowRequestInfoInterpreterTests.cs b/sidecar/tests/Aryx.AgentHost.Tests/WorkflowRequestInfoInterpreterTests.cs index 57c4ed6..0e2ce15 100644 --- a/sidecar/tests/Aryx.AgentHost.Tests/WorkflowRequestInfoInterpreterTests.cs +++ b/sidecar/tests/Aryx.AgentHost.Tests/WorkflowRequestInfoInterpreterTests.cs @@ -1,3 +1,4 @@ +using System.Collections; using System.Collections.Concurrent; using System.Runtime.CompilerServices; using Aryx.AgentHost.Contracts; @@ -15,7 +16,11 @@ public sealed class WorkflowRequestInfoInterpreterTests { ConcurrentDictionary toolNamesByCallId = new(StringComparer.Ordinal); RequestInfoEvent requestInfo = CreateRequestInfoEvent( - new FunctionCallContent("call-1", "view", new Dictionary())); + new FunctionCallContent("call-1", "view", new Dictionary + { + ["path"] = @"C:\workspace\file.txt", + ["viewRange"] = new object[] { 10, 25 }, + })); AgentActivityEventDto? activity = WorkflowRequestInfoInterpreter.TryCreateActivityFromRequest( CreateSingleAgentCommand(), @@ -28,6 +33,9 @@ public sealed class WorkflowRequestInfoInterpreterTests Assert.Equal("agent-1", activity.AgentId); Assert.Equal("Primary", activity.AgentName); Assert.Equal("view", activity.ToolName); + Assert.NotNull(activity.ToolArguments); + Assert.Equal(@"C:\workspace\file.txt", activity.ToolArguments["path"]); + Assert.Equal([10, 25], Assert.IsAssignableFrom>(activity.ToolArguments["viewRange"])); Assert.Equal("view", toolNamesByCallId["call-1"]); } @@ -36,7 +44,15 @@ public sealed class WorkflowRequestInfoInterpreterTests { ConcurrentDictionary toolNamesByCallId = new(StringComparer.Ordinal); RequestInfoEvent requestInfo = CreateRequestInfoEvent( - CreateMcpToolCall("call-1", "git.status", "Git MCP")); + CreateMcpToolCall( + "call-1", + "git.status", + "Git MCP", + new Dictionary + { + ["path"] = @"C:\workspace", + ["includeIgnored"] = true, + })); AgentActivityEventDto? activity = WorkflowRequestInfoInterpreter.TryCreateActivityFromRequest( CreateSingleAgentCommand(), @@ -47,6 +63,9 @@ public sealed class WorkflowRequestInfoInterpreterTests Assert.NotNull(activity); Assert.Equal("tool-calling", activity.ActivityType); 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"]); } @@ -54,7 +73,8 @@ public sealed class WorkflowRequestInfoInterpreterTests public void TryCreateActivityFromRequest_MapsCodeInterpreterCallsToSyntheticToolName() { ConcurrentDictionary toolNamesByCallId = new(StringComparer.Ordinal); - RequestInfoEvent requestInfo = CreateRequestInfoEvent(CreateCodeInterpreterToolCall("call-1")); + RequestInfoEvent requestInfo = CreateRequestInfoEvent( + CreateCodeInterpreterToolCall("call-1", "print('hello')")); AgentActivityEventDto? activity = WorkflowRequestInfoInterpreter.TryCreateActivityFromRequest( CreateSingleAgentCommand(), @@ -65,6 +85,10 @@ public sealed class WorkflowRequestInfoInterpreterTests Assert.NotNull(activity); Assert.Equal("tool-calling", activity.ActivityType); Assert.Equal("code interpreter", activity.ToolName); + Assert.NotNull(activity.ToolArguments); + Assert.Equal( + ["print('hello')"], + Assert.IsAssignableFrom>(activity.ToolArguments["inputs"])); Assert.Equal("code interpreter", toolNamesByCallId["call-1"]); } @@ -83,9 +107,55 @@ public sealed class WorkflowRequestInfoInterpreterTests Assert.NotNull(activity); Assert.Equal("tool-calling", activity.ActivityType); Assert.Equal("image generation", activity.ToolName); + Assert.Null(activity.ToolArguments); Assert.Empty(toolNamesByCallId); } + [Fact] + public void TryCreateActivityFromRequest_LeavesToolArgumentsNullWhenFunctionCallHasNoUsableArguments() + { + ConcurrentDictionary toolNamesByCallId = new(StringComparer.Ordinal); + RequestInfoEvent requestInfo = CreateRequestInfoEvent( + new FunctionCallContent("call-1", "view", new Dictionary + { + ["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 toolNamesByCallId = new(StringComparer.Ordinal); + RequestInfoEvent requestInfo = CreateRequestInfoEvent( + new FunctionCallContent( + "call-1", + "powershell", + new Dictionary + { + ["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] public void TryCreateActivityFromRequest_SkipsDuplicateTrackedToolCallIds() { @@ -242,22 +312,50 @@ public sealed class WorkflowRequestInfoInterpreterTests return new RequestInfoEvent(request); } - private static object CreateCodeInterpreterToolCall(string callId) + private static object CreateCodeInterpreterToolCall(string callId, params string[] inputs) { Type type = Type.GetType( "Microsoft.Extensions.AI.CodeInterpreterToolCallContent, Microsoft.Extensions.AI.Abstractions", throwOnError: true)!; object instance = Activator.CreateInstance(type)!; 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; } - private static object CreateMcpToolCall(string callId, string toolName, string serverName) + private static object CreateMcpToolCall( + string callId, + string toolName, + string serverName, + IReadOnlyDictionary? arguments = null) { Type type = Type.GetType( "Microsoft.Extensions.AI.McpServerToolCallContent, Microsoft.Extensions.AI.Abstractions", 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() diff --git a/src/main/AryxAppService.ts b/src/main/AryxAppService.ts index 0b553c4..c7620df 100644 --- a/src/main/AryxAppService.ts +++ b/src/main/AryxAppService.ts @@ -2543,6 +2543,7 @@ export class AryxAppService extends EventEmitter { sourceAgentName: event.sourceAgentName, toolName: event.toolName, toolCallId: event.toolCallId, + toolArguments: event.toolArguments, fileChanges: event.fileChanges, })); } @@ -2563,6 +2564,7 @@ export class AryxAppService extends EventEmitter { sourceAgentName: event.sourceAgentName, toolName: event.toolName, toolCallId: event.toolCallId, + toolArguments: event.toolArguments, fileChanges: event.fileChanges, }); } diff --git a/src/renderer/components/chat/ToolCallDetailPanel.tsx b/src/renderer/components/chat/ToolCallDetailPanel.tsx new file mode 100644 index 0000000..974b830 --- /dev/null +++ b/src/renderer/components/chat/ToolCallDetailPanel.tsx @@ -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; +} + +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 ( +
+ {/* Inline summary — always visible when summary exists */} + + + {/* Expanded argument list */} + {expanded && hasExpandableContent && ( +
+
+ {displayArgs.map(([key, value]) => { + const formatted = formatToolArgumentValue(value); + const isMultiline = formatted.includes('\n') || formatted.length > 120; + + return ( +
+ + {key} + + {isMultiline ? ( +
+                      {formatted}
+                    
+ ) : ( + + {formatted} + + )} +
+ ); + })} +
+
+ )} +
+ ); +} diff --git a/src/renderer/components/chat/TurnActivityPanel.tsx b/src/renderer/components/chat/TurnActivityPanel.tsx index 08077f4..38df009 100644 --- a/src/renderer/components/chat/TurnActivityPanel.tsx +++ b/src/renderer/components/chat/TurnActivityPanel.tsx @@ -15,6 +15,7 @@ import { import { useElapsedTimer } from '@renderer/hooks/useElapsedTimer'; import { FileChangePreview } from '@renderer/components/chat/FileChangePreview'; +import { ToolCallDetailPanel } from '@renderer/components/chat/ToolCallDetailPanel'; import { RunChangeSummaryCard } from '@renderer/components/chat/RunChangeSummaryCard'; import { formatEventLabel, truncateContent, filterEventsByAgent, summarizeActivity, type ActivitySummary } from '@renderer/lib/runTimelineFormatting'; import type { ChatMessageRecord } from '@shared/domain/session'; @@ -186,6 +187,11 @@ function ActivityTimelineEventRow({ event }: { event: RunTimelineEventRecord })

)} + {/* Tool call argument details */} + {event.kind === 'tool-call' && ( + + )} + {/* File change preview for tool-call events */} {event.kind === 'tool-call' && event.fileChanges && event.fileChanges.length > 0 && (
diff --git a/src/renderer/lib/toolCallSummary.ts b/src/renderer/lib/toolCallSummary.ts new file mode 100644 index 0000000..f0213c4 --- /dev/null +++ b/src/renderer/lib/toolCallSummary.ts @@ -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, key: string): string | undefined { + const value = args[key]; + return typeof value === 'string' && value.trim().length > 0 ? value.trim() : undefined; +} + +function summarizePath(args: Record): 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 | 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, toolName: string) => string | undefined; + +const toolSummarizers: Record = { + 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 | 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 | 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 | undefined, +): Array<[string, unknown]> { + if (!toolArguments) return []; + + return Object.entries(toolArguments).filter( + ([key, value]) => + !HIDDEN_ARGUMENT_KEYS.has(key) + && value !== null + && value !== undefined + && value !== '', + ); +} diff --git a/src/shared/contracts/sidecar.ts b/src/shared/contracts/sidecar.ts index 5ddae5f..b9f25e4 100644 --- a/src/shared/contracts/sidecar.ts +++ b/src/shared/contracts/sidecar.ts @@ -290,6 +290,7 @@ export interface AgentActivityEvent { sourceAgentName?: string; toolName?: string; toolCallId?: string; + toolArguments?: Record; fileChanges?: ToolCallFileChangePreview[]; } diff --git a/src/shared/domain/event.ts b/src/shared/domain/event.ts index 1bbefee..bb566fc 100644 --- a/src/shared/domain/event.ts +++ b/src/shared/domain/event.ts @@ -46,6 +46,7 @@ export interface SessionEventRecord { sourceAgentName?: string; toolName?: string; toolCallId?: string; + toolArguments?: Record; fileChanges?: ToolCallFileChangePreview[]; run?: SessionRunRecord; error?: string; diff --git a/src/shared/domain/runTimeline.ts b/src/shared/domain/runTimeline.ts index 2dff5e7..ffa6cf0 100644 --- a/src/shared/domain/runTimeline.ts +++ b/src/shared/domain/runTimeline.ts @@ -60,6 +60,7 @@ export interface RunTimelineEventRecord { targetAgentName?: string; toolName?: string; toolCallId?: string; + toolArguments?: Record; fileChanges?: ToolCallFileChangePreview[]; approvalId?: string; approvalKind?: ApprovalCheckpointKind; @@ -120,6 +121,7 @@ export interface AppendRunActivityEventInput { sourceAgentName?: string; toolName?: string; toolCallId?: string; + toolArguments?: Record; fileChanges?: ToolCallFileChangePreview[]; } @@ -824,6 +826,7 @@ export function appendRunActivityEvent( agentName: agent.agentName ?? existingEvent?.agentName, toolName: normalizeOptionalString(input.toolName) ?? existingEvent?.toolName, toolCallId, + toolArguments: input.toolArguments ?? existingEvent?.toolArguments, fileChanges: mergeToolCallFileChanges(existingEvent?.fileChanges, input.fileChanges), }; return existingIndex >= 0 diff --git a/tests/renderer/toolCallSummary.test.ts b/tests/renderer/toolCallSummary.test.ts new file mode 100644 index 0000000..704da6c --- /dev/null +++ b/tests/renderer/toolCallSummary.test.ts @@ -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 ...']]); + }); +});