From 81113d15829ccbc890096929cf0532d1f325309c Mon Sep 17 00:00:00 2001 From: Matt Cowger Date: Tue, 14 Jul 2026 03:55:06 -0700 Subject: [PATCH] feat(app): add tool call detail levels (#2031) * feat(app): compact tool call sequences * feat(app): summarize compact tool activity * feat(app): add tool call detail levels * fix(app): stabilize compact tool groups * fix(app): address compact tool review feedback --- packages/app/src/agent-stream/view.tsx | 73 +++- .../app/src/components/tool-call-group.tsx | 341 ++++++++++++++++++ packages/app/src/hooks/use-settings/index.ts | 9 +- .../src/hooks/use-settings/storage.test.ts | 43 +++ .../app/src/hooks/use-settings/storage.ts | 44 ++- packages/app/src/i18n/resources/ar.ts | 40 ++ packages/app/src/i18n/resources/en.ts | 40 ++ packages/app/src/i18n/resources/es.ts | 40 ++ packages/app/src/i18n/resources/fr.ts | 40 ++ packages/app/src/i18n/resources/ja.ts | 40 ++ packages/app/src/i18n/resources/pt-BR.ts | 40 ++ packages/app/src/i18n/resources/ru.ts | 40 ++ packages/app/src/i18n/resources/zh-CN.ts | 40 ++ .../appearance/appearance-section.tsx | 82 +++++ packages/app/src/tool-calls/grouping.test.ts | 264 ++++++++++++++ packages/app/src/tool-calls/grouping.ts | 325 +++++++++++++++++ 16 files changed, 1488 insertions(+), 13 deletions(-) create mode 100644 packages/app/src/components/tool-call-group.tsx create mode 100644 packages/app/src/tool-calls/grouping.test.ts create mode 100644 packages/app/src/tool-calls/grouping.ts diff --git a/packages/app/src/agent-stream/view.tsx b/packages/app/src/agent-stream/view.tsx index 446ddee5c..8250461f1 100644 --- a/packages/app/src/agent-stream/view.tsx +++ b/packages/app/src/agent-stream/view.tsx @@ -57,6 +57,8 @@ import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; import { ToolCallDetailsContent } from "@/components/tool-call-details"; import { QuestionFormCard } from "@/components/question-form-card"; import { ToolCallSheetProvider } from "@/components/tool-call-sheet"; +import { ToolCallGroup } from "@/components/tool-call-group"; +import { compactToolCallRuns } from "@/tool-calls/grouping"; import { type AgentStreamRenderModel, buildAgentStreamRenderModel } from "./model"; import { resolveStreamRenderStrategy } from "./strategy-resolver"; import { type StreamSegmentRenderers, type StreamViewportHandle } from "./strategy"; @@ -329,6 +331,7 @@ const AgentStreamViewComponent = forwardRef settings.autoExpandReasoning); + const toolCallDetailLevel = useSettings((settings) => settings.toolCallDetailLevel); const viewportRef = useRef(null); const isMobile = useIsCompactFormFactor(); const streamRenderStrategy = useMemo( @@ -343,6 +346,9 @@ const AgentStreamViewComponent = forwardRef>( new Set(), ); + const [expandedToolCallGroupIds, setExpandedToolCallGroupIds] = useState>( + new Set(), + ); const openFileExplorerForCheckout = usePanelStore((state) => state.openFileExplorerForCheckout); const setExplorerTabForCheckout = usePanelStore((state) => state.setExplorerTabForCheckout); @@ -391,6 +397,7 @@ const AgentStreamViewComponent = forwardRef { setIsNearBottom(true); setExpandedInlineToolCallIds(new Set()); + setExpandedToolCallGroupIds(new Set()); }, [agentId]); const handleInlinePathPress = useStableEvent( @@ -537,16 +544,25 @@ const AgentStreamViewComponent = forwardRef + compactToolCallRuns({ + tail: effectiveStreamItems, + head: effectiveStreamHead ?? EMPTY_STREAM_HEAD, + enabled: toolCallDetailLevel !== "detailed", + }), + [effectiveStreamHead, effectiveStreamItems, toolCallDetailLevel], + ); const baseRenderModel = useMemo(() => { return buildAgentStreamRenderModel({ agentStatus: context.status, - tail: effectiveStreamItems, - head: effectiveStreamHead ?? EMPTY_STREAM_HEAD, + tail: compactedToolCalls.tail, + head: compactedToolCalls.head, platform: isWeb ? "web" : "native", isMobileBreakpoint: isMobile, }); - }, [context.status, isMobile, effectiveStreamHead, effectiveStreamItems]); + }, [context.status, isMobile, compactedToolCalls.head, compactedToolCalls.tail]); const streamLayout = useMemo( () => layoutStream({ @@ -599,6 +615,18 @@ const AgentStreamViewComponent = forwardRef { + setExpandedToolCallGroupIds((previous) => { + const next = new Set(previous); + if (expanded) { + next.add(groupId); + } else { + next.delete(groupId); + } + return next; + }); + }, []); + const renderUserMessageItem = useCallback( (layoutItem: StreamLayoutItem, item: Extract) => { return ( @@ -662,8 +690,8 @@ const AgentStreamViewComponent = forwardRef) => { + const renderSingleToolCallItem = useCallback( + (item: Extract, isLastInSequence: boolean) => { const { payload } = item; if (payload.source === "agent") { @@ -690,7 +718,7 @@ const AgentStreamViewComponent = forwardRef ); @@ -705,7 +733,7 @@ const AgentStreamViewComponent = forwardRef ); @@ -713,6 +741,37 @@ const AgentStreamViewComponent = forwardRef) => { + const group = compactedToolCalls.groupsByHostId.get(item.id); + if (!group) { + return renderSingleToolCallItem(item, layoutItem.isLastInToolSequence); + } + const expanded = expandedToolCallGroupIds.has(group.id); + return ( + + {group.calls.map((call, index) => ( + + {renderSingleToolCallItem(call, index === group.calls.length - 1)} + + ))} + + ); + }, + [ + compactedToolCalls.groupsByHostId, + expandedToolCallGroupIds, + renderSingleToolCallItem, + setToolCallGroupExpanded, + toolCallDetailLevel, + ], + ); + const renderStreamItemContent = useCallback( (layoutItem: StreamLayoutItem) => { const item = layoutItem.item; diff --git a/packages/app/src/components/tool-call-group.tsx b/packages/app/src/components/tool-call-group.tsx new file mode 100644 index 000000000..ef44e3f7a --- /dev/null +++ b/packages/app/src/components/tool-call-group.tsx @@ -0,0 +1,341 @@ +import { memo, useCallback, useMemo, type ReactNode } from "react"; +import { + ActivityIndicator, + Pressable, + Text, + View, + type PressableStateCallbackType, +} from "react-native"; +import { useTranslation } from "react-i18next"; +import { ChevronRight, TriangleAlert, Wrench } from "lucide-react-native"; +import { StyleSheet } from "react-native-unistyles"; +import { useIsCompactFormFactor } from "@/constants/layout"; +import type { + CompactToolCallGroup as CompactToolCallGroupModel, + ToolCallCategorySummary, +} from "@/tool-calls/grouping"; +import { componentForToolCallIcon } from "@/utils/tool-call-icon"; + +interface ToolCallGroupProps { + group: CompactToolCallGroupModel; + presentation: "overview" | "concise"; + expanded: boolean; + onExpandedChange: (groupId: string, expanded: boolean) => void; + children: ReactNode; +} + +function CategoryStatus({ category }: { category: ToolCallCategorySummary }) { + const { t } = useTranslation(); + if (category.failedCount > 0) { + return ( + + + + {t("toolCallGroup.failed", { count: category.failedCount })} + + + ); + } + if (category.runningCount > 0) { + return ; + } + return null; +} + +function CategoryRow({ + category, + resourceLimit, +}: { + category: ToolCallCategorySummary; + resourceLimit: number; +}) { + const Icon = componentForToolCallIcon(category.iconName); + const visibleResources = category.resources.slice(0, resourceLimit); + const hiddenResourceCount = category.resources.length - visibleResources.length; + const resourceText = [ + ...visibleResources, + ...(hiddenResourceCount > 0 ? [`+${hiddenResourceCount}`] : []), + ].join(", "); + + return ( + + + + + ×{category.callCount} + {category.label} + + {resourceText ? ( + + {resourceText} + + ) : null} + + ); +} + +function GroupHeaderIcon({ + group, + compact, +}: { + group: CompactToolCallGroupModel; + compact: boolean; +}) { + const size = compact ? 11 : 12; + if (group.failedCount > 0) { + return ; + } + if (group.isRunning) { + return ; + } + return ; +} + +function joinSummaryParts(parts: string[], conjunction: string): string { + if (parts.length === 0) { + return ""; + } + let joined: string; + if (parts.length === 1) { + joined = parts[0] ?? ""; + } else if (parts.length === 2) { + joined = `${parts[0]} ${conjunction} ${parts[1]}`; + } else { + joined = `${parts.slice(0, -1).join(", ")}, ${conjunction} ${parts.at(-1)}`; + } + const firstCharacter = joined[0]; + return firstCharacter ? `${firstCharacter.toLocaleUpperCase()}${joined.slice(1)}` : joined; +} + +export const ToolCallGroup = memo(function ToolCallGroup({ + group, + presentation, + expanded, + onExpandedChange, + children, +}: ToolCallGroupProps) { + const { t } = useTranslation(); + const isCompact = useIsCompactFormFactor(); + const isOverview = presentation === "overview"; + const resourceLimit = isCompact ? 2 : 3; + const handlePress = useCallback( + () => onExpandedChange(group.id, !expanded), + [expanded, group.id, onExpandedChange], + ); + const accessibilityState = useMemo(() => ({ expanded }), [expanded]); + const headerStyle = useCallback( + ({ pressed, hovered }: PressableStateCallbackType & { hovered?: boolean }) => [ + styles.header, + !isOverview && styles.headerConcise, + (pressed || hovered || expanded) && styles.headerActive, + ], + [expanded, isOverview], + ); + const summary = useMemo(() => { + const parts: string[] = []; + if (group.editedFileCount > 0) { + parts.push( + t( + group.editedFileCount === 1 + ? "toolCallGroup.editedFiles.one" + : "toolCallGroup.editedFiles.other", + { count: group.editedFileCount }, + ), + ); + } + if (group.commandCount > 0) { + parts.push( + t( + group.commandCount === 1 ? "toolCallGroup.commands.one" : "toolCallGroup.commands.other", + { count: group.commandCount }, + ), + ); + } + if (group.readFileCount > 0) { + parts.push( + t( + group.readFileCount === 1 + ? "toolCallGroup.readFiles.one" + : "toolCallGroup.readFiles.other", + { count: group.readFileCount }, + ), + ); + } + if (group.searchCount > 0) { + parts.push( + t(group.searchCount === 1 ? "toolCallGroup.searches.one" : "toolCallGroup.searches.other", { + count: group.searchCount, + }), + ); + } + if (group.otherToolCount > 0) { + parts.push( + t( + group.otherToolCount === 1 + ? "toolCallGroup.otherTools.one" + : "toolCallGroup.otherTools.other", + { count: group.otherToolCount }, + ), + ); + } + if (group.paseoCallCount > 0) { + parts.push( + t( + group.paseoCallCount === 1 + ? "toolCallGroup.paseoCalls.one" + : "toolCallGroup.paseoCalls.other", + { count: group.paseoCallCount }, + ), + ); + } + return joinSummaryParts(parts, t("toolCallGroup.and")); + }, [group, t]); + const accessibilityLabel = isOverview + ? summary + : t("toolCallGroup.accessibilityLabel", { count: group.callCount }); + + return ( + + + + + + {isOverview ? ( + + {summary} + + ) : ( + <> + {t("toolCallGroup.title")} + ×{group.callCount} + + )} + {group.failedCount > 0 ? ( + + {t("toolCallGroup.failed", { count: group.failedCount })} + + ) : null} + + + + {expanded ? {children} : null} + {!expanded && !isOverview ? ( + + {group.categories.map((category) => ( + + ))} + + ) : null} + + ); +}); + +const styles = StyleSheet.create((theme) => ({ + container: { + marginHorizontal: -theme.spacing[3], + }, + header: { + minHeight: 26, + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[1], + paddingHorizontal: theme.spacing[2], + paddingVertical: theme.spacing[1], + borderRadius: theme.borderRadius.lg, + }, + headerActive: { + backgroundColor: theme.colors.surface1, + }, + headerConcise: { + minHeight: 30, + }, + headerIcon: { + width: 18, + height: 18, + alignItems: "center", + justifyContent: "center", + }, + summary: { + flex: 1, + minWidth: 0, + color: theme.colors.foregroundMuted, + fontSize: theme.fontSize.sm, + fontWeight: theme.fontWeight.normal, + }, + conciseTitle: { + color: theme.colors.foregroundMuted, + fontSize: theme.fontSize.base, + fontWeight: theme.fontWeight.normal, + }, + conciseCallCount: { + flex: 1, + color: theme.colors.foregroundMuted, + fontSize: theme.fontSize.xs, + }, + categories: { + gap: theme.spacing[1], + paddingLeft: theme.spacing[8], + paddingRight: theme.spacing[2], + paddingTop: theme.spacing[1], + }, + categoryRow: { + minHeight: 20, + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[2], + }, + categoryIcon: { + width: 14, + alignItems: "center", + }, + categoryLabel: { + color: theme.colors.foregroundMuted, + fontSize: theme.fontSize.sm, + minWidth: 64, + }, + categoryCount: { + width: theme.spacing[6], + color: theme.colors.foregroundMuted, + fontSize: theme.fontSize.xs, + textAlign: "right", + }, + categoryStatus: { + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[1], + }, + resources: { + flex: 1, + minWidth: 0, + color: theme.colors.foregroundMuted, + fontFamily: theme.fontFamily.mono, + fontSize: theme.fontSize.code, + }, + expandedCalls: { + paddingTop: theme.spacing[1], + marginHorizontal: theme.spacing[3], + }, + chevronExpanded: { + transform: [{ rotate: "90deg" }], + }, + foreground: { + color: theme.colors.foreground, + }, + muted: { + color: theme.colors.foregroundMuted, + }, + error: { + color: theme.colors.destructive, + fontSize: theme.fontSize.xs, + }, +})); diff --git a/packages/app/src/hooks/use-settings/index.ts b/packages/app/src/hooks/use-settings/index.ts index b1776783c..9e171f5dd 100644 --- a/packages/app/src/hooks/use-settings/index.ts +++ b/packages/app/src/hooks/use-settings/index.ts @@ -1,4 +1,4 @@ -import { useCallback } from "react"; +import { useCallback, useMemo } from "react"; import AsyncStorage from "@react-native-async-storage/async-storage"; import { useQuery, useQueryClient, type QueryClient } from "@tanstack/react-query"; import { queryClient as appQueryClient } from "@/data/query-client"; @@ -26,6 +26,7 @@ import { MIN_UI_FONT_SIZE, loadAppSettingsFromStorage as loadAppSettingsFromStoragePure, loadSettingsFromStorage as loadSettingsFromStoragePure, + normalizeAppSettings, parseClampedFontSize, parseTerminalScrollbackLines, sanitizeFontFamily, @@ -129,9 +130,10 @@ export function useAppSettings(): UseAppSettingsReturn { throw err; } }, [queryClient]); + const settings = useMemo(() => normalizeAppSettings(data), [data]); return { - settings: data ?? DEFAULT_CLIENT_SETTINGS, + settings, isLoading: isPending, error: error ?? null, updateSettings, @@ -186,6 +188,9 @@ export function useSettings( if (updates.autoExpandReasoning !== undefined) { appUpdates.autoExpandReasoning = updates.autoExpandReasoning; } + if (updates.toolCallDetailLevel !== undefined) { + appUpdates.toolCallDetailLevel = updates.toolCallDetailLevel; + } const promises: Promise[] = []; if (Object.keys(appUpdates).length > 0) { promises.push(appSettings.updateSettings(appUpdates)); diff --git a/packages/app/src/hooks/use-settings/storage.test.ts b/packages/app/src/hooks/use-settings/storage.test.ts index 87c5e4321..38b3f84da 100644 --- a/packages/app/src/hooks/use-settings/storage.test.ts +++ b/packages/app/src/hooks/use-settings/storage.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest"; import { QueryClient } from "@tanstack/react-query"; import { APP_SETTINGS_KEY, + APP_SETTINGS_QUERY_KEY, DEFAULT_APP_SETTINGS, DEFAULT_CLIENT_SETTINGS, DEFAULT_CODE_FONT_SIZE, @@ -282,6 +283,27 @@ describe("saveAppSettings", () => { }), ); }); + + it("normalizes a legacy cached settings shape before saving", async () => { + const deps = makeDeps(); + const queryClient = new QueryClient(); + queryClient.setQueryData(APP_SETTINGS_QUERY_KEY, { + theme: "dark", + compactToolCalls: true, + }); + + await saveAppSettings({ + queryClient, + updates: { theme: "light" }, + deps, + }); + + expect(JSON.parse(deps.storage.entries.get(APP_SETTINGS_KEY) ?? "null")).toEqual({ + ...DEFAULT_CLIENT_SETTINGS, + theme: "light", + toolCallDetailLevel: "overview", + }); + }); }); describe("parseTerminalScrollbackLines", () => { @@ -306,6 +328,27 @@ describe("appearance settings", () => { expect(result.uiFontSize).toBe(DEFAULT_UI_FONT_SIZE); expect(result.codeFontSize).toBe(DEFAULT_CODE_FONT_SIZE); expect(result.syntaxTheme).toBe("one"); + expect(result.toolCallDetailLevel).toBe("detailed"); + }); + + it("migrates the enabled compact tool call preference to overview", async () => { + const deps = makeDeps({ + storage: createInMemoryKeyValueStorage({ + [APP_SETTINGS_KEY]: JSON.stringify({ compactToolCalls: true }), + }), + }); + + expect((await loadAppSettingsFromStorage(deps)).toolCallDetailLevel).toBe("overview"); + }); + + it("loads an explicit tool call detail level", async () => { + const deps = makeDeps({ + storage: createInMemoryKeyValueStorage({ + [APP_SETTINGS_KEY]: JSON.stringify({ toolCallDetailLevel: "concise" }), + }), + }); + + expect((await loadAppSettingsFromStorage(deps)).toolCallDetailLevel).toBe("concise"); }); it("clamps the UI font size into range and rejects non-numeric values", async () => { diff --git a/packages/app/src/hooks/use-settings/storage.ts b/packages/app/src/hooks/use-settings/storage.ts index 116781227..dc982aef7 100644 --- a/packages/app/src/hooks/use-settings/storage.ts +++ b/packages/app/src/hooks/use-settings/storage.ts @@ -12,10 +12,16 @@ export type SendBehavior = "interrupt" | "queue"; export type ReleaseChannel = "stable" | "beta"; export type ServiceUrlBehavior = "ask" | "in-app" | "external"; export type WorkspaceTitleSource = "title" | "branch"; +export type ToolCallDetailLevel = "overview" | "concise" | "detailed"; const VALID_THEMES = new Set([...Object.keys(THEME_TO_UNISTYLES), "auto"]); const VALID_SERVICE_URL_BEHAVIORS = new Set(["ask", "in-app", "external"]); const VALID_WORKSPACE_TITLE_SOURCES = new Set(["title", "branch"]); +const VALID_TOOL_CALL_DETAIL_LEVELS = new Set([ + "overview", + "concise", + "detailed", +]); export const DEFAULT_TERMINAL_SCROLLBACK_LINES = 10_000; export const MIN_TERMINAL_SCROLLBACK_LINES = 0; export const MAX_TERMINAL_SCROLLBACK_LINES = 1_000_000; @@ -40,6 +46,7 @@ export interface AppSettings { syntaxTheme: SyntaxThemeId; // default "one" workspaceTitleSource: WorkspaceTitleSource; autoExpandReasoning: boolean; + toolCallDetailLevel: ToolCallDetailLevel; } export interface Settings extends AppSettings { @@ -47,6 +54,8 @@ export interface Settings extends AppSettings { releaseChannel: ReleaseChannel; } +type StoredAppSettings = Partial & { compactToolCalls?: unknown }; + export const DEFAULT_CLIENT_SETTINGS: AppSettings = { theme: "auto", language: "system", @@ -60,6 +69,7 @@ export const DEFAULT_CLIENT_SETTINGS: AppSettings = { syntaxTheme: "one", workspaceTitleSource: "title", autoExpandReasoning: false, + toolCallDetailLevel: "detailed", }; export const DEFAULT_APP_SETTINGS: Settings = { @@ -92,9 +102,10 @@ export async function saveAppSettings(input: { updates: Partial; deps: SettingsDeps; }): Promise { - const current = + const storedCurrent = input.queryClient.getQueryData(APP_SETTINGS_QUERY_KEY) ?? (await loadAppSettingsFromStorage(input.deps)); + const current = normalizeAppSettings(storedCurrent); const next = { ...current, ...input.updates }; input.queryClient.setQueryData(APP_SETTINGS_QUERY_KEY, next); await input.deps.storage.setItem(APP_SETTINGS_KEY, JSON.stringify(next)); @@ -104,8 +115,7 @@ export async function loadAppSettingsFromStorage(deps: SettingsDeps): Promise; - return { ...DEFAULT_CLIENT_SETTINGS, ...pickAppSettings(parsed) }; + return normalizeAppSettings(JSON.parse(stored)); } const legacyStored = await deps.storage.getItem(LEGACY_SETTINGS_KEY); @@ -153,7 +163,29 @@ export async function loadSettingsFromStorage(deps: SettingsDeps): Promise): Partial { +export function normalizeAppSettings(value: unknown): AppSettings { + const stored = + typeof value === "object" && value !== null && !Array.isArray(value) + ? (value as StoredAppSettings) + : {}; + return { ...DEFAULT_CLIENT_SETTINGS, ...pickAppSettings(stored) }; +} + +function parseToolCallDetailLevel(stored: StoredAppSettings): ToolCallDetailLevel | null { + if ( + typeof stored.toolCallDetailLevel === "string" && + VALID_TOOL_CALL_DETAIL_LEVELS.has(stored.toolCallDetailLevel) + ) { + return stored.toolCallDetailLevel; + } + if (typeof stored.compactToolCalls === "boolean") { + // COMPAT(compactToolCalls): migrated in v0.1.105, remove after 2027-01-12. + return stored.compactToolCalls ? "overview" : "detailed"; + } + return null; +} + +function pickAppSettings(stored: StoredAppSettings): Partial { const result: Partial = {}; if (typeof stored.theme === "string" && VALID_THEMES.has(stored.theme)) { result.theme = stored.theme; @@ -209,6 +241,10 @@ function pickAppSettings(stored: Partial): Partial { if (typeof stored.autoExpandReasoning === "boolean") { result.autoExpandReasoning = stored.autoExpandReasoning; } + const toolCallDetailLevel = parseToolCallDetailLevel(stored); + if (toolCallDetailLevel !== null) { + result.toolCallDetailLevel = toolCallDetailLevel; + } return result; } diff --git a/packages/app/src/i18n/resources/ar.ts b/packages/app/src/i18n/resources/ar.ts index 27554a016..0a39025b1 100644 --- a/packages/app/src/i18n/resources/ar.ts +++ b/packages/app/src/i18n/resources/ar.ts @@ -1395,6 +1395,36 @@ export const ar: TranslationResources = { input: "مدخل", output: "الإخراج", }, + toolCallGroup: { + title: "الأدوات", + accessibilityLabel: "الأدوات، {{count}} استدعاءات", + editedFiles: { + one: "حرّر {{count}} ملفًا", + other: "حرّر {{count}} ملفات", + }, + commands: { + one: "شغّل {{count}} أمرًا", + other: "شغّل {{count}} أوامر", + }, + readFiles: { + one: "قرأ {{count}} ملفًا", + other: "قرأ {{count}} ملفات", + }, + searches: { + one: "بحث {{count}} مرة", + other: "بحث {{count}} مرات", + }, + otherTools: { + one: "استخدم {{count}} أداة أخرى", + other: "استخدم {{count}} أدوات أخرى", + }, + paseoCalls: { + one: "استدعى Paseo {{count}} مرة", + other: "استدعى Paseo {{count}} مرات", + }, + and: "و", + failed: "فشل {{count}}", + }, renameModal: { rename: "إعادة تسمية", saving: "جارٍ الحفظ...", @@ -1492,6 +1522,16 @@ export const ar: TranslationResources = { label: "عرض التفكير دائماً", description: "إظهار تفكير الوكيل وخطوات الاستدلال بشكل كامل بشكل افتراضي", }, + toolCallDetail: { + label: "تفاصيل استدعاءات الأدوات", + description: "كيفية ظهور نشاط الأدوات في الخط الزمني للوكيل", + accessibilityLabel: "حدد مستوى تفاصيل الأدوات ({{value}})", + options: { + overview: "نظرة عامة", + concise: "موجز", + detailed: "مفصل", + }, + }, language: { label: "لغة", description: "لغة التطبيق", diff --git a/packages/app/src/i18n/resources/en.ts b/packages/app/src/i18n/resources/en.ts index 73ada70f3..37eab1582 100644 --- a/packages/app/src/i18n/resources/en.ts +++ b/packages/app/src/i18n/resources/en.ts @@ -1403,6 +1403,36 @@ export const en = { input: "Input", output: "Output", }, + toolCallGroup: { + title: "Tools", + accessibilityLabel: "Tools, {{count}} calls", + editedFiles: { + one: "edited {{count}} file", + other: "edited {{count}} files", + }, + commands: { + one: "ran {{count}} command", + other: "ran {{count}} commands", + }, + readFiles: { + one: "read {{count}} file", + other: "read {{count}} files", + }, + searches: { + one: "searched {{count}} time", + other: "searched {{count}} times", + }, + otherTools: { + one: "used {{count}} other tool", + other: "used {{count}} other tools", + }, + paseoCalls: { + one: "called Paseo {{count}} time", + other: "called Paseo {{count}} times", + }, + and: "and", + failed: "{{count}} failed", + }, renameModal: { rename: "Rename", saving: "Saving...", @@ -1499,6 +1529,16 @@ export const en = { label: "Always expand reasoning", description: "Show agent thinking and chain-of-thought blocks fully expanded by default", }, + toolCallDetail: { + label: "Tool call detail", + description: "How tool activity appears in agent timelines", + accessibilityLabel: "Select tool call detail ({{value}})", + options: { + overview: "Overview", + concise: "Concise", + detailed: "Detailed", + }, + }, language: { label: "Language", description: "App language", diff --git a/packages/app/src/i18n/resources/es.ts b/packages/app/src/i18n/resources/es.ts index c57fda7e1..aba840de3 100644 --- a/packages/app/src/i18n/resources/es.ts +++ b/packages/app/src/i18n/resources/es.ts @@ -1434,6 +1434,36 @@ export const es: TranslationResources = { input: "Aporte", output: "Producción", }, + toolCallGroup: { + title: "Herramientas", + accessibilityLabel: "Herramientas, {{count}} llamadas", + editedFiles: { + one: "editó {{count}} archivo", + other: "editó {{count}} archivos", + }, + commands: { + one: "ejecutó {{count}} comando", + other: "ejecutó {{count}} comandos", + }, + readFiles: { + one: "leyó {{count}} archivo", + other: "leyó {{count}} archivos", + }, + searches: { + one: "buscó {{count}} vez", + other: "buscó {{count}} veces", + }, + otherTools: { + one: "usó {{count}} herramienta adicional", + other: "usó {{count}} herramientas adicionales", + }, + paseoCalls: { + one: "llamó a Paseo {{count}} vez", + other: "llamó a Paseo {{count}} veces", + }, + and: "y", + failed: "{{count}} con error", + }, renameModal: { rename: "Rebautizar", saving: "Guardando...", @@ -1533,6 +1563,16 @@ export const es: TranslationResources = { description: "Mostrar los bloques de pensamiento y razonamiento del agente totalmente expandidos de forma predeterminada", }, + toolCallDetail: { + label: "Detalle de llamadas a herramientas", + description: "Cómo aparece la actividad de herramientas en las cronologías del agente", + accessibilityLabel: "Seleccionar detalle de herramientas ({{value}})", + options: { + overview: "Resumen", + concise: "Conciso", + detailed: "Detallado", + }, + }, language: { label: "Idioma", description: "Idioma de la aplicación", diff --git a/packages/app/src/i18n/resources/fr.ts b/packages/app/src/i18n/resources/fr.ts index 829a15c1c..82681b4a0 100644 --- a/packages/app/src/i18n/resources/fr.ts +++ b/packages/app/src/i18n/resources/fr.ts @@ -1437,6 +1437,36 @@ export const fr: TranslationResources = { input: "Saisir", output: "Sortir", }, + toolCallGroup: { + title: "Outils", + accessibilityLabel: "Outils, {{count}} appels", + editedFiles: { + one: "a modifié {{count}} fichier", + other: "a modifié {{count}} fichiers", + }, + commands: { + one: "a exécuté {{count}} commande", + other: "a exécuté {{count}} commandes", + }, + readFiles: { + one: "a lu {{count}} fichier", + other: "a lu {{count}} fichiers", + }, + searches: { + one: "a effectué {{count}} recherche", + other: "a effectué {{count}} recherches", + }, + otherTools: { + one: "a utilisé {{count}} autre outil", + other: "a utilisé {{count}} autres outils", + }, + paseoCalls: { + one: "a appelé Paseo {{count}} fois", + other: "a appelé Paseo {{count}} fois", + }, + and: "et", + failed: "{{count}} en échec", + }, renameModal: { rename: "Rebaptiser", saving: "Sauvegarde...", @@ -1535,6 +1565,16 @@ export const fr: TranslationResources = { label: "Toujours afficher le raisonnement", description: "Afficher le raisonnement de l'agent entièrement développé par défaut", }, + toolCallDetail: { + label: "Détail des appels d’outils", + description: "Affichage de l’activité des outils dans la chronologie de l’agent", + accessibilityLabel: "Sélectionner le détail des outils ({{value}})", + options: { + overview: "Vue d’ensemble", + concise: "Concis", + detailed: "Détaillé", + }, + }, language: { label: "Langue", description: "Langue de l'application", diff --git a/packages/app/src/i18n/resources/ja.ts b/packages/app/src/i18n/resources/ja.ts index 9514b45ea..e43962423 100644 --- a/packages/app/src/i18n/resources/ja.ts +++ b/packages/app/src/i18n/resources/ja.ts @@ -1412,6 +1412,36 @@ export const ja: TranslationResources = { input: "入力", output: "出力", }, + toolCallGroup: { + title: "ツール", + accessibilityLabel: "ツール、{{count}}件の呼び出し", + editedFiles: { + one: "{{count}}個のファイルを編集", + other: "{{count}}個のファイルを編集", + }, + commands: { + one: "{{count}}個のコマンドを実行", + other: "{{count}}個のコマンドを実行", + }, + readFiles: { + one: "{{count}}個のファイルを読み取り", + other: "{{count}}個のファイルを読み取り", + }, + searches: { + one: "{{count}}回検索", + other: "{{count}}回検索", + }, + otherTools: { + one: "その他のツールを{{count}}回使用", + other: "その他のツールを{{count}}回使用", + }, + paseoCalls: { + one: "Paseoを{{count}}回呼び出し", + other: "Paseoを{{count}}回呼び出し", + }, + and: "および", + failed: "{{count}}件失敗", + }, renameModal: { rename: "名前を変更", saving: "保存中...", @@ -1508,6 +1538,16 @@ export const ja: TranslationResources = { label: "常に思考プロセスを展開", description: "デフォルトでAIのエージェント思考・推論ブロックを完全に展開して表示します", }, + toolCallDetail: { + label: "ツール呼び出しの詳細", + description: "エージェントのタイムラインでのツール活動の表示方法", + accessibilityLabel: "ツール詳細を選択({{value}})", + options: { + overview: "概要", + concise: "簡潔", + detailed: "詳細", + }, + }, language: { label: "言語", description: "アプリの言語", diff --git a/packages/app/src/i18n/resources/pt-BR.ts b/packages/app/src/i18n/resources/pt-BR.ts index 582c9d261..7369b1db8 100644 --- a/packages/app/src/i18n/resources/pt-BR.ts +++ b/packages/app/src/i18n/resources/pt-BR.ts @@ -1420,6 +1420,36 @@ export const ptBR: TranslationResources = { input: "Entrada", output: "Saída", }, + toolCallGroup: { + title: "Ferramentas", + accessibilityLabel: "Ferramentas, {{count}} chamadas", + editedFiles: { + one: "editou {{count}} arquivo", + other: "editou {{count}} arquivos", + }, + commands: { + one: "executou {{count}} comando", + other: "executou {{count}} comandos", + }, + readFiles: { + one: "leu {{count}} arquivo", + other: "leu {{count}} arquivos", + }, + searches: { + one: "pesquisou {{count}} vez", + other: "pesquisou {{count}} vezes", + }, + otherTools: { + one: "usou {{count}} outra ferramenta", + other: "usou {{count}} outras ferramentas", + }, + paseoCalls: { + one: "chamou o Paseo {{count}} vez", + other: "chamou o Paseo {{count}} vezes", + }, + and: "e", + failed: "{{count}} com falha", + }, renameModal: { rename: "Renomear", saving: "Salvando...", @@ -1518,6 +1548,16 @@ export const ptBR: TranslationResources = { description: "Mostrar os blocos de pensamento e raciocínio do agente totalmente expandidos por padrão", }, + toolCallDetail: { + label: "Detalhe das chamadas de ferramentas", + description: "Como a atividade das ferramentas aparece na linha do tempo do agente", + accessibilityLabel: "Selecionar detalhe das ferramentas ({{value}})", + options: { + overview: "Visão geral", + concise: "Conciso", + detailed: "Detalhado", + }, + }, language: { label: "Idioma", description: "Idioma do app", diff --git a/packages/app/src/i18n/resources/ru.ts b/packages/app/src/i18n/resources/ru.ts index 59cea3eb2..208d4dbbc 100644 --- a/packages/app/src/i18n/resources/ru.ts +++ b/packages/app/src/i18n/resources/ru.ts @@ -1426,6 +1426,36 @@ export const ru: TranslationResources = { input: "Вход", output: "Выход", }, + toolCallGroup: { + title: "Инструменты", + accessibilityLabel: "Инструменты, вызовов: {{count}}", + editedFiles: { + one: "изменён {{count}} файл", + other: "изменено {{count}} файлов", + }, + commands: { + one: "выполнена {{count}} команда", + other: "выполнено {{count}} команд", + }, + readFiles: { + one: "прочитан {{count}} файл", + other: "прочитано {{count}} файлов", + }, + searches: { + one: "выполнен {{count}} поиск", + other: "выполнено {{count}} поисков", + }, + otherTools: { + one: "использован {{count}} другой инструмент", + other: "использовано {{count}} других инструментов", + }, + paseoCalls: { + one: "Paseo вызван {{count}} раз", + other: "Paseo вызван {{count}} раз", + }, + and: "и", + failed: "С ошибкой: {{count}}", + }, renameModal: { rename: "Переименовать", saving: "Сохранение...", @@ -1523,6 +1553,16 @@ export const ru: TranslationResources = { description: "По умолчанию показывать блоки размышлений и логики агента полностью развернутыми", }, + toolCallDetail: { + label: "Детализация вызовов инструментов", + description: "Отображение активности инструментов в хронологии агента", + accessibilityLabel: "Выбрать детализацию инструментов ({{value}})", + options: { + overview: "Обзор", + concise: "Кратко", + detailed: "Подробно", + }, + }, language: { label: "Язык", description: "Язык приложения", diff --git a/packages/app/src/i18n/resources/zh-CN.ts b/packages/app/src/i18n/resources/zh-CN.ts index c1bc82c41..f97c00a35 100644 --- a/packages/app/src/i18n/resources/zh-CN.ts +++ b/packages/app/src/i18n/resources/zh-CN.ts @@ -1379,6 +1379,36 @@ export const zhCN: TranslationResources = { input: "输入", output: "输出", }, + toolCallGroup: { + title: "工具", + accessibilityLabel: "工具,{{count}} 次调用", + editedFiles: { + one: "编辑了 {{count}} 个文件", + other: "编辑了 {{count}} 个文件", + }, + commands: { + one: "运行了 {{count}} 个命令", + other: "运行了 {{count}} 个命令", + }, + readFiles: { + one: "读取了 {{count}} 个文件", + other: "读取了 {{count}} 个文件", + }, + searches: { + one: "搜索了 {{count}} 次", + other: "搜索了 {{count}} 次", + }, + otherTools: { + one: "使用了 {{count}} 个其他工具", + other: "使用了 {{count}} 个其他工具", + }, + paseoCalls: { + one: "调用了 Paseo {{count}} 次", + other: "调用了 Paseo {{count}} 次", + }, + and: "并", + failed: "{{count}} 次失败", + }, renameModal: { rename: "重命名", saving: "正在保存...", @@ -1475,6 +1505,16 @@ export const zhCN: TranslationResources = { label: "始终展开推理过程", description: "默认情况下完全展开 AI 的思考和推理过程", }, + toolCallDetail: { + label: "工具调用详情", + description: "工具活动在智能体时间线中的显示方式", + accessibilityLabel: "选择工具调用详情({{value}})", + options: { + overview: "概览", + concise: "简洁", + detailed: "详细", + }, + }, language: { label: "语言", description: "应用语言", diff --git a/packages/app/src/screens/settings/appearance/appearance-section.tsx b/packages/app/src/screens/settings/appearance/appearance-section.tsx index ed7ac596c..9a0162406 100644 --- a/packages/app/src/screens/settings/appearance/appearance-section.tsx +++ b/packages/app/src/screens/settings/appearance/appearance-section.tsx @@ -212,6 +212,77 @@ function AutoExpandReasoningRow({ value, onChange }: AutoExpandReasoningRowProps ); } +const TOOL_CALL_DETAIL_ROW_STYLE = [settingsStyles.row, settingsStyles.rowBorder]; +const TOOL_CALL_DETAIL_LEVELS: readonly AppSettings["toolCallDetailLevel"][] = [ + "overview", + "concise", + "detailed", +]; + +function getToolCallDetailLevelLabel( + t: TFunction, + value: AppSettings["toolCallDetailLevel"], +): string { + return t(`settings.general.toolCallDetail.options.${value}`); +} + +interface ToolCallDetailMenuItemProps { + value: AppSettings["toolCallDetailLevel"]; + selected: boolean; + onChange: (value: AppSettings["toolCallDetailLevel"]) => void; +} + +function ToolCallDetailMenuItem({ value, selected, onChange }: ToolCallDetailMenuItemProps) { + const { t } = useTranslation(); + const handleSelect = useCallback(() => onChange(value), [onChange, value]); + return ( + + {getToolCallDetailLevelLabel(t, value)} + + ); +} + +interface ToolCallDetailRowProps { + value: AppSettings["toolCallDetailLevel"]; + onChange: (value: AppSettings["toolCallDetailLevel"]) => void; +} + +function ToolCallDetailRow({ value, onChange }: ToolCallDetailRowProps) { + const { t } = useTranslation(); + const selectedLabel = getToolCallDetailLevelLabel(t, value); + return ( + + + {t("settings.general.toolCallDetail.label")} + + {t("settings.general.toolCallDetail.description")} + + + + + {selectedLabel} + + + + {TOOL_CALL_DETAIL_LEVELS.map((option) => ( + + ))} + + + + ); +} + // --------------------------------------------------------------------------- // Fonts: family text fields + numeric size fields (commit on blur/submit) // --------------------------------------------------------------------------- @@ -427,6 +498,13 @@ export function AppearanceSection() { [updateSettings], ); + const handleToolCallDetailLevelChange = useCallback( + (toolCallDetailLevel: AppSettings["toolCallDetailLevel"]) => { + void updateSettings({ toolCallDetailLevel }); + }, + [updateSettings], + ); + const commitUiFontFamily = useCallback( (value: string) => { const sanitized = sanitizeFontFamily(value); @@ -513,6 +591,10 @@ export function AppearanceSection() { value={settings.autoExpandReasoning} onChange={handleAutoExpandReasoningChange} /> + diff --git a/packages/app/src/tool-calls/grouping.test.ts b/packages/app/src/tool-calls/grouping.test.ts new file mode 100644 index 000000000..e88a28edc --- /dev/null +++ b/packages/app/src/tool-calls/grouping.test.ts @@ -0,0 +1,264 @@ +import { describe, expect, it } from "vitest"; +import type { ToolCallDetail } from "@getpaseo/protocol/agent-types"; +import type { StreamItem, ToolCallItem } from "@/types/stream"; +import { compactToolCallRuns } from "./grouping"; + +function toolCall( + id: string, + detail: ToolCallDetail, + options: { + name?: string; + status?: "running" | "completed" | "failed" | "canceled"; + } = {}, +): ToolCallItem { + return { + kind: "tool_call", + id, + timestamp: new Date(`2026-01-01T00:00:${id.padStart(2, "0")}.000Z`), + payload: { + source: "agent", + data: { + provider: "claude", + callId: id, + name: options.name ?? detail.type, + status: options.status ?? "completed", + error: options.status === "failed" ? "boom" : null, + detail, + }, + }, + }; +} + +function assistant(id: string): StreamItem { + return { + kind: "assistant_message", + id, + text: id, + timestamp: new Date("2026-01-01T00:01:00.000Z"), + }; +} + +describe("compactToolCallRuns", () => { + it("preserves the original arrays when compaction is disabled", () => { + const tail = [toolCall("1", { type: "shell", command: "one" })]; + const head = [toolCall("2", { type: "shell", command: "two" })]; + + const result = compactToolCallRuns({ tail, head, enabled: false }); + + expect(result.tail).toBe(tail); + expect(result.head).toBe(head); + expect(result.groupsByHostId.size).toBe(0); + }); + + it("replaces four contiguous calls with a stable first-call host and latest timestamp", () => { + const calls = [ + toolCall("1", { type: "shell", command: "one" }), + toolCall("2", { type: "shell", command: "two" }), + toolCall("3", { type: "read", filePath: "/repo/src/a.ts" }), + toolCall("4", { type: "edit", filePath: "/repo/src/a.ts" }), + ]; + + const result = compactToolCallRuns({ tail: calls, head: [], enabled: true }); + + expect(result.tail).toHaveLength(1); + expect(result.tail[0]).toMatchObject({ id: "1", timestamp: calls[3]?.timestamp }); + expect(result.head).toEqual([]); + expect(result.groupsByHostId.get("1")?.id).toBe("1"); + expect(result.groupsByHostId.get("1")?.calls).toEqual(calls); + expect(result.groupsByHostId.get("1")).toMatchObject({ + editedFileCount: 1, + commandCount: 2, + readFileCount: 1, + otherToolCount: 0, + categories: [ + { key: "shell", label: "Shell", callCount: 2, resources: [] }, + { key: "read", label: "Read", callCount: 1, resources: ["a.ts"] }, + { key: "edit", label: "Edit", callCount: 1, resources: ["a.ts"] }, + ], + }); + + const nextCall = toolCall("5", { type: "shell", command: "five" }, { status: "running" }); + const updated = compactToolCallRuns({ + tail: [...calls, nextCall], + head: [], + enabled: true, + }); + expect(updated.tail[0]).toMatchObject({ id: "1", timestamp: nextCall.timestamp }); + expect(updated.groupsByHostId.get("1")?.id).toBe("1"); + }); + + it("does not compact short runs and stops at visible content boundaries", () => { + const firstRun = [ + toolCall("1", { type: "shell", command: "one" }), + toolCall("2", { type: "shell", command: "two" }), + toolCall("3", { type: "shell", command: "three" }), + ]; + const boundary = assistant("assistant"); + const secondRun = [ + toolCall("4", { type: "read", filePath: "/repo/a.ts" }), + toolCall("5", { type: "read", filePath: "/repo/b.ts" }), + toolCall("6", { type: "read", filePath: "/repo/c.ts" }), + toolCall("7", { type: "read", filePath: "/repo/d.ts" }), + ]; + + const result = compactToolCallRuns({ + tail: [...firstRun, boundary, ...secondRun], + head: [], + enabled: true, + }); + + expect(result.tail.slice(0, -1)).toEqual([...firstRun, boundary]); + expect(result.tail.at(-1)).toMatchObject({ id: "4", timestamp: secondRun[3]?.timestamp }); + expect([...result.groupsByHostId.keys()]).toEqual(["4"]); + }); + + it("forms one group across the history and live-head boundary", () => { + const tail = [ + assistant("assistant"), + toolCall("1", { type: "shell", command: "one" }), + toolCall("2", { type: "shell", command: "two" }), + ]; + const head = [ + toolCall("3", { type: "read", filePath: "/repo/a.ts" }), + toolCall("4", { type: "edit", filePath: "/repo/a.ts" }, { status: "running" }), + ]; + + const result = compactToolCallRuns({ tail, head, enabled: true }); + + expect(result.tail).toEqual([tail[0]]); + expect(result.head).toHaveLength(1); + expect(result.head[0]).toMatchObject({ id: "1", timestamp: head[1]?.timestamp }); + expect(result.groupsByHostId.get("1")?.calls).toEqual([...tail.slice(1), ...head]); + expect(result.groupsByHostId.get("1")?.isRunning).toBe(true); + }); + + it("separates reads and searches from other tools", () => { + const calls = [ + toolCall("1", { type: "read", filePath: "/repo/src/a.ts" }), + toolCall("2", { type: "read", filePath: "C:\\repo\\src\\beta.ts" }), + toolCall("3", { type: "fetch", url: "https://github.com/org/repo" }), + toolCall( + "4", + { type: "search", query: "paseo", toolName: "web_search" }, + { status: "failed" }, + ), + toolCall("5", { type: "fetch", url: "not a url" }), + ]; + + const result = compactToolCallRuns({ tail: calls, head: [], enabled: true }); + const group = result.groupsByHostId.get("1"); + + expect(group).toMatchObject({ + failedCount: 1, + isRunning: false, + editedFileCount: 0, + commandCount: 0, + readFileCount: 2, + searchCount: 1, + otherToolCount: 2, + }); + }); + + it("counts unique edited files while counting each shell command", () => { + const calls = [ + toolCall("1", { type: "edit", filePath: "/repo/a.ts" }), + toolCall("2", { type: "edit", filePath: "/repo/a.ts" }), + toolCall("3", { type: "write", filePath: "/repo/b.ts" }), + toolCall("4", { type: "shell", command: "npm test" }), + toolCall("5", { type: "shell", command: "npm run lint" }), + toolCall("6", { type: "read", filePath: "/repo/c.ts" }), + ]; + + const result = compactToolCallRuns({ tail: calls, head: [], enabled: true }); + + expect(result.groupsByHostId.get("1")).toMatchObject({ + editedFileCount: 2, + commandCount: 2, + readFileCount: 1, + otherToolCount: 0, + }); + }); + + it("counts Paseo calls separately from other tools", () => { + const calls = [ + toolCall("1", { type: "unknown", input: null, output: null }, { name: "paseo.list_agents" }), + toolCall( + "2", + { type: "unknown", input: null, output: null }, + { name: "mcp__paseo__list_worktrees" }, + ), + toolCall("3", { type: "fetch", url: "https://paseo.sh" }), + toolCall("4", { type: "fetch", url: "https://github.com/getpaseo" }), + ]; + + const result = compactToolCallRuns({ tail: calls, head: [], enabled: true }); + + expect(result.groupsByHostId.get("1")).toMatchObject({ + otherToolCount: 2, + paseoCallCount: 2, + }); + }); + + it("classifies direct Brave and Paseo runtime tool names", () => { + const unknownDetail = { type: "unknown" as const, input: null, output: null }; + const calls = [ + toolCall("1", unknownDetail, { name: "brave-search_brave_web_search" }), + toolCall("2", unknownDetail, { name: "brave-search_brave_llm_context" }), + toolCall("3", unknownDetail, { name: "paseo_list_providers" }), + toolCall("4", unknownDetail, { name: "paseo_list_worktrees" }), + toolCall("5", unknownDetail, { name: "paseo_list_worktrees" }), + toolCall("6", unknownDetail, { name: "mcp__exa__web_search" }), + ]; + + const result = compactToolCallRuns({ tail: calls, head: [], enabled: true }); + + expect(result.groupsByHostId.get("1")).toMatchObject({ + searchCount: 3, + otherToolCount: 0, + paseoCallCount: 3, + }); + }); + + it("keeps plan and spoken-message calls outside compact groups", () => { + const shellCalls = ["1", "2", "3", "4"].map((id) => + toolCall(id, { type: "shell", command: id }), + ); + const plan = toolCall("5", { type: "plan", text: "Plan" }); + const speak = toolCall( + "6", + { type: "unknown", input: "Hello", output: null }, + { name: "speak" }, + ); + + const result = compactToolCallRuns({ + tail: [...shellCalls, plan, speak], + head: [], + enabled: true, + }); + + expect(result.tail[0]).toMatchObject({ id: "1", timestamp: shellCalls[3]?.timestamp }); + expect(result.tail.slice(1)).toEqual([plan, speak]); + expect(result.groupsByHostId.get("1")?.calls).toEqual(shellCalls); + }); + + it("deduplicates file resources by full path while displaying basenames", () => { + const calls = [ + toolCall("1", { type: "read", filePath: "/repo/src/index.ts" }), + toolCall("2", { type: "read", filePath: "/repo/tests/index.ts" }), + toolCall("3", { type: "read", filePath: "/repo/src/other.ts" }), + toolCall("4", { type: "read", filePath: "/repo/src/index.ts" }), + ]; + + const result = compactToolCallRuns({ tail: calls, head: [], enabled: true }); + + expect(result.groupsByHostId.get("1")).toMatchObject({ + readFileCount: 3, + categories: [ + { + key: "read", + resources: ["index.ts", "index.ts", "other.ts"], + }, + ], + }); + }); +}); diff --git a/packages/app/src/tool-calls/grouping.ts b/packages/app/src/tool-calls/grouping.ts new file mode 100644 index 000000000..f8fce5976 --- /dev/null +++ b/packages/app/src/tool-calls/grouping.ts @@ -0,0 +1,325 @@ +import type { ToolCallDetail } from "@getpaseo/protocol/agent-types"; +import { isPaseoToolName } from "@getpaseo/protocol/tool-name-normalization"; +import { getFileNameFromPath } from "@/attachments/utils"; +import type { StreamItem, ToolCallItem } from "@/types/stream"; +import { buildToolCallDisplayModel } from "@/utils/tool-call-display"; +import { resolveToolCallIconName, type ToolCallIcon } from "@/utils/tool-call-icon-name"; + +export const MIN_COMPACT_TOOL_CALLS = 4; + +const DIRECT_PASEO_TOOL_PREFIX = "paseo_"; +const DIRECT_SEARCH_TOOL_SUFFIX_PATTERN = /(?:^|[_.:/])(?:web_search|llm_context)$/; + +export interface ToolCallCategorySummary { + key: string; + label: string; + iconName: ToolCallIcon; + callCount: number; + failedCount: number; + runningCount: number; + resources: string[]; +} + +export interface CompactToolCallGroup { + id: string; + calls: ToolCallItem[]; + callCount: number; + failedCount: number; + isRunning: boolean; + editedFileCount: number; + commandCount: number; + readFileCount: number; + searchCount: number; + otherToolCount: number; + paseoCallCount: number; + categories: ToolCallCategorySummary[]; +} + +export interface CompactToolCallRunsResult { + tail: StreamItem[]; + head: StreamItem[]; + groupsByHostId: Map; +} + +interface CompactToolCallRunsInput { + tail: StreamItem[]; + head: StreamItem[]; + enabled: boolean; +} + +interface TaggedStreamItem { + segment: "tail" | "head"; + item: StreamItem; +} + +interface ToolCallDescriptor { + detail: ToolCallDetail; + name: string; + status: "running" | "completed" | "failed" | "canceled"; + error: unknown; + metadata?: Record; +} + +interface ResourceSummary { + key: string; + label: string; +} + +function describeToolCall(item: ToolCallItem): ToolCallDescriptor { + if (item.payload.source === "agent") { + const { data } = item.payload; + return { + detail: data.detail, + name: data.name, + status: data.status, + error: data.error, + metadata: data.metadata, + }; + } + + const { data } = item.payload; + return { + detail: { + type: "unknown", + input: data.arguments ?? null, + output: data.result ?? null, + }, + name: data.toolName, + status: data.status === "executing" ? "running" : data.status, + error: data.error, + }; +} + +function isCompactableToolCall(item: StreamItem): item is ToolCallItem { + if (item.kind !== "tool_call") { + return false; + } + const descriptor = describeToolCall(item); + return descriptor.detail.type !== "plan" && descriptor.name.trim().toLowerCase() !== "speak"; +} + +function isDirectPaseoToolName(name: string): boolean { + return name.startsWith(DIRECT_PASEO_TOOL_PREFIX); +} + +function isDirectSearchToolName(name: string): boolean { + return DIRECT_SEARCH_TOOL_SUFFIX_PATTERN.test(name); +} + +function resourceForDetail(detail: ToolCallDetail): ResourceSummary | null { + if (detail.type === "read" || detail.type === "edit" || detail.type === "write") { + return { + key: detail.filePath, + label: getFileNameFromPath(detail.filePath) ?? detail.filePath, + }; + } + if (detail.type !== "fetch") { + return null; + } + try { + const hostname = new URL(detail.url).hostname || detail.url; + return { key: hostname, label: hostname }; + } catch { + return { key: detail.url, label: detail.url }; + } +} + +function categoryIdentity(input: { + descriptor: ToolCallDescriptor; + normalizedName: string; + displayName: string; +}): { key: string; label: string; iconName: ToolCallIcon } { + if (isPaseoToolName(input.descriptor.name) || isDirectPaseoToolName(input.normalizedName)) { + return { key: "paseo", label: "Paseo", iconName: "paseo" }; + } + if ( + (input.descriptor.detail.type === "search" && + input.descriptor.detail.toolName === "web_search") || + isDirectSearchToolName(input.normalizedName) + ) { + return { key: "web_search", label: "Web search", iconName: "search" }; + } + if (input.descriptor.detail.type === "fetch") { + return { key: "fetch", label: "Web fetch", iconName: "search" }; + } + if (input.descriptor.detail.type !== "unknown" && input.descriptor.detail.type !== "plain_text") { + return { + key: input.descriptor.detail.type, + label: input.displayName, + iconName: resolveToolCallIconName(input.descriptor.name, input.descriptor.detail), + }; + } + return { + key: `tool:${input.displayName.toLowerCase()}`, + label: input.displayName, + iconName: resolveToolCallIconName(input.descriptor.name, input.descriptor.detail), + }; +} + +function buildCompactToolCallGroup(calls: ToolCallItem[]) { + const editedFiles = new Set(); + const readFiles = new Set(); + const categories = new Map(); + const categoryResourceKeys = new Map>(); + let failedCount = 0; + let isRunning = false; + let commandCount = 0; + let searchCount = 0; + let otherToolCount = 0; + let paseoCallCount = 0; + + for (const call of calls) { + const descriptor = describeToolCall(call); + const isFailed = descriptor.status === "failed"; + const isCallRunning = descriptor.status === "running"; + failedCount += isFailed ? 1 : 0; + isRunning ||= isCallRunning; + const normalizedName = descriptor.name.trim().toLowerCase(); + const display = buildToolCallDisplayModel({ + name: descriptor.name, + status: descriptor.status, + error: descriptor.error, + detail: descriptor.detail, + metadata: descriptor.metadata, + }); + const identity = categoryIdentity({ + descriptor, + normalizedName, + displayName: display.displayName, + }); + let category = categories.get(identity.key); + if (!category) { + category = { + ...identity, + callCount: 0, + failedCount: 0, + runningCount: 0, + resources: [], + }; + categories.set(identity.key, category); + } + category.callCount += 1; + category.failedCount += isFailed ? 1 : 0; + category.runningCount += isCallRunning ? 1 : 0; + const resource = resourceForDetail(descriptor.detail); + if (resource) { + let resourceKeys = categoryResourceKeys.get(identity.key); + if (!resourceKeys) { + resourceKeys = new Set(); + categoryResourceKeys.set(identity.key, resourceKeys); + } + if (!resourceKeys.has(resource.key)) { + resourceKeys.add(resource.key); + category.resources.push(resource.label); + } + } + + if (isPaseoToolName(descriptor.name) || isDirectPaseoToolName(normalizedName)) { + paseoCallCount += 1; + continue; + } + if (descriptor.detail.type === "edit" || descriptor.detail.type === "write") { + editedFiles.add(descriptor.detail.filePath); + continue; + } + if (descriptor.detail.type === "shell") { + commandCount += 1; + continue; + } + if (descriptor.detail.type === "read") { + readFiles.add(descriptor.detail.filePath); + continue; + } + if (descriptor.detail.type === "search" || isDirectSearchToolName(normalizedName)) { + searchCount += 1; + continue; + } + otherToolCount += 1; + } + + const firstCall = calls[0]; + if (!firstCall) { + throw new Error("Cannot build an empty tool call group"); + } + return { + id: firstCall.id, + calls, + callCount: calls.length, + failedCount, + isRunning, + editedFileCount: editedFiles.size, + commandCount, + readFileCount: readFiles.size, + searchCount, + otherToolCount, + paseoCallCount, + categories: [...categories.values()], + } satisfies CompactToolCallGroup; +} + +export function compactToolCallRuns(input: CompactToolCallRunsInput): CompactToolCallRunsResult { + if (!input.enabled) { + return { + tail: input.tail, + head: input.head, + groupsByHostId: new Map(), + }; + } + + const taggedItems: TaggedStreamItem[] = [ + ...input.tail.map((item) => ({ segment: "tail" as const, item })), + ...input.head.map((item) => ({ segment: "head" as const, item })), + ]; + const compactedTail: StreamItem[] = []; + const compactedHead: StreamItem[] = []; + const groupsByHostId = new Map(); + let pendingRun: TaggedStreamItem[] = []; + + const append = ({ segment, item }: TaggedStreamItem) => { + (segment === "tail" ? compactedTail : compactedHead).push(item); + }; + const flushRun = () => { + if (pendingRun.length >= MIN_COMPACT_TOOL_CALLS) { + const first = pendingRun[0]; + const latest = pendingRun.at(-1); + if (!first || !latest) { + throw new Error("Cannot compact an empty tool call run"); + } + const calls = pendingRun.map(({ item }) => item as ToolCallItem); + const stableHost: TaggedStreamItem = { + segment: latest.segment, + item: { ...latest.item, id: first.item.id }, + }; + append(stableHost); + groupsByHostId.set(stableHost.item.id, buildCompactToolCallGroup(calls)); + } else { + for (const entry of pendingRun) { + append(entry); + } + } + pendingRun = []; + }; + + for (const entry of taggedItems) { + if (isCompactableToolCall(entry.item)) { + pendingRun.push(entry); + continue; + } + flushRun(); + append(entry); + } + flushRun(); + + if (groupsByHostId.size === 0) { + return { + tail: input.tail, + head: input.head, + groupsByHostId, + }; + } + return { + tail: compactedTail, + head: compactedHead, + groupsByHostId, + }; +}