From 4f3b2317ab80722d3a696562b7d028f6053af3ee Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Wed, 7 Jan 2026 10:30:29 +0700 Subject: [PATCH] wip: tool call structure investigation and e2e tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add debug logging for tool call payloads in agent-stream-view - Add e2e tests to verify tool call structure for Claude and Codex agents - Include sidebar toggle and horizontal scroll improvements from previous work 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- packages/app/src/app/_layout.tsx | 43 ++++- .../src/app/agent/[serverId]/[agentId].tsx | 31 ++-- packages/app/src/components/agent-list.tsx | 5 +- .../app/src/components/agent-stream-view.tsx | 2 + .../app/src/components/explorer-sidebar.tsx | 31 ++-- packages/app/src/components/git-diff-pane.tsx | 95 +++++++--- .../src/components/headers/menu-header.tsx | 15 +- .../src/components/headers/screen-header.tsx | 18 +- packages/app/src/constants/layout.ts | 5 + .../contexts/horizontal-scroll-context.tsx | 68 ++++++++ packages/server/src/server/daemon.e2e.test.ts | 165 ++++++++++++++++++ 11 files changed, 398 insertions(+), 80 deletions(-) create mode 100644 packages/app/src/contexts/horizontal-scroll-context.tsx diff --git a/packages/app/src/app/_layout.tsx b/packages/app/src/app/_layout.tsx index 10616faf7..d623ac4a5 100644 --- a/packages/app/src/app/_layout.tsx +++ b/packages/app/src/app/_layout.tsx @@ -15,11 +15,15 @@ import { useState, useEffect, type ReactNode, useMemo } from "react"; import { Platform } from "react-native"; import { SlidingSidebar } from "@/components/sliding-sidebar"; import { useSidebarStore } from "@/stores/sidebar-store"; -import { runOnJS, interpolate, Extrapolation } from "react-native-reanimated"; +import { runOnJS, interpolate, Extrapolation, useSharedValue } from "react-native-reanimated"; import { SidebarAnimationProvider, useSidebarAnimation, } from "@/contexts/sidebar-animation-context"; +import { + HorizontalScrollProvider, + useHorizontalScrollOptional, +} from "@/contexts/horizontal-scroll-context"; function QueryProvider({ children }: { children: ReactNode }) { const [queryClient] = useState( @@ -48,6 +52,7 @@ interface AppContainerProps { function AppContainer({ children, selectedAgentId }: AppContainerProps) { const { theme } = useUnistyles(); const { isOpen, open, toggle } = useSidebarStore(); + const horizontalScroll = useHorizontalScrollOptional(); // Cmd+B to toggle sidebar (web only) useEffect(() => { @@ -72,15 +77,41 @@ function AppContainer({ children, selectedAgentId }: AppContainerProps) { const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm"; + // Track initial touch position for manual activation + const touchStartX = useSharedValue(0); + // Open gesture: swipe right from anywhere to open sidebar (interactive drag) + // If any horizontal scroll is scrolled right, let the scroll view handle the gesture first const openGesture = useMemo( () => Gesture.Pan() .enabled(isMobile && !isOpen) - // Only activate after 15px horizontal movement to the right - .activeOffsetX(15) + .manualActivation(true) // Fail if 10px vertical movement happens first (allow vertical scroll) .failOffsetY([-10, 10]) + .onTouchesDown((event) => { + const touch = event.changedTouches[0]; + if (touch) { + touchStartX.value = touch.absoluteX; + } + }) + .onTouchesMove((event, stateManager) => { + const touch = event.changedTouches[0]; + if (!touch || event.numberOfTouches !== 1) return; + + const deltaX = touch.absoluteX - touchStartX.value; + + // If horizontal scroll is scrolled right, fail so ScrollView handles it + if (horizontalScroll?.isAnyScrolledRight.value) { + stateManager.fail(); + return; + } + + // Activate after 15px rightward movement + if (deltaX > 15) { + stateManager.activate(); + } + }) .onStart(() => { isGesturing.value = true; }) @@ -109,7 +140,7 @@ function AppContainer({ children, selectedAgentId }: AppContainerProps) { .onFinalize(() => { isGesturing.value = false; }), - [isMobile, isOpen, windowWidth, translateX, backdropOpacity, animateToOpen, animateToClose, open, isGesturing] + [isMobile, isOpen, windowWidth, translateX, backdropOpacity, animateToOpen, animateToClose, open, isGesturing, horizontalScroll?.isAnyScrolledRight, touchStartX] ); const content = ( @@ -221,7 +252,8 @@ export default function RootLayout() { - + + + diff --git a/packages/app/src/app/agent/[serverId]/[agentId].tsx b/packages/app/src/app/agent/[serverId]/[agentId].tsx index 12fd89229..55905f1cb 100644 --- a/packages/app/src/app/agent/[serverId]/[agentId].tsx +++ b/packages/app/src/app/agent/[serverId]/[agentId].tsx @@ -33,7 +33,7 @@ import { Users, ChevronRight, PlusIcon, - PanelRightOpen, + PanelRight, } from "lucide-react-native"; import { MenuHeader } from "@/components/headers/menu-header"; import { BackHeader } from "@/components/headers/back-header"; @@ -679,18 +679,29 @@ function AgentScreenContent({ rightContent={ - + {isMobile ? ( + + ) : ( + + )} - + diff --git a/packages/app/src/components/agent-list.tsx b/packages/app/src/components/agent-list.tsx index 04cd621c5..1f54be7b8 100644 --- a/packages/app/src/components/agent-list.tsx +++ b/packages/app/src/components/agent-list.tsx @@ -1,5 +1,4 @@ -import { View, Text, Pressable, Modal, RefreshControl, type ListRenderItem } from "react-native"; -import { FlatList } from "react-native-gesture-handler"; +import { View, Text, Pressable, Modal, RefreshControl, FlatList, type ListRenderItem } from "react-native"; import { useCallback, useState, type ReactElement } from "react"; import { router, usePathname } from "expo-router"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; @@ -222,7 +221,7 @@ const styles = StyleSheet.create((theme) => ({ }, listContent: { paddingHorizontal: theme.spacing[4], - paddingTop: theme.spacing[4], + paddingTop: theme.spacing[2], paddingBottom: theme.spacing[4], }, agentItem: { diff --git a/packages/app/src/components/agent-stream-view.tsx b/packages/app/src/components/agent-stream-view.tsx index 0d5aff7f9..09c6aa693 100644 --- a/packages/app/src/components/agent-stream-view.tsx +++ b/packages/app/src/components/agent-stream-view.tsx @@ -283,6 +283,8 @@ export function AgentStreamView({ case "tool_call": { const { payload } = item; + console.log("[TOOL_CALL_DEBUG]", JSON.stringify(payload, null, 2)); + if (payload.source === "agent") { const data = payload.data; const toolLabel = data.displayName ?? `${data.server}/${data.tool}`; diff --git a/packages/app/src/components/explorer-sidebar.tsx b/packages/app/src/components/explorer-sidebar.tsx index f5e6aca05..e1b9cf6e1 100644 --- a/packages/app/src/components/explorer-sidebar.tsx +++ b/packages/app/src/components/explorer-sidebar.tsx @@ -16,6 +16,7 @@ import { type ViewMode, } from "@/stores/explorer-sidebar-store"; import { useExplorerSidebarAnimation } from "@/contexts/explorer-sidebar-animation-context"; +import { HEADER_INNER_HEIGHT } from "@/constants/layout"; import { GitDiffPane } from "./git-diff-pane"; import { FileExplorerPane } from "./file-explorer-pane"; @@ -187,16 +188,14 @@ export function ExplorerSidebar({ serverId, agentId }: ExplorerSidebarProps) { return ( - {/* Resize handle on left edge */} + {/* Resize handle - absolutely positioned over left border */} - - + /> {/* Header with tabs and close button */} - + {/* Content based on active tab */} - + {activeTab === "changes" ? ( ) : ( @@ -355,22 +354,18 @@ const styles = StyleSheet.create((theme) => ({ overflow: "hidden", }, desktopSidebar: { - flexDirection: "row", + position: "relative", borderLeftWidth: 1, borderLeftColor: theme.colors.border, backgroundColor: theme.colors.background, }, resizeHandle: { - width: 8, - alignItems: "center", - justifyContent: "center", - }, - resizeHandleInner: { - width: 2, - height: 32, - backgroundColor: theme.colors.border, - borderRadius: 1, - opacity: 0.5, + position: "absolute", + left: -5, + top: 0, + bottom: 0, + width: 10, + zIndex: 10, }, sidebarContent: { flex: 1, @@ -378,11 +373,11 @@ const styles = StyleSheet.create((theme) => ({ overflow: "hidden", }, header: { + height: HEADER_INNER_HEIGHT, flexDirection: "row", alignItems: "center", justifyContent: "space-between", paddingHorizontal: theme.spacing[2], - paddingVertical: theme.spacing[1], borderBottomWidth: 1, borderBottomColor: theme.colors.border, }, diff --git a/packages/app/src/components/git-diff-pane.tsx b/packages/app/src/components/git-diff-pane.tsx index 6ebf8ef08..872703aec 100644 --- a/packages/app/src/components/git-diff-pane.tsx +++ b/packages/app/src/components/git-diff-pane.tsx @@ -1,5 +1,13 @@ -import { useState, useCallback } from "react"; -import { View, Text, ActivityIndicator, Pressable, RefreshControl } from "react-native"; +import { useState, useCallback, useEffect, useId } from "react"; +import { + View, + Text, + ActivityIndicator, + Pressable, + RefreshControl, + type NativeSyntheticEvent, + type NativeScrollEvent, +} from "react-native"; import { ScrollView } from "react-native-gesture-handler"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { ChevronRight } from "lucide-react-native"; @@ -10,6 +18,7 @@ import { type DiffLine, type HighlightToken, } from "@/hooks/use-highlighted-diff-query"; +import { useHorizontalScrollOptional } from "@/contexts/horizontal-scroll-context"; type HighlightStyle = NonNullable; @@ -70,6 +79,7 @@ function HighlightedText({ tokens, lineType }: HighlightedTextProps) { interface DiffFileSectionProps { file: ParsedDiffFile; defaultExpanded?: boolean; + testID?: string; } function DiffLineView({ line }: { line: DiffLine }) { @@ -106,16 +116,37 @@ function DiffLineView({ line }: { line: DiffLine }) { ); } -function DiffFileSection({ file, defaultExpanded = true }: DiffFileSectionProps) { +function DiffFileSection({ file, defaultExpanded = true, testID }: DiffFileSectionProps) { const { theme } = useUnistyles(); const [isExpanded, setIsExpanded] = useState(defaultExpanded); + const horizontalScroll = useHorizontalScrollOptional(); + const scrollId = useId(); const toggleExpanded = useCallback(() => { setIsExpanded((prev) => !prev); }, []); + // Register/unregister scroll offset tracking + useEffect(() => { + if (!horizontalScroll || !isExpanded) return; + // Start at 0 (not scrolled) + horizontalScroll.registerScrollOffset(scrollId, 0); + return () => { + horizontalScroll.unregisterScrollOffset(scrollId); + }; + }, [horizontalScroll, isExpanded, scrollId]); + + const handleScroll = useCallback( + (event: NativeSyntheticEvent) => { + if (!horizontalScroll) return; + const offsetX = event.nativeEvent.contentOffset.x; + horizontalScroll.registerScrollOffset(scrollId, offsetX); + }, + [horizontalScroll, scrollId] + ); + return ( - + [ styles.fileHeader, @@ -154,8 +185,11 @@ function DiffFileSection({ file, defaultExpanded = true }: DiffFileSectionProps) horizontal nestedScrollEnabled showsHorizontalScrollIndicator + bounces={false} style={styles.diffContent} contentContainerStyle={styles.diffContentInner} + onScroll={handleScroll} + scrollEventThrottle={16} > {file.hunks.map((hunk, hunkIndex) => @@ -200,7 +234,7 @@ export function GitDiffPane({ serverId, agentId }: GitDiffPaneProps) { return ( } > - {isLoading ? ( - - - Loading changes... - - ) : isError ? ( - - {errorMessage ?? "Failed to load changes"} - - ) : !hasChanges ? ( - - No changes - - ) : ( - files.map((file, fileIndex) => ( - - )) - )} + + {isLoading ? ( + + + Loading changes... + + ) : isError ? ( + + {errorMessage ?? "Failed to load changes"} + + ) : !hasChanges ? ( + + No changes + + ) : ( + files.map((file, fileIndex) => ( + + )) + )} + ); } @@ -238,7 +274,7 @@ const styles = StyleSheet.create((theme) => ({ }, contentContainer: { paddingHorizontal: theme.spacing[2], - paddingTop: theme.spacing[2], + paddingTop: theme.spacing[3], paddingBottom: theme.spacing[8], }, loadingContainer: { @@ -346,7 +382,7 @@ const styles = StyleSheet.create((theme) => ({ diffContent: { borderTopWidth: theme.borderWidth[1], borderTopColor: theme.colors.border, - backgroundColor: "#0d1117", // GitHub dark background + backgroundColor: "#0d1117", }, diffContentInner: { flexDirection: "column", @@ -354,6 +390,7 @@ const styles = StyleSheet.create((theme) => ({ linesContainer: { alignSelf: "flex-start", minWidth: "100%", + backgroundColor: "#0d1117", }, diffLineContainer: { paddingHorizontal: theme.spacing[3], @@ -365,25 +402,25 @@ const styles = StyleSheet.create((theme) => ({ color: theme.colors.foreground, }, addLineContainer: { - backgroundColor: "rgba(46, 160, 67, 0.1)", // GitHub green with transparency + backgroundColor: "rgba(46, 160, 67, 0.15)", // GitHub green }, addLineText: { color: "#c9d1d9", // Same text color as all code }, removeLineContainer: { - backgroundColor: "rgba(248, 81, 73, 0.1)", // GitHub red with transparency + backgroundColor: "rgba(248, 81, 73, 0.1)", // GitHub red }, removeLineText: { color: "#c9d1d9", // Same text color as all code }, headerLineContainer: { - backgroundColor: "#161b22", // GitHub dark header + backgroundColor: theme.colors.muted, }, headerLineText: { color: theme.colors.mutedForeground, }, contextLineContainer: { - backgroundColor: "#0d1117", // GitHub dark background + backgroundColor: "#0d1117", }, contextLineText: { color: theme.colors.mutedForeground, diff --git a/packages/app/src/components/headers/menu-header.tsx b/packages/app/src/components/headers/menu-header.tsx index 724153dc0..1b48671b1 100644 --- a/packages/app/src/components/headers/menu-header.tsx +++ b/packages/app/src/components/headers/menu-header.tsx @@ -1,7 +1,7 @@ import type { ReactNode } from "react"; import { Pressable, Text } from "react-native"; -import { StyleSheet, useUnistyles } from "react-native-unistyles"; -import { Menu } from "lucide-react-native"; +import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles"; +import { Menu, PanelLeft } from "lucide-react-native"; import { ScreenHeader } from "./screen-header"; import { useSidebarStore } from "@/stores/sidebar-store"; @@ -12,14 +12,21 @@ interface MenuHeaderProps { export function MenuHeader({ title, rightContent }: MenuHeaderProps) { const { theme } = useUnistyles(); - const { toggle } = useSidebarStore(); + const { isOpen, toggle } = useSidebarStore(); + const isMobile = + UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm"; + + const MenuIcon = isMobile ? Menu : PanelLeft; + const menuIconColor = !isMobile && isOpen + ? theme.colors.foreground + : theme.colors.mutedForeground; return ( - + {title && ( diff --git a/packages/app/src/components/headers/screen-header.tsx b/packages/app/src/components/headers/screen-header.tsx index 33a78d308..2435fe318 100644 --- a/packages/app/src/components/headers/screen-header.tsx +++ b/packages/app/src/components/headers/screen-header.tsx @@ -1,7 +1,8 @@ import type { ReactNode } from "react"; import { View, type StyleProp, type ViewStyle } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; -import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles"; +import { StyleSheet, UnistylesRuntime } from "react-native-unistyles"; +import { HEADER_INNER_HEIGHT } from "@/constants/layout"; interface ScreenHeaderProps { left?: ReactNode; @@ -16,8 +17,9 @@ interface ScreenHeaderProps { */ export function ScreenHeader({ left, right, leftStyle, rightStyle }: ScreenHeaderProps) { const insets = useSafeAreaInsets(); - const { theme } = useUnistyles(); - const topPadding = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm" ? 8 : 4; + const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm"; + // Only add extra padding on mobile for better touch targets; on desktop, only use safe area insets + const topPadding = isMobile ? 8 : 0; return ( @@ -37,17 +39,11 @@ const styles = StyleSheet.create((theme) => ({ }, inner: {}, row: { + height: HEADER_INNER_HEIGHT, flexDirection: "row", alignItems: "center", justifyContent: "space-between", - paddingHorizontal: { - xs: theme.spacing[2], - md: theme.spacing[1], - }, - paddingBottom: { - xs: theme.spacing[2], - md: theme.spacing[1], - }, + paddingHorizontal: theme.spacing[2], borderBottomWidth: theme.borderWidth[1], borderBottomColor: theme.colors.border, }, diff --git a/packages/app/src/constants/layout.ts b/packages/app/src/constants/layout.ts index 77097f884..ed1cb6989 100644 --- a/packages/app/src/constants/layout.ts +++ b/packages/app/src/constants/layout.ts @@ -1 +1,6 @@ export const FOOTER_HEIGHT = 75; + +// Shared header inner height (excluding safe area insets and border) +// Used by both agent header (ScreenHeader) and explorer sidebar header +// This ensures both headers have the same visual height +export const HEADER_INNER_HEIGHT = 48; diff --git a/packages/app/src/contexts/horizontal-scroll-context.tsx b/packages/app/src/contexts/horizontal-scroll-context.tsx new file mode 100644 index 000000000..79673d5ce --- /dev/null +++ b/packages/app/src/contexts/horizontal-scroll-context.tsx @@ -0,0 +1,68 @@ +import { createContext, useContext, useCallback, useRef, type ReactNode } from "react"; +import { useSharedValue, type SharedValue } from "react-native-reanimated"; + +interface HorizontalScrollContextValue { + // Shared value indicating if any registered horizontal scroll is scrolled (offset > 0) + isAnyScrolledRight: SharedValue; + // Register a scroll view's offset - returns an unregister function + registerScrollOffset: (id: string, offset: number) => void; + unregisterScrollOffset: (id: string) => void; +} + +const HorizontalScrollContext = createContext(null); + +export function HorizontalScrollProvider({ children }: { children: ReactNode }) { + const isAnyScrolledRight = useSharedValue(false); + const scrollOffsetsRef = useRef>(new Map()); + + const updateIsAnyScrolled = useCallback(() => { + let anyScrolled = false; + for (const offset of scrollOffsetsRef.current.values()) { + if (offset > 1) { + anyScrolled = true; + break; + } + } + isAnyScrolledRight.value = anyScrolled; + }, [isAnyScrolledRight]); + + const registerScrollOffset = useCallback( + (id: string, offset: number) => { + scrollOffsetsRef.current.set(id, offset); + updateIsAnyScrolled(); + }, + [updateIsAnyScrolled] + ); + + const unregisterScrollOffset = useCallback( + (id: string) => { + scrollOffsetsRef.current.delete(id); + updateIsAnyScrolled(); + }, + [updateIsAnyScrolled] + ); + + return ( + + {children} + + ); +} + +export function useHorizontalScroll() { + const context = useContext(HorizontalScrollContext); + if (!context) { + throw new Error("useHorizontalScroll must be used within HorizontalScrollProvider"); + } + return context; +} + +export function useHorizontalScrollOptional() { + return useContext(HorizontalScrollContext); +} diff --git a/packages/server/src/server/daemon.e2e.test.ts b/packages/server/src/server/daemon.e2e.test.ts index 142a1c03d..5084c1bdc 100644 --- a/packages/server/src/server/daemon.e2e.test.ts +++ b/packages/server/src/server/daemon.e2e.test.ts @@ -3115,4 +3115,169 @@ describe("daemon E2E", () => { 180000 // 3 minute timeout ); }); + + describe("tool call structure", () => { + test( + "Claude agent tool calls have expected structure", + async () => { + const cwd = tmpCwd(); + + // Create Claude agent with bypass permissions + const agent = await ctx.client.createAgent({ + provider: "claude", + cwd, + title: "Tool Structure Test - Claude", + modeId: "bypassPermissions", + }); + + expect(agent.provider).toBe("claude"); + + ctx.client.clearMessageQueue(); + + // Prompt that triggers a Read tool call + await ctx.client.sendMessage( + agent.id, + "Read the file /etc/hosts and tell me how many lines it has. Be brief." + ); + + const finalState = await ctx.client.waitForAgentIdle(agent.id, 120000); + expect(finalState.status).toBe("idle"); + + // Extract tool_call timeline items + const queue = ctx.client.getMessageQueue(); + const toolCalls: AgentTimelineItem[] = []; + for (const m of queue) { + if ( + m.type === "agent_stream" && + m.payload.agentId === agent.id && + m.payload.event.type === "timeline" && + m.payload.event.item.type === "tool_call" + ) { + toolCalls.push(m.payload.event.item); + } + } + + expect(toolCalls.length).toBeGreaterThan(0); + + // Log and verify structure for each tool call + for (const tc of toolCalls) { + expect(tc.type).toBe("tool_call"); + + // Current structure has: server, tool, displayName, kind + expect(typeof tc.server).toBe("string"); + expect(typeof tc.tool).toBe("string"); + + console.log( + "[CLAUDE TOOL_CALL]", + JSON.stringify({ + server: tc.server, + tool: tc.tool, + displayName: tc.displayName, + kind: tc.kind, + callId: tc.callId, + status: tc.status, + hasInput: tc.input !== undefined, + hasOutput: tc.output !== undefined, + }) + ); + } + + // Find a Read tool call specifically + const readCall = toolCalls.find( + (tc) => + tc.server === "Read" || + tc.tool === "Read" || + tc.displayName === "Read" + ); + expect(readCall).toBeDefined(); + expect(readCall?.input).toBeDefined(); + + // Cleanup + await ctx.client.deleteAgent(agent.id); + rmSync(cwd, { recursive: true, force: true }); + }, + 180000 + ); + + test( + "Codex agent tool calls have expected structure", + async () => { + const cwd = tmpCwd(); + + // Create Codex agent with full access + const agent = await ctx.client.createAgent({ + provider: "codex", + cwd, + title: "Tool Structure Test - Codex", + modeId: "full-access", + }); + + expect(agent.provider).toBe("codex"); + + ctx.client.clearMessageQueue(); + + // Prompt that triggers a shell command + await ctx.client.sendMessage( + agent.id, + "Run `echo hello` and tell me what it outputs. Be brief." + ); + + const finalState = await ctx.client.waitForAgentIdle(agent.id, 120000); + expect(finalState.status).toBe("idle"); + + // Extract tool_call timeline items + const queue = ctx.client.getMessageQueue(); + const toolCalls: AgentTimelineItem[] = []; + for (const m of queue) { + if ( + m.type === "agent_stream" && + m.payload.agentId === agent.id && + m.payload.event.type === "timeline" && + m.payload.event.item.type === "tool_call" + ) { + toolCalls.push(m.payload.event.item); + } + } + + expect(toolCalls.length).toBeGreaterThan(0); + + // Log and verify structure for each tool call + for (const tc of toolCalls) { + expect(tc.type).toBe("tool_call"); + + // Current structure has: server, tool, displayName, kind + expect(typeof tc.server).toBe("string"); + expect(typeof tc.tool).toBe("string"); + + console.log( + "[CODEX TOOL_CALL]", + JSON.stringify({ + server: tc.server, + tool: tc.tool, + displayName: tc.displayName, + kind: tc.kind, + callId: tc.callId, + status: tc.status, + hasInput: tc.input !== undefined, + hasOutput: tc.output !== undefined, + }) + ); + } + + // Find a shell/execute tool call + const shellCall = toolCalls.find( + (tc) => + tc.kind === "execute" || + tc.server?.includes("shell") || + tc.displayName?.includes("echo") + ); + expect(shellCall).toBeDefined(); + + // Cleanup + await ctx.client.deleteAgent(agent.id); + rmSync(cwd, { recursive: true, force: true }); + }, + 180000 + ); + }); });