diff --git a/packages/app/src/app/_layout.tsx b/packages/app/src/app/_layout.tsx index f6903bab4..d20194640 100644 --- a/packages/app/src/app/_layout.tsx +++ b/packages/app/src/app/_layout.tsx @@ -1,17 +1,20 @@ import { Stack } from 'expo-router'; import { SafeAreaProvider } from 'react-native-safe-area-context'; import { KeyboardProvider } from 'react-native-keyboard-controller'; +import { GestureHandlerRootView } from 'react-native-gesture-handler'; export default function RootLayout() { return ( - - - - - - - - - + + + + + + + + + + + ); } diff --git a/packages/app/src/app/index.tsx b/packages/app/src/app/index.tsx index d9d7fd225..30006baa6 100644 --- a/packages/app/src/app/index.tsx +++ b/packages/app/src/app/index.tsx @@ -12,6 +12,7 @@ import { Modal, } from "react-native"; import { useReanimatedKeyboardAnimation } from "react-native-keyboard-controller"; +import { GestureDetector, Gesture } from "react-native-gesture-handler"; import ReanimatedAnimated, { useAnimatedStyle, useSharedValue, @@ -19,6 +20,9 @@ import ReanimatedAnimated, { withRepeat, withSequence, withTiming, + cancelAnimation, + runOnJS, + Easing, } from "react-native-reanimated"; import { router } from "expo-router"; import { activateKeepAwakeAsync, deactivateKeepAwake } from "expo-keep-awake"; @@ -44,7 +48,7 @@ import { ToolCall, } from "@/components/message"; import { ArtifactDrawer, type Artifact } from "@/components/artifact-drawer"; -import { ActiveProcesses } from "@/components/active-processes"; +import { AgentSidebar } from "@/components/agent-sidebar"; import { AgentStreamView } from "@/components/agent-stream-view"; import { ConversationSelector } from "@/components/conversation-selector"; import { VolumeMeter } from "@/components/volume-meter"; @@ -59,6 +63,8 @@ import { MicOff, Plus, ChevronDown, + Menu, + Home, } from "lucide-react-native"; import type { ActivityLogPayload, @@ -124,6 +130,8 @@ interface Agent { name: string; description?: string | null; }>; + title?: string; + cwd: string; } interface Command { @@ -338,6 +346,9 @@ export default function VoiceAssistantScreen() { // Agent creation modal state const [showCreateAgentModal, setShowCreateAgentModal] = useState(false); + // Agent sidebar state + const [sidebarOpen, setSidebarOpen] = useState(false); + // Mode selector modal state const [showModeSelector, setShowModeSelector] = useState(false); @@ -347,6 +358,22 @@ export default function VoiceAssistantScreen() { const [agentUpdates, setAgentUpdates] = useState>( new Map() ); + const [pendingPermissions, setPendingPermissions] = useState< + Map< + string, + { + agentId: string; + requestId: string; + sessionId: string; + toolCall: any; + options: Array<{ + kind: string; + name: string; + optionId: string; + }>; + } + > + >(new Map()); const scrollViewRef = useRef(null); @@ -417,7 +444,7 @@ export default function VoiceAssistantScreen() { // Agent created handler const unsubAgentCreated = ws.on("agent_created", (message) => { if (message.type !== "agent_created") return; - const { agentId, status, type, currentModeId, availableModes } = + const { agentId, status, type, currentModeId, availableModes, title, cwd } = message.payload; console.log("[App] Agent created:", agentId, "currentModeId:", currentModeId, "availableModes:", availableModes); @@ -427,6 +454,8 @@ export default function VoiceAssistantScreen() { status: status as AgentStatus, type, createdAt: new Date(), + title, + cwd, currentModeId, availableModes, }; @@ -440,6 +469,29 @@ export default function VoiceAssistantScreen() { }); // Agent update handler + // Agent permission request handler + const unsubAgentPermissionRequest = ws.on("agent_permission_request", (message) => { + console.log("[App] agent_permission_request message received:", message.type); + if (message.type !== "agent_permission_request") return; + const { agentId, requestId, sessionId, toolCall, options } = message.payload; + + console.log("[App] Permission request received:", requestId, "for agent:", agentId); + console.log("[App] Permission details:", { agentId, requestId, toolCall, options }); + + // Store pending permission + setPendingPermissions((prev) => { + const updated = new Map(prev); + updated.set(requestId, { + agentId, + requestId, + sessionId, + toolCall, + options, + }); + return updated; + }); + }); + const unsubAgentUpdate = ws.on("agent_update", (message) => { if (message.type !== "agent_update") return; const { agentId, timestamp, notification } = message.payload; @@ -488,6 +540,8 @@ export default function VoiceAssistantScreen() { error: info.error, currentModeId: info.currentModeId, availableModes: info.availableModes, + title: info.title, + cwd: info.cwd, }); } return updated; @@ -803,6 +857,7 @@ export default function VoiceAssistantScreen() { return () => { unsubSessionState(); unsubAgentCreated(); + unsubAgentPermissionRequest(); unsubAgentUpdate(); unsubAgentStatus(); unsubActivity(); @@ -1002,6 +1057,36 @@ export default function VoiceAssistantScreen() { setShowModeSelector(false); } + // Permission response handler + function handlePermissionResponse(requestId: string, optionId: string) { + const permission = pendingPermissions.get(requestId); + if (!permission) { + console.error("[App] Permission not found:", requestId); + return; + } + + console.log("[App] Responding to permission:", requestId, "with option:", optionId); + + const message: WSInboundMessage = { + type: "session", + message: { + type: "agent_permission_response", + agentId: permission.agentId, + requestId, + optionId, + }, + }; + + ws.send(message); + + // Remove from pending permissions + setPendingPermissions((prev) => { + const updated = new Map(prev); + updated.delete(requestId); + return updated; + }); + } + // Text message handlers function handleSendMessage() { if (!userInput.trim() || !ws.isConnected) return; @@ -1171,6 +1256,7 @@ export default function VoiceAssistantScreen() { setAgents(new Map()); setCommands(new Map()); setAgentUpdates(new Map()); + setPendingPermissions(new Map()); setViewMode("orchestrator"); setActiveAgentId(null); @@ -1191,6 +1277,48 @@ export default function VoiceAssistantScreen() { const agent = activeAgentId ? agents.get(activeAgentId) : null; const streamItems = activeAgentId ? (agentStreamState.get(activeAgentId) || []) : []; + // Edge swipe state + const edgeSwipeTranslateX = useSharedValue(-300); + const isEdgeSwiping = useSharedValue(false); + + // Edge swipe gesture to open sidebar + const edgeSwipeGesture = Gesture.Pan() + .enabled(!sidebarOpen) + .activeOffsetX(10) // require a rightward intent + .failOffsetY([-15, 15]) // ignore primarily vertical swipes + .onStart(() => { + isEdgeSwiping.value = true; + cancelAnimation(edgeSwipeTranslateX); + edgeSwipeTranslateX.value = -300; + }) + .onChange((event) => { + if (!isEdgeSwiping.value) { + return; + } + // Move sidebar from -300 (closed) to 0 (open) as the finger moves + const newX = -300 + event.translationX; + edgeSwipeTranslateX.value = Math.max(-300, Math.min(0, newX)); + }) + .onEnd((event) => { + if (!isEdgeSwiping.value) { + edgeSwipeTranslateX.value = -300; + return; + } + + const shouldOpen = edgeSwipeTranslateX.value > -150 || event.velocityX > 500; + + edgeSwipeTranslateX.value = withTiming(shouldOpen ? 0 : -300, { + duration: 150, + easing: Easing.out(Easing.ease), + }); + + if (shouldOpen) { + runOnJS(setSidebarOpen)(true); + } + + isEdgeSwiping.value = false; + }); + // Render main view with shared structure return ( @@ -1199,6 +1327,28 @@ export default function VoiceAssistantScreen() { + {/* Menu button to open sidebar */} + { + edgeSwipeTranslateX.value = -300; + setSidebarOpen(true); + }} + style={styles.menuButton} + > + + + + {/* Orchestrator button (only show in agent view) */} + {viewMode === "agent" && ( + + + Orchestrator + + )} + @@ -1207,16 +1357,6 @@ export default function VoiceAssistantScreen() { onSelectConversation={handleSelectConversation} websocket={ws} /> - setShowCreateAgentModal(true)} - disabled={!ws.isConnected} - style={[ - styles.settingsButton, - !ws.isConnected && styles.buttonDisabled, - ]} - > - - router.push("/settings")} style={styles.settingsButton} @@ -1226,22 +1366,13 @@ export default function VoiceAssistantScreen() { - - {/* Active processes bar */} - {/* Content Area with Keyboard Handling */} - + + {/* Conditionally render content based on view mode */} {viewMode === "agent" && activeAgentId && agent ? ( // Agent view - render AgentStreamView @@ -1249,6 +1380,8 @@ export default function VoiceAssistantScreen() { agentId={activeAgentId} agent={agent} streamItems={streamItems} + pendingPermissions={pendingPermissions} + onPermissionResponse={handlePermissionResponse} /> ) : viewMode === "agent" && activeAgentId && !agent ? ( // Agent not found @@ -1509,7 +1642,8 @@ export default function VoiceAssistantScreen() { )} - + + {/* Artifact drawer */} + + {/* Agent sidebar */} + { + setSidebarOpen(false); + // Reset edge swipe position when closing + edgeSwipeTranslateX.value = -300; + }} + onSelectAgent={handleSelectAgent} + onNewAgent={() => setShowCreateAgentModal(true)} + edgeSwipeTranslateX={edgeSwipeTranslateX} + /> ); } @@ -1600,6 +1749,26 @@ const styles = StyleSheet.create((theme) => ({ }, headerLeft: { flex: 1, + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[3], + }, + menuButton: { + padding: theme.spacing[2], + }, + orchestratorButton: { + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[2], + paddingVertical: theme.spacing[2], + paddingHorizontal: theme.spacing[3], + backgroundColor: theme.colors.muted, + borderRadius: theme.borderRadius.md, + }, + orchestratorButtonText: { + color: theme.colors.foreground, + fontSize: theme.fontSize.sm, + fontWeight: theme.fontWeight.medium, }, headerRight: { flexDirection: "row", diff --git a/packages/app/src/components/agent-sidebar.tsx b/packages/app/src/components/agent-sidebar.tsx new file mode 100644 index 000000000..70d331d06 --- /dev/null +++ b/packages/app/src/components/agent-sidebar.tsx @@ -0,0 +1,339 @@ +import { View, Text, Pressable, ScrollView } from "react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; +import { StyleSheet, useUnistyles } from "react-native-unistyles"; +import { Plus, X } from "lucide-react-native"; +import { useEffect, useState } from "react"; +import { GestureDetector, Gesture } from "react-native-gesture-handler"; +import Animated, { + useSharedValue, + useAnimatedStyle, + withTiming, + cancelAnimation, + runOnJS, + Easing, +} from "react-native-reanimated"; +import type { AgentStatus } from "@server/server/acp/types"; + +interface Agent { + id: string; + status: AgentStatus; + title?: string; + cwd: string; +} + +interface AgentSidebarProps { + isOpen: boolean; + agents: Agent[]; + activeAgentId: string | null; + onClose: () => void; + onSelectAgent: (agentId: string) => void; + onNewAgent: () => void; + edgeSwipeTranslateX?: Animated.SharedValue | null; +} + +function getStatusColor(status: AgentStatus): string { + switch (status) { + case "initializing": + return "#FFA500"; + case "ready": + return "#2563EB"; + case "processing": + return "#FACC15"; + case "completed": + return "#10B981"; + case "failed": + return "#EF4444"; + case "killed": + return "#6B7280"; + default: + return "#6B7280"; + } +} + +export function AgentSidebar({ + isOpen, + agents, + activeAgentId, + onClose, + onSelectAgent, + onNewAgent, + edgeSwipeTranslateX, +}: AgentSidebarProps) { + const { theme } = useUnistyles(); + const insets = useSafeAreaInsets(); + const savedTranslateX = useSharedValue(0); + const [isVisible, setIsVisible] = useState(isOpen); + + // Use edgeSwipeTranslateX directly since it's always provided + const translateX = edgeSwipeTranslateX || useSharedValue(-300); + + useEffect(() => { + if (isOpen) { + setIsVisible(true); + // Only animate if starting from fully closed position (not mid-animation from edge swipe) + if (translateX.value === -300) { + translateX.value = withTiming(0, { + duration: 150, + easing: Easing.out(Easing.ease), + }); + } + } else { + // Only animate if starting from fully open position + if (translateX.value === 0) { + translateX.value = withTiming(-300, { + duration: 150, + easing: Easing.out(Easing.ease), + }, (finished) => { + if (finished) { + runOnJS(setIsVisible)(false); + } + }); + } else if (translateX.value === -300) { + // Already closed, just hide + setIsVisible(false); + } + } + }, [isOpen]); + + function handleAgentSelect(agentId: string) { + onSelectAgent(agentId); + onClose(); + } + + // Pan gesture for closing sidebar (swipe left on the sidebar) + const closeGesture = Gesture.Pan() + .enabled(isOpen) // Only active when sidebar is open + .activeOffsetX(-10) // Require leftward swipe + .onBegin(() => { + cancelAnimation(translateX); + savedTranslateX.value = translateX.value; + }) + .onChange((event) => { + // Directly update translateX - clamp between -300 and 0 + const newTranslateX = Math.max(-300, Math.min(0, savedTranslateX.value + event.translationX)); + translateX.value = newTranslateX; + }) + .onEnd((event) => { + const shouldClose = translateX.value < -150 || event.velocityX < -500; + + if (shouldClose) { + translateX.value = withTiming(-300, { + duration: 150, + easing: Easing.out(Easing.ease), + }, (finished) => { + if (finished) { + runOnJS(setIsVisible)(false); + runOnJS(onClose)(); + } + }); + } else { + translateX.value = withTiming(0, { + duration: 150, + easing: Easing.out(Easing.ease), + }); + } + }); + + const animatedSidebarStyle = useAnimatedStyle(() => ({ + transform: [{ translateX: translateX.value }], + })); + + const animatedBackdropStyle = useAnimatedStyle(() => { + // Map translateX (-300 to 0) to opacity (0 to 1) + const opacity = (translateX.value + 300) / 300; + return { opacity }; + }); + + // Don't render if not visible + if (!isVisible) { + return null; + } + + return ( + <> + {/* Backdrop - fade in/out with sidebar */} + + + + + {/* Sidebar with close gesture */} + + + {/* Header */} + + + Agents + + + + + + + {/* New Agent Button */} + { + onNewAgent(); + onClose(); + }} + > + + + New Agent + + + + {/* Agent List */} + + {agents.map((agent) => { + const isActive = agent.id === activeAgentId; + const statusColor = getStatusColor(agent.status); + + return ( + handleAgentSelect(agent.id)} + > + + {/* Status Indicator */} + + + {/* Agent Title */} + + + {agent.title || "New Agent"} + + + {/* Agent Directory */} + + {agent.cwd} + + + + + ); + })} + + {agents.length === 0 && ( + + + No agents yet + + + )} + + + + + ); +} + +const styles = StyleSheet.create({ + backdrop: { + flex: 1, + backgroundColor: "rgba(0, 0, 0, 0.5)", + zIndex: 998, + }, + sidebar: { + position: "absolute", + left: 0, + top: 0, + bottom: 0, + width: 300, + zIndex: 999, + paddingHorizontal: 16, + }, + header: { + flexDirection: "row", + justifyContent: "space-between", + alignItems: "center", + marginBottom: 16, + }, + headerTitle: { + fontSize: 24, + fontWeight: "700", + }, + closeButton: { + padding: 4, + }, + newAgentButton: { + flexDirection: "row", + alignItems: "center", + justifyContent: "center", + paddingVertical: 12, + paddingHorizontal: 16, + borderRadius: 8, + marginBottom: 16, + gap: 8, + }, + newAgentText: { + fontSize: 16, + fontWeight: "600", + }, + agentList: { + flex: 1, + }, + agentItem: { + paddingVertical: 12, + paddingHorizontal: 12, + borderRadius: 8, + marginBottom: 4, + }, + agentInfo: { + flexDirection: "row", + alignItems: "center", + gap: 12, + }, + statusDot: { + width: 8, + height: 8, + borderRadius: 4, + }, + agentContent: { + flex: 1, + }, + agentTitle: { + fontSize: 15, + fontWeight: "500", + marginBottom: 2, + }, + agentDirectory: { + fontSize: 12, + }, + emptyState: { + paddingVertical: 32, + alignItems: "center", + }, + emptyText: { + fontSize: 14, + }, +}); diff --git a/packages/app/src/components/agent-stream-view.tsx b/packages/app/src/components/agent-stream-view.tsx index 5ca4059ed..389eb5ee0 100644 --- a/packages/app/src/components/agent-stream-view.tsx +++ b/packages/app/src/components/agent-stream-view.tsx @@ -1,7 +1,7 @@ import { useEffect, useRef } from 'react'; import { View, Text, ScrollView, Pressable } from 'react-native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; -import { StyleSheet } from 'react-native-unistyles'; +import { StyleSheet, useUnistyles } from 'react-native-unistyles'; import type { AgentStatus } from '@server/server/acp/types'; import { AssistantMessage, UserMessage, ActivityLog, ToolCall } from './message'; import type { StreamItem } from '@/types/stream'; @@ -15,12 +15,29 @@ export interface AgentStreamViewProps { type: 'claude'; }; streamItems: StreamItem[]; + pendingPermissions: Map< + string, + { + agentId: string; + requestId: string; + sessionId: string; + toolCall: any; + options: Array<{ + kind: string; + name: string; + optionId: string; + }>; + } + >; + onPermissionResponse: (requestId: string, optionId: string) => void; } export function AgentStreamView({ agentId, agent, streamItems, + pendingPermissions, + onPermissionResponse, }: AgentStreamViewProps) { const scrollViewRef = useRef(null); const insets = useSafeAreaInsets(); @@ -108,11 +125,141 @@ export function AgentStreamView({ } }) )} + + {/* Render pending permissions for this agent */} + {Array.from(pendingPermissions.values()) + .filter((perm) => perm.agentId === agentId) + .map((permission) => ( + + ))} ); } +// Permission Request Card Component +function PermissionRequestCard({ + permission, + onResponse, +}: { + permission: { + requestId: string; + toolCall: any; + options: Array<{ + kind: string; + name: string; + optionId: string; + }>; + }; + onResponse: (requestId: string, optionId: string) => void; +}) { + const { theme } = useUnistyles(); + + // Determine permission type and content based on toolCall + const getPermissionInfo = () => { + const rawInput = permission.toolCall?.rawInput || {}; + const toolCallId = permission.toolCall?.toolCallId || ''; + + console.log('[PermissionCard] Tool call details:', { + toolCallId, + rawInputKeys: Object.keys(rawInput), + rawInput, + }); + + // Check if this is a plan (ExitPlanMode) + if (rawInput.plan) { + return { + title: 'Plan Ready for Review', + content: rawInput.plan, + type: 'plan' as const, + }; + } + + // Check if this is a file operation (Write, Edit, etc.) + if (rawInput.file_path) { + const operation = toolCallId.includes('Write') ? 'Create' : 'Edit'; + const fileContent = rawInput.content || rawInput.new_string || ''; + const preview = fileContent.length > 500 + ? fileContent.slice(0, 500) + '\n\n... (truncated)' + : fileContent; + + return { + title: `${operation} File Permission`, + content: `File: ${rawInput.file_path}\n\n${preview || '(empty file)'}`, + type: 'file' as const, + }; + } + + // Check if this is a command (Bash) + if (rawInput.command) { + return { + title: 'Run Command Permission', + content: `Command: ${rawInput.command}\n\nDescription: ${rawInput.description || 'No description'}`, + type: 'command' as const, + }; + } + + // Fallback - show whatever is in rawInput + return { + title: 'Permission Required', + content: JSON.stringify(rawInput, null, 2), + type: 'unknown' as const, + }; + }; + + const permissionInfo = getPermissionInfo(); + + return ( + + + {permissionInfo.title} + + + {permissionInfo.content && ( + + + {permissionInfo.content} + + + )} + + + How would you like to proceed? + + + + {permission.options.map((option) => ( + onResponse(permission.requestId, option.optionId)} + > + + {option.name} + + + ))} + + + ); +} + const stylesheet = StyleSheet.create((theme) => ({ container: { flex: 1, @@ -134,3 +281,42 @@ const stylesheet = StyleSheet.create((theme) => ({ textAlign: "center", }, })); + +const permissionStyles = StyleSheet.create((theme) => ({ + container: { + marginVertical: theme.spacing[3], + padding: theme.spacing[4], + borderRadius: theme.spacing[2], + borderWidth: 1, + }, + title: { + fontSize: 16, + fontWeight: "600", + marginBottom: theme.spacing[3], + }, + planContainer: { + padding: theme.spacing[3], + borderRadius: theme.spacing[1], + marginBottom: theme.spacing[3], + }, + planText: { + fontSize: 14, + lineHeight: 20, + }, + question: { + fontSize: 14, + marginBottom: theme.spacing[3], + }, + optionsContainer: { + gap: theme.spacing[2], + }, + optionButton: { + padding: theme.spacing[3], + borderRadius: theme.spacing[1], + alignItems: "center", + }, + optionText: { + fontSize: 14, + fontWeight: "600", + }, +})); diff --git a/packages/app/src/hooks/use-websocket.ts b/packages/app/src/hooks/use-websocket.ts index c3557492c..6baa10991 100644 --- a/packages/app/src/hooks/use-websocket.ts +++ b/packages/app/src/hooks/use-websocket.ts @@ -58,7 +58,7 @@ export function useWebSocket(url: string, conversationId?: string | null): UseWe // Only session messages trigger handlers if (wsMessage.type === 'session') { const sessionMessage = wsMessage.message; - // console.log(`[WS] Received session message type: ${sessionMessage.type}`); + console.log(`[WS] Received session message type: ${sessionMessage.type}`); // Track conversation ID when loaded if (sessionMessage.type === 'conversation_loaded') { diff --git a/packages/app/src/types/stream.ts b/packages/app/src/types/stream.ts index a1ea69e65..bd9151fae 100644 --- a/packages/app/src/types/stream.ts +++ b/packages/app/src/types/stream.ts @@ -33,19 +33,27 @@ function deriveUserMessageId(text: string, timestamp: Date, messageId?: string): } /** - * Derive deterministic ID for assistant message based on index in state + * Derive deterministic ID for assistant message + * Now uses stable messageId from server if available */ -function deriveAssistantMessageId(state: StreamItem[]): string { - const count = state.filter(s => s.kind === 'assistant_message').length; - return `assistant_${count}`; +function deriveAssistantMessageId(messageId: string | undefined, timestamp: Date): string { + if (messageId) { + return `assistant_${messageId}`; + } + // Fallback for messages without messageId (shouldn't happen with enriched updates) + return `assistant_${timestamp.getTime()}`; } /** - * Derive deterministic ID for thought based on index in state + * Derive deterministic ID for thought + * Now uses stable messageId from server if available */ -function deriveThoughtId(state: StreamItem[]): string { - const count = state.filter(s => s.kind === 'thought').length; - return `thought_${count}`; +function deriveThoughtId(messageId: string | undefined, timestamp: Date): string { + if (messageId) { + return `thought_${messageId}`; + } + // Fallback for thoughts without messageId (shouldn't happen with enriched updates) + return `thought_${timestamp.getTime()}`; } /** @@ -129,8 +137,8 @@ export interface ArtifactItem { */ type ParsedNotification = | { kind: 'user_message_chunk'; text: string; messageId?: string } - | { kind: 'agent_message_chunk'; text: string } - | { kind: 'agent_thought_chunk'; text: string } + | { kind: 'agent_message_chunk'; text: string; messageId?: string } + | { kind: 'agent_thought_chunk'; text: string; messageId?: string } | { kind: 'tool_call'; toolCallId: string; title: string; status?: string; toolKind?: string; rawInput?: any; rawOutput?: any; content?: any[]; locations?: any[] } | { kind: 'tool_call_update'; toolCallId: string; title?: string | null; status?: string | null; toolKind?: string | null; rawInput?: any; rawOutput?: any; content?: any[] | null; locations?: any[] | null } | { kind: 'plan'; entries: Array<{ content: string; status: string; priority: string }> } @@ -162,12 +170,14 @@ function parseNotification(notification: SessionNotification | any): ParsedNotif return { kind: 'agent_message_chunk', text: update.content?.text || '', + messageId: update.messageId, }; case 'agent_thought_chunk': return { kind: 'agent_thought_chunk', text: update.content?.text || '', + messageId: update.messageId, }; case 'tool_call': @@ -259,21 +269,26 @@ export function reduceStreamUpdate( }]; } - // Agent message chunks - accumulate into last assistant message or create new + // Agent message chunks - accumulate into message with matching ID or create new if (parsed.kind === 'agent_message_chunk') { - const last = state[state.length - 1]; - if (last?.kind === 'assistant_message') { + const id = deriveAssistantMessageId(parsed.messageId, timestamp); + + // Find existing message with this ID + const existingIndex = state.findIndex( + item => item.kind === 'assistant_message' && item.id === id + ); + + if (existingIndex >= 0) { // Append to existing message - return [ - ...state.slice(0, -1), - { - ...last, - text: last.text + parsed.text, - }, - ]; + const existing = state[existingIndex] as AssistantMessageItem; + return state.map((item, idx) => + idx === existingIndex + ? { ...existing, text: existing.text + parsed.text } + : item + ); } - // Create new message with deterministic ID - const id = deriveAssistantMessageId(state); + + // Create new message with stable ID return [...state, { kind: 'assistant_message', id, @@ -282,21 +297,26 @@ export function reduceStreamUpdate( }]; } - // Thought chunks - accumulate into last thought or create new + // Thought chunks - accumulate into thought with matching ID or create new if (parsed.kind === 'agent_thought_chunk') { - const last = state[state.length - 1]; - if (last?.kind === 'thought') { + const id = deriveThoughtId(parsed.messageId, timestamp); + + // Find existing thought with this ID + const existingIndex = state.findIndex( + item => item.kind === 'thought' && item.id === id + ); + + if (existingIndex >= 0) { // Append to existing thought - return [ - ...state.slice(0, -1), - { - ...last, - text: last.text + parsed.text, - }, - ]; + const existing = state[existingIndex] as ThoughtItem; + return state.map((item, idx) => + idx === existingIndex + ? { ...existing, text: existing.text + parsed.text } + : item + ); } - // Create new thought with deterministic ID - const id = deriveThoughtId(state); + + // Create new thought with stable ID return [...state, { kind: 'thought', id, diff --git a/test-idempotent-stream.ts b/test-idempotent-stream.ts new file mode 100644 index 000000000..31517a60f --- /dev/null +++ b/test-idempotent-stream.ts @@ -0,0 +1,181 @@ +/** + * Test script to verify idempotent stream reduction + * + * This tests that applying the same session updates multiple times + * or in different orders produces identical results. + */ + +import { reduceStreamUpdate, hydrateStreamState, type StreamItem } from './packages/app/src/types/stream'; +import type { SessionNotification } from '@agentclientprotocol/sdk'; + +// Helper to create enriched notifications with messageId +function createAgentMessageChunk(text: string, messageId: string): any { + return { + update: { + sessionUpdate: 'agent_message_chunk', + content: { text }, + messageId, + }, + }; +} + +function createAgentThoughtChunk(text: string, messageId: string): any { + return { + update: { + sessionUpdate: 'agent_thought_chunk', + content: { text }, + messageId, + }, + }; +} + +function createUserMessageChunk(text: string, messageId: string): any { + return { + update: { + sessionUpdate: 'user_message_chunk', + content: { text }, + messageId, + }, + }; +} + +function createToolCall(toolCallId: string, title: string): any { + return { + update: { + sessionUpdate: 'tool_call', + toolCallId, + title, + status: 'pending', + }, + }; +} + +// Test 1: Same updates applied twice should be idempotent +function testIdempotentReduction() { + console.log('\n=== Test 1: Idempotent Reduction ==='); + + const timestamp1 = new Date('2025-01-01T10:00:00Z'); + const timestamp2 = new Date('2025-01-01T10:00:01Z'); + const timestamp3 = new Date('2025-01-01T10:00:02Z'); + + // Create a sequence of updates + const updates = [ + { notification: createUserMessageChunk('Hello', 'user-msg-1'), timestamp: timestamp1 }, + { notification: createAgentMessageChunk('Hello! ', 'asst-msg-1'), timestamp: timestamp2 }, + { notification: createAgentMessageChunk('How can I ', 'asst-msg-1'), timestamp: timestamp2 }, + { notification: createAgentMessageChunk('help you?', 'asst-msg-1'), timestamp: timestamp2 }, + { notification: createAgentThoughtChunk('Thinking...', 'thought-1'), timestamp: timestamp3 }, + ]; + + // Apply updates once + const state1 = hydrateStreamState(updates); + + // Apply same updates again from scratch + const state2 = hydrateStreamState(updates); + + // Apply updates twice (should still be same) + const state3 = updates.reduce( + (state, { notification, timestamp }) => { + const s1 = reduceStreamUpdate(state, notification, timestamp); + // Apply again + return reduceStreamUpdate(s1, notification, timestamp); + }, + [] as StreamItem[] + ); + + console.log('State 1 (applied once):', JSON.stringify(state1, null, 2)); + console.log('State 2 (applied from scratch):', JSON.stringify(state2, null, 2)); + console.log('State 3 (applied twice per update):', JSON.stringify(state3, null, 2)); + + // Verify all states are equal + const state1Str = JSON.stringify(state1); + const state2Str = JSON.stringify(state2); + const state3Str = JSON.stringify(state3); + + if (state1Str === state2Str && state2Str === state3Str) { + console.log('✅ PASS: All states are identical'); + } else { + console.log('❌ FAIL: States differ'); + console.log('State 1 === State 2:', state1Str === state2Str); + console.log('State 2 === State 3:', state2Str === state3Str); + } + + // Verify message accumulation worked + const assistantMsg = state1.find(item => item.kind === 'assistant_message'); + if (assistantMsg && assistantMsg.text === 'Hello! How can I help you?') { + console.log('✅ PASS: Message chunks accumulated correctly'); + } else { + console.log('❌ FAIL: Message accumulation failed'); + console.log('Expected: "Hello! How can I help you?"'); + console.log('Got:', assistantMsg?.text); + } +} + +// Test 2: Duplicate user messages should not create duplicates +function testUserMessageDeduplication() { + console.log('\n=== Test 2: User Message Deduplication ==='); + + const timestamp = new Date('2025-01-01T10:00:00Z'); + + // Same user message sent twice (simulating reconnection) + const updates = [ + { notification: createUserMessageChunk('Test message', 'user-msg-1'), timestamp }, + { notification: createUserMessageChunk('Test message', 'user-msg-1'), timestamp }, + ]; + + const state = hydrateStreamState(updates); + + const userMessages = state.filter(item => item.kind === 'user_message'); + + console.log('User messages in state:', userMessages.length); + console.log('State:', JSON.stringify(state, null, 2)); + + if (userMessages.length === 1) { + console.log('✅ PASS: User message deduplicated correctly'); + } else { + console.log('❌ FAIL: Expected 1 user message, got', userMessages.length); + } +} + +// Test 3: Multiple assistant messages with different IDs +function testMultipleMessages() { + console.log('\n=== Test 3: Multiple Distinct Messages ==='); + + const timestamp1 = new Date('2025-01-01T10:00:00Z'); + const timestamp2 = new Date('2025-01-01T10:00:05Z'); + + // Two separate assistant messages (new turn resets message ID) + const updates = [ + { notification: createAgentMessageChunk('First ', 'msg-1'), timestamp: timestamp1 }, + { notification: createAgentMessageChunk('message', 'msg-1'), timestamp: timestamp1 }, + { notification: createToolCall('tool-1', 'Read file'), timestamp: timestamp1 }, + { notification: createAgentMessageChunk('Second ', 'msg-2'), timestamp: timestamp2 }, + { notification: createAgentMessageChunk('message', 'msg-2'), timestamp: timestamp2 }, + ]; + + const state = hydrateStreamState(updates); + + const assistantMessages = state.filter(item => item.kind === 'assistant_message'); + + console.log('Assistant messages:', assistantMessages.length); + console.log('State:', JSON.stringify(state, null, 2)); + + if (assistantMessages.length === 2 && + assistantMessages[0].text === 'First message' && + assistantMessages[1].text === 'Second message') { + console.log('✅ PASS: Multiple messages handled correctly'); + } else { + console.log('❌ FAIL: Expected 2 distinct messages'); + } +} + +// Run all tests +console.log('Testing Idempotent Stream Reduction'); +console.log('===================================='); + +testIdempotentReduction(); +testUserMessageDeduplication(); +testMultipleMessages(); + +console.log('\n===================================='); +console.log('Tests complete');