mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
feat(agent): add git diff viewer and improve permission handling
- Add git diff viewer screen accessible via agent dropdown menu - Implement git_diff_request/response WebSocket messages - Add dropdown menu to agent screen header with "View Changes" option - Clear pending permissions when starting new agent turn to prevent conflicts - Treat agent refusals as completed rather than failed state - Add test coverage for permission handling edge cases - Configure vitest to run in single-threaded mode for stability - Remove redundant wrapper in agent stream view 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -69,6 +69,7 @@ export default function RootLayout() {
|
||||
<Stack.Screen name="agent/[id]" />
|
||||
<Stack.Screen name="settings" />
|
||||
<Stack.Screen name="audio-test" />
|
||||
<Stack.Screen name="git-diff" />
|
||||
</Stack>
|
||||
<GlobalFooter />
|
||||
</AppContainer>
|
||||
|
||||
@@ -1,19 +1,23 @@
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { View, Text, ActivityIndicator } from "react-native";
|
||||
import { useLocalSearchParams } from "expo-router";
|
||||
import { useEffect, useMemo, useRef, useCallback, useState } from "react";
|
||||
import { View, Text, ActivityIndicator, Pressable, Modal, Dimensions } from "react-native";
|
||||
import { useLocalSearchParams, useRouter } from "expo-router";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { useReanimatedKeyboardAnimation } from "react-native-keyboard-controller";
|
||||
import ReanimatedAnimated, { useAnimatedStyle, useSharedValue } from "react-native-reanimated";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { MoreVertical, GitBranch } from "lucide-react-native";
|
||||
import { BackHeader } from "@/components/headers/back-header";
|
||||
import { AgentStreamView } from "@/components/agent-stream-view";
|
||||
import { AgentInputArea } from "@/components/agent-input-area";
|
||||
import { useSession } from "@/contexts/session-context";
|
||||
import { useFooterControls } from "@/contexts/footer-controls-context";
|
||||
|
||||
const DROPDOWN_WIDTH = 220;
|
||||
|
||||
export default function AgentScreen() {
|
||||
const { theme } = useUnistyles();
|
||||
const insets = useSafeAreaInsets();
|
||||
const router = useRouter();
|
||||
const { id } = useLocalSearchParams<{ id: string }>();
|
||||
const {
|
||||
agents,
|
||||
@@ -24,6 +28,9 @@ export default function AgentScreen() {
|
||||
initializeAgent,
|
||||
} = useSession();
|
||||
const { registerFooterControls, unregisterFooterControls } = useFooterControls();
|
||||
const [menuVisible, setMenuVisible] = useState(false);
|
||||
const [menuPosition, setMenuPosition] = useState({ top: 0, left: 0 });
|
||||
const menuButtonRef = useRef<View>(null);
|
||||
|
||||
// Keyboard animation
|
||||
const { height: keyboardHeight } = useReanimatedKeyboardAnimation();
|
||||
@@ -90,6 +97,34 @@ export default function AgentScreen() {
|
||||
};
|
||||
}, [agentControls, agent, isInitializing, registerFooterControls, unregisterFooterControls]);
|
||||
|
||||
const handleOpenMenu = useCallback(() => {
|
||||
menuButtonRef.current?.measureInWindow((x, y, width, height) => {
|
||||
const screenWidth = Dimensions.get("window").width;
|
||||
const verticalOffset = 6;
|
||||
const horizontalMargin = 16;
|
||||
const desiredLeft = x + width - DROPDOWN_WIDTH;
|
||||
const maxLeft = screenWidth - DROPDOWN_WIDTH - horizontalMargin;
|
||||
const clampedLeft = Math.min(Math.max(desiredLeft, horizontalMargin), maxLeft);
|
||||
|
||||
setMenuPosition({
|
||||
top: y + height + verticalOffset,
|
||||
left: clampedLeft,
|
||||
});
|
||||
setMenuVisible(true);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleCloseMenu = useCallback(() => {
|
||||
setMenuVisible(false);
|
||||
}, []);
|
||||
|
||||
const handleViewChanges = useCallback(() => {
|
||||
handleCloseMenu();
|
||||
if (id) {
|
||||
router.push(`/git-diff?agentId=${id}`);
|
||||
}
|
||||
}, [id, router, handleCloseMenu]);
|
||||
|
||||
if (!agent) {
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
@@ -104,7 +139,16 @@ export default function AgentScreen() {
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
{/* Header */}
|
||||
<BackHeader title={agent.title || "Agent"} />
|
||||
<BackHeader
|
||||
title={agent.title || "Agent"}
|
||||
rightContent={
|
||||
<View ref={menuButtonRef} collapsable={false}>
|
||||
<Pressable onPress={handleOpenMenu} style={styles.menuButton}>
|
||||
<MoreVertical size={20} color={theme.colors.foreground} />
|
||||
</Pressable>
|
||||
</View>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Content Area with Keyboard Animation */}
|
||||
<View style={styles.contentContainer}>
|
||||
@@ -127,6 +171,34 @@ export default function AgentScreen() {
|
||||
)}
|
||||
</ReanimatedAnimated.View>
|
||||
</View>
|
||||
|
||||
{/* Dropdown Menu */}
|
||||
<Modal
|
||||
visible={menuVisible}
|
||||
animationType="fade"
|
||||
transparent={true}
|
||||
onRequestClose={handleCloseMenu}
|
||||
>
|
||||
<View style={styles.menuOverlay}>
|
||||
<Pressable style={styles.menuBackdrop} onPress={handleCloseMenu} />
|
||||
<View
|
||||
style={[
|
||||
styles.dropdownMenu,
|
||||
{
|
||||
position: "absolute",
|
||||
top: menuPosition.top,
|
||||
left: menuPosition.left,
|
||||
width: DROPDOWN_WIDTH,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Pressable onPress={handleViewChanges} style={styles.menuItem}>
|
||||
<GitBranch size={20} color={theme.colors.foreground} />
|
||||
<Text style={styles.menuItemText}>View Changes</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
</Modal>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -162,4 +234,37 @@ const styles = StyleSheet.create((theme) => ({
|
||||
fontSize: theme.fontSize.lg,
|
||||
color: theme.colors.mutedForeground,
|
||||
},
|
||||
menuButton: {
|
||||
padding: theme.spacing[3],
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
},
|
||||
menuOverlay: {
|
||||
flex: 1,
|
||||
},
|
||||
menuBackdrop: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
},
|
||||
dropdownMenu: {
|
||||
backgroundColor: theme.colors.card,
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
padding: theme.spacing[2],
|
||||
shadowColor: "#000",
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.25,
|
||||
shadowRadius: 8,
|
||||
elevation: 5,
|
||||
},
|
||||
menuItem: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[3],
|
||||
paddingVertical: theme.spacing[3],
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
borderRadius: theme.borderRadius.md,
|
||||
},
|
||||
menuItemText: {
|
||||
fontSize: theme.fontSize.base,
|
||||
color: theme.colors.foreground,
|
||||
fontWeight: theme.fontWeight.normal,
|
||||
},
|
||||
}));
|
||||
|
||||
258
packages/app/src/app/git-diff.tsx
Normal file
258
packages/app/src/app/git-diff.tsx
Normal file
@@ -0,0 +1,258 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { View, Text, ScrollView, ActivityIndicator } from "react-native";
|
||||
import { useLocalSearchParams } from "expo-router";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { BackHeader } from "@/components/headers/back-header";
|
||||
import { useSession } from "@/contexts/session-context";
|
||||
|
||||
interface ParsedDiffFile {
|
||||
path: string;
|
||||
lines: Array<{
|
||||
type: "add" | "remove" | "context" | "header";
|
||||
content: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
function parseDiff(diffText: string): ParsedDiffFile[] {
|
||||
if (!diffText || diffText.trim().length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const files: ParsedDiffFile[] = [];
|
||||
const sections = diffText.split(/^diff --git /m).filter(Boolean);
|
||||
|
||||
for (const section of sections) {
|
||||
const lines = section.split("\n");
|
||||
const firstLine = lines[0];
|
||||
|
||||
const pathMatch = firstLine.match(/a\/(.*?) b\//);
|
||||
const path = pathMatch ? pathMatch[1] : "unknown";
|
||||
|
||||
const parsedLines: ParsedDiffFile["lines"] = [];
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i];
|
||||
|
||||
if (line.startsWith("+++") || line.startsWith("---") || line.startsWith("@@") || line.startsWith("index ")) {
|
||||
parsedLines.push({ type: "header", content: line });
|
||||
} else if (line.startsWith("+")) {
|
||||
parsedLines.push({ type: "add", content: line });
|
||||
} else if (line.startsWith("-")) {
|
||||
parsedLines.push({ type: "remove", content: line });
|
||||
} else {
|
||||
parsedLines.push({ type: "context", content: line });
|
||||
}
|
||||
}
|
||||
|
||||
files.push({ path, lines: parsedLines });
|
||||
}
|
||||
|
||||
return files;
|
||||
}
|
||||
|
||||
export default function GitDiffScreen() {
|
||||
const { agentId } = useLocalSearchParams<{ agentId: string }>();
|
||||
const { agents, gitDiffs, requestGitDiff } = useSession();
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
const agent = agentId ? agents.get(agentId) : undefined;
|
||||
const diffText = agentId ? gitDiffs.get(agentId) : undefined;
|
||||
|
||||
useEffect(() => {
|
||||
if (!agentId) {
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (diffText !== undefined) {
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
requestGitDiff(agentId);
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
setIsLoading(false);
|
||||
}, 5000);
|
||||
|
||||
return () => clearTimeout(timeout);
|
||||
}, [agentId, diffText, requestGitDiff]);
|
||||
|
||||
useEffect(() => {
|
||||
if (diffText !== undefined) {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [diffText]);
|
||||
|
||||
if (!agent) {
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<BackHeader title="Changes" />
|
||||
<View style={styles.errorContainer}>
|
||||
<Text style={styles.errorText}>Agent not found</Text>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const isError = diffText?.startsWith("Error:");
|
||||
const parsedFiles = isError || !diffText ? [] : parseDiff(diffText);
|
||||
const hasChanges = parsedFiles.length > 0;
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<BackHeader title="Changes" />
|
||||
|
||||
<ScrollView style={styles.scrollView} contentContainerStyle={styles.contentContainer}>
|
||||
{isLoading ? (
|
||||
<View style={styles.loadingContainer}>
|
||||
<ActivityIndicator size="large" />
|
||||
<Text style={styles.loadingText}>Loading changes...</Text>
|
||||
</View>
|
||||
) : isError ? (
|
||||
<View style={styles.errorContainer}>
|
||||
<Text style={styles.errorText}>{diffText}</Text>
|
||||
</View>
|
||||
) : !hasChanges ? (
|
||||
<View style={styles.emptyContainer}>
|
||||
<Text style={styles.emptyText}>No changes</Text>
|
||||
</View>
|
||||
) : (
|
||||
parsedFiles.map((file, fileIndex) => (
|
||||
<View key={fileIndex} style={styles.fileSection}>
|
||||
<View style={styles.fileHeader}>
|
||||
<Text style={styles.filePath}>{file.path}</Text>
|
||||
</View>
|
||||
<View style={styles.diffContent}>
|
||||
<ScrollView
|
||||
horizontal
|
||||
nestedScrollEnabled
|
||||
showsHorizontalScrollIndicator
|
||||
bounces={false}
|
||||
overScrollMode="never"
|
||||
contentContainerStyle={styles.diffScrollContent}
|
||||
>
|
||||
<View style={styles.diffLinesContainer}>
|
||||
{file.lines.map((line, lineIndex) => (
|
||||
<Text
|
||||
key={lineIndex}
|
||||
style={[
|
||||
styles.diffLine,
|
||||
line.type === "add" && styles.addLine,
|
||||
line.type === "remove" && styles.removeLine,
|
||||
line.type === "header" && styles.headerLine,
|
||||
line.type === "context" && styles.contextLine,
|
||||
]}
|
||||
>
|
||||
{line.content}
|
||||
</Text>
|
||||
))}
|
||||
</View>
|
||||
</ScrollView>
|
||||
</View>
|
||||
</View>
|
||||
))
|
||||
)}
|
||||
</ScrollView>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: theme.colors.background,
|
||||
},
|
||||
scrollView: {
|
||||
flex: 1,
|
||||
},
|
||||
contentContainer: {
|
||||
padding: theme.spacing[4],
|
||||
},
|
||||
loadingContainer: {
|
||||
flex: 1,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
paddingTop: theme.spacing[16],
|
||||
gap: theme.spacing[4],
|
||||
},
|
||||
loadingText: {
|
||||
fontSize: theme.fontSize.base,
|
||||
color: theme.colors.mutedForeground,
|
||||
},
|
||||
errorContainer: {
|
||||
flex: 1,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
paddingTop: theme.spacing[16],
|
||||
paddingHorizontal: theme.spacing[6],
|
||||
},
|
||||
errorText: {
|
||||
fontSize: theme.fontSize.base,
|
||||
color: theme.colors.destructive,
|
||||
textAlign: "center",
|
||||
},
|
||||
emptyContainer: {
|
||||
flex: 1,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
paddingTop: theme.spacing[16],
|
||||
},
|
||||
emptyText: {
|
||||
fontSize: theme.fontSize.lg,
|
||||
color: theme.colors.mutedForeground,
|
||||
},
|
||||
fileSection: {
|
||||
marginBottom: theme.spacing[6],
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
overflow: "hidden",
|
||||
borderWidth: theme.borderWidth[1],
|
||||
borderColor: theme.colors.border,
|
||||
},
|
||||
fileHeader: {
|
||||
backgroundColor: theme.colors.muted,
|
||||
padding: theme.spacing[3],
|
||||
borderBottomWidth: theme.borderWidth[1],
|
||||
borderBottomColor: theme.colors.border,
|
||||
},
|
||||
filePath: {
|
||||
fontSize: theme.fontSize.sm,
|
||||
fontWeight: theme.fontWeight.semibold,
|
||||
color: theme.colors.foreground,
|
||||
fontFamily: "monospace",
|
||||
},
|
||||
diffContent: {
|
||||
backgroundColor: theme.colors.card,
|
||||
},
|
||||
diffScrollContent: {
|
||||
flexDirection: "column",
|
||||
alignItems: "flex-start",
|
||||
paddingBottom: theme.spacing[2],
|
||||
},
|
||||
diffLinesContainer: {
|
||||
alignSelf: "flex-start",
|
||||
},
|
||||
diffLine: {
|
||||
fontSize: theme.fontSize.xs,
|
||||
fontFamily: "monospace",
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
paddingVertical: theme.spacing[1],
|
||||
flexShrink: 0,
|
||||
minWidth: "100%",
|
||||
},
|
||||
addLine: {
|
||||
backgroundColor: theme.colors.palette.green[900],
|
||||
color: theme.colors.palette.green[200],
|
||||
},
|
||||
removeLine: {
|
||||
backgroundColor: theme.colors.palette.red[900],
|
||||
color: theme.colors.palette.red[200],
|
||||
},
|
||||
headerLine: {
|
||||
color: theme.colors.mutedForeground,
|
||||
backgroundColor: theme.colors.muted,
|
||||
},
|
||||
contextLine: {
|
||||
color: theme.colors.mutedForeground,
|
||||
},
|
||||
}));
|
||||
@@ -267,28 +267,24 @@ export function AgentStreamView({
|
||||
onMomentumScrollEnd={handleScrollEnd}
|
||||
scrollEventThrottle={16}
|
||||
ListEmptyComponent={
|
||||
<View style={stylesheet.invertedWrapper}>
|
||||
<View style={stylesheet.emptyState}>
|
||||
<Text style={stylesheet.emptyStateText}>
|
||||
Start chatting with this agent...
|
||||
</Text>
|
||||
</View>
|
||||
<View style={stylesheet.emptyState}>
|
||||
<Text style={stylesheet.emptyStateText}>
|
||||
Start chatting with this agent...
|
||||
</Text>
|
||||
</View>
|
||||
}
|
||||
ListHeaderComponent={
|
||||
<View style={stylesheet.invertedWrapper}>
|
||||
{pendingPermissionItems.length > 0 ? (
|
||||
<View style={stylesheet.permissionsContainer}>
|
||||
{pendingPermissionItems.map((permission) => (
|
||||
<PermissionRequestCard
|
||||
key={permission.requestId}
|
||||
permission={permission}
|
||||
onResponse={onPermissionResponse}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
pendingPermissionItems.length > 0 ? (
|
||||
<View style={stylesheet.permissionsContainer}>
|
||||
{pendingPermissionItems.map((permission) => (
|
||||
<PermissionRequestCard
|
||||
key={permission.requestId}
|
||||
permission={permission}
|
||||
onResponse={onPermissionResponse}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
) : null
|
||||
}
|
||||
extraData={pendingPermissionItems.length}
|
||||
maintainVisibleContentPosition={{
|
||||
|
||||
@@ -6,9 +6,10 @@ import { ArrowLeft } from "lucide-react-native";
|
||||
|
||||
interface BackHeaderProps {
|
||||
title?: string;
|
||||
rightContent?: React.ReactNode;
|
||||
}
|
||||
|
||||
export function BackHeader({ title }: BackHeaderProps) {
|
||||
export function BackHeader({ title, rightContent }: BackHeaderProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const insets = useSafeAreaInsets();
|
||||
|
||||
@@ -31,8 +32,10 @@ export function BackHeader({ title }: BackHeaderProps) {
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* Right side - Empty for now */}
|
||||
<View style={styles.headerRight} />
|
||||
{/* Right side */}
|
||||
<View style={styles.headerRight}>
|
||||
{rightContent}
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
@@ -115,6 +115,10 @@ interface SessionContextValue {
|
||||
pendingPermissions: Map<string, PendingPermission>;
|
||||
setPendingPermissions: (perms: Map<string, PendingPermission> | ((prev: Map<string, PendingPermission>) => Map<string, PendingPermission>)) => void;
|
||||
|
||||
// Git diffs
|
||||
gitDiffs: Map<string, string>;
|
||||
requestGitDiff: (agentId: string) => void;
|
||||
|
||||
// Helpers
|
||||
initializeAgent: (params: { agentId: string; requestId?: string }) => void;
|
||||
sendAgentMessage: (agentId: string, message: string, imageUris?: string[]) => Promise<void>;
|
||||
@@ -167,6 +171,7 @@ export function SessionProvider({ children, serverUrl }: SessionProviderProps) {
|
||||
const [commands, setCommands] = useState<Map<string, Command>>(new Map());
|
||||
const [agentUpdates, setAgentUpdates] = useState<Map<string, AgentUpdate[]>>(new Map());
|
||||
const [pendingPermissions, setPendingPermissions] = useState<Map<string, PendingPermission>>(new Map());
|
||||
const [gitDiffs, setGitDiffs] = useState<Map<string, string>>(new Map());
|
||||
|
||||
// WebSocket message handlers
|
||||
useEffect(() => {
|
||||
@@ -593,6 +598,21 @@ export function SessionProvider({ children, serverUrl }: SessionProviderProps) {
|
||||
}
|
||||
});
|
||||
|
||||
// Git diff response handler
|
||||
const unsubGitDiff = ws.on("git_diff_response", (message) => {
|
||||
if (message.type !== "git_diff_response") return;
|
||||
const { agentId, diff, error } = message.payload;
|
||||
|
||||
console.log("[Session] Git diff response for agent:", agentId, error ? `(error: ${error})` : "");
|
||||
|
||||
if (error) {
|
||||
console.error("[Session] Git diff error:", error);
|
||||
setGitDiffs((prev) => new Map(prev).set(agentId, `Error: ${error}`));
|
||||
} else {
|
||||
setGitDiffs((prev) => new Map(prev).set(agentId, diff || ""));
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
unsubSessionState();
|
||||
unsubAgentCreated();
|
||||
@@ -605,6 +625,7 @@ export function SessionProvider({ children, serverUrl }: SessionProviderProps) {
|
||||
unsubActivity();
|
||||
unsubChunk();
|
||||
unsubTranscription();
|
||||
unsubGitDiff();
|
||||
};
|
||||
}, [ws, audioPlayer, setIsPlayingAudio]);
|
||||
|
||||
@@ -776,6 +797,17 @@ export function SessionProvider({ children, serverUrl }: SessionProviderProps) {
|
||||
isSpeakingRef.current = isSpeaking;
|
||||
}, []);
|
||||
|
||||
const requestGitDiff = useCallback((agentId: string) => {
|
||||
const msg: WSInboundMessage = {
|
||||
type: "session",
|
||||
message: {
|
||||
type: "git_diff_request",
|
||||
agentId,
|
||||
},
|
||||
};
|
||||
ws.send(msg);
|
||||
}, [ws]);
|
||||
|
||||
const value: SessionContextValue = {
|
||||
ws,
|
||||
audioPlayer,
|
||||
@@ -797,6 +829,8 @@ export function SessionProvider({ children, serverUrl }: SessionProviderProps) {
|
||||
setAgentUpdates,
|
||||
pendingPermissions,
|
||||
setPendingPermissions,
|
||||
gitDiffs,
|
||||
requestGitDiff,
|
||||
initializeAgent,
|
||||
sendAgentMessage,
|
||||
sendAgentAudio,
|
||||
|
||||
@@ -96,6 +96,83 @@ describe("AgentManager", () => {
|
||||
}
|
||||
}, 120000);
|
||||
|
||||
it("should not fail when sending '.' after plan permission request", async () => {
|
||||
const manager = new AgentManager();
|
||||
let permissionRequest: RequestPermissionRequest | null = null;
|
||||
let requestId: string | null = null;
|
||||
|
||||
const agentId = await manager.createAgent({
|
||||
cwd: tmpDir,
|
||||
type: "claude",
|
||||
initialMode: "plan",
|
||||
});
|
||||
createdAgents.push({ manager, agentId });
|
||||
|
||||
const unsubscribe = manager.subscribeToUpdates(
|
||||
agentId,
|
||||
(update: AgentUpdate) => {
|
||||
const notification: AgentNotification = update.notification;
|
||||
if (notification.type === "permission") {
|
||||
permissionRequest = notification.request;
|
||||
requestId = notification.requestId;
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
try {
|
||||
await manager.sendPrompt(
|
||||
agentId,
|
||||
"Create a file called test.txt with the content 'hello world'"
|
||||
);
|
||||
|
||||
let attempts = 0;
|
||||
while (!permissionRequest && attempts < 40) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
attempts++;
|
||||
}
|
||||
|
||||
expect(permissionRequest).toBeDefined();
|
||||
expect(requestId).toBeDefined();
|
||||
|
||||
console.log("Permission request received, now sending '.' message instead of responding");
|
||||
|
||||
// Instead of responding to permission, send a "." message
|
||||
await manager.sendPrompt(agentId, ".");
|
||||
|
||||
// Wait for agent to finish processing
|
||||
let status = manager.getAgentStatus(agentId);
|
||||
let waitAttempts = 0;
|
||||
while (status === "processing" && waitAttempts < 60) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
status = manager.getAgentStatus(agentId);
|
||||
waitAttempts++;
|
||||
}
|
||||
|
||||
const agentInfo = manager.listAgents().find((a) => a.id === agentId);
|
||||
const updates = manager.getAgentUpdates(agentId);
|
||||
const sessionUpdates = updates.filter(u => u.notification.type === "session");
|
||||
|
||||
console.log("Agent status after '.' message:", status);
|
||||
console.log("Agent error:", agentInfo?.error);
|
||||
console.log("Session updates count:", sessionUpdates.length);
|
||||
console.log("Last few updates:", updates.slice(-5).map(u => ({
|
||||
type: u.notification.type,
|
||||
timestamp: u.timestamp,
|
||||
})));
|
||||
|
||||
// The agent should NOT be in failed state
|
||||
expect(status).not.toBe("failed");
|
||||
|
||||
// The agent should be in a usable state (ready, completed, or processing)
|
||||
expect(["ready", "completed", "processing"]).toContain(status);
|
||||
|
||||
// There should be no error
|
||||
expect(agentInfo?.error).toBeNull();
|
||||
} finally {
|
||||
unsubscribe();
|
||||
}
|
||||
}, 120000);
|
||||
|
||||
describe("persistence", () => {
|
||||
it("should load persisted agent and send new prompt", async () => {
|
||||
const manager = new AgentManager();
|
||||
|
||||
@@ -416,6 +416,46 @@ export class AgentManager {
|
||||
}
|
||||
}
|
||||
|
||||
// Clear any pending permissions since we're starting a new turn
|
||||
if (agent.pendingPermissions.size > 0) {
|
||||
console.log(
|
||||
`[Agent ${agentId}] Clearing ${agent.pendingPermissions.size} pending permission(s)`
|
||||
);
|
||||
|
||||
// Reject all pending permission promises with cancellation
|
||||
for (const [requestId, permission] of agent.pendingPermissions) {
|
||||
permission.resolve({
|
||||
outcome: {
|
||||
outcome: "cancelled" as const,
|
||||
},
|
||||
});
|
||||
|
||||
// Emit permission_resolved notification so UI updates
|
||||
const agentUpdate: AgentUpdate = {
|
||||
agentId,
|
||||
timestamp: new Date(),
|
||||
notification: {
|
||||
type: "permission_resolved",
|
||||
requestId,
|
||||
agentId,
|
||||
optionId: "cancelled",
|
||||
},
|
||||
};
|
||||
|
||||
agent.updates.push(agentUpdate);
|
||||
|
||||
for (const subscriber of agent.subscribers) {
|
||||
try {
|
||||
subscriber(agentUpdate);
|
||||
} catch (error) {
|
||||
console.error(`[Agent ${agentId}] Subscriber error:`, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
agent.pendingPermissions.clear();
|
||||
}
|
||||
|
||||
// Get runtime (guaranteed to exist after ensureInitialized)
|
||||
if (
|
||||
agent.state.type !== "ready" &&
|
||||
@@ -506,14 +546,14 @@ export class AgentManager {
|
||||
stopReason: response.stopReason,
|
||||
};
|
||||
} else if (response.stopReason === "refusal") {
|
||||
console.error(
|
||||
console.warn(
|
||||
`[Agent ${agentId}] Agent refused to process the prompt`,
|
||||
response
|
||||
);
|
||||
agent.state = {
|
||||
type: "failed",
|
||||
lastError: "Agent refused to process the prompt",
|
||||
type: "completed",
|
||||
runtime: agent.state.runtime,
|
||||
stopReason: response.stopReason,
|
||||
};
|
||||
} else if (response.stopReason === "cancelled") {
|
||||
agent.state = { type: "ready", runtime: agent.state.runtime };
|
||||
|
||||
@@ -122,6 +122,11 @@ export const AgentPermissionResponseMessageSchema = z.object({
|
||||
optionId: z.string(),
|
||||
});
|
||||
|
||||
export const GitDiffRequestSchema = z.object({
|
||||
type: z.literal("git_diff_request"),
|
||||
agentId: z.string(),
|
||||
});
|
||||
|
||||
export const SessionInboundMessageSchema = z.discriminatedUnion("type", [
|
||||
UserTextMessageSchema,
|
||||
RealtimeAudioChunkMessageSchema,
|
||||
@@ -137,6 +142,7 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [
|
||||
InitializeAgentRequestMessageSchema,
|
||||
SetAgentModeMessageSchema,
|
||||
AgentPermissionResponseMessageSchema,
|
||||
GitDiffRequestSchema,
|
||||
]);
|
||||
|
||||
export type SessionInboundMessage = z.infer<typeof SessionInboundMessageSchema>;
|
||||
@@ -321,6 +327,15 @@ export const AgentPermissionResolvedMessageSchema = z.object({
|
||||
}),
|
||||
});
|
||||
|
||||
export const GitDiffResponseSchema = z.object({
|
||||
type: z.literal("git_diff_response"),
|
||||
payload: z.object({
|
||||
agentId: z.string(),
|
||||
diff: z.string(),
|
||||
error: z.string().nullable(),
|
||||
}),
|
||||
});
|
||||
|
||||
export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [
|
||||
ActivityLogMessageSchema,
|
||||
AssistantChunkMessageSchema,
|
||||
@@ -338,6 +353,7 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [
|
||||
DeleteConversationResponseMessageSchema,
|
||||
AgentPermissionRequestMessageSchema,
|
||||
AgentPermissionResolvedMessageSchema,
|
||||
GitDiffResponseSchema,
|
||||
]);
|
||||
|
||||
export type SessionOutboundMessage = z.infer<
|
||||
@@ -374,6 +390,8 @@ export type CreateAgentRequestMessage = z.infer<typeof CreateAgentRequestMessage
|
||||
export type InitializeAgentRequestMessage = z.infer<typeof InitializeAgentRequestMessageSchema>;
|
||||
export type SetAgentModeMessage = z.infer<typeof SetAgentModeMessageSchema>;
|
||||
export type AgentPermissionResponseMessage = z.infer<typeof AgentPermissionResponseMessageSchema>;
|
||||
export type GitDiffRequest = z.infer<typeof GitDiffRequestSchema>;
|
||||
export type GitDiffResponse = z.infer<typeof GitDiffResponseSchema>;
|
||||
|
||||
// ============================================================================
|
||||
// WebSocket Level Messages (wraps session messages)
|
||||
|
||||
@@ -518,6 +518,10 @@ export class Session {
|
||||
msg.optionId
|
||||
);
|
||||
break;
|
||||
|
||||
case "git_diff_request":
|
||||
await this.handleGitDiffRequest(msg.agentId);
|
||||
break;
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error(
|
||||
@@ -1016,6 +1020,62 @@ export class Session {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle git diff request for an agent
|
||||
*/
|
||||
private async handleGitDiffRequest(agentId: string): Promise<void> {
|
||||
console.log(
|
||||
`[Session ${this.clientId}] Handling git diff request for agent ${agentId}`
|
||||
);
|
||||
|
||||
try {
|
||||
const agents = this.agentManager.listAgents();
|
||||
const agent = agents.find((a) => a.id === agentId);
|
||||
|
||||
if (!agent) {
|
||||
this.emit({
|
||||
type: "git_diff_response",
|
||||
payload: {
|
||||
agentId,
|
||||
diff: "",
|
||||
error: `Agent not found: ${agentId}`,
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const { stdout } = await execAsync("git diff HEAD", {
|
||||
cwd: agent.cwd,
|
||||
});
|
||||
|
||||
this.emit({
|
||||
type: "git_diff_response",
|
||||
payload: {
|
||||
agentId,
|
||||
diff: stdout,
|
||||
error: null,
|
||||
},
|
||||
});
|
||||
|
||||
console.log(
|
||||
`[Session ${this.clientId}] Git diff for agent ${agentId} completed (${stdout.length} bytes)`
|
||||
);
|
||||
} catch (error: any) {
|
||||
console.error(
|
||||
`[Session ${this.clientId}] Failed to get git diff for agent ${agentId}:`,
|
||||
error
|
||||
);
|
||||
this.emit({
|
||||
type: "git_diff_response",
|
||||
payload: {
|
||||
agentId,
|
||||
diff: "",
|
||||
error: error.message,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send current session state (live agents and commands) to client
|
||||
*/
|
||||
|
||||
@@ -6,5 +6,11 @@ export default defineConfig({
|
||||
hookTimeout: 30000,
|
||||
globals: true,
|
||||
environment: "node",
|
||||
pool: "threads",
|
||||
poolOptions: {
|
||||
threads: {
|
||||
singleThread: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user