mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-14 13:22:37 +00:00
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:
@@ -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,
|
||||
},
|
||||
}));
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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: "لغة التطبيق",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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: "アプリの言語",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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: "Язык приложения",
|
||||
|
||||
@@ -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"],
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user