mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
refactor(agent): unify timeline events with tool_call schema and add resume/refresh capabilities
This commit is contained in:
@@ -13,7 +13,7 @@ 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, Folder } from "lucide-react-native";
|
||||
import { MoreVertical, GitBranch, Folder, RotateCcw } 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";
|
||||
@@ -34,6 +34,7 @@ export default function AgentScreen() {
|
||||
pendingPermissions,
|
||||
respondToPermission,
|
||||
initializeAgent,
|
||||
refreshAgent,
|
||||
} = useSession();
|
||||
const { registerFooterControls, unregisterFooterControls } = useFooterControls();
|
||||
const { width: windowWidth, height: windowHeight } = useWindowDimensions();
|
||||
@@ -192,6 +193,18 @@ export default function AgentScreen() {
|
||||
}
|
||||
}, [handleCloseMenu, id, router]);
|
||||
|
||||
const handleRefreshAgent = useCallback(() => {
|
||||
if (!id) {
|
||||
return;
|
||||
}
|
||||
if (!agent?.persistence) {
|
||||
handleCloseMenu();
|
||||
return;
|
||||
}
|
||||
handleCloseMenu();
|
||||
refreshAgent({ agentId: id });
|
||||
}, [agent?.persistence, handleCloseMenu, id, refreshAgent]);
|
||||
|
||||
if (!agent) {
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
@@ -268,6 +281,28 @@ export default function AgentScreen() {
|
||||
<Folder size={20} color={theme.colors.foreground} />
|
||||
<Text style={styles.menuItemText}>Browse Files</Text>
|
||||
</Pressable>
|
||||
{agent.persistence && (
|
||||
<Pressable
|
||||
onPress={handleRefreshAgent}
|
||||
style={[
|
||||
styles.menuItem,
|
||||
isInitializing ? styles.menuItemDisabled : null,
|
||||
]}
|
||||
disabled={isInitializing}
|
||||
>
|
||||
<RotateCcw size={20} color={theme.colors.foreground} />
|
||||
<Text style={styles.menuItemText}>
|
||||
{isInitializing ? "Refreshing..." : "Refresh from disk"}
|
||||
</Text>
|
||||
{isInitializing && (
|
||||
<ActivityIndicator
|
||||
size="small"
|
||||
color={theme.colors.primary}
|
||||
style={styles.menuItemSpinner}
|
||||
/>
|
||||
)}
|
||||
</Pressable>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
</Modal>
|
||||
@@ -334,6 +369,12 @@ const styles = StyleSheet.create((theme) => ({
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
borderRadius: theme.borderRadius.md,
|
||||
},
|
||||
menuItemDisabled: {
|
||||
opacity: 0.6,
|
||||
},
|
||||
menuItemSpinner: {
|
||||
marginLeft: "auto",
|
||||
},
|
||||
menuItemText: {
|
||||
fontSize: theme.fontSize.base,
|
||||
color: theme.colors.foreground,
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
ActivityIndicator,
|
||||
Image,
|
||||
FlatList,
|
||||
Image as RNImage,
|
||||
ListRenderItemInfo,
|
||||
NativeScrollEvent,
|
||||
NativeSyntheticEvent,
|
||||
Pressable,
|
||||
ScrollView,
|
||||
Text,
|
||||
@@ -10,7 +14,15 @@ import {
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { useLocalSearchParams } from "expo-router";
|
||||
import * as Clipboard from "expo-clipboard";
|
||||
import { Copy, Check, ArrowLeft } from "lucide-react-native";
|
||||
import {
|
||||
Copy,
|
||||
Check,
|
||||
ArrowLeft,
|
||||
File,
|
||||
FileText,
|
||||
Folder,
|
||||
Image as ImageIcon,
|
||||
} from "lucide-react-native";
|
||||
import { BackHeader } from "@/components/headers/back-header";
|
||||
import { useSession, type ExplorerEntry } from "@/contexts/session-context";
|
||||
|
||||
@@ -36,6 +48,8 @@ export default function FileExplorerScreen() {
|
||||
const pendingFileParamRef = useRef<string | null>(null);
|
||||
const [copiedPath, setCopiedPath] = useState<string | null>(null);
|
||||
const copyTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const listScrollRef = useRef<FlatList<ExplorerEntry> | null>(null);
|
||||
const listScrollOffsetRef = useRef(0);
|
||||
|
||||
const normalizedPathParam = normalizePathParam(getFirstParam(pathParamRaw));
|
||||
const normalizedFileParam = normalizeFileParam(getFirstParam(fileParamRaw));
|
||||
@@ -47,9 +61,21 @@ export default function FileExplorerScreen() {
|
||||
const agent = agentId ? agents.get(agentId) : undefined;
|
||||
const explorerState = agentId ? fileExplorer.get(agentId) : undefined;
|
||||
const currentPath = explorerState?.currentPath ?? ".";
|
||||
const directory = explorerState?.directories.get(currentPath);
|
||||
const pendingRequest = explorerState?.pendingRequest ?? null;
|
||||
const isExplorerLoading = explorerState?.isLoading ?? false;
|
||||
const isListingLoading = Boolean(
|
||||
isExplorerLoading && pendingRequest?.mode === "list"
|
||||
);
|
||||
const pendingDirectoryPath =
|
||||
isListingLoading && pendingRequest ? pendingRequest.path : null;
|
||||
const activePath = pendingDirectoryPath ?? currentPath;
|
||||
const directory = explorerState?.directories.get(activePath);
|
||||
const entries = directory?.entries ?? [];
|
||||
const isLoading = explorerState?.isLoading ?? false;
|
||||
const showInitialListLoading = isListingLoading && entries.length === 0;
|
||||
const showListLoadingBanner = isListingLoading && entries.length > 0;
|
||||
const isPreviewLoading = Boolean(
|
||||
isExplorerLoading && pendingRequest?.mode === "file"
|
||||
);
|
||||
const error = explorerState?.lastError ?? null;
|
||||
const preview = selectedEntryPath
|
||||
? explorerState?.files.get(selectedEntryPath)
|
||||
@@ -58,21 +84,39 @@ export default function FileExplorerScreen() {
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedEntryPath(null);
|
||||
}, [currentPath]);
|
||||
}, [activePath]);
|
||||
|
||||
useEffect(() => {
|
||||
if (shouldShowPreview) {
|
||||
return;
|
||||
}
|
||||
|
||||
const targetOffset = listScrollOffsetRef.current;
|
||||
if (!listScrollRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
listScrollRef.current.scrollToOffset({ offset: targetOffset, animated: false });
|
||||
}, [shouldShowPreview]);
|
||||
|
||||
useEffect(() => {
|
||||
listScrollOffsetRef.current = 0;
|
||||
listScrollRef.current?.scrollToOffset({ offset: 0, animated: false });
|
||||
}, [activePath]);
|
||||
|
||||
const parentPath = useMemo(() => {
|
||||
if (currentPath === ".") {
|
||||
if (activePath === ".") {
|
||||
return null;
|
||||
}
|
||||
const segments = currentPath.split("/");
|
||||
const segments = activePath.split("/");
|
||||
segments.pop();
|
||||
const nextPath = segments.join("/");
|
||||
return nextPath.length === 0 ? "." : nextPath;
|
||||
}, [currentPath]);
|
||||
}, [activePath]);
|
||||
|
||||
useEffect(() => {
|
||||
setCopiedPath(null);
|
||||
}, [currentPath]);
|
||||
}, [activePath]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!agentId || !initialTargetDirectory) {
|
||||
@@ -163,6 +207,76 @@ export default function FileExplorerScreen() {
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleListScroll = useCallback(
|
||||
(event: NativeSyntheticEvent<NativeScrollEvent>) => {
|
||||
listScrollOffsetRef.current = event.nativeEvent.contentOffset.y;
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const renderEntry = useCallback(
|
||||
({ item }: ListRenderItemInfo<ExplorerEntry>) => {
|
||||
const displayKind = getEntryDisplayKind(item);
|
||||
return (
|
||||
<Pressable
|
||||
style={[
|
||||
styles.entryRow,
|
||||
item.kind === "directory" ? styles.directoryRow : styles.fileRow,
|
||||
]}
|
||||
onPress={() => handleEntryPress(item)}
|
||||
>
|
||||
<View style={styles.entryInfo}>
|
||||
<View style={styles.entryIcon}>
|
||||
{renderEntryIcon(displayKind, theme.colors)}
|
||||
</View>
|
||||
<View style={styles.entryTextContainer}>
|
||||
<Text style={styles.entryName}>{item.name}</Text>
|
||||
<Text style={styles.entryMeta}>
|
||||
{item.kind.toUpperCase()} · {formatFileSize({ size: item.size })} ·{" "}
|
||||
{formatModifiedTime({ value: item.modifiedAt })}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
<Pressable
|
||||
onPress={(event) => {
|
||||
event.stopPropagation();
|
||||
handleCopyPath(item.path);
|
||||
}}
|
||||
hitSlop={8}
|
||||
style={styles.copyButton}
|
||||
>
|
||||
{copiedPath === item.path ? (
|
||||
<Check size={16} color={theme.colors.primary} />
|
||||
) : (
|
||||
<Copy size={16} color={theme.colors.foreground} />
|
||||
)}
|
||||
</Pressable>
|
||||
</Pressable>
|
||||
);
|
||||
},
|
||||
[copiedPath, handleCopyPath, handleEntryPress, theme.colors]
|
||||
);
|
||||
|
||||
const listHeaderComponent = useMemo(() => {
|
||||
if (!parentPath && !showListLoadingBanner) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={styles.headerContainer}>
|
||||
{parentPath && <UpRow label=".." onPress={handleNavigateUp} />}
|
||||
{showListLoadingBanner && (
|
||||
<View style={styles.loadingBanner}>
|
||||
<ActivityIndicator size="small" />
|
||||
<Text style={styles.loadingBannerText}>
|
||||
Loading {formatDirectoryLabel(activePath)}...
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}, [activePath, handleNavigateUp, parentPath, showListLoadingBanner]);
|
||||
|
||||
if (!agent) {
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
@@ -176,14 +290,14 @@ export default function FileExplorerScreen() {
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<BackHeader title={selectedEntryPath ?? (currentPath || ".")} />
|
||||
<BackHeader title={selectedEntryPath ?? (activePath || ".")} />
|
||||
|
||||
<View style={styles.content}>
|
||||
{shouldShowPreview ? (
|
||||
<View style={styles.previewWrapper}>
|
||||
<UpRow label="Back to directory" onPress={() => setSelectedEntryPath(null)} />
|
||||
<View style={styles.previewSection}>
|
||||
{isLoading && !preview ? (
|
||||
{isPreviewLoading && !preview ? (
|
||||
<View style={styles.centerState}>
|
||||
<ActivityIndicator size="small" />
|
||||
<Text style={styles.loadingText}>Loading file...</Text>
|
||||
@@ -204,7 +318,7 @@ export default function FileExplorerScreen() {
|
||||
</ScrollView>
|
||||
) : preview.kind === "image" && preview.content ? (
|
||||
<View style={styles.imagePreviewContainer}>
|
||||
<Image
|
||||
<RNImage
|
||||
source={{
|
||||
uri: `data:${preview.mimeType ?? "image/png"};base64,${
|
||||
preview.content
|
||||
@@ -230,52 +344,30 @@ export default function FileExplorerScreen() {
|
||||
<View style={styles.centerState}>
|
||||
<Text style={styles.errorText}>{error}</Text>
|
||||
</View>
|
||||
) : isLoading && entries.length === 0 ? (
|
||||
) : showInitialListLoading ? (
|
||||
<View style={styles.centerState}>
|
||||
<ActivityIndicator size="small" />
|
||||
<Text style={styles.loadingText}>Loading...</Text>
|
||||
<Text style={styles.loadingText}>Loading directory...</Text>
|
||||
</View>
|
||||
) : entries.length === 0 ? (
|
||||
<View style={styles.centerState}>
|
||||
<Text style={styles.emptyText}>Directory is empty</Text>
|
||||
</View>
|
||||
) : (
|
||||
<ScrollView contentContainerStyle={styles.entriesContent}>
|
||||
{parentPath && <UpRow label=".." onPress={handleNavigateUp} />}
|
||||
{entries.map((entry) => (
|
||||
<Pressable
|
||||
key={entry.path}
|
||||
style={[
|
||||
styles.entryRow,
|
||||
entry.kind === "directory"
|
||||
? styles.directoryRow
|
||||
: styles.fileRow,
|
||||
]}
|
||||
onPress={() => handleEntryPress(entry)}
|
||||
>
|
||||
<View style={styles.entryTextContainer}>
|
||||
<Text style={styles.entryName}>{entry.name}</Text>
|
||||
<Text style={styles.entryMeta}>
|
||||
{entry.kind.toUpperCase()} · {formatFileSize({ size: entry.size })} · {formatModifiedTime({ value: entry.modifiedAt })}
|
||||
</Text>
|
||||
</View>
|
||||
<Pressable
|
||||
onPress={(event) => {
|
||||
event.stopPropagation();
|
||||
handleCopyPath(entry.path);
|
||||
}}
|
||||
hitSlop={8}
|
||||
style={styles.copyButton}
|
||||
>
|
||||
{copiedPath === entry.path ? (
|
||||
<Check size={16} color={theme.colors.primary} />
|
||||
) : (
|
||||
<Copy size={16} color={theme.colors.foreground} />
|
||||
)}
|
||||
</Pressable>
|
||||
</Pressable>
|
||||
))}
|
||||
</ScrollView>
|
||||
<FlatList
|
||||
ref={listScrollRef}
|
||||
data={entries}
|
||||
renderItem={renderEntry}
|
||||
keyExtractor={(item) => item.path}
|
||||
contentContainerStyle={styles.entriesContent}
|
||||
onScroll={handleListScroll}
|
||||
scrollEventThrottle={16}
|
||||
ListHeaderComponent={listHeaderComponent}
|
||||
extraData={copiedPath}
|
||||
initialNumToRender={20}
|
||||
maxToRenderPerBatch={30}
|
||||
windowSize={10}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
@@ -284,6 +376,10 @@ export default function FileExplorerScreen() {
|
||||
);
|
||||
}
|
||||
|
||||
function formatDirectoryLabel(path: string): string {
|
||||
return path === "." ? "workspace root" : path;
|
||||
}
|
||||
|
||||
function formatFileSize({ size }: { size: number }): string {
|
||||
if (size < 1024) {
|
||||
return `${size} B`;
|
||||
@@ -351,6 +447,104 @@ function UpRow({ label, onPress }: { label: string; onPress: () => void }) {
|
||||
);
|
||||
}
|
||||
|
||||
type EntryDisplayKind = "directory" | "image" | "text" | "other";
|
||||
|
||||
const IMAGE_EXTENSIONS = new Set([
|
||||
"png",
|
||||
"jpg",
|
||||
"jpeg",
|
||||
"gif",
|
||||
"bmp",
|
||||
"svg",
|
||||
"webp",
|
||||
"ico",
|
||||
]);
|
||||
|
||||
const TEXT_EXTENSIONS = new Set([
|
||||
"txt",
|
||||
"md",
|
||||
"markdown",
|
||||
"ts",
|
||||
"tsx",
|
||||
"js",
|
||||
"jsx",
|
||||
"json",
|
||||
"yml",
|
||||
"yaml",
|
||||
"toml",
|
||||
"py",
|
||||
"rb",
|
||||
"go",
|
||||
"rs",
|
||||
"java",
|
||||
"kt",
|
||||
"c",
|
||||
"cpp",
|
||||
"cc",
|
||||
"h",
|
||||
"hpp",
|
||||
"cs",
|
||||
"swift",
|
||||
"php",
|
||||
"html",
|
||||
"css",
|
||||
"scss",
|
||||
"less",
|
||||
"xml",
|
||||
"sh",
|
||||
"bash",
|
||||
"zsh",
|
||||
"ini",
|
||||
"cfg",
|
||||
"conf",
|
||||
]);
|
||||
|
||||
function renderEntryIcon(
|
||||
kind: EntryDisplayKind,
|
||||
colors: { foreground: string; primary: string }
|
||||
) {
|
||||
const color = colors.foreground;
|
||||
switch (kind) {
|
||||
case "directory":
|
||||
return <Folder size={18} color={colors.primary} />;
|
||||
case "image":
|
||||
return <ImageIcon size={18} color={color} />;
|
||||
case "text":
|
||||
return <FileText size={18} color={color} />;
|
||||
default:
|
||||
return <File size={18} color={color} />;
|
||||
}
|
||||
}
|
||||
|
||||
function getEntryDisplayKind(entry: ExplorerEntry): EntryDisplayKind {
|
||||
if (entry.kind === "directory") {
|
||||
return "directory";
|
||||
}
|
||||
|
||||
const extension = getExtension(entry.name);
|
||||
if (extension === null) {
|
||||
return "other";
|
||||
}
|
||||
|
||||
if (IMAGE_EXTENSIONS.has(extension)) {
|
||||
return "image";
|
||||
}
|
||||
|
||||
if (TEXT_EXTENSIONS.has(extension)) {
|
||||
return "text";
|
||||
}
|
||||
|
||||
return "other";
|
||||
}
|
||||
|
||||
function getExtension(name: string): string | null {
|
||||
const index = name.lastIndexOf(".");
|
||||
if (index === -1 || index === name.length - 1) {
|
||||
return null;
|
||||
}
|
||||
return name.slice(index + 1).toLowerCase();
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
container: {
|
||||
flex: 1,
|
||||
@@ -369,6 +563,21 @@ const styles = StyleSheet.create((theme) => ({
|
||||
entriesContent: {
|
||||
paddingBottom: theme.spacing[4],
|
||||
},
|
||||
headerContainer: {
|
||||
gap: theme.spacing[2],
|
||||
paddingBottom: theme.spacing[2],
|
||||
},
|
||||
loadingBanner: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
paddingVertical: theme.spacing[2],
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
},
|
||||
loadingBannerText: {
|
||||
color: theme.colors.mutedForeground,
|
||||
fontSize: theme.fontSize.sm,
|
||||
},
|
||||
previewWrapper: {
|
||||
flex: 1,
|
||||
gap: theme.spacing[2],
|
||||
@@ -421,9 +630,19 @@ const styles = StyleSheet.create((theme) => ({
|
||||
selectedRow: {
|
||||
borderColor: theme.colors.primary,
|
||||
},
|
||||
entryInfo: {
|
||||
flex: 1,
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
columnGap: theme.spacing[2],
|
||||
marginRight: theme.spacing[3],
|
||||
},
|
||||
entryIcon: {
|
||||
width: 28,
|
||||
alignItems: "center",
|
||||
},
|
||||
entryTextContainer: {
|
||||
flex: 1,
|
||||
marginRight: theme.spacing[3],
|
||||
},
|
||||
entryName: {
|
||||
color: theme.colors.foreground,
|
||||
|
||||
@@ -15,6 +15,7 @@ import Animated, {
|
||||
} from "react-native-reanimated";
|
||||
import type { Agent } from "@/contexts/session-context";
|
||||
import { getAgentStatusColor, getAgentStatusLabel } from "@/utils/agent-status";
|
||||
import { getAgentProviderDefinition } from "@server/server/agent/provider-manifest";
|
||||
|
||||
interface AgentSidebarProps {
|
||||
isOpen: boolean;
|
||||
@@ -179,6 +180,7 @@ export function AgentSidebar({
|
||||
const isActive = agent.id === activeAgentId;
|
||||
const statusColor = getAgentStatusColor(agent.status);
|
||||
const statusLabel = getAgentStatusLabel(agent.status);
|
||||
const providerLabel = getAgentProviderDefinition(agent.provider).label;
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
@@ -209,6 +211,18 @@ export function AgentSidebar({
|
||||
</Text>
|
||||
|
||||
<View style={styles.statusBadge}>
|
||||
<View style={[styles.providerBadge, { backgroundColor: theme.colors.muted }]}>
|
||||
<Text
|
||||
style={[
|
||||
styles.providerText,
|
||||
{ color: theme.colors.mutedForeground },
|
||||
]}
|
||||
numberOfLines={1}
|
||||
>
|
||||
{providerLabel}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View
|
||||
style={[styles.statusDot, { backgroundColor: statusColor }]}
|
||||
/>
|
||||
@@ -309,6 +323,15 @@ const styles = StyleSheet.create({
|
||||
alignItems: "center",
|
||||
gap: 4,
|
||||
},
|
||||
providerBadge: {
|
||||
paddingHorizontal: 8,
|
||||
paddingVertical: 2,
|
||||
borderRadius: 999,
|
||||
},
|
||||
providerText: {
|
||||
fontSize: 11,
|
||||
fontWeight: "600",
|
||||
},
|
||||
statusDot: {
|
||||
width: 6,
|
||||
height: 6,
|
||||
|
||||
@@ -235,9 +235,11 @@ export function AgentStreamView({
|
||||
|
||||
if (payload.source === "agent") {
|
||||
const data = payload.data;
|
||||
const toolLabel = data.displayName ?? `${data.server}/${data.tool}`;
|
||||
return (
|
||||
<ToolCall
|
||||
toolName={`${data.server}/${data.tool}`}
|
||||
toolName={toolLabel}
|
||||
kind={data.kind}
|
||||
args={data.raw}
|
||||
status={data.status as "executing" | "completed" | "failed"}
|
||||
onOpenDetails={() => handleOpenToolCallDetails({ payload })}
|
||||
|
||||
@@ -5,12 +5,14 @@ import {
|
||||
Text,
|
||||
Pressable,
|
||||
ScrollView,
|
||||
FlatList,
|
||||
ActivityIndicator,
|
||||
InteractionManager,
|
||||
TextInput,
|
||||
Modal,
|
||||
useWindowDimensions,
|
||||
type LayoutChangeEvent,
|
||||
type ListRenderItem,
|
||||
} from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { useReanimatedKeyboardAnimation } from "react-native-keyboard-controller";
|
||||
@@ -32,7 +34,8 @@ import {
|
||||
AGENT_PROVIDER_DEFINITIONS,
|
||||
type AgentProviderDefinition,
|
||||
} from "@server/server/agent/provider-manifest";
|
||||
import type { AgentProvider, AgentMode, AgentSessionConfig } from "@server/server/agent/agent-sdk-types";
|
||||
import type { AgentProvider, AgentMode, AgentSessionConfig, AgentPersistenceHandle } from "@server/server/agent/agent-sdk-types";
|
||||
import type { WSInboundMessage } from "@server/server/messages";
|
||||
|
||||
interface CreateAgentModalProps {
|
||||
isVisible: boolean;
|
||||
@@ -48,6 +51,40 @@ const fallbackDefinition = providerDefinitions[0];
|
||||
const DEFAULT_PROVIDER: AgentProvider = fallbackDefinition?.id ?? "claude";
|
||||
const DEFAULT_MODE_FOR_DEFAULT_PROVIDER = fallbackDefinition?.defaultModeId ?? "";
|
||||
const BACKDROP_OPACITY = 0.55;
|
||||
const RESUME_PAGE_SIZE = 20;
|
||||
|
||||
type ResumeCandidate = {
|
||||
provider: AgentProvider;
|
||||
sessionId: string;
|
||||
cwd: string;
|
||||
title: string;
|
||||
lastActivityAt: Date;
|
||||
persistence: AgentPersistenceHandle;
|
||||
};
|
||||
|
||||
type ResumeTab = "new" | "resume";
|
||||
type ProviderFilter = "all" | AgentProvider;
|
||||
|
||||
function formatRelativeTime(date: Date): string {
|
||||
const now = Date.now();
|
||||
const diffMs = now - date.getTime();
|
||||
if (!Number.isFinite(diffMs)) {
|
||||
return "unknown";
|
||||
}
|
||||
const minutes = Math.max(0, Math.floor(diffMs / 60000));
|
||||
if (minutes < 1) {
|
||||
return "just now";
|
||||
}
|
||||
if (minutes < 60) {
|
||||
return `${minutes}m ago`;
|
||||
}
|
||||
const hours = Math.floor(minutes / 60);
|
||||
if (hours < 24) {
|
||||
return `${hours}h ago`;
|
||||
}
|
||||
const days = Math.floor(hours / 24);
|
||||
return `${days}d ago`;
|
||||
}
|
||||
|
||||
export function CreateAgentModal({
|
||||
isVisible,
|
||||
@@ -61,7 +98,7 @@ export function CreateAgentModal({
|
||||
const isCompactLayout = screenWidth < 720;
|
||||
|
||||
const { recentPaths, addRecentPath } = useRecentPaths();
|
||||
const { ws, createAgent } = useSession();
|
||||
const { ws, createAgent, resumeAgent } = useSession();
|
||||
const router = useRouter();
|
||||
|
||||
const [isMounted, setIsMounted] = useState(isVisible);
|
||||
@@ -73,12 +110,54 @@ export function CreateAgentModal({
|
||||
const [worktreeName, setWorktreeName] = useState("");
|
||||
const [errorMessage, setErrorMessage] = useState("");
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [pendingRequestId, setPendingRequestId] = useState<string | null>(null);
|
||||
const [activeTab, setActiveTab] = useState<ResumeTab>("new");
|
||||
const [resumeProviderFilter, setResumeProviderFilter] = useState<ProviderFilter>("all");
|
||||
const [resumeSearchQuery, setResumeSearchQuery] = useState("");
|
||||
const [resumeCandidates, setResumeCandidates] = useState<ResumeCandidate[]>([]);
|
||||
const [isResumeLoading, setIsResumeLoading] = useState(false);
|
||||
const [resumeError, setResumeError] = useState<string | null>(null);
|
||||
const pendingRequestIdRef = useRef<string | null>(null);
|
||||
|
||||
const pendingNavigationAgentIdRef = useRef<string | null>(null);
|
||||
|
||||
const tabOptions = useMemo(
|
||||
() => [
|
||||
{ id: "new" as ResumeTab, label: "New Agent" },
|
||||
{ id: "resume" as ResumeTab, label: "Resume Agent" },
|
||||
],
|
||||
[]
|
||||
);
|
||||
const providerFilterOptions = useMemo(
|
||||
() => [
|
||||
{ id: "all" as ProviderFilter, label: "All" },
|
||||
...providerDefinitions.map((definition) => ({
|
||||
id: definition.id as ProviderFilter,
|
||||
label: definition.label,
|
||||
})),
|
||||
],
|
||||
[]
|
||||
);
|
||||
const getProviderLabel = useCallback(
|
||||
(provider: AgentProvider) => providerDefinitionMap.get(provider)?.label ?? provider,
|
||||
[]
|
||||
);
|
||||
const agentDefinition = providerDefinitionMap.get(selectedProvider);
|
||||
const modeOptions = agentDefinition?.modes ?? [];
|
||||
const filteredResumeCandidates = useMemo(() => {
|
||||
const providerFilter = resumeProviderFilter;
|
||||
const query = resumeSearchQuery.trim().toLowerCase();
|
||||
return resumeCandidates
|
||||
.filter((candidate) => providerFilter === "all" || candidate.provider === providerFilter)
|
||||
.filter((candidate) => {
|
||||
if (query.length === 0) {
|
||||
return true;
|
||||
}
|
||||
const titleText = candidate.title.toLowerCase();
|
||||
const cwdText = candidate.cwd.toLowerCase();
|
||||
return titleText.includes(query) || cwdText.includes(query);
|
||||
})
|
||||
.sort((a, b) => b.lastActivityAt.getTime() - a.lastActivityAt.getTime());
|
||||
}, [resumeCandidates, resumeProviderFilter, resumeSearchQuery]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!agentDefinition) {
|
||||
@@ -108,7 +187,7 @@ export function CreateAgentModal({
|
||||
setSelectedMode(DEFAULT_MODE_FOR_DEFAULT_PROVIDER);
|
||||
setErrorMessage("");
|
||||
setIsLoading(false);
|
||||
setPendingRequestId(null);
|
||||
pendingRequestIdRef.current = null;
|
||||
}, []);
|
||||
|
||||
const navigateToAgentIfNeeded = useCallback(() => {
|
||||
@@ -130,6 +209,29 @@ export function CreateAgentModal({
|
||||
navigateToAgentIfNeeded();
|
||||
}, [navigateToAgentIfNeeded, resetFormState]);
|
||||
|
||||
const requestResumeCandidates = useCallback(
|
||||
(provider?: AgentProvider) => {
|
||||
setIsResumeLoading(true);
|
||||
setResumeError(null);
|
||||
const msg: WSInboundMessage = {
|
||||
type: "session",
|
||||
message: {
|
||||
type: "list_persisted_agents_request",
|
||||
...(provider ? { provider } : {}),
|
||||
limit: RESUME_PAGE_SIZE,
|
||||
},
|
||||
};
|
||||
try {
|
||||
ws.send(msg);
|
||||
} catch (error) {
|
||||
console.error("[CreateAgentModal] Failed to request persisted agents:", error);
|
||||
setIsResumeLoading(false);
|
||||
setResumeError("Unable to load saved agents. Please try again.");
|
||||
}
|
||||
},
|
||||
[ws]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isVisible) {
|
||||
console.log("[CreateAgentModal] visibility effect skipped (isVisible is false)", {
|
||||
@@ -284,8 +386,8 @@ export function CreateAgentModal({
|
||||
|
||||
const requestId = generateMessageId();
|
||||
|
||||
pendingRequestIdRef.current = requestId;
|
||||
setIsLoading(true);
|
||||
setPendingRequestId(requestId);
|
||||
setErrorMessage("");
|
||||
|
||||
const modeId = modeOptions.length > 0 && selectedMode !== "" ? selectedMode : undefined;
|
||||
@@ -306,7 +408,7 @@ export function CreateAgentModal({
|
||||
console.error("[CreateAgentModal] Failed to create agent:", error);
|
||||
setErrorMessage("Failed to create agent. Please try again.");
|
||||
setIsLoading(false);
|
||||
setPendingRequestId(null);
|
||||
pendingRequestIdRef.current = null;
|
||||
}
|
||||
}, [
|
||||
workingDir,
|
||||
@@ -319,26 +421,96 @@ export function CreateAgentModal({
|
||||
addRecentPath,
|
||||
createAgent,
|
||||
]);
|
||||
|
||||
const handleResumeCandidatePress = useCallback(
|
||||
(candidate: ResumeCandidate) => {
|
||||
if (isLoading) {
|
||||
return;
|
||||
}
|
||||
setErrorMessage("");
|
||||
const requestId = generateMessageId();
|
||||
pendingRequestIdRef.current = requestId;
|
||||
setIsLoading(true);
|
||||
resumeAgent({
|
||||
handle: candidate.persistence,
|
||||
requestId,
|
||||
});
|
||||
},
|
||||
[isLoading, resumeAgent]
|
||||
);
|
||||
|
||||
const renderResumeItem = useCallback<ListRenderItem<ResumeCandidate>>(
|
||||
({ item }) => (
|
||||
<Pressable
|
||||
onPress={() => handleResumeCandidatePress(item)}
|
||||
disabled={isLoading}
|
||||
style={styles.resumeItem}
|
||||
>
|
||||
<View style={styles.resumeItemHeader}>
|
||||
<Text style={styles.resumeItemTitle} numberOfLines={1}>
|
||||
{item.title}
|
||||
</Text>
|
||||
<Text style={styles.resumeItemTimestamp}>{formatRelativeTime(item.lastActivityAt)}</Text>
|
||||
</View>
|
||||
<Text style={styles.resumeItemPath} numberOfLines={1}>
|
||||
{item.cwd}
|
||||
</Text>
|
||||
<View style={styles.resumeItemMetaRow}>
|
||||
<View style={styles.resumeProviderBadge}>
|
||||
<Text style={styles.resumeProviderBadgeText}>{getProviderLabel(item.provider)}</Text>
|
||||
</View>
|
||||
<Text style={styles.resumeItemHint}>Tap to resume</Text>
|
||||
</View>
|
||||
</Pressable>
|
||||
),
|
||||
[getProviderLabel, handleResumeCandidatePress, isLoading]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!pendingRequestId) {
|
||||
return;
|
||||
}
|
||||
const unsubscribe = ws.on("list_persisted_agents_response", (message) => {
|
||||
if (message.type !== "list_persisted_agents_response") {
|
||||
return;
|
||||
}
|
||||
const mapped = message.payload.items.map((item) => ({
|
||||
provider: item.provider,
|
||||
sessionId: item.sessionId,
|
||||
cwd: item.cwd,
|
||||
title: item.title ?? `Session ${item.sessionId.slice(0, 8)}`,
|
||||
lastActivityAt: new Date(item.lastActivityAt),
|
||||
persistence: item.persistence,
|
||||
})) as ResumeCandidate[];
|
||||
|
||||
setResumeCandidates(mapped);
|
||||
setIsResumeLoading(false);
|
||||
});
|
||||
|
||||
return () => {
|
||||
unsubscribe();
|
||||
};
|
||||
}, [ws]);
|
||||
|
||||
useEffect(() => {
|
||||
const unsubscribe = ws.on("status", (message) => {
|
||||
if (message.type !== "status") {
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = message.payload as { status: string; agentId?: string; requestId?: string };
|
||||
if (payload.status !== "agent_created") {
|
||||
if (
|
||||
(payload.status !== "agent_created" && payload.status !== "agent_resumed") ||
|
||||
!payload.agentId
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if (payload.requestId !== pendingRequestId || !payload.agentId) {
|
||||
|
||||
const expectedRequestId = pendingRequestIdRef.current;
|
||||
if (!expectedRequestId || payload.requestId !== expectedRequestId) {
|
||||
return;
|
||||
}
|
||||
|
||||
console.log("[CreateAgentModal] Agent created:", payload.agentId);
|
||||
pendingRequestIdRef.current = null;
|
||||
setIsLoading(false);
|
||||
setPendingRequestId(null);
|
||||
pendingNavigationAgentIdRef.current = payload.agentId;
|
||||
handleClose();
|
||||
});
|
||||
@@ -346,7 +518,20 @@ export function CreateAgentModal({
|
||||
return () => {
|
||||
unsubscribe();
|
||||
};
|
||||
}, [pendingRequestId, ws, handleClose]);
|
||||
}, [ws, handleClose]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isVisible || activeTab !== "resume") {
|
||||
return;
|
||||
}
|
||||
const provider = resumeProviderFilter === "all" ? undefined : resumeProviderFilter;
|
||||
requestResumeCandidates(provider);
|
||||
}, [activeTab, isVisible, requestResumeCandidates, resumeProviderFilter]);
|
||||
|
||||
const refreshResumeList = useCallback(() => {
|
||||
const provider = resumeProviderFilter === "all" ? undefined : resumeProviderFilter;
|
||||
requestResumeCandidates(provider);
|
||||
}, [requestResumeCandidates, resumeProviderFilter]);
|
||||
|
||||
const shouldRender = isVisible || isMounted;
|
||||
|
||||
@@ -395,93 +580,194 @@ export function CreateAgentModal({
|
||||
paddingLeft={horizontalPaddingLeft}
|
||||
paddingRight={horizontalPaddingRight}
|
||||
onClose={handleClose}
|
||||
title={activeTab === "resume" ? "Resume Agent" : "Create New Agent"}
|
||||
/>
|
||||
<ScrollView
|
||||
style={styles.scroll}
|
||||
contentContainerStyle={[
|
||||
styles.scrollContent,
|
||||
<View
|
||||
style={[
|
||||
styles.tabSelector,
|
||||
{
|
||||
paddingBottom: insets.bottom + defaultTheme.spacing[16],
|
||||
paddingLeft: horizontalPaddingLeft,
|
||||
paddingRight: horizontalPaddingRight,
|
||||
},
|
||||
]}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
<WorkingDirectorySection
|
||||
errorMessage={errorMessage}
|
||||
isLoading={isLoading}
|
||||
recentPaths={recentPaths}
|
||||
workingDir={workingDir}
|
||||
onChangeWorkingDir={(value) => {
|
||||
setWorkingDir(value);
|
||||
setErrorMessage("");
|
||||
}}
|
||||
/>
|
||||
{tabOptions.map((tab) => {
|
||||
const isActive = activeTab === tab.id;
|
||||
return (
|
||||
<Pressable
|
||||
key={tab.id}
|
||||
onPress={() => setActiveTab(tab.id)}
|
||||
style={[styles.tabButton, isActive && styles.tabButtonActive]}
|
||||
>
|
||||
<Text style={[styles.tabButtonText, isActive && styles.tabButtonTextActive]}>{tab.label}</Text>
|
||||
</Pressable>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
|
||||
{activeTab === "new" ? (
|
||||
<>
|
||||
<ScrollView
|
||||
style={styles.scroll}
|
||||
contentContainerStyle={[
|
||||
styles.scrollContent,
|
||||
{
|
||||
paddingBottom: insets.bottom + defaultTheme.spacing[16],
|
||||
paddingLeft: horizontalPaddingLeft,
|
||||
paddingRight: horizontalPaddingRight,
|
||||
},
|
||||
]}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
<WorkingDirectorySection
|
||||
errorMessage={errorMessage}
|
||||
isLoading={isLoading}
|
||||
recentPaths={recentPaths}
|
||||
workingDir={workingDir}
|
||||
onChangeWorkingDir={(value) => {
|
||||
setWorkingDir(value);
|
||||
setErrorMessage("");
|
||||
}}
|
||||
/>
|
||||
|
||||
<View
|
||||
style={[
|
||||
styles.selectorRow,
|
||||
isCompactLayout && styles.selectorRowStacked,
|
||||
]}
|
||||
>
|
||||
<AssistantSelector
|
||||
providerDefinitions={providerDefinitions}
|
||||
disabled={isLoading}
|
||||
isStacked={isCompactLayout}
|
||||
selectedProvider={selectedProvider}
|
||||
onSelect={setSelectedProvider}
|
||||
/>
|
||||
<ModeSelector
|
||||
disabled={isLoading}
|
||||
isStacked={isCompactLayout}
|
||||
modeOptions={modeOptions}
|
||||
selectedMode={selectedMode}
|
||||
onSelect={setSelectedMode}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<WorktreeSection
|
||||
isLoading={isLoading}
|
||||
value={worktreeName}
|
||||
onChange={(text) => {
|
||||
const slugified = slugifyWorktreeName(text);
|
||||
setWorktreeName(slugified);
|
||||
setErrorMessage("");
|
||||
}}
|
||||
/>
|
||||
</ScrollView>
|
||||
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.footer,
|
||||
{
|
||||
paddingBottom: insets.bottom + defaultTheme.spacing[4],
|
||||
paddingLeft: horizontalPaddingLeft,
|
||||
paddingRight: horizontalPaddingRight,
|
||||
},
|
||||
footerAnimatedStyle,
|
||||
]}
|
||||
>
|
||||
<Pressable
|
||||
style={[
|
||||
styles.createButton,
|
||||
(workingDirIsEmpty || isLoading) && styles.createButtonDisabled,
|
||||
]}
|
||||
onPress={handleCreate}
|
||||
disabled={workingDirIsEmpty || isLoading}
|
||||
>
|
||||
{isLoading ? (
|
||||
<View style={styles.loadingContainer}>
|
||||
<ActivityIndicator color={defaultTheme.colors.palette.white} />
|
||||
<Text style={styles.createButtonText}>Creating...</Text>
|
||||
</View>
|
||||
) : (
|
||||
<Text style={styles.createButtonText}>Create Agent</Text>
|
||||
)}
|
||||
</Pressable>
|
||||
</Animated.View>
|
||||
</>
|
||||
) : (
|
||||
<View
|
||||
style={[
|
||||
styles.selectorRow,
|
||||
isCompactLayout && styles.selectorRowStacked,
|
||||
styles.resumeContainer,
|
||||
{
|
||||
paddingLeft: horizontalPaddingLeft,
|
||||
paddingRight: horizontalPaddingRight,
|
||||
paddingBottom: insets.bottom + defaultTheme.spacing[4],
|
||||
},
|
||||
]}
|
||||
>
|
||||
<AssistantSelector
|
||||
providerDefinitions={providerDefinitions}
|
||||
disabled={isLoading}
|
||||
isStacked={isCompactLayout}
|
||||
selectedProvider={selectedProvider}
|
||||
onSelect={setSelectedProvider}
|
||||
/>
|
||||
<ModeSelector
|
||||
disabled={isLoading}
|
||||
isStacked={isCompactLayout}
|
||||
modeOptions={modeOptions}
|
||||
selectedMode={selectedMode}
|
||||
onSelect={setSelectedMode}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<WorktreeSection
|
||||
isLoading={isLoading}
|
||||
value={worktreeName}
|
||||
onChange={(text) => {
|
||||
const slugified = slugifyWorktreeName(text);
|
||||
setWorktreeName(slugified);
|
||||
setErrorMessage("");
|
||||
}}
|
||||
/>
|
||||
</ScrollView>
|
||||
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.footer,
|
||||
{
|
||||
paddingBottom: insets.bottom + defaultTheme.spacing[4],
|
||||
paddingLeft: horizontalPaddingLeft,
|
||||
paddingRight: horizontalPaddingRight,
|
||||
},
|
||||
footerAnimatedStyle,
|
||||
]}
|
||||
>
|
||||
<Pressable
|
||||
style={[
|
||||
styles.createButton,
|
||||
(workingDirIsEmpty || isLoading) && styles.createButtonDisabled,
|
||||
]}
|
||||
onPress={handleCreate}
|
||||
disabled={workingDirIsEmpty || isLoading}
|
||||
>
|
||||
{isLoading ? (
|
||||
<View style={styles.loadingContainer}>
|
||||
<ActivityIndicator color={defaultTheme.colors.palette.white} />
|
||||
<Text style={styles.createButtonText}>Creating...</Text>
|
||||
<View style={styles.resumeFilters}>
|
||||
<View style={styles.providerFilterRow}>
|
||||
{providerFilterOptions.map((option) => {
|
||||
const isActive = resumeProviderFilter === option.id;
|
||||
return (
|
||||
<Pressable
|
||||
key={option.id}
|
||||
onPress={() => setResumeProviderFilter(option.id)}
|
||||
style={[styles.providerFilterButton, isActive && styles.providerFilterButtonActive]}
|
||||
>
|
||||
<Text
|
||||
style={[
|
||||
styles.providerFilterText,
|
||||
isActive && styles.providerFilterTextActive,
|
||||
]}
|
||||
>
|
||||
{option.label}
|
||||
</Text>
|
||||
</Pressable>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
<View style={styles.resumeSearchRow}>
|
||||
<TextInput
|
||||
style={styles.resumeSearchInput}
|
||||
placeholder="Search by title or path"
|
||||
placeholderTextColor={defaultTheme.colors.mutedForeground}
|
||||
value={resumeSearchQuery}
|
||||
onChangeText={setResumeSearchQuery}
|
||||
/>
|
||||
<Pressable style={styles.refreshButton} onPress={refreshResumeList} disabled={isResumeLoading}>
|
||||
<Text style={styles.refreshButtonText}>Refresh</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
{resumeError ? <Text style={styles.resumeErrorText}>{resumeError}</Text> : null}
|
||||
{isResumeLoading ? (
|
||||
<View style={styles.resumeLoading}>
|
||||
<ActivityIndicator color={defaultTheme.colors.mutedForeground} />
|
||||
<Text style={styles.resumeLoadingText}>Loading saved agents...</Text>
|
||||
</View>
|
||||
) : filteredResumeCandidates.length === 0 ? (
|
||||
<View style={styles.resumeEmptyState}>
|
||||
<Text style={styles.resumeEmptyTitle}>No agents found</Text>
|
||||
<Text style={styles.resumeEmptySubtitle}>
|
||||
We'll load the latest Claude and Codex sessions from your local history.
|
||||
</Text>
|
||||
<Pressable style={styles.refreshButtonAlt} onPress={refreshResumeList}>
|
||||
<Text style={styles.refreshButtonAltText}>Try Again</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
) : (
|
||||
<Text style={styles.createButtonText}>Create Agent</Text>
|
||||
<FlatList
|
||||
data={filteredResumeCandidates}
|
||||
renderItem={renderResumeItem}
|
||||
keyExtractor={(item) => `${item.provider}:${item.sessionId}`}
|
||||
ItemSeparatorComponent={() => <View style={styles.resumeItemSeparator} />}
|
||||
showsVerticalScrollIndicator={false}
|
||||
contentContainerStyle={styles.resumeListContent}
|
||||
/>
|
||||
)}
|
||||
</Pressable>
|
||||
</Animated.View>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</Animated.View>
|
||||
</View>
|
||||
@@ -494,12 +780,13 @@ interface ModalHeaderProps {
|
||||
paddingLeft: number;
|
||||
paddingRight: number;
|
||||
onClose: () => void;
|
||||
title: string;
|
||||
}
|
||||
|
||||
function ModalHeader({ paddingTop, paddingLeft, paddingRight, onClose }: ModalHeaderProps): ReactElement {
|
||||
function ModalHeader({ paddingTop, paddingLeft, paddingRight, onClose, title }: ModalHeaderProps): ReactElement {
|
||||
return (
|
||||
<View style={[styles.header, { paddingTop, paddingLeft, paddingRight }]}>
|
||||
<Text style={styles.headerTitle}>Create New Agent</Text>
|
||||
<Text style={styles.headerTitle}>{title}</Text>
|
||||
<Pressable onPress={onClose} style={styles.closeButton} hitSlop={8}>
|
||||
<X size={20} color={defaultTheme.colors.mutedForeground} />
|
||||
</Pressable>
|
||||
@@ -742,6 +1029,35 @@ const styles = StyleSheet.create((theme) => ({
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
tabSelector: {
|
||||
flexDirection: "row",
|
||||
gap: theme.spacing[2],
|
||||
paddingTop: theme.spacing[4],
|
||||
paddingBottom: theme.spacing[4],
|
||||
},
|
||||
tabButton: {
|
||||
flex: 1,
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
borderWidth: theme.borderWidth[1],
|
||||
borderColor: theme.colors.border,
|
||||
paddingVertical: theme.spacing[3],
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
backgroundColor: theme.colors.background,
|
||||
},
|
||||
tabButtonActive: {
|
||||
backgroundColor: theme.colors.muted,
|
||||
borderColor: theme.colors.palette.blue[500],
|
||||
},
|
||||
tabButtonText: {
|
||||
color: theme.colors.mutedForeground,
|
||||
fontSize: theme.fontSize.sm,
|
||||
fontWeight: theme.fontWeight.normal,
|
||||
},
|
||||
tabButtonTextActive: {
|
||||
color: theme.colors.foreground,
|
||||
fontWeight: theme.fontWeight.semibold,
|
||||
},
|
||||
scroll: {
|
||||
flex: 1,
|
||||
},
|
||||
@@ -894,4 +1210,161 @@ const styles = StyleSheet.create((theme) => ({
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
resumeContainer: {
|
||||
flex: 1,
|
||||
gap: theme.spacing[4],
|
||||
},
|
||||
resumeFilters: {
|
||||
gap: theme.spacing[3],
|
||||
},
|
||||
providerFilterRow: {
|
||||
flexDirection: "row",
|
||||
flexWrap: "wrap",
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
providerFilterButton: {
|
||||
paddingHorizontal: theme.spacing[4],
|
||||
paddingVertical: theme.spacing[2],
|
||||
borderRadius: theme.borderRadius.full,
|
||||
borderWidth: theme.borderWidth[1],
|
||||
borderColor: theme.colors.border,
|
||||
backgroundColor: theme.colors.background,
|
||||
},
|
||||
providerFilterButtonActive: {
|
||||
borderColor: theme.colors.palette.blue[500],
|
||||
backgroundColor: theme.colors.muted,
|
||||
},
|
||||
providerFilterText: {
|
||||
color: theme.colors.mutedForeground,
|
||||
fontSize: theme.fontSize.sm,
|
||||
fontWeight: theme.fontWeight.normal,
|
||||
},
|
||||
providerFilterTextActive: {
|
||||
color: theme.colors.foreground,
|
||||
fontWeight: theme.fontWeight.semibold,
|
||||
},
|
||||
resumeSearchRow: {
|
||||
flexDirection: "row",
|
||||
gap: theme.spacing[3],
|
||||
alignItems: "center",
|
||||
},
|
||||
resumeSearchInput: {
|
||||
flex: 1,
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
borderWidth: theme.borderWidth[1],
|
||||
borderColor: theme.colors.border,
|
||||
backgroundColor: theme.colors.background,
|
||||
paddingHorizontal: theme.spacing[4],
|
||||
paddingVertical: theme.spacing[3],
|
||||
color: theme.colors.foreground,
|
||||
},
|
||||
refreshButton: {
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
borderWidth: theme.borderWidth[1],
|
||||
borderColor: theme.colors.border,
|
||||
paddingHorizontal: theme.spacing[4],
|
||||
paddingVertical: theme.spacing[3],
|
||||
},
|
||||
refreshButtonText: {
|
||||
color: theme.colors.foreground,
|
||||
fontSize: theme.fontSize.sm,
|
||||
fontWeight: theme.fontWeight.semibold,
|
||||
},
|
||||
resumeErrorText: {
|
||||
color: theme.colors.palette.red[500],
|
||||
fontSize: theme.fontSize.sm,
|
||||
},
|
||||
resumeLoading: {
|
||||
flex: 1,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
resumeLoadingText: {
|
||||
color: theme.colors.mutedForeground,
|
||||
},
|
||||
resumeEmptyState: {
|
||||
flex: 1,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
gap: theme.spacing[2],
|
||||
paddingHorizontal: theme.spacing[6],
|
||||
textAlign: "center",
|
||||
},
|
||||
resumeEmptyTitle: {
|
||||
color: theme.colors.foreground,
|
||||
fontSize: theme.fontSize.lg,
|
||||
fontWeight: theme.fontWeight.semibold,
|
||||
textAlign: "center",
|
||||
},
|
||||
resumeEmptySubtitle: {
|
||||
color: theme.colors.mutedForeground,
|
||||
fontSize: theme.fontSize.sm,
|
||||
textAlign: "center",
|
||||
},
|
||||
refreshButtonAlt: {
|
||||
marginTop: theme.spacing[2],
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
backgroundColor: theme.colors.palette.blue[500],
|
||||
paddingHorizontal: theme.spacing[6],
|
||||
paddingVertical: theme.spacing[3],
|
||||
},
|
||||
refreshButtonAltText: {
|
||||
color: theme.colors.palette.white,
|
||||
fontWeight: theme.fontWeight.semibold,
|
||||
},
|
||||
resumeListContent: {
|
||||
paddingBottom: theme.spacing[8],
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
resumeItem: {
|
||||
borderWidth: theme.borderWidth[1],
|
||||
borderColor: theme.colors.border,
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
padding: theme.spacing[4],
|
||||
backgroundColor: theme.colors.background,
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
resumeItemHeader: {
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
resumeItemTitle: {
|
||||
color: theme.colors.foreground,
|
||||
fontWeight: theme.fontWeight.semibold,
|
||||
flex: 1,
|
||||
},
|
||||
resumeItemTimestamp: {
|
||||
color: theme.colors.mutedForeground,
|
||||
fontSize: theme.fontSize.sm,
|
||||
},
|
||||
resumeItemPath: {
|
||||
color: theme.colors.mutedForeground,
|
||||
fontSize: theme.fontSize.sm,
|
||||
},
|
||||
resumeItemMetaRow: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
},
|
||||
resumeProviderBadge: {
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
paddingVertical: theme.spacing[1],
|
||||
borderRadius: theme.borderRadius.full,
|
||||
backgroundColor: theme.colors.muted,
|
||||
},
|
||||
resumeProviderBadgeText: {
|
||||
color: theme.colors.foreground,
|
||||
fontSize: theme.fontSize.xs,
|
||||
fontWeight: theme.fontWeight.semibold,
|
||||
},
|
||||
resumeItemHint: {
|
||||
color: theme.colors.mutedForeground,
|
||||
fontSize: theme.fontSize.xs,
|
||||
},
|
||||
resumeItemSeparator: {
|
||||
height: theme.spacing[2],
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
Eye,
|
||||
SquareTerminal,
|
||||
Brain,
|
||||
Search,
|
||||
} from "lucide-react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { baseColors, theme } from "@/styles/theme";
|
||||
@@ -778,6 +779,7 @@ const toolKindIcons: Record<string, any> = {
|
||||
edit: Pencil,
|
||||
read: Eye,
|
||||
execute: SquareTerminal,
|
||||
search: Search,
|
||||
// Add more mappings as needed
|
||||
};
|
||||
|
||||
|
||||
@@ -212,10 +212,10 @@ export function ToolCallBottomSheet({
|
||||
if (payload.source === "agent") {
|
||||
const data = payload.data;
|
||||
return {
|
||||
toolName: `${data.server}/${data.tool}`,
|
||||
toolName: data.displayName ?? `${data.server}/${data.tool}`,
|
||||
args: data.raw,
|
||||
result: undefined,
|
||||
error: undefined,
|
||||
result: data.result,
|
||||
error: data.error,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -210,9 +210,11 @@ interface SessionContextValue {
|
||||
|
||||
// Helpers
|
||||
initializeAgent: (params: { agentId: string; requestId?: string }) => void;
|
||||
refreshAgent: (params: { agentId: string; requestId?: string }) => void;
|
||||
sendAgentMessage: (agentId: string, message: string, imageUris?: string[]) => Promise<void>;
|
||||
sendAgentAudio: (agentId: string, audioBlob: Blob, requestId?: string) => Promise<void>;
|
||||
createAgent: (options: { config: AgentSessionConfig; worktreeName?: string; requestId?: string }) => void;
|
||||
resumeAgent: (options: { handle: AgentPersistenceHandle; overrides?: Partial<AgentSessionConfig>; requestId?: string }) => void;
|
||||
setAgentMode: (agentId: string, modeId: string) => void;
|
||||
respondToPermission: (agentId: string, requestId: string, response: AgentPermissionResponse) => void;
|
||||
}
|
||||
@@ -729,6 +731,30 @@ export function SessionProvider({ children, serverUrl }: SessionProviderProps) {
|
||||
ws.send(msg);
|
||||
}, [ws]);
|
||||
|
||||
const refreshAgent = useCallback(({ agentId, requestId }: { agentId: string; requestId?: string }) => {
|
||||
setInitializingAgents((prev) => {
|
||||
const next = new Map(prev);
|
||||
next.set(agentId, true);
|
||||
return next;
|
||||
});
|
||||
|
||||
setAgentStreamState((prev) => {
|
||||
const next = new Map(prev);
|
||||
next.set(agentId, []);
|
||||
return next;
|
||||
});
|
||||
|
||||
const msg: WSInboundMessage = {
|
||||
type: "session",
|
||||
message: {
|
||||
type: "refresh_agent_request",
|
||||
agentId,
|
||||
requestId,
|
||||
},
|
||||
};
|
||||
ws.send(msg);
|
||||
}, [ws]);
|
||||
|
||||
const sendAgentMessage = useCallback(async (agentId: string, message: string, imageUris?: string[]) => {
|
||||
// Generate unique message ID for deduplication
|
||||
const messageId = generateMessageId();
|
||||
@@ -832,6 +858,19 @@ export function SessionProvider({ children, serverUrl }: SessionProviderProps) {
|
||||
ws.send(msg);
|
||||
}, [ws]);
|
||||
|
||||
const resumeAgent = useCallback(({ handle, overrides, requestId }: { handle: AgentPersistenceHandle; overrides?: Partial<AgentSessionConfig>; requestId?: string }) => {
|
||||
const msg: WSInboundMessage = {
|
||||
type: "session",
|
||||
message: {
|
||||
type: "resume_agent_request",
|
||||
handle,
|
||||
...(overrides ? { overrides } : {}),
|
||||
...(requestId ? { requestId } : {}),
|
||||
},
|
||||
};
|
||||
ws.send(msg);
|
||||
}, [ws]);
|
||||
|
||||
const setAgentMode = useCallback((agentId: string, modeId: string) => {
|
||||
const msg: WSInboundMessage = {
|
||||
type: "session",
|
||||
@@ -941,9 +980,11 @@ export function SessionProvider({ children, serverUrl }: SessionProviderProps) {
|
||||
requestDirectoryListing,
|
||||
requestFilePreview,
|
||||
initializeAgent,
|
||||
refreshAgent,
|
||||
sendAgentMessage,
|
||||
sendAgentAudio,
|
||||
createAgent,
|
||||
resumeAgent,
|
||||
setAgentMode,
|
||||
respondToPermission,
|
||||
};
|
||||
|
||||
@@ -68,6 +68,11 @@ export interface AgentToolCallData {
|
||||
tool: string;
|
||||
status: string;
|
||||
raw?: unknown;
|
||||
callId?: string;
|
||||
displayName?: string;
|
||||
kind?: string;
|
||||
result?: unknown;
|
||||
error?: unknown;
|
||||
}
|
||||
|
||||
export type ToolCallPayload =
|
||||
@@ -81,6 +86,10 @@ export interface ToolCallItem {
|
||||
payload: ToolCallPayload;
|
||||
}
|
||||
|
||||
type AgentToolCallItem = ToolCallItem & {
|
||||
payload: { source: "agent"; data: AgentToolCallData };
|
||||
};
|
||||
|
||||
type ActivityLogType = "system" | "info" | "success" | "error";
|
||||
|
||||
export interface ActivityLogItem {
|
||||
@@ -92,12 +101,54 @@ export interface ActivityLogItem {
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
type FileChangeEntry = { path: string; kind: string };
|
||||
type TodoEntry = { text: string; completed: boolean };
|
||||
|
||||
function normalizeChunk(text: string): { chunk: string; hasContent: boolean } {
|
||||
if (!text) {
|
||||
return { chunk: "", hasContent: false };
|
||||
}
|
||||
const chunk = text.replace(/\r/g, "");
|
||||
if (!chunk) {
|
||||
return { chunk: "", hasContent: false };
|
||||
}
|
||||
return { chunk, hasContent: /\S/.test(chunk) };
|
||||
}
|
||||
|
||||
function appendUserMessage(
|
||||
state: StreamItem[],
|
||||
text: string,
|
||||
timestamp: Date,
|
||||
messageId?: string
|
||||
): StreamItem[] {
|
||||
const { chunk, hasContent } = normalizeChunk(text);
|
||||
if (!hasContent) {
|
||||
return state;
|
||||
}
|
||||
|
||||
const entryId = messageId ?? createTimelineId("user", chunk.trim() || chunk, timestamp);
|
||||
const existingIndex = state.findIndex(
|
||||
(entry) => entry.kind === "user_message" && entry.id === entryId
|
||||
);
|
||||
|
||||
const nextItem: UserMessageItem = {
|
||||
kind: "user_message",
|
||||
id: entryId,
|
||||
text: chunk,
|
||||
timestamp,
|
||||
};
|
||||
|
||||
if (existingIndex >= 0) {
|
||||
const next = [...state];
|
||||
next[existingIndex] = nextItem;
|
||||
return next;
|
||||
}
|
||||
|
||||
return [...state, nextItem];
|
||||
}
|
||||
|
||||
function appendAssistantMessage(state: StreamItem[], text: string, timestamp: Date): StreamItem[] {
|
||||
const trimmed = text.trim();
|
||||
if (!trimmed) {
|
||||
const { chunk, hasContent } = normalizeChunk(text);
|
||||
if (!chunk) {
|
||||
return state;
|
||||
}
|
||||
|
||||
@@ -105,24 +156,29 @@ function appendAssistantMessage(state: StreamItem[], text: string, timestamp: Da
|
||||
if (last && last.kind === "assistant_message") {
|
||||
const updated: AssistantMessageItem = {
|
||||
...last,
|
||||
text: `${last.text}${trimmed}`,
|
||||
text: `${last.text}${chunk}`,
|
||||
timestamp,
|
||||
};
|
||||
return [...state.slice(0, -1), updated];
|
||||
}
|
||||
|
||||
if (!hasContent) {
|
||||
return state;
|
||||
}
|
||||
|
||||
const idSeed = chunk.trim() || chunk;
|
||||
const item: AssistantMessageItem = {
|
||||
kind: "assistant_message",
|
||||
id: createTimelineId("assistant", trimmed, timestamp),
|
||||
text: trimmed,
|
||||
id: createTimelineId("assistant", idSeed, timestamp),
|
||||
text: chunk,
|
||||
timestamp,
|
||||
};
|
||||
return [...state, item];
|
||||
}
|
||||
|
||||
function appendThought(state: StreamItem[], text: string, timestamp: Date): StreamItem[] {
|
||||
const trimmed = text.trim();
|
||||
if (!trimmed) {
|
||||
const { chunk, hasContent } = normalizeChunk(text);
|
||||
if (!chunk) {
|
||||
return state;
|
||||
}
|
||||
|
||||
@@ -130,16 +186,21 @@ function appendThought(state: StreamItem[], text: string, timestamp: Date): Stre
|
||||
if (last && last.kind === "thought") {
|
||||
const updated: ThoughtItem = {
|
||||
...last,
|
||||
text: `${last.text}${trimmed}`,
|
||||
text: `${last.text}${chunk}`,
|
||||
timestamp,
|
||||
};
|
||||
return [...state.slice(0, -1), updated];
|
||||
}
|
||||
|
||||
if (!hasContent) {
|
||||
return state;
|
||||
}
|
||||
|
||||
const idSeed = chunk.trim() || chunk;
|
||||
const item: ThoughtItem = {
|
||||
kind: "thought",
|
||||
id: createTimelineId("thought", trimmed, timestamp),
|
||||
text: trimmed,
|
||||
id: createTimelineId("thought", idSeed, timestamp),
|
||||
text: chunk,
|
||||
timestamp,
|
||||
};
|
||||
return [...state, item];
|
||||
@@ -150,19 +211,56 @@ function appendAgentToolCall(
|
||||
data: AgentToolCallData,
|
||||
timestamp: Date
|
||||
): StreamItem[] {
|
||||
const status = data.status;
|
||||
const id = createTimelineId(
|
||||
"tool",
|
||||
`${data.provider}:${data.server}:${data.tool}:${status}`,
|
||||
timestamp
|
||||
);
|
||||
const normalizedStatus = normalizeToolCallStatus(data.status);
|
||||
const callId = data.callId ?? extractToolCallId(data.raw);
|
||||
|
||||
const normalizedStatus: "executing" | "completed" | "failed" =
|
||||
status === "completed"
|
||||
? "completed"
|
||||
: status === "failed"
|
||||
? "failed"
|
||||
: "executing";
|
||||
const payloadData: AgentToolCallData = {
|
||||
...data,
|
||||
status: normalizedStatus,
|
||||
callId: callId ?? data.callId,
|
||||
};
|
||||
|
||||
if (callId) {
|
||||
const existingIndex = state.findIndex(
|
||||
(entry) =>
|
||||
entry.kind === "tool_call" &&
|
||||
entry.payload.source === "agent" &&
|
||||
entry.payload.data.callId === callId
|
||||
);
|
||||
|
||||
if (existingIndex >= 0) {
|
||||
const next = [...state];
|
||||
const existing = next[existingIndex] as AgentToolCallItem;
|
||||
const mergedRaw =
|
||||
existing.payload.data.raw !== undefined && existing.payload.data.raw !== null
|
||||
? existing.payload.data.raw
|
||||
: payloadData.raw;
|
||||
next[existingIndex] = {
|
||||
...existing,
|
||||
timestamp,
|
||||
payload: {
|
||||
source: "agent",
|
||||
data: {
|
||||
...existing.payload.data,
|
||||
...payloadData,
|
||||
raw: mergedRaw,
|
||||
displayName: payloadData.displayName ?? existing.payload.data.displayName,
|
||||
kind: payloadData.kind ?? existing.payload.data.kind,
|
||||
callId,
|
||||
},
|
||||
},
|
||||
};
|
||||
return next;
|
||||
}
|
||||
}
|
||||
|
||||
const id = callId
|
||||
? `agent_tool_${callId}`
|
||||
: createTimelineId(
|
||||
"tool",
|
||||
`${data.provider}:${data.server}:${data.tool}`,
|
||||
timestamp
|
||||
);
|
||||
|
||||
const item: ToolCallItem = {
|
||||
kind: "tool_call",
|
||||
@@ -170,21 +268,82 @@ function appendAgentToolCall(
|
||||
timestamp,
|
||||
payload: {
|
||||
source: "agent",
|
||||
data: { ...data, status: normalizedStatus },
|
||||
data: payloadData,
|
||||
},
|
||||
};
|
||||
|
||||
// Replace existing entry with same ID if present
|
||||
const index = state.findIndex((entry) => entry.id === id);
|
||||
if (index >= 0) {
|
||||
const next = [...state];
|
||||
next[index] = item;
|
||||
return next;
|
||||
}
|
||||
|
||||
return [...state, item];
|
||||
}
|
||||
|
||||
function isPermissionToolCall(raw: unknown): boolean {
|
||||
if (!raw || typeof raw !== "object") {
|
||||
return false;
|
||||
}
|
||||
const candidate = raw as { server?: string; kind?: string };
|
||||
return candidate.server === "permission" || candidate.kind === "permission";
|
||||
}
|
||||
|
||||
function normalizeToolCallStatus(status?: string): "executing" | "completed" | "failed" {
|
||||
if (!status) {
|
||||
return "executing";
|
||||
}
|
||||
const normalized = status.toLowerCase();
|
||||
if (/fail|error|deny|reject|cancel/.test(normalized)) {
|
||||
return "failed";
|
||||
}
|
||||
if (/complete|success|granted|applied|done|resolved/.test(normalized)) {
|
||||
return "completed";
|
||||
}
|
||||
return "executing";
|
||||
}
|
||||
|
||||
const TOOL_CALL_ID_KEYS = [
|
||||
"toolCallId",
|
||||
"tool_call_id",
|
||||
"callId",
|
||||
"call_id",
|
||||
"tool_use_id",
|
||||
"toolUseId",
|
||||
];
|
||||
|
||||
function extractToolCallId(raw: unknown, depth = 0): string | null {
|
||||
if (!raw || depth > 4) {
|
||||
return null;
|
||||
}
|
||||
if (typeof raw === "string" || typeof raw === "number") {
|
||||
return null;
|
||||
}
|
||||
if (Array.isArray(raw)) {
|
||||
for (const entry of raw) {
|
||||
const nested = extractToolCallId(entry, depth + 1);
|
||||
if (nested) {
|
||||
return nested;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
if (typeof raw === "object") {
|
||||
const record = raw as Record<string, unknown>;
|
||||
for (const key of TOOL_CALL_ID_KEYS) {
|
||||
const value = record[key];
|
||||
if (typeof value === "string" && value.length > 0) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
const idValue = record.id;
|
||||
if (typeof idValue === "string" && /tool|call/i.test(idValue)) {
|
||||
return idValue;
|
||||
}
|
||||
for (const value of Object.values(record)) {
|
||||
const nested = extractToolCallId(value, depth + 1);
|
||||
if (nested) {
|
||||
return nested;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function appendActivityLog(state: StreamItem[], entry: ActivityLogItem): StreamItem[] {
|
||||
const index = state.findIndex((existing) => existing.id === entry.id);
|
||||
if (index >= 0) {
|
||||
@@ -195,64 +354,6 @@ function appendActivityLog(state: StreamItem[], entry: ActivityLogItem): StreamI
|
||||
return [...state, entry];
|
||||
}
|
||||
|
||||
function toHumanLabel(value?: string): string {
|
||||
if (!value) {
|
||||
return "";
|
||||
}
|
||||
return value
|
||||
.replace(/[_-]+/g, " ")
|
||||
.split(" ")
|
||||
.filter(Boolean)
|
||||
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
|
||||
.join(" ");
|
||||
}
|
||||
|
||||
function describeCommandStatus(status?: string): ActivityLogType {
|
||||
if (!status) {
|
||||
return "info";
|
||||
}
|
||||
const normalized = status.toLowerCase();
|
||||
if (
|
||||
normalized.includes("fail") ||
|
||||
normalized.includes("error") ||
|
||||
normalized.includes("deny") ||
|
||||
normalized.includes("reject")
|
||||
) {
|
||||
return "error";
|
||||
}
|
||||
if (
|
||||
normalized.includes("success") ||
|
||||
normalized.includes("complete") ||
|
||||
normalized.includes("granted") ||
|
||||
normalized.includes("applied")
|
||||
) {
|
||||
return "success";
|
||||
}
|
||||
return "info";
|
||||
}
|
||||
|
||||
function formatCommandMessage(command: string, status?: string): string {
|
||||
const label = status ? toHumanLabel(status) : null;
|
||||
const header = label ? `Command (${label})` : "Command";
|
||||
return `${header}\n${command}`;
|
||||
}
|
||||
|
||||
function formatFileChangeMessage(files: FileChangeEntry[]): string {
|
||||
if (!files.length) {
|
||||
return "File changes";
|
||||
}
|
||||
const header = files.length === 1 ? "File change" : `${files.length} file changes`;
|
||||
const entries = files.map((file) => {
|
||||
const kindLabel = file.kind ? toHumanLabel(file.kind) : "";
|
||||
return `• ${kindLabel ? `[${kindLabel}] ` : ""}${file.path}`;
|
||||
});
|
||||
return [header, ...entries].join("\n");
|
||||
}
|
||||
|
||||
function formatWebSearchMessage(query: string): string {
|
||||
return `Web search\n"${query.trim()}"`;
|
||||
}
|
||||
|
||||
function formatTodoMessage(items: TodoEntry[]): string {
|
||||
if (!items.length) {
|
||||
return "Todo list";
|
||||
@@ -278,55 +379,34 @@ export function reduceStreamUpdate(
|
||||
case "timeline": {
|
||||
const item = event.item;
|
||||
switch (item.type) {
|
||||
case "user_message":
|
||||
return appendUserMessage(state, item.text, timestamp, item.messageId);
|
||||
case "assistant_message":
|
||||
return appendAssistantMessage(state, item.text, timestamp);
|
||||
case "reasoning":
|
||||
return appendThought(state, item.text, timestamp);
|
||||
case "mcp_tool":
|
||||
case "tool_call": {
|
||||
if (isPermissionToolCall(item)) {
|
||||
return state;
|
||||
}
|
||||
const rawPayload =
|
||||
item.raw ?? { input: item.input, output: item.output, error: item.error };
|
||||
return appendAgentToolCall(
|
||||
state,
|
||||
{
|
||||
provider: event.provider,
|
||||
server: item.server,
|
||||
tool: item.tool,
|
||||
status: item.status,
|
||||
raw: item.raw,
|
||||
status: item.status ?? "executing",
|
||||
raw: rawPayload,
|
||||
callId: item.callId,
|
||||
displayName: item.displayName,
|
||||
kind: item.kind,
|
||||
result: item.output,
|
||||
error: item.error,
|
||||
},
|
||||
timestamp
|
||||
);
|
||||
case "command": {
|
||||
const activity: ActivityLogItem = {
|
||||
kind: "activity_log",
|
||||
id: createTimelineId("command", `${item.command}:${item.status ?? ""}`, timestamp),
|
||||
timestamp,
|
||||
activityType: describeCommandStatus(item.status),
|
||||
message: formatCommandMessage(item.command, item.status),
|
||||
metadata: item.raw ? { raw: item.raw } : undefined,
|
||||
};
|
||||
return appendActivityLog(state, activity);
|
||||
}
|
||||
case "file_change": {
|
||||
const files = (item.files ?? []) as FileChangeEntry[];
|
||||
const activity: ActivityLogItem = {
|
||||
kind: "activity_log",
|
||||
id: createTimelineId("file_change", JSON.stringify(files), timestamp),
|
||||
timestamp,
|
||||
activityType: "info",
|
||||
message: formatFileChangeMessage(files),
|
||||
metadata: files.length ? { files } : undefined,
|
||||
};
|
||||
return appendActivityLog(state, activity);
|
||||
}
|
||||
case "web_search": {
|
||||
const activity: ActivityLogItem = {
|
||||
kind: "activity_log",
|
||||
id: createTimelineId("web_search", item.query ?? "", timestamp),
|
||||
timestamp,
|
||||
activityType: "info",
|
||||
message: formatWebSearchMessage(item.query ?? ""),
|
||||
metadata: item.raw ? { raw: item.raw } : { query: item.query },
|
||||
};
|
||||
return appendActivityLog(state, activity);
|
||||
}
|
||||
case "todo": {
|
||||
const items = (item.items ?? []) as TodoEntry[];
|
||||
|
||||
@@ -46,33 +46,51 @@ export function curateAgentActivity(
|
||||
|
||||
for (const item of recentItems) {
|
||||
switch (item.type) {
|
||||
case "user_message":
|
||||
flushBuffers(lines, buffers);
|
||||
lines.push(`[User] ${item.text.trim()}`);
|
||||
break;
|
||||
case "assistant_message":
|
||||
buffers.message = appendText(buffers.message, item.text);
|
||||
break;
|
||||
case "reasoning":
|
||||
buffers.thought = appendText(buffers.thought, item.text);
|
||||
break;
|
||||
case "command":
|
||||
case "tool_call": {
|
||||
flushBuffers(lines, buffers);
|
||||
lines.push(`[Command: ${item.command}] ${item.status}`);
|
||||
break;
|
||||
case "file_change":
|
||||
flushBuffers(lines, buffers);
|
||||
if (item.files.length > 0) {
|
||||
lines.push("[File Changes]");
|
||||
for (const file of item.files) {
|
||||
lines.push(`- (${file.kind}) ${file.path}`);
|
||||
const label =
|
||||
item.displayName ??
|
||||
(item.server && item.tool ? `${item.server}.${item.tool}` : item.tool ?? item.server ?? "Tool");
|
||||
const status = item.status ? ` ${item.status}` : "";
|
||||
if (item.kind === "execute" || item.server === "command") {
|
||||
lines.push(`[Command: ${label}]${status}`);
|
||||
} else if (item.kind === "edit" || item.server === "file_change") {
|
||||
const files =
|
||||
(item.output &&
|
||||
typeof item.output === "object" &&
|
||||
item.output !== null &&
|
||||
Array.isArray((item.output as Record<string, unknown>).files)
|
||||
? ((item.output as Record<string, unknown>).files as { path: string; kind: string }[])
|
||||
: []) ?? [];
|
||||
if (files.length > 0) {
|
||||
lines.push("[File Changes]");
|
||||
for (const file of files) {
|
||||
lines.push(`- (${file.kind}) ${file.path}`);
|
||||
}
|
||||
} else {
|
||||
lines.push(`[Edit] ${label}${status}`);
|
||||
}
|
||||
} else if (item.kind === "search" || item.server === "web_search") {
|
||||
const query =
|
||||
typeof item.input === "object" && item.input !== null && "query" in (item.input as Record<string, unknown>)
|
||||
? String((item.input as Record<string, unknown>).query ?? "")
|
||||
: "";
|
||||
lines.push(`[Web Search] ${query || label}`);
|
||||
} else {
|
||||
lines.push(`[Tool ${item.server}.${item.tool}]${status}`);
|
||||
}
|
||||
break;
|
||||
case "mcp_tool":
|
||||
flushBuffers(lines, buffers);
|
||||
lines.push(`[MCP ${item.server}.${item.tool}] ${item.status}`);
|
||||
break;
|
||||
case "web_search":
|
||||
flushBuffers(lines, buffers);
|
||||
lines.push(`[Web Search] ${item.query}`);
|
||||
break;
|
||||
}
|
||||
case "todo":
|
||||
flushBuffers(lines, buffers);
|
||||
lines.push("[Plan]");
|
||||
|
||||
@@ -16,6 +16,8 @@ import type {
|
||||
AgentStreamEvent,
|
||||
AgentTimelineItem,
|
||||
AgentUsage,
|
||||
ListPersistedAgentsOptions,
|
||||
PersistedAgentDescriptor,
|
||||
} from "./agent-sdk-types.js";
|
||||
|
||||
export type AgentLifecycleStatus =
|
||||
@@ -53,6 +55,10 @@ export type SubscribeOptions = {
|
||||
replayState?: boolean;
|
||||
};
|
||||
|
||||
export type PersistedAgentQueryOptions = ListPersistedAgentsOptions & {
|
||||
provider?: AgentProvider;
|
||||
};
|
||||
|
||||
export type AgentManagerOptions = {
|
||||
clients?: Partial<Record<AgentProvider, AgentClient>>;
|
||||
maxTimelineItems?: number;
|
||||
@@ -143,6 +149,44 @@ export class AgentManager {
|
||||
);
|
||||
}
|
||||
|
||||
async listPersistedAgents(
|
||||
options?: PersistedAgentQueryOptions
|
||||
): Promise<PersistedAgentDescriptor[]> {
|
||||
if (options?.provider) {
|
||||
const client = this.requireClient(options.provider);
|
||||
if (!client.listPersistedAgents) {
|
||||
return [];
|
||||
}
|
||||
return client.listPersistedAgents({ limit: options.limit });
|
||||
}
|
||||
|
||||
const descriptors: PersistedAgentDescriptor[] = [];
|
||||
for (const [provider, client] of this.clients.entries()) {
|
||||
if (!client.listPersistedAgents) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const entries = await client.listPersistedAgents({
|
||||
limit: options?.limit,
|
||||
});
|
||||
descriptors.push(...entries);
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
`[AgentManager] Failed to list persisted agents for provider '${provider}':`,
|
||||
error
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const limit = options?.limit ?? 20;
|
||||
return descriptors
|
||||
.sort(
|
||||
(a, b) =>
|
||||
b.lastActivityAt.getTime() - a.lastActivityAt.getTime()
|
||||
)
|
||||
.slice(0, limit);
|
||||
}
|
||||
|
||||
getAgent(id: string): AgentSnapshot | null {
|
||||
const agent = this.agents.get(id);
|
||||
return agent ? this.toSnapshot(agent) : null;
|
||||
@@ -182,6 +226,37 @@ export class AgentManager {
|
||||
);
|
||||
}
|
||||
|
||||
async refreshAgentFromPersistence(agentId: string): Promise<AgentSnapshot> {
|
||||
const existing = this.requireAgent(agentId);
|
||||
const handle = existing.persistence;
|
||||
if (!handle) {
|
||||
throw new Error(
|
||||
`Agent ${agentId} cannot be refreshed because it has no persistence handle`
|
||||
);
|
||||
}
|
||||
|
||||
const client = this.requireClient(handle.provider);
|
||||
const overrides = {
|
||||
...existing.config,
|
||||
provider: handle.provider,
|
||||
};
|
||||
|
||||
const session = await client.resumeSession(handle, overrides);
|
||||
|
||||
// Remove the existing agent entry before swapping sessions
|
||||
this.agents.delete(agentId);
|
||||
try {
|
||||
await existing.session.close();
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
`[AgentManager] Failed to close previous session for agent ${agentId} during refresh:`,
|
||||
error
|
||||
);
|
||||
}
|
||||
|
||||
return this.registerSession(session, overrides, agentId);
|
||||
}
|
||||
|
||||
async closeAgent(agentId: string): Promise<void> {
|
||||
const agent = this.requireAgent(agentId);
|
||||
this.agents.delete(agentId);
|
||||
@@ -229,6 +304,27 @@ export class AgentManager {
|
||||
};
|
||||
}
|
||||
|
||||
recordUserMessage(
|
||||
agentId: string,
|
||||
text: string,
|
||||
options?: { messageId?: string; raw?: unknown }
|
||||
): void {
|
||||
const agent = this.requireAgent(agentId);
|
||||
const item: AgentTimelineItem = {
|
||||
type: "user_message",
|
||||
text,
|
||||
messageId: options?.messageId,
|
||||
raw: options?.raw,
|
||||
};
|
||||
agent.updatedAt = new Date();
|
||||
this.recordTimeline(agent, item);
|
||||
this.dispatchStream(agentId, {
|
||||
type: "timeline",
|
||||
item,
|
||||
provider: agent.provider,
|
||||
});
|
||||
}
|
||||
|
||||
streamAgent(
|
||||
agentId: string,
|
||||
prompt: AgentPromptInput,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe, expect, test, beforeEach, afterEach } from "vitest";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { mkdtempSync, rmSync } from "node:fs";
|
||||
import { mkdtempSync, rmSync, writeFileSync, readFileSync } from "node:fs";
|
||||
|
||||
import { AgentRegistry } from "./agent-registry.js";
|
||||
import type { AgentSnapshot } from "./agent-manager.js";
|
||||
@@ -113,4 +113,33 @@ describe("AgentRegistry", () => {
|
||||
const record = await registry.get("agent-3");
|
||||
expect(record?.lastModeId).toBe("plan");
|
||||
});
|
||||
|
||||
test("recovers from trailing garbage in agents.json", async () => {
|
||||
const payload = [
|
||||
{
|
||||
id: "agent-4",
|
||||
provider: "claude",
|
||||
cwd: "/tmp/project",
|
||||
createdAt: "2024-01-01T00:00:00.000Z",
|
||||
updatedAt: "2024-01-01T00:00:00.000Z",
|
||||
title: "Recovered agent",
|
||||
lastStatus: "idle",
|
||||
lastModeId: "plan",
|
||||
config: null,
|
||||
persistence: null,
|
||||
},
|
||||
];
|
||||
writeFileSync(
|
||||
filePath,
|
||||
`${JSON.stringify(payload, null, 2)}\nGARBAGE-TRAILING`
|
||||
);
|
||||
|
||||
const reloaded = new AgentRegistry(filePath);
|
||||
const result = await reloaded.list();
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]?.title).toBe("Recovered agent");
|
||||
|
||||
const sanitized = readFileSync(filePath, "utf8");
|
||||
expect(sanitized.includes("GARBAGE-TRAILING")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { existsSync } from "node:fs";
|
||||
import { promises as fs } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { z } from "zod";
|
||||
|
||||
import type { AgentSnapshot } from "./agent-manager.js";
|
||||
@@ -51,13 +54,7 @@ export class AgentRegistry {
|
||||
|
||||
constructor(filePath?: string) {
|
||||
this.filePath =
|
||||
filePath ??
|
||||
path.join(
|
||||
process.cwd(),
|
||||
"packages",
|
||||
"server",
|
||||
"agents.json"
|
||||
);
|
||||
filePath ?? path.join(resolveServerPackageRoot(), "agents.json");
|
||||
}
|
||||
|
||||
async load(): Promise<StoredAgentRecord[]> {
|
||||
@@ -66,22 +63,9 @@ export class AgentRegistry {
|
||||
}
|
||||
try {
|
||||
const content = await fs.readFile(this.filePath, "utf8");
|
||||
const parsed = JSON.parse(content);
|
||||
if (!Array.isArray(parsed)) {
|
||||
throw new Error("Invalid agents.json format");
|
||||
}
|
||||
const records: StoredAgentRecord[] = [];
|
||||
for (const entry of parsed) {
|
||||
try {
|
||||
const record = STORED_AGENT_SCHEMA.parse(entry);
|
||||
records.push(record);
|
||||
this.cache.set(record.id, record);
|
||||
} catch (error) {
|
||||
console.error("[AgentRegistry] Skipping invalid record:", error);
|
||||
}
|
||||
}
|
||||
const parsed = await this.parseContent(content);
|
||||
this.loaded = true;
|
||||
return records;
|
||||
return parsed;
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
|
||||
this.loaded = true;
|
||||
@@ -194,7 +178,63 @@ export class AgentRegistry {
|
||||
private async flush(): Promise<void> {
|
||||
const payload = JSON.stringify(Array.from(this.cache.values()), null, 2);
|
||||
await fs.mkdir(path.dirname(this.filePath), { recursive: true });
|
||||
await fs.writeFile(this.filePath, payload, "utf8");
|
||||
await writeFileAtomically(this.filePath, payload);
|
||||
}
|
||||
|
||||
private async parseContent(content: string): Promise<StoredAgentRecord[]> {
|
||||
try {
|
||||
return this.parseRecords(content);
|
||||
} catch (error) {
|
||||
if (error instanceof SyntaxError) {
|
||||
const recovered = await this.tryRecoverCorruptedContent(content);
|
||||
if (recovered) {
|
||||
return recovered;
|
||||
}
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private parseRecords(content: string): StoredAgentRecord[] {
|
||||
const parsed = JSON.parse(content);
|
||||
if (!Array.isArray(parsed)) {
|
||||
throw new Error("Invalid agents.json format");
|
||||
}
|
||||
const records: StoredAgentRecord[] = [];
|
||||
for (const entry of parsed) {
|
||||
try {
|
||||
const record = STORED_AGENT_SCHEMA.parse(entry);
|
||||
records.push(record);
|
||||
this.cache.set(record.id, record);
|
||||
} catch (error) {
|
||||
console.error("[AgentRegistry] Skipping invalid record:", error);
|
||||
}
|
||||
}
|
||||
return records;
|
||||
}
|
||||
|
||||
private async tryRecoverCorruptedContent(
|
||||
content: string
|
||||
): Promise<StoredAgentRecord[] | null> {
|
||||
const start = content.indexOf("[");
|
||||
const end = content.lastIndexOf("]");
|
||||
if (start === -1 || end === -1 || end <= start) {
|
||||
return null;
|
||||
}
|
||||
const candidate = content.slice(start, end + 1);
|
||||
try {
|
||||
this.cache.clear();
|
||||
const records = this.parseRecords(candidate);
|
||||
console.warn(
|
||||
"[AgentRegistry] Recovered corrupted agents.json payload; rewrote sanitized copy"
|
||||
);
|
||||
const sanitizedPayload = JSON.stringify(records, null, 2);
|
||||
await writeFileAtomically(this.filePath, sanitizedPayload);
|
||||
return records;
|
||||
} catch (error) {
|
||||
this.cache.clear();
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -210,3 +250,29 @@ function sanitizeConfig(
|
||||
if (config.extra) cleaned.extra = JSON.parse(JSON.stringify(config.extra));
|
||||
return cleaned;
|
||||
}
|
||||
|
||||
function resolveServerPackageRoot(): string {
|
||||
let currentDir = path.dirname(fileURLToPath(import.meta.url));
|
||||
while (true) {
|
||||
if (existsSync(path.join(currentDir, "package.json"))) {
|
||||
return currentDir;
|
||||
}
|
||||
const parentDir = path.dirname(currentDir);
|
||||
if (parentDir === currentDir) {
|
||||
throw new Error(
|
||||
"[AgentRegistry] Failed to locate server package root for agents.json"
|
||||
);
|
||||
}
|
||||
currentDir = parentDir;
|
||||
}
|
||||
}
|
||||
|
||||
async function writeFileAtomically(targetPath: string, payload: string) {
|
||||
const directory = path.dirname(targetPath);
|
||||
const tempPath = path.join(
|
||||
directory,
|
||||
`.agents.json.tmp-${process.pid}-${Date.now()}-${randomUUID()}`
|
||||
);
|
||||
await fs.writeFile(tempPath, payload, "utf8");
|
||||
await fs.rename(tempPath, targetPath);
|
||||
}
|
||||
|
||||
@@ -45,12 +45,22 @@ export type AgentUsage = {
|
||||
};
|
||||
|
||||
export type AgentTimelineItem =
|
||||
| { type: "user_message"; text: string; messageId?: string; raw?: unknown }
|
||||
| { type: "assistant_message"; text: string; raw?: unknown }
|
||||
| { type: "reasoning"; text: string; raw?: unknown }
|
||||
| { type: "command"; command: string; status: string; raw?: unknown }
|
||||
| { type: "file_change"; files: { path: string; kind: string }[]; raw?: unknown }
|
||||
| { type: "mcp_tool"; server: string; tool: string; status: string; raw?: unknown }
|
||||
| { type: "web_search"; query: string; raw?: unknown }
|
||||
| {
|
||||
type: "tool_call";
|
||||
server: string;
|
||||
tool: string;
|
||||
status?: string;
|
||||
callId?: string;
|
||||
displayName?: string;
|
||||
kind?: string;
|
||||
input?: unknown;
|
||||
output?: unknown;
|
||||
error?: unknown;
|
||||
raw?: unknown;
|
||||
}
|
||||
| { type: "todo"; items: { text: string; completed: boolean }[]; raw?: unknown }
|
||||
| { type: "error"; message: string; raw?: unknown };
|
||||
|
||||
@@ -105,6 +115,19 @@ export type AgentRunResult = {
|
||||
timeline: AgentTimelineItem[];
|
||||
};
|
||||
|
||||
export type ListPersistedAgentsOptions = {
|
||||
limit?: number;
|
||||
};
|
||||
|
||||
export type PersistedAgentDescriptor = {
|
||||
provider: AgentProvider;
|
||||
sessionId: string;
|
||||
cwd: string;
|
||||
title: string | null;
|
||||
lastActivityAt: Date;
|
||||
persistence: AgentPersistenceHandle;
|
||||
};
|
||||
|
||||
export type AgentSessionConfig = {
|
||||
provider: AgentProvider;
|
||||
cwd: string;
|
||||
@@ -143,4 +166,5 @@ export interface AgentClient {
|
||||
readonly capabilities: AgentCapabilityFlags;
|
||||
createSession(config: AgentSessionConfig): Promise<AgentSession>;
|
||||
resumeSession(handle: AgentPersistenceHandle, overrides?: Partial<AgentSessionConfig>): Promise<AgentSession>;
|
||||
listPersistedAgents?(options?: ListPersistedAgentsOptions): Promise<PersistedAgentDescriptor[]>;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { extractUserMessageText } from "./claude-agent.js";
|
||||
|
||||
describe("extractUserMessageText", () => {
|
||||
test("returns trimmed string content", () => {
|
||||
expect(extractUserMessageText(" Hello world ")).toBe("Hello world");
|
||||
});
|
||||
|
||||
test("combines multiple text blocks", () => {
|
||||
const content = [
|
||||
{ type: "text", text: "First line" },
|
||||
{ type: "text", text: "Second line" },
|
||||
];
|
||||
|
||||
expect(extractUserMessageText(content)).toBe("First line\n\nSecond line");
|
||||
});
|
||||
|
||||
test("returns null when no textual content is present", () => {
|
||||
const content = [
|
||||
{ type: "image", source: "foo.png" },
|
||||
{ type: "file", path: "bar.txt" },
|
||||
];
|
||||
|
||||
expect(extractUserMessageText(content)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -111,21 +111,33 @@ describe("ClaudeAgentClient (SDK integration)", () => {
|
||||
}
|
||||
}
|
||||
|
||||
const toolEvent = timeline.find((item) => item.type === "mcp_tool");
|
||||
const commandEvents = timeline.filter(
|
||||
(item): item is Extract<AgentTimelineItem, { type: "command" }> =>
|
||||
item.type === "command" && typeof item.command === "string" && !item.command.startsWith("permission:")
|
||||
const toolCalls = timeline.filter(
|
||||
(item): item is Extract<AgentTimelineItem, { type: "tool_call" }> =>
|
||||
item.type === "tool_call"
|
||||
);
|
||||
const fileChangeEvent = timeline.find(
|
||||
const commandEvents = toolCalls.filter(
|
||||
(item) =>
|
||||
item.type === "file_change" &&
|
||||
item.files.some((file) => file.path.includes("tool-test.txt"))
|
||||
(item.kind === "execute" || item.server === "command") &&
|
||||
typeof item.displayName === "string" &&
|
||||
!item.displayName.startsWith("permission:")
|
||||
);
|
||||
const fileChangeEvent = toolCalls.find((item) => {
|
||||
if (!item.output || typeof item.output !== "object") {
|
||||
return false;
|
||||
}
|
||||
const files = (item.output as Record<string, unknown>).files;
|
||||
if (!Array.isArray(files)) {
|
||||
return false;
|
||||
}
|
||||
return files.some((file) => typeof file?.path === "string" && file.path.includes("tool-test.txt"));
|
||||
});
|
||||
|
||||
const sawPwdCommand = commandEvents.some((item) => item.command.toLowerCase().includes("pwd") && item.status === "completed");
|
||||
const sawPwdCommand = commandEvents.some(
|
||||
(item) => (item.displayName ?? "").toLowerCase().includes("pwd") && item.status === "completed"
|
||||
);
|
||||
|
||||
expect(completed).toBe(true);
|
||||
expect(toolEvent).toBeTruthy();
|
||||
expect(toolCalls.length).toBeGreaterThan(0);
|
||||
expect(sawPwdCommand).toBe(true);
|
||||
expect(fileChangeEvent).toBeTruthy();
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import fs, { promises as fsPromises } from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import {
|
||||
@@ -35,6 +35,8 @@ import type {
|
||||
AgentStreamEvent,
|
||||
AgentTimelineItem,
|
||||
AgentUsage,
|
||||
ListPersistedAgentsOptions,
|
||||
PersistedAgentDescriptor,
|
||||
} from "../agent-sdk-types.js";
|
||||
|
||||
const CLAUDE_CAPABILITIES: AgentCapabilityFlags = {
|
||||
@@ -71,7 +73,43 @@ const DEFAULT_MODES: AgentMode[] = [
|
||||
|
||||
type ClaudeAgentConfig = AgentSessionConfig & { provider: "claude" };
|
||||
|
||||
type ClaudeContentChunk = { type: string; [key: string]: any };
|
||||
export type ClaudeContentChunk = { type: string; [key: string]: any };
|
||||
|
||||
export function extractUserMessageText(
|
||||
content: string | ClaudeContentChunk[]
|
||||
): string | null {
|
||||
if (typeof content === "string") {
|
||||
const normalized = content.trim();
|
||||
return normalized.length > 0 ? normalized : null;
|
||||
}
|
||||
|
||||
if (!Array.isArray(content)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const parts: string[] = [];
|
||||
for (const block of content) {
|
||||
if (!block || typeof block !== "object") {
|
||||
continue;
|
||||
}
|
||||
const text = typeof block.text === "string" ? block.text : undefined;
|
||||
if (text && text.trim()) {
|
||||
parts.push(text.trim());
|
||||
continue;
|
||||
}
|
||||
const input = typeof block.input === "string" ? block.input : undefined;
|
||||
if (input && input.trim()) {
|
||||
parts.push(input.trim());
|
||||
}
|
||||
}
|
||||
|
||||
if (parts.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const combined = parts.join("\n\n").trim();
|
||||
return combined.length > 0 ? combined : null;
|
||||
}
|
||||
|
||||
type PendingPermission = {
|
||||
request: AgentPermissionRequest;
|
||||
@@ -81,6 +119,7 @@ type PendingPermission = {
|
||||
};
|
||||
|
||||
type ToolUseClassification = "generic" | "command" | "file_change";
|
||||
type ToolCallTimelineItem = Extract<AgentTimelineItem, { type: "tool_call" }>;
|
||||
|
||||
type ToolUseCacheEntry = {
|
||||
id: string;
|
||||
@@ -90,6 +129,7 @@ type ToolUseCacheEntry = {
|
||||
started: boolean;
|
||||
commandText?: string;
|
||||
files?: { path: string; kind: string }[];
|
||||
input?: Record<string, unknown> | null;
|
||||
};
|
||||
|
||||
const DEFAULT_PERMISSION_TIMEOUT_MS = 120_000;
|
||||
@@ -123,6 +163,28 @@ export class ClaudeAgentClient implements AgentClient {
|
||||
return new ClaudeAgentSession(claudeConfig, this.defaults, handle);
|
||||
}
|
||||
|
||||
async listPersistedAgents(options?: ListPersistedAgentsOptions): Promise<PersistedAgentDescriptor[]> {
|
||||
const projectsRoot = path.join(os.homedir(), ".claude", "projects");
|
||||
if (!(await pathExists(projectsRoot))) {
|
||||
return [];
|
||||
}
|
||||
const limit = options?.limit ?? 20;
|
||||
const candidates = await collectRecentClaudeSessions(projectsRoot, limit * 3);
|
||||
const descriptors: PersistedAgentDescriptor[] = [];
|
||||
|
||||
for (const candidate of candidates) {
|
||||
const descriptor = await parseClaudeSessionDescriptor(candidate.path, candidate.mtime);
|
||||
if (descriptor) {
|
||||
descriptors.push(descriptor);
|
||||
}
|
||||
if (descriptors.length >= limit) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return descriptors;
|
||||
}
|
||||
|
||||
private assertConfig(config: AgentSessionConfig): ClaudeAgentConfig {
|
||||
if (config.provider !== "claude") {
|
||||
throw new Error(`ClaudeAgentClient received config for provider '${config.provider}'`);
|
||||
@@ -269,10 +331,13 @@ class ClaudeAgentSession implements AgentSession {
|
||||
if (response.behavior === "allow") {
|
||||
if (pending.request.kind === "plan") {
|
||||
await this.setMode("acceptEdits");
|
||||
this.enqueueTimeline({
|
||||
type: "command",
|
||||
command: "plan:approved",
|
||||
this.pushToolCall({
|
||||
server: "plan",
|
||||
tool: "plan_approval",
|
||||
status: "granted",
|
||||
callId: pending.request.id,
|
||||
displayName: "Plan approved",
|
||||
kind: "plan",
|
||||
raw: pending.request,
|
||||
});
|
||||
}
|
||||
@@ -282,10 +347,14 @@ class ClaudeAgentSession implements AgentSession {
|
||||
updatedPermissions: this.normalizePermissionUpdates(response.updatedPermissions),
|
||||
};
|
||||
pending.resolve(result);
|
||||
this.enqueueTimeline({
|
||||
type: "command",
|
||||
command: `permission:${pending.request.name}`,
|
||||
this.pushToolCall({
|
||||
server: "permission",
|
||||
tool: pending.request.name,
|
||||
status: "granted",
|
||||
callId: pending.request.id,
|
||||
displayName: pending.request.title ?? pending.request.name,
|
||||
kind: "permission",
|
||||
input: pending.request.input,
|
||||
raw: { request: pending.request, response },
|
||||
});
|
||||
} else {
|
||||
@@ -295,10 +364,14 @@ class ClaudeAgentSession implements AgentSession {
|
||||
interrupt: response.interrupt,
|
||||
};
|
||||
pending.resolve(result);
|
||||
this.enqueueTimeline({
|
||||
type: "command",
|
||||
command: `permission:${pending.request.name}`,
|
||||
this.pushToolCall({
|
||||
server: "permission",
|
||||
tool: pending.request.name,
|
||||
status: "denied",
|
||||
callId: pending.request.id,
|
||||
displayName: pending.request.title ?? pending.request.name,
|
||||
kind: "permission",
|
||||
input: pending.request.input,
|
||||
raw: { request: pending.request, response },
|
||||
});
|
||||
}
|
||||
@@ -551,10 +624,14 @@ class ClaudeAgentSession implements AgentSession {
|
||||
raw: { toolName, input, options },
|
||||
};
|
||||
|
||||
this.enqueueTimeline({
|
||||
type: "command",
|
||||
command: `permission:${toolName}`,
|
||||
this.pushToolCall({
|
||||
server: "permission",
|
||||
tool: toolName,
|
||||
status: "requested",
|
||||
callId: requestId,
|
||||
displayName: request.title ?? toolName,
|
||||
kind: "permission",
|
||||
input,
|
||||
raw: { toolName, input },
|
||||
});
|
||||
|
||||
@@ -576,10 +653,14 @@ class ClaudeAgentSession implements AgentSession {
|
||||
this.pendingPermissions.delete(requestId);
|
||||
cleanup();
|
||||
const error = new Error("Permission request timed out");
|
||||
this.enqueueTimeline({
|
||||
type: "command",
|
||||
command: `permission:${toolName}`,
|
||||
this.pushToolCall({
|
||||
server: "permission",
|
||||
tool: toolName,
|
||||
status: "denied",
|
||||
callId: requestId,
|
||||
displayName: request.title ?? toolName,
|
||||
kind: "permission",
|
||||
input,
|
||||
raw: { reason: "timeout", toolName, input },
|
||||
});
|
||||
this.pushEvent({
|
||||
@@ -634,6 +715,37 @@ class ClaudeAgentSession implements AgentSession {
|
||||
this.pushEvent({ type: "timeline", item, provider: "claude" });
|
||||
}
|
||||
|
||||
private pushToolCall(
|
||||
data: Omit<ToolCallTimelineItem, "type">,
|
||||
target?: AgentTimelineItem[]
|
||||
) {
|
||||
const item: AgentTimelineItem = { type: "tool_call", ...data };
|
||||
if (target) {
|
||||
target.push(item);
|
||||
return;
|
||||
}
|
||||
this.enqueueTimeline(item);
|
||||
}
|
||||
|
||||
private getToolKind(classification?: ToolUseClassification): string | undefined {
|
||||
switch (classification) {
|
||||
case "command":
|
||||
return "execute";
|
||||
case "file_change":
|
||||
return "edit";
|
||||
case "generic":
|
||||
default:
|
||||
return "tool";
|
||||
}
|
||||
}
|
||||
|
||||
private buildToolDisplayName(entry?: ToolUseCacheEntry): string | undefined {
|
||||
if (!entry) {
|
||||
return undefined;
|
||||
}
|
||||
return entry.commandText ?? entry.name;
|
||||
}
|
||||
|
||||
private pushEvent(event: AgentStreamEvent) {
|
||||
if (this.eventQueue) {
|
||||
this.eventQueue.push(event);
|
||||
@@ -700,20 +812,39 @@ class ClaudeAgentSession implements AgentSession {
|
||||
|
||||
private convertHistoryEntry(entry: any): AgentTimelineItem[] {
|
||||
const message = entry?.message;
|
||||
if (!message) {
|
||||
if (!message || !("content" in message)) {
|
||||
return [];
|
||||
}
|
||||
if (entry.type === "assistant" && message.content) {
|
||||
return this.mapBlocksToTimeline(message.content as ClaudeContentChunk[]);
|
||||
const content = message.content as string | ClaudeContentChunk[];
|
||||
|
||||
if (entry.type === "assistant" && content) {
|
||||
return this.mapBlocksToTimeline(content);
|
||||
}
|
||||
if (Array.isArray(message.content)) {
|
||||
const hasToolBlock = message.content.some((block: ClaudeContentChunk) =>
|
||||
typeof block?.type === "string" && block.type.includes("tool")
|
||||
|
||||
if (entry.type === "user") {
|
||||
const text = extractUserMessageText(content);
|
||||
if (text) {
|
||||
return [
|
||||
{
|
||||
type: "user_message",
|
||||
text,
|
||||
raw: message,
|
||||
},
|
||||
];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
if (Array.isArray(content)) {
|
||||
const hasToolBlock = content.some(
|
||||
(block: ClaudeContentChunk) =>
|
||||
typeof block?.type === "string" && block.type.includes("tool")
|
||||
);
|
||||
if (hasToolBlock) {
|
||||
return this.mapBlocksToTimeline(message.content as ClaudeContentChunk[]);
|
||||
return this.mapBlocksToTimeline(content);
|
||||
}
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
@@ -773,28 +904,19 @@ class ClaudeAgentSession implements AgentSession {
|
||||
}
|
||||
entry.started = true;
|
||||
this.toolUseCache.set(entry.id, entry);
|
||||
this.enqueueTimeline({
|
||||
type: "command",
|
||||
command: `permission:${entry.name}`,
|
||||
status: "granted",
|
||||
raw: block,
|
||||
});
|
||||
if (entry.classification === "command") {
|
||||
const commandText = entry.commandText ?? entry.name;
|
||||
items.push({
|
||||
type: "command",
|
||||
command: commandText,
|
||||
status: "running",
|
||||
this.pushToolCall(
|
||||
{
|
||||
server: entry.server,
|
||||
tool: entry.name,
|
||||
status: "pending",
|
||||
callId: entry.id,
|
||||
displayName: this.buildToolDisplayName(entry),
|
||||
kind: this.getToolKind(entry.classification),
|
||||
input: entry.input ?? this.normalizeToolInput(block.input),
|
||||
raw: block,
|
||||
});
|
||||
}
|
||||
items.push({
|
||||
type: "mcp_tool",
|
||||
server: entry.server,
|
||||
tool: entry.name,
|
||||
status: "pending",
|
||||
raw: block,
|
||||
});
|
||||
},
|
||||
items
|
||||
);
|
||||
}
|
||||
|
||||
private handleToolResult(block: ClaudeContentChunk, items: AgentTimelineItem[]): void {
|
||||
@@ -802,29 +924,25 @@ class ClaudeAgentSession implements AgentSession {
|
||||
const server = entry?.server ?? block.server ?? "tool";
|
||||
const tool = entry?.name ?? block.tool_name ?? "tool";
|
||||
const status = block.is_error ? "failed" : "completed";
|
||||
items.push({
|
||||
type: "mcp_tool",
|
||||
server,
|
||||
tool,
|
||||
status,
|
||||
raw: block,
|
||||
});
|
||||
if (entry?.classification === "command") {
|
||||
const commandText = entry.commandText ?? entry.name;
|
||||
items.push({
|
||||
type: "command",
|
||||
command: commandText,
|
||||
const rawPayload =
|
||||
!block.is_error && entry?.classification === "file_change" && entry.files?.length
|
||||
? { block, files: entry.files }
|
||||
: block;
|
||||
this.pushToolCall(
|
||||
{
|
||||
server,
|
||||
tool,
|
||||
status,
|
||||
raw: block,
|
||||
});
|
||||
}
|
||||
if (!block.is_error && entry?.classification === "file_change" && entry.files?.length) {
|
||||
items.push({
|
||||
type: "file_change",
|
||||
files: entry.files,
|
||||
raw: { block, files: entry.files },
|
||||
});
|
||||
}
|
||||
callId: typeof block.tool_use_id === "string" ? block.tool_use_id : undefined,
|
||||
displayName: this.buildToolDisplayName(entry),
|
||||
kind: this.getToolKind(entry?.classification),
|
||||
input: entry?.input,
|
||||
output: !block.is_error && entry?.files?.length ? { files: entry.files } : undefined,
|
||||
error: block.is_error ? block : undefined,
|
||||
raw: rawPayload,
|
||||
},
|
||||
items
|
||||
);
|
||||
if (typeof block.tool_use_id === "string") {
|
||||
this.toolUseCache.delete(block.tool_use_id);
|
||||
}
|
||||
@@ -872,6 +990,7 @@ class ClaudeAgentSession implements AgentSession {
|
||||
|
||||
if (block.type === "tool_use") {
|
||||
const input = this.normalizeToolInput(block.input);
|
||||
existing.input = input;
|
||||
if (input) {
|
||||
if (this.isCommandTool(existing.name, input)) {
|
||||
existing.classification = "command";
|
||||
@@ -1043,3 +1162,142 @@ class Pushable<T> implements AsyncIterable<T> {
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
type ClaudeSessionCandidate = {
|
||||
path: string;
|
||||
mtime: Date;
|
||||
};
|
||||
|
||||
async function pathExists(target: string): Promise<boolean> {
|
||||
try {
|
||||
await fsPromises.access(target);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function collectRecentClaudeSessions(root: string, limit: number): Promise<ClaudeSessionCandidate[]> {
|
||||
let projectDirs: string[];
|
||||
try {
|
||||
projectDirs = await fsPromises.readdir(root);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
const candidates: ClaudeSessionCandidate[] = [];
|
||||
for (const dirName of projectDirs) {
|
||||
const projectPath = path.join(root, dirName);
|
||||
let stats: fs.Stats;
|
||||
try {
|
||||
stats = await fsPromises.stat(projectPath);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
if (!stats.isDirectory()) {
|
||||
continue;
|
||||
}
|
||||
let files: string[];
|
||||
try {
|
||||
files = await fsPromises.readdir(projectPath);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
for (const file of files) {
|
||||
if (!file.endsWith(".jsonl")) {
|
||||
continue;
|
||||
}
|
||||
const fullPath = path.join(projectPath, file);
|
||||
try {
|
||||
const fileStats = await fsPromises.stat(fullPath);
|
||||
candidates.push({ path: fullPath, mtime: fileStats.mtime });
|
||||
} catch {
|
||||
// ignore stat errors for individual files
|
||||
}
|
||||
}
|
||||
}
|
||||
return candidates
|
||||
.sort((a, b) => b.mtime.getTime() - a.mtime.getTime())
|
||||
.slice(0, limit);
|
||||
}
|
||||
|
||||
async function parseClaudeSessionDescriptor(
|
||||
filePath: string,
|
||||
mtime: Date
|
||||
): Promise<PersistedAgentDescriptor | null> {
|
||||
let content: string;
|
||||
try {
|
||||
content = await fsPromises.readFile(filePath, "utf8");
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
let sessionId: string | null = null;
|
||||
let cwd: string | null = null;
|
||||
let title: string | null = null;
|
||||
|
||||
for (const rawLine of content.split(/\r?\n/)) {
|
||||
const line = rawLine.trim();
|
||||
if (!line) continue;
|
||||
let entry: any;
|
||||
try {
|
||||
entry = JSON.parse(line);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
if (!sessionId && typeof entry.sessionId === "string") {
|
||||
sessionId = entry.sessionId;
|
||||
}
|
||||
if (!cwd && typeof entry.cwd === "string") {
|
||||
cwd = entry.cwd;
|
||||
}
|
||||
if (!title && entry.type === "user" && entry.message) {
|
||||
title = extractClaudeUserText(entry.message);
|
||||
}
|
||||
if (sessionId && cwd && title) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!sessionId || !cwd) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const persistence: AgentPersistenceHandle = {
|
||||
provider: "claude",
|
||||
sessionId,
|
||||
nativeHandle: sessionId,
|
||||
metadata: {
|
||||
provider: "claude",
|
||||
cwd,
|
||||
},
|
||||
};
|
||||
|
||||
return {
|
||||
provider: "claude",
|
||||
sessionId,
|
||||
cwd,
|
||||
title: (title ?? "").trim() || `Claude session ${sessionId.slice(0, 8)}`,
|
||||
lastActivityAt: mtime,
|
||||
persistence,
|
||||
};
|
||||
}
|
||||
|
||||
function extractClaudeUserText(message: any): string | null {
|
||||
if (!message) {
|
||||
return null;
|
||||
}
|
||||
if (typeof message.content === "string") {
|
||||
return message.content.trim();
|
||||
}
|
||||
if (typeof message.text === "string") {
|
||||
return message.text.trim();
|
||||
}
|
||||
if (Array.isArray(message.content)) {
|
||||
for (const block of message.content) {
|
||||
if (block && typeof block.text === "string") {
|
||||
return block.text.trim();
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -110,7 +110,7 @@ describe("CodexAgentClient (SDK integration)", () => {
|
||||
if (
|
||||
event.type === "timeline" &&
|
||||
event.provider === "codex" &&
|
||||
(event.item.type === "file_change" || event.item.type === "command" || event.item.type === "mcp_tool")
|
||||
event.item.type === "tool_call"
|
||||
) {
|
||||
sawActivity = true;
|
||||
}
|
||||
|
||||
@@ -34,6 +34,8 @@ import type {
|
||||
AgentStreamEvent,
|
||||
AgentTimelineItem,
|
||||
AgentUsage,
|
||||
ListPersistedAgentsOptions,
|
||||
PersistedAgentDescriptor,
|
||||
} from "../agent-sdk-types.js";
|
||||
|
||||
type CodexAgentConfig = AgentSessionConfig & { provider: "codex" };
|
||||
@@ -96,6 +98,33 @@ const MODE_PRESETS: Record<
|
||||
type CodexOptionsOverrides = Partial<ThreadOptions> & { skipGitRepoCheck?: boolean };
|
||||
|
||||
const MAX_ROLLOUT_SEARCH_DEPTH = 5;
|
||||
type ToolCallTimelineItem = Extract<AgentTimelineItem, { type: "tool_call" }>;
|
||||
|
||||
function createToolCallTimelineItem(
|
||||
data: Omit<ToolCallTimelineItem, "type">
|
||||
): AgentTimelineItem {
|
||||
return { type: "tool_call", ...data };
|
||||
}
|
||||
|
||||
function buildCommandDisplayName(command?: unknown): string {
|
||||
if (typeof command === "string") {
|
||||
const trimmed = command.trim();
|
||||
if (trimmed.length) {
|
||||
return trimmed;
|
||||
}
|
||||
}
|
||||
return "Command";
|
||||
}
|
||||
|
||||
function buildFileChangeSummary(files: { path: string; kind: string }[]): string {
|
||||
if (!files.length) {
|
||||
return "File change";
|
||||
}
|
||||
if (files.length === 1) {
|
||||
return `${files[0].kind ?? "edit"}: ${files[0].path}`;
|
||||
}
|
||||
return `${files.length} file changes`;
|
||||
}
|
||||
|
||||
function coerceSandboxMode(value?: string): SandboxMode | undefined {
|
||||
if (!value) return undefined;
|
||||
@@ -166,6 +195,29 @@ export class CodexAgentClient implements AgentClient {
|
||||
return CodexAgentSession.create(this.codex, codexConfig, handle);
|
||||
}
|
||||
|
||||
async listPersistedAgents(options?: ListPersistedAgentsOptions): Promise<PersistedAgentDescriptor[]> {
|
||||
const sessionRoot = resolveCodexSessionRoot();
|
||||
if (!sessionRoot) {
|
||||
return [];
|
||||
}
|
||||
if (!(await fileExists(sessionRoot))) {
|
||||
return [];
|
||||
}
|
||||
const limit = options?.limit ?? 20;
|
||||
const candidates = await collectRecentRolloutFiles(sessionRoot, limit * 3);
|
||||
const descriptors: PersistedAgentDescriptor[] = [];
|
||||
for (const candidate of candidates) {
|
||||
const descriptor = await parseRolloutDescriptor(candidate.path, candidate.mtime, sessionRoot);
|
||||
if (descriptor) {
|
||||
descriptors.push(descriptor);
|
||||
}
|
||||
if (descriptors.length >= limit) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return descriptors;
|
||||
}
|
||||
|
||||
private assertConfig(config: AgentSessionConfig): CodexAgentConfig {
|
||||
if (config.provider !== "codex") {
|
||||
throw new Error(`CodexAgentClient received config for provider '${config.provider}'`);
|
||||
@@ -323,12 +375,16 @@ class CodexAgentSession implements AgentSession {
|
||||
this.enqueueHistoryEvent({
|
||||
type: "timeline",
|
||||
provider: "codex",
|
||||
item: {
|
||||
type: "command",
|
||||
command: `permission:${request.name}`,
|
||||
item: createToolCallTimelineItem({
|
||||
server: "permission",
|
||||
tool: request.name,
|
||||
status,
|
||||
callId: request.id,
|
||||
displayName: request.title ?? request.name,
|
||||
kind: "permission",
|
||||
input: request.input,
|
||||
raw: { request, response },
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
this.enqueueHistoryEvent({
|
||||
@@ -579,12 +635,16 @@ class CodexAgentSession implements AgentSession {
|
||||
{
|
||||
type: "timeline",
|
||||
provider: "codex",
|
||||
item: {
|
||||
type: "command",
|
||||
command: `permission:${request.name}`,
|
||||
item: createToolCallTimelineItem({
|
||||
server: "permission",
|
||||
tool: request.name,
|
||||
status: "requested",
|
||||
callId: request.id,
|
||||
displayName: request.title ?? request.name,
|
||||
kind: "permission",
|
||||
input: request.input,
|
||||
raw: request.raw,
|
||||
},
|
||||
}),
|
||||
},
|
||||
{ type: "permission_requested", provider: "codex", request },
|
||||
];
|
||||
@@ -698,23 +758,54 @@ class CodexAgentSession implements AgentSession {
|
||||
case "reasoning":
|
||||
return { type: "reasoning", text: item.text, raw: item };
|
||||
case "command_execution":
|
||||
return { type: "command", command: item.command, status: item.status, raw: item };
|
||||
case "file_change":
|
||||
return {
|
||||
type: "file_change",
|
||||
files: item.changes.map((change) => ({ path: change.path, kind: change.kind })),
|
||||
return createToolCallTimelineItem({
|
||||
server: "command",
|
||||
tool: "shell",
|
||||
status: item.status,
|
||||
callId: (item as any)?.call_id,
|
||||
displayName: buildCommandDisplayName(item.command),
|
||||
kind: "execute",
|
||||
input: { command: item.command, cwd: (item as any)?.cwd },
|
||||
output: (item as any)?.output,
|
||||
error: (item as any)?.error,
|
||||
raw: item,
|
||||
};
|
||||
});
|
||||
case "file_change": {
|
||||
const files = item.changes.map((change) => ({ path: change.path, kind: change.kind }));
|
||||
return createToolCallTimelineItem({
|
||||
server: "file_change",
|
||||
tool: "apply_patch",
|
||||
status: "completed",
|
||||
callId: (item as any)?.call_id,
|
||||
displayName: buildFileChangeSummary(files),
|
||||
kind: "edit",
|
||||
output: { files },
|
||||
raw: item,
|
||||
});
|
||||
}
|
||||
case "mcp_tool_call":
|
||||
return {
|
||||
type: "mcp_tool",
|
||||
return createToolCallTimelineItem({
|
||||
server: item.server,
|
||||
tool: item.tool,
|
||||
status: item.status,
|
||||
callId: (item as any)?.call_id,
|
||||
displayName: `${item.server}.${item.tool}`,
|
||||
kind: "tool",
|
||||
input: (item as any)?.input,
|
||||
output: (item as any)?.output,
|
||||
raw: item,
|
||||
};
|
||||
});
|
||||
case "web_search":
|
||||
return { type: "web_search", query: item.query, raw: item };
|
||||
return createToolCallTimelineItem({
|
||||
server: "web_search",
|
||||
tool: "web_search",
|
||||
status: (item as any)?.status ?? "completed",
|
||||
callId: (item as any)?.call_id,
|
||||
displayName: item.query ? `Web search: ${item.query}` : "Web search",
|
||||
kind: "search",
|
||||
input: { query: item.query },
|
||||
raw: item,
|
||||
});
|
||||
case "todo_list":
|
||||
return { type: "todo", items: item.items, raw: item };
|
||||
case "error":
|
||||
@@ -894,13 +985,16 @@ function handleRolloutFunctionCall(
|
||||
events.push({
|
||||
type: "timeline",
|
||||
provider: "codex",
|
||||
item: {
|
||||
type: "mcp_tool",
|
||||
item: createToolCallTimelineItem({
|
||||
server: "codex",
|
||||
tool: name,
|
||||
status: payload.status ?? "in_progress",
|
||||
callId: typeof payload.call_id === "string" ? payload.call_id : undefined,
|
||||
displayName: `${name}`,
|
||||
kind: "tool",
|
||||
input: safeJsonParse(payload.arguments),
|
||||
raw: payload,
|
||||
},
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -922,12 +1016,17 @@ function finalizeRolloutFunctionCall(
|
||||
events.push({
|
||||
type: "timeline",
|
||||
provider: "codex",
|
||||
item: {
|
||||
type: "command",
|
||||
command: command.command,
|
||||
item: createToolCallTimelineItem({
|
||||
server: "command",
|
||||
tool: "shell",
|
||||
status,
|
||||
callId: payload.call_id,
|
||||
displayName: buildCommandDisplayName(command.command),
|
||||
kind: "execute",
|
||||
input: { command: command.command },
|
||||
output: result,
|
||||
raw: { payload, result },
|
||||
},
|
||||
}),
|
||||
});
|
||||
commandCalls.delete(payload.call_id);
|
||||
}
|
||||
@@ -939,11 +1038,15 @@ function handleRolloutCustomToolCall(payload: any, events: AgentStreamEvent[]):
|
||||
events.push({
|
||||
type: "timeline",
|
||||
provider: "codex",
|
||||
item: {
|
||||
type: "file_change",
|
||||
files,
|
||||
item: createToolCallTimelineItem({
|
||||
server: "file_change",
|
||||
tool: "apply_patch",
|
||||
status: "completed",
|
||||
displayName: buildFileChangeSummary(files),
|
||||
kind: "edit",
|
||||
output: { files },
|
||||
raw: payload,
|
||||
},
|
||||
}),
|
||||
});
|
||||
}
|
||||
return;
|
||||
@@ -953,17 +1056,147 @@ function handleRolloutCustomToolCall(payload: any, events: AgentStreamEvent[]):
|
||||
events.push({
|
||||
type: "timeline",
|
||||
provider: "codex",
|
||||
item: {
|
||||
type: "mcp_tool",
|
||||
item: createToolCallTimelineItem({
|
||||
server: "codex",
|
||||
tool: payload.name,
|
||||
status: payload.status ?? "completed",
|
||||
displayName: payload.name,
|
||||
kind: "tool",
|
||||
input: payload.input,
|
||||
output: payload.output,
|
||||
raw: payload,
|
||||
},
|
||||
}),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
type RolloutCandidate = {
|
||||
path: string;
|
||||
mtime: Date;
|
||||
};
|
||||
|
||||
async function collectRecentRolloutFiles(rootDir: string, limit: number): Promise<RolloutCandidate[]> {
|
||||
const candidates: RolloutCandidate[] = [];
|
||||
async function traverse(dir: string): Promise<void> {
|
||||
let entries: Dirent[];
|
||||
try {
|
||||
entries = await fs.readdir(dir, { withFileTypes: true });
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
const files = entries.filter(
|
||||
(entry) =>
|
||||
entry.isFile() &&
|
||||
entry.name.startsWith("rollout-") &&
|
||||
(entry.name.endsWith(".json") || entry.name.endsWith(".jsonl"))
|
||||
);
|
||||
for (const entry of files) {
|
||||
const fullPath = path.join(dir, entry.name);
|
||||
try {
|
||||
const stat = await fs.stat(fullPath);
|
||||
candidates.push({ path: fullPath, mtime: stat.mtime });
|
||||
} catch {
|
||||
// ignore stat failures
|
||||
}
|
||||
}
|
||||
const directories = entries
|
||||
.filter((entry) => entry.isDirectory())
|
||||
.sort((a, b) => b.name.localeCompare(a.name));
|
||||
for (const dirEntry of directories) {
|
||||
await traverse(path.join(dir, dirEntry.name));
|
||||
}
|
||||
}
|
||||
await traverse(rootDir);
|
||||
return candidates
|
||||
.sort((a, b) => b.mtime.getTime() - a.mtime.getTime())
|
||||
.slice(0, limit);
|
||||
}
|
||||
|
||||
async function parseRolloutDescriptor(
|
||||
filePath: string,
|
||||
mtime: Date,
|
||||
sessionRoot: string
|
||||
): Promise<PersistedAgentDescriptor | null> {
|
||||
let content: string;
|
||||
try {
|
||||
content = await fs.readFile(filePath, "utf8");
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
let sessionId: string | null = null;
|
||||
let cwd: string | null = null;
|
||||
let title: string | null = null;
|
||||
|
||||
for (const rawLine of content.split(/\r?\n/)) {
|
||||
const line = rawLine.trim();
|
||||
if (!line) continue;
|
||||
let entry: any;
|
||||
try {
|
||||
entry = JSON.parse(line);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
if (!sessionId && entry?.type === "session_meta" && entry.payload) {
|
||||
sessionId = typeof entry.payload.id === "string" ? entry.payload.id : null;
|
||||
if (typeof entry.payload.cwd === "string" && entry.payload.cwd.length > 0) {
|
||||
cwd = entry.payload.cwd;
|
||||
}
|
||||
} else if (!title && entry?.type === "response_item" && entry.payload?.role === "user") {
|
||||
title = extractCodexUserPrompt(entry.payload);
|
||||
}
|
||||
if (sessionId && cwd && title) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!sessionId || !cwd) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const persistence: AgentPersistenceHandle = {
|
||||
provider: "codex",
|
||||
sessionId,
|
||||
nativeHandle: sessionId,
|
||||
metadata: sanitizeMetadata({
|
||||
provider: "codex",
|
||||
cwd,
|
||||
codexSessionDir: sessionRoot,
|
||||
codexRolloutPath: filePath,
|
||||
}),
|
||||
};
|
||||
|
||||
return {
|
||||
provider: "codex",
|
||||
sessionId,
|
||||
cwd,
|
||||
title: (title ?? "").trim() || `Codex session ${sessionId.slice(0, 8)}`,
|
||||
lastActivityAt: mtime,
|
||||
persistence,
|
||||
};
|
||||
}
|
||||
|
||||
function extractCodexUserPrompt(payload: any): string | null {
|
||||
const content = payload?.content;
|
||||
if (!Array.isArray(content)) {
|
||||
return null;
|
||||
}
|
||||
for (const block of content) {
|
||||
if (block && typeof block === "object" && typeof block.text === "string") {
|
||||
return block.text.trim();
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function resolveCodexSessionRoot(): string | null {
|
||||
if (process.env.CODEX_SESSION_DIR) {
|
||||
return process.env.CODEX_SESSION_DIR;
|
||||
}
|
||||
const codexHome = process.env.CODEX_HOME ?? path.join(os.homedir(), ".codex");
|
||||
return path.join(codexHome, "sessions");
|
||||
}
|
||||
|
||||
function extractMessageText(content: unknown): string {
|
||||
if (!Array.isArray(content)) {
|
||||
return "";
|
||||
|
||||
@@ -112,6 +112,12 @@ export const AgentPermissionRequestPayloadSchema: z.ZodType<AgentPermissionReque
|
||||
|
||||
export const AgentTimelineItemPayloadSchema: z.ZodType<AgentTimelineItem> =
|
||||
z.discriminatedUnion("type", [
|
||||
z.object({
|
||||
type: z.literal("user_message"),
|
||||
text: z.string(),
|
||||
messageId: z.string().optional(),
|
||||
raw: z.unknown().optional(),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal("assistant_message"),
|
||||
text: z.string(),
|
||||
@@ -123,31 +129,16 @@ export const AgentTimelineItemPayloadSchema: z.ZodType<AgentTimelineItem> =
|
||||
raw: z.unknown().optional(),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal("command"),
|
||||
command: z.string(),
|
||||
status: z.string(),
|
||||
raw: z.unknown().optional(),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal("file_change"),
|
||||
files: z.array(
|
||||
z.object({
|
||||
path: z.string(),
|
||||
kind: z.string(),
|
||||
})
|
||||
),
|
||||
raw: z.unknown().optional(),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal("mcp_tool"),
|
||||
type: z.literal("tool_call"),
|
||||
server: z.string(),
|
||||
tool: z.string(),
|
||||
status: z.string(),
|
||||
raw: z.unknown().optional(),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal("web_search"),
|
||||
query: z.string(),
|
||||
status: z.string().optional(),
|
||||
callId: z.string().optional(),
|
||||
displayName: z.string().optional(),
|
||||
kind: z.string().optional(),
|
||||
input: z.unknown().optional(),
|
||||
output: z.unknown().optional(),
|
||||
error: z.unknown().optional(),
|
||||
raw: z.unknown().optional(),
|
||||
}),
|
||||
z.object({
|
||||
@@ -334,6 +325,25 @@ export const CreateAgentRequestMessageSchema = z.object({
|
||||
requestId: z.string().optional(),
|
||||
});
|
||||
|
||||
export const ResumeAgentRequestMessageSchema = z.object({
|
||||
type: z.literal("resume_agent_request"),
|
||||
handle: AgentPersistenceHandleSchema,
|
||||
overrides: AgentSessionConfigSchema.partial().optional(),
|
||||
requestId: z.string().optional(),
|
||||
});
|
||||
|
||||
export const RefreshAgentRequestMessageSchema = z.object({
|
||||
type: z.literal("refresh_agent_request"),
|
||||
agentId: z.string(),
|
||||
requestId: z.string().optional(),
|
||||
});
|
||||
|
||||
export const ListPersistedAgentsRequestMessageSchema = z.object({
|
||||
type: z.literal("list_persisted_agents_request"),
|
||||
provider: AgentProviderSchema.optional(),
|
||||
limit: z.number().int().positive().optional(),
|
||||
});
|
||||
|
||||
export const InitializeAgentRequestMessageSchema = z.object({
|
||||
type: z.literal("initialize_agent_request"),
|
||||
agentId: z.string(),
|
||||
@@ -400,11 +410,14 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [
|
||||
SendAgentMessageSchema,
|
||||
SendAgentAudioSchema,
|
||||
CreateAgentRequestMessageSchema,
|
||||
ResumeAgentRequestMessageSchema,
|
||||
RefreshAgentRequestMessageSchema,
|
||||
InitializeAgentRequestMessageSchema,
|
||||
SetAgentModeMessageSchema,
|
||||
AgentPermissionResponseMessageSchema,
|
||||
GitDiffRequestSchema,
|
||||
FileExplorerRequestSchema,
|
||||
ListPersistedAgentsRequestMessageSchema,
|
||||
]);
|
||||
|
||||
export type SessionInboundMessage = z.infer<typeof SessionInboundMessageSchema>;
|
||||
@@ -580,6 +593,24 @@ export const AgentPermissionResolvedMessageSchema = z.object({
|
||||
}),
|
||||
});
|
||||
|
||||
const PersistedAgentDescriptorPayloadSchema = z.object({
|
||||
provider: AgentProviderSchema,
|
||||
sessionId: z.string(),
|
||||
cwd: z.string(),
|
||||
title: z.string(),
|
||||
lastActivityAt: z.string(),
|
||||
persistence: AgentPersistenceHandleSchema,
|
||||
});
|
||||
|
||||
export type PersistedAgentDescriptorPayload = z.infer<typeof PersistedAgentDescriptorPayloadSchema>;
|
||||
|
||||
export const ListPersistedAgentsResponseSchema = z.object({
|
||||
type: z.literal("list_persisted_agents_response"),
|
||||
payload: z.object({
|
||||
items: z.array(PersistedAgentDescriptorPayloadSchema),
|
||||
}),
|
||||
});
|
||||
|
||||
export const GitDiffResponseSchema = z.object({
|
||||
type: z.literal("git_diff_response"),
|
||||
payload: z.object({
|
||||
@@ -618,6 +649,7 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [
|
||||
DeleteConversationResponseMessageSchema,
|
||||
AgentPermissionRequestMessageSchema,
|
||||
AgentPermissionResolvedMessageSchema,
|
||||
ListPersistedAgentsResponseSchema,
|
||||
GitDiffResponseSchema,
|
||||
FileExplorerResponseSchema,
|
||||
]);
|
||||
@@ -645,6 +677,7 @@ export type ListConversationsResponseMessage = z.infer<typeof ListConversationsR
|
||||
export type DeleteConversationResponseMessage = z.infer<typeof DeleteConversationResponseMessageSchema>;
|
||||
export type AgentPermissionRequestMessage = z.infer<typeof AgentPermissionRequestMessageSchema>;
|
||||
export type AgentPermissionResolvedMessage = z.infer<typeof AgentPermissionResolvedMessageSchema>;
|
||||
export type ListPersistedAgentsResponseMessage = z.infer<typeof ListPersistedAgentsResponseSchema>;
|
||||
|
||||
// Type exports for payload types
|
||||
export type ActivityLogPayload = z.infer<typeof ActivityLogPayloadSchema>;
|
||||
@@ -655,6 +688,8 @@ export type RealtimeAudioChunkMessage = z.infer<typeof RealtimeAudioChunkMessage
|
||||
export type SendAgentMessage = z.infer<typeof SendAgentMessageSchema>;
|
||||
export type SendAgentAudio = z.infer<typeof SendAgentAudioSchema>;
|
||||
export type CreateAgentRequestMessage = z.infer<typeof CreateAgentRequestMessageSchema>;
|
||||
export type ResumeAgentRequestMessage = z.infer<typeof ResumeAgentRequestMessageSchema>;
|
||||
export type ListPersistedAgentsRequestMessage = z.infer<typeof ListPersistedAgentsRequestMessageSchema>;
|
||||
export type InitializeAgentRequestMessage = z.infer<typeof InitializeAgentRequestMessageSchema>;
|
||||
export type SetAgentModeMessage = z.infer<typeof SetAgentModeMessageSchema>;
|
||||
export type AgentPermissionResponseMessage = z.infer<typeof AgentPermissionResponseMessageSchema>;
|
||||
|
||||
@@ -191,6 +191,40 @@ export class Session {
|
||||
return `${base}\n\n[Image attachments]\n${attachmentSummary}\n(Actual image bytes omitted; request a screenshot or file if needed.)`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Interrupt the agent's active run so the next prompt starts a fresh turn.
|
||||
* Returns once the manager confirms the stream has been cancelled.
|
||||
*/
|
||||
private async interruptAgentIfRunning(agentId: string): Promise<void> {
|
||||
const snapshot = this.agentManager.getAgent(agentId);
|
||||
if (!snapshot) {
|
||||
throw new Error(`Agent ${agentId} not found`);
|
||||
}
|
||||
|
||||
if (snapshot.status !== "running") {
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(
|
||||
`[Session ${this.clientId}] Interrupting active run for agent ${agentId}`
|
||||
);
|
||||
|
||||
try {
|
||||
const cancelled = await this.agentManager.cancelAgentRun(agentId);
|
||||
if (!cancelled) {
|
||||
console.warn(
|
||||
`[Session ${this.clientId}] Agent ${agentId} reported running but no active run was cancelled`
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`[Session ${this.clientId}] Failed to interrupt agent ${agentId}:`,
|
||||
error
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start streaming an agent run and forward results via the websocket broadcast
|
||||
*/
|
||||
@@ -516,6 +550,14 @@ export class Session {
|
||||
await this.handleCreateAgentRequest(msg);
|
||||
break;
|
||||
|
||||
case "resume_agent_request":
|
||||
await this.handleResumeAgentRequest(msg);
|
||||
break;
|
||||
|
||||
case "refresh_agent_request":
|
||||
await this.handleRefreshAgentRequest(msg);
|
||||
break;
|
||||
|
||||
case "initialize_agent_request":
|
||||
await this.handleInitializeAgentRequest(msg.agentId, msg.requestId);
|
||||
break;
|
||||
@@ -539,6 +581,10 @@ export class Session {
|
||||
case "file_explorer_request":
|
||||
await this.handleFileExplorerRequest(msg);
|
||||
break;
|
||||
|
||||
case "list_persisted_agents_request":
|
||||
await this.handleListPersistedAgentsRequest(msg);
|
||||
break;
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error(
|
||||
@@ -657,7 +703,7 @@ export class Session {
|
||||
private async handleSendAgentMessage(
|
||||
agentId: string,
|
||||
text: string,
|
||||
_messageId?: string,
|
||||
messageId?: string,
|
||||
images?: Array<{ data: string; mimeType: string }>
|
||||
): Promise<void> {
|
||||
console.log(
|
||||
@@ -666,7 +712,28 @@ export class Session {
|
||||
}] Sending text to agent ${agentId}: ${text.substring(0, 50)}...${images && images.length > 0 ? ` with ${images.length} image attachment(s)` : ''}`
|
||||
);
|
||||
|
||||
try {
|
||||
await this.interruptAgentIfRunning(agentId);
|
||||
} catch (error) {
|
||||
this.handleAgentRunError(
|
||||
agentId,
|
||||
error,
|
||||
"Failed to interrupt running agent before sending prompt"
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const prompt = this.buildAgentPrompt(text, images);
|
||||
|
||||
try {
|
||||
this.agentManager.recordUserMessage(agentId, text, { messageId });
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`[Session ${this.clientId}] Failed to record user message for agent ${agentId}:`,
|
||||
error
|
||||
);
|
||||
}
|
||||
|
||||
this.startAgentStream(agentId, prompt);
|
||||
}
|
||||
|
||||
@@ -722,6 +789,26 @@ export class Session {
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
await this.interruptAgentIfRunning(agentId);
|
||||
} catch (error) {
|
||||
this.handleAgentRunError(
|
||||
agentId,
|
||||
error,
|
||||
"Failed to interrupt running agent before sending audio prompt"
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
this.agentManager.recordUserMessage(agentId, transcriptText);
|
||||
} catch (recordError) {
|
||||
console.error(
|
||||
`[Session ${this.clientId}] Failed to record transcribed user message for agent ${agentId}:`,
|
||||
recordError
|
||||
);
|
||||
}
|
||||
|
||||
// Send transcribed text to agent
|
||||
this.startAgentStream(agentId, transcriptText);
|
||||
console.log(
|
||||
@@ -869,6 +956,103 @@ export class Session {
|
||||
}
|
||||
}
|
||||
|
||||
private async handleResumeAgentRequest(
|
||||
msg: Extract<SessionInboundMessage, { type: "resume_agent_request" }>
|
||||
): Promise<void> {
|
||||
const { handle, overrides, requestId } = msg;
|
||||
if (!handle) {
|
||||
console.warn(
|
||||
`[Session ${this.clientId}] Resume request missing persistence handle`
|
||||
);
|
||||
this.emit({
|
||||
type: "activity_log",
|
||||
payload: {
|
||||
id: uuidv4(),
|
||||
timestamp: new Date(),
|
||||
type: "error",
|
||||
content: "Unable to resume agent: missing persistence handle",
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
console.log(
|
||||
`[Session ${this.clientId}] Resuming agent ${handle.sessionId} (${handle.provider})`
|
||||
);
|
||||
try {
|
||||
const snapshot = await this.agentManager.resumeAgent(handle, overrides);
|
||||
this.setCachedTitle(snapshot.id, null);
|
||||
await this.forwardAgentState(snapshot);
|
||||
const timelineSize = this.emitAgentTimelineSnapshot(snapshot);
|
||||
if (requestId) {
|
||||
this.emit({
|
||||
type: "status",
|
||||
payload: {
|
||||
status: "agent_resumed",
|
||||
agentId: snapshot.id,
|
||||
requestId,
|
||||
timelineSize,
|
||||
},
|
||||
});
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error(
|
||||
`[Session ${this.clientId}] Failed to resume agent:`,
|
||||
error
|
||||
);
|
||||
this.emit({
|
||||
type: "activity_log",
|
||||
payload: {
|
||||
id: uuidv4(),
|
||||
timestamp: new Date(),
|
||||
type: "error",
|
||||
content: `Failed to resume agent: ${error.message}`,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private async handleRefreshAgentRequest(
|
||||
msg: Extract<SessionInboundMessage, { type: "refresh_agent_request" }>
|
||||
): Promise<void> {
|
||||
const { agentId, requestId } = msg;
|
||||
console.log(
|
||||
`[Session ${this.clientId}] Refreshing agent ${agentId} from persistence`
|
||||
);
|
||||
|
||||
try {
|
||||
const snapshot = await this.agentManager.refreshAgentFromPersistence(
|
||||
agentId
|
||||
);
|
||||
await this.forwardAgentState(snapshot);
|
||||
const timelineSize = this.emitAgentTimelineSnapshot(snapshot);
|
||||
if (requestId) {
|
||||
this.emit({
|
||||
type: "status",
|
||||
payload: {
|
||||
status: "agent_refreshed",
|
||||
agentId,
|
||||
requestId,
|
||||
timelineSize,
|
||||
},
|
||||
});
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error(
|
||||
`[Session ${this.clientId}] Failed to refresh agent ${agentId}:`,
|
||||
error
|
||||
);
|
||||
this.emit({
|
||||
type: "activity_log",
|
||||
payload: {
|
||||
id: uuidv4(),
|
||||
timestamp: new Date(),
|
||||
type: "error",
|
||||
content: `Failed to refresh agent: ${error.message}`,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private async buildAgentSessionConfig(
|
||||
config: AgentSessionConfig,
|
||||
worktreeName?: string
|
||||
@@ -893,6 +1077,47 @@ export class Session {
|
||||
};
|
||||
}
|
||||
|
||||
private async handleListPersistedAgentsRequest(
|
||||
msg: Extract<SessionInboundMessage, { type: "list_persisted_agents_request" }>
|
||||
): Promise<void> {
|
||||
const { provider, limit } = msg;
|
||||
try {
|
||||
const entries = await this.agentManager.listPersistedAgents({
|
||||
provider,
|
||||
limit,
|
||||
});
|
||||
this.emit({
|
||||
type: "list_persisted_agents_response",
|
||||
payload: {
|
||||
items: entries.map((entry) => ({
|
||||
provider: entry.provider,
|
||||
sessionId: entry.sessionId,
|
||||
cwd: entry.cwd,
|
||||
title: entry.title ?? `Session ${entry.sessionId.slice(0, 8)}`,
|
||||
lastActivityAt: entry.lastActivityAt.toISOString(),
|
||||
persistence: entry.persistence,
|
||||
})),
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`[Session ${this.clientId}] Failed to list persisted agents:`,
|
||||
error
|
||||
);
|
||||
this.emit({
|
||||
type: "activity_log",
|
||||
payload: {
|
||||
id: uuidv4(),
|
||||
timestamp: new Date(),
|
||||
type: "error",
|
||||
content: `Failed to list saved agents: ${
|
||||
(error as Error)?.message ?? error
|
||||
}`,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle set agent mode request
|
||||
*/
|
||||
@@ -1154,10 +1379,6 @@ export class Session {
|
||||
|
||||
private emitAgentTimelineSnapshot(agent: AgentSnapshot): number {
|
||||
const timeline = this.agentManager.getTimeline(agent.id);
|
||||
if (timeline.length === 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const events = timeline.map((item) => ({
|
||||
event: serializeAgentStreamEvent({
|
||||
type: "timeline",
|
||||
|
||||
103
plan.md
103
plan.md
@@ -1,6 +1,8 @@
|
||||
Refactor plan: full migration from ACP to the SDK AgentManager stack. This is a hard refactor—no shims, no temporary adapters. It’s acceptable (and expected) that TypeScript won’t compile in intermediate steps
|
||||
as long as we keep pushing forward toward the final architecture, and have strong typing in place.
|
||||
|
||||
Commit after each task with a descriptive commit message.
|
||||
|
||||
These tasks need to be done sequentially by different agents:
|
||||
|
||||
- [x] Inventory every ACP dependency (server session/websocket/messages/MCP/title generator/frontend reducers) and document the new AgentManager equivalents + message contracts (see docs/acp-dependency-inventory.md)
|
||||
@@ -20,6 +22,107 @@ These tasks need to be done sequentially by different agents:
|
||||
- [x] Peer review of backend changes; address feedback, then review frontend changes; final regression pass before merging
|
||||
- [x] Final review of the codebase: check for duplicated code, untyped code, unused imports, etc.
|
||||
|
||||
# User testing and review
|
||||
|
||||
- [x] The create new agent modal stay in "Creating agent..." state forever, but the agent is created, if I close it I can see the agent in the list, hmm but wait when I click on the agent it says "Loading agent..." forever, I can see clean logs in the server, no errors:
|
||||
|
||||
Fixed by ensuring the server always emits an `agent_stream_snapshot` (even when empty) so the frontend clears the initializing state after a successful agent creation/initialization.
|
||||
|
||||
````log
|
||||
[WS] create_agent_request details: {
|
||||
cwd: '~/dev/voice-dev',
|
||||
initialMode: 'full-access',
|
||||
worktreeName: undefined,
|
||||
requestId: 'msg_1763196834129_jtfh2egsk'
|
||||
}
|
||||
[Session client-4] Creating agent in ~/dev/voice-dev (codex)
|
||||
[Session client-4] Created agent 29d1ee14-7687-44ce-8cd2-7c58b74288fa (codex)
|
||||
[WS] Received message type: session {
|
||||
type: 'session',
|
||||
message: {
|
||||
type: 'initialize_agent_request',
|
||||
agentId: '29d1ee14-7687-44ce-8cd2-7c58b74288fa'
|
||||
}
|
||||
}
|
||||
[Session client-4] Initializing agent 29d1ee14-7687-44ce-8cd2-7c58b74288fa on demand
|
||||
[Session client-4] Agent 29d1ee14-7687-44ce-8cd2-7c58b74288fa initialized with 0 timeline item(s)
|
||||
``
|
||||
|
||||
- [x] Super important: sending a prompt to an agent doesnt interrupt it, it must interrupt it and start a new turn, on top of their partial already streamed response.
|
||||
Fixed by interrupting the agent's active run (Session.interruptAgentIfRunning) before starting a new stream so each prompt spins up a fresh turn immediately after the partial response ends.
|
||||
- [x] We should not have separate edit, command and tool call events. Everything should be a tool call and be treated the same. Web fetch and editing a file should be shown the same for example in the agent stream view. Right now the commands and file edit are shown differently. Tool calls should show a loading pill when executing and then a completed pill when it's done. That pill when click we show the tool call bottom sheet. This was already working before.
|
||||
- `AgentTimelineItem` now only uses `assistant_message`, `reasoning`, `tool_call`, `todo`, and `error` entries—Codex/Claude providers emit unified tool call payloads (callId, kind, displayName, input/output/error) for commands, MCP tools, file edits, web search, and permission prompts.
|
||||
- The frontend stream reducer consumes the new tool call schema directly, so pills/bottom sheet share the same loading/completed lifecycle with raw inputs preserved for diff rendering again.
|
||||
- `activity-curator`, tests, and docs updated to describe the shared tool call contract. Follow-up: the dedicated permission-hiding task below can now simply filter `tool_call` entries with `server === "permission"` or `kind === "permission"`.
|
||||
|
||||
- [x] I also noticed that with tool calls, we're not showing the input? we're dropping it somewhere, i notcied this with Claude tool calls, make sure you add a test for this, ask it to write a file or run a specific non-destructive command and assert that tool call are being emitted with the correct data. (Stream reducer now preserves the initial raw tool input payload, and `test-idempotent-stream.ts` includes `testToolCallInputPreservation` to guard against regressions.)
|
||||
- [x] For the agent stream assistant message. I don't know how it happens but we lose spaces, like all the words are merged together. Maybe we are trimming chunks? (Fixed by preserving whitespace in `packages/app/src/types/stream.ts`; `test-idempotent-stream.ts` now includes `testAssistantWhitespacePreservation`—needs `ts-node/tsx` or similar runner since `node test-idempotent-stream.ts` currently fails on TS imports.)
|
||||
- [x] Agents are being wiped out on restart: [AgentRegistry] Failed to load agents: SyntaxError: Unexpected non-whitespace character after JSON at position 368 (line 16 column 2)
|
||||
- AgentRegistry now recovers when `agents.json` has trailing garbage (e.g. write crashes) by trimming to the last valid array, rewriting a sanitized copy, and continuing to load cached agents. Covered by `recovers from trailing garbage in agents.json` in `agent-registry.test.ts`.
|
||||
- [x] We should not be showing permission granted/denied messages in the agent stream view. Permissions are only relevant when they're awaiting for a response, and we already had thism make sure its wirded up. (Filtered permission tool_call entries with `server === "permission" || kind === "permission"` in `packages/app/src/types/stream.ts`; `test-idempotent-stream.ts` now has `testPermissionToolCallFiltering`.)
|
||||
- [x] In the file browser, we should show an icon for the file type on the right, for a quick visual clue of the file type. Support directory, image, text file or other. (Implemented directory/image/text/other detection in `file-explorer.tsx` with lucide icons.)
|
||||
- [x] In the file browser we should remember the scroll when we come back from the file preview. It's frustrating to have to scroll back to where you were.
|
||||
- Scroll position is now captured per directory view and restored when leaving a preview; changing directories still resets the offset to the top so navigation semantics stay predictable.
|
||||
- [x] Add an agent kind indicator in the agent list, so we can quickly identify the agent kind (Claude, Codex, etc.). On the left of the status pill. (AgentSidebar now pulls the provider label from `getAgentProviderDefinition` and renders a muted badge between the cwd and status pill so Claude/Codex are visible at a glance.)
|
||||
|
||||
# User testing round 2
|
||||
|
||||
- [x] File browser icons are not showing up, they should be on the left side of the file name. (Icons now render before the filename with a fixed-width container so alignment stays consistent.)
|
||||
- [x] Creating an agent doesnt redirect to the agent screen, it stays on the create agent modal. Hmm wait, its a bit of a hit and miss? Ah wait, i know, it only works for Codex for some reason. Claude hangs but the agent is in the list.
|
||||
- Fixed by subscribing once to `status` messages inside `create-agent-modal.tsx` and matching against a persistent pending request ref so we can't miss the `agent_created` ack if it arrives before the effect registers. Both Codex and Claude creation now close the modal and redirect reliably.
|
||||
- [x] User messages are not showing up across deamon restarts when loading agents, at least for Codex. I see assistant messages though, review Claude too just in case, add a regression test for this.
|
||||
- AgentManager now records `user_message` timeline entries whenever text/audio prompts are sent (including message IDs) and emits them through `agent_stream_snapshot` so codex/claude history hydrates correctly after reconnects. The frontend reducer dedupes optimistic entries using the shared messageId, and `test-idempotent-stream.ts` includes `testUserMessageHydration` to guard against regressions.
|
||||
- [x] Improve the file browser loading states, at the moment when we load a big directory, it just hangs on click, we should navigate immediately and show a loading state. (File explorer now switches paths optimistically and shows dedicated directory-loading banners/spinners driven by the pending request so clicks provide instant feedback even before entries stream back.)
|
||||
- [x] Make the browser file list be a virtualized list to support large dirs. (File explorer now renders entries through a virtualized FlatList so scroll state + loading banners remain intact even for thousands of files.)
|
||||
- [x] This is still happening, especially when the dev server restarts due to changes. I winder if we're not writing that file atomically or something?
|
||||
[AgentRegistry] Failed to load agents: SyntaxError: Unexpected non-whitespace character after JSON at position 1848 (line 69 column 1)
|
||||
at JSON.parse (<anonymous>)
|
||||
at AgentRegistry.parseRecords (
|
||||
Might be related I noticed we wrote this file:
|
||||
ls -lah packages/server/packages/server/agents.json
|
||||
-rw-r--r--@ 1 moboudra staff 1.8K 15 Nov 10:53 packages/server/packages/server/agents.json
|
||||
|
||||
It's in the wrong place, make sure all file writes are atomic and single source of truth. File being written in different places is a big red flag.
|
||||
- AgentRegistry now resolves the server workspace root (so writes always land in `packages/server/agents.json` regardless of cwd) and flushes via temp-file+rename to avoid partial writes; sanitized rewrites reuse the same atomic helper.
|
||||
|
||||
- [x] something is clearly wrong with the storage:
|
||||
|
||||
[Session client-1] Failed to record agent config for f74ce920-d398-41a3-a9ca-cf5151f1afad: Error: ENOENT: no such file or directory, rename '/Users/moboudra/dev/voice-dev/packages/server/.agents.json.tmp-67975-1763201899064' -> '/Users/moboudra/dev/voice-dev/packages/server/agents.json'
|
||||
at async Object.rename (node:internal/fs/promises:786:10)
|
||||
at async writeFileAtomically (/Users/moboudra/dev/voice-dev/packages/server/src/server/agent/agent-registry.ts:276:3)
|
||||
at async AgentRegistry.flush (/Users/moboudra/dev/voice-dev/packages/server/src/server/agent/agent-registry.ts:180:5)
|
||||
at async AgentRegistry.recordConfig (/Users/moboudra/dev/voice-dev/packages/server/src/server/agent/agent-registry.ts:130:5)
|
||||
at async Session.handleCreateAgentRequest (/Users/moboudra/dev/voice-dev/packages/server/src/server/session.ts:897:9)
|
||||
at async Session.handleMessage (/Users/moboudra/dev/voice-dev/packages/server/src/server/session.ts:550:11)
|
||||
at async VoiceAssistantWebSocketServer.handleMessage (/Users/moboudra/dev/voice-dev/packages/server/src/server/websocket-server.ts:184:15) {
|
||||
errno: -2,
|
||||
code: 'ENOENT',
|
||||
syscall: 'rename',
|
||||
path: '/Users/moboudra/dev/voice-dev/packages/server/.agents.json.tmp-67975-1763201899064',
|
||||
dest: '/Users/moboudra/dev/voice-dev/packages/server/agents.json'
|
||||
}
|
||||
|
||||
Cause was colliding temp filenames when multiple flushes landed in the same millisecond; `writeFileAtomically` now adds a `randomUUID` suffix so each flush writes to a unique tmp file before renaming.
|
||||
|
||||
- [x] I am not seeing my user messages in the agent stream view, on deamon restart. This was on Claude. Make sure you test this. Create agent, then hydrate it only from the persitance assert that you see the same events you received when creating the agent.
|
||||
- Claude history hydration now recognizes `user` entries inside the provider JSONL logs and converts them into `user_message` timeline items (via `extractUserMessageText`), so persisted agents replay the full turn order after a daemon restart. Added `claude-agent.history.test.ts` to lock in the parser behavior.
|
||||
|
||||
# New feature, resuming agents that were created outside of the app
|
||||
|
||||
The new architecture supports this, which is great, lets take advantage of it and implement this.
|
||||
|
||||
- [x] In the new agent screen, we should add a toggle at the top, "new agent" or "resume agent", and when we toggle to "resume agent", we should show a list of agents that were created outside of the app, be able to filter by agent provider, you can leverage the ~/.claude and ~/.codex directories to list the agents available. Make sure you use a flat list as the agent list can be long. Allow searching by title. If not tile is provided by the persisted state, use the first message in the chat. For each item show the title, directory, time since last activity (sort by this). When we click on an item, we should hydrate this agent and put it into our own agent list. Maybe just load the last 20 or something for now sorted by date. Implement the list functionality in the providers to abstract this logic per provider.
|
||||
- Server now exposes `list_persisted_agents_request` + `resume_agent_request`; provider clients look at `~/.claude/projects` + `~/.codex/sessions` and hydrate the latest 20 entries (title falls back to the first user message). The modal has a New/Resume toggle, provider chips, search, and a refreshed FlatList; tapping a card calls the new resume flow and shows a spinner until the `agent_resumed` status arrives. Follow-up: pagination/empty states beyond the first 20 items and persisting the fetched list client-side might be worth exploring if we need quicker refreshes.
|
||||
|
||||
|
||||
- [x] The resume flow works well, but I'd like that when I use the Codex/Claude session in my laptop, which changes the storage in the home dir, we hydrate those somehow? dot know whats the best way, maybe we poll them and if tere are changes we re-emit new events? This way jumping between laptop and mobile is seamless. Maybe it's client driven, so when we navigate to an agent screen we check for new persised messages we haven't seen yet? If this is challenging but doable lets do it. If it's super hard, lets just add a "Refresh button" in the agent three dot menu, that esentially reloads the agent from scratch so we dont ahve to keep track of the messages we have seen and not seen. But the ideal solution is that its automatic for best UX.
|
||||
- Added a manual "Refresh from disk" action in the agent three-dot menu; it rehydrates the agent via a new `refresh_agent_request`, swapping in a freshly resumed SDK session and replaying its full persisted timeline. This is a client-triggered refresh (automatic polling still TBD so future agents can revisit if we want background sync).
|
||||
|
||||
# Context
|
||||
|
||||
Session now subscribes directly to `AgentManager` events and forwards `agent_state`, `agent_stream`, and permission messages; the websocket layer is back to a thin transport. Next agent should verify downstream consumers (frontend + MCP services) can ingest the new stream schema.
|
||||
|
||||
```
|
||||
|
||||
```
|
||||
````
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { reduceStreamUpdate, hydrateStreamState, type StreamItem } from "./packages/app/src/types/stream";
|
||||
import { reduceStreamUpdate, hydrateStreamState, type StreamItem, type ToolCallItem } from "./packages/app/src/types/stream";
|
||||
|
||||
type AgentStreamEventPayload = Parameters<typeof reduceStreamUpdate>[1];
|
||||
|
||||
@@ -18,15 +18,47 @@ function reasoningTimeline(text: string): AgentStreamEventPayload {
|
||||
};
|
||||
}
|
||||
|
||||
function toolTimeline(id: string, status: string): AgentStreamEventPayload {
|
||||
function toolTimeline(id: string, status: string, raw?: unknown): AgentStreamEventPayload {
|
||||
return {
|
||||
type: "timeline",
|
||||
provider: "claude",
|
||||
item: {
|
||||
type: "mcp_tool",
|
||||
type: "tool_call",
|
||||
server: "terminal",
|
||||
tool: id,
|
||||
status,
|
||||
callId: id,
|
||||
displayName: id,
|
||||
kind: "execute",
|
||||
raw,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function permissionTimeline(id: string, status: string): AgentStreamEventPayload {
|
||||
return {
|
||||
type: "timeline",
|
||||
provider: "claude",
|
||||
item: {
|
||||
type: "tool_call",
|
||||
server: "permission",
|
||||
tool: "permission_request",
|
||||
status,
|
||||
callId: id,
|
||||
displayName: "Permission",
|
||||
kind: "permission",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function userTimeline(text: string, messageId?: string): AgentStreamEventPayload {
|
||||
return {
|
||||
type: "timeline",
|
||||
provider: "claude",
|
||||
item: {
|
||||
type: "user_message",
|
||||
text,
|
||||
messageId,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -147,6 +179,149 @@ function testMultipleMessages() {
|
||||
}
|
||||
}
|
||||
|
||||
// Test 4: Tool call raw input should survive completion updates
|
||||
function testToolCallInputPreservation() {
|
||||
console.log('\n=== Test 4: Tool Call Input Preservation ===');
|
||||
|
||||
const timestampStart = new Date("2025-01-01T10:00:00Z");
|
||||
const timestampFinish = new Date("2025-01-01T10:00:05Z");
|
||||
|
||||
const toolCallId = "tool-raw-test";
|
||||
const toolInput = {
|
||||
type: "mcp_tool_use",
|
||||
tool_use_id: toolCallId,
|
||||
input: {
|
||||
command: "pwd",
|
||||
},
|
||||
};
|
||||
const toolResult = {
|
||||
type: "mcp_tool_result",
|
||||
tool_use_id: toolCallId,
|
||||
output: {
|
||||
stdout: "/tmp",
|
||||
},
|
||||
};
|
||||
|
||||
const updates = [
|
||||
{ event: toolTimeline(toolCallId, "pending", toolInput), timestamp: timestampStart },
|
||||
{ event: toolTimeline(toolCallId, "completed", toolResult), timestamp: timestampFinish },
|
||||
];
|
||||
|
||||
const state = hydrateStreamState(updates);
|
||||
const toolCallEntry = state.find(
|
||||
(item): item is ToolCallItem =>
|
||||
item.kind === "tool_call" && item.payload.source === "agent"
|
||||
);
|
||||
|
||||
if (!toolCallEntry) {
|
||||
console.log("❌ FAIL: Tool call entry not found");
|
||||
return;
|
||||
}
|
||||
|
||||
const rawPayload = toolCallEntry.payload.data.raw as Record<string, unknown> | undefined;
|
||||
|
||||
const hasInput =
|
||||
!!rawPayload &&
|
||||
typeof rawPayload === "object" &&
|
||||
rawPayload !== null &&
|
||||
"input" in rawPayload;
|
||||
|
||||
const preservedOriginal = rawPayload === toolInput;
|
||||
|
||||
if (hasInput && preservedOriginal) {
|
||||
console.log("✅ PASS: Tool call raw input preserved after completion");
|
||||
} else {
|
||||
console.log("❌ FAIL: Tool call input was lost or replaced");
|
||||
console.log("Raw payload:", rawPayload);
|
||||
}
|
||||
}
|
||||
|
||||
// Test 5: Assistant message chunks should preserve whitespace between words
|
||||
function testAssistantWhitespacePreservation() {
|
||||
console.log('\n=== Test 5: Assistant Message Whitespace Preservation ===');
|
||||
|
||||
const timestamp = new Date('2025-01-01T11:00:00Z');
|
||||
|
||||
const updates = [
|
||||
{ event: assistantTimeline("Hello "), timestamp },
|
||||
{ event: assistantTimeline("world"), timestamp },
|
||||
{ event: assistantTimeline(" !"), timestamp },
|
||||
];
|
||||
|
||||
const state = hydrateStreamState(updates);
|
||||
const assistantMsg = state.find((item) => item.kind === "assistant_message");
|
||||
|
||||
if (assistantMsg && assistantMsg.text === "Hello world !") {
|
||||
console.log("✅ PASS: Assistant message whitespace preserved");
|
||||
} else {
|
||||
console.log("❌ FAIL: Expected whitespace to be preserved");
|
||||
console.log("Assistant message:", assistantMsg);
|
||||
}
|
||||
}
|
||||
|
||||
// Test 6: User messages should persist through hydration and deduplicate with live events
|
||||
function testUserMessageHydration() {
|
||||
console.log('\n=== Test 6: User Message Hydration ===');
|
||||
|
||||
const timestamp = new Date('2025-01-01T11:30:00Z');
|
||||
const messageId = 'msg_user_1';
|
||||
|
||||
const updates = [
|
||||
{ event: userTimeline('Run npm test', messageId), timestamp },
|
||||
{ event: assistantTimeline('On it!'), timestamp },
|
||||
];
|
||||
|
||||
const hydrated = hydrateStreamState(updates);
|
||||
const hydratedUser = hydrated.find((item) => item.kind === 'user_message');
|
||||
|
||||
if (hydratedUser && hydratedUser.text === 'Run npm test' && hydratedUser.id === messageId) {
|
||||
console.log('✅ PASS: Hydrated stream contains persisted user message');
|
||||
} else {
|
||||
console.log('❌ FAIL: Expected user message to survive hydration');
|
||||
console.log('Hydrated state:', JSON.stringify(hydrated, null, 2));
|
||||
}
|
||||
|
||||
const optimisticState: StreamItem[] = [
|
||||
{ kind: 'user_message', id: messageId, text: 'Run npm test', timestamp },
|
||||
];
|
||||
|
||||
const afterServerEvent = reduceStreamUpdate(
|
||||
optimisticState,
|
||||
userTimeline('Run npm test', messageId),
|
||||
timestamp
|
||||
);
|
||||
|
||||
if (afterServerEvent.length === 1 && afterServerEvent[0].kind === 'user_message') {
|
||||
console.log('✅ PASS: Duplicate server event does not create extra user entry');
|
||||
} else {
|
||||
console.log('❌ FAIL: Duplicate server event should not add another user message');
|
||||
console.log('State:', JSON.stringify(afterServerEvent, null, 2));
|
||||
}
|
||||
}
|
||||
|
||||
// Test 7: Permission tool calls should not show in the timeline
|
||||
function testPermissionToolCallFiltering() {
|
||||
console.log('\n=== Test 7: Permission Tool Call Filtering ===');
|
||||
|
||||
const timestamp = new Date('2025-01-01T12:00:00Z');
|
||||
const updates = [
|
||||
{ event: permissionTimeline('permission-1', 'pending'), timestamp },
|
||||
{ event: permissionTimeline('permission-1', 'granted'), timestamp },
|
||||
];
|
||||
|
||||
const state = hydrateStreamState(updates);
|
||||
const permissionEntries = state.filter(
|
||||
(item) => item.kind === 'tool_call' && item.payload.source === 'agent'
|
||||
);
|
||||
|
||||
if (permissionEntries.length === 0) {
|
||||
console.log('✅ PASS: Permission tool calls hidden from timeline');
|
||||
} else {
|
||||
console.log('❌ FAIL: Permission tool calls should be hidden');
|
||||
console.log('State:', JSON.stringify(state, null, 2));
|
||||
}
|
||||
}
|
||||
|
||||
// Run all tests
|
||||
console.log("Testing Idempotent Stream Reduction");
|
||||
console.log("====================================");
|
||||
@@ -154,6 +329,10 @@ console.log("====================================");
|
||||
testIdempotentReduction();
|
||||
testUserMessageDeduplication();
|
||||
testMultipleMessages();
|
||||
testToolCallInputPreservation();
|
||||
testAssistantWhitespacePreservation();
|
||||
testUserMessageHydration();
|
||||
testPermissionToolCallFiltering();
|
||||
|
||||
console.log("\n====================================");
|
||||
console.log("Tests complete");
|
||||
|
||||
Reference in New Issue
Block a user