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
This commit is contained in:
Matt Cowger
2026-07-14 18:55:06 +08:00
committed by GitHub
parent 3b078240a8
commit 81113d1582
16 changed files with 1488 additions and 13 deletions
+66 -7
View File
@@ -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<AgentStreamViewHandle, AgentStreamVi
const { t } = useTranslation();
const router = useRouter();
const autoExpandReasoning = useSettings((settings) => settings.autoExpandReasoning);
const toolCallDetailLevel = useSettings((settings) => settings.toolCallDetailLevel);
const viewportRef = useRef<StreamViewportHandle | null>(null);
const isMobile = useIsCompactFormFactor();
const streamRenderStrategy = useMemo(
@@ -343,6 +346,9 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
const [expandedInlineToolCallIds, setExpandedInlineToolCallIds] = useState<Set<string>>(
new Set(),
);
const [expandedToolCallGroupIds, setExpandedToolCallGroupIds] = useState<Set<string>>(
new Set(),
);
const openFileExplorerForCheckout = usePanelStore((state) => state.openFileExplorerForCheckout);
const setExplorerTabForCheckout = usePanelStore((state) => state.setExplorerTabForCheckout);
@@ -391,6 +397,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
useEffect(() => {
setIsNearBottom(true);
setExpandedInlineToolCallIds(new Set());
setExpandedToolCallGroupIds(new Set());
}, [agentId]);
const handleInlinePathPress = useStableEvent(
@@ -537,16 +544,25 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
}
const effectiveStreamItems = isActive ? streamItems : frozenStreamItemsRef.current;
const effectiveStreamHead = isActive ? streamHead : frozenStreamHeadRef.current;
const compactedToolCalls = useMemo(
() =>
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<AgentStreamViewHandle, AgentStreamVi
[streamRenderStrategy],
);
const setToolCallGroupExpanded = useCallback((groupId: string, expanded: boolean) => {
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<StreamItem, { kind: "user_message" }>) => {
return (
@@ -662,8 +690,8 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
[autoExpandReasoning, setInlineDetailsExpanded],
);
const renderToolCallItem = useCallback(
(layoutItem: StreamLayoutItem, item: Extract<StreamItem, { kind: "tool_call" }>) => {
const renderSingleToolCallItem = useCallback(
(item: Extract<StreamItem, { kind: "tool_call" }>, isLastInSequence: boolean) => {
const { payload } = item;
if (payload.source === "agent") {
@@ -690,7 +718,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
detail={data.detail}
cwd={context.cwd}
metadata={data.metadata}
isLastInSequence={layoutItem.isLastInToolSequence}
isLastInSequence={isLastInSequence}
onOpenFilePath={handleToolCallOpenFile}
/>
);
@@ -705,7 +733,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
args={data.arguments}
result={data.result}
status={data.status}
isLastInSequence={layoutItem.isLastInToolSequence}
isLastInSequence={isLastInSequence}
onOpenFilePath={handleToolCallOpenFile}
/>
);
@@ -713,6 +741,37 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
[context.cwd, setInlineDetailsExpanded, handleToolCallOpenFile],
);
const renderToolCallItem = useCallback(
(layoutItem: StreamLayoutItem, item: Extract<StreamItem, { kind: "tool_call" }>) => {
const group = compactedToolCalls.groupsByHostId.get(item.id);
if (!group) {
return renderSingleToolCallItem(item, layoutItem.isLastInToolSequence);
}
const expanded = expandedToolCallGroupIds.has(group.id);
return (
<ToolCallGroup
group={group}
presentation={toolCallDetailLevel === "concise" ? "concise" : "overview"}
expanded={expanded}
onExpandedChange={setToolCallGroupExpanded}
>
{group.calls.map((call, index) => (
<React.Fragment key={call.id}>
{renderSingleToolCallItem(call, index === group.calls.length - 1)}
</React.Fragment>
))}
</ToolCallGroup>
);
},
[
compactedToolCalls.groupsByHostId,
expandedToolCallGroupIds,
renderSingleToolCallItem,
setToolCallGroupExpanded,
toolCallDetailLevel,
],
);
const renderStreamItemContent = useCallback(
(layoutItem: StreamLayoutItem) => {
const item = layoutItem.item;
@@ -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 (
<View style={styles.categoryStatus}>
<TriangleAlert size={12} color={styles.error.color} />
<Text style={styles.error}>
{t("toolCallGroup.failed", { count: category.failedCount })}
</Text>
</View>
);
}
if (category.runningCount > 0) {
return <ActivityIndicator size={12} color={styles.muted.color} />;
}
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 (
<View style={styles.categoryRow}>
<View style={styles.categoryIcon}>
<Icon size={12} color={styles.muted.color} />
</View>
<Text style={styles.categoryCount}>×{category.callCount}</Text>
<Text style={styles.categoryLabel}>{category.label}</Text>
<CategoryStatus category={category} />
{resourceText ? (
<Text style={styles.resources} numberOfLines={1}>
{resourceText}
</Text>
) : null}
</View>
);
}
function GroupHeaderIcon({
group,
compact,
}: {
group: CompactToolCallGroupModel;
compact: boolean;
}) {
const size = compact ? 11 : 12;
if (group.failedCount > 0) {
return <TriangleAlert size={size} color={styles.error.color} />;
}
if (group.isRunning) {
return <ActivityIndicator size={size} color={styles.foreground.color} />;
}
return <Wrench size={size} color={styles.muted.color} />;
}
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 (
<View style={styles.container} testID="tool-call-group">
<Pressable
onPress={handlePress}
accessibilityRole="button"
accessibilityState={accessibilityState}
accessibilityLabel={accessibilityLabel}
style={headerStyle}
>
<View style={styles.headerIcon}>
<GroupHeaderIcon group={group} compact={isOverview} />
</View>
{isOverview ? (
<Text style={styles.summary} numberOfLines={1}>
{summary}
</Text>
) : (
<>
<Text style={styles.conciseTitle}>{t("toolCallGroup.title")}</Text>
<Text style={styles.conciseCallCount}>×{group.callCount}</Text>
</>
)}
{group.failedCount > 0 ? (
<Text style={styles.error}>
{t("toolCallGroup.failed", { count: group.failedCount })}
</Text>
) : null}
<ChevronRight
size={isOverview ? 12 : 14}
color={styles.muted.color}
style={expanded ? styles.chevronExpanded : undefined}
/>
</Pressable>
{expanded ? <View style={styles.expandedCalls}>{children}</View> : null}
{!expanded && !isOverview ? (
<View style={styles.categories}>
{group.categories.map((category) => (
<CategoryRow key={category.key} category={category} resourceLimit={resourceLimit} />
))}
</View>
) : null}
</View>
);
});
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,
},
}));
+7 -2
View File
@@ -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<TSelected>(
if (updates.autoExpandReasoning !== undefined) {
appUpdates.autoExpandReasoning = updates.autoExpandReasoning;
}
if (updates.toolCallDetailLevel !== undefined) {
appUpdates.toolCallDetailLevel = updates.toolCallDetailLevel;
}
const promises: Promise<void>[] = [];
if (Object.keys(appUpdates).length > 0) {
promises.push(appSettings.updateSettings(appUpdates));
@@ -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 () => {
+40 -4
View File
@@ -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<string>([...Object.keys(THEME_TO_UNISTYLES), "auto"]);
const VALID_SERVICE_URL_BEHAVIORS = new Set<ServiceUrlBehavior>(["ask", "in-app", "external"]);
const VALID_WORKSPACE_TITLE_SOURCES = new Set<WorkspaceTitleSource>(["title", "branch"]);
const VALID_TOOL_CALL_DETAIL_LEVELS = new Set<ToolCallDetailLevel>([
"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<AppSettings> & { 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<AppSettings>;
deps: SettingsDeps;
}): Promise<void> {
const current =
const storedCurrent =
input.queryClient.getQueryData<AppSettings>(APP_SETTINGS_QUERY_KEY) ??
(await loadAppSettingsFromStorage(input.deps));
const current = normalizeAppSettings(storedCurrent);
const next = { ...current, ...input.updates };
input.queryClient.setQueryData<AppSettings>(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<Ap
try {
const stored = await deps.storage.getItem(APP_SETTINGS_KEY);
if (stored) {
const parsed = JSON.parse(stored) as Partial<AppSettings>;
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<Setti
};
}
function pickAppSettings(stored: Partial<AppSettings>): Partial<AppSettings> {
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<AppSettings> {
const result: Partial<AppSettings> = {};
if (typeof stored.theme === "string" && VALID_THEMES.has(stored.theme)) {
result.theme = stored.theme;
@@ -209,6 +241,10 @@ function pickAppSettings(stored: Partial<AppSettings>): Partial<AppSettings> {
if (typeof stored.autoExpandReasoning === "boolean") {
result.autoExpandReasoning = stored.autoExpandReasoning;
}
const toolCallDetailLevel = parseToolCallDetailLevel(stored);
if (toolCallDetailLevel !== null) {
result.toolCallDetailLevel = toolCallDetailLevel;
}
return result;
}
+40
View File
@@ -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: "لغة التطبيق",
+40
View File
@@ -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",
+40
View File
@@ -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",
+40
View File
@@ -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",
+40
View File
@@ -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: "アプリの言語",
+40
View File
@@ -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",
+40
View File
@@ -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: "Язык приложения",
+40
View File
@@ -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: "应用语言",
@@ -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 (
<DropdownMenuItem selected={selected} onSelect={handleSelect}>
{getToolCallDetailLevelLabel(t, value)}
</DropdownMenuItem>
);
}
interface ToolCallDetailRowProps {
value: AppSettings["toolCallDetailLevel"];
onChange: (value: AppSettings["toolCallDetailLevel"]) => void;
}
function ToolCallDetailRow({ value, onChange }: ToolCallDetailRowProps) {
const { t } = useTranslation();
const selectedLabel = getToolCallDetailLevelLabel(t, value);
return (
<View style={TOOL_CALL_DETAIL_ROW_STYLE}>
<View style={settingsStyles.rowContent}>
<Text style={settingsStyles.rowTitle}>{t("settings.general.toolCallDetail.label")}</Text>
<Text style={settingsStyles.rowHint}>
{t("settings.general.toolCallDetail.description")}
</Text>
</View>
<DropdownMenu>
<DropdownMenuTrigger
style={dropdownTriggerStyle}
accessibilityLabel={t("settings.general.toolCallDetail.accessibilityLabel", {
value: selectedLabel,
})}
>
<Text style={styles.triggerText}>{selectedLabel}</Text>
<ThemedChevronDown size={ICON_SIZE.sm} uniProps={mutedColorMapping} />
</DropdownMenuTrigger>
<DropdownMenuContent side="bottom" align="end" width={200}>
{TOOL_CALL_DETAIL_LEVELS.map((option) => (
<ToolCallDetailMenuItem
key={option}
value={option}
selected={value === option}
onChange={onChange}
/>
))}
</DropdownMenuContent>
</DropdownMenu>
</View>
);
}
// ---------------------------------------------------------------------------
// 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}
/>
<ToolCallDetailRow
value={settings.toolCallDetailLevel}
onChange={handleToolCallDetailLevelChange}
/>
</View>
</SettingsSection>
<SettingsSection title={t("settings.appearance.fonts.title")}>
@@ -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"],
},
],
});
});
});
+325
View File
@@ -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<string, CompactToolCallGroup>;
}
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<string, unknown>;
}
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<string>();
const readFiles = new Set<string>();
const categories = new Map<string, ToolCallCategorySummary>();
const categoryResourceKeys = new Map<string, Set<string>>();
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<string, CompactToolCallGroup>();
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,
};
}