From 57800a0f178797eaf8e2480ad8e501d20e8ce59e Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Mon, 29 Jun 2026 16:55:45 +0200 Subject: [PATCH 1/8] Restore the "Drop files here" backdrop when dragging files (#1801) Decouples file drag-and-drop into a FileDropZone provider (drag target + backdrop) and a useFileDrop consumer hook, with drag state on a Reanimated shared value so dragging triggers no React re-renders. Fixes the collapsed/invisible backdrop (the drop zone had been moved into the composer with flex:0, collapsing the overlay to zero height) and restores whole-surface coverage across the agent panel, New Workspace screen, draft workspace tab, and workspace-setup dialog. The backdrop and copy cursor only appear when a drop will actually be accepted (a consumer is mounted and not submitting). Refs #520 --- .../src/components/adaptive-modal-sheet.tsx | 20 +- .../app/src/components/file-drop-zone.tsx | 106 ---------- .../app/src/components/file-drop/context.ts | 20 ++ .../file-drop/file-drop-backdrop.tsx | 71 +++++++ .../components/file-drop/file-drop-zone.tsx | 84 ++++++++ .../app/src/components/file-drop/types.ts | 21 ++ .../file-drop/use-drop-listeners.ts} | 185 ++++++++---------- .../src/components/file-drop/use-file-drop.ts | 35 ++++ .../src/components/workspace-setup-dialog.tsx | 5 +- packages/app/src/composer/attachments/drop.ts | 2 +- .../app/src/composer/draft/workspace-tab.tsx | 5 +- packages/app/src/composer/index.tsx | 184 +++++++++-------- packages/app/src/panels/agent-panel.tsx | 5 +- .../app/src/screens/new-workspace-screen.tsx | 5 +- 14 files changed, 423 insertions(+), 325 deletions(-) delete mode 100644 packages/app/src/components/file-drop-zone.tsx create mode 100644 packages/app/src/components/file-drop/context.ts create mode 100644 packages/app/src/components/file-drop/file-drop-backdrop.tsx create mode 100644 packages/app/src/components/file-drop/file-drop-zone.tsx create mode 100644 packages/app/src/components/file-drop/types.ts rename packages/app/src/{hooks/use-file-drop-zone.ts => components/file-drop/use-drop-listeners.ts} (53%) create mode 100644 packages/app/src/components/file-drop/use-file-drop.ts diff --git a/packages/app/src/components/adaptive-modal-sheet.tsx b/packages/app/src/components/adaptive-modal-sheet.tsx index 979d0db29..e564f429d 100644 --- a/packages/app/src/components/adaptive-modal-sheet.tsx +++ b/packages/app/src/components/adaptive-modal-sheet.tsx @@ -15,8 +15,6 @@ import { } from "@gorhom/bottom-sheet"; import Animated from "react-native-reanimated"; import { ArrowLeft, Search, X } from "lucide-react-native"; -import { FileDropZone } from "@/components/file-drop-zone"; -import type { ImageAttachment } from "@/composer/types"; import { IsolatedBottomSheetModal, useIsolatedBottomSheetVisibility, @@ -451,9 +449,6 @@ export interface AdaptiveModalSheetProps { testID?: string; /** Override the max width of the desktop card. */ desktopMaxWidth?: number; - /** When provided, wraps the card content in a FileDropZone. */ - onFilesDropped?: (files: ImageAttachment[]) => void; - onGenericFilesDropped?: (items: import("@/hooks/use-file-drop-zone").DroppedItem[]) => void; scrollable?: boolean; presentation?: "push" | "replace"; } @@ -467,8 +462,6 @@ export function AdaptiveModalSheet({ snapPoints, testID, desktopMaxWidth, - onFilesDropped, - onGenericFilesDropped, scrollable = true, presentation, }: AdaptiveModalSheetProps) { @@ -602,18 +595,7 @@ export function AdaptiveModalSheet({ style={ABSOLUTE_FILL_STYLE} onPress={onClose} /> - - {onFilesDropped ? ( - - {cardInner} - - ) : ( - cardInner - )} - + {cardInner} ); diff --git a/packages/app/src/components/file-drop-zone.tsx b/packages/app/src/components/file-drop-zone.tsx deleted file mode 100644 index 0a0ba4b3f..000000000 --- a/packages/app/src/components/file-drop-zone.tsx +++ /dev/null @@ -1,106 +0,0 @@ -import { View, Text } from "react-native"; -import type { StyleProp, ViewStyle } from "react-native"; -import { StyleSheet, useUnistyles } from "react-native-unistyles"; -import Animated, { useAnimatedStyle, withTiming, useSharedValue } from "react-native-reanimated"; -import { useEffect, useMemo } from "react"; -import { Upload } from "lucide-react-native"; -import { useTranslation } from "react-i18next"; -import { useFileDropZone, type DroppedItem } from "@/hooks/use-file-drop-zone"; -import type { ImageAttachment } from "@/composer/types"; -import { isWeb } from "@/constants/platform"; - -interface FileDropZoneProps { - children: React.ReactNode; - onFilesDropped: (files: ImageAttachment[]) => void; - onGenericFilesDropped?: (items: DroppedItem[]) => void; - disabled?: boolean; - style?: StyleProp; -} - -const IS_WEB = isWeb; - -export function FileDropZone({ - children, - onFilesDropped, - onGenericFilesDropped, - disabled = false, - style, -}: FileDropZoneProps) { - const { t } = useTranslation(); - const { theme } = useUnistyles(); - const { isDragging, containerRef } = useFileDropZone({ - onFilesDropped, - onGenericFilesDropped, - disabled, - }); - - const overlayOpacity = useSharedValue(0); - - useEffect(() => { - overlayOpacity.value = withTiming(isDragging ? 1 : 0, { duration: 150 }); - }, [isDragging, overlayOpacity]); - - const overlayAnimatedStyle = useAnimatedStyle(() => ({ - opacity: overlayOpacity.value, - pointerEvents: overlayOpacity.value > 0 ? "auto" : "none", - })); - - const overlayStyle = useMemo( - () => [styles.overlay, overlayAnimatedStyle], - [overlayAnimatedStyle], - ); - const containerStyle = useMemo(() => [styles.container, style], [style]); - - // On non-web platforms, just render children - if (!IS_WEB) { - return children; - } - - return ( - } - style={containerStyle} - > - {children} - - {/* Drop overlay */} - - {/* Backdrop */} - - {/* Content */} - - - {t("composer.attachments.dropFilesHere")} - - - - ); -} - -const styles = StyleSheet.create((theme) => ({ - container: { - flex: 1, - position: "relative", - }, - overlay: { - ...StyleSheet.absoluteFillObject, - alignItems: "center", - justifyContent: "center", - zIndex: 1000, - }, - backdrop: { - ...StyleSheet.absoluteFillObject, - backgroundColor: theme.colors.surface0, - opacity: 0.7, - }, - overlayContent: { - alignItems: "center", - gap: theme.spacing[2], - }, - overlayText: { - fontSize: theme.fontSize.base, - fontWeight: theme.fontWeight.medium, - color: theme.colors.foreground, - }, -})); diff --git a/packages/app/src/components/file-drop/context.ts b/packages/app/src/components/file-drop/context.ts new file mode 100644 index 000000000..32640f38a --- /dev/null +++ b/packages/app/src/components/file-drop/context.ts @@ -0,0 +1,20 @@ +import { createContext, useContext } from "react"; +import type { SharedValue } from "react-native-reanimated"; +import type { FileDropSink } from "./types"; + +export interface FileDropContextValue { + /** Drag-active flag, driven on the UI thread so toggling it triggers no React render. */ + isDragging: SharedValue; + /** Active sink can't accept right now (e.g. composer submitting): hide backdrop and reject drops. */ + suppressed: SharedValue; + /** Whether a consumer is currently registered — no consumer (e.g. archived agent), no backdrop. */ + hasSink: SharedValue; + /** Register the active sink. Pass a getter so the zone always reads the latest handlers. */ + registerSink: (getSink: () => FileDropSink | null) => () => void; +} + +export const FileDropContext = createContext(null); + +export function useFileDropContext(): FileDropContextValue | null { + return useContext(FileDropContext); +} diff --git a/packages/app/src/components/file-drop/file-drop-backdrop.tsx b/packages/app/src/components/file-drop/file-drop-backdrop.tsx new file mode 100644 index 000000000..b661dbe49 --- /dev/null +++ b/packages/app/src/components/file-drop/file-drop-backdrop.tsx @@ -0,0 +1,71 @@ +import { View, Text, StyleSheet as RNStyleSheet } from "react-native"; +import { StyleSheet, withUnistyles } from "react-native-unistyles"; +import Animated, { useAnimatedStyle, withTiming } from "react-native-reanimated"; +import { Upload } from "lucide-react-native"; +import { useMemo } from "react"; +import { useTranslation } from "react-i18next"; +import type { Theme } from "@/styles/theme"; +import { useFileDropContext } from "./context"; + +const ThemedUpload = withUnistyles(Upload); +const primaryIconColorMapping = (theme: Theme) => ({ color: theme.colors.primary }); + +/** + * Drop overlay rendered by FileDropZone. Reads `isDragging` on the UI thread so the dim + * only ever repaints the backdrop — never the surrounding tree. + */ +export function FileDropBackdrop() { + const { t } = useTranslation(); + const ctx = useFileDropContext(); + const isDragging = ctx?.isDragging; + const suppressed = ctx?.suppressed; + const hasSink = ctx?.hasSink; + + const animatedStyle = useAnimatedStyle(() => { + const active = isDragging?.value && hasSink?.value && !suppressed?.value; + return { opacity: withTiming(active ? 1 : 0, { duration: 150 }) }; + }); + + const overlayStyle = useMemo(() => [positionStyles.overlay, animatedStyle], [animatedStyle]); + + if (!ctx) return null; + + // Animated.View keeps only plain-RN positioning; theme-dependent paint lives on the + // non-animated children (applying themed Unistyles styles to an Animated.View crashes + // on theme change — see docs/unistyles.md). + return ( + + + + + {t("composer.attachments.dropFilesHere")} + + + ); +} + +const positionStyles = RNStyleSheet.create({ + overlay: { + ...RNStyleSheet.absoluteFillObject, + alignItems: "center", + justifyContent: "center", + zIndex: 1000, + }, +}); + +const styles = StyleSheet.create((theme) => ({ + backdrop: { + ...StyleSheet.absoluteFillObject, + backgroundColor: theme.colors.surface0, + opacity: 0.7, + }, + content: { + alignItems: "center", + gap: theme.spacing[2], + }, + text: { + fontSize: theme.fontSize.base, + fontWeight: theme.fontWeight.medium, + color: theme.colors.foreground, + }, +})); diff --git a/packages/app/src/components/file-drop/file-drop-zone.tsx b/packages/app/src/components/file-drop/file-drop-zone.tsx new file mode 100644 index 000000000..6d12ec605 --- /dev/null +++ b/packages/app/src/components/file-drop/file-drop-zone.tsx @@ -0,0 +1,84 @@ +import type { ReactNode, RefObject } from "react"; +import { useCallback, useMemo, useRef } from "react"; +import { View } from "react-native"; +import type { StyleProp, ViewStyle } from "react-native"; +import { StyleSheet } from "react-native-unistyles"; +import { useSharedValue } from "react-native-reanimated"; +import { isWeb } from "@/constants/platform"; +import { FileDropContext, type FileDropContextValue } from "./context"; +import { FileDropBackdrop } from "./file-drop-backdrop"; +import { useDropListeners } from "./use-drop-listeners"; +import type { FileDropSink } from "./types"; + +interface FileDropZoneProps { + children: ReactNode; + /** When true, no drops are accepted and the backdrop stays hidden. */ + disabled?: boolean; + /** Styles the drop area (defaults to filling its parent). The backdrop fills this area. */ + style?: StyleProp; +} + +/** + * Defines a drag-and-drop area and renders its dim backdrop. Files are consumed by any + * descendant calling `useFileDrop` — the drop area, the backdrop, and the consumer are + * decoupled, so a consumer's layout can never collapse the backdrop. + */ +export function FileDropZone({ children, disabled = false, style }: FileDropZoneProps) { + const isDragging = useSharedValue(false); + const suppressed = useSharedValue(false); + const hasSink = useSharedValue(false); + const activeGetSink = useRef<(() => FileDropSink | null) | null>(null); + + const registerSink = useCallback( + (getSink: () => FileDropSink | null) => { + activeGetSink.current = getSink; + hasSink.value = true; + return () => { + if (activeGetSink.current === getSink) { + activeGetSink.current = null; + hasSink.value = false; + } + }; + }, + [hasSink], + ); + + const getSink = useCallback(() => activeGetSink.current?.() ?? null, []); + + const ctx = useMemo( + () => ({ isDragging, suppressed, hasSink, registerSink }), + [isDragging, suppressed, hasSink, registerSink], + ); + + const containerRef = useDropListeners({ isDragging, suppressed, hasSink, getSink, disabled }); + + const targetStyle = useMemo(() => [styles.target, style], [style]); + + // On native there is no web drag-and-drop, so skip the listeners and the backdrop — but still + // render the styled layout View (callers use FileDropZone as their container) and provide + // context so useFileDrop no-ops safely. + if (!isWeb) { + return ( + + {children} + + ); + } + + return ( + + } style={targetStyle}> + {children} + + + + ); +} + +// No default flex: the caller's `style` owns sizing (full-area surfaces pass flex:1; a dialog +// passes a content-sized style). `position` anchors the absolutely-positioned backdrop. +const styles = StyleSheet.create({ + target: { + position: "relative", + }, +}); diff --git a/packages/app/src/components/file-drop/types.ts b/packages/app/src/components/file-drop/types.ts new file mode 100644 index 000000000..11b44773d --- /dev/null +++ b/packages/app/src/components/file-drop/types.ts @@ -0,0 +1,21 @@ +import type { ImageAttachment } from "@/composer/types"; + +export interface DroppedFileItem { + kind: "web-file"; + file: File; +} +export interface DroppedPathItem { + kind: "desktop-path"; + path: string; +} +export type DroppedItem = DroppedFileItem | DroppedPathItem; + +/** + * What a consumer (e.g. a composer) registers to receive files dropped onto the + * surrounding FileDropZone. Raster images arrive already persisted via `onFiles`; + * everything else arrives raw via `onGenericFiles`. + */ +export interface FileDropSink { + onFiles: (images: ImageAttachment[]) => void; + onGenericFiles?: (items: DroppedItem[]) => void; +} diff --git a/packages/app/src/hooks/use-file-drop-zone.ts b/packages/app/src/components/file-drop/use-drop-listeners.ts similarity index 53% rename from packages/app/src/hooks/use-file-drop-zone.ts rename to packages/app/src/components/file-drop/use-drop-listeners.ts index 710faf1f5..b8411b50c 100644 --- a/packages/app/src/hooks/use-file-drop-zone.ts +++ b/packages/app/src/components/file-drop/use-drop-listeners.ts @@ -1,4 +1,6 @@ -import { useState, useRef, useEffect } from "react"; +import { useEffect, useRef } from "react"; +import type { RefObject } from "react"; +import type { SharedValue } from "react-native-reanimated"; import type { ImageAttachment } from "@/composer/types"; import { getDesktopHost } from "@/desktop/host"; import { persistAttachmentFromBlob, persistAttachmentFromFileUri } from "@/attachments/service"; @@ -8,45 +10,13 @@ import { isRasterImagePath, } from "@/attachments/file-types"; import { isWeb } from "@/constants/platform"; - -export interface DroppedFileItem { - kind: "web-file"; - file: File; -} -export interface DroppedPathItem { - kind: "desktop-path"; - path: string; -} -export type DroppedItem = DroppedFileItem | DroppedPathItem; - -interface UseFileDropZoneOptions { - onFilesDropped: (files: ImageAttachment[]) => void; - onGenericFilesDropped?: (items: DroppedItem[]) => void; - disabled?: boolean; -} - -interface UseFileDropZoneReturn { - isDragging: boolean; - containerRef: React.RefObject; -} - -const IS_WEB = isWeb; +import type { DroppedItem, DroppedPathItem, FileDropSink } from "./types"; type DesktopDragDropPayload = - | { - type: "enter"; - paths: string[]; - } - | { - type: "over"; - } - | { - type: "drop"; - paths: string[]; - } - | { - type: "leave"; - }; + | { type: "enter"; paths: string[] } + | { type: "over" } + | { type: "drop"; paths: string[] } + | { type: "leave" }; interface DesktopDragDropEvent { payload: DesktopDragDropPayload; @@ -65,37 +35,43 @@ async function fileToImageAttachment(file: File): Promise { }); } -export function useFileDropZone({ - onFilesDropped, - onGenericFilesDropped, - disabled = false, -}: UseFileDropZoneOptions): UseFileDropZoneReturn { - const [isDragging, setIsDragging] = useState(false); +interface UseDropListenersOptions { + isDragging: SharedValue; + /** Active sink can't accept right now: reject drops without showing acceptance. */ + suppressed: SharedValue; + /** Whether a consumer is mounted: with none, don't advertise or accept drops. */ + hasSink: SharedValue; + /** Stable getter for the currently registered sink. */ + getSink: () => FileDropSink | null; + disabled: boolean; +} + +/** + * Attaches web/desktop drag-and-drop listeners to the returned element ref. Drag state is + * written to a shared value (no React renders); dropped files are routed to the active sink. + */ +export function useDropListeners({ + isDragging, + suppressed, + hasSink, + getSink, + disabled, +}: UseDropListenersOptions): RefObject { const containerRef = useRef(null); - const dragCounterRef = useRef(0); - const onFilesDroppedRef = useRef(onFilesDropped); - const onGenericFilesDroppedRef = useRef(onGenericFilesDropped); + const dragCounter = useRef(0); + const disabledRef = useRef(disabled); + disabledRef.current = disabled; - // Keep callback refs up to date - useEffect(() => { - onFilesDroppedRef.current = onFilesDropped; - }, [onFilesDropped]); - - useEffect(() => { - onGenericFilesDroppedRef.current = onGenericFilesDropped; - }, [onGenericFilesDropped]); - - // Reset drag state when disabled changes + // Clear an in-progress drag when the zone becomes disabled. useEffect(() => { if (disabled) { - setIsDragging(false); - dragCounterRef.current = 0; + isDragging.value = false; + dragCounter.current = 0; } - }, [disabled]); + }, [disabled, isDragging]); - // Set up event listeners on web useEffect(() => { - if (!IS_WEB) return; + if (!isWeb) return; let disposed = false; let cleanup: (() => void) | undefined; @@ -108,13 +84,16 @@ export function useFileDropZone({ didCleanup = true; try { void Promise.resolve(cleanupFn()).catch((error) => { - console.warn("[useFileDropZone] Failed to remove desktop drag-drop listener:", error); + console.warn("[useDropListeners] Failed to remove desktop drag-drop listener:", error); }); } catch (error) { - console.warn("[useFileDropZone] Failed to remove desktop drag-drop listener:", error); + console.warn("[useDropListeners] Failed to remove desktop drag-drop listener:", error); } } + // Desktop drag-drop (Tauri-style) is window-scoped, not element-scoped: with multiple zones + // mounted, every zone would react to the same drop. Dormant today — current Electron does not + // expose onDragDropEvent, so the element-scoped HTML5 DOM path below is what actually runs. async function setupDesktopDragDrop(): Promise { const desktopHost = getDesktopHost(); if (desktopHost === null) { @@ -130,29 +109,32 @@ export function useFileDropZone({ const unlisten = await desktopWindow.onDragDropEvent((event: DesktopDragDropEvent) => { const payload = event.payload; if (payload.type === "leave") { - setIsDragging(false); + isDragging.value = false; return; } if (payload.type === "enter" || payload.type === "over") { - if (!disabled) { - setIsDragging(true); + if (!disabledRef.current) { + isDragging.value = true; } return; } // Drop always ends the current drag operation. - setIsDragging(false); + isDragging.value = false; - if (disabled) return; + if (disabledRef.current || suppressed.value) return; + + const sink = getSink(); + if (!sink) return; const items: DroppedPathItem[] = payload.paths.map((path) => ({ kind: "desktop-path", path, })); - if (onGenericFilesDroppedRef.current && items.length > 0) { - onGenericFilesDroppedRef.current(items); + if (sink.onGenericFiles && items.length > 0) { + sink.onGenericFiles(items); } const imagePaths = payload.paths.filter(isRasterImagePath); @@ -165,11 +147,15 @@ export function useFileDropZone({ if (attachments.length === 0) { return; } - onFilesDroppedRef.current(attachments); + // Use the sink captured at drop time, not a fresh getSink() — routing belongs to the + // composer the user dropped on (matches the web path below). No post-persist busy + // re-check: a mixed drop's own generic upload flips the busy flag, and re-checking + // would discard the image from the same drop. + sink.onFiles(attachments); return; }) .catch((error) => { - console.error("[useFileDropZone] Failed to persist dropped files:", error); + console.error("[useDropListeners] Failed to persist dropped files:", error); }); }); @@ -181,7 +167,7 @@ export function useFileDropZone({ cleanup = unlisten; return true; } catch (error) { - console.warn("[useFileDropZone] Failed to listen for desktop drag-drop:", error); + console.warn("[useDropListeners] Failed to listen for desktop drag-drop:", error); return false; } } @@ -196,11 +182,11 @@ export function useFileDropZone({ e.preventDefault(); e.stopPropagation(); - if (disabled) return; + if (disabledRef.current) return; - dragCounterRef.current++; + dragCounter.current++; if (e.dataTransfer?.types.includes("Files")) { - setIsDragging(true); + isDragging.value = true; } } @@ -208,22 +194,22 @@ export function useFileDropZone({ e.preventDefault(); e.stopPropagation(); - if (disabled) return; - - if (e.dataTransfer) { - e.dataTransfer.dropEffect = "copy"; - } + if (!e.dataTransfer) return; + // Only advertise "copy" when the drop would actually be accepted, so the cursor doesn't + // promise a drop that the handler then discards (suppressed/archived/no consumer mounted). + const canAccept = !disabledRef.current && !suppressed.value && hasSink.value; + e.dataTransfer.dropEffect = canAccept ? "copy" : "none"; } function handleDragLeave(e: DragEvent) { e.preventDefault(); e.stopPropagation(); - if (disabled) return; + if (disabledRef.current) return; - dragCounterRef.current--; - if (dragCounterRef.current === 0) { - setIsDragging(false); + dragCounter.current--; + if (dragCounter.current === 0) { + isDragging.value = false; } } @@ -231,10 +217,13 @@ export function useFileDropZone({ e.preventDefault(); e.stopPropagation(); - setIsDragging(false); - dragCounterRef.current = 0; + isDragging.value = false; + dragCounter.current = 0; - if (disabled) return; + if (disabledRef.current || suppressed.value) return; + + const sink = getSink(); + if (!sink) return; const files = Array.from(e.dataTransfer?.files ?? []); const genericItems: DroppedItem[] = files.map((file) => ({ @@ -242,8 +231,8 @@ export function useFileDropZone({ file, })); - if (onGenericFilesDroppedRef.current && genericItems.length > 0) { - onGenericFilesDroppedRef.current(genericItems); + if (sink.onGenericFiles && genericItems.length > 0) { + sink.onGenericFiles(genericItems); } const imageFiles = files.filter(isRasterImageFile); @@ -252,9 +241,12 @@ export function useFileDropZone({ try { const attachments = await Promise.all(imageFiles.map(fileToImageAttachment)); - onFilesDroppedRef.current(attachments); + // No post-persist busy re-check: a mixed drop's own generic upload flips the busy flag, + // and re-checking would discard the image from the same drop. The guard at drop start + // already rejects drops that begin while busy. + sink.onFiles(attachments); } catch (error) { - console.error("[useFileDropZone] Failed to process dropped files:", error); + console.error("[useDropListeners] Failed to process dropped files:", error); } } @@ -283,10 +275,7 @@ export function useFileDropZone({ disposed = true; runCleanup(); }; - }, [disabled]); + }, [isDragging, suppressed, hasSink, getSink]); - return { - isDragging, - containerRef, - }; + return containerRef; } diff --git a/packages/app/src/components/file-drop/use-file-drop.ts b/packages/app/src/components/file-drop/use-file-drop.ts new file mode 100644 index 000000000..2f8009475 --- /dev/null +++ b/packages/app/src/components/file-drop/use-file-drop.ts @@ -0,0 +1,35 @@ +import { useEffect, useRef } from "react"; +import { useFileDropContext } from "./context"; +import type { FileDropSink } from "./types"; + +interface UseFileDropOptions { + /** When true, the zone hides the backdrop and rejects drops atomically (e.g. while submitting). */ + disabled?: boolean; +} + +/** + * Receive files dropped onto the surrounding FileDropZone. The sink is read through + * a ref, so passing a fresh object every render neither re-registers nor re-renders. + * No-ops when rendered without a FileDropZone ancestor. + */ +export function useFileDrop(sink: FileDropSink, options?: UseFileDropOptions): void { + const ctx = useFileDropContext(); + const sinkRef = useRef(sink); + sinkRef.current = sink; + const disabled = options?.disabled ?? false; + + const registerSink = ctx?.registerSink; + useEffect(() => { + if (!registerSink) return; + return registerSink(() => sinkRef.current); + }, [registerSink]); + + const suppressed = ctx?.suppressed; + useEffect(() => { + if (!suppressed) return; + suppressed.value = disabled; + return () => { + suppressed.value = false; + }; + }, [suppressed, disabled]); +} diff --git a/packages/app/src/components/workspace-setup-dialog.tsx b/packages/app/src/components/workspace-setup-dialog.tsx index 0a607612d..90eef6906 100644 --- a/packages/app/src/components/workspace-setup-dialog.tsx +++ b/packages/app/src/components/workspace-setup-dialog.tsx @@ -4,6 +4,7 @@ import { StyleSheet } from "react-native-unistyles"; import { useTranslation } from "react-i18next"; import { createNameId } from "mnemonic-id"; import { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-modal-sheet"; +import { FileDropZone } from "@/components/file-drop/file-drop-zone"; import { Composer } from "@/composer"; import { DraftAgentModeControl } from "@/composer/agent-controls/mode-control"; import { useToast } from "@/contexts/toast-context"; @@ -420,7 +421,7 @@ export function WorkspaceSetupDialog() { testID="workspace-setup-dialog" desktopMaxWidth={640} > - + - + {errorMessage ? {errorMessage} : null} diff --git a/packages/app/src/composer/attachments/drop.ts b/packages/app/src/composer/attachments/drop.ts index 8114f780b..b5a0f1b6a 100644 --- a/packages/app/src/composer/attachments/drop.ts +++ b/packages/app/src/composer/attachments/drop.ts @@ -4,7 +4,7 @@ import { isRasterImagePath, } from "@/attachments/file-types"; import { readDesktopFileBytes, type PickedFile } from "@/attachments/picked-file"; -import type { DroppedItem } from "@/hooks/use-file-drop-zone"; +import type { DroppedItem } from "@/components/file-drop/types"; interface DroppedAttachmentsRuntime { readDesktopFileBytes(path: string): Promise; diff --git a/packages/app/src/composer/draft/workspace-tab.tsx b/packages/app/src/composer/draft/workspace-tab.tsx index e0067030a..14507a386 100644 --- a/packages/app/src/composer/draft/workspace-tab.tsx +++ b/packages/app/src/composer/draft/workspace-tab.tsx @@ -8,6 +8,7 @@ import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style"; import { useContainerWidthBelow } from "@/hooks/use-container-width"; import invariant from "tiny-invariant"; import { Composer } from "@/composer"; +import { FileDropZone } from "@/components/file-drop/file-drop-zone"; import { DraftAgentModeControl } from "@/composer/agent-controls/mode-control"; import { ComposerImportPill } from "@/composer/draft/import-pill"; import { AgentStreamView } from "@/agent-stream/view"; @@ -644,7 +645,7 @@ export function WorkspaceDraftAgentTab({ ); return ( - + {isSubmitting && draftAgent ? ( @@ -702,7 +703,7 @@ export function WorkspaceDraftAgentTab({ isCompactLayout={isCompactComposerLayout} /> - + ); } diff --git a/packages/app/src/composer/index.tsx b/packages/app/src/composer/index.tsx index 4065610fc..26fa355d9 100644 --- a/packages/app/src/composer/index.tsx +++ b/packages/app/src/composer/index.tsx @@ -43,8 +43,8 @@ import { ContextWindowMeter } from "@/components/context-window-meter"; import { useImageAttachmentPicker } from "@/hooks/use-image-attachment-picker"; import { useSessionStore } from "@/stores/session-store"; import { useFilePicker } from "@/hooks/use-file-picker"; -import { FileDropZone } from "@/components/file-drop-zone"; -import type { DroppedItem } from "@/hooks/use-file-drop-zone"; +import { useFileDrop } from "@/components/file-drop/use-file-drop"; +import type { DroppedItem } from "@/components/file-drop/types"; import { MessageInput, type MessageInputRef, type AttachmentMenuItem } from "./input/input"; import type { ImageAttachment, MessagePayload } from "./types"; import { ICON_SIZE, type Theme } from "@/styles/theme"; @@ -1881,6 +1881,15 @@ export function Composer({ const messageInputContainerRef = useRef(null); const isSubmitBusy = isProcessing || isSubmitLoading || isUploadingFile; + + // Disable drops while submitting/uploading: the submit path clears and restores attachments, + // so a drop in that window would be lost or land on a locked draft. `disabled` hides the + // backdrop and rejects the drop atomically, instead of accepting a drop with no feedback. + useFileDrop( + { onFiles: addImages, onGenericFiles: handleGenericFilesDropped }, + { disabled: isSubmitBusy }, + ); + const messageInputAutoFocus = autoFocus && isDesktopWebBreakpoint; const submitLoadingPressHandler = isAgentRunning ? handleCancelAgent : undefined; const sendErrorNode = useMemo( @@ -1894,97 +1903,90 @@ export function Composer({ return ( - - - - {/* Input area */} - - - {queueList} - {sendErrorNode} + + + {/* Input area */} + + + {queueList} + {sendErrorNode} - - + + - {/* MessageInput handles everything: text, dictation, attachments, all buttons */} - - - + {/* MessageInput handles everything: text, dictation, attachments, all buttons */} + + - {renderComposerFooter(footer, footerInlineContent)} - - + + {renderComposerFooter(footer, footerInlineContent)} + ); } @@ -1994,10 +1996,6 @@ const styles = StyleSheet.create((theme: Theme) => ({ flexDirection: "column", position: "relative", }, - dropZone: { - flex: 0, - width: "100%", - }, borderSeparator: { height: theme.borderWidth[1], backgroundColor: theme.colors.border, diff --git a/packages/app/src/panels/agent-panel.tsx b/packages/app/src/panels/agent-panel.tsx index 960c5e6a1..6cb47587a 100644 --- a/packages/app/src/panels/agent-panel.tsx +++ b/packages/app/src/panels/agent-panel.tsx @@ -12,6 +12,7 @@ import { shallow, useShallow } from "zustand/shallow"; import { useStoreWithEqualityFn } from "zustand/traditional"; import { AgentStreamView, type AgentStreamViewHandle } from "@/agent-stream/view"; import { ArchivedAgentCallout } from "@/components/archived-agent-callout"; +import { FileDropZone } from "@/components/file-drop/file-drop-zone"; import { Composer } from "@/composer"; import { AgentModeControl } from "@/composer/agent-controls/mode-control"; import { RewindComposerRestoreProvider } from "@/components/rewind/composer-restore"; @@ -1177,7 +1178,7 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({ return ( - + {contentContainer} {composerSection} @@ -1189,7 +1190,7 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({ ) : null} - + {isArchivingCurrentAgent ? ( diff --git a/packages/app/src/screens/new-workspace-screen.tsx b/packages/app/src/screens/new-workspace-screen.tsx index 93defa90c..62d710828 100644 --- a/packages/app/src/screens/new-workspace-screen.tsx +++ b/packages/app/src/screens/new-workspace-screen.tsx @@ -11,6 +11,7 @@ import { createNameId } from "mnemonic-id"; import { useQuery } from "@tanstack/react-query"; import { Check, ChevronDown, Folder, GitBranch, GitPullRequest, X } from "lucide-react-native"; import { Composer } from "@/composer"; +import { FileDropZone } from "@/components/file-drop/file-drop-zone"; import { DraftAgentModeControl } from "@/composer/agent-controls/mode-control"; import { splitComposerAttachmentsForSubmit } from "@/composer/attachments/submit"; import { HostStatusDot } from "@/components/host-status-dot"; @@ -1967,7 +1968,7 @@ export function NewWorkspaceScreen({ const screenHeaderLeft = useMemo(() => , []); return ( - + @@ -2003,7 +2004,7 @@ export function NewWorkspaceScreen({ {errorMessage ? {errorMessage} : null} - + ); } From b613bea9f6704e7398a24240f5c890aa54efa2e7 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Mon, 29 Jun 2026 17:23:18 +0200 Subject: [PATCH 2/8] Default client RPC waits to 60 seconds (#1789) * fix(client): wait longer for session responses * fix(client): default RPC waits to sixty seconds * fix(app): preserve detached stream scroll on delayed history Code drift: longer client RPC waits let delayed timeline responses arrive after a user scroll-away, so web stream anchoring must not reattach on transient scroll-top resets. Restore the 15s connect deadline and leave app initialization slack above the default 60s session RPC wait. * fix(client): keep helper waits within caller deadlines Review fix: the 60s default session RPC wait leaked into wait previews and waitForAgentUpsert helper fetches. Bound those helper RPCs to the caller deadline or a short best-effort preview timeout, and allow small scroll ranges to reattach at bottom. * fix(client): respect caller timeout budgets * fix(cli): keep diagnostic probes responsive * Refactor daemon client request options * Preserve daemon client legacy overloads --- docs/architecture.md | 2 + docs/timeline-sync.md | 2 + packages/app/e2e/helpers/archive-tab.ts | 6 +- packages/app/e2e/helpers/seed-client.ts | 6 +- .../src/agent-stream/strategy-web.test.tsx | 207 ++++++ .../app/src/agent-stream/strategy-web.tsx | 9 +- .../src/app/h/[serverId]/agent/[agentId].tsx | 2 +- packages/app/src/contexts/session-context.tsx | 11 + .../hooks/use-agent-commands-query.test.ts | 14 +- .../app/src/hooks/use-agent-commands-query.ts | 12 +- .../hooks/use-agent-initialization.test.ts | 39 +- .../app/src/hooks/use-agent-initialization.ts | 32 +- packages/app/src/panels/agent-panel.tsx | 4 +- .../app/src/utils/agent-initialization.ts | 13 +- packages/cli/src/commands/agent/attach.ts | 8 +- packages/cli/src/commands/agent/delete.ts | 2 +- packages/cli/src/commands/agent/inspect.ts | 2 +- packages/cli/src/commands/agent/logs.ts | 19 +- packages/cli/src/commands/agent/mode.ts | 2 +- packages/cli/src/commands/agent/stop.ts | 2 +- packages/cli/src/commands/agent/update.ts | 4 +- packages/cli/src/commands/agent/wait.ts | 5 +- packages/cli/src/commands/chat/shared.ts | 16 +- packages/cli/src/commands/chat/wait.ts | 20 +- .../cli/src/commands/daemon/local-daemon.ts | 12 +- packages/cli/src/commands/daemon/pair.ts | 6 +- packages/cli/src/commands/daemon/status.ts | 11 +- packages/cli/src/commands/onboard.ts | 40 +- packages/cli/src/commands/permit/allow.ts | 2 +- packages/cli/src/commands/permit/deny.ts | 2 +- packages/cli/src/utils/timeline.ts | 4 + packages/client/src/daemon-client.test.ts | 648 +++++++++++++++++- packages/client/src/daemon-client.ts | 247 +++---- packages/client/src/index.ts | 2 +- .../claude/agent-commands.e2e.test.ts | 4 +- .../codex-mcp-agent-commands.e2e.test.ts | 4 +- .../opencode-agent-commands.e2e.test.ts | 4 +- ...code-agent-custom-command.real.e2e.test.ts | 2 +- .../cli-run-workspace-precedence.e2e.test.ts | 2 +- .../src/server/daemon-client.e2e.test.ts | 25 +- .../daemon-e2e/agent-operations.e2e.test.ts | 4 +- .../claude-autonomous-wake.real.e2e.test.ts | 12 +- .../daemon-e2e/claude-rewind.real.e2e.test.ts | 4 +- .../daemon-e2e/codex-rewind.real.e2e.test.ts | 2 +- ...-runtime-reconcile-claude.real.e2e.test.ts | 2 +- .../opencode-draft-features.real.e2e.test.ts | 3 +- ...ncode-initial-prompt-wait.real.e2e.test.ts | 2 +- .../opencode-invalid-mode.real.e2e.test.ts | 2 +- .../opencode-invalid-model.real.e2e.test.ts | 2 +- .../opencode-rewind.real.e2e.test.ts | 6 +- .../opencode-send-interrupt.real.e2e.test.ts | 2 +- .../daemon-e2e/pi-rewind.real.e2e.test.ts | 2 +- .../src/server/daemon-e2e/pi.real.e2e.test.ts | 2 +- ...d-during-tool-call-claude.real.e2e.test.ts | 2 +- ...nd-during-tool-call-codex.real.e2e.test.ts | 4 +- ...hile-running-stuck-claude.real.e2e.test.ts | 2 +- .../send-while-running-stuck.real.e2e.test.ts | 2 +- .../workspace-same-cwd-isolation.e2e.test.ts | 6 +- 58 files changed, 1234 insertions(+), 281 deletions(-) diff --git a/docs/architecture.md b/docs/architecture.md index 848131416..ab95f6849 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -169,6 +169,8 @@ There is no dedicated welcome message; the server emits a `status` session messa Client liveness checks use the top-level JSON `ping`/`pong` envelope, not a session RPC and not RFC6455 protocol ping. The app runs through browser and React Native WebSocket APIs, which do not expose protocol ping, so this envelope is the portable way to test the direct or relay data path. Session RPC timeouts are operation failures and must not be treated as proof that the socket is dead. +Client session RPC waits default to 60s so slow relay or mobile networks do not turn a live but delayed daemon response into a false operation failure. Keep connect timeouts, app-level grace windows, explicit diagnostic latency probes, liveness ping timers, and genuinely long-running RPCs separate from this default. + New session RPCs use dotted names with `.request` and `.response` suffixes, such as `checkout.github.set_auto_merge.request` and `checkout.github.set_auto_merge.response`. See [rpc-namespacing.md](rpc-namespacing.md) for the convention and migration rules for older flat RPC names. **Notable session message types:** diff --git a/docs/timeline-sync.md b/docs/timeline-sync.md index 2d85dfaf2..f2b9bf6b1 100644 --- a/docs/timeline-sync.md +++ b/docs/timeline-sync.md @@ -28,6 +28,8 @@ Page limits are projected-item targets. A tool call lifecycle is one projected i When the app fetches `direction: "after"` and the daemon responds with `hasNewer: true`, the app must immediately fetch the next page from `endCursor`. The catch-up is complete only when `hasNewer: false`. +Initialization timeouts guard lack of catch-up progress, not the full multi-page sync. A successful page that queues the next `after` page refreshes the watchdog. + The first load of an agent without a local cursor is different: it fetches a bounded latest tail page. Older history remains user-driven by scrolling upward. ## Resume behavior diff --git a/packages/app/e2e/helpers/archive-tab.ts b/packages/app/e2e/helpers/archive-tab.ts index 869bc6561..768c6c6bf 100644 --- a/packages/app/e2e/helpers/archive-tab.ts +++ b/packages/app/e2e/helpers/archive-tab.ts @@ -88,11 +88,13 @@ export async function archiveAgentFromDaemon( export async function fetchAgentArchivedAt( client: { - fetchAgent(agentId: string): Promise<{ agent: { archivedAt?: string | null } } | null>; + fetchAgent(options: { + agentId: string; + }): Promise<{ agent: { archivedAt?: string | null } } | null>; }, agentId: string, ): Promise { - const result = await client.fetchAgent(agentId); + const result = await client.fetchAgent({ agentId }); return result?.agent.archivedAt ?? null; } diff --git a/packages/app/e2e/helpers/seed-client.ts b/packages/app/e2e/helpers/seed-client.ts index 5c3e30bcb..1f2ab0447 100644 --- a/packages/app/e2e/helpers/seed-client.ts +++ b/packages/app/e2e/helpers/seed-client.ts @@ -132,9 +132,9 @@ export interface SeedDaemonClient { timeout?: number, ): Promise<{ status: string; final?: { lastError?: string | null } | null }>; archiveAgent(agentId: string): Promise<{ archivedAt: string }>; - fetchAgent( - agentId: string, - ): Promise<{ agent: { id: string; archivedAt?: string | null } } | null>; + fetchAgent(options: { + agentId: string; + }): Promise<{ agent: { id: string; archivedAt?: string | null } } | null>; getLastServerInfoMessage(): { features?: { projectAdd?: boolean; worktreeRestore?: boolean } | null; } | null; diff --git a/packages/app/src/agent-stream/strategy-web.test.tsx b/packages/app/src/agent-stream/strategy-web.test.tsx index ff8b69788..eb57d3064 100644 --- a/packages/app/src/agent-stream/strategy-web.test.tsx +++ b/packages/app/src/agent-stream/strategy-web.test.tsx @@ -404,4 +404,211 @@ describe("createWebStreamStrategy", () => { expect(scrollTo).not.toHaveBeenCalled(); }); + + it("does not force bottom after upward wheel when cached scroll top is stale", async () => { + const scrollTo = vi.fn(function ( + this: HTMLElement, + options?: ScrollToOptions | number, + y?: number, + ) { + const top = typeof options === "object" ? (options.top ?? 0) : (y ?? 0); + Object.defineProperty(this, "scrollTop", { + configurable: true, + value: top, + }); + }); + HTMLElement.prototype.scrollTo = scrollTo; + + const strategy = createWebStreamStrategy({ isMobileBreakpoint: true }); + const viewportRef = React.createRef(); + const historyMounted = Array.from({ length: 20 }, (_, index) => userMessage(index)); + const routeBottomAnchorRequest = { + agentId: "agent", + reason: "initial-entry" as const, + requestKey: "server:agent:initial-entry", + }; + const renderInput = { + agentId: "agent", + segments: { + historyVirtualized: [], + historyMounted, + liveHead: [], + }, + boundary: { + hasVirtualizedHistory: false, + hasMountedHistory: true, + hasLiveHead: false, + }, + renderers: createRenderers(vi.fn()), + listEmptyComponent: null, + viewportRef, + routeBottomAnchorRequest, + onNearBottomChange: vi.fn(), + onNearHistoryStart: vi.fn(), + isLoadingOlderHistory: false, + hasOlderHistory: false, + scrollEnabled: true, + listStyle: null, + baseListContentContainerStyle: null, + forwardListContentContainerStyle: null, + }; + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + + act(() => { + root?.render( + strategy.render({ + ...renderInput, + isAuthoritativeHistoryReady: false, + }), + ); + }); + + const scrollContainer = container.querySelector('[data-testid="agent-chat-scroll"]'); + if (!(scrollContainer instanceof HTMLElement)) { + throw new Error("Expected agent chat scroll container"); + } + Object.defineProperty(scrollContainer, "clientHeight", { configurable: true, value: 500 }); + Object.defineProperty(scrollContainer, "scrollHeight", { configurable: true, value: 1491 }); + Object.defineProperty(scrollContainer, "scrollTop", { configurable: true, value: 0 }); + await act(async () => { + await new Promise((resolve) => requestAnimationFrame(resolve)); + }); + act(() => { + scrollContainer.dispatchEvent(new Event("scroll")); + }); + scrollTo.mockClear(); + + Object.defineProperty(scrollContainer, "scrollTop", { configurable: true, value: 991 }); + act(() => { + scrollContainer.dispatchEvent(new WheelEvent("wheel", { deltaY: -900 })); + }); + Object.defineProperty(scrollContainer, "scrollTop", { configurable: true, value: 91 }); + act(() => { + scrollContainer.dispatchEvent(new Event("scroll")); + }); + expect(scrollTo).not.toHaveBeenCalled(); + + Object.defineProperty(scrollContainer, "scrollTop", { configurable: true, value: 0 }); + act(() => { + scrollContainer.dispatchEvent(new Event("scroll")); + }); + expect(scrollTo).not.toHaveBeenCalled(); + + Object.defineProperty(scrollContainer, "scrollHeight", { configurable: true, value: 2531 }); + act(() => { + root?.render( + strategy.render({ + ...renderInput, + isAuthoritativeHistoryReady: true, + }), + ); + }); + + expect(scrollTo).not.toHaveBeenCalled(); + }); + + it("reattaches follow-output when a small scroll range returns to bottom", async () => { + const scrollTo = vi.fn(function ( + this: HTMLElement, + options?: ScrollToOptions | number, + y?: number, + ) { + const top = typeof options === "object" ? (options.top ?? 0) : (y ?? 0); + Object.defineProperty(this, "scrollTop", { + configurable: true, + value: top, + }); + }); + HTMLElement.prototype.scrollTo = scrollTo; + + const strategy = createWebStreamStrategy({ isMobileBreakpoint: true }); + const viewportRef = React.createRef(); + const renderInput = { + agentId: "agent", + segments: { + historyVirtualized: [], + historyMounted: [userMessage(1), userMessage(2)], + liveHead: [], + }, + boundary: { + hasVirtualizedHistory: false, + hasMountedHistory: true, + hasLiveHead: false, + }, + renderers: createRenderers(vi.fn()), + listEmptyComponent: null, + viewportRef, + routeBottomAnchorRequest: null, + onNearBottomChange: vi.fn(), + onNearHistoryStart: vi.fn(), + isLoadingOlderHistory: false, + hasOlderHistory: false, + scrollEnabled: true, + listStyle: null, + baseListContentContainerStyle: null, + forwardListContentContainerStyle: null, + }; + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + + act(() => { + root?.render( + strategy.render({ + ...renderInput, + isAuthoritativeHistoryReady: true, + }), + ); + }); + + const scrollContainer = container.querySelector('[data-testid="agent-chat-scroll"]'); + if (!(scrollContainer instanceof HTMLElement)) { + throw new Error("Expected agent chat scroll container"); + } + Object.defineProperty(scrollContainer, "clientHeight", { configurable: true, value: 500 }); + Object.defineProperty(scrollContainer, "scrollHeight", { configurable: true, value: 550 }); + Object.defineProperty(scrollContainer, "scrollTop", { configurable: true, value: 50 }); + await act(async () => { + await new Promise((resolve) => requestAnimationFrame(resolve)); + }); + act(() => { + scrollContainer.dispatchEvent(new Event("scroll")); + }); + scrollTo.mockClear(); + + act(() => { + scrollContainer.dispatchEvent(new WheelEvent("wheel", { deltaY: -30 })); + }); + Object.defineProperty(scrollContainer, "scrollTop", { configurable: true, value: 20 }); + act(() => { + scrollContainer.dispatchEvent(new Event("scroll")); + }); + expect(scrollTo).not.toHaveBeenCalled(); + + Object.defineProperty(scrollContainer, "scrollTop", { configurable: true, value: 50 }); + act(() => { + scrollContainer.dispatchEvent(new Event("scroll")); + }); + scrollTo.mockClear(); + + act(() => { + root?.render( + strategy.render({ + ...renderInput, + segments: { + ...renderInput.segments, + liveHead: [userMessage(3)], + }, + isAuthoritativeHistoryReady: true, + }), + ); + }); + await act(async () => { + await new Promise((resolve) => requestAnimationFrame(resolve)); + }); + + expect(scrollTo).toHaveBeenCalled(); + }); }); diff --git a/packages/app/src/agent-stream/strategy-web.tsx b/packages/app/src/agent-stream/strategy-web.tsx index a5d352659..15b552039 100644 --- a/packages/app/src/agent-stream/strategy-web.tsx +++ b/packages/app/src/agent-stream/strategy-web.tsx @@ -117,11 +117,12 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool contentRef.current = node; }, []); const [followOutput, setFollowOutputr] = useState(true); + const followOutputRef = useRef(followOutput); const setFollowOutput = (value: boolean) => { + followOutputRef.current = value; setFollowOutputr(value); return value; }; - const followOutputRef = useRef(followOutput); const lastKnownScrollTopRef = useRef(0); const pendingUserScrollUpIntentRef = useRef(false); const isPointerScrollActiveRef = useRef(false); @@ -277,12 +278,14 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool const currentScrollTop = scrollContainer.scrollTop; const isAtBottom = isScrollContainerAtBottom(scrollContainer); const scrolledUp = currentScrollTop < lastKnownScrollTopRef.current - USER_SCROLL_DELTA_EPSILON; + const scrolledDown = + currentScrollTop > lastKnownScrollTopRef.current + USER_SCROLL_DELTA_EPSILON; - if (!followOutputRef.current && isAtBottom) { + if (!followOutputRef.current && isAtBottom && scrolledDown) { setFollowOutput(true); pendingUserScrollUpIntentRef.current = false; } else if (followOutputRef.current && pendingUserScrollUpIntentRef.current) { - if (scrolledUp) { + if (scrolledUp || !isAtBottom) { cancelPendingStickToBottom(); setFollowOutput(false); } diff --git a/packages/app/src/app/h/[serverId]/agent/[agentId].tsx b/packages/app/src/app/h/[serverId]/agent/[agentId].tsx index 9763d7168..180e588ef 100644 --- a/packages/app/src/app/h/[serverId]/agent/[agentId].tsx +++ b/packages/app/src/app/h/[serverId]/agent/[agentId].tsx @@ -84,7 +84,7 @@ function HostAgentReadyRouteContent() { let cancelled = false; void client - .fetchAgent(agentId) + .fetchAgent({ agentId }) .then((result) => { if (cancelled || redirectedRef.current) { return; diff --git a/packages/app/src/contexts/session-context.tsx b/packages/app/src/contexts/session-context.tsx index b196b4afe..6f5980f74 100644 --- a/packages/app/src/contexts/session-context.tsx +++ b/packages/app/src/contexts/session-context.tsx @@ -6,6 +6,7 @@ import { useTranslation } from "react-i18next"; import { useClientActivity } from "@/hooks/use-client-activity"; import { usePushTokenRegistration } from "@/hooks/use-push-token-registration"; import { clearArchiveAgentPending } from "@/hooks/use-archive-agent"; +import { refreshAgentInitializationTimeout } from "@/hooks/use-agent-initialization"; import { prefetchProvidersSnapshot } from "@/hooks/use-providers-snapshot"; import { generateMessageId, type StreamItem } from "@/types/stream"; import { @@ -1210,6 +1211,16 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider error: payload.error, }); if (followUp?.direction === "after") { + refreshAgentInitializationTimeout({ + key: initKey, + agentId, + setAgentInitializing: (id, initializing) => { + if (initializing) { + return; + } + clearAgentInitializingFlag(setInitializingAgents, serverId, id); + }, + }); requestCanonicalCatchUp(agentId, { epoch: followUp.cursor.epoch, endSeq: followUp.cursor.seq, diff --git a/packages/app/src/hooks/use-agent-commands-query.test.ts b/packages/app/src/hooks/use-agent-commands-query.test.ts index dfff07095..87f5919da 100644 --- a/packages/app/src/hooks/use-agent-commands-query.test.ts +++ b/packages/app/src/hooks/use-agent-commands-query.test.ts @@ -1,12 +1,11 @@ import { describe, expect, it } from "vitest"; -import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; import { type AgentCommandsClient, type DraftCommandConfig, fetchAgentCommands, } from "./use-agent-commands-query"; -type ListCommands = DaemonClient["listCommands"]; +type ListCommands = AgentCommandsClient["listCommands"]; type ListCommandsResult = Awaited>; interface ListCommandsCall { @@ -22,15 +21,8 @@ function createClient(response: ListCommandsResult): FakeAgentCommandsClient { const calls: ListCommandsCall[] = []; return { calls, - listCommands: (async ( - agentId: string, - requestIdOrOptions?: string | { draftConfig?: DraftCommandConfig }, - ) => { - const options = - typeof requestIdOrOptions === "object" && requestIdOrOptions !== null - ? requestIdOrOptions - : undefined; - calls.push({ agentId, draftConfig: options?.draftConfig }); + listCommands: (async (options: Parameters[0]) => { + calls.push({ agentId: options.agentId, draftConfig: options.draftConfig }); return response; }) as ListCommands, }; diff --git a/packages/app/src/hooks/use-agent-commands-query.ts b/packages/app/src/hooks/use-agent-commands-query.ts index 3df32043d..22afaed3e 100644 --- a/packages/app/src/hooks/use-agent-commands-query.ts +++ b/packages/app/src/hooks/use-agent-commands-query.ts @@ -16,14 +16,22 @@ export interface AgentSlashCommand { export type DraftCommandConfig = AgentCommandsDraftConfig; -export type AgentCommandsClient = Pick; +interface ListAgentCommandsOptions { + agentId: string; + draftConfig?: DraftCommandConfig; +} + +export interface AgentCommandsClient { + listCommands(options: ListAgentCommandsOptions): ReturnType; +} export async function fetchAgentCommands(input: { client: AgentCommandsClient; agentId: string; draftConfig?: DraftCommandConfig; }): Promise { - const response = await input.client.listCommands(input.agentId, { + const response = await input.client.listCommands({ + agentId: input.agentId, draftConfig: input.draftConfig, }); return response.commands as AgentSlashCommand[]; diff --git a/packages/app/src/hooks/use-agent-initialization.test.ts b/packages/app/src/hooks/use-agent-initialization.test.ts index 850d5ce00..2ef638caf 100644 --- a/packages/app/src/hooks/use-agent-initialization.test.ts +++ b/packages/app/src/hooks/use-agent-initialization.test.ts @@ -5,6 +5,7 @@ import { getInitDeferred, getInitKey, resolveInitDeferred } from "@/utils/agent- import { createSetAgentInitializing, ensureAgentIsInitialized, + refreshAgentInitializationTimeout, refreshAgent, } from "./use-agent-initialization"; @@ -80,7 +81,7 @@ describe("ensureAgentIsInitialized", () => { expect(getInitDeferred(getInitKey(serverId, agentId))?.requestDirection).toBe("tail"); }); - it("times out initialization after 30 seconds", async () => { + it("times out initialization after 65 seconds", async () => { vi.useFakeTimers(); const client = makeClient(); useSessionStore.getState().initializeSession(serverId, client as never); @@ -92,18 +93,50 @@ describe("ensureAgentIsInitialized", () => { setAgentInitializing: bindSetAgentInitializing(), }); - vi.advanceTimersByTime(29_999); + vi.advanceTimersByTime(64_999); expect(getInitDeferred(getInitKey(serverId, agentId))).toBeDefined(); vi.advanceTimersByTime(1); - await expect(promise).rejects.toThrow("History sync timed out after 30s"); + await expect(promise).rejects.toThrow("History sync timed out after 65s"); expect(getInitDeferred(getInitKey(serverId, agentId))).toBeUndefined(); expect(useSessionStore.getState().sessions[serverId]?.initializingAgents.get(agentId)).toBe( false, ); vi.useRealTimers(); }); + + it("refreshes the initialization timeout after paged catch-up progress", async () => { + vi.useFakeTimers(); + const client = makeClient(); + useSessionStore.getState().initializeSession(serverId, client as never); + const setAgentInitializing = bindSetAgentInitializing(); + const key = getInitKey(serverId, agentId); + + const promise = ensureAgentIsInitialized({ + serverId, + agentId, + client: client as never, + setAgentInitializing, + }); + + vi.advanceTimersByTime(64_999); + refreshAgentInitializationTimeout({ key, agentId, setAgentInitializing }); + + vi.advanceTimersByTime(1); + expect(getInitDeferred(key)).toBeDefined(); + + const rejection = expect(promise).rejects.toThrow("History sync timed out after 65s"); + + vi.advanceTimersByTime(64_998); + expect(getInitDeferred(key)).toBeDefined(); + + vi.advanceTimersByTime(1); + + await rejection; + expect(getInitDeferred(key)).toBeUndefined(); + vi.useRealTimers(); + }); }); describe("refreshAgent", () => { diff --git a/packages/app/src/hooks/use-agent-initialization.ts b/packages/app/src/hooks/use-agent-initialization.ts index f37e4622b..b9a76bc99 100644 --- a/packages/app/src/hooks/use-agent-initialization.ts +++ b/packages/app/src/hooks/use-agent-initialization.ts @@ -3,19 +3,36 @@ import { useTranslation } from "react-i18next"; import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; import { useSessionStore } from "@/stores/session-store"; import { - attachInitTimeout, createInitDeferred, getInitDeferred, getInitKey, + INIT_TIMEOUT_MS, rejectInitDeferred, + refreshInitTimeout, } from "@/utils/agent-initialization"; import { planInitialAgentTimelineSync, planTimelineTailFetch } from "@/timeline/timeline-sync-plan"; import { i18n } from "@/i18n/i18next"; -export const INIT_TIMEOUT_MS = 30_000; - export type SetAgentInitializing = (agentId: string, initializing: boolean) => void; +export function createHistorySyncTimeoutError(): Error { + return new Error(`History sync timed out after ${Math.round(INIT_TIMEOUT_MS / 1000)}s`); +} + +export function refreshAgentInitializationTimeout(input: { + key: string; + agentId: string; + setAgentInitializing: SetAgentInitializing; +}): void { + refreshInitTimeout({ + key: input.key, + onTimeout: () => { + input.setAgentInitializing(input.agentId, false); + rejectInitDeferred(input.key, createHistorySyncTimeoutError()); + }, + }); +} + export interface EnsureAgentIsInitializedInput { serverId: string; agentId: string; @@ -38,14 +55,7 @@ export function ensureAgentIsInitialized(input: EnsureAgentIsInitializedInput): const timelineRequest = planInitialAgentTimelineSync({ cursor, hasAuthoritativeHistory }); const deferred = createInitDeferred(key, timelineRequest.direction); - const timeoutId = setTimeout(() => { - setAgentInitializing(agentId, false); - rejectInitDeferred( - key, - new Error(`History sync timed out after ${Math.round(INIT_TIMEOUT_MS / 1000)}s`), - ); - }, INIT_TIMEOUT_MS); - attachInitTimeout(key, timeoutId); + refreshAgentInitializationTimeout({ key, agentId, setAgentInitializing }); setAgentInitializing(agentId, true); diff --git a/packages/app/src/panels/agent-panel.tsx b/packages/app/src/panels/agent-panel.tsx index 6cb47587a..b34b7f57a 100644 --- a/packages/app/src/panels/agent-panel.tsx +++ b/packages/app/src/panels/agent-panel.tsx @@ -588,7 +588,7 @@ function AgentPanelBody({ const attemptToken = ++lookupAttemptTokenRef.current; client - .fetchAgent(agentId) + .fetchAgent({ agentId }) .then((result) => { if (attemptToken !== lookupAttemptTokenRef.current) { return; @@ -985,7 +985,7 @@ function ChatAgentContent({ const currentAgent = currentSession?.agents.get(agentId) ?? currentSession?.agentDetails.get(agentId); if (!currentAgent) { - const result = await client.fetchAgent(agentId); + const result = await client.fetchAgent({ agentId }); if (attemptToken !== initAttemptTokenRef.current) { return; } diff --git a/packages/app/src/utils/agent-initialization.ts b/packages/app/src/utils/agent-initialization.ts index 9b73f7373..0d0da8e4d 100644 --- a/packages/app/src/utils/agent-initialization.ts +++ b/packages/app/src/utils/agent-initialization.ts @@ -7,6 +7,7 @@ export interface DeferredInit { } const initPromises = new Map(); +export const INIT_TIMEOUT_MS = 65_000; export function getInitKey(serverId: string, agentId: string): string { return `${serverId}:${agentId}`; @@ -36,12 +37,20 @@ export function createInitDeferred(key: string, requestDirection: "tail" | "afte return deferred; } -export function attachInitTimeout(key: string, timeoutId: ReturnType): void { - const deferred = initPromises.get(key); +export function refreshInitTimeout(input: { + key: string; + onTimeout: () => void; + timeoutMs?: number; +}): void { + const timeoutId = setTimeout(input.onTimeout, input.timeoutMs ?? INIT_TIMEOUT_MS); + const deferred = initPromises.get(input.key); if (!deferred) { clearTimeout(timeoutId); return; } + if (deferred.timeoutId) { + clearTimeout(deferred.timeoutId); + } deferred.timeoutId = timeoutId; } diff --git a/packages/cli/src/commands/agent/attach.ts b/packages/cli/src/commands/agent/attach.ts index 6c726eef5..fb58f71fa 100644 --- a/packages/cli/src/commands/agent/attach.ts +++ b/packages/cli/src/commands/agent/attach.ts @@ -6,7 +6,10 @@ export function addAttachOptions(cmd: Command): Command { .argument("", "Agent ID (or prefix)"); } import { connectToDaemon, getDaemonHost } from "../../utils/client.js"; -import { fetchProjectedTimelineItems } from "../../utils/timeline.js"; +import { + fetchProjectedTimelineItems, + LIVE_HISTORY_FETCH_TIMEOUT_MS, +} from "../../utils/timeline.js"; import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; import type { AgentTimelineItem } from "@getpaseo/protocol/agent-types"; import type { AgentStreamEventPayload, AgentStreamMessage } from "@getpaseo/protocol/messages"; @@ -121,7 +124,7 @@ export async function runAttachCommand( } try { - const fetchResult = await client.fetchAgent(id); + const fetchResult = await client.fetchAgent({ agentId: id }); if (!fetchResult) { console.error(`Error: No agent found matching: ${id}`); console.error("Use `paseo ls` to list available agents"); @@ -139,6 +142,7 @@ export async function runAttachCommand( const timelineItems = await fetchProjectedTimelineItems({ client, agentId: resolvedId, + timeoutMs: LIVE_HISTORY_FETCH_TIMEOUT_MS, }); for (const item of timelineItems) { printTimelineItem(item); diff --git a/packages/cli/src/commands/agent/delete.ts b/packages/cli/src/commands/agent/delete.ts index 159ea941e..dafb26e4c 100644 --- a/packages/cli/src/commands/agent/delete.ts +++ b/packages/cli/src/commands/agent/delete.ts @@ -76,7 +76,7 @@ export async function runDeleteCommand( return isSameOrDescendantPath(options.cwd!, a.cwd); }); } else if (id) { - const fetchResult = await client.fetchAgent(id); + const fetchResult = await client.fetchAgent({ agentId: id }); if (!fetchResult) { const error: CommandError = { code: "AGENT_NOT_FOUND", diff --git a/packages/cli/src/commands/agent/inspect.ts b/packages/cli/src/commands/agent/inspect.ts index f1a0c919f..93f1da3d4 100644 --- a/packages/cli/src/commands/agent/inspect.ts +++ b/packages/cli/src/commands/agent/inspect.ts @@ -243,7 +243,7 @@ export async function runInspectCommand( } try { - const fetchResult = await client.fetchAgent(agentIdArg); + const fetchResult = await client.fetchAgent({ agentId: agentIdArg }); if (!fetchResult) { const error: CommandError = { code: "AGENT_NOT_FOUND", diff --git a/packages/cli/src/commands/agent/logs.ts b/packages/cli/src/commands/agent/logs.ts index f2b414a42..14355aee8 100644 --- a/packages/cli/src/commands/agent/logs.ts +++ b/packages/cli/src/commands/agent/logs.ts @@ -1,7 +1,10 @@ import { Command } from "commander"; import { connectToDaemon, getDaemonHost } from "../../utils/client.js"; import type { CommandOptions } from "../../output/index.js"; -import { fetchProjectedTimelineItems } from "../../utils/timeline.js"; +import { + fetchProjectedTimelineItems, + LIVE_HISTORY_FETCH_TIMEOUT_MS, +} from "../../utils/timeline.js"; import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; import type { AgentTimelineItem } from "@getpaseo/protocol/agent-types"; import type { AgentStreamMessage } from "@getpaseo/protocol/messages"; @@ -32,8 +35,9 @@ export const NO_ACTIVITY_MESSAGE = "No activity to display."; export async function fetchAgentTimelineItems( client: DaemonClient, agentId: string, + options?: { timeoutMs?: number }, ): Promise { - return fetchProjectedTimelineItems({ client, agentId }); + return fetchProjectedTimelineItems({ client, agentId, timeoutMs: options?.timeoutMs }); } export function formatAgentActivityTranscript( @@ -107,7 +111,7 @@ export async function runLogsCommand( } try { - const fetchResult = await client.fetchAgent(id); + const fetchResult = await client.fetchAgent({ agentId: id }); if (!fetchResult) { console.error(`Error: No agent found matching: ${id}`); console.error("Use `paseo ls` to list available agents"); @@ -173,7 +177,14 @@ async function runFollowMode( const tailCount = parseTailCount(options.tail) ?? DEFAULT_FOLLOW_TAIL; // First, get existing timeline. - let existingItems = await fetchAgentTimelineItems(client, agentId); + let existingItems: AgentTimelineItem[] = []; + try { + existingItems = await fetchAgentTimelineItems(client, agentId, { + timeoutMs: LIVE_HISTORY_FETCH_TIMEOUT_MS, + }); + } catch (error) { + console.warn("Warning: failed to fetch existing timeline", error); + } // Apply filter to existing items if (options.filter) { diff --git a/packages/cli/src/commands/agent/mode.ts b/packages/cli/src/commands/agent/mode.ts index 1cd25904c..46229b580 100644 --- a/packages/cli/src/commands/agent/mode.ts +++ b/packages/cli/src/commands/agent/mode.ts @@ -65,7 +65,7 @@ export async function runModeCommand( let client: Awaited> | undefined; try { client = await connectToDaemon({ host: options.host }); - const fetchResult = await client.fetchAgent(id); + const fetchResult = await client.fetchAgent({ agentId: id }); if (!fetchResult) { const error: CommandError = { code: "AGENT_NOT_FOUND", diff --git a/packages/cli/src/commands/agent/stop.ts b/packages/cli/src/commands/agent/stop.ts index 23bcda19d..42d269fcf 100644 --- a/packages/cli/src/commands/agent/stop.ts +++ b/packages/cli/src/commands/agent/stop.ts @@ -83,7 +83,7 @@ export async function runStopCommand( }); } else if (id) { // Stop specific agent - const fetchResult = await client.fetchAgent(id); + const fetchResult = await client.fetchAgent({ agentId: id }); if (!fetchResult) { const error: CommandError = { code: "AGENT_NOT_FOUND", diff --git a/packages/cli/src/commands/agent/update.ts b/packages/cli/src/commands/agent/update.ts index b531fadf6..ee868afff 100644 --- a/packages/cli/src/commands/agent/update.ts +++ b/packages/cli/src/commands/agent/update.ts @@ -132,7 +132,7 @@ export async function runUpdateCommand( } try { - const fetchResult = await client.fetchAgent(agentIdArg); + const fetchResult = await client.fetchAgent({ agentId: agentIdArg }); if (!fetchResult) { const error: CommandError = { code: "AGENT_NOT_FOUND", @@ -148,7 +148,7 @@ export async function runUpdateCommand( ...(Object.keys(labels).length > 0 ? { labels } : {}), }); - const updatedResult = await client.fetchAgent(agentId); + const updatedResult = await client.fetchAgent({ agentId }); if (!updatedResult) { throw new Error(`Agent not found after update: ${agentId}`); } diff --git a/packages/cli/src/commands/agent/wait.ts b/packages/cli/src/commands/agent/wait.ts index 9db46dc19..6c800746c 100644 --- a/packages/cli/src/commands/agent/wait.ts +++ b/packages/cli/src/commands/agent/wait.ts @@ -32,6 +32,7 @@ export interface AgentWaitOptions extends CommandOptions { } const WAIT_ACTIVITY_PREVIEW_COUNT = 5; +const WAIT_ACTIVITY_PREVIEW_TIMEOUT_MS = 2_000; function appendRecentActivity(message: string, transcript: string | null): string { if (!transcript || transcript.trim().length === 0) { @@ -46,7 +47,9 @@ async function getRecentActivityTranscript( agentId: string, ): Promise { try { - const timelineItems = await fetchAgentTimelineItems(client, agentId); + const timelineItems = await fetchAgentTimelineItems(client, agentId, { + timeoutMs: WAIT_ACTIVITY_PREVIEW_TIMEOUT_MS, + }); return formatAgentActivityTranscript(timelineItems, WAIT_ACTIVITY_PREVIEW_COUNT); } catch { return null; diff --git a/packages/cli/src/commands/chat/shared.ts b/packages/cli/src/commands/chat/shared.ts index 3254ff2b7..52ea27096 100644 --- a/packages/cli/src/commands/chat/shared.ts +++ b/packages/cli/src/commands/chat/shared.ts @@ -26,6 +26,7 @@ export async function connectChatClient(host?: string) { export async function attachAgentNamesToMessages( client: Awaited>, messages: ChatMessageRow[], + options: { timeout?: number; bestEffort?: boolean } = {}, ): Promise { const agentIds = new Set(); for (const message of messages) { @@ -39,9 +40,18 @@ export async function attachAgentNamesToMessages( return messages; } - const payload = await client.fetchAgents({ - filter: { includeArchived: true }, - }); + let payload: Awaited>; + try { + payload = await client.fetchAgents({ + filter: { includeArchived: true }, + ...(typeof options.timeout === "number" ? { timeout: options.timeout } : {}), + }); + } catch (error) { + if (options.bestEffort) { + return messages; + } + throw error; + } const agentNames = new Map(); for (const entry of payload.entries) { const title = entry.agent.title?.trim(); diff --git a/packages/cli/src/commands/chat/wait.ts b/packages/cli/src/commands/chat/wait.ts index 3c8d38ee6..3db53c897 100644 --- a/packages/cli/src/commands/chat/wait.ts +++ b/packages/cli/src/commands/chat/wait.ts @@ -13,26 +13,44 @@ export interface ChatWaitOptions extends ChatCommandOptions { timeout?: string; } +const CHAT_WAIT_PREFLIGHT_TIMEOUT_MS = 2000; + export async function runWaitCommand( room: string, options: ChatWaitOptions, _command: Command, ): Promise> { + const timeoutMs = parseTimeoutMs(options.timeout); const { client } = await connectChatClient(options.host); + const deadline = typeof timeoutMs === "number" ? Date.now() + timeoutMs : null; + const hasExplicitTimeout = deadline !== null; + const remainingTimeoutMs = () => + deadline === null ? undefined : Math.max(1, deadline - Date.now()); try { const latest = await client.readChatMessages({ room, limit: 1, + ...(hasExplicitTimeout + ? { + timeout: Math.min(remainingTimeoutMs() ?? 1, CHAT_WAIT_PREFLIGHT_TIMEOUT_MS), + } + : {}), }); const afterMessageId = latest.messages[0]?.id; const payload = await client.waitForChatMessages({ room, afterMessageId, - timeoutMs: parseTimeoutMs(options.timeout), + timeoutMs: remainingTimeoutMs() ?? timeoutMs, }); const messages = await attachAgentNamesToMessages( client, payload.messages.map(toChatMessageRow), + hasExplicitTimeout + ? { + timeout: remainingTimeoutMs(), + bestEffort: true, + } + : {}, ); return { type: "list", diff --git a/packages/cli/src/commands/daemon/local-daemon.ts b/packages/cli/src/commands/daemon/local-daemon.ts index f5c17b4ae..7423c275c 100644 --- a/packages/cli/src/commands/daemon/local-daemon.ts +++ b/packages/cli/src/commands/daemon/local-daemon.ts @@ -667,7 +667,9 @@ async function requestLifecycleShutdown( }; } - const client = await tryConnectToDaemon({ host, timeout: Math.min(timeoutMs, 5000) }); + const deadline = Date.now() + timeoutMs; + const remainingTimeoutMs = () => Math.max(1, deadline - Date.now()); + const client = await tryConnectToDaemon({ host, timeout: Math.min(remainingTimeoutMs(), 5000) }); if (!client) { return { requested: false, @@ -676,7 +678,7 @@ async function requestLifecycleShutdown( } try { - await client.shutdownServer(); + await client.shutdownServer({ timeout: Math.min(remainingTimeoutMs(), 5000) }); return { requested: true }; } catch (error) { return { @@ -696,8 +698,10 @@ export async function stopLocalDaemon( const timeoutMs = options.timeoutMs ?? DEFAULT_STOP_TIMEOUT_MS; const killTimeoutMs = options.killTimeoutMs ?? DEFAULT_KILL_TIMEOUT_MS; const state = resolveLocalDaemonState({ home: options.home }); + const deadline = Date.now() + timeoutMs; + const remainingTimeoutMs = () => Math.max(1, deadline - Date.now()); - const shutdownAttempt = await requestLifecycleShutdown(state, timeoutMs); + const shutdownAttempt = await requestLifecycleShutdown(state, remainingTimeoutMs()); const lifecycleRequested = shutdownAttempt.requested; if (!state.pidInfo || (!state.running && !lifecycleRequested)) { @@ -720,7 +724,7 @@ export async function stopLocalDaemon( const { stopped, forced } = await waitForStopAfterRequest({ state, pid, - timeoutMs, + timeoutMs: remainingTimeoutMs(), killTimeoutMs, force: options.force, }); diff --git a/packages/cli/src/commands/daemon/pair.ts b/packages/cli/src/commands/daemon/pair.ts index cd238fce7..a30f0e2cc 100644 --- a/packages/cli/src/commands/daemon/pair.ts +++ b/packages/cli/src/commands/daemon/pair.ts @@ -10,6 +10,8 @@ interface PairOptions { json?: boolean; } +const PAIRING_DAEMON_RPC_TIMEOUT_MS = 1500; + export function pairCommand(): Command { return addJsonOption(new Command("pair").description("Print the daemon pairing QR code and link")) .option("--home ", "Paseo home directory (default: ~/.paseo)") @@ -35,7 +37,9 @@ export async function runPairCommand(options: PairOptions): Promise { client.getLastServerInfoMessage()?.features?.daemonStatusRpc === true; if (supportsDaemonStatusRpc) { try { - const offer = await client.getDaemonPairingOffer(); + const offer = await client.getDaemonPairingOffer({ + timeout: PAIRING_DAEMON_RPC_TIMEOUT_MS, + }); await client.close().catch(() => {}); outputPairingResult( { relayEnabled: offer.relayEnabled, url: offer.url, qr: offer.qr ?? null }, diff --git a/packages/cli/src/commands/daemon/status.ts b/packages/cli/src/commands/daemon/status.ts index baea163d5..075c6f005 100644 --- a/packages/cli/src/commands/daemon/status.ts +++ b/packages/cli/src/commands/daemon/status.ts @@ -6,6 +6,8 @@ import type { CommandOptions, ListResult, OutputSchema } from "../../output/inde import { resolveLocalDaemonState, resolveTcpHostFromListen } from "./local-daemon.js"; import { resolveNodePathFromPid } from "./runtime-toolchain.js"; +const DAEMON_STATUS_PROBE_TIMEOUT_MS = 1500; + interface ProviderBinaryStatus { label: string; path: string | null; @@ -276,7 +278,10 @@ async function probeDaemonOverWebsocket(args: { const supportsDaemonStatusRpc = client.getLastServerInfoMessage()?.features?.daemonStatusRpc === true; try { - const agentsPayload = await client.fetchAgents({ filter: { includeArchived: true } }); + const agentsPayload = await client.fetchAgents({ + filter: { includeArchived: true }, + timeout: DAEMON_STATUS_PROBE_TIMEOUT_MS, + }); const agents = agentsPayload.entries.map((entry) => entry.agent); const runningAgents = agents.filter((a) => a.status === "running").length; const idleAgents = agents.filter((a) => a.status === "idle").length; @@ -284,7 +289,9 @@ async function probeDaemonOverWebsocket(args: { let daemonProviders: ProviderBinaryStatus[] | undefined; if (supportsDaemonStatusRpc) { try { - const statusPayload = await client.getDaemonStatus(); + const statusPayload = await client.getDaemonStatus({ + timeout: DAEMON_STATUS_PROBE_TIMEOUT_MS, + }); const labelMap = new Map(PROVIDER_BINARIES.map((p) => [p.binary, p.label])); daemonProviders = statusPayload.providers.map((p) => ({ label: labelMap.get(p.provider) ?? p.provider, diff --git a/packages/cli/src/commands/onboard.ts b/packages/cli/src/commands/onboard.ts index b581509ec..2600bdb29 100644 --- a/packages/cli/src/commands/onboard.ts +++ b/packages/cli/src/commands/onboard.ts @@ -40,6 +40,7 @@ type OnboardPersistedConfig = PersistedConfig & { }; const DEFAULT_READY_TIMEOUT_MS = 10 * 60 * 1000; +const READY_PROBE_TIMEOUT_MS = 1200; class OnboardCancelledError extends Error {} @@ -201,15 +202,22 @@ function renderProgressLine(progress: DownloadProgress): string { type ProbeResult = { kind: "ready"; listen: string; host: string | null } | { kind: "pending" }; -async function probeDaemonReady(home: string): Promise { +async function probeDaemonReady(home: string, timeoutMs: number): Promise { const state = resolveLocalDaemonState({ home }); const host = resolveTcpHostFromListen(state.listen); + const deadline = Date.now() + timeoutMs; + const remainingTimeoutMs = () => Math.max(1, deadline - Date.now()); if (state.running && host) { - const client = await tryConnectToDaemon({ host, timeout: 1200 }); + const client = await tryConnectToDaemon({ + host, + timeout: Math.min(remainingTimeoutMs(), READY_PROBE_TIMEOUT_MS), + }); if (client) { try { - await client.fetchAgents(); + await client.fetchAgents({ + timeout: Math.min(remainingTimeoutMs(), READY_PROBE_TIMEOUT_MS), + }); return { kind: "ready", listen: state.listen, host }; } catch { // Daemon process is alive but not API-ready yet. @@ -255,23 +263,29 @@ async function waitForDaemonReady(args: { onStatus?: (message: string) => void; }): Promise<{ listen: string; host: string | null }> { const deadline = Date.now() + args.timeoutMs; + const createTimeoutError = () => { + const recentLogs = tailDaemonLog(args.home, 60); + return new Error( + [ + `Timed out after ${Math.ceil(args.timeoutMs / 1000)}s waiting for daemon readiness.`, + recentLogs ? `Recent daemon logs:\n${recentLogs}` : null, + ] + .filter(Boolean) + .join("\n\n"), + ); + }; async function poll(state: ProgressState): Promise<{ listen: string; host: string | null }> { - const probe = await probeDaemonReady(args.home); + if (Date.now() >= deadline) { + throw createTimeoutError(); + } + const probe = await probeDaemonReady(args.home, Math.max(1, deadline - Date.now())); if (probe.kind === "ready") { return { listen: probe.listen, host: probe.host }; } const nextState = announceProgress(args.home, state, args.onStatus); if (Date.now() >= deadline) { - const recentLogs = tailDaemonLog(args.home, 60); - throw new Error( - [ - `Timed out after ${Math.ceil(args.timeoutMs / 1000)}s waiting for daemon readiness.`, - recentLogs ? `Recent daemon logs:\n${recentLogs}` : null, - ] - .filter(Boolean) - .join("\n\n"), - ); + throw createTimeoutError(); } await sleep(200); return poll(nextState); diff --git a/packages/cli/src/commands/permit/allow.ts b/packages/cli/src/commands/permit/allow.ts index d3c4b484d..15234c0d0 100644 --- a/packages/cli/src/commands/permit/allow.ts +++ b/packages/cli/src/commands/permit/allow.ts @@ -79,7 +79,7 @@ export async function runAllowCommand( } try { - const fetchResult = await client.fetchAgent(agentIdOrPrefix); + const fetchResult = await client.fetchAgent({ agentId: agentIdOrPrefix }); if (!fetchResult) { await client.close(); const error: CommandError = { diff --git a/packages/cli/src/commands/permit/deny.ts b/packages/cli/src/commands/permit/deny.ts index bb37537c9..94ffe9d57 100644 --- a/packages/cli/src/commands/permit/deny.ts +++ b/packages/cli/src/commands/permit/deny.ts @@ -45,7 +45,7 @@ export async function runDenyCommand( } try { - const fetchResult = await client.fetchAgent(agentIdOrPrefix); + const fetchResult = await client.fetchAgent({ agentId: agentIdOrPrefix }); if (!fetchResult) { await client.close(); const error: CommandError = { diff --git a/packages/cli/src/utils/timeline.ts b/packages/cli/src/utils/timeline.ts index 77e4d3142..7e84757da 100644 --- a/packages/cli/src/utils/timeline.ts +++ b/packages/cli/src/utils/timeline.ts @@ -1,9 +1,12 @@ import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; import type { AgentTimelineItem } from "@getpaseo/protocol/agent-types"; +export const LIVE_HISTORY_FETCH_TIMEOUT_MS = 2_000; + interface FetchProjectedTimelineItemsInput { client: DaemonClient; agentId: string; + timeoutMs?: number; } export async function fetchProjectedTimelineItems( @@ -13,6 +16,7 @@ export async function fetchProjectedTimelineItems( direction: "tail", limit: 0, projection: "projected", + timeout: input.timeoutMs, }); return timeline.entries.map((entry) => entry.item); } diff --git a/packages/client/src/daemon-client.test.ts b/packages/client/src/daemon-client.test.ts index 8550c1b9d..d0cd496d4 100644 --- a/packages/client/src/daemon-client.test.ts +++ b/packages/client/src/daemon-client.test.ts @@ -522,6 +522,520 @@ test("keeps the transport connected when a session RPC ping times out", async () expect(client.getConnectionState().status).toBe("connected"); }); +test("defaults session RPC waiters to sixty seconds", async () => { + useHeartbeatClock(); + const logger = createMockLogger(); + const mock = createMockTransport(); + + const client = new DaemonClient({ + url: "ws://test", + clientId: "clsk_unit_test", + logger, + reconnect: { enabled: false }, + transportFactory: () => mock.transport, + }); + clients.push(client); + + const connectPromise = client.connect(); + mock.triggerOpen(); + await connectPromise; + + const responsePromise = client.fetchAgent({ + agentId: "agent-1", + requestId: "req-agent-1", + }); + let settled = false; + void responsePromise.then( + () => { + settled = true; + return undefined; + }, + () => { + settled = true; + return undefined; + }, + ); + + expect(parseSentFrame(mock.sent[0])).toEqual({ + type: "fetch_agent_request", + requestId: "req-agent-1", + agentId: "agent-1", + }); + + await vi.advanceTimersByTimeAsync(59_999); + expect(settled).toBe(false); + + await vi.advanceTimersByTimeAsync(1); + await expect(responsePromise).rejects.toThrow("Timeout waiting for message (60000ms)"); +}); + +test("honors explicit fetchAgent timeout below the session RPC default", async () => { + useHeartbeatClock(); + const logger = createMockLogger(); + const mock = createMockTransport(); + + const client = new DaemonClient({ + url: "ws://test", + clientId: "clsk_unit_test", + logger, + reconnect: { enabled: false }, + transportFactory: () => mock.transport, + }); + clients.push(client); + + const connectPromise = client.connect(); + mock.triggerOpen(); + await connectPromise; + + const responsePromise = client.fetchAgent({ + agentId: "agent-1", + requestId: "req-agent-1", + timeout: 5_000, + }); + let settled = false; + void responsePromise.then( + () => { + settled = true; + return undefined; + }, + () => { + settled = true; + return undefined; + }, + ); + + expect(parseSentFrame(mock.sent[0])).toEqual({ + type: "fetch_agent_request", + requestId: "req-agent-1", + agentId: "agent-1", + }); + + await vi.advanceTimersByTimeAsync(4_999); + expect(settled).toBe(false); + + await vi.advanceTimersByTimeAsync(1); + await expect(responsePromise).rejects.toThrow("Timeout waiting for message (5000ms)"); +}); + +test("preserves legacy fetchAgent id overload", async () => { + const logger = createMockLogger(); + const mock = createMockTransport(); + + const client = new DaemonClient({ + url: "ws://test", + clientId: "clsk_unit_test", + logger, + reconnect: { enabled: false }, + transportFactory: () => mock.transport, + }); + clients.push(client); + + const connectPromise = client.connect(); + mock.triggerOpen(); + await connectPromise; + + const responsePromise = client.fetchAgent("agent-1", "req-agent-legacy"); + + expect(parseSentFrame(mock.sent[0])).toEqual({ + type: "fetch_agent_request", + requestId: "req-agent-legacy", + agentId: "agent-1", + }); + + mock.triggerMessage( + wrapSessionMessage({ + type: "fetch_agent_response", + payload: { + requestId: "req-agent-legacy", + agent: null, + project: null, + error: "legacy fetch sentinel", + }, + }), + ); + + await expect(responsePromise).rejects.toThrow("legacy fetch sentinel"); +}); + +test("honors explicit fetchAgentTimeline timeout below the session RPC default", async () => { + useHeartbeatClock(); + const logger = createMockLogger(); + const mock = createMockTransport(); + + const client = new DaemonClient({ + url: "ws://test", + clientId: "clsk_unit_test", + logger, + reconnect: { enabled: false }, + transportFactory: () => mock.transport, + }); + clients.push(client); + + const connectPromise = client.connect(); + mock.triggerOpen(); + await connectPromise; + + const responsePromise = client.fetchAgentTimeline("agent-1", { + requestId: "req-timeline-1", + direction: "tail", + limit: 0, + projection: "projected", + timeout: 2_000, + }); + let settled = false; + void responsePromise.then( + () => { + settled = true; + return undefined; + }, + () => { + settled = true; + return undefined; + }, + ); + + expect(parseSentFrame(mock.sent[0])).toEqual({ + type: "fetch_agent_timeline_request", + requestId: "req-timeline-1", + agentId: "agent-1", + direction: "tail", + limit: 0, + projection: "projected", + }); + + await vi.advanceTimersByTimeAsync(1_999); + expect(settled).toBe(false); + + await vi.advanceTimersByTimeAsync(1); + await expect(responsePromise).rejects.toThrow("Timeout waiting for message (2000ms)"); +}); + +test("honors explicit fetchAgents timeout below the session RPC default", async () => { + useHeartbeatClock(); + const logger = createMockLogger(); + const mock = createMockTransport(); + + const client = new DaemonClient({ + url: "ws://test", + clientId: "clsk_unit_test", + logger, + reconnect: { enabled: false }, + transportFactory: () => mock.transport, + }); + clients.push(client); + + const connectPromise = client.connect(); + mock.triggerOpen(); + await connectPromise; + + const responsePromise = client.fetchAgents({ + requestId: "req-agents-1", + scope: "active", + timeout: 1_200, + }); + let settled = false; + void responsePromise.then( + () => { + settled = true; + return undefined; + }, + () => { + settled = true; + return undefined; + }, + ); + + expect(parseSentFrame(mock.sent[0])).toEqual({ + type: "fetch_agents_request", + requestId: "req-agents-1", + scope: "active", + }); + + await vi.advanceTimersByTimeAsync(1_199); + expect(settled).toBe(false); + + await vi.advanceTimersByTimeAsync(1); + await expect(responsePromise).rejects.toThrow("Timeout waiting for message (1200ms)"); +}); + +test("honors explicit shutdownServer timeout below the session RPC default", async () => { + useHeartbeatClock(); + const logger = createMockLogger(); + const mock = createMockTransport(); + + const client = new DaemonClient({ + url: "ws://test", + clientId: "clsk_unit_test", + logger, + reconnect: { enabled: false }, + transportFactory: () => mock.transport, + }); + clients.push(client); + + const connectPromise = client.connect(); + mock.triggerOpen(); + await connectPromise; + + const responsePromise = client.shutdownServer({ + requestId: "req-shutdown-1", + timeout: 1_500, + }); + let settled = false; + void responsePromise.then( + () => { + settled = true; + return undefined; + }, + () => { + settled = true; + return undefined; + }, + ); + + expect(parseSentFrame(mock.sent[0])).toEqual({ + type: "shutdown_server_request", + requestId: "req-shutdown-1", + }); + + await vi.advanceTimersByTimeAsync(1_499); + expect(settled).toBe(false); + + await vi.advanceTimersByTimeAsync(1); + await expect(responsePromise).rejects.toThrow("Timeout waiting for message (1500ms)"); +}); + +test("honors explicit readChatMessages timeout below the session RPC default", async () => { + useHeartbeatClock(); + const logger = createMockLogger(); + const mock = createMockTransport(); + + const client = new DaemonClient({ + url: "ws://test", + clientId: "clsk_unit_test", + logger, + reconnect: { enabled: false }, + transportFactory: () => mock.transport, + }); + clients.push(client); + + const connectPromise = client.connect(); + mock.triggerOpen(); + await connectPromise; + + const responsePromise = client.readChatMessages({ + requestId: "req-chat-read-1", + room: "room-1", + limit: 1, + timeout: 2_500, + }); + let settled = false; + void responsePromise.then( + () => { + settled = true; + return undefined; + }, + () => { + settled = true; + return undefined; + }, + ); + + expect(parseSentFrame(mock.sent[0])).toEqual({ + type: "chat/read", + requestId: "req-chat-read-1", + room: "room-1", + limit: 1, + }); + + await vi.advanceTimersByTimeAsync(2_499); + expect(settled).toBe(false); + + await vi.advanceTimersByTimeAsync(1); + await expect(responsePromise).rejects.toThrow("Timeout waiting for message (2500ms)"); +}); + +test("honors explicit getDaemonStatus timeout below the session RPC default", async () => { + useHeartbeatClock(); + const logger = createMockLogger(); + const mock = createMockTransport(); + + const client = new DaemonClient({ + url: "ws://test", + clientId: "clsk_unit_test", + logger, + reconnect: { enabled: false }, + transportFactory: () => mock.transport, + }); + clients.push(client); + + const connectPromise = client.connect(); + mock.triggerOpen(); + await connectPromise; + + const responsePromise = client.getDaemonStatus({ + requestId: "req-daemon-status-1", + timeout: 1_500, + }); + let settled = false; + void responsePromise.then( + () => { + settled = true; + return undefined; + }, + () => { + settled = true; + return undefined; + }, + ); + + expect(parseSentFrame(mock.sent[0])).toEqual({ + type: "daemon.get_status.request", + requestId: "req-daemon-status-1", + }); + + await vi.advanceTimersByTimeAsync(1_499); + expect(settled).toBe(false); + + await vi.advanceTimersByTimeAsync(1); + await expect(responsePromise).rejects.toThrow("Timeout waiting for message (1500ms)"); +}); + +test("honors explicit getDaemonPairingOffer timeout below the session RPC default", async () => { + useHeartbeatClock(); + const logger = createMockLogger(); + const mock = createMockTransport(); + + const client = new DaemonClient({ + url: "ws://test", + clientId: "clsk_unit_test", + logger, + reconnect: { enabled: false }, + transportFactory: () => mock.transport, + }); + clients.push(client); + + const connectPromise = client.connect(); + mock.triggerOpen(); + await connectPromise; + + const responsePromise = client.getDaemonPairingOffer({ + requestId: "req-pairing-offer-1", + timeout: 1_500, + }); + let settled = false; + void responsePromise.then( + () => { + settled = true; + return undefined; + }, + () => { + settled = true; + return undefined; + }, + ); + + expect(parseSentFrame(mock.sent[0])).toEqual({ + type: "daemon.get_pairing_offer.request", + requestId: "req-pairing-offer-1", + }); + + await vi.advanceTimersByTimeAsync(1_499); + expect(settled).toBe(false); + + await vi.advanceTimersByTimeAsync(1); + await expect(responsePromise).rejects.toThrow("Timeout waiting for message (1500ms)"); +}); + +test("keeps waitForAgentUpsert initial fetch inside the requested deadline", async () => { + useHeartbeatClock(); + const logger = createMockLogger(); + const mock = createMockTransport(); + + const client = new DaemonClient({ + url: "ws://test", + clientId: "clsk_unit_test", + logger, + reconnect: { enabled: false }, + transportFactory: () => mock.transport, + }); + clients.push(client); + + const connectPromise = client.connect(); + mock.triggerOpen(); + await connectPromise; + + const waitPromise = client.waitForAgentUpsert("agent-1", () => false, 5_000); + let settled = false; + void waitPromise.then( + () => { + settled = true; + return undefined; + }, + () => { + settled = true; + return undefined; + }, + ); + + expect(parseSentFrame(mock.sent[0])).toEqual({ + type: "fetch_agent_request", + requestId: expect.any(String), + agentId: "agent-1", + }); + + await vi.advanceTimersByTimeAsync(4_999); + expect(settled).toBe(false); + + await vi.advanceTimersByTimeAsync(1); + await expect(waitPromise).rejects.toThrow("Timed out waiting for agent agent-1"); +}); + +test("keeps default connect timeout shorter than session RPC waiters", async () => { + useHeartbeatClock(); + try { + const logger = createMockLogger(); + const mock = createMockTransport(); + + const client = new DaemonClient({ + url: "ws://test", + clientId: "clsk_unit_test", + logger, + reconnect: { enabled: false }, + transportFactory: () => mock.transport, + }); + clients.push(client); + + let settled = false; + const pendingConnect = client.connect().then( + () => { + settled = true; + return { ok: true as const }; + }, + (error) => { + settled = true; + return { ok: false as const, error }; + }, + ); + + await vi.advanceTimersByTimeAsync(14_999); + expect(settled).toBe(false); + expect(client.getConnectionState().status).toBe("connecting"); + + await vi.advanceTimersByTimeAsync(1); + const result = await pendingConnect; + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error).toBeInstanceOf(Error); + if (result.error instanceof Error) { + expect(result.error.message).toContain("Connection timed out"); + } + } + expect(client.getConnectionState().status).toBe("disconnected"); + } finally { + vi.useRealTimers(); + } +}); + test("stays online through ten minutes of pongs that arrive five seconds late", async () => { useHeartbeatClock(); const session = new DaemonClientSession(); @@ -1754,16 +2268,8 @@ test("sends explicit shutdown_server_request via shutdownServer", async () => { mock.triggerOpen(); await connectPromise; - const lifecycleClient = client as unknown as { - shutdownServer: (requestId?: string) => Promise<{ - status: "shutdown_requested"; - clientId: string; - requestId: string; - }>; - }; - - expect(typeof lifecycleClient.shutdownServer).toBe("function"); - const promise = lifecycleClient.shutdownServer("req-shutdown-1"); + expect(typeof client.shutdownServer).toBe("function"); + const promise = client.shutdownServer({ requestId: "req-shutdown-1" }); expect(mock.sent).toHaveLength(1); const request = parseSentFrame(mock.sent[0]); @@ -3347,7 +3853,8 @@ test("lists commands with draft config via RPC", async () => { mock.triggerOpen(); await connectPromise; - const promise = client.listCommands("__new_agent__", { + const promise = client.listCommands({ + agentId: "__new_agent__", draftConfig: { provider: "codex", cwd: "/tmp/project", @@ -3392,7 +3899,7 @@ test("lists commands with draft config via RPC", async () => { }); }); -test("lists commands with legacy requestId signature via RPC", async () => { +test("lists commands with explicit requestId via RPC", async () => { const logger = createMockLogger(); const mock = createMockTransport(); @@ -3409,13 +3916,16 @@ test("lists commands with legacy requestId signature via RPC", async () => { mock.triggerOpen(); await connectPromise; - const promise = client.listCommands("agent-1", "req-legacy"); + const promise = client.listCommands({ + agentId: "agent-1", + requestId: "req-commands", + }); expect(mock.sent).toHaveLength(1); const request = parseSentFrame(mock.sent[0]); expect(request.type).toBe("list_commands_request"); expect(request.agentId).toBe("agent-1"); - expect(request.requestId).toBe("req-legacy"); + expect(request.requestId).toBe("req-commands"); expect(request.draftConfig).toBeUndefined(); mock.triggerMessage( @@ -3427,7 +3937,7 @@ test("lists commands with legacy requestId signature via RPC", async () => { agentId: "agent-1", commands: [], error: null, - requestId: "req-legacy", + requestId: "req-commands", }, }, }), @@ -3437,7 +3947,113 @@ test("lists commands with legacy requestId signature via RPC", async () => { agentId: "agent-1", commands: [], error: null, - requestId: "req-legacy", + requestId: "req-commands", + }); +}); + +test("preserves legacy listCommands id overload", async () => { + const logger = createMockLogger(); + const mock = createMockTransport(); + + const client = new DaemonClient({ + url: "ws://test", + clientId: "clsk_unit_test", + logger, + reconnect: { enabled: false }, + transportFactory: () => mock.transport, + }); + clients.push(client); + + const connectPromise = client.connect(); + mock.triggerOpen(); + await connectPromise; + + const promise = client.listCommands("agent-1", "req-commands-legacy"); + + expect(parseSentFrame(mock.sent[0])).toEqual({ + type: "list_commands_request", + requestId: "req-commands-legacy", + agentId: "agent-1", + }); + + mock.triggerMessage( + wrapSessionMessage({ + type: "list_commands_response", + payload: { + agentId: "agent-1", + commands: [], + error: null, + requestId: "req-commands-legacy", + }, + }), + ); + + await expect(promise).resolves.toEqual({ + agentId: "agent-1", + commands: [], + error: null, + requestId: "req-commands-legacy", + }); +}); + +test("preserves legacy listCommands options overload", async () => { + const logger = createMockLogger(); + const mock = createMockTransport(); + + const client = new DaemonClient({ + url: "ws://test", + clientId: "clsk_unit_test", + logger, + reconnect: { enabled: false }, + transportFactory: () => mock.transport, + }); + clients.push(client); + + const connectPromise = client.connect(); + mock.triggerOpen(); + await connectPromise; + + const promise = client.listCommands("__new_agent__", { + requestId: "req-commands-draft-legacy", + draftConfig: { + provider: "codex", + cwd: "/tmp/project", + modeId: "bypassPermissions", + model: "gpt-5", + thinkingOptionId: "off", + }, + }); + + expect(parseSentFrame(mock.sent[0])).toEqual({ + type: "list_commands_request", + requestId: "req-commands-draft-legacy", + agentId: "__new_agent__", + draftConfig: { + provider: "codex", + cwd: "/tmp/project", + modeId: "bypassPermissions", + model: "gpt-5", + thinkingOptionId: "off", + }, + }); + + mock.triggerMessage( + wrapSessionMessage({ + type: "list_commands_response", + payload: { + agentId: "__new_agent__", + commands: [{ name: "help", description: "Show help", argumentHint: "" }], + error: null, + requestId: "req-commands-draft-legacy", + }, + }), + ); + + await expect(promise).resolves.toEqual({ + agentId: "__new_agent__", + commands: [{ name: "help", description: "Show help", argumentHint: "" }], + error: null, + requestId: "req-commands-draft-legacy", }); }); diff --git a/packages/client/src/daemon-client.ts b/packages/client/src/daemon-client.ts index 005cfd2e3..9a317540b 100644 --- a/packages/client/src/daemon-client.ts +++ b/packages/client/src/daemon-client.ts @@ -374,9 +374,11 @@ export interface WriteProjectConfigInput { requestId?: string; } interface ListCommandsOptions { + agentId: string; requestId?: string; draftConfig?: ListCommandsDraftConfig; } +type LegacyListCommandsOptions = Omit; type SetVoiceModePayload = Extract< SessionOutboundMessage, { type: "set_voice_mode_response" } @@ -458,17 +460,64 @@ export type FetchAgentTimelinePayload = FetchAgentTimelineResponseMessage["paylo export type FetchAgentTimelineDirection = FetchAgentTimelinePayload["direction"]; export type FetchAgentTimelineProjection = FetchAgentTimelinePayload["projection"]; export type FetchAgentTimelineCursor = NonNullable; +export interface FetchAgentOptions { + agentId: string; + requestId?: string; + timeout?: number; +} +type LegacyFetchAgentOptions = Omit; export interface FetchAgentTimelineOptions { direction?: FetchAgentTimelineDirection; cursor?: FetchAgentTimelineCursor; limit?: number; projection?: FetchAgentTimelineProjection; requestId?: string; + timeout?: number; +} + +// COMPAT(daemon-client-object-options): added in v0.1.102; remove after +// 2026-12-29 once SDK callers have migrated to object parameters. +function normalizeFetchAgentOptions( + input: FetchAgentOptions | string, + legacyOptions?: LegacyFetchAgentOptions | string, +): FetchAgentOptions { + if (typeof input !== "string") { + return input; + } + if (typeof legacyOptions === "string") { + return { agentId: input, requestId: legacyOptions }; + } + return { agentId: input, ...legacyOptions }; +} + +function normalizeListCommandsOptions( + input: ListCommandsOptions | string, + legacyOptions?: LegacyListCommandsOptions | string, +): ListCommandsOptions { + if (typeof input !== "string") { + return input; + } + if (typeof legacyOptions === "string") { + return { agentId: input, requestId: legacyOptions }; + } + return { agentId: input, ...legacyOptions }; } type AgentRefreshedStatusPayload = z.infer; type RestartRequestedStatusPayload = z.infer; type ShutdownRequestedStatusPayload = z.infer; +export interface ShutdownServerOptions { + requestId?: string; + timeout?: number; +} +export interface DaemonStatusOptions { + requestId?: string; + timeout?: number; +} +export interface DaemonPairingOfferOptions { + requestId?: string; + timeout?: number; +} type DaemonUpdateResponse = z.infer; type FetchAgentsPayload = Extract< SessionOutboundMessage, @@ -477,6 +526,7 @@ type FetchAgentsPayload = Extract< type FetchAgentsRequest = Extract; export type FetchAgentsOptions = Omit & { requestId?: string; + timeout?: number; }; export type FetchAgentsEntry = FetchAgentsPayload["entries"][number]; export type FetchAgentsPageInfo = FetchAgentsPayload["pageInfo"]; @@ -544,6 +594,7 @@ export interface ReadChatMessagesOptions { since?: string; authorAgentId?: string; requestId?: string; + timeout?: number; } export interface WaitForChatMessagesOptions { room: string; @@ -762,15 +813,16 @@ function toTimeoutError(error: unknown, label: string, timeoutMs: number): Error const DEFAULT_RECONNECT_BASE_DELAY_MS = 1500; const DEFAULT_RECONNECT_MAX_DELAY_MS = 30000; -const DEFAULT_CONNECT_TIMEOUT_MS = 15000; +const DEFAULT_SESSION_RPC_TIMEOUT_MS = 60_000; +const DEFAULT_CONNECT_TIMEOUT_MS = 15_000; const DEFAULT_LIVENESS_TIMEOUT_MS = 5000; const LIVENESS_HEARTBEAT_INTERVAL_MS = 10_000; const LIVENESS_HEARTBEAT_TIMEOUT_MS = 15_000; const LIVENESS_FAILURE_RECONNECT_THRESHOLD = 2; /** Default timeout for waiting for connection before sending queued messages */ -const DEFAULT_SEND_QUEUE_TIMEOUT_MS = 10000; -const DEFAULT_DICTATION_FINISH_ACCEPT_TIMEOUT_MS = 15000; +const DEFAULT_SEND_QUEUE_TIMEOUT_MS = DEFAULT_SESSION_RPC_TIMEOUT_MS; +const DEFAULT_DICTATION_FINISH_ACCEPT_TIMEOUT_MS = DEFAULT_SESSION_RPC_TIMEOUT_MS; const DEFAULT_DICTATION_FINISH_FALLBACK_TIMEOUT_MS = 5 * 60 * 1000; const DEFAULT_DICTATION_FINISH_TIMEOUT_GRACE_MS = 5000; @@ -1420,10 +1472,11 @@ export class DaemonClient { private async sendRequest(params: { requestId: string; message: SessionInboundMessage; - timeout: number; + timeout?: number; select: (msg: SessionOutboundMessage) => T | null; options?: { skipQueue?: boolean }; }): Promise { + const timeout = params.timeout ?? DEFAULT_SESSION_RPC_TIMEOUT_MS; const { promise, cancel } = this.waitForWithCancel>( (msg) => { if (msg.type === "rpc_error" && msg.payload.requestId === params.requestId) { @@ -1443,7 +1496,7 @@ export class DaemonClient { } return { kind: "ok", value }; }, - params.timeout, + timeout, params.options, ); @@ -1469,7 +1522,7 @@ export class DaemonClient { >(params: { requestId: string; message: SessionInboundMessage; - timeout: number; + timeout?: number; responseType: TResponseType; options?: { skipQueue?: boolean }; selectPayload?: (payload: CorrelatedResponsePayload) => TResult | null; @@ -1503,7 +1556,7 @@ export class DaemonClient { requestId?: string; message: { type: SessionInboundMessage["type"] } & Record; responseType: TResponseType; - timeout: number; + timeout?: number; selectPayload?: (payload: CorrelatedResponsePayload) => TResult | null; }): Promise { const resolvedRequestId = this.createRequestId(params.requestId); @@ -1530,7 +1583,7 @@ export class DaemonClient { string, unknown >; - timeout: number; + timeout?: number; selectPayload?: (payload: CorrelatedResponsePayload) => TResult | null; }): Promise { const responseType = params.message.type.replace(/\.request$/, ".response") as TResponseType; @@ -1562,7 +1615,6 @@ export class DaemonClient { await this.sendRequest({ requestId, message, - timeout: 15000, options: { skipQueue: true }, select: (msg) => { if (msg.type !== "clear_agent_attention_response") { @@ -1586,7 +1638,6 @@ export class DaemonClient { const response = await this.sendRequest({ requestId, message, - timeout: 15000, options: { skipQueue: true }, select: (msg) => { if (msg.type !== "workspace.clear_attention.response") { @@ -1782,7 +1833,7 @@ export class DaemonClient { return this.sendRequest({ requestId: resolvedRequestId, message, - timeout: 10000, + timeout: options?.timeout, options: { skipQueue: true }, select: (msg) => { if (msg.type !== "fetch_agents_response") { @@ -1808,7 +1859,6 @@ export class DaemonClient { return this.sendRequest({ requestId: resolvedRequestId, message, - timeout: 10000, options: { skipQueue: true }, select: (msg) => { if (msg.type !== "fetch_agent_history_response") { @@ -1837,7 +1887,6 @@ export class DaemonClient { return this.sendRequest({ requestId: resolvedRequestId, message, - timeout: 10000, options: { skipQueue: true }, select: (msg) => { if (msg.type !== "fetch_recent_provider_sessions_response") { @@ -1864,7 +1913,6 @@ export class DaemonClient { return this.sendRequest({ requestId: resolvedRequestId, message, - timeout: 10000, options: { skipQueue: true }, select: (msg) => { if (msg.type !== "fetch_workspaces_response") { @@ -1886,9 +1934,6 @@ export class DaemonClient { cwd, }, responseType: "open_project_response", - // Large local repos (e.g. a big monorepo/brain checkout) need >10s for the - // daemon to resolve the path, detect git, and materialize the workspace. - timeout: 60000, }); } @@ -1900,7 +1945,6 @@ export class DaemonClient { cwd, }, responseType: "project.add.response", - timeout: 10000, }); } @@ -1919,7 +1963,6 @@ export class DaemonClient { scriptName, }, responseType: "start_workspace_script_response", - timeout: 10000, }); } @@ -1934,7 +1977,6 @@ export class DaemonClient { workspaceId, }, responseType: "archive_workspace_response", - timeout: 10000, }); } @@ -1949,21 +1991,30 @@ export class DaemonClient { workspaceId, }, responseType: "workspace_setup_status_response", - timeout: 10000, }); } - async fetchAgent(agentId: string, requestId?: string): Promise { - const resolvedRequestId = this.createRequestId(requestId); + async fetchAgent(options: FetchAgentOptions): Promise; + async fetchAgent(agentId: string, requestId?: string): Promise; + async fetchAgent( + agentId: string, + options?: LegacyFetchAgentOptions, + ): Promise; + async fetchAgent( + input: FetchAgentOptions | string, + legacyOptions?: LegacyFetchAgentOptions | string, + ): Promise { + const options = normalizeFetchAgentOptions(input, legacyOptions); + const resolvedRequestId = this.createRequestId(options.requestId); const message = SessionInboundMessageSchema.parse({ type: "fetch_agent_request", requestId: resolvedRequestId, - agentId, + agentId: options.agentId, }); const payload = await this.sendRequest({ requestId: resolvedRequestId, message, - timeout: 10000, + timeout: options.timeout, options: { skipQueue: true }, select: (msg) => { if (msg.type !== "fetch_agent_response") { @@ -2048,7 +2099,6 @@ export class DaemonClient { const status = await this.sendRequest({ requestId, message, - timeout: 60000, options: { skipQueue: true }, select: (msg) => { if (msg.type !== "status") { @@ -2082,7 +2132,6 @@ export class DaemonClient { await this.sendRequest({ requestId, message, - timeout: 10000, options: { skipQueue: true }, select: (msg) => { if (msg.type !== "agent_deleted") { @@ -2106,7 +2155,6 @@ export class DaemonClient { const result = await this.sendRequest({ requestId, message, - timeout: 10000, options: { skipQueue: true }, select: (msg) => { if (msg.type !== "agent_archived") { @@ -2127,7 +2175,6 @@ export class DaemonClient { type: "agent.detach.request", agentId, }, - timeout: 10000, }); if (!payload.accepted) { throw new Error(payload.error ?? "detachAgent rejected"); @@ -2151,7 +2198,6 @@ export class DaemonClient { const payload = await this.sendRequest({ requestId, message, - timeout: 10000, options: { skipQueue: true }, select: (msg) => { if (msg.type !== "update_agent_response") { @@ -2181,7 +2227,6 @@ export class DaemonClient { customName, }, responseType: "project.rename.response", - timeout: 10000, }); if (!payload.accepted) { throw new Error(payload.error ?? "renameProject rejected"); @@ -2199,7 +2244,6 @@ export class DaemonClient { type: "project.remove.request", projectId, }, - timeout: 10000, }); if (!payload.accepted) { throw new Error(payload.error ?? "removeProject rejected"); @@ -2220,7 +2264,6 @@ export class DaemonClient { title, }, responseType: "workspace.title.set.response", - timeout: 10000, }); if (!payload.accepted) { throw new Error(payload.error ?? "setWorkspaceTitle rejected"); @@ -2243,7 +2286,6 @@ export class DaemonClient { const status = await this.sendRequest({ requestId, message, - timeout: 15000, options: { skipQueue: true }, select: (msg) => { if (msg.type !== "status") { @@ -2275,7 +2317,6 @@ export class DaemonClient { const status = await this.sendRequest({ requestId, message, - timeout: 15000, options: { skipQueue: true }, select: (msg) => { if (msg.type !== "status") { @@ -2312,7 +2353,6 @@ export class DaemonClient { return this.sendRequest({ requestId: resolvedRequestId, message, - timeout: 15000, options: { skipQueue: true }, select: (msg) => { if (msg.type !== "status") { @@ -2345,7 +2385,7 @@ export class DaemonClient { const payload = await this.sendRequest({ requestId: resolvedRequestId, message, - timeout: 15000, + timeout: options.timeout, options: { skipQueue: true }, select: (msg) => { if (msg.type !== "fetch_agent_timeline_response") { @@ -2388,7 +2428,6 @@ export class DaemonClient { const payload = await this.sendRequest({ requestId, message, - timeout: 15000, options: { skipQueue: true }, select: (msg) => { if (msg.type !== "send_agent_message_response") { @@ -2425,7 +2464,6 @@ export class DaemonClient { const payload = await this.sendRequest({ requestId, message, - timeout: 15000, options: { skipQueue: true }, select: (msg) => { if (msg.type !== "agent.rewind.response") { @@ -2453,7 +2491,6 @@ export class DaemonClient { await this.sendRequest({ requestId, message, - timeout: 15000, options: { skipQueue: true }, select: (msg) => { if (msg.type !== "cancel_agent_response") { @@ -2478,7 +2515,6 @@ export class DaemonClient { const payload = await this.sendRequest({ requestId, message, - timeout: 15000, options: { skipQueue: true }, select: (msg) => { if (msg.type !== "set_agent_mode_response") { @@ -2507,7 +2543,6 @@ export class DaemonClient { const payload = await this.sendRequest({ requestId, message, - timeout: 15000, options: { skipQueue: true }, select: (msg) => { if (msg.type !== "set_agent_model_response") { @@ -2536,7 +2571,6 @@ export class DaemonClient { const payload = await this.sendRequest({ requestId, message, - timeout: 15000, options: { skipQueue: true }, select: (msg) => { if (msg.type !== "set_agent_feature_response") { @@ -2567,7 +2601,6 @@ export class DaemonClient { const payload = await this.sendRequest({ requestId, message, - timeout: 15000, options: { skipQueue: true }, select: (msg) => { if (msg.type !== "set_agent_thinking_response") { @@ -2595,7 +2628,6 @@ export class DaemonClient { return this.sendRequest({ requestId: resolvedRequestId, message, - timeout: 10000, options: { skipQueue: true }, select: (msg) => { if (msg.type !== "status") { @@ -2613,8 +2645,8 @@ export class DaemonClient { }); } - async shutdownServer(requestId?: string): Promise { - const resolvedRequestId = this.createRequestId(requestId); + async shutdownServer(options?: ShutdownServerOptions): Promise { + const resolvedRequestId = this.createRequestId(options?.requestId); const message = SessionInboundMessageSchema.parse({ type: "shutdown_server_request", requestId: resolvedRequestId, @@ -2622,7 +2654,7 @@ export class DaemonClient { return this.sendRequest({ requestId: resolvedRequestId, message, - timeout: 10000, + timeout: options?.timeout, options: { skipQueue: true }, select: (msg) => { if (msg.type !== "status") { @@ -2679,7 +2711,6 @@ export class DaemonClient { const response = await this.sendRequest({ requestId, message, - timeout: 10000, select: (msg) => { if (msg.type !== "set_voice_mode_response") { return null; @@ -2948,7 +2979,6 @@ export class DaemonClient { const responsePromise = this.sendRequest({ requestId: resolvedRequestId, message, - timeout: 60000, options: { skipQueue: true }, select: (msg) => { if (msg.type !== "checkout_status_response") { @@ -3049,7 +3079,6 @@ export class DaemonClient { requestId: resolvedRequestId, message, responseType: "subscribe_checkout_diff_response", - timeout: 60000, options: { skipQueue: true }, selectPayload: (payload) => { if (payload.subscriptionId !== subscriptionId) { @@ -3090,7 +3119,6 @@ export class DaemonClient { addAll: input.addAll, }, responseType: "checkout_commit_response", - timeout: 60000, }); } @@ -3109,7 +3137,6 @@ export class DaemonClient { requireCleanTarget: input.requireCleanTarget, }, responseType: "checkout_merge_response", - timeout: 60000, }); } @@ -3127,7 +3154,6 @@ export class DaemonClient { requireCleanTarget: input.requireCleanTarget, }, responseType: "checkout_merge_from_base_response", - timeout: 60000, }); } @@ -3139,7 +3165,6 @@ export class DaemonClient { cwd, }, responseType: "checkout_pull_response", - timeout: 60000, }); } @@ -3151,7 +3176,6 @@ export class DaemonClient { cwd, }, responseType: "checkout_push_response", - timeout: 60000, }); } @@ -3163,7 +3187,6 @@ export class DaemonClient { cwd, }, responseType: "checkout.refresh.response", - timeout: 60000, }); } @@ -3182,7 +3205,6 @@ export class DaemonClient { baseRef: input.baseRef, }, responseType: "checkout_pr_create_response", - timeout: 60000, }); } @@ -3199,7 +3221,6 @@ export class DaemonClient { mergeMethod: input.method, }, responseType: "checkout_pr_merge_response", - timeout: 60000, }); } @@ -3216,7 +3237,6 @@ export class DaemonClient { enabled: input.enabled, ...(input.enabled ? { mergeMethod: input.method } : {}), }, - timeout: 60000, }); } @@ -3241,7 +3261,6 @@ export class DaemonClient { checkRunId: input.checkRunId, workflowRunId: input.workflowRunId, }, - timeout: 60000, }, ); } @@ -3254,7 +3273,6 @@ export class DaemonClient { cwd, }, responseType: "checkout_pr_status_response", - timeout: 60000, }); } @@ -3272,7 +3290,6 @@ export class DaemonClient { repoName: input.repoName, }, responseType: "pull_request_timeline_response", - timeout: 60000, }); } @@ -3289,7 +3306,6 @@ export class DaemonClient { branch, }, responseType: "checkout_switch_branch_response", - timeout: 30000, }); } @@ -3302,7 +3318,6 @@ export class DaemonClient { branch: input.branch, }, responseType: "checkout.rename_branch.response", - timeout: 30000, }); } @@ -3319,7 +3334,6 @@ export class DaemonClient { branch: options?.branch, }, responseType: "stash_save_response", - timeout: 30000, }); } @@ -3332,7 +3346,6 @@ export class DaemonClient { stashIndex, }, responseType: "stash_pop_response", - timeout: 30000, }); } @@ -3349,7 +3362,6 @@ export class DaemonClient { paseoOnly: options?.paseoOnly, }, responseType: "stash_list_response", - timeout: 10000, }); } @@ -3365,7 +3377,6 @@ export class DaemonClient { repoRoot: input.repoRoot, }, responseType: "paseo_worktree_list_response", - timeout: 60000, }); } @@ -3390,7 +3401,6 @@ export class DaemonClient { ...(input.scope !== undefined ? { scope: input.scope } : {}), }, responseType: "paseo_worktree_archive_response", - timeout: 60000, }); } @@ -3413,7 +3423,6 @@ export class DaemonClient { ...(input.githubPrNumber !== undefined ? { githubPrNumber: input.githubPrNumber } : {}), }, responseType: "create_paseo_worktree_response", - timeout: 60000, }); } @@ -3436,7 +3445,6 @@ export class DaemonClient { : {}), }, responseType: "workspace.create.response", - timeout: 60000, }); } @@ -3452,7 +3460,6 @@ export class DaemonClient { branchName: options.branchName, }, responseType: "validate_branch_response", - timeout: 10000, }); } @@ -3469,7 +3476,6 @@ export class DaemonClient { limit: options.limit, }, responseType: "branch_suggestions_response", - timeout: 10000, }); } @@ -3487,7 +3493,6 @@ export class DaemonClient { kinds: options.kinds, }, responseType: "github_search_response", - timeout: 15000, }); } @@ -3516,7 +3521,6 @@ export class DaemonClient { responseType: "directory_suggestions_response", // Home-tree scans on large home dirs can take several seconds; don't cut // the suggestion request off early (it would surface as an empty list). - timeout: 30000, }); } @@ -3541,7 +3545,6 @@ export class DaemonClient { ...(acceptBinary ? { acceptBinary: true } : {}), }, responseType: "file_explorer_response", - timeout: 10000, }); } @@ -3601,7 +3604,6 @@ export class DaemonClient { requestId: resolvedRequestId, }, responseType: "file.upload.response", - timeout: 60000, options: { skipQueue: true }, }); @@ -3653,7 +3655,6 @@ export class DaemonClient { path, }, responseType: "file_download_token_response", - timeout: 10000, }); } @@ -3668,7 +3669,6 @@ export class DaemonClient { cwd, }, responseType: "project_icon_response", - timeout: 10000, }); } @@ -3733,7 +3733,6 @@ export class DaemonClient { type: "list_available_providers_request", }, responseType: "list_available_providers_response", - timeout: 60000, }); } @@ -3748,7 +3747,6 @@ export class DaemonClient { cwd: options?.cwd, }, responseType: "get_providers_snapshot_response", - timeout: 10000, }); } @@ -3761,29 +3759,30 @@ export class DaemonClient { type: "get_daemon_config_request", }, responseType: "get_daemon_config_response", - timeout: 10000, }); } - async getDaemonStatus(requestId?: string): Promise { + async getDaemonStatus(options?: DaemonStatusOptions): Promise { return this.sendCorrelatedSessionRequest({ - requestId, + requestId: options?.requestId, message: { type: "daemon.get_status.request", }, responseType: "daemon.get_status.response", - timeout: 10000, + timeout: options?.timeout, }); } - async getDaemonPairingOffer(requestId?: string): Promise { + async getDaemonPairingOffer( + options?: DaemonPairingOfferOptions, + ): Promise { return this.sendCorrelatedSessionRequest({ - requestId, + requestId: options?.requestId, message: { type: "daemon.get_pairing_offer.request", }, responseType: "daemon.get_pairing_offer.response", - timeout: 10000, + timeout: options?.timeout, }); } @@ -3793,7 +3792,6 @@ export class DaemonClient { message: { type: "diagnostics.request", }, - timeout: 30000, }); } @@ -3808,7 +3806,6 @@ export class DaemonClient { config, }, responseType: "set_daemon_config_response", - timeout: 10000, }); } @@ -3820,7 +3817,6 @@ export class DaemonClient { repoRoot, }, responseType: "read_project_config_response", - timeout: 10000, }); } @@ -3834,7 +3830,6 @@ export class DaemonClient { expectedRevision: input.expectedRevision, }, responseType: "write_project_config_response", - timeout: 10000, }); } @@ -3876,30 +3871,28 @@ export class DaemonClient { message: { type: "provider.usage.list.request", }, - timeout: 30000, }); } + async listCommands(options: ListCommandsOptions): Promise; async listCommands(agentId: string, requestId?: string): Promise; - async listCommands(agentId: string, options?: ListCommandsOptions): Promise; async listCommands( agentId: string, - requestIdOrOptions?: string | ListCommandsOptions, + options?: LegacyListCommandsOptions, + ): Promise; + async listCommands( + input: ListCommandsOptions | string, + legacyOptions?: LegacyListCommandsOptions | string, ): Promise { - const requestId = - typeof requestIdOrOptions === "string" ? requestIdOrOptions : requestIdOrOptions?.requestId; - const draftConfig = - typeof requestIdOrOptions === "string" ? undefined : requestIdOrOptions?.draftConfig; - + const options = normalizeListCommandsOptions(input, legacyOptions); return this.sendCorrelatedSessionRequest({ - requestId, + requestId: options.requestId, message: { type: "list_commands_request", - agentId, - ...(draftConfig ? { draftConfig } : {}), + agentId: options.agentId, + ...(options.draftConfig ? { draftConfig: options.draftConfig } : {}), }, responseType: "list_commands_response", - timeout: 30000, }); } @@ -3961,12 +3954,20 @@ export class DaemonClient { predicate: (snapshot: AgentSnapshotPayload) => boolean, timeout = 60000, ): Promise { - const initialResult = await this.fetchAgent(agentId).catch(() => null); + const deadline = Date.now() + timeout; + const remainingTimeoutMs = () => Math.max(1, deadline - Date.now()); + const timeoutError = () => new Error(`Timed out waiting for agent ${agentId}`); + const fetchAgentWithinDeadline = () => + this.fetchAgent({ agentId, timeout: remainingTimeoutMs() }).catch(() => null); + + const initialResult = await fetchAgentWithinDeadline(); if (initialResult && predicate(initialResult.agent)) { return initialResult.agent; } + if (Date.now() >= deadline) { + throw timeoutError(); + } - const deadline = Date.now() + timeout; return await new Promise((resolve, reject) => { let settled = false; let pollInFlight = false; @@ -4017,7 +4018,7 @@ export class DaemonClient { } pollInFlight = true; try { - const result = await this.fetchAgent(agentId).catch(() => null); + const result = await fetchAgentWithinDeadline(); maybeResolve(result?.agent ?? null); } finally { pollInFlight = false; @@ -4042,7 +4043,7 @@ export class DaemonClient { timeoutTimer = setTimeout(() => { finish({ kind: "error", - error: new Error(`Timed out waiting for agent ${agentId}`), + error: timeoutError(), }); }, remaining); @@ -4126,7 +4127,6 @@ export class DaemonClient { requestId: resolvedRequestId, message, responseType: "list_terminals_response", - timeout: 10000, options: { skipQueue: true }, }); } @@ -4152,7 +4152,6 @@ export class DaemonClient { requestId: resolvedRequestId, message, responseType: "create_terminal_response", - timeout: 10000, options: { skipQueue: true }, }); } @@ -4166,7 +4165,6 @@ export class DaemonClient { title: input.title, }, responseType: "terminal.rename.response", - timeout: 10000, }); } @@ -4190,7 +4188,6 @@ export class DaemonClient { requestId: resolvedRequestId, message, responseType: "subscribe_terminal_response", - timeout: 10000, options: { skipQueue: true }, }); if (payload.error === null) { @@ -4231,7 +4228,6 @@ export class DaemonClient { requestId: resolvedRequestId, message, responseType: "kill_terminal_response", - timeout: 10000, options: { skipQueue: true }, }); } @@ -4251,7 +4247,6 @@ export class DaemonClient { requestId: resolvedRequestId, message, responseType: "close_items_response", - timeout: 10000, options: { skipQueue: true }, }); } @@ -4274,7 +4269,6 @@ export class DaemonClient { requestId: resolvedRequestId, message, responseType: "capture_terminal_response", - timeout: 10000, options: { skipQueue: true }, }); } @@ -4288,7 +4282,6 @@ export class DaemonClient { ...(options.purpose ? { purpose: options.purpose } : {}), }, responseType: "chat/create/response", - timeout: 10000, }); } @@ -4299,7 +4292,6 @@ export class DaemonClient { type: "chat/list", }, responseType: "chat/list/response", - timeout: 10000, }); } @@ -4311,7 +4303,6 @@ export class DaemonClient { room: options.room, }, responseType: "chat/inspect/response", - timeout: 10000, }); } @@ -4323,7 +4314,6 @@ export class DaemonClient { room: options.room, }, responseType: "chat/delete/response", - timeout: 10000, }); } @@ -4338,7 +4328,6 @@ export class DaemonClient { ...(options.replyToMessageId ? { replyToMessageId: options.replyToMessageId } : {}), }, responseType: "chat/post/response", - timeout: 10000, }); } @@ -4353,7 +4342,7 @@ export class DaemonClient { ...(options.authorAgentId ? { authorAgentId: options.authorAgentId } : {}), }, responseType: "chat/read/response", - timeout: 10000, + timeout: options.timeout, }); } @@ -4385,7 +4374,6 @@ export class DaemonClient { ...(typeof options.runOnCreate === "boolean" ? { runOnCreate: options.runOnCreate } : {}), }, responseType: "schedule/create/response", - timeout: 10000, }); } @@ -4396,7 +4384,6 @@ export class DaemonClient { type: "schedule/list", }, responseType: "schedule/list/response", - timeout: 10000, }); } @@ -4408,7 +4395,6 @@ export class DaemonClient { scheduleId: options.id, }, responseType: "schedule/inspect/response", - timeout: 10000, }); } @@ -4420,7 +4406,6 @@ export class DaemonClient { scheduleId: options.id, }, responseType: "schedule/logs/response", - timeout: 10000, }); } @@ -4432,7 +4417,6 @@ export class DaemonClient { scheduleId: options.id, }, responseType: "schedule/pause/response", - timeout: 10000, }); } @@ -4444,7 +4428,6 @@ export class DaemonClient { scheduleId: options.id, }, responseType: "schedule/resume/response", - timeout: 10000, }); } @@ -4456,7 +4439,6 @@ export class DaemonClient { scheduleId: options.id, }, responseType: "schedule/delete/response", - timeout: 10000, }); } @@ -4468,7 +4450,6 @@ export class DaemonClient { scheduleId: options.id, }, responseType: "schedule/run-once/response", - timeout: 10000, }); } @@ -4486,7 +4467,6 @@ export class DaemonClient { ...(options.expiresAt !== undefined ? { expiresAt: options.expiresAt } : {}), }, responseType: "schedule/update/response", - timeout: 10000, }); } @@ -4515,7 +4495,6 @@ export class DaemonClient { ...(typeof options.maxTimeMs === "number" ? { maxTimeMs: options.maxTimeMs } : {}), }, responseType: "loop/run/response", - timeout: 15000, }); } @@ -4526,7 +4505,6 @@ export class DaemonClient { type: "loop/list", }, responseType: "loop/list/response", - timeout: 10000, }); } @@ -4539,7 +4517,6 @@ export class DaemonClient { id: normalized.id, }, responseType: "loop/inspect/response", - timeout: 10000, }); } @@ -4553,7 +4530,6 @@ export class DaemonClient { ...(typeof normalized.afterSeq === "number" ? { afterSeq: normalized.afterSeq } : {}), }, responseType: "loop/logs/response", - timeout: 10000, }); } @@ -4566,7 +4542,6 @@ export class DaemonClient { id: normalized.id, }, responseType: "loop/stop/response", - timeout: 10000, }); } diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts index 5faa0bf9d..c7018eb3f 100644 --- a/packages/client/src/index.ts +++ b/packages/client/src/index.ts @@ -472,7 +472,7 @@ function createAgentHandleFactory(daemonClient: DaemonClient): AgentHandleFactor }, latest: () => latest, refetch: async (requestId) => { - const result = await daemonClient.fetchAgent(id, requestId); + const result = await daemonClient.fetchAgent({ agentId: id, requestId }); latest = result?.agent ?? null; return result; }, diff --git a/packages/server/src/server/agent/providers/claude/agent-commands.e2e.test.ts b/packages/server/src/server/agent/providers/claude/agent-commands.e2e.test.ts index 39a07a6f0..13727dc49 100644 --- a/packages/server/src/server/agent/providers/claude/agent-commands.e2e.test.ts +++ b/packages/server/src/server/agent/providers/claude/agent-commands.e2e.test.ts @@ -28,7 +28,7 @@ describe("claude agent commands E2E", () => { expect(agent.status).toBe("idle"); // List commands - const result = await ctx.client.listCommands(agent.id); + const result = await ctx.client.listCommands({ agentId: agent.id }); // Should have no error expect(result.error).toBeNull(); @@ -52,7 +52,7 @@ describe("claude agent commands E2E", () => { }, 60000); test("returns error for non-existent agent", async () => { - const result = await ctx.client.listCommands("non-existent-agent-id"); + const result = await ctx.client.listCommands({ agentId: "non-existent-agent-id" }); expect(result.error).toBeTruthy(); expect(result.error).toContain("Agent not found"); diff --git a/packages/server/src/server/agent/providers/codex-mcp-agent-commands.e2e.test.ts b/packages/server/src/server/agent/providers/codex-mcp-agent-commands.e2e.test.ts index 5d3e7779b..81b5c9b86 100644 --- a/packages/server/src/server/agent/providers/codex-mcp-agent-commands.e2e.test.ts +++ b/packages/server/src/server/agent/providers/codex-mcp-agent-commands.e2e.test.ts @@ -67,7 +67,7 @@ describe("codex agent commands E2E", () => { expect(agent.provider).toBe("codex"); expect(agent.status).toBe("idle"); - const result = await ctx.client.listCommands(agent.id); + const result = await ctx.client.listCommands({ agentId: agent.id }); expect(result.error).toBeNull(); expect(result.commands.length).toBeGreaterThan(0); @@ -177,7 +177,7 @@ describe("codex agent commands E2E", () => { }, 30_000); test("returns error for non-existent agent", async () => { - const result = await ctx.client.listCommands("non-existent-agent-id"); + const result = await ctx.client.listCommands({ agentId: "non-existent-agent-id" }); expect(result.error).toBeTruthy(); expect(result.error).toContain("Agent not found"); diff --git a/packages/server/src/server/agent/providers/opencode-agent-commands.e2e.test.ts b/packages/server/src/server/agent/providers/opencode-agent-commands.e2e.test.ts index 4edaaaad6..f712c6034 100644 --- a/packages/server/src/server/agent/providers/opencode-agent-commands.e2e.test.ts +++ b/packages/server/src/server/agent/providers/opencode-agent-commands.e2e.test.ts @@ -24,7 +24,7 @@ describe("opencode agent commands E2E", () => { expect(agent.provider).toBe("opencode"); expect(agent.status).toBe("idle"); - const result = await ctx.client.listCommands(agent.id); + const result = await ctx.client.listCommands({ agentId: agent.id }); expect(result.error).toBeNull(); expect(result.commands.length).toBeGreaterThan(0); @@ -87,7 +87,7 @@ describe("opencode agent commands E2E", () => { }, 30_000); test("returns error for non-existent agent", async () => { - const result = await ctx.client.listCommands("non-existent-agent-id"); + const result = await ctx.client.listCommands({ agentId: "non-existent-agent-id" }); expect(result.error).toBeTruthy(); expect(result.error).toContain("Agent not found"); diff --git a/packages/server/src/server/agent/providers/opencode-agent-custom-command.real.e2e.test.ts b/packages/server/src/server/agent/providers/opencode-agent-custom-command.real.e2e.test.ts index 1d1874370..433db9f30 100644 --- a/packages/server/src/server/agent/providers/opencode-agent-custom-command.real.e2e.test.ts +++ b/packages/server/src/server/agent/providers/opencode-agent-custom-command.real.e2e.test.ts @@ -77,7 +77,7 @@ describe("opencode custom command Big Pickle E2E (real)", () => { expect(agent.model).toBe(OPENCODE_REAL_TEST_MODEL); expect(agent.status).toBe("idle"); - const commands = await ctx.client.listCommands(agent.id); + const commands = await ctx.client.listCommands({ agentId: agent.id }); expect(commands.error).toBeNull(); expect(commands.commands).toEqual( expect.arrayContaining([ diff --git a/packages/server/src/server/cli-run-workspace-precedence.e2e.test.ts b/packages/server/src/server/cli-run-workspace-precedence.e2e.test.ts index 2f42d69dd..b99295e46 100644 --- a/packages/server/src/server/cli-run-workspace-precedence.e2e.test.ts +++ b/packages/server/src/server/cli-run-workspace-precedence.e2e.test.ts @@ -53,7 +53,7 @@ test("daemon mints a distinct local workspace per run and stamps agents by id", expect(firstAgent.workspaceId).toBe(firstWorkspaceId); expect(await workspaceIds(client)).toContain(firstWorkspaceId); - const fetchedFirst = await client.fetchAgent(firstAgent.id); + const fetchedFirst = await client.fetchAgent({ agentId: firstAgent.id }); expect(fetchedFirst?.agent.workspaceId).toBe(firstWorkspaceId); // A second bare run in the SAME cwd mints a DISTINCT workspace; each run diff --git a/packages/server/src/server/daemon-client.e2e.test.ts b/packages/server/src/server/daemon-client.e2e.test.ts index 5caabdf37..e723ab892 100644 --- a/packages/server/src/server/daemon-client.e2e.test.ts +++ b/packages/server/src/server/daemon-client.e2e.test.ts @@ -197,12 +197,12 @@ test("createAgent with background initialPrompt returns a running snapshot befor expect(agent.status).toBe("running"); - const fetchedWhileRunning = await client.fetchAgent(agent.id); + const fetchedWhileRunning = await client.fetchAgent({ agentId: agent.id }); expect(fetchedWhileRunning?.agent.status).toBe("running"); await new Promise((resolve) => setTimeout(resolve, 350)); - const fetchedAfterCompletion = await client.fetchAgent(agent.id); + const fetchedAfterCompletion = await client.fetchAgent({ agentId: agent.id }); expect(fetchedAfterCompletion?.agent.status).toBe("idle"); } finally { await client.close(); @@ -638,7 +638,7 @@ test("interrupts a running agent before archiving", async () => { const result = await ctx.client.archiveAgent(created.id); expect(result.archivedAt).toBeTruthy(); - const archivedResult = await ctx.client.fetchAgent(created.id); + const archivedResult = await ctx.client.fetchAgent({ agentId: created.id }); expect(archivedResult).not.toBeNull(); expect(archivedResult?.agent.archivedAt).toBeTruthy(); expect(archivedResult?.agent.status).not.toBe("running"); @@ -671,7 +671,7 @@ test("send_agent_message auto-unarchives archived agents", async () => { const finalState = await ctx.client.waitForFinish(created.id, 120000); expect(finalState.status).toBe("idle"); - const refreshed = await ctx.client.fetchAgent(created.id); + const refreshed = await ctx.client.fetchAgent({ agentId: created.id }); expect(refreshed).not.toBeNull(); expect(refreshed?.agent.archivedAt).toBeNull(); } finally { @@ -691,7 +691,7 @@ test("refresh_agent auto-unarchives archived agents", async () => { await ctx.client.archiveAgent(created.id); await ctx.client.refreshAgent(created.id); - const refreshed = await ctx.client.fetchAgent(created.id); + const refreshed = await ctx.client.fetchAgent({ agentId: created.id }); expect(refreshed).not.toBeNull(); expect(refreshed?.agent.archivedAt).toBeNull(); } finally { @@ -770,7 +770,7 @@ test("resume_agent auto-unarchives archived agents", async () => { cwd, }, }); - const agentBeforeArchive = await ctx.client.fetchAgent(created.id); + const agentBeforeArchive = await ctx.client.fetchAgent({ agentId: created.id }); expect(agentBeforeArchive?.agent.persistence).toBeTruthy(); await ctx.client.archiveAgent(created.id); @@ -779,7 +779,7 @@ test("resume_agent auto-unarchives archived agents", async () => { throw new Error("Expected persistence handle for resume test"); } const resumed = await ctx.client.resumeAgent(handle); - const resumedDetails = await ctx.client.fetchAgent(resumed.id); + const resumedDetails = await ctx.client.fetchAgent({ agentId: resumed.id }); expect(resumedDetails).not.toBeNull(); expect(resumedDetails?.agent.archivedAt).toBeNull(); @@ -807,7 +807,7 @@ test("update_agent persists unloaded title and labels across auto-unarchive", as labels: { lane: "phase-1a" }, }); - const archived = await ctx.client.fetchAgent(created.id); + const archived = await ctx.client.fetchAgent({ agentId: created.id }); expect(archived).not.toBeNull(); expect(archived?.agent.archivedAt).toBeTruthy(); expect(archived?.agent.title).toBe("Pinned Title"); @@ -817,7 +817,7 @@ test("update_agent persists unloaded title and labels across auto-unarchive", as const finalState = await ctx.client.waitForFinish(created.id, 120000); expect(finalState.status).toBe("idle"); - const unarchived = await ctx.client.fetchAgent(created.id); + const unarchived = await ctx.client.fetchAgent({ agentId: created.id }); expect(unarchived).not.toBeNull(); expect(unarchived?.agent.archivedAt).toBeNull(); expect(unarchived?.agent.title).toBe("Pinned Title"); @@ -974,7 +974,7 @@ test("creates agent and exercises lifecycle", async () => { expect(agent.id).toBeTruthy(); expect(agent.status).toBe("idle"); - const fetchedResult = await ctx.client.fetchAgent(agent.id); + const fetchedResult = await ctx.client.fetchAgent({ agentId: agent.id }); expect(fetchedResult?.agent.id).toBe(agent.id); const agentUpdate = await agentUpdatePromise; @@ -1149,7 +1149,10 @@ test("creates agent and exercises lifecycle", async () => { return unsubscribeCommands; }); - const commands = await ctx.client.listCommands(agent.id, commandsRequestId); + const commands = await ctx.client.listCommands({ + agentId: agent.id, + requestId: commandsRequestId, + }); const commandsMessage = await commandsResponsePromise; expect(commands.agentId).toBe(agent.id); expect(Array.isArray(commands.commands)).toBe(true); diff --git a/packages/server/src/server/daemon-e2e/agent-operations.e2e.test.ts b/packages/server/src/server/daemon-e2e/agent-operations.e2e.test.ts index 65933bd70..c5618b9f0 100644 --- a/packages/server/src/server/daemon-e2e/agent-operations.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/agent-operations.e2e.test.ts @@ -62,7 +62,7 @@ describe("timestamp behavior", () => { direction: "tail", limit: 200, }); - const refreshedResult = await ctx.client.fetchAgent(agent.id); + const refreshedResult = await ctx.client.fetchAgent({ agentId: agent.id }); // Verify agent is still idle expect(refreshedResult?.agent.status).toBe("idle"); @@ -79,7 +79,7 @@ describe("timestamp behavior", () => { direction: "tail", limit: 200, }); - const clearResult = await ctx.client.fetchAgent(agent.id); + const clearResult = await ctx.client.fetchAgent({ agentId: agent.id }); // Timestamp should STILL not have changed expect(clearResult?.agent.updatedAt).toBe(initialUpdatedAt); diff --git a/packages/server/src/server/daemon-e2e/claude-autonomous-wake.real.e2e.test.ts b/packages/server/src/server/daemon-e2e/claude-autonomous-wake.real.e2e.test.ts index 83cbc0521..fe482d00a 100644 --- a/packages/server/src/server/daemon-e2e/claude-autonomous-wake.real.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/claude-autonomous-wake.real.e2e.test.ts @@ -575,7 +575,7 @@ describe("daemon E2E (real claude) - autonomous wake from background task", () = } expect(sawTimelineGrowth).toBe(true); } else { - const current = await client.fetchAgent(agent.id); + const current = await client.fetchAgent({ agentId: agent.id }); expect(current.agent.status).toBe("idle"); } } finally { @@ -642,7 +642,7 @@ describe("daemon E2E (real claude) - autonomous wake from background task", () = expect(settled.status).toBe("idle"); } - const finalResult = await client.fetchAgent(agent.id); + const finalResult = await client.fetchAgent({ agentId: agent.id }); expect(finalResult?.agent.status).toBe("idle"); } finally { await client.close(); @@ -835,7 +835,7 @@ describe("daemon E2E (real claude) - autonomous wake from background task", () = try { secondCompletion = await client.waitForFinish(agent.id, 20_000); } catch { - const atTimeoutResult = await client.fetchAgent(agent.id); + const atTimeoutResult = await client.fetchAgent({ agentId: agent.id }); // eslint-disable-next-line no-console console.log( JSON.stringify({ @@ -911,7 +911,7 @@ describe("daemon E2E (real claude) - autonomous wake from background task", () = try { secondCompletion = await client.waitForFinish(agent.id, 20_000); } catch { - const atTimeoutResult = await client.fetchAgent(agent.id); + const atTimeoutResult = await client.fetchAgent({ agentId: agent.id }); // eslint-disable-next-line no-console console.log( JSON.stringify({ @@ -1068,7 +1068,7 @@ describe("daemon E2E (real claude) - autonomous wake from background task", () = error instanceof Error ? error : new Error(String(error ?? "wait_for_finish failed")); } - let afterWaitResult = await client.fetchAgent(agent.id); + let afterWaitResult = await client.fetchAgent({ agentId: agent.id }); let cancelRecovered = true; if (afterWaitResult?.agent.status === "running") { await client.cancelAgent(agent.id); @@ -1078,7 +1078,7 @@ describe("daemon E2E (real claude) - autonomous wake from background task", () = } catch { cancelRecovered = false; } - afterWaitResult = await client.fetchAgent(agent.id); + afterWaitResult = await client.fetchAgent({ agentId: agent.id }); } expect(waitError).toBeNull(); diff --git a/packages/server/src/server/daemon-e2e/claude-rewind.real.e2e.test.ts b/packages/server/src/server/daemon-e2e/claude-rewind.real.e2e.test.ts index 553591f24..c362529e2 100644 --- a/packages/server/src/server/daemon-e2e/claude-rewind.real.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/claude-rewind.real.e2e.test.ts @@ -179,7 +179,7 @@ async function runtimeSessionId( harness: ClaudeRewindHarness, session: ClaudeRewindSession, ): Promise { - const snapshot = await harness.client.fetchAgent(session.agentId); + const snapshot = await harness.client.fetchAgent({ agentId: session.agentId }); return snapshot?.agent.runtimeInfo?.sessionId ?? snapshot?.agent.persistence?.sessionId ?? null; } @@ -289,7 +289,7 @@ describe("daemon E2E (real claude) - rewind", () => { expect(sessionIdAfter).not.toBe(sessionIdBefore); } - const snapshot = await harness.client.fetchAgent(session.agentId); + const snapshot = await harness.client.fetchAgent({ agentId: session.agentId }); expect(snapshot?.agent.status).toBe("idle"); expect(snapshot?.agent.pendingPermissions).toEqual([]); } finally { diff --git a/packages/server/src/server/daemon-e2e/codex-rewind.real.e2e.test.ts b/packages/server/src/server/daemon-e2e/codex-rewind.real.e2e.test.ts index a3c403105..e61346a6a 100644 --- a/packages/server/src/server/daemon-e2e/codex-rewind.real.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/codex-rewind.real.e2e.test.ts @@ -34,7 +34,7 @@ interface CodexRewindSession { const TURN_TIMEOUT_MS = 180_000; async function fetchThreadId(client: DaemonClient, agentId: string): Promise { - const result = await client.fetchAgent(agentId); + const result = await client.fetchAgent({ agentId }); const threadId = result?.agent.persistence?.sessionId ?? result?.agent.runtimeInfo?.sessionId; if (!threadId) { throw new Error(`Agent ${agentId} does not have a visible Codex thread id`); diff --git a/packages/server/src/server/daemon-e2e/model-runtime-reconcile-claude.real.e2e.test.ts b/packages/server/src/server/daemon-e2e/model-runtime-reconcile-claude.real.e2e.test.ts index cecbeebe9..83fe12e1e 100644 --- a/packages/server/src/server/daemon-e2e/model-runtime-reconcile-claude.real.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/model-runtime-reconcile-claude.real.e2e.test.ts @@ -55,7 +55,7 @@ describe("daemon E2E (real claude) - runtime model reconciliation", () => { const finish = await client.waitForFinish(agent.id, 180_000); expect(finish.status).toBe("idle"); - const snapshot = await client.fetchAgent(agent.id); + const snapshot = await client.fetchAgent({ agentId: agent.id }); expect(snapshot).not.toBeNull(); const runtimeModelId = snapshot?.agent.runtimeInfo?.model ?? null; expect(typeof runtimeModelId).toBe("string"); diff --git a/packages/server/src/server/daemon-e2e/opencode-draft-features.real.e2e.test.ts b/packages/server/src/server/daemon-e2e/opencode-draft-features.real.e2e.test.ts index ac4df109e..d66ca028e 100644 --- a/packages/server/src/server/daemon-e2e/opencode-draft-features.real.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/opencode-draft-features.real.e2e.test.ts @@ -109,7 +109,8 @@ describe("daemon E2E (real opencode) - draft feature discovery", () => { expect(await provider.listImportableSessions?.({ cwd })).toEqual([]); await withConnectedOpenCodeDaemon(provider, async ({ client }) => { - const response = await client.listCommands("draft-opencode-agent", { + const response = await client.listCommands({ + agentId: "draft-opencode-agent", draftConfig: { provider: "opencode", cwd, diff --git a/packages/server/src/server/daemon-e2e/opencode-initial-prompt-wait.real.e2e.test.ts b/packages/server/src/server/daemon-e2e/opencode-initial-prompt-wait.real.e2e.test.ts index 585194628..9fa2f13cf 100644 --- a/packages/server/src/server/daemon-e2e/opencode-initial-prompt-wait.real.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/opencode-initial-prompt-wait.real.e2e.test.ts @@ -66,7 +66,7 @@ describe("daemon E2E (real opencode) - initial prompt wait", () => { expect(finish.status).toBe("idle"); expect(finish.lastMessage).toContain("BIG_PICKLE_OK"); - const snapshot = await client.fetchAgent(agent.id); + const snapshot = await client.fetchAgent({ agentId: agent.id }); expect(snapshot.agent?.status).toBe("idle"); const timeline = await client.fetchAgentTimeline(agent.id, { diff --git a/packages/server/src/server/daemon-e2e/opencode-invalid-mode.real.e2e.test.ts b/packages/server/src/server/daemon-e2e/opencode-invalid-mode.real.e2e.test.ts index aa67c0a32..8235937f6 100644 --- a/packages/server/src/server/daemon-e2e/opencode-invalid-mode.real.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/opencode-invalid-mode.real.e2e.test.ts @@ -57,7 +57,7 @@ describe("daemon E2E (real opencode) - invalid mode handling", () => { expect(finish.status).toBe("error"); expect(finish.error).toBeTruthy(); - const snapshot = await client.fetchAgent(agent.id); + const snapshot = await client.fetchAgent({ agentId: agent.id }); expect(snapshot.agent?.status).toBe("error"); } finally { await client.close().catch(() => undefined); diff --git a/packages/server/src/server/daemon-e2e/opencode-invalid-model.real.e2e.test.ts b/packages/server/src/server/daemon-e2e/opencode-invalid-model.real.e2e.test.ts index 4796f0423..455d09908 100644 --- a/packages/server/src/server/daemon-e2e/opencode-invalid-model.real.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/opencode-invalid-model.real.e2e.test.ts @@ -57,7 +57,7 @@ describe("daemon E2E (real opencode) - invalid model handling", () => { expect(finish.status).toBe("error"); expect(finish.error).toBeTruthy(); - const snapshot = await client.fetchAgent(agent.id); + const snapshot = await client.fetchAgent({ agentId: agent.id }); expect(snapshot.agent?.status).toBe("error"); } finally { await client.close().catch(() => undefined); diff --git a/packages/server/src/server/daemon-e2e/opencode-rewind.real.e2e.test.ts b/packages/server/src/server/daemon-e2e/opencode-rewind.real.e2e.test.ts index 995da59ef..81934b463 100644 --- a/packages/server/src/server/daemon-e2e/opencode-rewind.real.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/opencode-rewind.real.e2e.test.ts @@ -69,7 +69,7 @@ async function runtimeSessionId( harness: OpenCodeRewindHarness, session: OpenCodeRewindSession, ): Promise { - const snapshot = await harness.client.fetchAgent(session.agentId); + const snapshot = await harness.client.fetchAgent({ agentId: session.agentId }); const sessionId = snapshot?.agent.runtimeInfo?.sessionId ?? snapshot?.agent.persistence?.sessionId; if (!sessionId) { @@ -107,7 +107,7 @@ async function recordAgentStatusTransitions( session: OpenCodeRewindSession, ): Promise<{ records: AgentStatusRecord[]; stop: () => void }> { const records: AgentStatusRecord[] = []; - const initial = await harness.client.fetchAgent(session.agentId); + const initial = await harness.client.fetchAgent({ agentId: session.agentId }); if (initial?.agent.status) { records.push({ at: Date.now(), status: initial.agent.status }); } @@ -143,7 +143,7 @@ async function expectAgentIdle( harness: OpenCodeRewindHarness, session: OpenCodeRewindSession, ): Promise { - const snapshot = await harness.client.fetchAgent(session.agentId); + const snapshot = await harness.client.fetchAgent({ agentId: session.agentId }); expect(snapshot?.agent.status).toBe("idle"); } diff --git a/packages/server/src/server/daemon-e2e/opencode-send-interrupt.real.e2e.test.ts b/packages/server/src/server/daemon-e2e/opencode-send-interrupt.real.e2e.test.ts index 64e0ecdd8..bdf5012f6 100644 --- a/packages/server/src/server/daemon-e2e/opencode-send-interrupt.real.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/opencode-send-interrupt.real.e2e.test.ts @@ -100,7 +100,7 @@ async function approvePendingPermissions( agentId: string, handledPermissionIds: Set, ): Promise { - const snapshot = await client.fetchAgent(agentId).catch(() => null); + const snapshot = await client.fetchAgent({ agentId }).catch(() => null); const pending = snapshot?.agent.pendingPermissions ?? []; const toApprove = pending.filter((permission) => !handledPermissionIds.has(permission.id)); for (const permission of toApprove) { diff --git a/packages/server/src/server/daemon-e2e/pi-rewind.real.e2e.test.ts b/packages/server/src/server/daemon-e2e/pi-rewind.real.e2e.test.ts index 848e0fec8..e013328a1 100644 --- a/packages/server/src/server/daemon-e2e/pi-rewind.real.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/pi-rewind.real.e2e.test.ts @@ -50,7 +50,7 @@ async function runtimeSessionId( harness: PiRewindHarness, session: PiRewindSession, ): Promise { - const snapshot = await harness.client.fetchAgent(session.agentId); + const snapshot = await harness.client.fetchAgent({ agentId: session.agentId }); const sessionId = snapshot?.agent.runtimeInfo?.sessionId ?? snapshot?.agent.persistence?.sessionId; if (!sessionId) { diff --git a/packages/server/src/server/daemon-e2e/pi.real.e2e.test.ts b/packages/server/src/server/daemon-e2e/pi.real.e2e.test.ts index 949f976bb..881ef6fbe 100644 --- a/packages/server/src/server/daemon-e2e/pi.real.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/pi.real.e2e.test.ts @@ -149,7 +149,7 @@ test( model: PI_FREE_COMPACTION_TEST_MODEL, }); - const result = await client.listCommands(agent.id); + const result = await client.listCommands({ agentId: agent.id }); expect(result.commands).toEqual( expect.arrayContaining([ expect.objectContaining({ diff --git a/packages/server/src/server/daemon-e2e/send-during-tool-call-claude.real.e2e.test.ts b/packages/server/src/server/daemon-e2e/send-during-tool-call-claude.real.e2e.test.ts index 86e725add..568bf454b 100644 --- a/packages/server/src/server/daemon-e2e/send-during-tool-call-claude.real.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/send-during-tool-call-claude.real.e2e.test.ts @@ -244,7 +244,7 @@ describe("daemon E2E (real claude) - send message during tool call", () => { const timeline = await client.fetchAgentTimeline(agent.id, { limit: 100 }); if (finish.status !== "idle") { - const snapshot = await client.fetchAgent(agent.id); + const snapshot = await client.fetchAgent({ agentId: agent.id }); throw new Error( `Expected idle after replacement, got ${finish.status}. postSendStatuses=${JSON.stringify(postSendStatuses)} statusesBeforeFirstAssistant=${JSON.stringify(statusesBeforeFirstAssistant)} postSendAssistantTexts=${JSON.stringify(postSendAssistantTexts)} turnStarted=${countTurnStarted(postSendMessages, agent.id)} agentStatus=${snapshot?.agent.status ?? null} recentTimeline=${JSON.stringify(summarizeTimelineItems(timeline))}`, ); diff --git a/packages/server/src/server/daemon-e2e/send-during-tool-call-codex.real.e2e.test.ts b/packages/server/src/server/daemon-e2e/send-during-tool-call-codex.real.e2e.test.ts index 5fd5d99ba..e122e846c 100644 --- a/packages/server/src/server/daemon-e2e/send-during-tool-call-codex.real.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/send-during-tool-call-codex.real.e2e.test.ts @@ -204,7 +204,7 @@ describe("daemon E2E (real codex) - send message during tool call", () => { const timeline = await client.fetchAgentTimeline(agent.id, { limit: 100 }); if (finish.status !== "idle") { - const snapshot = await client.fetchAgent(agent.id); + const snapshot = await client.fetchAgent({ agentId: agent.id }); throw new Error( `Expected idle after replacement, got ${finish.status}. postSendStatuses=${JSON.stringify(postSendStatuses)} statusesBeforeFirstAssistant=${JSON.stringify(statusesBeforeFirstAssistant)} postSendAssistantTexts=${JSON.stringify(getAssistantTexts(postSendMessages, agent.id))} agentStatus=${snapshot?.agent.status ?? null} recentTimeline=${JSON.stringify(summarizeTimelineItems(timeline))}`, ); @@ -278,7 +278,7 @@ describe("daemon E2E (real codex) - send message during tool call", () => { const timeline = await client.fetchAgentTimeline(agent.id, { limit: 100 }); if (finish.status !== "idle") { - const snapshot = await client.fetchAgent(agent.id); + const snapshot = await client.fetchAgent({ agentId: agent.id }); throw new Error( `Expected idle after quick follow-up, got ${finish.status}. postSendStatuses=${JSON.stringify(postSendStatuses)} statusesBeforeFirstAssistant=${JSON.stringify(statusesBeforeFirstAssistant)} postSendAssistantTexts=${JSON.stringify(getAssistantTexts(postSendMessages, agent.id))} agentStatus=${snapshot?.agent.status ?? null} recentTimeline=${JSON.stringify(summarizeTimelineItems(timeline))}`, ); diff --git a/packages/server/src/server/daemon-e2e/send-while-running-stuck-claude.real.e2e.test.ts b/packages/server/src/server/daemon-e2e/send-while-running-stuck-claude.real.e2e.test.ts index 1d14fc737..f9f6eb6af 100644 --- a/packages/server/src/server/daemon-e2e/send-while-running-stuck-claude.real.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/send-while-running-stuck-claude.real.e2e.test.ts @@ -114,7 +114,7 @@ describe("daemon E2E (real claude) - send while running recovery", () => { } await secondary.waitForFinish(agent.id, 180_000); - const finalResult = await secondary.fetchAgent(agent.id); + const finalResult = await secondary.fetchAgent({ agentId: agent.id }); if (finalResult) { applySnapshot(finalResult.agent); } diff --git a/packages/server/src/server/daemon-e2e/send-while-running-stuck.real.e2e.test.ts b/packages/server/src/server/daemon-e2e/send-while-running-stuck.real.e2e.test.ts index 1ed3f52f0..aa75a77ee 100644 --- a/packages/server/src/server/daemon-e2e/send-while-running-stuck.real.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/send-while-running-stuck.real.e2e.test.ts @@ -111,7 +111,7 @@ describe("daemon E2E (real codex) - send while running recovery", () => { } await secondary.waitForFinish(agent.id, 120_000); - const finalResult = await secondary.fetchAgent(agent.id); + const finalResult = await secondary.fetchAgent({ agentId: agent.id }); if (finalResult) { applySnapshot(finalResult.agent); } diff --git a/packages/server/src/server/workspace-same-cwd-isolation.e2e.test.ts b/packages/server/src/server/workspace-same-cwd-isolation.e2e.test.ts index f49573bf3..58bf79c66 100644 --- a/packages/server/src/server/workspace-same-cwd-isolation.e2e.test.ts +++ b/packages/server/src/server/workspace-same-cwd-isolation.e2e.test.ts @@ -513,7 +513,7 @@ test("creating another same-cwd local workspace keeps running status on the owni throw new Error(third.error ?? "Expected third workspace to be created"); } - expect((await client.fetchAgent(agent.id))?.agent.status).toBe("running"); + expect((await client.fetchAgent({ agentId: agent.id }))?.agent.status).toBe("running"); expect(await statusByWorkspaceId(client)).toEqual( new Map([ [firstWorkspaceId, "running"], @@ -565,7 +565,7 @@ test("two workspaces sharing one cwd compute agent status per workspaceId", asyn ); await waitForPermission(client, agentA.id); - const fetchedA = await client.fetchAgent(agentA.id); + const fetchedA = await client.fetchAgent({ agentId: agentA.id }); expect(fetchedA?.agent.workspaceId).toBe(WORKSPACE_A); expect(await statusByWorkspaceId(client)).toEqual( @@ -609,7 +609,7 @@ test("two workspaces sharing one cwd compute agent status per workspaceId", asyn ); await waitForPermission(client, agentB.id); - const fetchedB = await client.fetchAgent(agentB.id); + const fetchedB = await client.fetchAgent({ agentId: agentB.id }); expect(fetchedB?.agent.workspaceId).toBe(WORKSPACE_B); expect(await statusByWorkspaceId(client)).toEqual( From e63a97196850556958fa1682de49e3d81aa73a75 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Mon, 29 Jun 2026 17:31:11 +0200 Subject: [PATCH 3/8] Make daemon shutdowns easier to diagnose (#1790) * fix(daemon): log stop reasons and client identity Record websocket client identity, process memory/uptime, and shutdown reasons across CLI, desktop, supervisor, and worker paths so daemon drops can be traced from the triggering client to worker termination. * fix(daemon): keep shutdown diagnostics in sync Test drift: supervisor and relay tests still asserted the old log text and metadata shape after shutdown diagnostics started logging structured reasons and relay connection ids. Update those assertions to the new diagnostic contract. Also centralize client lifecycle reason normalization and derive desktop daemon stop reasons from one tuple so future changes cannot silently drift. --- .../app/src/desktop/daemon/desktop-daemon.ts | 14 +- .../hooks/use-built-in-daemon-management.ts | 2 +- .../app/src/screens/settings/host-page.tsx | 2 +- .../cli/src/commands/daemon/local-daemon.ts | 15 ++ packages/cli/src/commands/daemon/stop.ts | 4 + .../tests/22-daemon-stop-supervisor.test.ts | 20 ++- .../25-daemon-restart-supervisor.test.ts | 10 +- .../tests/33-daemon-stop-tree-kill.test.ts | 6 + .../desktop/src/daemon/daemon-manager.test.ts | 64 ++++++- packages/desktop/src/daemon/daemon-manager.ts | 118 +++++++++++-- packages/desktop/src/main.ts | 2 +- .../server/scripts/supervisor.logging.test.ts | 17 ++ packages/server/scripts/supervisor.ts | 39 +++-- packages/server/src/server/bootstrap.ts | 3 +- packages/server/src/server/daemon-worker.ts | 31 +++- .../server/src/server/lifecycle-reasons.ts | 6 + .../server/src/server/process-diagnostics.ts | 38 ++++ .../server/src/server/relay-transport.test.ts | 1 + packages/server/src/server/relay-transport.ts | 1 + packages/server/src/server/session.ts | 20 ++- .../daemon-self-update-session-controller.ts | 2 +- .../session/daemon/daemon-session.test.ts | 12 ++ .../server/session/daemon/daemon-session.ts | 2 +- .../src/server/session/daemon/diagnostics.ts | 10 ++ .../websocket-server.relay-reconnect.test.ts | 48 +++++- .../server/src/server/websocket-server.ts | 163 +++++++++++++++--- .../src/server/websocket/runtime-metrics.ts | 3 + 27 files changed, 567 insertions(+), 86 deletions(-) create mode 100644 packages/server/src/server/lifecycle-reasons.ts create mode 100644 packages/server/src/server/process-diagnostics.ts diff --git a/packages/app/src/desktop/daemon/desktop-daemon.ts b/packages/app/src/desktop/daemon/desktop-daemon.ts index cabd81388..3c8e0a7ae 100644 --- a/packages/app/src/desktop/daemon/desktop-daemon.ts +++ b/packages/app/src/desktop/daemon/desktop-daemon.ts @@ -2,6 +2,14 @@ import { getDesktopHost, isElectronRuntime } from "@/desktop/host"; import { invokeDesktopCommand } from "@/desktop/electron/invoke"; export type DesktopDaemonState = "starting" | "running" | "stopped" | "errored"; +export type DesktopDaemonStopReason = + | "manual_ipc" + | "settings" + | "host_remove" + | "quit" + | "app_update" + | "version_mismatch" + | "restart"; export interface DesktopDaemonStatus { serverId: string; @@ -122,8 +130,10 @@ export async function startDesktopDaemon(): Promise { return parseDesktopDaemonStatus(await invokeDesktopCommand("start_desktop_daemon")); } -export async function stopDesktopDaemon(): Promise { - return parseDesktopDaemonStatus(await invokeDesktopCommand("stop_desktop_daemon")); +export async function stopDesktopDaemon( + reason: DesktopDaemonStopReason = "manual_ipc", +): Promise { + return parseDesktopDaemonStatus(await invokeDesktopCommand("stop_desktop_daemon", { reason })); } export async function restartDesktopDaemon(): Promise { diff --git a/packages/app/src/desktop/hooks/use-built-in-daemon-management.ts b/packages/app/src/desktop/hooks/use-built-in-daemon-management.ts index 2f8f30360..cb2e2e392 100644 --- a/packages/app/src/desktop/hooks/use-built-in-daemon-management.ts +++ b/packages/app/src/desktop/hooks/use-built-in-daemon-management.ts @@ -60,7 +60,7 @@ export function useBuiltInDaemonManagement( }), persistSettings: (next) => updateSettings(next) as Promise, startDaemon: startDesktopDaemon, - stopDaemon: stopDesktopDaemon, + stopDaemon: () => stopDesktopDaemon("settings"), }); if (result.kind === "enabled") { const upsertResult = await upsertDesktopDaemonConnection( diff --git a/packages/app/src/screens/settings/host-page.tsx b/packages/app/src/screens/settings/host-page.tsx index 8f39fd0be..ddefea1e9 100644 --- a/packages/app/src/screens/settings/host-page.tsx +++ b/packages/app/src/screens/settings/host-page.tsx @@ -1244,7 +1244,7 @@ function RemoveHostSection({ await updateSettings({ daemon: { manageBuiltInDaemon: false } }); didDisableDaemonManagement = true; if (daemonStatus?.status === "running" && daemonStatus.desktopManaged) { - setStatus(await stopDesktopDaemon()); + setStatus(await stopDesktopDaemon("host_remove")); didStopDaemon = true; } await removeHost(host.serverId); diff --git a/packages/cli/src/commands/daemon/local-daemon.ts b/packages/cli/src/commands/daemon/local-daemon.ts index 7423c275c..06f124e13 100644 --- a/packages/cli/src/commands/daemon/local-daemon.ts +++ b/packages/cli/src/commands/daemon/local-daemon.ts @@ -59,6 +59,8 @@ export interface StopLocalDaemonResult { home: string; pid: number | null; forced: boolean; + usedLifecycleRpc: boolean; + reason: "not_running" | "lifecycle_shutdown_rpc" | "owner_pid_signal" | "owner_pid_sigkill"; message: string; } @@ -230,6 +232,15 @@ function resolveStopMessage( return fallbackMessage ?? "Daemon stopped via owner PID signal"; } +function resolveStopReason( + forced: boolean, + lifecycleRequested: boolean, +): StopLocalDaemonResult["reason"] { + if (forced) return "owner_pid_sigkill"; + if (lifecycleRequested) return "lifecycle_shutdown_rpc"; + return "owner_pid_signal"; +} + function readPidFile(pidPath: string): LocalDaemonPidInfo | null { try { const parsed = JSON.parse(readFileSync(pidPath, "utf-8")) as Record; @@ -421,6 +432,8 @@ function createNotRunningStopResult( home: state.home, pid, forced: false, + usedLifecycleRpc: false, + reason: "not_running", message, }; } @@ -741,6 +754,8 @@ export async function stopLocalDaemon( home: state.home, pid, forced, + usedLifecycleRpc: lifecycleRequested, + reason: resolveStopReason(forced, lifecycleRequested), message: resolveStopMessage(forced, lifecycleRequested, fallbackMessage), }; } diff --git a/packages/cli/src/commands/daemon/stop.ts b/packages/cli/src/commands/daemon/stop.ts index b893f6677..76351bb07 100644 --- a/packages/cli/src/commands/daemon/stop.ts +++ b/packages/cli/src/commands/daemon/stop.ts @@ -16,6 +16,8 @@ interface StopResult { home: string; pid: string; forced: boolean; + usedLifecycleRpc: boolean; + reason: "not_running" | "lifecycle_shutdown_rpc" | "owner_pid_signal" | "owner_pid_sigkill"; message: string; } @@ -75,6 +77,8 @@ export async function runStopCommand( home: result.home, pid: result.pid === null ? "-" : String(result.pid), forced: result.forced, + usedLifecycleRpc: result.usedLifecycleRpc, + reason: result.reason, message: result.message, }, schema: stopResultSchema, diff --git a/packages/cli/tests/22-daemon-stop-supervisor.test.ts b/packages/cli/tests/22-daemon-stop-supervisor.test.ts index 1dc2abb91..277e9fd4a 100644 --- a/packages/cli/tests/22-daemon-stop-supervisor.test.ts +++ b/packages/cli/tests/22-daemon-stop-supervisor.test.ts @@ -84,6 +84,11 @@ async function readDaemonStatus(paseoHome: string): Promise { } } +async function readCapturedSupervisorLogs(paseoHome: string, recentLogs: string): Promise { + const durableLogs = await readFile(join(paseoHome, "daemon.log"), "utf8").catch(() => ""); + return `${recentLogs}\n${durableLogs}`; +} + async function waitFor( check: () => Promise | boolean, timeoutMs: number, @@ -206,13 +211,20 @@ try { "stopped", "daemon should remain stopped after stop command", ); + const capturedSupervisorLogs = await readCapturedSupervisorLogs(paseoHome, recentSupervisorLogs); assert( - recentSupervisorLogs.includes("Shutdown requested by worker. Stopping worker..."), - `stop should request lifecycle shutdown from daemon worker, logs:\n${recentSupervisorLogs}`, + capturedSupervisorLogs.includes('"msg":"Worker requested shutdown"') && + capturedSupervisorLogs.includes('"reason":"client_shutdown_rpc"'), + `stop should log lifecycle shutdown reason from daemon worker, logs:\n${capturedSupervisorLogs}`, ); assert( - !recentSupervisorLogs.includes("cli_shutdown"), - `supervisor logs should not route shutdown by reason string:\n${recentSupervisorLogs}`, + capturedSupervisorLogs.includes('"msg":"Supervisor sending signal to worker"') && + capturedSupervisorLogs.includes('"signal":"SIGTERM"'), + `stop should log supervisor signal dispatch, logs:\n${capturedSupervisorLogs}`, + ); + assert( + !capturedSupervisorLogs.includes("cli_shutdown"), + `supervisor logs should not route shutdown by reason string:\n${capturedSupervisorLogs}`, ); console.log("✓ stop leaves supervised daemon stopped (no respawn)\n"); } finally { diff --git a/packages/cli/tests/25-daemon-restart-supervisor.test.ts b/packages/cli/tests/25-daemon-restart-supervisor.test.ts index 4feb337f5..10e559a2d 100644 --- a/packages/cli/tests/25-daemon-restart-supervisor.test.ts +++ b/packages/cli/tests/25-daemon-restart-supervisor.test.ts @@ -229,8 +229,14 @@ try { ); const capturedSupervisorLogs = await readCapturedSupervisorLogs(paseoHome, recentSupervisorLogs); assert( - capturedSupervisorLogs.includes("Restart requested by worker. Stopping worker for restart..."), - `restart should route through supervisor restart intent, logs:\n${capturedSupervisorLogs}`, + capturedSupervisorLogs.includes('"msg":"Worker requested restart"') && + capturedSupervisorLogs.includes('"reason":"settings_update"'), + `restart should log lifecycle restart reason from daemon worker, logs:\n${capturedSupervisorLogs}`, + ); + assert( + capturedSupervisorLogs.includes('"msg":"Supervisor sending signal to worker"') && + capturedSupervisorLogs.includes('"signal":"SIGTERM"'), + `restart should log supervisor signal dispatch, logs:\n${capturedSupervisorLogs}`, ); console.log("✓ app-style restart keeps daemon healthy and restarts worker\n"); } finally { diff --git a/packages/cli/tests/33-daemon-stop-tree-kill.test.ts b/packages/cli/tests/33-daemon-stop-tree-kill.test.ts index 8a60fbcb9..5ee8af7b6 100644 --- a/packages/cli/tests/33-daemon-stop-tree-kill.test.ts +++ b/packages/cli/tests/33-daemon-stop-tree-kill.test.ts @@ -144,17 +144,23 @@ try { const parsed = JSON.parse(stopResult.stdout) as { action?: unknown; forced?: unknown; + usedLifecycleRpc?: unknown; + reason?: unknown; message?: unknown; }; assert.deepStrictEqual( { action: parsed.action, forced: parsed.forced, + usedLifecycleRpc: parsed.usedLifecycleRpc, + reason: parsed.reason, message: parsed.message, }, { action: "stopped", forced: true, + usedLifecycleRpc: false, + reason: "owner_pid_sigkill", message: "Daemon owner process was force-stopped", }, `stop should report forced tree cleanup: ${stopResult.stdout}`, diff --git a/packages/desktop/src/daemon/daemon-manager.test.ts b/packages/desktop/src/daemon/daemon-manager.test.ts index 443e9f9fb..695fbf72a 100644 --- a/packages/desktop/src/daemon/daemon-manager.test.ts +++ b/packages/desktop/src/daemon/daemon-manager.test.ts @@ -17,6 +17,8 @@ const mocks = vi.hoisted(() => ({ runExternalCliJsonCommand: vi.fn(), runExternalCliTextCommand: vi.fn(), spawnProcess: vi.fn(), + logInfo: vi.fn(), + logError: vi.fn(), })); vi.mock("electron", () => ({ @@ -30,7 +32,7 @@ vi.mock("electron", () => ({ })); vi.mock("electron-log/main", () => ({ - default: { info: vi.fn(), error: vi.fn() }, + default: { info: mocks.logInfo, error: mocks.logError }, })); vi.mock("@getpaseo/server", () => ({ @@ -101,6 +103,8 @@ describe("daemon-manager commands", () => { mocks.runExternalCliJsonCommand.mockReset(); mocks.runExternalCliTextCommand.mockReset(); mocks.spawnProcess.mockReset(); + mocks.logInfo.mockReset(); + mocks.logError.mockReset(); rmSync(mocks.paseoHome, { recursive: true, force: true }); }); @@ -194,6 +198,31 @@ describe("daemon-manager commands", () => { "status", "--json", ]); + expect(mocks.logInfo).toHaveBeenCalledWith( + "[desktop daemon]", + "desktop daemon stop requested", + expect.objectContaining({ + reason: "manual_ipc", + statusBefore: expect.objectContaining({ + status: "running", + pid: 4242, + serverId: "server-1", + desktopManaged: true, + }), + }), + ); + expect(mocks.logInfo).toHaveBeenCalledWith( + "[desktop daemon]", + "desktop daemon stop completed", + expect.objectContaining({ + reason: "manual_ipc", + cliResult: { action: "stopped" }, + statusAfter: expect.objectContaining({ + status: "stopped", + serverId: null, + }), + }), + ); }); it("routes stale reachable desktop daemon stops through external CLI daemon stop", async () => { @@ -239,6 +268,39 @@ describe("daemon-manager commands", () => { ]); }); + it("records the renderer stop reason when stopping the desktop daemon", async () => { + mocks.runExternalCliJsonCommand + .mockResolvedValueOnce({ + localDaemon: "running", + serverId: "server-1", + pid: 4242, + listen: "127.0.0.1:6767", + desktopManaged: true, + }) + .mockResolvedValueOnce({ action: "stopped", reason: "lifecycle_shutdown_rpc" }) + .mockResolvedValueOnce({ + localDaemon: "stopped", + serverId: "", + }); + const handlers = createDaemonCommandHandlers(); + + await handlers.stop_desktop_daemon({ reason: "host_remove" }); + + expect(mocks.logInfo).toHaveBeenCalledWith( + "[desktop daemon]", + "desktop daemon stop requested", + expect.objectContaining({ reason: "host_remove" }), + ); + expect(mocks.logInfo).toHaveBeenCalledWith( + "[desktop daemon]", + "desktop daemon stop completed", + expect.objectContaining({ + reason: "host_remove", + cliResult: { action: "stopped", reason: "lifecycle_shutdown_rpc" }, + }), + ); + }); + it("uses a stale reachable desktop daemon when the version matches", async () => { mocks.runExternalCliJsonCommand.mockResolvedValue({ localDaemon: "stale_pid", diff --git a/packages/desktop/src/daemon/daemon-manager.ts b/packages/desktop/src/daemon/daemon-manager.ts index a80899076..10bae9a95 100644 --- a/packages/desktop/src/daemon/daemon-manager.ts +++ b/packages/desktop/src/daemon/daemon-manager.ts @@ -46,6 +46,19 @@ const STARTUP_POLL_MAX_ATTEMPTS = 150; const DETACHED_STARTUP_GRACE_MS = 1200; type DesktopDaemonState = "starting" | "running" | "stopped" | "errored"; +const DESKTOP_DAEMON_STOP_REASON_VALUES = [ + "manual_ipc", + "settings", + "host_remove", + "quit", + "app_update", + "version_mismatch", + "restart", +] as const; +export type DesktopDaemonStopReason = (typeof DESKTOP_DAEMON_STOP_REASON_VALUES)[number]; + +const DESKTOP_DAEMON_STOP_REASONS = new Set(DESKTOP_DAEMON_STOP_REASON_VALUES); +const DEFAULT_DESKTOP_DAEMON_STOP_REASON: DesktopDaemonStopReason = "manual_ipc"; export interface DesktopDaemonStatus { serverId: string; @@ -88,6 +101,16 @@ function parseAppUpdateCheckIntent( return args?.intent === "manual" ? "manual" : "automatic"; } +function parseDesktopDaemonStopReason( + args: Record | undefined, +): DesktopDaemonStopReason { + const reason = args?.reason; + if (typeof reason === "string" && DESKTOP_DAEMON_STOP_REASONS.has(reason)) { + return reason as DesktopDaemonStopReason; + } + return DEFAULT_DESKTOP_DAEMON_STOP_REASON; +} + // --------------------------------------------------------------------------- // Utilities // --------------------------------------------------------------------------- @@ -112,17 +135,62 @@ export function isDesktopManagedDaemonRunningSync(): boolean { } } -export async function stopDesktopDaemonViaCli(): Promise { - await runExternalCliJsonCommand([ - "daemon", - "stop", - "--json", - "--timeout", - "5", - "--force", - "--kill-timeout", - "5", - ]); +function summarizeDesktopDaemonStatus(status: DesktopDaemonStatus): Record { + return { + status: status.status, + pid: status.pid, + listen: status.listen, + serverId: status.serverId || null, + version: status.version, + desktopManaged: status.desktopManaged, + error: status.error, + }; +} + +const DESKTOP_DAEMON_STOP_CLI_ARGS = [ + "daemon", + "stop", + "--json", + "--timeout", + "5", + "--force", + "--kill-timeout", + "5", +]; + +async function runDesktopDaemonStopViaCli({ + reason, + statusBefore, + resolveStatusAfter = false, +}: { + reason: DesktopDaemonStopReason; + statusBefore?: DesktopDaemonStatus | null; + resolveStatusAfter?: boolean; +}): Promise<{ + cliResult: unknown; + statusAfter: DesktopDaemonStatus | null; +}> { + logDesktopDaemonLifecycle("desktop daemon stop requested", { + reason, + statusBefore: statusBefore ? summarizeDesktopDaemonStatus(statusBefore) : null, + }); + + const cliResult = await runExternalCliJsonCommand(DESKTOP_DAEMON_STOP_CLI_ARGS); + const statusAfter = resolveStatusAfter ? await resolveDesktopDaemonStatus() : null; + + logDesktopDaemonLifecycle("desktop daemon stop completed", { + reason, + cliResult, + statusAfter: statusAfter ? summarizeDesktopDaemonStatus(statusAfter) : null, + }); + + return { cliResult, statusAfter }; +} + +export async function stopDesktopDaemonViaCli( + reason: DesktopDaemonStopReason = DEFAULT_DESKTOP_DAEMON_STOP_REASON, +): Promise { + await runDesktopDaemonStopViaCli({ reason }); } function isProcessRunning(pid: number): boolean { @@ -317,7 +385,7 @@ async function startDaemon(): Promise { appVersion: normalizeVersion(resolveDesktopAppVersion()), daemonVersion: normalizeVersion(current.version), }); - await stopDesktopDaemon(); + await stopDesktopDaemon("version_mismatch"); } else { return current; } @@ -405,17 +473,29 @@ async function startDaemon(): Promise { return pollForRunningDaemon(); } -export async function stopDesktopDaemon(): Promise { +export async function stopDesktopDaemon( + reason: DesktopDaemonStopReason = DEFAULT_DESKTOP_DAEMON_STOP_REASON, +): Promise { const status = await resolveDesktopDaemonStatus(); - if (status.status !== "running") return status; + if (status.status !== "running") { + logDesktopDaemonLifecycle("desktop daemon stop skipped", { + reason, + statusBefore: summarizeDesktopDaemonStatus(status), + }); + return status; + } - await stopDesktopDaemonViaCli(); - return await resolveDesktopDaemonStatus(); + const { statusAfter } = await runDesktopDaemonStopViaCli({ + reason, + statusBefore: status, + resolveStatusAfter: true, + }); + return statusAfter ?? (await resolveDesktopDaemonStatus()); } async function restartDaemon(): Promise { assertBuiltInDaemonManagementEnabled(await getDesktopSettingsStore().get()); - await stopDesktopDaemon(); + await stopDesktopDaemon("restart"); return startDaemon(); } @@ -491,7 +571,7 @@ export function createDaemonCommandHandlers(): Record resolveDesktopDaemonStatus(), start_desktop_daemon: () => startDaemon(), - stop_desktop_daemon: () => stopDesktopDaemon(), + stop_desktop_daemon: (args) => stopDesktopDaemon(parseDesktopDaemonStopReason(args)), restart_desktop_daemon: () => restartDaemon(), desktop_daemon_logs: () => getDaemonLogs(), desktop_daemon_pairing: () => getDaemonPairing(), @@ -532,7 +612,7 @@ export function createDaemonCommandHandlers(): Record { - await stopDesktopDaemon(); + await stopDesktopDaemon("app_update"); }, ); }, diff --git a/packages/desktop/src/main.ts b/packages/desktop/src/main.ts index 4f4cdedbc..ac9ba73eb 100644 --- a/packages/desktop/src/main.ts +++ b/packages/desktop/src/main.ts @@ -755,7 +755,7 @@ app.on( stopDesktopManagedDaemonOnQuitIfNeeded({ settingsStore: getDesktopSettingsStore(), isDesktopManagedDaemonRunning: isDesktopManagedDaemonRunningSync, - stopDaemon: stopDesktopDaemonViaCli, + stopDaemon: () => stopDesktopDaemonViaCli("quit"), showShutdownFeedback: showDaemonShutdownDialog, }), onStopError: (error) => { diff --git a/packages/server/scripts/supervisor.logging.test.ts b/packages/server/scripts/supervisor.logging.test.ts index 3d42179fd..3d1d926f6 100644 --- a/packages/server/scripts/supervisor.logging.test.ts +++ b/packages/server/scripts/supervisor.logging.test.ts @@ -169,6 +169,23 @@ describe("supervisor durable logging", () => { expect(result.log).toContain("raw stderr line\n"); }); + test("logs the worker shutdown reason before signaling the worker", async () => { + const result = await runSupervisorFixture({ + workerSource: ` + process.send?.({ type: "paseo:shutdown", reason: "client_shutdown_rpc" }); + setInterval(() => {}, 1000); + `, + }); + + expect(result.code).toBe(0); + expect(result.signal).toBeNull(); + expect(result.log).toContain('"msg":"Worker requested shutdown"'); + expect(result.log).toContain('"reason":"client_shutdown_rpc"'); + expect(result.log).toContain('"msg":"Supervisor sending signal to worker"'); + expect(result.log).toContain('"signal":"SIGTERM"'); + expect(result.log).toContain('"workerPid":'); + }); + // POSIX-only: Windows reports the worker self-kill as an exit code, not SIGKILL. test.skipIf(isPlatform("win32"))( "logs worker signal exits even when the worker cannot log", diff --git a/packages/server/scripts/supervisor.ts b/packages/server/scripts/supervisor.ts index 1f8a78656..6e7c1b032 100644 --- a/packages/server/scripts/supervisor.ts +++ b/packages/server/scripts/supervisor.ts @@ -14,6 +14,7 @@ interface SupervisorLogFileOptions { type WorkerLifecycleMessage = | { type: "paseo:shutdown"; + reason?: string; } | { type: "paseo:ready"; @@ -56,7 +57,11 @@ function parseLifecycleMessage(msg: unknown): WorkerLifecycleMessage | null { } const type = (msg as { type?: unknown }).type; if (type === "paseo:shutdown") { - return { type: "paseo:shutdown" }; + const reason = (msg as { reason?: unknown }).reason; + return { + type: "paseo:shutdown", + ...(typeof reason === "string" && reason.trim().length > 0 ? { reason } : {}), + }; } if (type === "paseo:ready") { const listen = (msg as { listen?: unknown }).listen; @@ -236,16 +241,15 @@ export function runSupervisor(options: SupervisorOptions): void { } if (lifecycleMessage.type === "paseo:shutdown") { - writeLifecycleLog("Worker requested shutdown"); - requestShutdown("Shutdown requested by worker"); + const reason = lifecycleMessage.reason ?? "worker_requested_shutdown"; + writeLifecycleLog("Worker requested shutdown", { reason }); + requestShutdown(reason); return; } - writeLifecycleLog( - "Worker requested restart", - lifecycleMessage.reason ? { reason: lifecycleMessage.reason } : {}, - ); - requestRestart("Restart requested by worker"); + const reason = lifecycleMessage.reason ?? "worker_requested_restart"; + writeLifecycleLog("Worker requested restart", { reason }); + requestRestart(reason); }); child.on("close", (code, signal) => { @@ -279,6 +283,19 @@ export function runSupervisor(options: SupervisorOptions): void { }); }; + const signalWorker = (signal: NodeJS.Signals, reason: string): void => { + if (!child) { + return; + } + writeLifecycleLog("Supervisor sending signal to worker", { + reason, + signal, + supervisorPid: process.pid, + workerPid: child.pid ?? null, + }); + child.kill(signal); + }; + const requestRestart = (reason: string) => { if (!child || restarting || shuttingDown) { return; @@ -286,7 +303,7 @@ export function runSupervisor(options: SupervisorOptions): void { restarting = true; writeLifecycleLog("Restart requested", { reason }); log(`${reason}. Stopping worker for restart...`); - child.kill("SIGTERM"); + signalWorker("SIGTERM", reason); }; const requestShutdown = (reason: string) => { @@ -301,11 +318,11 @@ export function runSupervisor(options: SupervisorOptions): void { exitSupervisor(0); return; } - child.kill("SIGTERM"); + signalWorker("SIGTERM", reason); }; const forwardSignal = (signal: NodeJS.Signals) => { - requestShutdown(`Received ${signal}`); + requestShutdown(`supervisor_received_${signal}`); }; process.on("SIGINT", () => forwardSignal("SIGINT")); diff --git a/packages/server/src/server/bootstrap.ts b/packages/server/src/server/bootstrap.ts index 87690c564..f704c3992 100644 --- a/packages/server/src/server/bootstrap.ts +++ b/packages/server/src/server/bootstrap.ts @@ -311,12 +311,13 @@ export type DaemonLifecycleIntent = type: "shutdown"; clientId: string; requestId: string; + reason: string; } | { type: "restart"; clientId: string; requestId: string; - reason?: string; + reason: string; }; export interface PaseoDaemonConfig { diff --git a/packages/server/src/server/daemon-worker.ts b/packages/server/src/server/daemon-worker.ts index 188b9b35e..b0909f048 100644 --- a/packages/server/src/server/daemon-worker.ts +++ b/packages/server/src/server/daemon-worker.ts @@ -5,12 +5,14 @@ import { loadConfig } from "./config.js"; import { resolvePaseoHome } from "./paseo-home.js"; import { createRootLogger } from "./logger.js"; import type { DaemonLifecycleIntent } from "./bootstrap.js"; +import { getProcessDiagnostics } from "./process-diagnostics.js"; process.title = "Paseo Daemon"; type SupervisorLifecycleMessage = | { type: "paseo:shutdown"; + reason: string; } | { type: "paseo:ready"; @@ -123,15 +125,23 @@ async function main() { const beginShutdown = ( signal: string, options?: { + reason?: string; successExitCode?: number; }, ) => { + const reason = options?.reason ?? `worker_received_${signal}`; if (!shutdownPromise) { - logger.info(`${signal} received, shutting down gracefully...`); + logger.info( + { signal, reason, ...getProcessDiagnostics() }, + `${signal} received, shutting down gracefully...`, + ); shutdownPromise = (async () => { const forceExit = setTimeout(() => { - logger.warn("Forcing shutdown - HTTP server didn't close in time"); + logger.warn( + { signal, reason, ...getProcessDiagnostics() }, + "Forcing shutdown - HTTP server didn't close in time", + ); process.exit(1); }, 10000); @@ -152,7 +162,10 @@ async function main() { } })(); } else { - logger.info(`${signal} received while shutdown is already in progress`); + logger.info( + { signal, reason, ...getProcessDiagnostics() }, + `${signal} received while shutdown is already in progress`, + ); } installExitHook(); @@ -174,13 +187,13 @@ async function main() { const handleLifecycleIntent = (intent: DaemonLifecycleIntent) => { if (intent.type === "shutdown") { logger.warn( - { clientId: intent.clientId, requestId: intent.requestId }, + { clientId: intent.clientId, requestId: intent.requestId, reason: intent.reason }, "Shutdown requested via websocket", ); - if (sendSupervisorLifecycleMessage({ type: "paseo:shutdown" })) { + if (sendSupervisorLifecycleMessage({ type: "paseo:shutdown", reason: intent.reason })) { return; } - beginShutdown("shutdown lifecycle intent"); + beginShutdown("shutdown lifecycle intent", { reason: intent.reason }); return; } @@ -196,7 +209,10 @@ async function main() { ) { return; } - beginShutdown("restart lifecycle intent", { successExitCode: 0 }); + beginShutdown("restart lifecycle intent", { + reason: intent.reason, + successExitCode: 0, + }); }; const installSupervisorLivenessGuard = () => { @@ -215,6 +231,7 @@ async function main() { writeWorkerLifecycleLog(paseoHome, "Supervisor liveness lost; worker exiting", { reason, + ...getProcessDiagnostics(), supervisorPid, currentParentPid: process.ppid, ipcConnected: typeof process.connected === "boolean" ? process.connected : null, diff --git a/packages/server/src/server/lifecycle-reasons.ts b/packages/server/src/server/lifecycle-reasons.ts new file mode 100644 index 000000000..08c620db2 --- /dev/null +++ b/packages/server/src/server/lifecycle-reasons.ts @@ -0,0 +1,6 @@ +export const CLIENT_SHUTDOWN_RPC_REASON = "client_shutdown_rpc"; +export const DEFAULT_CLIENT_RESTART_RPC_REASON = "client_restart_rpc"; + +export function normalizeClientRestartRpcReason(reason: string | undefined): string { + return reason?.trim() || DEFAULT_CLIENT_RESTART_RPC_REASON; +} diff --git a/packages/server/src/server/process-diagnostics.ts b/packages/server/src/server/process-diagnostics.ts new file mode 100644 index 000000000..fbb78181c --- /dev/null +++ b/packages/server/src/server/process-diagnostics.ts @@ -0,0 +1,38 @@ +export interface ProcessMemoryDiagnostics { + rss: number; + heapTotal: number; + heapUsed: number; + external: number; + arrayBuffers: number; +} + +export interface ProcessDiagnostics { + pid: number; + ppid: number; + uptimeSeconds: number; + memory: ProcessMemoryDiagnostics; +} + +export function getProcessMemoryDiagnostics(): ProcessMemoryDiagnostics { + const memory = process.memoryUsage(); + return { + rss: memory.rss, + heapTotal: memory.heapTotal, + heapUsed: memory.heapUsed, + external: memory.external, + arrayBuffers: memory.arrayBuffers, + }; +} + +export function getProcessUptimeSeconds(): number { + return Math.round(process.uptime() * 1000) / 1000; +} + +export function getProcessDiagnostics(): ProcessDiagnostics { + return { + pid: process.pid, + ppid: process.ppid, + uptimeSeconds: getProcessUptimeSeconds(), + memory: getProcessMemoryDiagnostics(), + }; +} diff --git a/packages/server/src/server/relay-transport.test.ts b/packages/server/src/server/relay-transport.test.ts index c4e40f6c8..fb1ddfe4b 100644 --- a/packages/server/src/server/relay-transport.test.ts +++ b/packages/server/src/server/relay-transport.test.ts @@ -234,6 +234,7 @@ describe("relay-transport control lifecycle", () => { { transport: "relay", externalSessionKey: "session:clt_test", + relayConnectionId: "clt_test", }, ]); }); diff --git a/packages/server/src/server/relay-transport.ts b/packages/server/src/server/relay-transport.ts index fcd4fad79..4aeca54f3 100644 --- a/packages/server/src/server/relay-transport.ts +++ b/packages/server/src/server/relay-transport.ts @@ -387,6 +387,7 @@ export function startRelayTransport({ const externalMetadata: ExternalSocketMetadata = { transport: "relay", externalSessionKey: `session:${connectionId}`, + relayConnectionId: connectionId, }; if (daemonKeyPair) { void attachEncryptedSocket( diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index 91df7536a..7b7a0931d 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -61,6 +61,10 @@ import { getErrorMessage, getErrorMessageOr } from "@getpaseo/protocol/error-uti import { getAgentStatusPriority } from "@getpaseo/protocol/agent-state-bucket"; import { getParentAgentIdFromLabels } from "@getpaseo/protocol/agent-labels"; import type { WorkspaceGitRuntimeSnapshot, WorkspaceGitService } from "./workspace-git-service.js"; +import { + CLIENT_SHUTDOWN_RPC_REASON, + normalizeClientRestartRpcReason, +} from "./lifecycle-reasons.js"; import { AgentManager } from "./agent/agent-manager.js"; import { ProviderSnapshotManager } from "./agent/provider-snapshot-manager.js"; @@ -472,12 +476,13 @@ export type SessionLifecycleIntent = type: "shutdown"; clientId: string; requestId: string; + reason: string; } | { type: "restart"; clientId: string; requestId: string; - reason?: string; + reason: string; }; function parseClientCapabilities( @@ -1731,6 +1736,10 @@ export class Session { this.peakInflightRequests = this.inflightRequests; } + public getSessionId(): string { + return this.sessionId; + } + public async handleBinaryFrame(binaryFrame: BinaryFrame): Promise { if (binaryFrame.kind === "file_transfer") { await this.workspaceFilesSession.handleFileTransferFrame(binaryFrame.frame); @@ -1740,6 +1749,7 @@ export class Session { } private async handleRestartServerRequest(requestId: string, reason?: string): Promise { + const lifecycleReason = normalizeClientRestartRpcReason(reason); const payload: { status: string } & Record = { status: "restart_requested", clientId: this.clientId, @@ -1749,7 +1759,7 @@ export class Session { } payload.requestId = requestId; - this.sessionLogger.warn({ reason }, "Restart requested via websocket"); + this.sessionLogger.warn({ reason: lifecycleReason }, "Restart requested via websocket"); this.emit({ type: "status", payload, @@ -1759,12 +1769,13 @@ export class Session { type: "restart", clientId: this.clientId, requestId, - ...(reason ? { reason } : {}), + reason: lifecycleReason, }); } private async handleShutdownServerRequest(requestId: string): Promise { - this.sessionLogger.warn("Shutdown requested via websocket"); + const reason = CLIENT_SHUTDOWN_RPC_REASON; + this.sessionLogger.warn({ reason }, "Shutdown requested via websocket"); this.emit({ type: "status", payload: { @@ -1778,6 +1789,7 @@ export class Session { type: "shutdown", clientId: this.clientId, requestId, + reason, }); } diff --git a/packages/server/src/server/session/daemon/daemon-self-update-session-controller.ts b/packages/server/src/server/session/daemon/daemon-self-update-session-controller.ts index 9f7975e1c..0cfb6a43d 100644 --- a/packages/server/src/server/session/daemon/daemon-self-update-session-controller.ts +++ b/packages/server/src/server/session/daemon/daemon-self-update-session-controller.ts @@ -18,7 +18,7 @@ interface DaemonSelfUpdateRestartIntent { type: "restart"; clientId: string; requestId: string; - reason?: string; + reason: string; } export interface DaemonSelfUpdateSessionControllerOptions { diff --git a/packages/server/src/server/session/daemon/daemon-session.test.ts b/packages/server/src/server/session/daemon/daemon-session.test.ts index 22ca4c43d..9417a17ed 100644 --- a/packages/server/src/server/session/daemon/daemon-session.test.ts +++ b/packages/server/src/server/session/daemon/daemon-session.test.ts @@ -261,6 +261,14 @@ describe("DaemonSession", () => { getWebSocketRuntimeMetrics: () => ({ collectedAt: "2026-01-02T03:04:05.000Z", windowMs: 30_000, + uptimeSeconds: 12.345, + memory: { + rss: 1024 * 1024 * 64, + heapTotal: 1024 * 1024 * 32, + heapUsed: 1024 * 1024 * 12, + external: 1024 * 1024 * 3, + arrayBuffers: 1024 * 512, + }, final: false, sessions: { activeConnections: 2, @@ -349,6 +357,10 @@ describe("DaemonSession", () => { } expect(message.payload.diagnostic).toContain("WebSocket runtime metrics"); expect(message.payload.diagnostic).toContain("Collected at: 2026-01-02T03:04:05.000Z"); + expect(message.payload.diagnostic).toContain("Process uptime: 12s"); + expect(message.payload.diagnostic).toContain( + "Process memory: rss=64.0 MiB, heap=12.0 MiB / 32.0 MiB", + ); expect(message.payload.diagnostic).toContain( "Sessions: active=2, externalKeys=3, reconnectGrace=1", ); diff --git a/packages/server/src/server/session/daemon/daemon-session.ts b/packages/server/src/server/session/daemon/daemon-session.ts index 2669afdc0..bc9e89dec 100644 --- a/packages/server/src/server/session/daemon/daemon-session.ts +++ b/packages/server/src/server/session/daemon/daemon-session.ts @@ -29,7 +29,7 @@ export interface DaemonSessionHost { type: "restart"; clientId: string; requestId: string; - reason?: string; + reason: string; }): void; } diff --git a/packages/server/src/server/session/daemon/diagnostics.ts b/packages/server/src/server/session/daemon/diagnostics.ts index 468e1aac1..eccffcd23 100644 --- a/packages/server/src/server/session/daemon/diagnostics.ts +++ b/packages/server/src/server/session/daemon/diagnostics.ts @@ -248,6 +248,16 @@ function collectWebSocketRuntimeEntries(options: DaemonDiagnosticsOptions): Diag return [ { label: "Collected at", value: snapshot.collectedAt }, { label: "Window", value: formatDurationMs(snapshot.windowMs) }, + { label: "Process uptime", value: formatDurationMs(snapshot.uptimeSeconds * 1000) }, + { + label: "Process memory", + value: [ + `rss=${formatBytes(snapshot.memory.rss)}`, + `heap=${formatBytes(snapshot.memory.heapUsed)} / ${formatBytes(snapshot.memory.heapTotal)}`, + `external=${formatBytes(snapshot.memory.external)}`, + `arrayBuffers=${formatBytes(snapshot.memory.arrayBuffers)}`, + ].join(", "), + }, { label: "Final", value: String(snapshot.final) }, { label: "Sessions", diff --git a/packages/server/src/server/websocket-server.relay-reconnect.test.ts b/packages/server/src/server/websocket-server.relay-reconnect.test.ts index d4a456ada..22e7787bf 100644 --- a/packages/server/src/server/websocket-server.relay-reconnect.test.ts +++ b/packages/server/src/server/websocket-server.relay-reconnect.test.ts @@ -52,6 +52,7 @@ const sessionMock = vi.hoisted(() => { handleBinaryFrame = vi.fn((_frame: unknown) => {}); supports = vi.fn((capability: string) => this.args.clientCapabilities?.[capability] === true); getClientActivity = vi.fn(() => null); + getSessionId = vi.fn(() => "mock-session-id"); resetPeakInflight = vi.fn(() => {}); getRuntimeMetrics = vi.fn(() => ({ checkoutDiffTargetCount: 0, @@ -214,14 +215,18 @@ function createLogger() { return logger; } -function createServer(options?: { speechReadiness?: SpeechReadinessSnapshot | null }) { +function createServer(options?: { + speechReadiness?: SpeechReadinessSnapshot | null; + logger?: ReturnType; +}) { const speechReadiness = options?.speechReadiness ?? null; const daemonConfigStore = { onChange: vi.fn(() => () => {}), }; + const logger = options?.logger ?? createLogger(); return new VoiceAssistantWebSocketServer( createStub({}), - createStub(createLogger()), + createStub(logger), "srv_test", createStub({ subscribe: vi.fn(() => () => {}), @@ -596,6 +601,45 @@ describe("relay external socket reconnect behavior", () => { await server.close(); }); + test("logs control RPCs with the socket identity", async () => { + const logger = createLogger(); + const server = createServer({ logger }); + const socket = new MockSocket(); + + await server.attachExternalSocket(socket, { + transport: "relay", + relayConnectionId: "relay-conn-1", + }); + socket.emit("message", JSON.stringify(createHelloMessage("cid-control-log"))); + socket.emit( + "message", + JSON.stringify({ + type: "session", + message: { + type: "shutdown_server_request", + requestId: "shutdown-1", + }, + }), + ); + await Promise.resolve(); + + expect(logger.warn).toHaveBeenCalledWith( + expect.objectContaining({ + connectionId: expect.stringMatching(/^conn_/), + transport: "relay", + relayConnectionId: "relay-conn-1", + clientId: "cid-control-log", + sessionId: "mock-session-id", + requestType: "shutdown_server_request", + requestId: "shutdown-1", + reason: "client_shutdown_rpc", + }), + "ws_control_rpc_received", + ); + + await server.close(); + }); + test("responds to top-level ping while provider diagnostic is still running", async () => { const server = createServer(); const socket = new MockSocket(); diff --git a/packages/server/src/server/websocket-server.ts b/packages/server/src/server/websocket-server.ts index 91f64db92..d9ccaccae 100644 --- a/packages/server/src/server/websocket-server.ts +++ b/packages/server/src/server/websocket-server.ts @@ -2,6 +2,7 @@ import { WebSocket, WebSocketServer } from "ws"; import type { IncomingMessage, Server as HTTPServer } from "http"; import { basename, join } from "path"; import { hostname as getHostname } from "node:os"; +import { randomUUID } from "node:crypto"; import { monitorEventLoopDelay } from "node:perf_hooks"; import type { AgentManager, AgentMetricsSnapshot } from "./agent/agent-manager.js"; import type { AgentStorage } from "./agent/agent-storage.js"; @@ -63,17 +64,37 @@ import { type WebSocketRuntimeDiagnosticSnapshot, } from "./websocket/runtime-metrics.js"; import { ProviderUsageService } from "../services/quota-fetcher/service.js"; +import { getProcessMemoryDiagnostics, getProcessUptimeSeconds } from "./process-diagnostics.js"; +import { + CLIENT_SHUTDOWN_RPC_REASON, + normalizeClientRestartRpcReason, +} from "./lifecycle-reasons.js"; const WS_CLOSE_DAEMON_AUTH_FAILED = 4401; export interface ExternalSocketMetadata { transport: "relay"; externalSessionKey?: string; + relayConnectionId?: string; } interface PendingConnection { connectionLogger: pino.Logger; helloTimeout: ReturnType | null; + identity: WebSocketConnectionIdentity; +} + +interface WebSocketConnectionIdentity { + connectionId: string; + transport: "direct" | "relay"; + host?: string; + origin?: string; + userAgent?: string; + remoteAddress?: string; + relayConnectionId?: string; + clientId?: string; + sessionId?: string; + appVersion?: string; } interface WebSocketServerConfig { @@ -355,6 +376,7 @@ export class VoiceAssistantWebSocketServer { private readonly wss: WebSocketServer; private readonly pendingConnections: Map = new Map(); private readonly sessions: Map = new Map(); + private readonly socketIdentities: Map = new Map(); private readonly externalSessionsByKey: Map = new Map(); private readonly serverId: string; private readonly daemonVersion: string; @@ -818,6 +840,7 @@ export class VoiceAssistantWebSocketServer { this.workspaceGitService.dispose(); this.pendingConnections.clear(); this.sessions.clear(); + this.socketIdentities.clear(); this.externalSessionsByKey.clear(); this.wss.close(); } @@ -867,26 +890,14 @@ export class VoiceAssistantWebSocketServer { metadata?: ExternalSocketMetadata, ): Promise { const requestMetadata = extractSocketRequestMetadata(request); - const connectionLoggerFields: Record = { - transport: metadata?.transport === "relay" ? "relay" : "direct", - }; - if (requestMetadata.host) { - connectionLoggerFields.host = requestMetadata.host; - } - if (requestMetadata.origin) { - connectionLoggerFields.origin = requestMetadata.origin; - } - if (requestMetadata.userAgent) { - connectionLoggerFields.userAgent = requestMetadata.userAgent; - } - if (requestMetadata.remoteAddress) { - connectionLoggerFields.remoteAddress = requestMetadata.remoteAddress; - } - const connectionLogger = this.logger.child(connectionLoggerFields); + const identity = createWebSocketConnectionIdentity(requestMetadata, metadata); + this.socketIdentities.set(ws, identity); + const connectionLogger = this.logger.child(toConnectionLogFields(identity)); const pending: PendingConnection = { connectionLogger, helloTimeout: null, + identity, }; const timeout = setTimeout(() => { if (this.pendingConnections.get(ws) !== pending) { @@ -895,7 +906,7 @@ export class VoiceAssistantWebSocketServer { pending.helloTimeout = null; this.pendingConnections.delete(ws); pending.connectionLogger.warn( - { timeoutMs: HELLO_TIMEOUT_MS }, + { ...toConnectionLogFields(identity), timeoutMs: HELLO_TIMEOUT_MS }, "Closing connection due to missing hello", ); try { @@ -911,8 +922,9 @@ export class VoiceAssistantWebSocketServer { this.incrementRuntimeCounter("connectedAwaitingHello"); this.bindSocketHandlers(ws); - pending.connectionLogger.trace( + pending.connectionLogger.info( { + ...toConnectionLogFields(identity), totalPendingConnections: this.pendingConnections.size, }, "Client connected; awaiting hello", @@ -1089,6 +1101,10 @@ export class VoiceAssistantWebSocketServer { } this.clearPendingConnection(ws); + pending.identity.clientId = clientId; + if (message.appVersion) { + pending.identity.appVersion = message.appVersion; + } const existing = this.externalSessionsByKey.get(clientId); if (existing) { this.incrementRuntimeCounter("helloResumed"); @@ -1111,10 +1127,11 @@ export class VoiceAssistantWebSocketServer { } existing.sockets.add(ws); this.sessions.set(ws, existing); + pending.identity.sessionId = existing.session.getSessionId(); this.sendToClient(ws, this.createServerInfoMessage()); - existing.connectionLogger.trace( + pending.connectionLogger.info( { - clientId, + ...toConnectionLogFields(pending.identity), resumed: true, totalSessions: this.sessions.size, }, @@ -1134,10 +1151,11 @@ export class VoiceAssistantWebSocketServer { }); this.sessions.set(ws, connection); this.externalSessionsByKey.set(clientId, connection); + pending.identity.sessionId = connection.session.getSessionId(); this.sendToClient(ws, this.createServerInfoMessage()); - connection.connectionLogger.trace( + connection.connectionLogger.info( { - clientId, + ...toConnectionLogFields(pending.identity), resumed: false, totalSessions: this.sessions.size, }, @@ -1256,26 +1274,42 @@ export class VoiceAssistantWebSocketServer { error?: Error; }, ): Promise { + const identity = this.socketIdentities.get(ws); + const identityFields = identity ? toConnectionLogFields(identity) : {}; const pending = this.clearPendingConnection(ws); if (pending) { this.incrementRuntimeCounter("pendingDisconnected"); - pending.connectionLogger.trace( + pending.connectionLogger.info( { + ...identityFields, code: details.code, reason: stringifyCloseReason(details.reason), }, "Pending client disconnected", ); + this.socketIdentities.delete(ws); return; } const connection = this.sessions.get(ws); if (!connection) { + if (identity) { + this.logger.info( + { + ...identityFields, + code: details.code, + reason: stringifyCloseReason(details.reason), + }, + "Client socket closed without active session", + ); + this.socketIdentities.delete(ws); + } return; } this.sessions.delete(ws); connection.sockets.delete(ws); + this.socketIdentities.delete(ws); if (connection.sockets.size === 0) { this.incrementRuntimeCounter("sessionDisconnectedWaitingReconnect"); @@ -1291,9 +1325,9 @@ export class VoiceAssistantWebSocketServer { }, EXTERNAL_SESSION_DISCONNECT_GRACE_MS); connection.externalDisconnectCleanupTimeout = timeout; - connection.connectionLogger.trace( + connection.connectionLogger.info( { - clientId: connection.clientId, + ...identityFields, code: details.code, reason: stringifyCloseReason(details.reason), reconnectGraceMs: EXTERNAL_SESSION_DISCONNECT_GRACE_MS, @@ -1305,9 +1339,9 @@ export class VoiceAssistantWebSocketServer { if (connection.sockets.size > 0) { this.incrementRuntimeCounter("sessionSocketDisconnectedAttached"); - connection.connectionLogger.trace( + connection.connectionLogger.info( { - clientId: connection.clientId, + ...identityFields, remainingSockets: connection.sockets.size, code: details.code, reason: stringifyCloseReason(details.reason), @@ -1332,6 +1366,7 @@ export class VoiceAssistantWebSocketServer { for (const socket of connection.sockets) { this.sessions.delete(socket); + this.socketIdentities.delete(socket); } connection.sockets.clear(); const existing = this.externalSessionsByKey.get(connection.clientId); @@ -1562,7 +1597,7 @@ export class VoiceAssistantWebSocketServer { } if (message.type === "session") { - void this.dispatchSessionMessage(activeConnection, message).catch((error: unknown) => { + void this.dispatchSessionMessage(ws, activeConnection, message).catch((error: unknown) => { this.handleRawMessageError({ ws, data, error, log: activeConnection.connectionLogger }); }); } @@ -1572,10 +1607,22 @@ export class VoiceAssistantWebSocketServer { } private async dispatchSessionMessage( + ws: WebSocketLike, activeConnection: SessionConnection, message: Extract, ): Promise { this.recordInboundSessionRequestType(message.message.type); + const controlRpc = getControlRpcLogInfo(message.message); + if (controlRpc) { + const identity = this.socketIdentities.get(ws); + activeConnection.connectionLogger.warn( + { + ...(identity ? toConnectionLogFields(identity) : { clientId: activeConnection.clientId }), + ...controlRpc, + }, + "ws_control_rpc_received", + ); + } const startMs = performance.now(); await activeConnection.session.handleMessage(message.message); const durationMs = performance.now() - startMs; @@ -1750,6 +1797,8 @@ export class VoiceAssistantWebSocketServer { outboundBinaryFrameTypesTop: runtimeMetrics.outboundBinaryFrameTypesTop, bufferedAmount: runtimeMetrics.bufferedAmount, eventLoopDelay: this.snapshotEventLoopDelay(), + uptimeSeconds: getProcessUptimeSeconds(), + memory: getProcessMemoryDiagnostics(), runtime: sessionMetrics, latency: runtimeMetrics.latency, agents: agentSnapshot, @@ -1926,6 +1975,36 @@ interface SocketRequestMetadata { remoteAddress?: string; } +function createWebSocketConnectionIdentity( + requestMetadata: SocketRequestMetadata, + metadata: ExternalSocketMetadata | undefined, +): WebSocketConnectionIdentity { + return { + connectionId: `conn_${randomUUID().replaceAll("-", "")}`, + transport: metadata?.transport === "relay" ? "relay" : "direct", + ...(requestMetadata.host ? { host: requestMetadata.host } : {}), + ...(requestMetadata.origin ? { origin: requestMetadata.origin } : {}), + ...(requestMetadata.userAgent ? { userAgent: requestMetadata.userAgent } : {}), + ...(requestMetadata.remoteAddress ? { remoteAddress: requestMetadata.remoteAddress } : {}), + ...(metadata?.relayConnectionId ? { relayConnectionId: metadata.relayConnectionId } : {}), + }; +} + +function toConnectionLogFields(identity: WebSocketConnectionIdentity): Record { + return { + connectionId: identity.connectionId, + transport: identity.transport, + ...(identity.host ? { host: identity.host } : {}), + ...(identity.origin ? { origin: identity.origin } : {}), + ...(identity.userAgent ? { userAgent: identity.userAgent } : {}), + ...(identity.remoteAddress ? { remoteAddress: identity.remoteAddress } : {}), + ...(identity.relayConnectionId ? { relayConnectionId: identity.relayConnectionId } : {}), + ...(identity.clientId ? { clientId: identity.clientId } : {}), + ...(identity.sessionId ? { sessionId: identity.sessionId } : {}), + ...(identity.appVersion ? { appVersion: identity.appVersion } : {}), + }; +} + function extractSocketRequestMetadata(request: unknown): SocketRequestMetadata { if (!request || typeof request !== "object") { return {}; @@ -2091,6 +2170,34 @@ function stringifyCloseReason(reason: unknown): string | null { return text.length > 0 ? text : null; } +function getControlRpcLogInfo( + message: Extract["message"], +): { requestType: string; requestId: string; reason?: string } | null { + if (message.type === "shutdown_server_request") { + return { + requestType: message.type, + requestId: message.requestId, + reason: CLIENT_SHUTDOWN_RPC_REASON, + }; + } + if (message.type === "restart_server_request") { + const reason = normalizeClientRestartRpcReason(message.reason); + return { + requestType: message.type, + requestId: message.requestId, + reason, + }; + } + if (message.type === "daemon.update.request") { + return { + requestType: message.type, + requestId: message.requestId, + reason: "daemon_update", + }; + } + return null; +} + function extractRequestInfoFromUnknownWsInbound( payload: unknown, ): { requestId: string; requestType?: string } | null { diff --git a/packages/server/src/server/websocket/runtime-metrics.ts b/packages/server/src/server/websocket/runtime-metrics.ts index 214d7449a..fbb31210f 100644 --- a/packages/server/src/server/websocket/runtime-metrics.ts +++ b/packages/server/src/server/websocket/runtime-metrics.ts @@ -1,4 +1,5 @@ import type { SessionOutboundMessage, WSOutboundMessage } from "../messages.js"; +import type { ProcessMemoryDiagnostics } from "../process-diagnostics.js"; export interface WebSocketRuntimeCounters { connectedAwaitingHello: number; @@ -62,6 +63,8 @@ export interface WebSocketRuntimeDiagnosticSnapshot< p99Ms: number; maxMs: number; } | null; + uptimeSeconds: number; + memory: ProcessMemoryDiagnostics; runtime: TRuntime; agents: TAgents; } From 85acaceb167b3136ab459de67c31b9d628aae284 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Mon, 29 Jun 2026 18:29:59 +0200 Subject: [PATCH 4/8] Fork assistant turns into new drafts (#1788) * feat(chat): fork assistant turns into new drafts Builds chat-history attachments on the daemon and routes them into draft composers so users can continue an assistant turn in an existing workspace tab or New Workspace. * fix(chat): clean up fork draft handling * fix(chat): address fork draft review feedback * fix(server): build fork context from bounded timeline * fix(app): restore assistant turn footer in streams * fix(app): preserve fork draft setup * fix(app): tighten assistant fork footers * fix(app): lock active fork draft handoff * fix(app): preserve attachment-only draft retries * fix(app): unify composer attachment scopes * fix(app): align chat history attachment labels * fix(app): centralize attachment pill content * fix(app): trim fork context for workspace naming --- packages/app/e2e/agent-stream-ui.spec.ts | 14 +- packages/app/e2e/assistant-fork-menu.spec.ts | 121 +++++++++ .../app/e2e/helpers/agent-bottom-anchor.ts | 19 ++ packages/app/e2e/helpers/mock-agent.ts | 6 +- packages/app/src/agent-stream/layout.test.ts | 63 +++++ packages/app/src/agent-stream/layout.ts | 108 +++++++- .../src/agent-stream/turn-boundary.test.ts | 64 +++++ .../app/src/agent-stream/turn-boundary.ts | 13 + packages/app/src/agent-stream/turn-footer.tsx | 26 +- packages/app/src/agent-stream/view.tsx | 199 +++++++++++++- packages/app/src/app/new.tsx | 4 + .../attachments/attachment-pill-content.tsx | 141 ++++++++++ packages/app/src/attachments/types.ts | 13 + .../attachments/workspace-attachment-utils.ts | 5 + .../workspace-attachments-store.test.ts | 54 ++++ .../workspace-attachments-store.ts | 85 +++++- .../src/components/assistant-fork-menu.tsx | 148 +++++++++++ packages/app/src/components/message.tsx | 92 ++----- .../composer/attachments/workspace-cleanup.ts | 70 +++++ .../composer/attachments/workspace.test.ts | 72 ++++++ .../src/composer/attachments/workspace.tsx | 142 ++-------- .../src/composer/draft/create-flow.test.ts | 59 +++++ .../app/src/composer/draft/create-flow.ts | 3 +- .../src/composer/draft/workspace-tab-core.ts | 7 + .../src/composer/draft/workspace-tab.test.ts | 22 +- .../app/src/composer/draft/workspace-tab.tsx | 41 ++- packages/app/src/composer/index.tsx | 8 +- .../hooks/use-agent-screen-state-machine.ts | 18 +- packages/app/src/i18n/resources/ar.ts | 6 + packages/app/src/i18n/resources/en.ts | 6 + packages/app/src/i18n/resources/es.ts | 6 + packages/app/src/i18n/resources/fr.ts | 6 + packages/app/src/i18n/resources/ja.ts | 6 + packages/app/src/i18n/resources/pt-BR.ts | 6 + packages/app/src/i18n/resources/ru.ts | 6 + packages/app/src/i18n/resources/zh-CN.ts | 6 + packages/app/src/panels/agent-panel.tsx | 50 ++-- .../new-workspace-fork-context.test.ts | 49 ++++ .../src/screens/new-workspace-fork-context.ts | 50 ++++ .../app/src/screens/new-workspace-screen.tsx | 242 ++++++++++++++++-- .../app/src/stores/create-flow-store.test.ts | 38 ++- packages/app/src/stores/create-flow-store.ts | 14 + .../workspace-draft-submission-store.ts | 37 +++ packages/app/src/types/stream.ts | 1 + packages/app/src/utils/host-routes.test.ts | 10 + packages/app/src/utils/host-routes.ts | 4 + packages/client/src/daemon-client.ts | 41 +++ .../protocol/src/messages.attachments.test.ts | 41 +++ packages/protocol/src/messages.ts | 43 +++- .../src/server/agent/activity-curator.test.ts | 147 ++++++++++- .../src/server/agent/activity-curator.ts | 167 ++++++++++-- .../agent/providers/mock-load-test-agent.ts | 9 + packages/server/src/server/session.ts | 66 ++++- .../server/src/server/websocket-server.ts | 2 + 54 files changed, 2364 insertions(+), 312 deletions(-) create mode 100644 packages/app/e2e/assistant-fork-menu.spec.ts create mode 100644 packages/app/src/agent-stream/turn-boundary.test.ts create mode 100644 packages/app/src/agent-stream/turn-boundary.ts create mode 100644 packages/app/src/attachments/attachment-pill-content.tsx create mode 100644 packages/app/src/components/assistant-fork-menu.tsx create mode 100644 packages/app/src/composer/attachments/workspace-cleanup.ts create mode 100644 packages/app/src/composer/attachments/workspace.test.ts create mode 100644 packages/app/src/screens/new-workspace-fork-context.test.ts create mode 100644 packages/app/src/screens/new-workspace-fork-context.ts diff --git a/packages/app/e2e/agent-stream-ui.spec.ts b/packages/app/e2e/agent-stream-ui.spec.ts index 6b52d77c5..7439a5604 100644 --- a/packages/app/e2e/agent-stream-ui.spec.ts +++ b/packages/app/e2e/agent-stream-ui.spec.ts @@ -9,6 +9,7 @@ import { import { expectScrollStaysFixed, readScrollMetrics, + scrollAgentChatToBottom, scrollChatAwayFromBottom, waitForScrollableChat, } from "./helpers/agent-bottom-anchor"; @@ -18,6 +19,8 @@ import { clickNewChat } from "./helpers/launcher"; import { expectComposerVisible, startRunningMockAgent } from "./helpers/composer"; import { openAgentRoute, seedMockAgentWorkspace } from "./helpers/mock-agent"; +const SCROLL_AWAY_MIN_SCROLLABLE_DISTANCE = 360; + test.describe("Agent stream UI", () => { test("auto-scroll sticks to bottom across token bursts", async ({ page }) => { test.setTimeout(120_000); @@ -54,7 +57,10 @@ test.describe("Agent stream UI", () => { timeout: 30_000, }); await awaitAssistantMessage(page); - await waitForScrollableChat(page, { minScrollableDistance: 900, timeout: 30_000 }); + await waitForScrollableChat(page, { + minScrollableDistance: SCROLL_AWAY_MIN_SCROLLABLE_DISTANCE, + timeout: 30_000, + }); const baseline = await scrollChatAwayFromBottom(page, { deltaY: -900, minDistanceFromBottom: 300, @@ -100,7 +106,10 @@ test.describe("Agent stream UI", () => { timeout: 30_000, }); await awaitAssistantMessage(page); - await waitForScrollableChat(page, { minScrollableDistance: 900, timeout: 45_000 }); + await waitForScrollableChat(page, { + minScrollableDistance: SCROLL_AWAY_MIN_SCROLLABLE_DISTANCE, + timeout: 45_000, + }); const baseline = await scrollChatAwayFromBottom(page, { deltaY: -900, minDistanceFromBottom: 300, @@ -122,6 +131,7 @@ test.describe("Agent stream UI", () => { await awaitAssistantMessage(page); await expectInlineWorkingIndicator(page); await expectAgentIdle(page, 30_000); + await scrollAgentChatToBottom(page); await expectTurnCopyButton(page); } finally { await agent.cleanup(); diff --git a/packages/app/e2e/assistant-fork-menu.spec.ts b/packages/app/e2e/assistant-fork-menu.spec.ts new file mode 100644 index 000000000..a1ab1eafb --- /dev/null +++ b/packages/app/e2e/assistant-fork-menu.spec.ts @@ -0,0 +1,121 @@ +import { expect, test as base, type Page } from "./fixtures"; +import { scrollAgentChatToBottom } from "./helpers/agent-bottom-anchor"; +import { awaitAssistantMessage } from "./helpers/agent-stream"; +import { expectComposerVisible } from "./helpers/composer"; +import { getE2EDaemonPort } from "./helpers/daemon-port"; +import { + openAgentRoute, + seedMockAgentWorkspace, + type MockAgentOptions, + type MockAgentWorkspace, +} from "./helpers/mock-agent"; +import { getServerId } from "./helpers/server-id"; +import { seedSavedSettingsHosts } from "./helpers/settings"; + +const test = base.extend<{ + seedForkWorkspace: (options: MockAgentOptions) => Promise; +}>({ + seedForkWorkspace: async ({ browserName: _browserName }, provide) => { + const sessions: MockAgentWorkspace[] = []; + await provide(async (options) => { + const session = await seedMockAgentWorkspace(options); + sessions.push(session); + return session; + }); + await Promise.allSettled(sessions.map((session) => session.cleanup())); + }, +}); + +async function openAssistantForkMenu(page: Page): Promise { + await expect + .poll( + async () => { + await scrollAgentChatToBottom(page); + return page.getByTestId("assistant-fork-menu-trigger").count(); + }, + { timeout: 30_000 }, + ) + .toBeGreaterThan(0); + const trigger = page.getByTestId("assistant-fork-menu-trigger").last(); + await expect(trigger).toBeVisible({ timeout: 30_000 }); + await trigger.click(); + await expect(page.getByTestId("assistant-fork-menu-content")).toBeVisible({ + timeout: 10_000, + }); +} + +async function expectChatHistoryPill(page: Page): Promise { + const pill = page.getByTestId("composer-chat-history-attachment-pill").first(); + await expect(pill).toBeVisible({ timeout: 30_000 }); + await expect(pill).toContainText("Chat history"); +} + +test.describe("Assistant fork menu", () => { + test.describe.configure({ timeout: 180_000 }); + + test("forks an assistant turn into a new workspace draft tab", async ({ + page, + seedForkWorkspace, + }) => { + const session = await seedForkWorkspace({ + repoPrefix: "assistant-fork-tab-", + title: "Assistant fork tab", + initialPrompt: "emit 1 coalesced agent stream updates for assistant fork tab.", + model: "ten-second-stream", + }); + + await openAgentRoute(page, session); + await expectComposerVisible(page); + await awaitAssistantMessage(page); + await session.client.waitForFinish(session.agentId, 45_000); + + await openAssistantForkMenu(page); + await page.getByTestId("assistant-fork-menu-new-tab").click(); + + await expectChatHistoryPill(page); + }); + + test("forks an assistant turn into New Workspace and keeps the attachment across host changes", async ({ + page, + seedForkWorkspace, + }) => { + await seedSavedSettingsHosts(page, [ + { + serverId: getServerId(), + label: "localhost", + endpoint: `127.0.0.1:${getE2EDaemonPort()}`, + }, + { + serverId: "secondary-assistant-fork-host", + label: "Secondary host", + // The host does not need to be reachable; this pins that the draft-scoped + // attachment survives changing the selected target host. + endpoint: "127.0.0.1:9", + }, + ]); + + const session = await seedForkWorkspace({ + repoPrefix: "assistant-fork-workspace-", + title: "Assistant fork workspace", + initialPrompt: "emit 1 coalesced agent stream updates for assistant fork new workspace.", + model: "ten-second-stream", + }); + + await openAgentRoute(page, session); + await expectComposerVisible(page); + await awaitAssistantMessage(page); + await session.client.waitForFinish(session.agentId, 45_000); + + await openAssistantForkMenu(page); + await page.getByTestId("assistant-fork-menu-new-workspace").click(); + + await expect(page).toHaveURL(/\/new\?.*draftId=/, { timeout: 30_000 }); + await expectChatHistoryPill(page); + + await page.getByTestId("host-picker-trigger").click(); + await page + .getByTestId("new-workspace-host-picker-option-secondary-assistant-fork-host") + .click(); + await expectChatHistoryPill(page); + }); +}); diff --git a/packages/app/e2e/helpers/agent-bottom-anchor.ts b/packages/app/e2e/helpers/agent-bottom-anchor.ts index 561e8ea7e..a0ff65ba2 100644 --- a/packages/app/e2e/helpers/agent-bottom-anchor.ts +++ b/packages/app/e2e/helpers/agent-bottom-anchor.ts @@ -55,6 +55,25 @@ export async function expectNearBottom(page: Page): Promise { .toBeLessThanOrEqual(NEAR_BOTTOM_THRESHOLD_PX); } +export async function scrollAgentChatToBottom(page: Page): Promise { + const chatScroll = getVisibleChatScroll(page); + await chatScroll.evaluate((root: Element) => { + const scrollElement = root as HTMLElement; + scrollElement.scrollTop = scrollElement.scrollHeight; + }); + await expect + .poll(async () => + chatScroll.evaluate((root: Element) => { + const scrollElement = root as HTMLElement; + return Math.max( + 0, + scrollElement.scrollHeight - (scrollElement.scrollTop + scrollElement.clientHeight), + ); + }), + ) + .toBeLessThanOrEqual(NEAR_BOTTOM_THRESHOLD_PX); +} + export async function waitForContentGrowth( page: Page, previousContentHeight: number, diff --git a/packages/app/e2e/helpers/mock-agent.ts b/packages/app/e2e/helpers/mock-agent.ts index 9cb7b1aaa..687891136 100644 --- a/packages/app/e2e/helpers/mock-agent.ts +++ b/packages/app/e2e/helpers/mock-agent.ts @@ -1,7 +1,7 @@ import type { Page } from "@playwright/test"; -import { buildHostWorkspaceRoute } from "../../src/utils/host-routes"; import { seedWorkspace, type SeedDaemonClient } from "./seed-client"; import { getServerId } from "./server-id"; +import { buildHostAgentDetailRoute } from "../../src/utils/host-routes"; export interface MockAgentWorkspace { agentId: string; @@ -54,9 +54,7 @@ export async function seedMockAgentWorkspace( } export function buildAgentRoute(workspaceId: string, agentId: string): string { - return `${buildHostWorkspaceRoute(getServerId(), workspaceId)}?open=${encodeURIComponent( - `agent:${agentId}`, - )}`; + return buildHostAgentDetailRoute(getServerId(), agentId, workspaceId); } /** Boots the app directly at the agent's workspace route and waits for the open intent to settle. */ diff --git a/packages/app/src/agent-stream/layout.test.ts b/packages/app/src/agent-stream/layout.test.ts index fba61bdba..719fdb2b4 100644 --- a/packages/app/src/agent-stream/layout.test.ts +++ b/packages/app/src/agent-stream/layout.test.ts @@ -289,4 +289,67 @@ describe("layoutStream", () => { expect(findLayoutItem(layout, assistant.id).completedFooter).toBeNull(); expect(footerOwners(layout)).toEqual([assistant.id]); }); + + it.each(["web", "android"] as const)( + "keeps inline footer on an assistant turn with trailing tool rows before the next user on %s", + (platform) => { + const assistant = assistantMessage("a1", 2); + const tool = toolCall("tool-1", 3); + const layout = layoutFor({ + platform, + tail: [userMessage("u1", 1), assistant, tool, userMessage("u2", 4)], + timingIds: [assistant.id], + }); + + expect(layout.auxiliaryTurnFooter).toBeNull(); + expect(findLayoutItem(layout, assistant.id).completedFooter?.itemId).toBe(assistant.id); + expect(footerOwners(layout)).toEqual([assistant.id]); + }, + ); + + it.each(["web", "android"] as const)( + "uses the latest assistant for an inline footer when a turn has multiple assistant blocks on %s", + (platform) => { + const firstAssistant = assistantMessage("a1", 2); + const firstTool = toolCall("tool-1", 3); + const latestAssistant = assistantMessage("a2", 4); + const latestTool = toolCall("tool-2", 5); + const layout = layoutFor({ + platform, + tail: [ + userMessage("u1", 1), + firstAssistant, + firstTool, + latestAssistant, + latestTool, + userMessage("u2", 6), + ], + timingIds: [firstAssistant.id, latestAssistant.id], + }); + + expect(layout.auxiliaryTurnFooter).toBeNull(); + expect(findLayoutItem(layout, firstAssistant.id).completedFooter).toBeNull(); + expect(findLayoutItem(layout, latestAssistant.id).completedFooter?.itemId).toBe( + latestAssistant.id, + ); + expect(footerOwners(layout)).toEqual([latestAssistant.id]); + }, + ); + + it.each(["web", "android"] as const)( + "keeps bottom footer on the latest assistant turn when trailing tool rows end the turn on %s", + (platform) => { + const assistant = assistantMessage("a1", 2); + const tool = toolCall("tool-1", 3); + const layout = layoutFor({ + platform, + tail: [userMessage("u1", 1), assistant, tool], + timingIds: [assistant.id], + }); + + expect(layout.auxiliaryTurnFooter?.itemId).toBe(assistant.id); + expect(findLayoutItem(layout, assistant.id).completedFooter).toBeNull(); + expect(footerOwners(layout)).toEqual([assistant.id]); + }, + ); }); diff --git a/packages/app/src/agent-stream/layout.ts b/packages/app/src/agent-stream/layout.ts index e70021342..0f5c4c97e 100644 --- a/packages/app/src/agent-stream/layout.ts +++ b/packages/app/src/agent-stream/layout.ts @@ -68,18 +68,48 @@ function createTurnFooterHost(input: { }; } +function findLatestAssistantIndexInTurn(input: { + strategy: StreamStrategy; + items: StreamItem[]; + startIndex: number; +}): number | null { + for ( + let index = input.startIndex; + index >= 0 && index < input.items.length; + index = input.strategy.getNeighborIndex(index, "above") + ) { + const item = input.items[index]; + if (!item || item.kind === "user_message") { + return null; + } + if (item.kind === "assistant_message") { + return index; + } + } + return null; +} + function resolveAuxiliaryTurnFooter(input: StreamLayoutInput): TurnFooterHost | null { if (input.agentStatus === "running") { return null; } const footerItems = input.liveHead.length > 0 ? input.liveHead : input.history; - const startIndex = input.strategy.getLatestItemIndex(footerItems); - if (startIndex === null) { + const latestIndex = input.strategy.getLatestItemIndex(footerItems); + if (latestIndex === null) { return null; } - const item = footerItems[startIndex]; + const assistantIndex = findLatestAssistantIndexInTurn({ + strategy: input.strategy, + items: footerItems, + startIndex: latestIndex, + }); + if (assistantIndex === null) { + return null; + } + + const item = footerItems[assistantIndex]; if (!item || item.kind !== "assistant_message") { return null; } @@ -87,26 +117,79 @@ function resolveAuxiliaryTurnFooter(input: StreamLayoutInput): TurnFooterHost | return createTurnFooterHost({ item, items: footerItems, - index: startIndex, + index: assistantIndex, timingByAssistantId: input.timingByAssistantId, }); } +function findTurnEndIndexInSegment(input: { + strategy: StreamStrategy; + items: StreamItem[]; + startIndex: number; +}): number { + let endIndex = input.startIndex; + for ( + let index = input.strategy.getNeighborIndex(input.startIndex, "below"); + index >= 0 && index < input.items.length; + index = input.strategy.getNeighborIndex(index, "below") + ) { + const item = input.items[index]; + if (!item || item.kind === "user_message") { + break; + } + endIndex = index; + } + return endIndex; +} + function shouldRenderCompletedFooter(input: { + strategy: StreamStrategy; + items: StreamItem[]; + index: number; item: StreamItem; belowItem: StreamItem | null; agentStatus: string; auxiliaryTurnFooter: TurnFooterHost | null; }): boolean { - return ( - input.item.kind === "assistant_message" && - input.auxiliaryTurnFooter?.itemId !== input.item.id && - (input.belowItem?.kind === "user_message" || - (input.belowItem === null && input.agentStatus !== "running")) - ); + if ( + input.item.kind !== "assistant_message" || + input.auxiliaryTurnFooter?.itemId === input.item.id + ) { + return false; + } + + if ( + input.belowItem?.kind === "user_message" || + (input.belowItem === null && input.agentStatus !== "running") + ) { + return true; + } + + if (!isToolSequenceItem(input.belowItem)) { + return false; + } + + const sameSegmentBelowItem = input.strategy.getNeighborItem(input.items, input.index, "below"); + if (sameSegmentBelowItem?.id !== input.belowItem.id) { + return false; + } + + const turnEndIndex = findTurnEndIndexInSegment({ + strategy: input.strategy, + items: input.items, + startIndex: input.index, + }); + const assistantIndex = findLatestAssistantIndexInTurn({ + strategy: input.strategy, + items: input.items, + startIndex: turnEndIndex, + }); + return assistantIndex === input.index; } -function isToolSequenceItem(item: StreamItem | null): boolean { +function isToolSequenceItem( + item: StreamItem | null, +): item is Extract { return item?.kind === "tool_call" || item?.kind === "thought" || item?.kind === "todo_list"; } @@ -175,6 +258,9 @@ function layoutSegment(input: LayoutSegmentInput): StreamLayoutItem[] { belowItem, }); const completedFooter = shouldRenderCompletedFooter({ + strategy: input.strategy, + items: input.items, + index, item, belowItem, agentStatus: input.agentStatus, diff --git a/packages/app/src/agent-stream/turn-boundary.test.ts b/packages/app/src/agent-stream/turn-boundary.test.ts new file mode 100644 index 000000000..c18fad104 --- /dev/null +++ b/packages/app/src/agent-stream/turn-boundary.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it } from "vitest"; +import type { StreamItem } from "@/types/stream"; +import { resolveAssistantTurnBoundaryMessageId } from "./turn-boundary"; + +function timestamp(seed: number): Date { + return new Date(`2026-01-01T00:00:${seed.toString().padStart(2, "0")}.000Z`); +} + +function userMessage(id: string, seed: number): Extract { + return { + kind: "user_message", + id, + text: id, + timestamp: timestamp(seed), + }; +} + +function assistantMessage( + id: string, + seed: number, + messageId?: string, +): Extract { + return { + kind: "assistant_message", + id, + text: id, + timestamp: timestamp(seed), + ...(messageId ? { messageId } : {}), + }; +} + +describe("resolveAssistantTurnBoundaryMessageId", () => { + it("uses the selected assistant message id", () => { + const selected = assistantMessage("assistant-1", 2, "msg-assistant-1"); + + expect( + resolveAssistantTurnBoundaryMessageId({ + items: [userMessage("user-1", 1), selected], + startIndex: 1, + }), + ).toBe("msg-assistant-1"); + }); + + it("does not borrow a boundary id from another assistant in the same turn", () => { + const first = assistantMessage("assistant-1", 2, "msg-assistant-1"); + const selected = assistantMessage("assistant-2", 3); + + expect( + resolveAssistantTurnBoundaryMessageId({ + items: [userMessage("user-1", 1), first, selected], + startIndex: 2, + }), + ).toBeUndefined(); + }); + + it("requires the selected item to be an assistant message", () => { + expect( + resolveAssistantTurnBoundaryMessageId({ + items: [userMessage("user-1", 1), assistantMessage("assistant-1", 2, "msg-assistant-1")], + startIndex: 0, + }), + ).toBeUndefined(); + }); +}); diff --git a/packages/app/src/agent-stream/turn-boundary.ts b/packages/app/src/agent-stream/turn-boundary.ts new file mode 100644 index 000000000..75fa63a3e --- /dev/null +++ b/packages/app/src/agent-stream/turn-boundary.ts @@ -0,0 +1,13 @@ +import type { StreamItem } from "@/types/stream"; + +export function resolveAssistantTurnBoundaryMessageId(input: { + items: readonly StreamItem[]; + startIndex: number; +}): string | undefined { + const item = input.items[input.startIndex]; + if (item?.kind !== "assistant_message") { + return undefined; + } + // Forking without the selected assistant's durable message id would send the wrong slice. + return item.messageId || undefined; +} diff --git a/packages/app/src/agent-stream/turn-footer.tsx b/packages/app/src/agent-stream/turn-footer.tsx index 4ca005234..f9e63960d 100644 --- a/packages/app/src/agent-stream/turn-footer.tsx +++ b/packages/app/src/agent-stream/turn-footer.tsx @@ -9,7 +9,13 @@ import { collectAssistantTurnContentForStreamRenderStrategy, type StreamStrategy, } from "./strategy"; -import { AssistantTurnFooter, LiveElapsed, STREAM_METADATA_FONT_SIZE } from "@/components/message"; +import { resolveAssistantTurnBoundaryMessageId } from "./turn-boundary"; +import { + AssistantTurnFooter, + LiveElapsed, + STREAM_METADATA_FONT_SIZE, + type AssistantForkTarget, +} from "@/components/message"; import type { TurnFooterHost } from "./layout"; import { SyncedLoader } from "@/components/synced-loader"; @@ -22,17 +28,23 @@ const workingIndicatorColorMapping = (theme: Theme) => ({ }); export type TurnContentStrategy = StreamStrategy; +export type AssistantTurnForkHandler = (input: { + target: AssistantForkTarget; + boundaryMessageId?: string; +}) => Promise | void; export const TurnFooter = memo(function TurnFooter({ isRunning, inFlightTurnStartedAt, host, strategy, + onForkAssistantTurn, }: { isRunning: boolean; inFlightTurnStartedAt: Date | null; host: TurnFooterHost | null; strategy: TurnContentStrategy; + onForkAssistantTurn?: AssistantTurnForkHandler; }) { if (isRunning) { return ( @@ -50,6 +62,7 @@ export const TurnFooter = memo(function TurnFooter({ items={host.items} timing={host.timing} startIndex={host.startIndex} + onForkAssistantTurn={onForkAssistantTurn} /> ); }); @@ -59,11 +72,13 @@ export const CompletedTurnFooterRow = memo(function CompletedTurnFooterRow({ items, timing, startIndex, + onForkAssistantTurn, }: { strategy: TurnContentStrategy; items: StreamItem[]; timing?: TurnTiming; startIndex: number; + onForkAssistantTurn?: AssistantTurnForkHandler; }) { return ( @@ -72,6 +87,7 @@ export const CompletedTurnFooterRow = memo(function CompletedTurnFooterRow({ items={items} timing={timing} startIndex={startIndex} + onForkAssistantTurn={onForkAssistantTurn} /> ); @@ -111,11 +127,13 @@ function CompletedTurnFooter({ items, timing, startIndex, + onForkAssistantTurn, }: { strategy: TurnContentStrategy; items: StreamItem[]; timing?: TurnTiming; startIndex: number; + onForkAssistantTurn?: AssistantTurnForkHandler; }) { const getContent = useCallback( () => @@ -126,12 +144,18 @@ function CompletedTurnFooter({ }), [strategy, items, startIndex], ); + const boundaryMessageId = resolveAssistantTurnBoundaryMessageId({ + items, + startIndex, + }); return ( ); diff --git a/packages/app/src/agent-stream/view.tsx b/packages/app/src/agent-stream/view.tsx index 9b5f44f90..a73a17358 100644 --- a/packages/app/src/agent-stream/view.tsx +++ b/packages/app/src/agent-stream/view.tsx @@ -11,6 +11,7 @@ import React, { type ComponentProps, type ReactNode, } from "react"; +import { useRouter } from "expo-router"; import { useTranslation } from "react-i18next"; import { View, @@ -59,7 +60,12 @@ import { ToolCallSheetProvider } from "@/components/tool-call-sheet"; import { type AgentStreamRenderModel, buildAgentStreamRenderModel } from "./model"; import { resolveStreamRenderStrategy } from "./strategy-resolver"; import { type StreamSegmentRenderers, type StreamViewportHandle } from "./strategy"; -import { CompletedTurnFooterRow, TurnFooter, type TurnContentStrategy } from "./turn-footer"; +import { + CompletedTurnFooterRow, + TurnFooter, + type AssistantTurnForkHandler, + type TurnContentStrategy, +} from "./turn-footer"; import { layoutStream, type StreamLayoutItem } from "./layout"; import { type BottomAnchorLocalRequest, @@ -76,11 +82,21 @@ import { type WorkspaceFileOpenRequest, } from "@/workspace/file-open"; import { navigateToPreparedWorkspaceTab } from "@/utils/workspace-navigation"; +import { buildNewWorkspaceRoute } from "@/utils/host-routes"; import { useStableEvent } from "@/hooks/use-stable-event"; import { isWeb } from "@/constants/platform"; import type { Theme } from "@/styles/theme"; import { recordRenderProfileReasons } from "@/utils/render-profiler"; import { MountedTabActiveContext } from "@/components/split-container"; +import { generateDraftId } from "@/stores/draft-keys"; +import { + buildDraftWorkspaceAttachmentScopeKey, + useWorkspaceAttachmentsStore, +} from "@/attachments/workspace-attachments-store"; +import type { WorkspaceComposerAttachment } from "@/attachments/types"; +import type { WorkspaceDraftTabSetup, WorkspaceTabTarget } from "@/stores/workspace-tabs-store"; +import { toErrorMessage } from "@/utils/error-messages"; +import { useWorkspaceDraftSubmissionStore } from "@/stores/workspace-draft-submission-store"; function renderLiveAuxiliaryNode(input: { pendingPermissions: ReactNode; @@ -121,6 +137,7 @@ function renderStreamItemWithTurnFooter(input: { content: ReactNode; layoutItem: StreamLayoutItem; strategy: TurnContentStrategy; + onForkAssistantTurn?: AssistantTurnForkHandler; }): ReactNode { if (!input.content) { return null; @@ -133,6 +150,7 @@ function renderStreamItemWithTurnFooter(input: { items={footerHost.items} timing={footerHost.timing} startIndex={footerHost.startIndex} + onForkAssistantTurn={input.onForkAssistantTurn} /> ) : null; const content = ( @@ -233,6 +251,56 @@ const AGENT_CAPABILITY_FLAG_KEYS: (keyof AgentCapabilityFlags)[] = [ const EMPTY_STREAM_HEAD: StreamItem[] = []; +function buildChatHistoryAttachment(input: { + draftId: string; + serverId: string; + agentId: string; + payload: Awaited>; + missingAttachmentMessage: string; +}): WorkspaceComposerAttachment { + if (!input.payload.attachment) { + throw new Error(input.missingAttachmentMessage); + } + return { + kind: "chat_history", + id: `chat_history:${input.draftId}`, + attachment: input.payload.attachment, + source: { + serverId: input.serverId, + agentId: input.agentId, + boundaryMessageId: input.payload.boundaryMessageId, + itemCount: input.payload.itemCount, + }, + }; +} + +function buildForkDraftSetup(agent: AgentScreenAgent): WorkspaceDraftTabSetup | undefined { + if (!agent.provider) { + return undefined; + } + + const featureValues: Record = {}; + for (const feature of agent.features ?? []) { + featureValues[feature.id] = feature.value; + } + + return { + provider: agent.provider, + cwd: agent.cwd, + modeId: agent.currentModeId ?? agent.runtimeInfo?.modeId ?? null, + model: agent.model ?? agent.runtimeInfo?.model ?? null, + thinkingOptionId: agent.thinkingOptionId ?? agent.runtimeInfo?.thinkingOptionId ?? null, + featureValues, + }; +} + +function buildForkDraftTabTarget( + setup: WorkspaceDraftTabSetup | undefined, + draftId: string, +): WorkspaceTabTarget { + return setup ? { kind: "draft", draftId, setup } : { kind: "draft", draftId }; +} + const AgentStreamViewComponent = forwardRef( function AgentStreamView( { @@ -249,6 +317,7 @@ const AgentStreamViewComponent = forwardRef(null); const isMobile = useIsCompactFormFactor(); const streamRenderStrategy = useMemo( @@ -273,6 +342,9 @@ const AgentStreamViewComponent = forwardRef state.sessions[resolvedServerId]?.agentStreamHead?.get(agentId), ); + const supportsAgentForkContext = useSessionStore( + (state) => state.sessions[resolvedServerId]?.serverInfo?.features?.agentForkContext === true, + ); const workspaceRoot = agent.cwd?.trim() || ""; const { requestDirectoryListing } = useFileExplorerActions({ @@ -361,6 +433,76 @@ const AgentStreamViewComponent = forwardRef { + try { + if (!supportsAgentForkContext) { + toast?.error(t("message.actions.forkUnavailable")); + return; + } + if (!client) { + throw new Error(t("workspace.terminal.hostDisconnected")); + } + const draftSetup = buildForkDraftSetup(agent); + const prepareForkDraft = async () => { + const draftId = generateDraftId(); + const payload = await client.buildAgentForkContext( + agentId, + boundaryMessageId ? { boundaryMessageId } : {}, + ); + const attachment = buildChatHistoryAttachment({ + draftId, + serverId: resolvedServerId, + agentId, + payload, + missingAttachmentMessage: t("message.actions.forkFailed"), + }); + useWorkspaceAttachmentsStore.getState().setWorkspaceAttachments({ + scopeKey: buildDraftWorkspaceAttachmentScopeKey(draftId), + attachments: [attachment], + }); + return draftId; + }; + + if (target === "tab") { + const workspaceId = agent.workspaceId; + if (!workspaceId) { + throw new Error(t("message.actions.forkMissingWorkspace")); + } + const draftId = await prepareForkDraft(); + navigateToPreparedWorkspaceTab({ + serverId: resolvedServerId, + workspaceId, + target: buildForkDraftTabTarget(draftSetup, draftId), + }); + return; + } + + const draftId = await prepareForkDraft(); + const sourceDirectory = + agent.projectPlacement?.checkout?.cwd?.trim() || agent.cwd.trim() || undefined; + if (draftSetup) { + useWorkspaceDraftSubmissionStore.getState().setDraftSetup({ + draftId, + setup: draftSetup, + sourceDirectory, + }); + } + router.push( + buildNewWorkspaceRoute({ + serverId: resolvedServerId, + sourceDirectory, + displayName: agent.projectPlacement?.projectName, + projectId: agent.projectPlacement?.projectKey, + draftId, + }), + ); + } catch (error) { + toast?.error(toErrorMessage(error) || t("message.actions.forkFailed")); + } + }, + ); + // Freeze stream data while this tab slot is hidden to prevent offscreen FlatList // cell-window renders on every 48ms flush from background agents. // When isActive flips back to true, the context change triggers a re-render and @@ -602,9 +744,10 @@ const AgentStreamViewComponent = forwardRef ) : null, [ + handleForkAssistantTurn, showRunningTurnFooter, baseRenderModel.turnTiming.runningStartedAt, bottomTurnFooterHost, @@ -788,22 +933,60 @@ function agentCapabilityFlagsEqual( return AGENT_CAPABILITY_FLAG_KEYS.every((key) => left?.[key] === right?.[key]); } +function collectAgentProjectPlacementDiffs( + left: AgentScreenAgent["projectPlacement"], + right: AgentScreenAgent["projectPlacement"], +): string[] { + const reasons: string[] = []; + if (left?.checkout?.cwd !== right?.checkout?.cwd) { + reasons.push("agent.projectPlacement.checkout.cwd"); + } + if (left?.checkout?.isGit !== right?.checkout?.isGit) { + reasons.push("agent.projectPlacement.checkout.isGit"); + } + if (left?.projectName !== right?.projectName) { + reasons.push("agent.projectPlacement.projectName"); + } + if (left?.projectKey !== right?.projectKey) { + reasons.push("agent.projectPlacement.projectKey"); + } + return reasons; +} + +function collectAgentSetupDiffs(left: AgentScreenAgent, right: AgentScreenAgent): string[] { + const reasons: string[] = []; + if (left.provider !== right.provider) reasons.push("agent.provider"); + if (left.currentModeId !== right.currentModeId) reasons.push("agent.currentModeId"); + if (left.model !== right.model) reasons.push("agent.model"); + if (left.thinkingOptionId !== right.thinkingOptionId) { + reasons.push("agent.thinkingOptionId"); + } + if (left.runtimeInfo?.modeId !== right.runtimeInfo?.modeId) { + reasons.push("agent.runtimeInfo.modeId"); + } + if (left.runtimeInfo?.model !== right.runtimeInfo?.model) { + reasons.push("agent.runtimeInfo.model"); + } + if (left.runtimeInfo?.thinkingOptionId !== right.runtimeInfo?.thinkingOptionId) { + reasons.push("agent.runtimeInfo.thinkingOptionId"); + } + if (left.features !== right.features) reasons.push("agent.features"); + return reasons; +} + function collectAgentScreenAgentDiffs(left: AgentScreenAgent, right: AgentScreenAgent): string[] { const reasons: string[] = []; if (left.serverId !== right.serverId) reasons.push("agent.serverId"); if (left.id !== right.id) reasons.push("agent.id"); + if (left.workspaceId !== right.workspaceId) reasons.push("agent.workspaceId"); if (left.status !== right.status) reasons.push("agent.status"); if (left.cwd !== right.cwd) reasons.push("agent.cwd"); if (!agentCapabilityFlagsEqual(left.capabilities, right.capabilities)) { reasons.push("agent.capabilities"); } if (left.lastError !== right.lastError) reasons.push("agent.lastError"); - if (left.projectPlacement?.checkout?.cwd !== right.projectPlacement?.checkout?.cwd) { - reasons.push("agent.projectPlacement.checkout.cwd"); - } - if (left.projectPlacement?.checkout?.isGit !== right.projectPlacement?.checkout?.isGit) { - reasons.push("agent.projectPlacement.checkout.isGit"); - } + reasons.push(...collectAgentSetupDiffs(left, right)); + reasons.push(...collectAgentProjectPlacementDiffs(left.projectPlacement, right.projectPlacement)); return reasons; } diff --git a/packages/app/src/app/new.tsx b/packages/app/src/app/new.tsx index 98fc99b99..281ec3355 100644 --- a/packages/app/src/app/new.tsx +++ b/packages/app/src/app/new.tsx @@ -8,16 +8,19 @@ export default function NewWorkspaceRoute() { dir?: string; name?: string; projectId?: string; + draftId?: string; }>(); const serverId = typeof params.serverId === "string" ? params.serverId : ""; const sourceDirectory = typeof params.dir === "string" ? params.dir : undefined; const displayName = typeof params.name === "string" ? params.name : undefined; const projectId = typeof params.projectId === "string" ? params.projectId : undefined; + const draftId = typeof params.draftId === "string" ? params.draftId : undefined; const screenKey = JSON.stringify([ serverId, sourceDirectory ?? null, displayName ?? null, projectId ?? null, + draftId ?? null, ]); return ( @@ -28,6 +31,7 @@ export default function NewWorkspaceRoute() { sourceDirectory={sourceDirectory} displayName={displayName} projectId={projectId} + draftId={draftId} /> ); diff --git a/packages/app/src/attachments/attachment-pill-content.tsx b/packages/app/src/attachments/attachment-pill-content.tsx new file mode 100644 index 000000000..540e0230a --- /dev/null +++ b/packages/app/src/attachments/attachment-pill-content.tsx @@ -0,0 +1,141 @@ +import type { ReactNode } from "react"; +import type { TFunction } from "i18next"; +import { + CircleDot, + FileText, + GitPullRequest, + MessageSquareCode, + MousePointer2, +} from "lucide-react-native"; +import { withUnistyles } from "react-native-unistyles"; +import type { AgentAttachment } from "@getpaseo/protocol/messages"; +import type { WorkspaceComposerAttachment } from "@/attachments/types"; +import { getFileTypeLabel } from "@/attachments/file-types"; +import { isPullRequestContextAttachment } from "@/attachments/workspace-attachment-utils"; +import { ICON_SIZE, type Theme } from "@/styles/theme"; + +export interface AttachmentPillContent { + icon: ReactNode; + title: string; + subtitle: string; +} + +function getReviewSubtitle(count: number, t: TFunction): string { + return count === 1 + ? t("message.attachments.commentsOne") + : t("message.attachments.commentsMany", { count }); +} + +function getPullRequestContextSubtitle(attachment: WorkspaceComposerAttachment): string { + if (attachment.kind === "github.pull_request_check") { + return "Check logs"; + } + if (attachment.kind === "github.pull_request_comment") { + return "Comment"; + } + return "Review"; +} + +function getTextAttachmentSubtitle( + attachment: Extract, + t: TFunction, +): string { + if (attachment.contextKind === "chat_history") { + return "Previous conversation"; + } + return t("message.attachments.text"); +} + +export function getAgentAttachmentPillContent( + attachment: AgentAttachment, + t: TFunction, +): AttachmentPillContent { + switch (attachment.type) { + case "review": + return { + icon: attachmentReviewIcon, + title: t("message.attachments.review"), + subtitle: getReviewSubtitle(attachment.comments.length, t), + }; + case "github_pr": + return { + icon: attachmentGithubPrIcon, + title: attachment.title, + subtitle: `PR #${attachment.number}`, + }; + case "github_issue": + return { + icon: attachmentGithubIssueIcon, + title: attachment.title, + subtitle: `Issue #${attachment.number}`, + }; + case "text": + return { + icon: attachmentFileIcon, + title: attachment.title ?? t("message.attachments.textAttachment"), + subtitle: getTextAttachmentSubtitle(attachment, t), + }; + case "uploaded_file": + return { + icon: attachmentFileIcon, + title: attachment.fileName, + subtitle: getFileTypeLabel(attachment.fileName) ?? t("message.attachments.file"), + }; + } +} + +export function getWorkspaceAttachmentPillContent( + attachment: WorkspaceComposerAttachment, + t: TFunction, +): AttachmentPillContent { + if (attachment.kind === "browser_element") { + return { + icon: attachmentBrowserIcon, + title: attachment.attachment.tag, + subtitle: t("composer.attachments.element"), + }; + } + if (isPullRequestContextAttachment(attachment)) { + return { + icon: attachmentFileIcon, + title: attachment.title, + subtitle: getPullRequestContextSubtitle(attachment), + }; + } + if (attachment.kind === "chat_history") { + return { + icon: attachmentFileIcon, + title: attachment.attachment.title ?? t("message.attachments.textAttachment"), + subtitle: getTextAttachmentSubtitle(attachment.attachment, t), + }; + } + return { + icon: attachmentReviewIcon, + title: t("message.attachments.review"), + subtitle: getReviewSubtitle(attachment.commentCount, t), + }; +} + +const ThemedAttachmentFileText = withUnistyles(FileText); +const ThemedAttachmentGitPullRequest = withUnistyles(GitPullRequest); +const ThemedAttachmentCircleDot = withUnistyles(CircleDot); +const ThemedAttachmentMessageSquareCode = withUnistyles(MessageSquareCode); +const ThemedAttachmentMousePointer = withUnistyles(MousePointer2); + +const iconForegroundMutedMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted }); + +const attachmentReviewIcon = ( + +); +const attachmentGithubPrIcon = ( + +); +const attachmentGithubIssueIcon = ( + +); +const attachmentFileIcon = ( + +); +const attachmentBrowserIcon = ( + +); diff --git a/packages/app/src/attachments/types.ts b/packages/app/src/attachments/types.ts index 24cbf02f1..e4dcde5ce 100644 --- a/packages/app/src/attachments/types.ts +++ b/packages/app/src/attachments/types.ts @@ -63,6 +63,18 @@ export type PullRequestContextAttachment = | ({ kind: "github.pull_request_review" } & PullRequestContextAttachmentFields) | ({ kind: "github.pull_request_check" } & PullRequestContextAttachmentFields); +export interface ChatHistoryContextAttachment { + kind: "chat_history"; + id: string; + attachment: Extract; + source: { + serverId: string; + agentId: string; + boundaryMessageId?: string | null; + itemCount?: number; + }; +} + export type UserComposerAttachment = | { kind: "image"; metadata: AttachmentMetadata } | { kind: "file"; attachment: UploadedFileAttachment } @@ -75,6 +87,7 @@ export type WorkspaceComposerAttachment = attachment: BrowserElementAttachment; } | PullRequestContextAttachment + | ChatHistoryContextAttachment | { kind: "review"; attachment: Extract; diff --git a/packages/app/src/attachments/workspace-attachment-utils.ts b/packages/app/src/attachments/workspace-attachment-utils.ts index 8284be001..141c51d01 100644 --- a/packages/app/src/attachments/workspace-attachment-utils.ts +++ b/packages/app/src/attachments/workspace-attachment-utils.ts @@ -22,6 +22,7 @@ export function isWorkspaceAttachment( return ( attachment?.kind === "review" || attachment?.kind === "browser_element" || + attachment?.kind === "chat_history" || isPullRequestContextAttachment(attachment) ); } @@ -33,6 +34,7 @@ export function userAttachmentsOnly( (attachment): attachment is UserComposerAttachment => attachment.kind !== "review" && attachment.kind !== "browser_element" && + attachment.kind !== "chat_history" && !isPullRequestContextAttachment(attachment), ); } @@ -56,5 +58,8 @@ export function workspaceAttachmentToSubmitAttachment( text: attachment.text, }; } + if (attachment.kind === "chat_history") { + return attachment.attachment; + } return attachment.kind === "review" ? attachment.attachment : null; } diff --git a/packages/app/src/attachments/workspace-attachments-store.test.ts b/packages/app/src/attachments/workspace-attachments-store.test.ts index 888a21d49..2b603e805 100644 --- a/packages/app/src/attachments/workspace-attachments-store.test.ts +++ b/packages/app/src/attachments/workspace-attachments-store.test.ts @@ -2,7 +2,9 @@ import { describe, expect, it } from "vitest"; import type { WorkspaceComposerAttachment } from "./types"; import { appendWorkspaceAttachment, + buildDraftWorkspaceAttachmentScopeKey, buildWorkspaceAttachmentScopeKey, + collectWorkspaceAttachmentsForScopes, resetWorkspaceAttachmentsStore, useWorkspaceAttachmentsStore, } from "./workspace-attachments-store"; @@ -57,6 +59,24 @@ function contextAttachment(id: string): WorkspaceComposerAttachment { }; } +function chatHistoryAttachment(id: string, text = "Previous chat."): WorkspaceComposerAttachment { + return { + kind: "chat_history", + id, + attachment: { + type: "text", + mimeType: "text/plain", + contextKind: "chat_history", + title: "Chat history", + text, + }, + source: { + serverId: "local", + agentId: "agent-1", + }, + }; +} + describe("workspace attachments store", () => { it("scopes workspace attachments by server and workspace before cwd fallback", () => { expect( @@ -76,6 +96,12 @@ describe("workspace attachments store", () => { ).toBe("workspace-attachments:server=local:cwd=%2Frepo"); }); + it("scopes draft attachments by draft id", () => { + expect(buildDraftWorkspaceAttachmentScopeKey("draft-1")).toBe( + "workspace-attachments:draft=draft-1", + ); + }); + it("publishes and clears attachments for a workspace scope", () => { resetWorkspaceAttachmentsStore(); const scopeKey = buildWorkspaceAttachmentScopeKey({ @@ -116,6 +142,13 @@ describe("workspace attachments store", () => { expect(appendWorkspaceAttachment([original], replacement)).toEqual([replacement]); }); + it("dedupes repeated chat history attachments by id", () => { + const original = chatHistoryAttachment("chat_history:draft-1", "Original chat."); + const replacement = chatHistoryAttachment("chat_history:draft-1", "Updated chat."); + + expect(appendWorkspaceAttachment([original], replacement)).toEqual([replacement]); + }); + it("adds a workspace attachment against the current scope state", () => { resetWorkspaceAttachmentsStore(); const scopeKey = buildWorkspaceAttachmentScopeKey({ @@ -138,4 +171,25 @@ describe("workspace attachments store", () => { context, ]); }); + + it("collects attachments across requested scopes in scope order", () => { + const draftScopeKey = buildDraftWorkspaceAttachmentScopeKey("draft-1"); + const workspaceScopeKey = buildWorkspaceAttachmentScopeKey({ + serverId: "local", + workspaceId: "workspace-1", + cwd: "/repo", + }); + const draftContext = chatHistoryAttachment("chat_history:draft-1"); + const workspaceContext = contextAttachment("comment-1"); + + expect( + collectWorkspaceAttachmentsForScopes({ + attachmentsByScope: { + [workspaceScopeKey]: [workspaceContext], + [draftScopeKey]: [draftContext], + }, + scopeKeys: [` ${draftScopeKey} `, "", workspaceScopeKey], + }), + ).toEqual([draftContext, workspaceContext]); + }); }); diff --git a/packages/app/src/attachments/workspace-attachments-store.ts b/packages/app/src/attachments/workspace-attachments-store.ts index 12ccc130c..7010c06dc 100644 --- a/packages/app/src/attachments/workspace-attachments-store.ts +++ b/packages/app/src/attachments/workspace-attachments-store.ts @@ -1,15 +1,26 @@ import { useMemo } from "react"; import { create } from "zustand"; +import { useShallow } from "zustand/shallow"; import type { WorkspaceComposerAttachment } from "./types"; const EMPTY_WORKSPACE_ATTACHMENTS: readonly WorkspaceComposerAttachment[] = []; export interface WorkspaceAttachmentScopeInput { + kind?: "workspace"; serverId: string; workspaceId?: string | null; cwd: string; } +export interface DraftWorkspaceAttachmentScopeInput { + kind: "draft"; + draftId: string; +} + +export type WorkspaceAttachmentScope = + | WorkspaceAttachmentScopeInput + | DraftWorkspaceAttachmentScopeInput; + interface WorkspaceAttachmentsStoreState { attachmentsByScope: Record; } @@ -52,6 +63,10 @@ export function buildWorkspaceAttachmentScopeKey(input: WorkspaceAttachmentScope ); } +export function buildDraftWorkspaceAttachmentScopeKey(draftId: string): string { + return ["workspace-attachments", `draft=${encodeScopePart(draftId)}`].join(":"); +} + function areWorkspaceAttachmentsEqual( left: readonly WorkspaceComposerAttachment[], right: readonly WorkspaceComposerAttachment[], @@ -66,11 +81,12 @@ function areWorkspaceAttachmentsEqual( } function getContextAttachmentKey(attachment: WorkspaceComposerAttachment): string | null { - if ( - attachment.kind !== "github.pull_request_comment" && - attachment.kind !== "github.pull_request_review" && - attachment.kind !== "github.pull_request_check" - ) { + const isContextAttachment = + attachment.kind === "chat_history" || + attachment.kind === "github.pull_request_comment" || + attachment.kind === "github.pull_request_review" || + attachment.kind === "github.pull_request_check"; + if (!isContextAttachment) { return null; } return JSON.stringify({ @@ -145,11 +161,25 @@ export const useWorkspaceAttachmentsStore = create()( }, })); -export function useWorkspaceAttachmentScopeKey(input: WorkspaceAttachmentScopeInput): string { - const { serverId, workspaceId, cwd } = input; +export function useWorkspaceAttachmentScopeKey(input: WorkspaceAttachmentScope): string { + const isDraftScope = input.kind === "draft"; + const draftId = isDraftScope ? input.draftId : ""; + const serverId = isDraftScope ? "" : input.serverId; + const workspaceId = isDraftScope ? undefined : input.workspaceId; + const cwd = isDraftScope ? "" : input.cwd; + return useMemo(() => { + if (isDraftScope) { + return buildDraftWorkspaceAttachmentScopeKey(draftId); + } + return buildWorkspaceAttachmentScopeKey({ serverId, workspaceId, cwd }); + }, [cwd, draftId, isDraftScope, serverId, workspaceId]); +} + +export function useDraftWorkspaceAttachmentScopeKey(draftId: string | null | undefined): string { + const normalizedDraftId = useMemo(() => draftId?.trim() ?? "", [draftId]); return useMemo( - () => buildWorkspaceAttachmentScopeKey({ serverId, workspaceId, cwd }), - [serverId, workspaceId, cwd], + () => (normalizedDraftId ? buildDraftWorkspaceAttachmentScopeKey(normalizedDraftId) : ""), + [normalizedDraftId], ); } @@ -159,6 +189,43 @@ export function useWorkspaceAttachments(scopeKey: string): readonly WorkspaceCom ); } +export function collectWorkspaceAttachmentsForScopes(input: { + attachmentsByScope: Record; + scopeKeys: readonly string[]; +}): readonly WorkspaceComposerAttachment[] { + const attachments: WorkspaceComposerAttachment[] = []; + for (const scopeKey of input.scopeKeys) { + const normalizedScopeKey = scopeKey.trim(); + if (!normalizedScopeKey) { + continue; + } + attachments.push( + ...(input.attachmentsByScope[normalizedScopeKey] ?? EMPTY_WORKSPACE_ATTACHMENTS), + ); + } + return attachments; +} + +export function useWorkspaceAttachmentsForScopes( + scopeKeys: readonly string[] | undefined, +): readonly WorkspaceComposerAttachment[] { + const normalizedScopeKeys = useMemo( + () => (scopeKeys ?? []).map((scopeKey) => scopeKey.trim()).filter(Boolean), + [scopeKeys], + ); + const attachmentsByScope = useWorkspaceAttachmentsStore( + useShallow((state) => state.attachmentsByScope), + ); + return useMemo( + () => + collectWorkspaceAttachmentsForScopes({ + attachmentsByScope, + scopeKeys: normalizedScopeKeys, + }), + [attachmentsByScope, normalizedScopeKeys], + ); +} + export function resetWorkspaceAttachmentsStore(): void { useWorkspaceAttachmentsStore.setState({ attachmentsByScope: {} }); } diff --git a/packages/app/src/components/assistant-fork-menu.tsx b/packages/app/src/components/assistant-fork-menu.tsx new file mode 100644 index 000000000..59c052197 --- /dev/null +++ b/packages/app/src/components/assistant-fork-menu.tsx @@ -0,0 +1,148 @@ +import { memo, useCallback, useMemo, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { Text, View } from "react-native"; +import { FolderPlus, Split } from "lucide-react-native"; +import { StyleSheet, withUnistyles } from "react-native-unistyles"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; +import type { Theme } from "@/styles/theme"; + +export type AssistantForkTarget = "tab" | "workspace"; + +interface AssistantForkMenuProps { + onFork: (target: AssistantForkTarget) => Promise | void; + testID?: string; +} + +const ThemedFolderPlus = withUnistyles(FolderPlus); +const ThemedSplit = withUnistyles(Split); + +const foregroundColorMapping = (theme: Theme) => ({ color: theme.colors.foreground }); +const foregroundMutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted }); + +function getIcon(target: AssistantForkTarget) { + switch (target) { + case "tab": + return ; + case "workspace": + return ; + } +} + +export const AssistantForkMenu = memo(function AssistantForkMenu({ + onFork, + testID = "assistant-fork-menu", +}: AssistantForkMenuProps) { + const { t } = useTranslation(); + const [isOpen, setIsOpen] = useState(false); + const [pendingTarget, setPendingTarget] = useState(null); + const isLocked = pendingTarget !== null; + + const handleOpenChange = useCallback( + (next: boolean) => { + if (!next && pendingTarget !== null) return; + setIsOpen(next); + }, + [pendingTarget], + ); + + const handleSelect = useCallback( + (target: AssistantForkTarget) => async () => { + if (isLocked) return; + setPendingTarget(target); + try { + await onFork(target); + } finally { + setPendingTarget(null); + setIsOpen(false); + } + }, + [isLocked, onFork], + ); + + const triggerStyle = useCallback( + () => [styles.trigger, isLocked ? styles.triggerDisabled : null], + [isLocked], + ); + + const tooltipContent = useMemo( + () => ( + + {t("message.actions.forkMenu")} + + ), + [t], + ); + + return ( + + + + + + {({ hovered, open }) => ( + + )} + + + + {tooltipContent} + + + + {t("message.actions.forkInNewTab")} + + + {t("message.actions.forkInNewWorkspace")} + + + + ); +}); + +const styles = StyleSheet.create((theme) => ({ + trigger: { + padding: theme.spacing[1], + alignItems: "center", + justifyContent: "center", + backgroundColor: "transparent", + }, + triggerDisabled: { + opacity: theme.opacity[50], + }, + triggerSlot: { + alignSelf: "center", + }, + tooltipText: { + color: theme.colors.foreground, + fontSize: theme.fontSize.xs, + }, +})); diff --git a/packages/app/src/components/message.tsx b/packages/app/src/components/message.tsx index 30f45c541..6a8cf8922 100644 --- a/packages/app/src/components/message.tsx +++ b/packages/app/src/components/message.tsx @@ -32,7 +32,6 @@ import { useQuery } from "@tanstack/react-query"; import MaskedView from "@react-native-masked-view/masked-view"; import { Circle, - CircleDot, Info, CheckCircle, XCircle, @@ -42,15 +41,13 @@ import { Check, CheckSquare, Copy, - GitPullRequest, - MessageSquareCode, TriangleAlertIcon, Scissors, MicVocal, FileSymlink, } from "lucide-react-native"; import { StyleSheet, withUnistyles } from "react-native-unistyles"; -import { ICON_SIZE, type Theme } from "@/styles/theme"; +import type { Theme } from "@/styles/theme"; import { useIsCompactFormFactor } from "@/constants/layout"; import Animated, { Easing, @@ -89,6 +86,7 @@ import { getFileNameFromPath, parseImageDataUrl, } from "@/attachments/utils"; +import { getAgentAttachmentPillContent } from "@/attachments/attachment-pill-content"; import { PlanCard } from "./plan-card"; import { useToolCallSheet } from "./tool-call-sheet"; import { ToolCallDetailsContent } from "./tool-call-details"; @@ -104,7 +102,6 @@ import { import { getCompactionMarkerLabel } from "./message-compaction-label"; import { useAttachmentPreviewUrl } from "@/attachments/use-attachment-preview-url"; import { persistAttachmentFromBytes, persistAttachmentFromDataUrl } from "@/attachments/service"; -import { getFileTypeLabel } from "@/attachments/file-types"; import { AttachmentFrame, AttachmentLabel, @@ -116,7 +113,9 @@ import { isWeb, isNative } from "@/constants/platform"; import type { AgentCapabilityFlags } from "@getpaseo/protocol/agent-types"; import { RewindMenu, type RewindMode } from "@/components/rewind/rewind-menu"; import { useRewindAgentMutation } from "@/components/rewind/use-rewind-agent-mutation"; +import { AssistantForkMenu, type AssistantForkTarget } from "@/components/assistant-fork-menu"; export type { InlinePathTarget } from "@/assistant-file-links"; +export type { AssistantForkTarget }; interface UserMessageProps { serverId?: string; @@ -170,10 +169,6 @@ const ThemedTodoCheckIcon = withUnistyles(Check); const ThemedFileSymlinkIcon = withUnistyles(FileSymlink); const ThemedTriangleAlertIcon = withUnistyles(TriangleAlertIcon); const ThemedChevronRightIcon = withUnistyles(ChevronRight); -const ThemedAttachmentFileText = withUnistyles(FileText); -const ThemedGitPullRequest = withUnistyles(GitPullRequest); -const ThemedCircleDot = withUnistyles(CircleDot); -const ThemedMessageSquareCode = withUnistyles(MessageSquareCode); const foregroundColorMapping = (theme: Theme) => ({ color: theme.colors.foreground }); const foregroundMutedColorMapping = (theme: Theme) => ({ @@ -402,19 +397,6 @@ const userMessageStylesheet = StyleSheet.create((theme) => ({ }, })); -const attachmentReviewIcon = ( - -); -const attachmentGithubPrIcon = ( - -); -const attachmentGithubIssueIcon = ( - -); -const attachmentFileIcon = ( - -); - interface UserMessageImagePillProps { image: UserMessageImageAttachment; onOpen: (image: UserMessageImageAttachment) => void; @@ -432,55 +414,6 @@ function UserMessageImagePill({ image, onOpen, accessibilityLabel }: UserMessage ); } -interface UserMessageAttachmentContent { - icon: ReactNode; - title: string; - subtitle: string; -} - -function getUserMessageAttachmentContent( - attachment: AgentAttachment, - t: ReturnType["t"], -): UserMessageAttachmentContent { - switch (attachment.type) { - case "review": { - const count = attachment.comments.length; - return { - icon: attachmentReviewIcon, - title: t("message.attachments.review"), - subtitle: - count === 1 - ? t("message.attachments.commentsOne") - : t("message.attachments.commentsMany", { count }), - }; - } - case "github_pr": - return { - icon: attachmentGithubPrIcon, - title: attachment.title, - subtitle: `PR #${attachment.number}`, - }; - case "github_issue": - return { - icon: attachmentGithubIssueIcon, - title: attachment.title, - subtitle: `Issue #${attachment.number}`, - }; - case "text": - return { - icon: attachmentFileIcon, - title: attachment.title ?? t("message.attachments.textAttachment"), - subtitle: t("message.attachments.text"), - }; - case "uploaded_file": - return { - icon: attachmentFileIcon, - title: attachment.fileName, - subtitle: getFileTypeLabel(attachment.fileName) ?? t("message.attachments.file"), - }; - } -} - export const UserMessage = memo(function UserMessage({ serverId, agentId, @@ -579,7 +512,7 @@ export const UserMessage = memo(function UserMessage({ {hasAttachments ? ( {attachments.map((attachment, index) => { - const content = getUserMessageAttachmentContent(attachment, t); + const content = getAgentAttachmentPillContent(attachment, t); return ( string; completedAt?: Date; durationMs?: number; + forkBoundaryMessageId?: string; + onFork?: (input: { + target: AssistantForkTarget; + boundaryMessageId?: string; + }) => Promise | void; } const assistantTurnFooterStylesheet = StyleSheet.create((theme) => ({ @@ -672,6 +610,8 @@ export const AssistantTurnFooter = memo(function AssistantTurnFooter({ getContent, completedAt, durationMs, + forkBoundaryMessageId, + onFork, }: AssistantTurnFooterProps) { const [hovered, setHovered] = useState(false); const [pressedReveal, setPressedReveal] = useState(false); @@ -711,6 +651,13 @@ export const AssistantTurnFooter = memo(function AssistantTurnFooter({ revealTimerRef.current = null; }, TIMESTAMP_REVEAL_MS); }, [canSwap]); + const handleFork = useCallback( + (target: AssistantForkTarget) => { + return onFork?.({ target, boundaryMessageId: forkBoundaryMessageId }); + }, + [forkBoundaryMessageId, onFork], + ); + const canFork = Boolean(onFork && forkBoundaryMessageId); return ( @@ -718,6 +665,7 @@ export const AssistantTurnFooter = memo(function AssistantTurnFooter({ getContent={getContent} containerStyle={assistantTurnFooterStylesheet.copyButton} /> + {canFork ? : null} {durationLabel ? ( ({ + filePath: comment.filePath, + side: comment.side, + lineNumber: comment.lineNumber, + body: comment.body, + })), + }); +} + +export function removeWorkspaceAttachmentsMatching(selectedKey: string): void { + const { attachmentsByScope, setWorkspaceAttachments } = useWorkspaceAttachmentsStore.getState(); + for (const [scopeKey, attachments] of Object.entries(attachmentsByScope)) { + const nextAttachments = attachments.filter( + (attachment) => getAttachmentKey(attachment) !== selectedKey, + ); + if (nextAttachments.length !== attachments.length) { + setWorkspaceAttachments({ scopeKey, attachments: nextAttachments }); + } + } +} + +function isSentContextAttachment( + attachment: ComposerAttachment, +): attachment is WorkspaceComposerAttachment { + return ( + attachment.kind === "browser_element" || + attachment.kind === "chat_history" || + isPullRequestContextAttachment(attachment) + ); +} + +export function removeSentContextAttachments(attachments: readonly ComposerAttachment[]): void { + const sentContextKeys = attachments.filter(isSentContextAttachment).map(getAttachmentKey); + for (const key of sentContextKeys) { + removeWorkspaceAttachmentsMatching(key); + } +} diff --git a/packages/app/src/composer/attachments/workspace.test.ts b/packages/app/src/composer/attachments/workspace.test.ts new file mode 100644 index 000000000..0a4353f62 --- /dev/null +++ b/packages/app/src/composer/attachments/workspace.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it } from "vitest"; +import type { WorkspaceComposerAttachment } from "@/attachments/types"; +import { + buildDraftWorkspaceAttachmentScopeKey, + resetWorkspaceAttachmentsStore, + useWorkspaceAttachmentsStore, +} from "@/attachments/workspace-attachments-store"; +import { removeSentContextAttachments } from "./workspace-cleanup"; + +function chatHistoryAttachment(): WorkspaceComposerAttachment { + return { + kind: "chat_history", + id: "chat_history:draft-1", + attachment: { + type: "text", + mimeType: "text/plain", + contextKind: "chat_history", + title: "Chat history", + text: "Previous chat.", + }, + source: { + serverId: "local", + agentId: "agent-1", + }, + }; +} + +function pullRequestContextAttachment(): WorkspaceComposerAttachment { + return { + kind: "github.pull_request_comment", + id: "comment-1", + title: "Comment", + text: "Please check this.", + }; +} + +function browserElementAttachment(): WorkspaceComposerAttachment { + return { + kind: "browser_element", + attachment: { + url: "https://example.com", + selector: "button.primary", + tag: "button", + text: "Click me", + outerHTML: '', + computedStyles: {}, + boundingRect: { x: 0, y: 0, width: 100, height: 40 }, + reactSource: null, + parentChain: [], + children: [], + formatted: "button.primary\nClick me", + }, + }; +} + +describe("workspace composer attachment cleanup", () => { + it("clears sent scoped context attachments from their stores", () => { + resetWorkspaceAttachmentsStore(); + const scopeKey = buildDraftWorkspaceAttachmentScopeKey("draft-1"); + const chatHistory = chatHistoryAttachment(); + const pullRequestContext = pullRequestContextAttachment(); + const browserElement = browserElementAttachment(); + useWorkspaceAttachmentsStore.getState().setWorkspaceAttachments({ + scopeKey, + attachments: [chatHistory, pullRequestContext, browserElement], + }); + + removeSentContextAttachments([chatHistory, pullRequestContext, browserElement]); + + expect(useWorkspaceAttachmentsStore.getState().attachmentsByScope[scopeKey]).toBeUndefined(); + }); +}); diff --git a/packages/app/src/composer/attachments/workspace.tsx b/packages/app/src/composer/attachments/workspace.tsx index e41424793..0beff1fb5 100644 --- a/packages/app/src/composer/attachments/workspace.tsx +++ b/packages/app/src/composer/attachments/workspace.tsx @@ -1,25 +1,25 @@ import { useCallback, useEffect, useMemo, useState, type ReactElement } from "react"; import { useTranslation } from "react-i18next"; -import { withUnistyles } from "react-native-unistyles"; -import { FileText, MessageSquareCode, MousePointer2 } from "lucide-react-native"; import type { ComposerAttachment, UserComposerAttachment, WorkspaceComposerAttachment, } from "@/attachments/types"; +import { getWorkspaceAttachmentPillContent } from "@/attachments/attachment-pill-content"; import { AttachmentLabel, AttachmentPill } from "@/components/attachment-pill"; -import { useWorkspaceAttachmentsStore } from "@/attachments/workspace-attachments-store"; import { isWorkspaceAttachment, isPullRequestContextAttachment, userAttachmentsOnly, workspaceAttachmentToSubmitAttachment, } from "@/attachments/workspace-attachment-utils"; -import { ICON_SIZE, type Theme } from "@/styles/theme"; +import { + getAttachmentKey, + removeSentContextAttachments, + removeWorkspaceAttachmentsMatching, +} from "./workspace-cleanup"; import { useClearReviewDraft } from "@/review/store"; -type TranslationFn = ReturnType["t"]; - interface WorkspaceAttachmentBindingInput { normalAttachments: UserComposerAttachment[]; workspaceAttachments: readonly WorkspaceComposerAttachment[]; @@ -50,97 +50,9 @@ interface ComposerWorkspaceAttachmentBinding { resetSuppression: () => void; } -function getAttachmentKey(attachment: WorkspaceComposerAttachment): string { - if (attachment.kind === "browser_element") { - return JSON.stringify({ - type: "browser_element", - url: attachment.attachment.url, - selector: attachment.attachment.selector, - tag: attachment.attachment.tag, - text: attachment.attachment.text, - html: attachment.attachment.outerHTML, - }); - } - if (isPullRequestContextAttachment(attachment)) { - return JSON.stringify({ - kind: attachment.kind, - id: attachment.id, - }); - } - return JSON.stringify({ - type: "review", - cwd: attachment.attachment.cwd, - mode: attachment.attachment.mode, - baseRef: attachment.attachment.baseRef ?? null, - reviewDraftKey: attachment.reviewDraftKey, - comments: attachment.attachment.comments.map((comment) => ({ - filePath: comment.filePath, - side: comment.side, - lineNumber: comment.lineNumber, - body: comment.body, - })), - }); -} - -function removeWorkspaceAttachmentsMatching(selectedKey: string): void { - const { attachmentsByScope, setWorkspaceAttachments } = useWorkspaceAttachmentsStore.getState(); - for (const [scopeKey, attachments] of Object.entries(attachmentsByScope)) { - const nextAttachments = attachments.filter( - (attachment) => getAttachmentKey(attachment) !== selectedKey, - ); - if (nextAttachments.length !== attachments.length) { - setWorkspaceAttachments({ scopeKey, attachments: nextAttachments }); - } - } -} - -function removeSentContextAttachments(attachments: readonly ComposerAttachment[]): void { - const sentContextKeys = attachments.filter(isPullRequestContextAttachment).map(getAttachmentKey); - for (const key of sentContextKeys) { - removeWorkspaceAttachmentsMatching(key); - } -} - -function getContextSourceLabel(attachment: WorkspaceComposerAttachment): string { - if (attachment.kind === "github.pull_request_check") { - return "Check logs"; - } - if (attachment.kind === "github.pull_request_comment") { - return "Comment"; - } - return "Review"; -} - -interface PillContent { - title: string; - subtitle: string; -} - -function getPillContent(attachment: WorkspaceComposerAttachment, t: TranslationFn): PillContent { - if (attachment.kind === "browser_element") { - return { - title: attachment.attachment.tag, - subtitle: t("composer.attachments.element"), - }; - } - if (isPullRequestContextAttachment(attachment)) { - return { - title: attachment.title, - subtitle: getContextSourceLabel(attachment), - }; - } - return { - title: t("message.attachments.review"), - subtitle: - attachment.commentCount === 1 - ? t("message.attachments.commentsOne") - : t("message.attachments.commentsMany", { count: attachment.commentCount }), - }; -} - function getOpenAccessibilityLabel( attachment: WorkspaceComposerAttachment, - t: TranslationFn, + t: ReturnType["t"], ): string { if (attachment.kind === "browser_element") { return t("composer.attachments.openBrowserElement"); @@ -148,12 +60,15 @@ function getOpenAccessibilityLabel( if (isPullRequestContextAttachment(attachment)) { return "Open context attachment"; } + if (attachment.kind === "chat_history") { + return "Open chat history attachment"; + } return t("composer.attachments.openReview"); } function getRemoveAccessibilityLabel( attachment: WorkspaceComposerAttachment, - t: TranslationFn, + t: ReturnType["t"], ): string { if (attachment.kind === "browser_element") { return t("composer.attachments.removeBrowserElement"); @@ -161,17 +76,17 @@ function getRemoveAccessibilityLabel( if (isPullRequestContextAttachment(attachment)) { return "Remove context attachment"; } + if (attachment.kind === "chat_history") { + return "Remove chat history attachment"; + } return t("composer.attachments.removeReview"); } -function renderPillIcon(attachment: WorkspaceComposerAttachment): ReactElement { - if (attachment.kind === "browser_element") { - return ; +function getPillTestID(attachment: WorkspaceComposerAttachment): string { + if (attachment.kind === "chat_history") { + return "composer-chat-history-attachment-pill"; } - if (isPullRequestContextAttachment(attachment)) { - return ; - } - return ; + return "composer-review-attachment-pill"; } function renderPill(args: RenderWorkspaceAttachmentPillArgs): ReactElement { @@ -249,7 +164,11 @@ function useWorkspaceAttachmentBinding({ ({ selectedAttachments: current, index }: RemoveWorkspaceAttachmentInput) => { const selected = current[index]; if (isWorkspaceAttachment(selected)) { - if (selected.kind === "browser_element" || isPullRequestContextAttachment(selected)) { + if ( + selected.kind === "browser_element" || + selected.kind === "chat_history" || + isPullRequestContextAttachment(selected) + ) { const selectedKey = getAttachmentKey(selected); removeWorkspaceAttachmentsMatching(selectedKey); return true; @@ -323,7 +242,7 @@ function WorkspaceAttachmentPill({ onRemove, }: WorkspaceAttachmentPillProps) { const { t } = useTranslation(); - const content = getPillContent(attachment, t); + const content = getWorkspaceAttachmentPillContent(attachment, t); const handleOpen = useCallback(() => { onOpen(attachment); }, [onOpen, attachment]); @@ -332,18 +251,14 @@ function WorkspaceAttachmentPill({ }, [onRemove, index]); return ( - + ); } @@ -355,8 +270,3 @@ export const composerWorkspaceAttachment = { userAttachmentsOnly, useBinding: useWorkspaceAttachmentBinding, }; - -const ThemedMousePointer2 = withUnistyles(MousePointer2); -const ThemedMessageSquareCode = withUnistyles(MessageSquareCode); -const ThemedFileText = withUnistyles(FileText); -const iconForegroundMutedMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted }); diff --git a/packages/app/src/composer/draft/create-flow.test.ts b/packages/app/src/composer/draft/create-flow.test.ts index 70ae16b66..41ea2a4d9 100644 --- a/packages/app/src/composer/draft/create-flow.test.ts +++ b/packages/app/src/composer/draft/create-flow.test.ts @@ -87,4 +87,63 @@ describe("useDraftAgentCreateFlow", () => { }); expect(onCreateSuccess).toHaveBeenCalledTimes(1); }); + + it("allows retrying an empty prompt when the draft still has context attachments", async () => { + const attachment = { + kind: "chat_history", + id: "chat-history-1", + attachment: { + type: "text", + mimeType: "text/plain", + contextKind: "chat_history", + title: "Chat history", + text: "Previous conversation", + }, + source: { + serverId: "server-1", + agentId: "agent-source", + }, + } as const; + const createRequest = vi.fn(async () => ({ + agentId: "agent-1", + result: { id: "agent-1" }, + })); + const onCreateSuccess = vi.fn(); + const validateBeforeSubmit = vi.fn(() => null); + + const { result } = renderHook(() => + useDraftAgentCreateFlow({ + draftId: "draft-1", + getPendingServerId: () => "server-1", + buildDraftAgent: (currentAttempt) => ({ currentAttempt }), + createRequest, + onCreateSuccess, + validateBeforeSubmit, + }), + ); + + await act(async () => { + await result.current.handleCreateFromInput({ + text: " ", + attachments: [attachment], + cwd: "/repo", + }); + }); + + expect(validateBeforeSubmit).toHaveBeenCalledWith({ + text: "", + attachments: [attachment], + cwd: "/repo", + }); + expect(createRequest).toHaveBeenCalledWith({ + attempt: expect.objectContaining({ + text: "", + attachments: [attachment.attachment], + }), + text: "", + attachments: [attachment.attachment], + cwd: "/repo", + }); + expect(onCreateSuccess).toHaveBeenCalledTimes(1); + }); }); diff --git a/packages/app/src/composer/draft/create-flow.ts b/packages/app/src/composer/draft/create-flow.ts index 3e85b2a8b..82d063aac 100644 --- a/packages/app/src/composer/draft/create-flow.ts +++ b/packages/app/src/composer/draft/create-flow.ts @@ -241,7 +241,8 @@ export function useDraftAgentCreateFlow({ const images = wirePayload.images; const trimmedPrompt = text.trim(); - if (!trimmedPrompt && !allowEmptyText) { + const hasAttachmentContent = images.length > 0 || wirePayload.attachments.length > 0; + if (!trimmedPrompt && !hasAttachmentContent && !allowEmptyText) { const error = new Error(t("composer.errors.initialPromptRequired")); dispatch({ type: "DRAFT_SET_ERROR", message: error.message }); throw error; diff --git a/packages/app/src/composer/draft/workspace-tab-core.ts b/packages/app/src/composer/draft/workspace-tab-core.ts index 7e31b03a9..30411aeb0 100644 --- a/packages/app/src/composer/draft/workspace-tab-core.ts +++ b/packages/app/src/composer/draft/workspace-tab-core.ts @@ -5,6 +5,13 @@ export interface WorkspaceDraftAutoSubmitConfig { model: string | null; } +export function shouldAllowEmptyDraftText(input: { + allowsEmptyAutoSubmit: boolean; + attachments: readonly unknown[]; +}): boolean { + return input.allowsEmptyAutoSubmit || input.attachments.length > 0; +} + export function validateDraftSubmission(input: { text: string; allowsEmptyAutoSubmit: boolean; diff --git a/packages/app/src/composer/draft/workspace-tab.test.ts b/packages/app/src/composer/draft/workspace-tab.test.ts index 4f9a0e202..5952083a6 100644 --- a/packages/app/src/composer/draft/workspace-tab.test.ts +++ b/packages/app/src/composer/draft/workspace-tab.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "vitest"; -import { validateDraftSubmission } from "./workspace-tab-core"; +import { shouldAllowEmptyDraftText, validateDraftSubmission } from "./workspace-tab-core"; const baseComposerState = { providerDefinitions: [{ id: "codewhale" }], @@ -49,3 +49,23 @@ describe("workspace draft agent model validation", () => { ).toBe("No model is available for the selected provider"); }); }); + +describe("workspace draft empty text readiness", () => { + test("allows attachment-only retries after a fork draft create fails", () => { + expect( + shouldAllowEmptyDraftText({ + allowsEmptyAutoSubmit: false, + attachments: [{ kind: "chat_history" }], + }), + ).toBe(true); + }); + + test("still rejects empty drafts with no auto-submit and no attachments", () => { + expect( + shouldAllowEmptyDraftText({ + allowsEmptyAutoSubmit: false, + attachments: [], + }), + ).toBe(false); + }); +}); diff --git a/packages/app/src/composer/draft/workspace-tab.tsx b/packages/app/src/composer/draft/workspace-tab.tsx index 14507a386..dc1befb39 100644 --- a/packages/app/src/composer/draft/workspace-tab.tsx +++ b/packages/app/src/composer/draft/workspace-tab.tsx @@ -27,14 +27,18 @@ import { useWorkspaceDraftSubmissionStore } from "@/stores/workspace-draft-submi import { encodeImages } from "@/utils/encode-images"; import type { WorkspaceFileOpenRequest } from "@/workspace/file-open"; import { shouldAutoFocusWorkspaceDraftComposer } from "@/screens/workspace/workspace-draft-pane-focus"; -import { validateDraftSubmission } from "@/composer/draft/workspace-tab-core"; +import { + shouldAllowEmptyDraftText, + validateDraftSubmission, +} from "@/composer/draft/workspace-tab-core"; import type { AgentCapabilityFlags } from "@getpaseo/protocol/agent-types"; import type { AgentSnapshotPayload } from "@getpaseo/protocol/messages"; import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; import type { WorkspaceComposerAttachment } from "@/attachments/types"; import { - useWorkspaceAttachments, + useDraftWorkspaceAttachmentScopeKey, useWorkspaceAttachmentScopeKey, + useWorkspaceAttachmentsStore, } from "@/attachments/workspace-attachments-store"; import type { UserMessageImageAttachment } from "@/types/stream"; import { @@ -118,6 +122,7 @@ async function submitDraftCreateRequest(input: { text: string; images?: UserMessageImageAttachment[]; attachments?: unknown; + cwd: string; client: DaemonClient | null; workspaceDirectory: string | null; workspaceId: string | null; @@ -138,6 +143,7 @@ async function submitDraftCreateRequest(input: { text, images, attachments, + cwd, client, workspaceDirectory, workspaceId, @@ -162,7 +168,7 @@ async function submitDraftCreateRequest(input: { }); const config = buildWorkspaceDraftAgentConfig({ provider, - cwd: workspaceDirectory, + cwd, ...modeIdOverride, model: autoSubmitConfig?.model ?? (composerState.effectiveModelId || undefined), thinkingOptionId: @@ -400,7 +406,14 @@ export function WorkspaceDraftAgentTab({ cwd: composerState.workingDir, workspaceId, }); - const workspaceAttachments = useWorkspaceAttachments(workspaceAttachmentScopeKey); + const draftAttachmentScopeKey = useDraftWorkspaceAttachmentScopeKey(draftId); + const attachmentScopeKeys = useMemo( + () => [draftAttachmentScopeKey, workspaceAttachmentScopeKey].filter(Boolean), + [draftAttachmentScopeKey, workspaceAttachmentScopeKey], + ); + const clearWorkspaceAttachments = useWorkspaceAttachmentsStore( + (state) => state.clearWorkspaceAttachments, + ); const openFileExplorerForCheckout = usePanelStore((state) => state.openFileExplorerForCheckout); const setExplorerTabForCheckout = usePanelStore((state) => state.setExplorerTabForCheckout); const handleOpenWorkspaceAttachment = useCallback( @@ -437,15 +450,20 @@ export function WorkspaceDraftAgentTab({ getPendingServerId: () => serverId, initialAttempt: initialCreateAttempt, allowEmptyText: allowsEmptyAutoSubmit, - validateBeforeSubmit: ({ text }) => - validateDraftSubmission({ - text, + validateBeforeSubmit: ({ text, attachments }) => { + const allowsEmptyDraftText = shouldAllowEmptyDraftText({ allowsEmptyAutoSubmit, + attachments, + }); + return validateDraftSubmission({ + text, + allowsEmptyAutoSubmit: allowsEmptyDraftText, composerState, autoSubmitConfig, workspaceDirectory: draftWorkingDirectory, hasClient: Boolean(client), - }), + }); + }, onBeforeSubmit: async () => { await composerState.persistFormPreferences(); if (isWeb) { @@ -463,12 +481,13 @@ export function WorkspaceDraftAgentTab({ composerState, selectModelMessage: t("workspaceSetup.errors.selectModel"), }), - createRequest: async ({ attempt, text, images, attachments }) => + createRequest: async ({ attempt, text, images, attachments, cwd }) => submitDraftCreateRequest({ attempt, text, images, attachments, + cwd, client, workspaceDirectory: draftWorkingDirectory, workspaceId: workspaceFields?.id ?? null, @@ -479,6 +498,8 @@ export function WorkspaceDraftAgentTab({ }), onCreateSuccess: ({ result }) => { clearDraftInput("sent"); + clearWorkspaceAttachments({ scopeKey: draftAttachmentScopeKey }); + useWorkspaceDraftSubmissionStore.getState().clearDraftSetup({ draftId }); onCreated(result); }, }); @@ -690,7 +711,7 @@ export function WorkspaceDraftAgentTab({ value={draftInput.text} onChangeText={draftInput.setText} attachments={draftInput.attachments} - workspaceAttachments={workspaceAttachments} + attachmentScopeKeys={attachmentScopeKeys} onOpenWorkspaceAttachment={handleOpenWorkspaceAttachment} onChangeAttachments={draftInput.setAttachments} cwd={composerState.workingDir} diff --git a/packages/app/src/composer/index.tsx b/packages/app/src/composer/index.tsx index 26fa355d9..396b279ad 100644 --- a/packages/app/src/composer/index.tsx +++ b/packages/app/src/composer/index.tsx @@ -103,6 +103,7 @@ import type { } from "@/attachments/types"; import type { PickedFile } from "@/attachments/picked-file"; import { composerWorkspaceAttachment } from "@/composer/attachments/workspace"; +import { useWorkspaceAttachmentsForScopes } from "@/attachments/workspace-attachments-store"; import { droppedItemsToPickedFiles } from "@/composer/attachments/drop"; import { getFileTypeLabel } from "@/attachments/file-types"; import { Combobox, ComboboxItem, type ComboboxOption } from "@/components/ui/combobox"; @@ -121,6 +122,8 @@ type AttachmentListUpdater = | UserComposerAttachment[] | ((prev: UserComposerAttachment[]) => UserComposerAttachment[]); +const EMPTY_ATTACHMENT_SCOPE_KEYS: readonly string[] = []; + function noop() {} const noopCallback = () => {}; @@ -759,7 +762,7 @@ interface ComposerProps { value: string; onChangeText: (text: string) => void; attachments: UserComposerAttachment[]; - workspaceAttachments?: readonly WorkspaceComposerAttachment[]; + attachmentScopeKeys?: readonly string[]; onOpenWorkspaceAttachment?: (attachment: WorkspaceComposerAttachment) => void; onChangeAttachments: (updater: AttachmentListUpdater) => void; cwd: string; @@ -971,7 +974,7 @@ export function Composer({ value, onChangeText, attachments, - workspaceAttachments = [], + attachmentScopeKeys = EMPTY_ATTACHMENT_SCOPE_KEYS, onOpenWorkspaceAttachment, onChangeAttachments, cwd, @@ -1026,6 +1029,7 @@ export function Composer({ const messagePlaceholder = resolveMessagePlaceholder(isDesktopLayout, t); const userInput = value; const setUserInput = onChangeText; + const workspaceAttachments = useWorkspaceAttachmentsForScopes(attachmentScopeKeys); const { selectedAttachments, buildOutgoingAttachments, diff --git a/packages/app/src/hooks/use-agent-screen-state-machine.ts b/packages/app/src/hooks/use-agent-screen-state-machine.ts index 6a332b70c..9882ff5cf 100644 --- a/packages/app/src/hooks/use-agent-screen-state-machine.ts +++ b/packages/app/src/hooks/use-agent-screen-state-machine.ts @@ -1,15 +1,31 @@ import { useRef } from "react"; -import type { AgentCapabilityFlags } from "@getpaseo/protocol/agent-types"; +import type { + AgentCapabilityFlags, + AgentFeature, + AgentProvider, +} from "@getpaseo/protocol/agent-types"; export interface AgentScreenAgent { serverId: string; id: string; + provider?: AgentProvider; status: "initializing" | "idle" | "running" | "error" | "closed"; cwd: string; workspaceId?: string; capabilities?: AgentCapabilityFlags; + currentModeId?: string | null; + model?: string | null; + thinkingOptionId?: string | null; + runtimeInfo?: { + model?: string | null; + modeId?: string | null; + thinkingOptionId?: string | null; + } | null; + features?: readonly AgentFeature[]; lastError?: string | null; projectPlacement?: { + projectKey?: string; + projectName?: string; checkout?: { cwd?: string; isGit?: boolean; diff --git a/packages/app/src/i18n/resources/ar.ts b/packages/app/src/i18n/resources/ar.ts index 24bf54b36..860175303 100644 --- a/packages/app/src/i18n/resources/ar.ts +++ b/packages/app/src/i18n/resources/ar.ts @@ -245,6 +245,12 @@ export const ar: TranslationResources = { copyCode: "نسخ الرمز", copyTurn: "نسخ بدوره", copyMessage: "انسخ الرسالة", + forkMenu: "تفريع الرسالة", + forkInNewTab: "تفريع في تبويب جديد", + forkInNewWorkspace: "تفريع في مساحة عمل جديدة", + forkUnavailable: "حدّث المضيف لاستخدام هذا.", + forkMissingWorkspace: "هذا الوكيل ليس في مساحة عمل.", + forkFailed: "فشل تفريع المحادثة", openFile: "افتح الملف", copied: "منقول", }, diff --git a/packages/app/src/i18n/resources/en.ts b/packages/app/src/i18n/resources/en.ts index 35a12a7ff..e473546e1 100644 --- a/packages/app/src/i18n/resources/en.ts +++ b/packages/app/src/i18n/resources/en.ts @@ -244,6 +244,12 @@ export const en = { copyCode: "Copy code", copyTurn: "Copy turn", copyMessage: "Copy message", + forkMenu: "Fork message", + forkInNewTab: "Fork in a new tab", + forkInNewWorkspace: "Fork in a new workspace", + forkUnavailable: "Update the host to use this.", + forkMissingWorkspace: "This agent is not in a workspace.", + forkFailed: "Failed to fork chat", openFile: "Open file", copied: "Copied", }, diff --git a/packages/app/src/i18n/resources/es.ts b/packages/app/src/i18n/resources/es.ts index 2b2f0fbbe..95a1ef551 100644 --- a/packages/app/src/i18n/resources/es.ts +++ b/packages/app/src/i18n/resources/es.ts @@ -248,6 +248,12 @@ export const es: TranslationResources = { copyCode: "Copiar código", copyTurn: "Copiar turno", copyMessage: "Copiar mensaje", + forkMenu: "Bifurcar mensaje", + forkInNewTab: "Bifurcar en una pestaña nueva", + forkInNewWorkspace: "Bifurcar en un espacio de trabajo nuevo", + forkUnavailable: "Actualiza el host para usar esto.", + forkMissingWorkspace: "Este agente no está en un espacio de trabajo.", + forkFailed: "No se pudo bifurcar el chat", openFile: "Abrir archivo", copied: "Copiado", }, diff --git a/packages/app/src/i18n/resources/fr.ts b/packages/app/src/i18n/resources/fr.ts index 5c8a33779..0a8ed5bbb 100644 --- a/packages/app/src/i18n/resources/fr.ts +++ b/packages/app/src/i18n/resources/fr.ts @@ -248,6 +248,12 @@ export const fr: TranslationResources = { copyCode: "Copier le code", copyTurn: "Copier le tour", copyMessage: "Copier le message", + forkMenu: "Dupliquer le message", + forkInNewTab: "Dupliquer dans un nouvel onglet", + forkInNewWorkspace: "Dupliquer dans un nouvel espace de travail", + forkUnavailable: "Mettez l'hôte à jour pour utiliser ceci.", + forkMissingWorkspace: "Cet agent n'est pas dans un espace de travail.", + forkFailed: "Impossible de dupliquer le chat", openFile: "Ouvrir le fichier", copied: "Copié", }, diff --git a/packages/app/src/i18n/resources/ja.ts b/packages/app/src/i18n/resources/ja.ts index 4dc29ae4e..6deb07889 100644 --- a/packages/app/src/i18n/resources/ja.ts +++ b/packages/app/src/i18n/resources/ja.ts @@ -248,6 +248,12 @@ export const ja: TranslationResources = { copyCode: "コードをコピー", copyTurn: "ターンをコピー", copyMessage: "メッセージをコピー", + forkMenu: "メッセージをフォーク", + forkInNewTab: "新しいタブにフォーク", + forkInNewWorkspace: "新しいワークスペースにフォーク", + forkUnavailable: "これを使用するにはホストを更新してください。", + forkMissingWorkspace: "このエージェントはワークスペース内にありません。", + forkFailed: "チャットのフォークに失敗しました", openFile: "ファイルを開く", copied: "コピーしました", }, diff --git a/packages/app/src/i18n/resources/pt-BR.ts b/packages/app/src/i18n/resources/pt-BR.ts index 3dcad94fa..a38038048 100644 --- a/packages/app/src/i18n/resources/pt-BR.ts +++ b/packages/app/src/i18n/resources/pt-BR.ts @@ -248,6 +248,12 @@ export const ptBR: TranslationResources = { copyCode: "Copiar código", copyTurn: "Copiar turno", copyMessage: "Copiar mensagem", + forkMenu: "Bifurcar mensagem", + forkInNewTab: "Bifurcar em uma nova aba", + forkInNewWorkspace: "Bifurcar em um novo workspace", + forkUnavailable: "Atualize o host para usar isto.", + forkMissingWorkspace: "Este agente não está em um workspace.", + forkFailed: "Falha ao bifurcar o chat", openFile: "Abrir arquivo", copied: "Copiado", }, diff --git a/packages/app/src/i18n/resources/ru.ts b/packages/app/src/i18n/resources/ru.ts index 5e66f20fd..39a42b5a6 100644 --- a/packages/app/src/i18n/resources/ru.ts +++ b/packages/app/src/i18n/resources/ru.ts @@ -247,6 +247,12 @@ export const ru: TranslationResources = { copyCode: "Скопировать код", copyTurn: "Копировать ход", copyMessage: "Копировать сообщение", + forkMenu: "Форкнуть сообщение", + forkInNewTab: "Форкнуть в новой вкладке", + forkInNewWorkspace: "Форкнуть в новом рабочем пространстве", + forkUnavailable: "Обновите хост, чтобы использовать это.", + forkMissingWorkspace: "Этот агент не находится в рабочем пространстве.", + forkFailed: "Не удалось форкнуть чат", openFile: "Открыть файл", copied: "Скопировано", }, diff --git a/packages/app/src/i18n/resources/zh-CN.ts b/packages/app/src/i18n/resources/zh-CN.ts index 4504959b6..6cf1982bc 100644 --- a/packages/app/src/i18n/resources/zh-CN.ts +++ b/packages/app/src/i18n/resources/zh-CN.ts @@ -245,6 +245,12 @@ export const zhCN: TranslationResources = { copyCode: "复制代码", copyTurn: "复制回合", copyMessage: "复制消息", + forkMenu: "分叉消息", + forkInNewTab: "分叉到新标签页", + forkInNewWorkspace: "分叉到新工作区", + forkUnavailable: "请更新主机以使用此功能。", + forkMissingWorkspace: "此 Agent 不在工作区中。", + forkFailed: "分叉聊天失败", openFile: "打开文件", copied: "已复制", }, diff --git a/packages/app/src/panels/agent-panel.tsx b/packages/app/src/panels/agent-panel.tsx index b34b7f57a..f806f68c6 100644 --- a/packages/app/src/panels/agent-panel.tsx +++ b/packages/app/src/panels/agent-panel.tsx @@ -24,10 +24,7 @@ import { type ToastState, } from "@/components/toast-host"; import type { WorkspaceComposerAttachment } from "@/attachments/types"; -import { - useWorkspaceAttachments, - useWorkspaceAttachmentScopeKey, -} from "@/attachments/workspace-attachments-store"; +import { useWorkspaceAttachmentScopeKey } from "@/attachments/workspace-attachments-store"; import { COMPACT_FORM_FACTOR_WIDTH, useIsCompactFormFactor } from "@/constants/layout"; import { isNative, isWeb } from "@/constants/platform"; import { useAgentAttentionClear } from "@/hooks/use-agent-attention-clear"; @@ -86,10 +83,16 @@ import { buildDraftAgentSetup, type ClientSlashCommand } from "@/client-slash-co interface ChatAgentStateShape { serverId: string | null; id: string | null; + provider?: Agent["provider"]; status: Agent["status"] | null; cwd: string | null; workspaceId?: string; capabilities?: Agent["capabilities"]; + currentModeId?: Agent["currentModeId"]; + model?: Agent["model"]; + thinkingOptionId?: Agent["thinkingOptionId"]; + runtimeInfo?: Agent["runtimeInfo"]; + features?: Agent["features"]; lastError?: Agent["lastError"] | null; } @@ -130,10 +133,16 @@ function selectChatAgentState( return { serverId: agent.serverId, id: agent.id, + provider: agent.provider, status: agent.status, cwd: agent.cwd, workspaceId: agent.workspaceId, capabilities: agent.capabilities, + currentModeId: agent.currentModeId, + model: agent.model, + thinkingOptionId: agent.thinkingOptionId, + runtimeInfo: agent.runtimeInfo, + features: agent.features, lastError: agent.lastError ?? null, archivedAt: agent.archivedAt ?? null, requiresAttention: agent.requiresAttention ?? false, @@ -151,10 +160,16 @@ function buildChatAgentFromState( return { serverId: state.serverId, id: state.id, + provider: state.provider, status: state.status, cwd: state.cwd, workspaceId: state.workspaceId, capabilities: state.capabilities, + currentModeId: state.currentModeId, + model: state.model, + thinkingOptionId: state.thinkingOptionId, + runtimeInfo: state.runtimeInfo, + features: state.features, lastError: state.lastError ?? null, projectPlacement, }; @@ -546,18 +561,7 @@ function AgentPanelBody({ (a, b) => a === b || JSON.stringify(a) === JSON.stringify(b), ); const agentState = useSessionStore( - useShallow((state) => { - const agent = resolveChatAgentFromSession(state, serverId, agentId); - return { - serverId: agent?.serverId ?? null, - id: agent?.id ?? null, - status: agent?.status ?? null, - cwd: agent?.cwd ?? null, - workspaceId: agent?.workspaceId, - lastError: agent?.lastError ?? null, - archivedAt: agent?.archivedAt ?? null, - }; - }), + useShallow((state) => selectChatAgentState(state, serverId, agentId)), ); const [lookupState, setLookupState] = useState({ tag: "idle" }); const lookupAttemptTokenRef = useRef(0); @@ -644,9 +648,16 @@ function AgentPanelBody({ ? { serverId: agentState.serverId, id: agentState.id, + provider: agentState.provider, status: agentState.status, cwd: agentState.cwd, workspaceId: agentState.workspaceId, + capabilities: agentState.capabilities, + currentModeId: agentState.currentModeId, + model: agentState.model, + thinkingOptionId: agentState.thinkingOptionId, + runtimeInfo: agentState.runtimeInfo, + features: agentState.features, lastError: agentState.lastError ?? null, projectPlacement, } @@ -1378,7 +1389,10 @@ function ActiveAgentComposer({ cwd, workspaceId, }); - const workspaceAttachments = useWorkspaceAttachments(workspaceAttachmentScopeKey); + const attachmentScopeKeys = useMemo( + () => [workspaceAttachmentScopeKey], + [workspaceAttachmentScopeKey], + ); const openFileExplorerForCheckout = usePanelStore((state) => state.openFileExplorerForCheckout); const setExplorerTabForCheckout = usePanelStore((state) => state.setExplorerTabForCheckout); const handleOpenWorkspaceAttachment = useCallback( @@ -1479,7 +1493,7 @@ function ActiveAgentComposer({ value={agentInputDraft.text} onChangeText={agentInputDraft.setText} attachments={agentInputDraft.attachments} - workspaceAttachments={workspaceAttachments} + attachmentScopeKeys={attachmentScopeKeys} onOpenWorkspaceAttachment={handleOpenWorkspaceAttachment} onChangeAttachments={agentInputDraft.setAttachments} cwd={cwd} diff --git a/packages/app/src/screens/new-workspace-fork-context.test.ts b/packages/app/src/screens/new-workspace-fork-context.test.ts new file mode 100644 index 000000000..6299c4560 --- /dev/null +++ b/packages/app/src/screens/new-workspace-fork-context.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from "vitest"; +import type { AgentAttachment } from "@getpaseo/protocol/messages"; +import { + getWorkspaceNamingAttachments, + remapDraftCwdToWorkspace, +} from "./new-workspace-fork-context"; + +describe("remapDraftCwdToWorkspace", () => { + it("preserves a Windows subdirectory when source path casing differs", () => { + expect( + remapDraftCwdToWorkspace({ + cwd: "c:\\Repo\\packages\\app", + sourceDirectory: "C:\\Repo", + workspaceDirectory: "D:\\Worktrees\\fork", + }), + ).toBe("D:\\Worktrees\\fork\\packages\\app"); + }); + + it("falls back to the workspace root when the cwd is outside the source directory", () => { + expect( + remapDraftCwdToWorkspace({ + cwd: "/other/repo/packages/app", + sourceDirectory: "/repo", + workspaceDirectory: "/worktrees/fork", + }), + ).toBe("/worktrees/fork"); + }); +}); + +describe("getWorkspaceNamingAttachments", () => { + it("removes full chat history from workspace naming context", () => { + const chatHistory = { + type: "text", + mimeType: "text/plain", + contextKind: "chat_history", + title: "Chat history", + text: "Long prior conversation", + } satisfies AgentAttachment; + const prContext = { + type: "github_pr", + mimeType: "application/github-pr", + number: 1788, + title: "Fork assistant turns into new drafts", + url: "https://github.com/getpaseo/paseo/pull/1788", + } satisfies AgentAttachment; + + expect(getWorkspaceNamingAttachments([chatHistory, prContext])).toEqual([prContext]); + }); +}); diff --git a/packages/app/src/screens/new-workspace-fork-context.ts b/packages/app/src/screens/new-workspace-fork-context.ts new file mode 100644 index 000000000..1189f59c4 --- /dev/null +++ b/packages/app/src/screens/new-workspace-fork-context.ts @@ -0,0 +1,50 @@ +import type { AgentAttachment } from "@getpaseo/protocol/messages"; + +function isLikelyWindowsPath(path: string): boolean { + return /^[a-zA-Z]:\//.test(path); +} + +function isChatHistoryTextAttachment(attachment: AgentAttachment): boolean { + return attachment.type === "text" && attachment.contextKind === "chat_history"; +} + +export function getWorkspaceNamingAttachments( + attachments: readonly AgentAttachment[], +): AgentAttachment[] { + return attachments.filter((attachment) => !isChatHistoryTextAttachment(attachment)); +} + +export function remapDraftCwdToWorkspace(input: { + cwd: string; + sourceDirectory?: string | null; + workspaceDirectory: string; +}): string { + const cwd = input.cwd.trim(); + const sourceDirectory = input.sourceDirectory?.trim(); + const workspaceDirectory = input.workspaceDirectory.trim(); + if (!cwd || !sourceDirectory) { + return workspaceDirectory; + } + const normalizedCwd = cwd.replace(/\\/g, "/").replace(/\/+$/, ""); + const normalizedSource = sourceDirectory.replace(/\\/g, "/").replace(/\/+$/, ""); + const compareCaseInsensitively = + isLikelyWindowsPath(normalizedCwd) || isLikelyWindowsPath(normalizedSource); + const comparableCwd = compareCaseInsensitively ? normalizedCwd.toLowerCase() : normalizedCwd; + const comparableSource = compareCaseInsensitively + ? normalizedSource.toLowerCase() + : normalizedSource; + if (comparableCwd === comparableSource) { + return workspaceDirectory; + } + const relativePath = comparableCwd.startsWith(`${comparableSource}/`) + ? normalizedCwd.slice(normalizedSource.length + 1) + : ""; + if (!relativePath) { + return workspaceDirectory; + } + const separator = + workspaceDirectory.includes("\\") && !workspaceDirectory.includes("/") ? "\\" : "/"; + return [workspaceDirectory.replace(/[\\/]+$/, ""), ...relativePath.split("/")] + .filter(Boolean) + .join(separator); +} diff --git a/packages/app/src/screens/new-workspace-screen.tsx b/packages/app/src/screens/new-workspace-screen.tsx index 62d710828..e9a44fc2f 100644 --- a/packages/app/src/screens/new-workspace-screen.tsx +++ b/packages/app/src/screens/new-workspace-screen.tsx @@ -36,10 +36,14 @@ import { normalizeWorkspaceDescriptor, useSessionStore } from "@/stores/session- import { useWorkspace } from "@/stores/session-store-hooks"; import { generateDraftId } from "@/stores/draft-keys"; import { useDraftStore } from "@/stores/draft-store"; -import { useCreateFlowStore } from "@/stores/create-flow-store"; -import { useWorkspaceDraftSubmissionStore } from "@/stores/workspace-draft-submission-store"; +import { isActiveCreateFlowForDraft, useCreateFlowStore } from "@/stores/create-flow-store"; +import { + useWorkspaceDraftSubmissionStore, + type PendingWorkspaceDraftSetup, +} from "@/stores/workspace-draft-submission-store"; import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style"; import { useFormPreferences } from "@/hooks/use-form-preferences"; +import type { CreateAgentInitialValues } from "@/hooks/use-agent-form-state"; import { generateMessageId } from "@/types/stream"; import { toErrorMessage } from "@/utils/error-messages"; import { projectIconPlaceholderLabelFromDisplayName } from "@/utils/project-display-name"; @@ -57,11 +61,17 @@ import { } from "@/projects/host-projects"; import { useProjectIconDataByProjectKey } from "@/projects/project-icons"; import type { ComposerAttachment, UserComposerAttachment } from "@/attachments/types"; +import { useDraftWorkspaceAttachmentScopeKey } from "@/attachments/workspace-attachments-store"; import type { MessagePayload } from "@/composer/types"; import type { AgentAttachment, GitHubSearchItem } from "@getpaseo/protocol/messages"; import type { CreatePaseoWorktreeInput } from "@getpaseo/client/internal/daemon-client"; import type { AgentProvider } from "@getpaseo/protocol/agent-types"; +import type { WorkspaceDraftTabSetup, WorkspaceTabTarget } from "@/stores/workspace-tabs-store"; import { isEmptyWorkspaceSubmission, runCreateEmptyWorkspace } from "./new-workspace-empty"; +import { + getWorkspaceNamingAttachments, + remapDraftCwdToWorkspace, +} from "./new-workspace-fork-context"; import { pickerItemToCheckoutRequest, type PickerCheckoutRequest, @@ -82,6 +92,37 @@ function resolveCheckoutRequest( }; } +function useIsNewWorkspaceDraftHandoffActive(input: { + draftId: string | undefined; + selectedServerId: string; +}): boolean { + const normalizedDraftId = input.draftId?.trim() ?? ""; + return useCreateFlowStore((state) => + isActiveCreateFlowForDraft({ + draftId: normalizedDraftId, + serverId: input.selectedServerId, + pending: normalizedDraftId ? state.pendingByDraftId[normalizedDraftId] : null, + }), + ); +} + +function resolveVisibleDraftContextScopeKeys(input: { + isDraftHandoffActive: boolean; + draftContextScopeKey: string; +}): readonly string[] { + if (input.isDraftHandoffActive || !input.draftContextScopeKey) { + return []; + } + return [input.draftContextScopeKey]; +} + +function isNewWorkspacePending(input: { + pendingAction: "chat" | "empty" | null; + isDraftHandoffActive: boolean; +}): boolean { + return input.pendingAction !== null || input.isDraftHandoffActive; +} + function buildFirstAgentContext(input: { prompt: string; attachments: AgentAttachment[]; @@ -102,6 +143,7 @@ interface NewWorkspaceScreenProps { sourceDirectory?: string; projectId?: string; displayName?: string; + draftId?: string; } interface PickerOptionData { @@ -714,6 +756,18 @@ function getContentStyle(input: { isCompact: boolean; insetBottom: number }) { return [styles.content, styles.contentCentered]; } +function buildNewWorkspaceDraftKey(input: { + selectedServerId: string; + selectedSourceDirectory: string | null; + draftId?: string; +}): string { + const explicitDraftId = input.draftId?.trim(); + if (explicitDraftId) { + return `new-workspace:draft:${explicitDraftId}`; + } + return `new-workspace:${input.selectedServerId}:${input.selectedSourceDirectory ?? "choose-project"}`; +} + function getSelectedPickerItem(selection: PickerSelection | null): PickerItem | null { if (!selection) return null; return selection.item; @@ -733,12 +787,28 @@ function normalizeBranchDetails( interface SubmitDraftInput { serverId: string; draftKey: string; + draftId?: string; + initialSetup?: WorkspaceDraftTabSetup; workspaceId: string; workspaceDirectory: string; text: string; attachments: ComposerAttachment[]; provider: AgentProvider; - composerState: NonNullable["composerState"]>; + composerState: NewWorkspaceComposerState; +} + +type NewWorkspaceComposerState = NonNullable< + ReturnType["composerState"] +>; + +interface WorkspaceDraftSubmissionConfig { + cwd: string; + provider: AgentProvider; + modeId: string | null; + model: string | null; + thinkingOptionId: string | null; + featureValues: Record | undefined; + target: WorkspaceTabTarget; } async function createAndMergeWorkspace(input: { @@ -820,6 +890,7 @@ async function createMultiplicityWorkspace(input: { interface CreateChatAgentInput { payload: MessagePayload; composerState: ReturnType["composerState"]; + forkDraftSetup?: PendingWorkspaceDraftSetup | null; ensureWorkspace: (input: { cwd: string; prompt: string; @@ -828,12 +899,67 @@ interface CreateChatAgentInput { }) => Promise>; serverId: string; draftKey: string; + draftId?: string; labels: { composerStateRequired: string; selectModel: string; }; } +function buildWorkspaceDraftSetupFromComposer(input: { + cwd: string; + provider: AgentProvider; + composerState: NewWorkspaceComposerState; +}): WorkspaceDraftTabSetup { + return { + provider: input.provider, + cwd: input.cwd, + modeId: input.composerState.selectedMode || null, + model: input.composerState.effectiveModelId || null, + thinkingOptionId: input.composerState.effectiveThinkingOptionId || null, + featureValues: input.composerState.featureValues ?? {}, + }; +} + +function buildWorkspaceDraftSetupForCreatedWorkspace(input: { + forkDraftSetup: PendingWorkspaceDraftSetup | null | undefined; + workspaceDirectory: string; + provider: AgentProvider; + composerState: NewWorkspaceComposerState; +}): WorkspaceDraftTabSetup | undefined { + if (!input.forkDraftSetup) { + return undefined; + } + return buildWorkspaceDraftSetupFromComposer({ + cwd: remapDraftCwdToWorkspace({ + cwd: input.forkDraftSetup.setup.cwd, + sourceDirectory: input.forkDraftSetup.sourceDirectory, + workspaceDirectory: input.workspaceDirectory, + }), + provider: input.provider, + composerState: input.composerState, + }); +} + +function buildComposerInitialValues(input: { + workingDir: string | undefined; + initialSetup?: WorkspaceDraftTabSetup | null; +}): CreateAgentInitialValues | undefined { + if (input.initialSetup) { + return { + workingDir: input.workingDir ?? input.initialSetup.cwd, + provider: input.initialSetup.provider, + modeId: input.initialSetup.modeId, + model: input.initialSetup.model, + thinkingOptionId: input.initialSetup.thinkingOptionId, + }; + } + if (input.workingDir) { + return { workingDir: input.workingDir }; + } + return undefined; +} + async function runCreateChatAgent(input: CreateChatAgentInput): Promise { const { payload, composerState, ensureWorkspace, serverId, draftKey } = input; const { text, attachments, cwd } = payload; @@ -845,15 +971,24 @@ async function runCreateChatAgent(input: CreateChatAgentInput): Promise { throw new Error(input.labels.selectModel); } const { attachments: reviewAttachments } = splitComposerAttachmentsForSubmit(attachments); + const workspaceNamingAttachments = getWorkspaceNamingAttachments(reviewAttachments); const ensuredWorkspace = await ensureWorkspace({ cwd, prompt: text, - attachments: reviewAttachments, + attachments: workspaceNamingAttachments, withInitialAgent: true, }); + const initialSetup = buildWorkspaceDraftSetupForCreatedWorkspace({ + forkDraftSetup: input.forkDraftSetup, + workspaceDirectory: ensuredWorkspace.workspaceDirectory, + provider, + composerState, + }); submitWorkspaceDraft({ serverId, draftKey, + draftId: input.draftId, + initialSetup, workspaceId: ensuredWorkspace.id, workspaceDirectory: ensuredWorkspace.workspaceDirectory, text, @@ -868,12 +1003,14 @@ function buildComposerConfig(input: { isConnected: boolean; workspaceDirectory: string | null; sourceDirectory: string | null; + initialSetup?: WorkspaceDraftTabSetup | null; }): Parameters[0]["composer"] { - const { serverId, isConnected, workspaceDirectory, sourceDirectory } = input; + const { serverId, isConnected, workspaceDirectory, sourceDirectory, initialSetup } = input; const workingDir = workspaceDirectory || sourceDirectory || undefined; return { initialServerId: serverId || null, - initialValues: workingDir ? { workingDir } : undefined, + initialValues: buildComposerInitialValues({ workingDir, initialSetup }), + initialFeatureValues: initialSetup?.featureValues, isVisible: true, onlineServerIds: isConnected && serverId ? [serverId] : [], lockedWorkingDir: workingDir, @@ -921,21 +1058,72 @@ function useCheckoutHintDismissals(attachments: ReadonlyArray { + if (!normalizedDraftId) { + return null; + } + return state.setupByDraftId[normalizedDraftId] ?? null; + }); +} + +function resolveWorkspaceDraftSubmissionConfig(input: { + draftId: string; + workspaceDirectory: string; + provider: AgentProvider; + composerState: NewWorkspaceComposerState; + initialSetup?: WorkspaceDraftTabSetup; +}): WorkspaceDraftSubmissionConfig { + const { draftId, workspaceDirectory, provider, composerState, initialSetup } = input; + if (initialSetup) { + return { + cwd: initialSetup.cwd, + provider: initialSetup.provider, + modeId: initialSetup.modeId, + model: initialSetup.model, + thinkingOptionId: initialSetup.thinkingOptionId, + featureValues: initialSetup.featureValues, + target: { kind: "draft", draftId, setup: initialSetup }, + }; + } + return { + cwd: workspaceDirectory, + provider, + modeId: composerState.selectedMode || null, + model: composerState.effectiveModelId || null, + thinkingOptionId: composerState.effectiveThinkingOptionId || null, + featureValues: composerState.featureValues, + target: { kind: "draft", draftId }, + }; +} + function submitWorkspaceDraft(input: SubmitDraftInput): void { const { serverId, draftKey, + draftId: draftIdInput, workspaceId, workspaceDirectory, text, attachments, provider, composerState, + initialSetup, } = input; - const draftId = generateDraftId(); + const draftId = draftIdInput?.trim() || generateDraftId(); const clientMessageId = generateMessageId(); const timestamp = Date.now(); const wirePayload = splitComposerAttachmentsForSubmit(attachments); + const submission = resolveWorkspaceDraftSubmissionConfig({ + draftId, + workspaceDirectory, + provider, + composerState, + initialSetup, + }); useCreateFlowStore.getState().setPending({ serverId, draftId, @@ -953,23 +1141,21 @@ function submitWorkspaceDraft(input: SubmitDraftInput): void { draftId, text: text.trim(), attachments, - cwd: workspaceDirectory, - provider, + cwd: submission.cwd, + provider: submission.provider, clientMessageId, timestamp, - ...(composerState.selectedMode !== "" ? { modeId: composerState.selectedMode } : {}), - ...(composerState.effectiveModelId ? { model: composerState.effectiveModelId } : {}), - ...(composerState.effectiveThinkingOptionId - ? { thinkingOptionId: composerState.effectiveThinkingOptionId } - : {}), - ...(composerState.featureValues ? { featureValues: composerState.featureValues } : {}), + ...(submission.modeId ? { modeId: submission.modeId } : {}), + ...(submission.model ? { model: submission.model } : {}), + ...(submission.thinkingOptionId ? { thinkingOptionId: submission.thinkingOptionId } : {}), + ...(submission.featureValues ? { featureValues: submission.featureValues } : {}), allowEmptyText: true, }); navigateToPreparedWorkspaceTab({ serverId, workspaceId, currentPathname: "/new", - target: { kind: "draft", draftId }, + target: submission.target, }); useDraftStore.getState().clearDraftInput({ draftKey, lifecycle: "sent" }); } @@ -1360,6 +1546,7 @@ export function NewWorkspaceScreen({ sourceDirectory: sourceDirectoryProp, projectId, displayName: displayNameProp, + draftId, }: NewWorkspaceScreenProps) { const { theme } = useUnistyles(); const { t } = useTranslation(); @@ -1396,6 +1583,7 @@ export function NewWorkspaceScreen({ const projectPickerAnchorRef = useRef(null); const isolationPickerAnchorRef = useRef(null); const hostPickerAnchorRef = useRef(null); + const isDraftHandoffActive = useIsNewWorkspaceDraftHandoffActive({ draftId, selectedServerId }); useEffect(() => { const trimmed = pickerSearchQuery.trim(); @@ -1404,7 +1592,7 @@ export function NewWorkspaceScreen({ }, [pickerSearchQuery]); const workspace = createdWorkspace; - const isPending = pendingAction !== null; + const isPending = isNewWorkspacePending({ pendingAction, isDraftHandoffActive }); const client = useHostRuntimeClient(selectedServerId); const isConnected = useHostRuntimeIsConnected(selectedServerId); const { @@ -1441,7 +1629,17 @@ export function NewWorkspaceScreen({ const projectIconDataByProjectKey = useProjectIconDataByProjectKey({ projects: projectIconTargets, }); - const draftKey = `new-workspace:${selectedServerId}:${selectedSourceDirectory ?? "choose-project"}`; + const draftKey = buildNewWorkspaceDraftKey({ + selectedServerId, + selectedSourceDirectory, + draftId, + }); + const forkDraftSetup = usePendingWorkspaceDraftSetup(draftId); + const draftContextScopeKey = useDraftWorkspaceAttachmentScopeKey(draftId); + const visibleDraftContextScopeKeys = useMemo( + () => resolveVisibleDraftContextScopeKeys({ isDraftHandoffActive, draftContextScopeKey }), + [draftContextScopeKey, isDraftHandoffActive], + ); const chatDraft = useAgentInputDraft({ draftKey, composer: buildComposerConfig({ @@ -1449,6 +1647,7 @@ export function NewWorkspaceScreen({ isConnected, workspaceDirectory: workspace?.workspaceDirectory ?? null, sourceDirectory: selectedSourceDirectory, + initialSetup: forkDraftSetup?.setup, }), }); const composerState = chatDraft.composerState; @@ -1793,9 +1992,11 @@ export function NewWorkspaceScreen({ await runCreateChatAgent({ payload, composerState, + forkDraftSetup, ensureWorkspace, serverId: selectedServerId, draftKey, + draftId, labels: { composerStateRequired: t("newWorkspace.errors.composerStateRequired"), selectModel: t("newWorkspace.errors.selectModel"), @@ -1808,7 +2009,7 @@ export function NewWorkspaceScreen({ toast.error(message); } }, - [composerState, draftKey, ensureWorkspace, selectedServerId, t, toast], + [composerState, draftId, draftKey, ensureWorkspace, forkDraftSetup, selectedServerId, t, toast], ); const renderPickerOption = useCallback( @@ -1987,12 +2188,13 @@ export function NewWorkspaceScreen({ submitButtonAccessibilityLabel={t("newWorkspace.create")} submitButtonTestID="workspace-create-submit" submitIcon="return" - isSubmitLoading={pendingAction !== null} + isSubmitLoading={isPending} submitBehavior="preserve-and-lock" blurOnSubmit={true} value={chatDraft.text} onChangeText={chatDraft.setText} attachments={chatDraft.attachments} + attachmentScopeKeys={visibleDraftContextScopeKeys} onChangeAttachments={chatDraft.setAttachments} cwd={selectedSourceDirectory ?? ""} clearDraft={handleClearDraft} diff --git a/packages/app/src/stores/create-flow-store.test.ts b/packages/app/src/stores/create-flow-store.test.ts index 17844e4bd..c1b4ec8d7 100644 --- a/packages/app/src/stores/create-flow-store.test.ts +++ b/packages/app/src/stores/create-flow-store.test.ts @@ -1,5 +1,5 @@ import { beforeEach, describe, expect, it } from "vitest"; -import { useCreateFlowStore } from "./create-flow-store"; +import { isActiveCreateFlowForDraft, useCreateFlowStore } from "./create-flow-store"; describe("create-flow-store", () => { beforeEach(() => { @@ -88,4 +88,40 @@ describe("create-flow-store", () => { expect(useCreateFlowStore.getState().pendingByDraftId["draft-1"]).toBeUndefined(); }); + + it("matches only active pending create flows for a draft and server", () => { + useCreateFlowStore.getState().setPending({ + draftId: "draft-1", + serverId: "server-1", + agentId: null, + clientMessageId: "msg-1", + text: "hello", + timestamp: Date.now(), + }); + const pending = useCreateFlowStore.getState().pendingByDraftId["draft-1"]; + + expect( + isActiveCreateFlowForDraft({ + pending, + serverId: "server-1", + draftId: " draft-1 ", + }), + ).toBe(true); + expect( + isActiveCreateFlowForDraft({ + pending, + serverId: "server-2", + draftId: "draft-1", + }), + ).toBe(false); + + useCreateFlowStore.getState().markLifecycle({ draftId: "draft-1", lifecycle: "sent" }); + expect( + isActiveCreateFlowForDraft({ + pending: useCreateFlowStore.getState().pendingByDraftId["draft-1"], + serverId: "server-1", + draftId: "draft-1", + }), + ).toBe(false); + }); }); diff --git a/packages/app/src/stores/create-flow-store.ts b/packages/app/src/stores/create-flow-store.ts index 5f4cfea5d..5ca6b9532 100644 --- a/packages/app/src/stores/create-flow-store.ts +++ b/packages/app/src/stores/create-flow-store.ts @@ -17,6 +17,20 @@ export interface PendingCreateAttempt { attachments?: AgentAttachment[]; } +export function isActiveCreateFlowForDraft(input: { + pending: PendingCreateAttempt | null | undefined; + serverId: string; + draftId: string | null | undefined; +}): boolean { + const draftId = input.draftId?.trim(); + return Boolean( + draftId && + input.pending?.draftId === draftId && + input.pending.serverId === input.serverId && + input.pending.lifecycle === "active", + ); +} + interface CreateFlowState { pendingByDraftId: Record; setPending: (pending: Omit) => void; diff --git a/packages/app/src/stores/workspace-draft-submission-store.ts b/packages/app/src/stores/workspace-draft-submission-store.ts index 014d5f929..d31a14485 100644 --- a/packages/app/src/stores/workspace-draft-submission-store.ts +++ b/packages/app/src/stores/workspace-draft-submission-store.ts @@ -1,6 +1,7 @@ import { create } from "zustand"; import type { ComposerAttachment } from "@/attachments/types"; import type { AgentProvider } from "@getpaseo/protocol/agent-types"; +import type { WorkspaceDraftTabSetup } from "@/stores/workspace-tabs-store"; export interface PendingWorkspaceDraftSubmission { serverId: string; @@ -19,9 +20,21 @@ export interface PendingWorkspaceDraftSubmission { allowEmptyText?: boolean; } +export interface PendingWorkspaceDraftSetup { + setup: WorkspaceDraftTabSetup; + sourceDirectory?: string | null; +} + interface WorkspaceDraftSubmissionState { pendingByDraftId: Record; + setupByDraftId: Record; setPending: (submission: PendingWorkspaceDraftSubmission) => void; + setDraftSetup: (input: { + draftId: string; + setup: WorkspaceDraftTabSetup; + sourceDirectory?: string | null; + }) => void; + clearDraftSetup: (input: { draftId: string }) => void; consumePending: (input: { serverId: string; workspaceId: string; @@ -40,9 +53,14 @@ function matchesPendingSubmission( ); } +function normalizeDraftId(draftId: string): string { + return draftId.trim(); +} + export const useWorkspaceDraftSubmissionStore = create( (set, get) => ({ pendingByDraftId: {}, + setupByDraftId: {}, setPending: (submission) => set((state) => ({ pendingByDraftId: { @@ -50,6 +68,25 @@ export const useWorkspaceDraftSubmissionStore = create { + const normalizedDraftId = normalizeDraftId(draftId); + if (!normalizedDraftId) return; + set((state) => ({ + setupByDraftId: { + ...state.setupByDraftId, + [normalizedDraftId]: { setup, sourceDirectory: sourceDirectory ?? null }, + }, + })); + }, + clearDraftSetup: ({ draftId }) => { + const normalizedDraftId = normalizeDraftId(draftId); + if (!normalizedDraftId) return; + set((state) => { + if (!state.setupByDraftId[normalizedDraftId]) return state; + const { [normalizedDraftId]: _removed, ...setupByDraftId } = state.setupByDraftId; + return { setupByDraftId }; + }); + }, consumePending: (input) => { const pending = get().pendingByDraftId[input.draftId]; if (!matchesPendingSubmission(pending, input)) { diff --git a/packages/app/src/types/stream.ts b/packages/app/src/types/stream.ts index c67e0d0ed..67c7480d0 100644 --- a/packages/app/src/types/stream.ts +++ b/packages/app/src/types/stream.ts @@ -986,6 +986,7 @@ function promoteCompletedAssistantBlocks(params: { tail: StreamItem[]; head: Str groupId: blockGroupId, blockIndex: firstBlockIndex + offset, }), + ...(activeItem.messageId ? { messageId: activeItem.messageId } : {}), blockGroupId, blockIndex: firstBlockIndex + offset, text: block, diff --git a/packages/app/src/utils/host-routes.test.ts b/packages/app/src/utils/host-routes.test.ts index c2db2032d..7d9754df6 100644 --- a/packages/app/src/utils/host-routes.test.ts +++ b/packages/app/src/utils/host-routes.test.ts @@ -238,6 +238,16 @@ describe("global routes", () => { }), ).toBe("/new?serverId=local&dir=%2Frepo%2Fproject&name=Project&projectId=project-1"); }); + + it("buildNewWorkspaceRoute carries a draft context id", () => { + expect( + buildNewWorkspaceRoute({ + serverId: "local", + sourceDirectory: "/repo/project", + draftId: "draft-1", + }), + ).toBe("/new?serverId=local&dir=%2Frepo%2Fproject&draftId=draft-1"); + }); }); describe("host settings section slugs", () => { diff --git a/packages/app/src/utils/host-routes.ts b/packages/app/src/utils/host-routes.ts index 0b8dfcbb6..d32257243 100644 --- a/packages/app/src/utils/host-routes.ts +++ b/packages/app/src/utils/host-routes.ts @@ -424,6 +424,7 @@ interface NewWorkspaceRouteOptions { sourceDirectory?: string; displayName?: string; projectId?: string; + draftId?: string; } function buildNewWorkspaceSearch(options: NewWorkspaceRouteOptions): string { @@ -441,6 +442,9 @@ function buildNewWorkspaceSearch(options: NewWorkspaceRouteOptions): string { if (options.projectId) { params.set("projectId", options.projectId); } + if (options.draftId) { + params.set("draftId", options.draftId); + } return params.toString(); } diff --git a/packages/client/src/daemon-client.ts b/packages/client/src/daemon-client.ts index 9a317540b..18240ceb2 100644 --- a/packages/client/src/daemon-client.ts +++ b/packages/client/src/daemon-client.ts @@ -26,6 +26,7 @@ import type { FileUploadResponse, FileExplorerResponse, FetchAgentTimelineResponseMessage, + AgentForkContextResponseMessage, GitSetupOptions, CheckoutStatusResponse, CheckoutCommitResponse, @@ -456,6 +457,7 @@ type ScheduleUpdatePayload = Extract< { type: "schedule/update/response" } >["payload"]; export type FetchAgentTimelinePayload = FetchAgentTimelineResponseMessage["payload"]; +export type AgentForkContextPayload = AgentForkContextResponseMessage["payload"]; export type FetchAgentTimelineDirection = FetchAgentTimelinePayload["direction"]; export type FetchAgentTimelineProjection = FetchAgentTimelinePayload["projection"]; @@ -502,6 +504,10 @@ function normalizeListCommandsOptions( } return { agentId: input, ...legacyOptions }; } +export interface AgentForkContextOptions { + boundaryMessageId?: string; + requestId?: string; +} type AgentRefreshedStatusPayload = z.infer; type RestartRequestedStatusPayload = z.infer; @@ -2405,6 +2411,41 @@ export class DaemonClient { return payload; } + async buildAgentForkContext( + agentId: string, + options: AgentForkContextOptions = {}, + ): Promise { + const resolvedRequestId = this.createRequestId(options.requestId); + const message = SessionInboundMessageSchema.parse({ + type: "agent.fork_context.request", + agentId, + requestId: resolvedRequestId, + ...(options.boundaryMessageId ? { boundaryMessageId: options.boundaryMessageId } : {}), + }); + + const payload = await this.sendRequest({ + requestId: resolvedRequestId, + message, + timeout: 15000, + options: { skipQueue: true }, + select: (msg) => { + if (msg.type !== "agent.fork_context.response") { + return null; + } + if (msg.payload.requestId !== resolvedRequestId) { + return null; + } + return msg.payload; + }, + }); + + if (payload.error) { + throw new Error(payload.error); + } + + return payload; + } + // ============================================================================ // Agent Interaction // ============================================================================ diff --git a/packages/protocol/src/messages.attachments.test.ts b/packages/protocol/src/messages.attachments.test.ts index 114e2a72d..57f52d240 100644 --- a/packages/protocol/src/messages.attachments.test.ts +++ b/packages/protocol/src/messages.attachments.test.ts @@ -226,6 +226,47 @@ describe("shared messages attachments", () => { ]); }); + it("keeps known text attachment context kinds and ignores future ones", () => { + const parsed = SendAgentMessageRequestSchema.parse({ + type: "send_agent_message_request", + requestId: "req-text-context", + agentId: "agent-1", + text: "Continue", + attachments: [ + { + type: "text", + mimeType: "text/plain", + contextKind: "chat_history", + title: "Chat history", + text: "Earlier context", + }, + { + type: "text", + mimeType: "text/plain", + contextKind: "future_context", + title: "Future context", + text: "Future client hint", + }, + ], + }); + + expect(parsed.attachments).toEqual([ + { + type: "text", + mimeType: "text/plain", + contextKind: "chat_history", + title: "Chat history", + text: "Earlier context", + }, + { + type: "text", + mimeType: "text/plain", + title: "Future context", + text: "Future client hint", + }, + ]); + }); + it("keeps known firstAgentContext attachments and drops unknown ones", () => { const parsed = CreatePaseoWorktreeRequestSchema.parse({ type: "create_paseo_worktree_request", diff --git a/packages/protocol/src/messages.ts b/packages/protocol/src/messages.ts index 499de202a..7eb0edaa1 100644 --- a/packages/protocol/src/messages.ts +++ b/packages/protocol/src/messages.ts @@ -843,12 +843,18 @@ export const GitHubIssueAttachmentSchema = z.object({ body: z.string().nullable().optional(), }); -export const TextAttachmentSchema = z.object({ - type: z.literal("text"), - mimeType: z.literal("text/plain"), - title: z.string().nullable().optional(), - text: z.string(), -}); +export const TextAttachmentSchema = z + .object({ + type: z.literal("text"), + mimeType: z.literal("text/plain"), + contextKind: z.string().optional(), + title: z.string().nullable().optional(), + text: z.string(), + }) + .transform(({ contextKind, ...attachment }) => ({ + ...attachment, + ...(contextKind === "chat_history" ? { contextKind } : {}), + })); export const ReviewAttachmentContextLineSchema = z.object({ oldLineNumber: z.number().int().positive().nullable(), @@ -1274,6 +1280,13 @@ export const FetchAgentTimelineRequestMessageSchema = z.object({ projection: z.enum(["projected", "canonical"]).optional(), }); +export const AgentForkContextRequestMessageSchema = z.object({ + type: z.literal("agent.fork_context.request"), + agentId: z.string(), + boundaryMessageId: z.string().optional(), + requestId: z.string(), +}); + export const SetAgentModeRequestMessageSchema = z.object({ type: z.literal("set_agent_mode_request"), agentId: z.string(), @@ -2060,6 +2073,7 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [ RestartServerRequestMessageSchema, DaemonUpdateRequestMessageSchema, FetchAgentTimelineRequestMessageSchema, + AgentForkContextRequestMessageSchema, SetAgentModeRequestMessageSchema, SetAgentModelRequestMessageSchema, SetAgentThinkingRequestMessageSchema, @@ -2335,6 +2349,8 @@ export const ServerInfoStatusPayloadSchema = z daemonDiagnostics: z.boolean().optional(), // COMPAT(daemonSelfUpdate): added in v0.1.93, remove gate after 2026-12-13. daemonSelfUpdate: z.boolean().optional(), + // COMPAT(agentForkContext): added in v0.1.102, remove gate after 2026-12-28. + agentForkContext: z.boolean().optional(), }) .optional(), }) @@ -2926,6 +2942,18 @@ export const FetchAgentTimelineResponseMessageSchema = z.object({ }), }); +export const AgentForkContextResponseMessageSchema = z.object({ + type: z.literal("agent.fork_context.response"), + payload: z.object({ + requestId: z.string(), + agentId: z.string(), + attachment: TextAttachmentSchema.nullable(), + itemCount: z.number().int().nonnegative(), + boundaryMessageId: z.string().nullable(), + error: z.string().nullable(), + }), +}); + export const CancelAgentResponseMessageSchema = z.object({ type: z.literal("cancel_agent_response"), payload: z.object({ @@ -4158,6 +4186,7 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [ ArchiveWorkspaceResponseMessageSchema, FetchAgentResponseMessageSchema, FetchAgentTimelineResponseMessageSchema, + AgentForkContextResponseMessageSchema, CancelAgentResponseMessageSchema, ClearAgentAttentionResponseMessageSchema, WorkspaceCreateResponseSchema, @@ -4320,6 +4349,7 @@ export type FetchAgentResponseMessage = z.infer; +export type AgentForkContextResponseMessage = z.infer; export type CancelAgentResponseMessage = z.infer; export type SendAgentMessageResponseMessage = z.infer; export type SetVoiceModeResponseMessage = z.infer; @@ -4410,6 +4440,7 @@ export type FetchRecentProviderSessionsRequestMessage = z.infer< >; export type FetchWorkspacesRequestMessage = z.infer; export type FetchAgentRequestMessage = z.infer; +export type AgentForkContextRequestMessage = z.infer; export type SendAgentMessageRequest = z.infer; export type WaitForFinishRequest = z.infer; export type DictationStreamStartMessage = z.infer; diff --git a/packages/server/src/server/agent/activity-curator.test.ts b/packages/server/src/server/agent/activity-curator.test.ts index f6dfc7bb5..d7500ac73 100644 --- a/packages/server/src/server/agent/activity-curator.test.ts +++ b/packages/server/src/server/agent/activity-curator.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest"; -import { curateAgentActivity } from "./activity-curator.js"; +import { buildAgentForkContextAttachment, curateAgentActivity } from "./activity-curator.js"; import type { AgentTimelineItem } from "./agent-sdk-types.js"; +import type { AgentTimelineRow } from "./agent-timeline-store-types.js"; function toolCallItem(params: { callId: string; @@ -29,6 +30,14 @@ function toolCallItem(params: { }; } +function row(seq: number, item: AgentTimelineItem): AgentTimelineRow { + return { + seq, + timestamp: `2026-06-28T00:00:${String(seq).padStart(2, "0")}.000Z`, + item, + }; +} + describe("curateAgentActivity", () => { it("renders user/assistant/reasoning entries", () => { const timeline: AgentTimelineItem[] = [ @@ -217,4 +226,140 @@ second line'`, it("returns a default message when timeline is empty", () => { expect(curateAgentActivity([])).toBe("No activity to display."); }); + + it("builds fork context from user messages, assistant messages, and tool summaries", () => { + const result = buildAgentForkContextAttachment({ + agentTitle: "Source Agent", + cwd: "/repo", + boundaryMessageId: "assistant-1", + rows: [ + row(1, { type: "user_message", text: "Ship the thing", messageId: "user-1" }), + row(2, { type: "reasoning", text: "private chain of thought" }), + row( + 3, + toolCallItem({ + callId: "read-1", + name: "read_file", + detail: { + type: "read", + filePath: "src/index.ts", + content: "console.log('hi')", + }, + }), + ), + row( + 4, + toolCallItem({ + callId: "external-1", + name: "paseo__create_agent", + input: { initialPrompt: "do not include raw external tool input" }, + }), + ), + row(5, { + type: "assistant_message", + text: "Done.", + messageId: "assistant-1", + }), + row(6, { + type: "assistant_message", + text: "Later answer.", + messageId: "assistant-2", + }), + ], + }); + + expect(result.boundaryMessageId).toBe("assistant-1"); + expect(result.attachment).toMatchObject({ + type: "text", + mimeType: "text/plain", + contextKind: "chat_history", + title: "Chat history", + }); + expect(result.attachment.text).toContain("Source agent: Source Agent"); + expect(result.attachment.text).toContain("Source directory: /repo"); + expect(result.attachment.text).toContain("[User] Ship the thing"); + expect(result.attachment.text).toContain("[Read] src/index.ts"); + expect(result.attachment.text).toContain("[paseo__create_agent]"); + expect(result.attachment.text).toContain("[Assistant] Done."); + expect(result.attachment.text).not.toContain("private chain of thought"); + expect(result.attachment.text).not.toContain("do not include raw external tool input"); + expect(result.attachment.text).not.toContain("Later answer."); + }); + + it("does not cap fork context to the generic recent activity limit", () => { + const messageRows = Array.from({ length: 25 }, (_, index) => + row(index + 1, { + type: "user_message", + text: `Message ${index + 1}`, + messageId: `user-${index + 1}`, + }), + ); + const result = buildAgentForkContextAttachment({ + boundaryMessageId: "assistant-1", + rows: [ + ...messageRows, + row(26, { + type: "assistant_message", + text: "Done.", + messageId: "assistant-1", + }), + ], + }); + + expect(result.itemCount).toBe(26); + expect(result.attachment.text).toContain("[User] Message 1"); + expect(result.attachment.text).toContain("[User] Message 25"); + expect(result.attachment.text).toContain("[Assistant] Done."); + }); + + it("selects the fork boundary before collapsing later tool updates", () => { + const result = buildAgentForkContextAttachment({ + boundaryMessageId: "assistant-1", + rows: [ + row(1, { type: "user_message", text: "Run it", messageId: "user-1" }), + row( + 2, + toolCallItem({ + callId: "terminal-1", + name: "terminal", + status: "running", + detail: { + type: "plain_text", + label: "before boundary", + }, + }), + ), + row(3, { + type: "assistant_message", + text: "Partial result.", + messageId: "assistant-1", + }), + row( + 4, + toolCallItem({ + callId: "terminal-1", + name: "terminal", + status: "completed", + detail: { + type: "plain_text", + label: "after boundary", + }, + }), + ), + ], + }); + + expect(result.attachment.text).toContain("[Terminal] before boundary"); + expect(result.attachment.text).toContain("[Assistant] Partial result."); + expect(result.attachment.text).not.toContain("after boundary"); + }); + + it("rejects missing assistant boundaries instead of silently using the wrong context", () => { + expect(() => + buildAgentForkContextAttachment({ + boundaryMessageId: "missing", + rows: [row(1, { type: "assistant_message", text: "Done.", messageId: "assistant-1" })], + }), + ).toThrow("Selected assistant message is no longer available."); + }); }); diff --git a/packages/server/src/server/agent/activity-curator.ts b/packages/server/src/server/agent/activity-curator.ts index 7bfb5f973..cb248cb62 100644 --- a/packages/server/src/server/agent/activity-curator.ts +++ b/packages/server/src/server/agent/activity-curator.ts @@ -1,4 +1,6 @@ import type { AgentTimelineItem } from "./agent-sdk-types.js"; +import type { AgentAttachment } from "@getpaseo/protocol/messages"; +import type { AgentTimelineRow } from "./agent-timeline-store-types.js"; import { isLikelyExternalToolName } from "@getpaseo/protocol/tool-name-normalization"; import { buildToolCallDisplayModel } from "@getpaseo/protocol/tool-call-display"; import { projectTimelineRows } from "./timeline-projection.js"; @@ -10,12 +12,16 @@ const MAX_TOOL_SUMMARY_CHARS = 200; interface ActivityCuratorOptions { maxItems?: number; labelAssistantMessages?: boolean; + includeKinds?: readonly AgentTimelineItem["type"][]; + includeExternalToolInput?: boolean; } interface ActivityEntry { text: string; } +type TextAgentAttachment = Extract; + function appendText(buffer: string, text: string): string { const normalized = text.trim(); if (!normalized) { @@ -95,24 +101,56 @@ function projectForCuration(items: readonly AgentTimelineItem[]): AgentTimelineI return projectTimelineRows({ rows, mode: "projected" }).map((entry) => entry.item); } -function curateAgentActivityEntries( - timeline: AgentTimelineItem[], +function shouldIncludeItem(item: AgentTimelineItem, options?: ActivityCuratorOptions): boolean { + if (!options?.includeKinds) { + return true; + } + return options.includeKinds.includes(item.type); +} + +function formatToolCallEntry( + item: Extract, + options?: ActivityCuratorOptions, +): ActivityEntry { + const inputJson = formatToolInputJson(inputFromUnknownDetail(item.detail)); + const display = buildToolCallDisplayModel({ + name: item.name, + status: item.status, + error: item.error, + detail: item.detail, + metadata: item.metadata, + }); + const displayName = display.displayName; + const summary = formatToolSummary(display.summary); + if ( + (options?.includeExternalToolInput ?? true) && + isLikelyExternalToolName(item.name) && + inputJson + ) { + return activityEntry(`[${displayName}] ${inputJson}`); + } + return activityEntry(summary ? `[${displayName}] ${summary}` : `[${displayName}]`); +} + +function curateProjectedActivityEntries( + items: readonly AgentTimelineItem[], options?: ActivityCuratorOptions, ): ActivityEntry[] { - if (timeline.length === 0) { + if (items.length === 0) { return []; } - const collapsed = projectForCuration(timeline); - const maxItems = options?.maxItems ?? DEFAULT_MAX_ITEMS; - const recentItems = - maxItems > 0 && collapsed.length > maxItems ? collapsed.slice(-maxItems) : collapsed; + const recentItems = maxItems > 0 && items.length > maxItems ? items.slice(-maxItems) : items; const entries: ActivityEntry[] = []; const buffers = { message: "", thought: "" }; for (const item of recentItems) { + if (!shouldIncludeItem(item, options)) { + continue; + } + switch (item.type) { case "user_message": flushBuffers(entries, buffers, options); @@ -126,25 +164,7 @@ function curateAgentActivityEntries( break; case "tool_call": { flushBuffers(entries, buffers, options); - const inputJson = formatToolInputJson(inputFromUnknownDetail(item.detail)); - const display = buildToolCallDisplayModel({ - name: item.name, - status: item.status, - error: item.error, - detail: item.detail, - metadata: item.metadata, - }); - const displayName = display.displayName; - const summary = formatToolSummary(display.summary); - if (isLikelyExternalToolName(item.name) && inputJson) { - entries.push(activityEntry(`[${displayName}] ${inputJson}`)); - break; - } - if (summary) { - entries.push(activityEntry(`[${displayName}] ${summary}`)); - } else { - entries.push(activityEntry(`[${displayName}]`)); - } + entries.push(formatToolCallEntry(item, options)); break; } case "todo": @@ -172,6 +192,14 @@ function curateAgentActivityEntries( return entries; } +function curateAgentActivityEntries( + timeline: AgentTimelineItem[], + options?: ActivityCuratorOptions, +): ActivityEntry[] { + const collapsed = projectForCuration(timeline); + return curateProjectedActivityEntries(collapsed, options); +} + /** * Convert normalized agent timeline items into a concise text summary. */ @@ -184,3 +212,90 @@ export function curateAgentActivity( ? entries.map((entry) => entry.text).join("\n") : "No activity to display."; } + +function selectForkContextRows(input: { + rows: readonly AgentTimelineRow[]; + boundaryMessageId?: string | null; +}): { items: AgentTimelineItem[]; boundaryMessageId: string | null } { + const boundaryMessageId = input.boundaryMessageId?.trim() || null; + if (!boundaryMessageId) { + const projected = projectTimelineRows({ rows: input.rows, mode: "projected" }); + return { + items: projected.map((entry) => entry.item), + boundaryMessageId: null, + }; + } + + const boundaryIndex = input.rows.findLastIndex( + (row) => row.item.type === "assistant_message" && row.item.messageId === boundaryMessageId, + ); + if (boundaryIndex < 0) { + throw new Error("Selected assistant message is no longer available."); + } + const selectedRows = input.rows.slice(0, boundaryIndex + 1); + const projected = projectTimelineRows({ rows: selectedRows, mode: "projected" }); + + return { + items: projected.map((entry) => entry.item), + boundaryMessageId, + }; +} + +function trimContextMetadata(value: string | null | undefined): string | null { + const trimmed = value?.trim(); + return trimmed ? trimmed : null; +} + +function buildForkContextText(input: { + body: string; + agentTitle?: string | null; + cwd?: string | null; +}): string { + const header = ["Chat history from a previous Paseo agent."]; + const agentTitle = trimContextMetadata(input.agentTitle); + const cwd = trimContextMetadata(input.cwd); + if (agentTitle) { + header.push(`Source agent: ${agentTitle}`); + } + if (cwd) { + header.push(`Source directory: ${cwd}`); + } + return `${header.join("\n")}\n\n${input.body}`; +} + +export function buildAgentForkContextAttachment(input: { + rows: readonly AgentTimelineRow[]; + boundaryMessageId?: string | null; + agentTitle?: string | null; + cwd?: string | null; +}): { attachment: TextAgentAttachment; itemCount: number; boundaryMessageId: string | null } { + const selected = selectForkContextRows({ + rows: input.rows, + boundaryMessageId: input.boundaryMessageId, + }); + const entries = curateProjectedActivityEntries(selected.items, { + maxItems: 0, + labelAssistantMessages: true, + includeKinds: ["user_message", "assistant_message", "tool_call"], + includeExternalToolInput: false, + }); + const body = + entries.length > 0 + ? entries.map((entry) => entry.text).join("\n") + : "No chat history to display."; + return { + attachment: { + type: "text", + mimeType: "text/plain", + contextKind: "chat_history", + title: "Chat history", + text: buildForkContextText({ + body, + agentTitle: input.agentTitle, + cwd: input.cwd, + }), + }, + itemCount: selected.items.length, + boundaryMessageId: selected.boundaryMessageId, + }; +} diff --git a/packages/server/src/server/agent/providers/mock-load-test-agent.ts b/packages/server/src/server/agent/providers/mock-load-test-agent.ts index 439628d2d..1609fe4a2 100644 --- a/packages/server/src/server/agent/providers/mock-load-test-agent.ts +++ b/packages/server/src/server/agent/providers/mock-load-test-agent.ts @@ -97,6 +97,7 @@ const MODELS: AgentModelDefinition[] = [ interface ActiveTurn { turnId: string; + assistantMessageId: string; prompt: AgentPromptInput; startedAt: number; cycle: number; @@ -609,12 +610,14 @@ export class MockLoadTestAgentSession implements AgentSession { const profile = resolveModelProfile(this.modelId); const turnId = randomUUID(); + const assistantMessageId = randomUUID(); let resolve!: (result: AgentRunResult) => void; const completed = new Promise((promiseResolve) => { resolve = promiseResolve; }); const turn: ActiveTurn = { turnId, + assistantMessageId, prompt, startedAt: Date.now(), cycle: 0, @@ -860,6 +863,7 @@ export class MockLoadTestAgentSession implements AgentSession { this.emitTimeline(turn.turnId, { type: "assistant_message", text: finalText, + messageId: turn.assistantMessageId, }); this.activeTurn = null; this.emit({ @@ -874,6 +878,7 @@ export class MockLoadTestAgentSession implements AgentSession { { type: "assistant_message", text: finalText, + messageId: turn.assistantMessageId, }, ], canceled: false, @@ -989,6 +994,7 @@ export class MockLoadTestAgentSession implements AgentSession { ? { type: "assistant_message", text: `stress-update-${index}`, + messageId: turn.assistantMessageId, } : { type: "todo", @@ -1067,6 +1073,7 @@ export class MockLoadTestAgentSession implements AgentSession { this.emitTimeline(turn.turnId, { type: "assistant_message", text: `data:image/png;base64,${payload}`, + messageId: turn.assistantMessageId, }); } @@ -1133,6 +1140,7 @@ export class MockLoadTestAgentSession implements AgentSession { this.emitTimeline(turn.turnId, { type: "assistant_message", text: event.text, + messageId: turn.assistantMessageId, }); return; } @@ -1178,6 +1186,7 @@ export class MockLoadTestAgentSession implements AgentSession { this.emitTimeline(turn.turnId, { type: "assistant_message", text: "\n\n_(end of synthetic stream)_\n", + messageId: turn.assistantMessageId, }); this.finishTurnWithText(turn, "Synthetic load test complete"); } diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index 7b7a0931d..38cab91fe 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -99,6 +99,7 @@ import { type TimelineProjectionEntry, type TimelineProjectionMode, } from "./agent/timeline-projection.js"; +import { buildAgentForkContextAttachment } from "./agent/activity-curator.js"; import type { StructuredGenerationDaemonConfig } from "./agent/structured-generation-providers.js"; import { getAgentStreamEventTurnId, @@ -1371,6 +1372,7 @@ export class Session { this.dispatchVoiceAndControlMessage(msg) ?? this.dispatchAgentRewindMessage(msg) ?? this.dispatchAgentRelationshipMessage(msg) ?? + this.dispatchAgentTimelineMessage(msg) ?? this.dispatchAgentLifecycleMessage(msg) ?? this.dispatchAgentConfigMessage(msg) ?? this.dispatchCheckoutMessage(msg) ?? @@ -1450,6 +1452,17 @@ export class Session { } } + private dispatchAgentTimelineMessage(msg: SessionInboundMessage): Promise | undefined { + switch (msg.type) { + case "fetch_agent_timeline_request": + return this.handleFetchAgentTimelineRequest(msg); + case "agent.fork_context.request": + return this.handleAgentForkContextRequest(msg); + default: + return undefined; + } + } + private dispatchAgentLifecycleMessage(msg: SessionInboundMessage): Promise | undefined { switch (msg.type) { case "fetch_agents_request": @@ -1484,8 +1497,6 @@ export class Session { return this.handleRefreshAgentRequest(msg); case "cancel_agent_request": return this.handleCancelAgentRequest(msg.agentId, msg.requestId); - case "fetch_agent_timeline_request": - return this.handleFetchAgentTimelineRequest(msg); case "agent_permission_response": return this.handleAgentPermissionResponse(msg.agentId, msg.requestId, msg.response); case "clear_agent_attention": @@ -5355,6 +5366,57 @@ export class Session { } } + private async handleAgentForkContextRequest( + msg: Extract, + ): Promise { + try { + const snapshot = await ensureAgentLoaded(msg.agentId, { + agentManager: this.agentManager, + agentStorage: this.agentStorage, + logger: this.sessionLogger, + }); + const agentPayload = await this.buildAgentPayload(snapshot); + const rows = this.agentManager.fetchTimeline(msg.agentId, { + direction: "tail", + limit: 0, + }).rows; + const forkContext = buildAgentForkContextAttachment({ + rows, + boundaryMessageId: msg.boundaryMessageId, + agentTitle: agentPayload.title, + cwd: snapshot.cwd, + }); + + this.emit({ + type: "agent.fork_context.response", + payload: { + requestId: msg.requestId, + agentId: msg.agentId, + attachment: forkContext.attachment, + itemCount: forkContext.itemCount, + boundaryMessageId: forkContext.boundaryMessageId, + error: null, + }, + }); + } catch (error) { + this.sessionLogger.error( + { err: error, agentId: msg.agentId }, + "Failed to handle agent.fork_context.request", + ); + this.emit({ + type: "agent.fork_context.response", + payload: { + requestId: msg.requestId, + agentId: msg.agentId, + attachment: null, + itemCount: 0, + boundaryMessageId: msg.boundaryMessageId ?? null, + error: error instanceof Error ? error.message : String(error), + }, + }); + } + } + private async handleSendAgentMessageRequest( msg: Extract, ): Promise { diff --git a/packages/server/src/server/websocket-server.ts b/packages/server/src/server/websocket-server.ts index d9ccaccae..5387b63ea 100644 --- a/packages/server/src/server/websocket-server.ts +++ b/packages/server/src/server/websocket-server.ts @@ -1200,6 +1200,8 @@ export class VoiceAssistantWebSocketServer { daemonDiagnostics: true, // COMPAT(daemonSelfUpdate): added in v0.1.93, remove gate after 2026-12-13. daemonSelfUpdate: true, + // COMPAT(agentForkContext): added in v0.1.102, remove gate after 2026-12-28. + agentForkContext: true, }, }; } From b5559dc1defb9c5de798fc0e9daa0b93ac2aaafb Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Mon, 29 Jun 2026 18:38:52 +0200 Subject: [PATCH 5/8] Keep streamed chat images in order (#1805) * fix(app): preserve assistant stream order Assistant head flushing only considered item kind, so a new identified assistant message could promote ahead of earlier live rows. Flush the live head when assistant message ids diverge so live rows remain an ordered suffix. * refactor(app): reuse assistant message id helper --- .../timeline/session-stream-reducers.test.ts | 153 ++++++++++++++++++ packages/app/src/types/stream.ts | 34 +++- 2 files changed, 180 insertions(+), 7 deletions(-) diff --git a/packages/app/src/timeline/session-stream-reducers.test.ts b/packages/app/src/timeline/session-stream-reducers.test.ts index 6c2ce8d90..848c34982 100644 --- a/packages/app/src/timeline/session-stream-reducers.test.ts +++ b/packages/app/src/timeline/session-stream-reducers.test.ts @@ -69,6 +69,14 @@ function makeTimelineEvent( } as AgentStreamEventPayload; } +function makeAssistantTimelineEvent(text: string, messageId?: string): AgentStreamEventPayload { + return { + type: "timeline", + provider: "claude", + item: { type: "assistant_message", text, ...(messageId ? { messageId } : {}) }, + } as AgentStreamEventPayload; +} + function makeToolCallTimelineEvent(callId: string): AgentStreamEventPayload { return { type: "timeline", @@ -1523,6 +1531,65 @@ describe("processAgentStreamEvents", () => { expect(result.sideEffects).toEqual([]); }); + it("keeps matching assistant message ids in the live head", () => { + const result = processAgentStreamEvents({ + events: [ + makeStreamReducerEvent(makeAssistantTimelineEvent("Hel", "assistant-one"), 1), + makeStreamReducerEvent(makeAssistantTimelineEvent("lo", "assistant-one"), 2), + ], + currentTail: [], + currentHead: [], + currentCursor: undefined, + currentAgent: null, + }); + + expect(result.changedTail).toBe(false); + expect(result.changedHead).toBe(true); + expect(result.tail).toEqual([]); + expect(result.head).toHaveLength(1); + expect(result.head[0]).toMatchObject({ + kind: "assistant_message", + text: "Hello", + messageId: "assistant-one", + }); + }); + + it("flushes the live assistant head before starting a different assistant message id", () => { + const result = processAgentStreamEvents({ + events: [ + makeStreamReducerEvent(makeAssistantTimelineEvent("First", "assistant-one"), 1), + makeStreamReducerEvent(makeAssistantTimelineEvent("Second", "assistant-two"), 2), + ], + currentTail: [], + currentHead: [], + currentCursor: undefined, + currentAgent: null, + }); + + expect(result.changedTail).toBe(true); + expect(result.changedHead).toBe(true); + expect(getAssistantTexts(result.tail)).toEqual(["First"]); + expect(getAssistantTexts(result.head)).toEqual(["Second"]); + }); + + it("flushes an anonymous assistant head before starting an identified assistant message", () => { + const result = processAgentStreamEvents({ + events: [ + makeStreamReducerEvent(makeAssistantTimelineEvent("Anonymous"), 1), + makeStreamReducerEvent(makeAssistantTimelineEvent("Identified", "assistant-two"), 2), + ], + currentTail: [], + currentHead: [], + currentCursor: undefined, + currentAgent: null, + }); + + expect(result.changedTail).toBe(true); + expect(result.changedHead).toBe(true); + expect(getAssistantTexts(result.tail)).toEqual(["Anonymous"]); + expect(getAssistantTexts(result.head)).toEqual(["Identified"]); + }); + it("promotes completed assistant markdown blocks to tail while keeping the live block in head", () => { const result = processAgentStreamEvents({ events: [ @@ -1632,6 +1699,92 @@ describe("processAgentStreamEvents", () => { } satisfies TimelineCursor); }); + it("keeps Claude image tool-result output before following assistant blocks while text streams", () => { + const imageMarkdown = + "![Image](/tmp/paseo-attachments/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.png)"; + const result = processAgentStreamEvents({ + events: [ + makeStreamReducerEvent( + { + type: "timeline", + provider: "claude", + item: { + type: "assistant_message", + text: "ABC", + messageId: "assistant-before", + }, + } as AgentStreamEventPayload, + 1, + ), + makeStreamReducerEvent(makeToolCallTimelineEvent("toolu_read_png"), 2), + makeStreamReducerEvent( + { + type: "timeline", + provider: "claude", + item: { + type: "tool_call", + callId: "toolu_read_png", + name: "Read", + status: "completed", + detail: { + type: "read", + filePath: "/tmp/image.png", + }, + error: null, + }, + } as AgentStreamEventPayload, + 3, + ), + makeStreamReducerEvent( + { + type: "timeline", + provider: "claude", + item: { + type: "assistant_message", + text: imageMarkdown, + }, + } as AgentStreamEventPayload, + 4, + ), + makeStreamReducerEvent( + { + type: "timeline", + provider: "claude", + item: { + type: "assistant_message", + text: "D", + messageId: "assistant-after", + }, + } as AgentStreamEventPayload, + 5, + ), + makeStreamReducerEvent( + { + type: "timeline", + provider: "claude", + item: { + type: "assistant_message", + text: "\n\nE", + messageId: "assistant-after", + }, + } as AgentStreamEventPayload, + 6, + ), + ], + currentTail: [], + currentHead: [], + currentCursor: undefined, + currentAgent: null, + }); + + expect(getAssistantTexts([...result.tail, ...result.head])).toEqual([ + "ABC", + imageMarkdown, + "D", + "E", + ]); + }); + it("returns the final optimistic lifecycle patch across a batch", () => { const result = processAgentStreamEvents({ events: [ diff --git a/packages/app/src/types/stream.ts b/packages/app/src/types/stream.ts index 67c7480d0..fb711fd2a 100644 --- a/packages/app/src/types/stream.ts +++ b/packages/app/src/types/stream.ts @@ -888,6 +888,13 @@ function getEventItemKind(event: AgentStreamEventPayload): StreamItem["kind"] | } } +function getIncomingAssistantMessageId(event: AgentStreamEventPayload): string | undefined { + if (event.type !== "timeline" || event.item.type !== "assistant_message") { + return undefined; + } + return event.item.messageId; +} + /** * Finalize head items before flushing to tail. * Marks thoughts as "ready" since they're no longer being streamed. @@ -939,10 +946,7 @@ function getTailAssistantToResume(params: { if (params.tailAssistant?.kind !== "assistant_message") { return null; } - const incomingMessageId = - params.event.type === "timeline" && params.event.item.type === "assistant_message" - ? params.event.item.messageId - : undefined; + const incomingMessageId = getIncomingAssistantMessageId(params.event); if (incomingMessageId !== undefined && params.tailAssistant.messageId !== incomingMessageId) { return null; } @@ -1036,9 +1040,14 @@ export function flushHeadToTail(tail: StreamItem[], head: StreamItem[]): StreamI /** * Determine if the head should be flushed based on incoming event kind. - * Flush when a different kind arrives or when the incoming kind is not streamable. + * Flush when a different streamable lane starts, including a new identified assistant message. */ -function shouldFlushHead(head: StreamItem[], incomingKind: StreamItem["kind"] | null): boolean { +function shouldFlushHead(input: { + head: StreamItem[]; + incomingKind: StreamItem["kind"] | null; + event: AgentStreamEventPayload; +}): boolean { + const { head, incomingKind, event } = input; if (head.length === 0) { return false; } @@ -1072,6 +1081,11 @@ function shouldFlushHead(head: StreamItem[], incomingKind: StreamItem["kind"] | return true; } + if (incomingKind === "assistant_message" && lastStreamable.kind === "assistant_message") { + const incomingMessageId = getIncomingAssistantMessageId(event); + return incomingMessageId !== undefined && lastStreamable.messageId !== incomingMessageId; + } + return false; } @@ -1134,7 +1148,13 @@ export function applyStreamEvent(params: { const incomingKind = getEventItemKind(event); // Check if we need to flush head before processing this event - if (shouldFlushHead(nextHead, incomingKind)) { + if ( + shouldFlushHead({ + head: nextHead, + incomingKind, + event, + }) + ) { flushHead(); } From a1afdfee5103761798af170f18b8df58caa38a55 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Mon, 29 Jun 2026 19:40:15 +0200 Subject: [PATCH 6/8] Fix Claude subagent narration leaking into chat (#1807) * fix(claude): keep subagent text out of parent transcript Sidechain tool activity is folded into the parent Task/Agent row, but sidechain assistant text was still going through the top-level text assembler. That made the chat show subagent narration without its matching tool rows. * test(claude): simplify subagent sidechain assertion --- package-lock.json | 258 +++++++++--------- packages/server/package.json | 2 +- .../claude/agent.sub-agent-sidechain.test.ts | 54 ++++ .../server/agent/providers/claude/agent.ts | 43 +-- 4 files changed, 209 insertions(+), 148 deletions(-) diff --git a/package-lock.json b/package-lock.json index 76e924484..ed1ad2394 100644 --- a/package-lock.json +++ b/package-lock.json @@ -109,134 +109,6 @@ "zod": "^3.25.76 || ^4.1.8" } }, - "node_modules/@anthropic-ai/claude-agent-sdk": { - "version": "0.3.181", - "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk/-/claude-agent-sdk-0.3.181.tgz", - "integrity": "sha512-xT2pXqqvCSmTwO5zctIxL4dO9vX7+rKeybvLBuEvUxub8sDyScK8s1dVstxL4ZtHQzOIDUng43qEq/kefNp+aA==", - "license": "SEE LICENSE IN README.md", - "engines": { - "node": ">=18.0.0" - }, - "optionalDependencies": { - "@anthropic-ai/claude-agent-sdk-darwin-arm64": "0.3.181", - "@anthropic-ai/claude-agent-sdk-darwin-x64": "0.3.181", - "@anthropic-ai/claude-agent-sdk-linux-arm64": "0.3.181", - "@anthropic-ai/claude-agent-sdk-linux-arm64-musl": "0.3.181", - "@anthropic-ai/claude-agent-sdk-linux-x64": "0.3.181", - "@anthropic-ai/claude-agent-sdk-linux-x64-musl": "0.3.181", - "@anthropic-ai/claude-agent-sdk-win32-arm64": "0.3.181", - "@anthropic-ai/claude-agent-sdk-win32-x64": "0.3.181" - }, - "peerDependencies": { - "@anthropic-ai/sdk": ">=0.93.0", - "@modelcontextprotocol/sdk": "^1.29.0", - "zod": "^4.0.0" - } - }, - "node_modules/@anthropic-ai/claude-agent-sdk-darwin-arm64": { - "version": "0.3.181", - "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-arm64/-/claude-agent-sdk-darwin-arm64-0.3.181.tgz", - "integrity": "sha512-krU5aV651tfv0IeLXo2m+YYduOdirQ+/ptv4IVXivsrjtBXHrtTTLrOaBEFuRQSLqCAMaTH3cITR+mj6ij4kMA==", - "cpu": [ - "arm64" - ], - "license": "SEE LICENSE IN LICENSE.md", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@anthropic-ai/claude-agent-sdk-darwin-x64": { - "version": "0.3.181", - "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-x64/-/claude-agent-sdk-darwin-x64-0.3.181.tgz", - "integrity": "sha512-kD+eMhGu2GA1/itW1LDYYELTNnQCre5C1RpekdEtQRP1iXWt6qQ+E42RJmMutBGVLGAl1cqRXIxTHXbuOWsecQ==", - "cpu": [ - "x64" - ], - "license": "SEE LICENSE IN LICENSE.md", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@anthropic-ai/claude-agent-sdk-linux-arm64": { - "version": "0.3.181", - "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64/-/claude-agent-sdk-linux-arm64-0.3.181.tgz", - "integrity": "sha512-DgrafZ4bfZN7mPOLraEzraU2A263K9Kg01i3PBSH4Mx5CfTUbJ6IVR1cYgwRCk2c4BwQmS0onnvxfgwAxP0dKg==", - "cpu": [ - "arm64" - ], - "license": "SEE LICENSE IN LICENSE.md", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@anthropic-ai/claude-agent-sdk-linux-arm64-musl": { - "version": "0.3.181", - "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64-musl/-/claude-agent-sdk-linux-arm64-musl-0.3.181.tgz", - "integrity": "sha512-lI39OgTCqKAqTtNF3a6+z4ToS2QKLWQw0oZGSGCMb2k6MrPB2OqyN/JSH6nvKfxLpaQmVTowwpyR4JInHMzobw==", - "cpu": [ - "arm64" - ], - "license": "SEE LICENSE IN LICENSE.md", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@anthropic-ai/claude-agent-sdk-linux-x64": { - "version": "0.3.181", - "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64/-/claude-agent-sdk-linux-x64-0.3.181.tgz", - "integrity": "sha512-KKYcN2PKHPUOPKKWsPY1bEEkeVMiOmTRpzIleGlCPsXJLS/QnN6RgNOHIrapRmh0z9uFl24KCP5X1OUMWZqu4Q==", - "cpu": [ - "x64" - ], - "license": "SEE LICENSE IN LICENSE.md", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@anthropic-ai/claude-agent-sdk-linux-x64-musl": { - "version": "0.3.181", - "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64-musl/-/claude-agent-sdk-linux-x64-musl-0.3.181.tgz", - "integrity": "sha512-FNf6QhAZ8/7q9MfFTCEfc0nA6oOSrEyKq3Xv2Jt4iSEViXYuIC25shk4T4EvU8qoLtbS51NCxCdpD7b0zCHvFQ==", - "cpu": [ - "x64" - ], - "license": "SEE LICENSE IN LICENSE.md", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@anthropic-ai/claude-agent-sdk-win32-arm64": { - "version": "0.3.181", - "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-arm64/-/claude-agent-sdk-win32-arm64-0.3.181.tgz", - "integrity": "sha512-7plhWEB9/ExlzTMY9SzUy6zSoCrMP4KFBJwfmkt0plxwv8lFJqX/tEH/r4QDQxrMQnR+d6RcbgPH6g3hnu3NMQ==", - "cpu": [ - "arm64" - ], - "license": "SEE LICENSE IN LICENSE.md", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@anthropic-ai/claude-agent-sdk-win32-x64": { - "version": "0.3.181", - "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-x64/-/claude-agent-sdk-win32-x64-0.3.181.tgz", - "integrity": "sha512-CQKaMv5tYBUxtFe1XG2qhnNJrmChXf+nhi5FhbE3GElWeNaXKMOTYqflii3zOT3yhqnrbh7fz1gzhOFwALtCeA==", - "cpu": [ - "x64" - ], - "license": "SEE LICENSE IN LICENSE.md", - "optional": true, - "os": [ - "win32" - ] - }, "node_modules/@anthropic-ai/sdk": { "version": "0.104.2", "resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.104.2.tgz", @@ -38147,7 +38019,7 @@ "version": "0.1.102-beta.1", "dependencies": { "@agentclientprotocol/sdk": "^0.17.1", - "@anthropic-ai/claude-agent-sdk": "^0.3.181", + "@anthropic-ai/claude-agent-sdk": "^0.3.195", "@anthropic-ai/sdk": "^0.104.2", "@getpaseo/client": "0.1.102-beta.1", "@getpaseo/highlight": "0.1.102-beta.1", @@ -38195,6 +38067,134 @@ "vitest": "^4.1.6" } }, + "packages/server/node_modules/@anthropic-ai/claude-agent-sdk": { + "version": "0.3.195", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk/-/claude-agent-sdk-0.3.195.tgz", + "integrity": "sha512-FVmXu9pvOMbuBKWrF8YsYQdQ/upOpv5rS8lFAnFO5jbyXT/2hN7kEPd2vd2GJpaMvNcO/KptyQUK5AxjjTz3+w==", + "license": "SEE LICENSE IN README.md", + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "@anthropic-ai/claude-agent-sdk-darwin-arm64": "0.3.195", + "@anthropic-ai/claude-agent-sdk-darwin-x64": "0.3.195", + "@anthropic-ai/claude-agent-sdk-linux-arm64": "0.3.195", + "@anthropic-ai/claude-agent-sdk-linux-arm64-musl": "0.3.195", + "@anthropic-ai/claude-agent-sdk-linux-x64": "0.3.195", + "@anthropic-ai/claude-agent-sdk-linux-x64-musl": "0.3.195", + "@anthropic-ai/claude-agent-sdk-win32-arm64": "0.3.195", + "@anthropic-ai/claude-agent-sdk-win32-x64": "0.3.195" + }, + "peerDependencies": { + "@anthropic-ai/sdk": ">=0.93.0", + "@modelcontextprotocol/sdk": "^1.29.0", + "zod": "^4.0.0" + } + }, + "packages/server/node_modules/@anthropic-ai/claude-agent-sdk-darwin-arm64": { + "version": "0.3.195", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-arm64/-/claude-agent-sdk-darwin-arm64-0.3.195.tgz", + "integrity": "sha512-WIMM/8HRCLsTDHFTIwQvvE8WCA/oaMJtdQxsP7iNyfzIGwXbuOyU95V8vYIhZfaO2yaSpbBRncunq4CtR5H4ng==", + "cpu": [ + "arm64" + ], + "license": "SEE LICENSE IN LICENSE.md", + "optional": true, + "os": [ + "darwin" + ] + }, + "packages/server/node_modules/@anthropic-ai/claude-agent-sdk-darwin-x64": { + "version": "0.3.195", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-x64/-/claude-agent-sdk-darwin-x64-0.3.195.tgz", + "integrity": "sha512-RY7DB+4LXosE0MJ+XELmakfPrDN1YX4lkk9CTDm28jGCVcESRz9kAEqbyaiC48dZcmN9V1NCLutzINGdcr1TBg==", + "cpu": [ + "x64" + ], + "license": "SEE LICENSE IN LICENSE.md", + "optional": true, + "os": [ + "darwin" + ] + }, + "packages/server/node_modules/@anthropic-ai/claude-agent-sdk-linux-arm64": { + "version": "0.3.195", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64/-/claude-agent-sdk-linux-arm64-0.3.195.tgz", + "integrity": "sha512-JuIq5Fnz/F1snl0aqi1gcuRZqPWoPNrL9dJ0DuievCxKkO8hnEz/Mmn5Zos7x1X8HE//ZnEvmQXoEQEZXonJew==", + "cpu": [ + "arm64" + ], + "license": "SEE LICENSE IN LICENSE.md", + "optional": true, + "os": [ + "linux" + ] + }, + "packages/server/node_modules/@anthropic-ai/claude-agent-sdk-linux-arm64-musl": { + "version": "0.3.195", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64-musl/-/claude-agent-sdk-linux-arm64-musl-0.3.195.tgz", + "integrity": "sha512-ZmyBA/AFzhgutcxb7dbhCm6GTjJytwNYXTxJoKE2B3A409WCYccjMqeji6vCMNxyyfylglGo5D8dVMIxW9aoug==", + "cpu": [ + "arm64" + ], + "license": "SEE LICENSE IN LICENSE.md", + "optional": true, + "os": [ + "linux" + ] + }, + "packages/server/node_modules/@anthropic-ai/claude-agent-sdk-linux-x64": { + "version": "0.3.195", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64/-/claude-agent-sdk-linux-x64-0.3.195.tgz", + "integrity": "sha512-s1lNi1cL93luoqsItH+fNO4KpIhdkvnVhWGGQUQ/8ftwa2gfmcIQnOg1hG8Ks+KzeD3UUQ8L9YEVHVADnFI/9A==", + "cpu": [ + "x64" + ], + "license": "SEE LICENSE IN LICENSE.md", + "optional": true, + "os": [ + "linux" + ] + }, + "packages/server/node_modules/@anthropic-ai/claude-agent-sdk-linux-x64-musl": { + "version": "0.3.195", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64-musl/-/claude-agent-sdk-linux-x64-musl-0.3.195.tgz", + "integrity": "sha512-nf8Q/LauB+ZOC6QDjxNhbsvwUtYjKYnaWJLTYFwhkmsLujePnety1AtT/1ubaUoq5AM1j297DhMlYTasa79OUA==", + "cpu": [ + "x64" + ], + "license": "SEE LICENSE IN LICENSE.md", + "optional": true, + "os": [ + "linux" + ] + }, + "packages/server/node_modules/@anthropic-ai/claude-agent-sdk-win32-arm64": { + "version": "0.3.195", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-arm64/-/claude-agent-sdk-win32-arm64-0.3.195.tgz", + "integrity": "sha512-hbkDE+xPIZzRWm+D+BKrH9uJH6USIZdDIlsyrIlGi3JFHoieYoA1vdUNyldSS9+F3ZqQtfPjr2Qy08IVB6akYA==", + "cpu": [ + "arm64" + ], + "license": "SEE LICENSE IN LICENSE.md", + "optional": true, + "os": [ + "win32" + ] + }, + "packages/server/node_modules/@anthropic-ai/claude-agent-sdk-win32-x64": { + "version": "0.3.195", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-x64/-/claude-agent-sdk-win32-x64-0.3.195.tgz", + "integrity": "sha512-av0piEB3X1Dzhpr8A+DqHVZ9y8s1jpn8enzwX0TKKUPBn5IqLTWC7wD6v66aoUgu4f+g4ThZirmDZA6shyPEZQ==", + "cpu": [ + "x64" + ], + "license": "SEE LICENSE IN LICENSE.md", + "optional": true, + "os": [ + "win32" + ] + }, "packages/server/node_modules/@isaacs/ttlcache": { "version": "2.1.4", "resolved": "https://registry.npmjs.org/@isaacs/ttlcache/-/ttlcache-2.1.4.tgz", diff --git a/packages/server/package.json b/packages/server/package.json index 61fcb7bcc..748a94446 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -63,7 +63,7 @@ }, "dependencies": { "@agentclientprotocol/sdk": "^0.17.1", - "@anthropic-ai/claude-agent-sdk": "^0.3.181", + "@anthropic-ai/claude-agent-sdk": "^0.3.195", "@anthropic-ai/sdk": "^0.104.2", "@getpaseo/client": "0.1.102-beta.1", "@getpaseo/highlight": "0.1.102-beta.1", diff --git a/packages/server/src/server/agent/providers/claude/agent.sub-agent-sidechain.test.ts b/packages/server/src/server/agent/providers/claude/agent.sub-agent-sidechain.test.ts index ca87fee3e..a7b8e0fe7 100644 --- a/packages/server/src/server/agent/providers/claude/agent.sub-agent-sidechain.test.ts +++ b/packages/server/src/server/agent/providers/claude/agent.sub-agent-sidechain.test.ts @@ -211,6 +211,20 @@ describe("ClaudeAgentSession sub-agent sidechain updates", () => { parent_tool_use_id: "task-call-1", elapsed_time_seconds: 1, }, + { + type: "assistant", + parent_tool_use_id: "task-call-1", + message: { + id: "subagent-message-1", + role: "assistant", + content: [ + { + type: "text", + text: "Sub-agent narration belongs inside the Task row, not the parent transcript.", + }, + ], + }, + }, { type: "assistant", parent_tool_use_id: null, @@ -295,6 +309,46 @@ describe("ClaudeAgentSession sub-agent sidechain updates", () => { expect(projectedTaskCalls).toHaveLength(1); }); + test("keeps sidechain assistant text out of the parent transcript", async () => { + const session = await new ClaudeAgentClient({ + logger, + queryFactory, + resolveBinary: async () => "/test/claude/bin", + }).createSession({ + provider: "claude", + cwd: process.cwd(), + }); + + const events = await collectUntilTerminal(streamSession(session, "delegate work")); + await session.close(); + + const visibleAssistantText = events + .flatMap((event) => + event.type === "timeline" && event.item.type === "assistant_message" + ? [event.item.text] + : [], + ) + .join(""); + + expect(visibleAssistantText).not.toContain("Sub-agent narration"); + + const latestSubAgentUpdate = events + .filter( + (event): event is Extract => + event.type === "timeline" && + event.item.type === "tool_call" && + event.item.callId === "task-call-1" && + event.item.detail.type === "sub_agent", + ) + .map((event) => event.item) + .at(-1); + + expect(latestSubAgentUpdate?.detail).toMatchObject({ + type: "sub_agent", + log: expect.stringContaining("[Read] README.md"), + }); + }); + test("tails sub-agent actions instead of dropping latest entries at cap", async () => { queryFactory.mockImplementation(() => buildQueryMock(buildTailScenarioEvents(205))); diff --git a/packages/server/src/server/agent/providers/claude/agent.ts b/packages/server/src/server/agent/providers/claude/agent.ts index 7761d8ea0..62af2b0fa 100644 --- a/packages/server/src/server/agent/providers/claude/agent.ts +++ b/packages/server/src/server/agent/providers/claude/agent.ts @@ -1731,6 +1731,14 @@ function isClaudeSubagentToolName(name: string | undefined): boolean { return name === "Task" || name === "Agent"; } +function readClaudeParentToolUseId(message: SDKMessage): string | null { + if (!("parent_tool_use_id" in message)) { + return null; + } + const parentToolUseId = (message as { parent_tool_use_id?: unknown }).parent_tool_use_id; + return typeof parentToolUseId === "string" && parentToolUseId.length > 0 ? parentToolUseId : null; +} + class ClaudeContextUsageState { private contextWindowMaxTokens: number | undefined; private streamRequestInputTokens: number | undefined; @@ -3495,20 +3503,22 @@ class ClaudeAgentSession implements AgentSession { suppressAssistantText: true, suppressReasoning: true, }); - const assistantTimelineEvents = this.timelineAssembler - .consume({ - message, - runId: turnId, - messageIdHint, - }) - .map( - (item) => - ({ - type: "timeline", - item, - provider: "claude", - }) satisfies AgentStreamEvent, - ); + const assistantTimelineEvents = readClaudeParentToolUseId(message) + ? [] + : this.timelineAssembler + .consume({ + message, + runId: turnId, + messageIdHint, + }) + .map( + (item) => + ({ + type: "timeline", + item, + provider: "claude", + }) satisfies AgentStreamEvent, + ); return [...messageEvents, ...assistantTimelineEvents]; } @@ -3582,10 +3592,7 @@ class ClaudeAgentSession implements AgentSession { suppressReasoning?: boolean; }, ): AgentStreamEvent[] { - const parentToolUseId = - "parent_tool_use_id" in message - ? (message as { parent_tool_use_id: string | null }).parent_tool_use_id - : null; + const parentToolUseId = readClaudeParentToolUseId(message); if (parentToolUseId) { return this.sidechainTracker.handleMessage(message, parentToolUseId); } From 31d14ec8a757e1adc973f7d07e1b994b8f1712f6 Mon Sep 17 00:00:00 2001 From: "paseo-ai[bot]" <266920839+paseo-ai[bot]@users.noreply.github.com> Date: Mon, 29 Jun 2026 17:43:59 +0000 Subject: [PATCH 7/8] fix: update lockfile signatures and Nix hash [skip ci] --- nix/npm-deps.hash | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nix/npm-deps.hash b/nix/npm-deps.hash index cd47ad330..b45f83c66 100644 --- a/nix/npm-deps.hash +++ b/nix/npm-deps.hash @@ -1 +1 @@ -sha256-Q8PQXknQerl0SOejZ+q/uCSqQwH541AHJ4bzu5Dap+A= +sha256-4fS/faM5nUjfRnc0UznCisNAby2fiNg0ROklW0FyptY= From f31bc2b8b7c8e3d5565be7ff9ae4e36be376025b Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Mon, 29 Jun 2026 20:21:19 +0200 Subject: [PATCH 8/8] Fix pushed state for checked-out PR worktrees (#1804) * fix(worktree): track checkout PR branches correctly Checkout-PR worktrees could point branch tracking at a PR push remote without fetching a matching remote-tracking ref. Configure same-repo PRs to track origin, make fork PR remotes fetchable before assigning upstream, and push fork PR branches to their configured remote. * fix(worktree): scope PR push routing to Paseo targets Review caught that using ordinary upstream config for Push could send normal branches to non-origin remotes and mishandle deduped PR branch names. Store a Paseo-specific push target during PR worktree creation, then have Push honor only that marker before falling back to origin. * fix(worktree): use git upstream for PR pushes * fix(worktree): allow PR checkout without head upstream * fix(worktree): preserve fork PR git push target * fix(worktree): preserve PR push targets * fix(worktree): preserve PR push status * fix(worktree): set upstream after push-only PR push --- .../resolve-worktree-creation-intent.test.ts | 49 +++ .../resolve-worktree-creation-intent.ts | 4 +- .../src/server/worktree-core.posix.test.ts | 381 +++++++++++++++++- .../server/src/utils/checkout-git.test.ts | 217 +++++++++- packages/server/src/utils/checkout-git.ts | 245 ++++++++++- packages/server/src/utils/worktree.ts | 131 +++++- 6 files changed, 998 insertions(+), 29 deletions(-) diff --git a/packages/server/src/server/resolve-worktree-creation-intent.test.ts b/packages/server/src/server/resolve-worktree-creation-intent.test.ts index 7381f7658..7998b3815 100644 --- a/packages/server/src/server/resolve-worktree-creation-intent.test.ts +++ b/packages/server/src/server/resolve-worktree-creation-intent.test.ts @@ -113,6 +113,55 @@ describe("resolveWorktreeCreationIntent", () => { expect(deps.headRefLookups).toEqual([{ cwd: repoRoot, number: 42 }]); }); + test("does not configure a synthetic push remote for same-repo PR targets", async () => { + const deps = createResolverHarness(); + deps.github.getPullRequestCheckoutTarget = async () => ({ + number: 1790, + baseRefName: "main", + headRefName: "daemon-shutdown-diagnostics", + headOwnerLogin: "getpaseo", + headRepositorySshUrl: "git@github.com:getpaseo/paseo.git", + headRepositoryUrl: "https://github.com/getpaseo/paseo", + isCrossRepository: false, + }); + + await expect( + resolveWorktreeCreationIntent({ action: "checkout", githubPrNumber: 1790 }, repoRoot, deps), + ).resolves.toEqual({ + kind: "checkout-github-pr", + githubPrNumber: 1790, + headRef: "daemon-shutdown-diagnostics", + baseRefName: "main", + trackOriginHead: true, + }); + expect(deps.headRefLookups).toEqual([]); + }); + + test("configures the contributor remote for fork PR targets", async () => { + const deps = createResolverHarness(); + deps.github.getPullRequestCheckoutTarget = async () => ({ + number: 526, + baseRefName: "main", + headRefName: "main", + headOwnerLogin: "therainisme", + headRepositorySshUrl: "git@github.com:therainisme/paseo.git", + headRepositoryUrl: "https://github.com/therainisme/paseo", + isCrossRepository: true, + }); + + await expect( + resolveWorktreeCreationIntent({ action: "checkout", githubPrNumber: 526 }, repoRoot, deps), + ).resolves.toEqual({ + kind: "checkout-github-pr", + githubPrNumber: 526, + headRef: "main", + baseRefName: "main", + localBranchName: "therainisme/main", + pushRemoteUrl: "git@github.com:therainisme/paseo.git", + }); + expect(deps.headRefLookups).toEqual([]); + }); + test("uses an explicit PR head ref without calling GitHub", async () => { const deps = createResolverHarness(); diff --git a/packages/server/src/server/resolve-worktree-creation-intent.ts b/packages/server/src/server/resolve-worktree-creation-intent.ts index 5b0adba5d..8378496f3 100644 --- a/packages/server/src/server/resolve-worktree-creation-intent.ts +++ b/packages/server/src/server/resolve-worktree-creation-intent.ts @@ -116,9 +116,10 @@ async function resolveGitHubPrCheckoutIntent(params: { checkoutTarget?.baseRefName?.trim() || (await resolveDefaultBranch(params.repoRoot, params.deps)); const localBranchName = buildGitHubPrLocalBranchName({ headRef, checkoutTarget }); - const pushRemoteUrl = checkoutTarget + const pushRemoteUrl = checkoutTarget?.isCrossRepository ? checkoutTarget.headRepositorySshUrl || checkoutTarget.headRepositoryUrl || undefined : undefined; + const trackOriginHead = checkoutTarget ? !checkoutTarget.isCrossRepository : false; return { kind: "checkout-github-pr", @@ -127,6 +128,7 @@ async function resolveGitHubPrCheckoutIntent(params: { baseRefName, ...(localBranchName !== headRef ? { localBranchName } : {}), ...(pushRemoteUrl ? { pushRemoteUrl } : {}), + ...(trackOriginHead ? { trackOriginHead } : {}), }; } diff --git a/packages/server/src/server/worktree-core.posix.test.ts b/packages/server/src/server/worktree-core.posix.test.ts index 58778b3f6..f5e105502 100644 --- a/packages/server/src/server/worktree-core.posix.test.ts +++ b/packages/server/src/server/worktree-core.posix.test.ts @@ -14,6 +14,7 @@ import { tmpdir } from "node:os"; import path from "node:path"; import { describe, expect, test, afterEach } from "vitest"; import type { GitHubService } from "../services/github-service.js"; +import { getCheckoutStatus, pushCurrentBranch } from "../utils/checkout-git.js"; import { UnknownBranchError } from "../utils/worktree.js"; import { createWorktreeCore as createCoreWorktree } from "./worktree-core.js"; import { isPlatform } from "../test-utils/platform.js"; @@ -123,6 +124,44 @@ function createGitHubPrRemoteRepo(): { tempDir: string; repoDir: string; paseoHo return { tempDir, repoDir, paseoHome }; } +function createSameRepoGitHubPrRemoteRepo(): { + tempDir: string; + repoDir: string; + remoteDir: string; + paseoHome: string; +} { + const { tempDir, repoDir, paseoHome } = createGitRepo(); + const remoteDir = path.join(tempDir, "origin.git"); + const featureBranch = "daemon-shutdown-diagnostics"; + + execFileSync("git", ["clone", "--bare", repoDir, remoteDir], { + stdio: "pipe", + }); + execFileSync("git", ["remote", "add", "origin", remoteDir], { + cwd: repoDir, + stdio: "pipe", + }); + execFileSync("git", ["checkout", "-b", featureBranch], { cwd: repoDir, stdio: "pipe" }); + writeFileSync(path.join(repoDir, "README.md"), "same repo pr branch\n"); + execFileSync("git", ["add", "README.md"], { cwd: repoDir, stdio: "pipe" }); + execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "same repo pr branch"], { + cwd: repoDir, + stdio: "pipe", + }); + const prHead = execFileSync("git", ["rev-parse", "HEAD"], { cwd: repoDir, stdio: "pipe" }) + .toString() + .trim(); + execFileSync("git", ["push", "origin", featureBranch], { cwd: repoDir, stdio: "pipe" }); + execFileSync("git", [`--git-dir=${remoteDir}`, "update-ref", "refs/pull/1790/head", prHead], { + stdio: "pipe", + }); + execFileSync("git", ["checkout", "main"], { cwd: repoDir, stdio: "pipe" }); + execFileSync("git", ["branch", "-D", featureBranch], { cwd: repoDir, stdio: "pipe" }); + execFileSync("git", ["fetch", "origin"], { cwd: repoDir, stdio: "pipe" }); + + return { tempDir, repoDir, remoteDir, paseoHome }; +} + function createForkGitHubPrRemoteRepo(): { tempDir: string; repoDir: string; @@ -411,6 +450,201 @@ describe.skipIf(isPlatform("win32"))("worktree-core POSIX-only", () => { expect(result.worktree.branchName).toBe("pr-123"); }); + test("checks out a same-repo GitHub PR with valid origin tracking", async () => { + const { tempDir, repoDir, remoteDir, paseoHome } = createSameRepoGitHubPrRemoteRepo(); + cleanupPaths.push(tempDir); + const github = { + ...createGitHubServiceStub(), + getPullRequestCheckoutTarget: async () => ({ + number: 1790, + baseRefName: "main", + headRefName: "daemon-shutdown-diagnostics", + headOwnerLogin: "getpaseo", + headRepositorySshUrl: remoteDir, + headRepositoryUrl: remoteDir, + isCrossRepository: false, + }), + }; + + const result = await createCoreWorktree( + { + cwd: repoDir, + action: "checkout", + githubPrNumber: 1790, + paseoHome, + runSetup: false, + }, + createCoreDeps({ github }), + ); + const status = await getCheckoutStatus(result.worktree.worktreePath, { paseoHome }); + + expect(result.worktree.branchName).toBe("daemon-shutdown-diagnostics"); + expect(getBranchUpstream(result.worktree.worktreePath)).toBe( + "origin/daemon-shutdown-diagnostics", + ); + expect(status).toMatchObject({ + isGit: true, + currentBranch: "daemon-shutdown-diagnostics", + aheadOfOrigin: 0, + behindOfOrigin: 0, + }); + }); + + test("checks out a same-repo GitHub PR whose head branch was deleted", async () => { + const { tempDir, repoDir, remoteDir, paseoHome } = createSameRepoGitHubPrRemoteRepo(); + cleanupPaths.push(tempDir); + execFileSync( + "git", + ["--git-dir", remoteDir, "update-ref", "-d", "refs/heads/daemon-shutdown-diagnostics"], + { stdio: "pipe" }, + ); + const github = { + ...createGitHubServiceStub(), + getPullRequestCheckoutTarget: async () => ({ + number: 1790, + baseRefName: "main", + headRefName: "daemon-shutdown-diagnostics", + headOwnerLogin: "getpaseo", + headRepositorySshUrl: remoteDir, + headRepositoryUrl: remoteDir, + isCrossRepository: false, + }), + }; + + const result = await createCoreWorktree( + { + cwd: repoDir, + action: "checkout", + githubPrNumber: 1790, + paseoHome, + runSetup: false, + }, + createCoreDeps({ github }), + ); + const readme = readFileSync(path.join(result.worktree.worktreePath, "README.md"), "utf8"); + + expect(result.worktree.branchName).toBe("daemon-shutdown-diagnostics"); + expect(readme.replace(/\r\n/g, "\n")).toBe("same repo pr branch\n"); + expect(getBranchUpstream(result.worktree.worktreePath)).toBeNull(); + }); + + test("pushes a deduplicated same-repo GitHub PR branch to the PR head", async () => { + const { tempDir, repoDir, remoteDir, paseoHome } = createSameRepoGitHubPrRemoteRepo(); + cleanupPaths.push(tempDir); + execFileSync("git", ["remote", "set-url", "origin", `file://${remoteDir}`], { + cwd: repoDir, + stdio: "pipe", + }); + execFileSync("git", ["remote", "set-url", "--push", "origin", remoteDir], { + cwd: repoDir, + stdio: "pipe", + }); + const github = { + ...createGitHubServiceStub(), + getPullRequestCheckoutTarget: async () => ({ + number: 1790, + baseRefName: "main", + headRefName: "daemon-shutdown-diagnostics", + headOwnerLogin: "getpaseo", + headRepositorySshUrl: remoteDir, + headRepositoryUrl: remoteDir, + isCrossRepository: false, + }), + }; + + await createCoreWorktree( + { + cwd: repoDir, + worktreeSlug: "first-pr-worktree", + action: "checkout", + githubPrNumber: 1790, + paseoHome, + runSetup: false, + }, + createCoreDeps({ github }), + ); + const second = await createCoreWorktree( + { + cwd: repoDir, + worktreeSlug: "second-pr-worktree", + action: "checkout", + githubPrNumber: 1790, + paseoHome, + runSetup: false, + }, + createCoreDeps({ github }), + ); + writeFileSync(path.join(second.worktree.worktreePath, "FOLLOWUP.md"), "same repo edit\n"); + execFileSync("git", ["add", "FOLLOWUP.md"], { + cwd: second.worktree.worktreePath, + stdio: "pipe", + }); + execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "same repo edit"], { + cwd: second.worktree.worktreePath, + stdio: "pipe", + }); + const localHead = execFileSync("git", ["rev-parse", "HEAD"], { + cwd: second.worktree.worktreePath, + stdio: "pipe", + }) + .toString() + .trim(); + const pushRemote = execFileSync( + "git", + ["config", `branch.${second.worktree.branchName}.pushRemote`], + { + cwd: second.worktree.worktreePath, + stdio: "pipe", + }, + ) + .toString() + .trim(); + const pushRefspec = execFileSync("git", ["config", "remote.paseo-pr-1790.push"], { + cwd: second.worktree.worktreePath, + stdio: "pipe", + }) + .toString() + .trim(); + const pushRemoteUrl = execFileSync("git", ["config", "remote.paseo-pr-1790.url"], { + cwd: second.worktree.worktreePath, + stdio: "pipe", + }) + .toString() + .trim(); + + execFileSync("git", ["push"], { cwd: second.worktree.worktreePath, stdio: "pipe" }); + + const remotePrHead = execFileSync( + "git", + ["--git-dir", remoteDir, "rev-parse", "refs/heads/daemon-shutdown-diagnostics"], + { stdio: "pipe" }, + ) + .toString() + .trim(); + const dedupedRemoteBranch = spawnSync( + "git", + [ + "--git-dir", + remoteDir, + "show-ref", + "--verify", + "--quiet", + "refs/heads/daemon-shutdown-diagnostics-1", + ], + { encoding: "utf8" }, + ); + + expect(second.worktree.branchName).toBe("daemon-shutdown-diagnostics-1"); + expect(getBranchUpstream(second.worktree.worktreePath)).toBe( + "origin/daemon-shutdown-diagnostics", + ); + expect(pushRemote).toBe("paseo-pr-1790"); + expect(pushRefspec).toBe("HEAD:refs/heads/daemon-shutdown-diagnostics"); + expect(pushRemoteUrl).toBe(remoteDir); + expect(remotePrHead).toBe(localHead); + expect(dedupedRemoteBranch.status).toBe(1); + }); + test("checks out a fork PR whose head branch collides with local main", async () => { const { tempDir, repoDir, headRemoteDir, paseoHome } = createForkGitHubPrRemoteRepo(); cleanupPaths.push(tempDir); @@ -452,6 +686,13 @@ describe.skipIf(isPlatform("win32"))("worktree-core POSIX-only", () => { .toString() .trim(); const readme = readFileSync(path.join(result.worktree.worktreePath, "README.md"), "utf8"); + const cleanStatus = await getCheckoutStatus(result.worktree.worktreePath, { paseoHome }); + const pushRefspec = execFileSync("git", ["config", "remote.paseo-pr-526.push"], { + cwd: result.worktree.worktreePath, + stdio: "pipe", + }) + .toString() + .trim(); writeFileSync(path.join(result.worktree.worktreePath, "FOLLOWUP.md"), "maintainer edit\n"); execFileSync("git", ["add", "FOLLOWUP.md"], { cwd: result.worktree.worktreePath, @@ -461,11 +702,23 @@ describe.skipIf(isPlatform("win32"))("worktree-core POSIX-only", () => { cwd: result.worktree.worktreePath, stdio: "pipe", }); - const pushDryRunResult = spawnSync("git", ["push", "--dry-run"], { + const localHead = execFileSync("git", ["rev-parse", "HEAD"], { cwd: result.worktree.worktreePath, - encoding: "utf8", - }); - const pushDryRun = `${pushDryRunResult.stdout}${pushDryRunResult.stderr}`; + stdio: "pipe", + }) + .toString() + .trim(); + + execFileSync("git", ["push"], { cwd: result.worktree.worktreePath, stdio: "pipe" }); + + const remotePrHead = execFileSync("git", [ + "--git-dir", + headRemoteDir, + "rev-parse", + "refs/heads/main", + ]) + .toString() + .trim(); expect(sourceBranch).toBe("main"); expect(result.intent).toEqual({ @@ -480,7 +733,115 @@ describe.skipIf(isPlatform("win32"))("worktree-core POSIX-only", () => { expect(path.basename(result.worktree.worktreePath)).toBe("therainisme-main"); expect(worktreeBranch).toBe("therainisme/main"); expect(readme.replace(/\r\n/g, "\n")).toBe("fork pr main branch\n"); - expect(pushDryRun).toContain("HEAD -> main"); + expect(getBranchUpstream(result.worktree.worktreePath)).toBe("paseo-pr-526/main"); + expect(pushRefspec).toBe("HEAD:refs/heads/main"); + expect(cleanStatus).toMatchObject({ + isGit: true, + currentBranch: "therainisme/main", + aheadOfOrigin: 0, + behindOfOrigin: 0, + }); + expect(remotePrHead).toBe(localHead); + }); + + test("pushes a fork PR when the contributor branch cannot be fetched at checkout", async () => { + const { tempDir, repoDir, headRemoteDir, paseoHome } = createForkGitHubPrRemoteRepo(); + cleanupPaths.push(tempDir); + execFileSync("git", ["--git-dir", headRemoteDir, "update-ref", "-d", "refs/heads/main"], { + stdio: "pipe", + }); + const github = { + ...createGitHubServiceStub(), + getPullRequestCheckoutTarget: async () => ({ + number: 526, + baseRefName: "main", + headRefName: "main", + headOwnerLogin: "therainisme", + headRepositorySshUrl: headRemoteDir, + headRepositoryUrl: headRemoteDir, + isCrossRepository: true, + }), + }; + + const result = await createCoreWorktree( + { + cwd: repoDir, + action: "checkout", + githubPrNumber: 526, + refName: "main", + paseoHome, + runSetup: false, + }, + createCoreDeps({ github }), + ); + const pushRemote = execFileSync( + "git", + ["config", `branch.${result.worktree.branchName}.pushRemote`], + { + cwd: result.worktree.worktreePath, + stdio: "pipe", + }, + ) + .toString() + .trim(); + const pushRefspec = execFileSync("git", ["config", "remote.paseo-pr-526.push"], { + cwd: result.worktree.worktreePath, + stdio: "pipe", + }) + .toString() + .trim(); + const upstreamBeforePush = getBranchUpstream(result.worktree.worktreePath); + writeFileSync(path.join(result.worktree.worktreePath, "FOLLOWUP.md"), "fork edit\n"); + execFileSync("git", ["add", "FOLLOWUP.md"], { + cwd: result.worktree.worktreePath, + stdio: "pipe", + }); + execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "fork edit"], { + cwd: result.worktree.worktreePath, + stdio: "pipe", + }); + const localHead = execFileSync("git", ["rev-parse", "HEAD"], { + cwd: result.worktree.worktreePath, + stdio: "pipe", + }) + .toString() + .trim(); + + await pushCurrentBranch(result.worktree.worktreePath); + + const remotePrHead = execFileSync("git", [ + "--git-dir", + headRemoteDir, + "rev-parse", + "refs/heads/main", + ]) + .toString() + .trim(); + const trackedPrRemoteHead = execFileSync( + "git", + ["rev-parse", "refs/remotes/paseo-pr-526/main"], + { + cwd: result.worktree.worktreePath, + stdio: "pipe", + }, + ) + .toString() + .trim(); + const afterPushStatus = await getCheckoutStatus(result.worktree.worktreePath); + + expect(result.worktree.branchName).toBe("therainisme/main"); + expect(upstreamBeforePush).toBeNull(); + expect(getBranchUpstream(result.worktree.worktreePath)).toBe("paseo-pr-526/main"); + expect(pushRemote).toBe("paseo-pr-526"); + expect(pushRefspec).toBe("HEAD:refs/heads/main"); + expect(remotePrHead).toBe(localHead); + expect(trackedPrRemoteHead).toBe(localHead); + expect(afterPushStatus).toMatchObject({ + isGit: true, + currentBranch: "therainisme/main", + aheadOfOrigin: 0, + behindOfOrigin: 0, + }); }); test("uses a unique local branch when the same fork PR branch already exists", async () => { @@ -526,14 +887,8 @@ describe.skipIf(isPlatform("win32"))("worktree-core POSIX-only", () => { expect(first.worktree.branchName).toBe("therainisme/main"); expect(second.worktree.branchName).toBe("therainisme/main-1"); - expect( - execFileSync("git", ["config", "--get", "remote.paseo-pr-526.push"], { - cwd: second.worktree.worktreePath, - stdio: "pipe", - }) - .toString() - .trim(), - ).toBe("HEAD:refs/heads/main"); + expect(getBranchUpstream(first.worktree.worktreePath)).toBe("paseo-pr-526/main"); + expect(getBranchUpstream(second.worktree.worktreePath)).toBe("paseo-pr-526/main"); }); test("throws a typed error for an unknown checkout branch", async () => { diff --git a/packages/server/src/utils/checkout-git.test.ts b/packages/server/src/utils/checkout-git.test.ts index 2a28cdeaa..fde7ad0d8 100644 --- a/packages/server/src/utils/checkout-git.test.ts +++ b/packages/server/src/utils/checkout-git.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, beforeEach, afterEach } from "vitest"; -import { execFileSync, execSync } from "child_process"; +import { execFileSync, execSync, spawnSync } from "child_process"; import { existsSync, mkdtempSync, @@ -187,6 +187,14 @@ async function readPullRequestLookupTargetFromFacts( return facts.pullRequestLookupTarget; } +function getBranchUpstream(cwd: string): string | null { + const result = spawnSync("git", ["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"], { + cwd, + encoding: "utf8", + }); + return result.status === 0 ? result.stdout.trim() : null; +} + function setupRemoteTrackingMain( repoDir: string, tempDir: string, @@ -1700,6 +1708,183 @@ const x = 1; expect(upstream).toBe("origin/feature"); }); + it("pushes the current branch to its configured upstream", async () => { + const originDir = join(tempDir, "origin.git"); + const prRemoteDir = join(tempDir, "pr-remote.git"); + execFileSync("git", ["clone", "--bare", repoDir, originDir]); + execFileSync("git", ["clone", "--bare", repoDir, prRemoteDir]); + execFileSync("git", ["remote", "add", "origin", originDir], { cwd: repoDir }); + execFileSync("git", ["remote", "add", "paseo-pr-526", prRemoteDir], { cwd: repoDir }); + execFileSync("git", ["checkout", "-b", "therainisme/main"], { cwd: repoDir }); + execFileSync("git", ["fetch", "paseo-pr-526", "main"], { cwd: repoDir }); + execFileSync("git", ["config", "branch.therainisme/main.remote", "paseo-pr-526"], { + cwd: repoDir, + }); + execFileSync("git", ["config", "branch.therainisme/main.merge", "refs/heads/main"], { + cwd: repoDir, + }); + writeFileSync(join(repoDir, "fork-pr.txt"), "fork pr edit\n"); + execFileSync("git", ["add", "fork-pr.txt"], { cwd: repoDir }); + execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "fork pr edit"], { + cwd: repoDir, + }); + const localHead = execFileSync("git", ["rev-parse", "HEAD"], { cwd: repoDir }) + .toString() + .trim(); + + await pushCurrentBranch(repoDir); + + const prRemoteMain = execFileSync("git", [ + "--git-dir", + prRemoteDir, + "rev-parse", + "refs/heads/main", + ]) + .toString() + .trim(); + const upstream = execFileSync( + "git", + ["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"], + { cwd: repoDir }, + ) + .toString() + .trim(); + expect(prRemoteMain).toBe(localHead); + expect(upstream).toBe("paseo-pr-526/main"); + }); + + it("pushes the current branch to its configured push remote", async () => { + const originDir = join(tempDir, "origin.git"); + const prRemoteDir = join(tempDir, "pr-remote.git"); + execFileSync("git", ["clone", "--bare", repoDir, originDir]); + execFileSync("git", ["clone", "--bare", repoDir, prRemoteDir]); + execFileSync("git", ["remote", "add", "origin", originDir], { cwd: repoDir }); + execFileSync("git", ["remote", "add", "paseo-pr-526", prRemoteDir], { cwd: repoDir }); + execFileSync("git", ["checkout", "-b", "therainisme/main"], { cwd: repoDir }); + execFileSync("git", ["config", "branch.therainisme/main.pushRemote", "paseo-pr-526"], { + cwd: repoDir, + }); + execFileSync("git", ["config", "remote.paseo-pr-526.push", "HEAD:refs/heads/main"], { + cwd: repoDir, + }); + writeFileSync(join(repoDir, "fork-pr.txt"), "fork pr edit\n"); + execFileSync("git", ["add", "fork-pr.txt"], { cwd: repoDir }); + execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "fork pr edit"], { + cwd: repoDir, + }); + const localHead = execFileSync("git", ["rev-parse", "HEAD"], { cwd: repoDir }) + .toString() + .trim(); + const upstreamBeforePush = getBranchUpstream(repoDir); + + await pushCurrentBranch(repoDir); + + const prRemoteMain = execFileSync("git", [ + "--git-dir", + prRemoteDir, + "rev-parse", + "refs/heads/main", + ]) + .toString() + .trim(); + const originBranch = spawnSync( + "git", + ["--git-dir", originDir, "show-ref", "--verify", "--quiet", "refs/heads/therainisme/main"], + { encoding: "utf8" }, + ); + const trackedPrRemoteHead = execFileSync( + "git", + ["rev-parse", "refs/remotes/paseo-pr-526/main"], + { + cwd: repoDir, + }, + ) + .toString() + .trim(); + const afterPushStatus = await getCheckoutStatus(repoDir); + expect(upstreamBeforePush).toBeNull(); + expect(prRemoteMain).toBe(localHead); + expect(getBranchUpstream(repoDir)).toBe("paseo-pr-526/main"); + expect(trackedPrRemoteHead).toBe(localHead); + expect(afterPushStatus).toMatchObject({ aheadOfOrigin: 0, behindOfOrigin: 0 }); + expect(originBranch.status).toBe(1); + }); + + it("refreshes the tracked ref after pushing through a configured push remote", async () => { + const originDir = join(tempDir, "origin.git"); + execFileSync("git", ["clone", "--bare", repoDir, originDir]); + execFileSync("git", ["remote", "add", "origin", originDir], { cwd: repoDir }); + execFileSync("git", ["checkout", "-b", "feature"], { cwd: repoDir }); + execFileSync("git", ["push", "-u", "origin", "feature"], { cwd: repoDir }); + execFileSync("git", ["remote", "add", "paseo-pr-1790", originDir], { cwd: repoDir }); + execFileSync("git", ["config", "branch.feature.pushRemote", "paseo-pr-1790"], { + cwd: repoDir, + }); + execFileSync("git", ["config", "remote.paseo-pr-1790.push", "HEAD:refs/heads/feature"], { + cwd: repoDir, + }); + writeFileSync(join(repoDir, "feature.txt"), "feature edit\n"); + execFileSync("git", ["add", "feature.txt"], { cwd: repoDir }); + execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "feature edit"], { + cwd: repoDir, + }); + const localHead = execFileSync("git", ["rev-parse", "HEAD"], { cwd: repoDir }) + .toString() + .trim(); + + const beforePushStatus = await getCheckoutStatus(repoDir); + await pushCurrentBranch(repoDir); + + const trackedOriginHead = execFileSync("git", ["rev-parse", "refs/remotes/origin/feature"], { + cwd: repoDir, + }) + .toString() + .trim(); + const afterPushStatus = await getCheckoutStatus(repoDir); + expect(beforePushStatus).toMatchObject({ aheadOfOrigin: 1, behindOfOrigin: 0 }); + expect(trackedOriginHead).toBe(localHead); + expect(afterPushStatus).toMatchObject({ aheadOfOrigin: 0, behindOfOrigin: 0 }); + }); + + it("pushes ordinary branches to their configured upstream", async () => { + const originDir = join(tempDir, "origin.git"); + const upstreamDir = join(tempDir, "upstream.git"); + execFileSync("git", ["clone", "--bare", repoDir, originDir]); + execFileSync("git", ["clone", "--bare", repoDir, upstreamDir]); + execFileSync("git", ["remote", "add", "origin", originDir], { cwd: repoDir }); + execFileSync("git", ["remote", "add", "upstream", upstreamDir], { cwd: repoDir }); + execFileSync("git", ["fetch", "upstream", "main"], { cwd: repoDir }); + execFileSync("git", ["checkout", "-b", "contrib"], { cwd: repoDir }); + execFileSync("git", ["config", "branch.contrib.remote", "upstream"], { cwd: repoDir }); + execFileSync("git", ["config", "branch.contrib.merge", "refs/heads/main"], { cwd: repoDir }); + writeFileSync(join(repoDir, "contrib.txt"), "contrib edit\n"); + execFileSync("git", ["add", "contrib.txt"], { cwd: repoDir }); + execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "contrib edit"], { + cwd: repoDir, + }); + const localHead = execFileSync("git", ["rev-parse", "HEAD"], { cwd: repoDir }) + .toString() + .trim(); + + await pushCurrentBranch(repoDir); + + const upstreamMainAfter = execFileSync("git", [ + "--git-dir", + upstreamDir, + "rev-parse", + "refs/heads/main", + ]) + .toString() + .trim(); + const originContrib = spawnSync( + "git", + ["--git-dir", originDir, "show-ref", "--verify", "--quiet", "refs/heads/contrib"], + { encoding: "utf8" }, + ); + expect(upstreamMainAfter).toBe(localHead); + expect(originContrib.status).toBe(1); + }); + it("lists merged local and remote branch suggestions with provenance", async () => { const remoteDir = join(tempDir, "remote.git"); execFileSync("git", ["init", "--bare", "-b", "main", remoteDir]); @@ -2110,6 +2295,36 @@ const x = 1; expect(lookupTarget).toEqual({ headRef: "main", headRepositoryOwner: "chethanuk" }); }); + it("uses the configured push remote for fork PR lookup when upstream is absent", async () => { + execFileSync("git", ["checkout", "-b", "chethanuk/main"], { cwd: repoDir }); + execFileSync("git", ["remote", "add", "origin", "https://github.com/getpaseo/paseo.git"], { + cwd: repoDir, + }); + execFileSync("git", ["remote", "add", "paseo-pr-345", "git@github.com:chethanuk/paseo.git"], { + cwd: repoDir, + }); + execFileSync("git", ["config", "branch.chethanuk/main.pushRemote", "paseo-pr-345"], { + cwd: repoDir, + }); + execFileSync("git", ["config", "remote.paseo-pr-345.push", "HEAD:refs/heads/main"], { + cwd: repoDir, + }); + const requestedTargets: RequestedPullRequestTarget[] = []; + const github = createGitHubServiceRecordingPullRequestTargets({ requestedTargets }); + + const factsTarget = await readPullRequestLookupTargetFromFacts(repoDir, paseoHome); + await getPullRequestStatus( + repoDir, + github, + { force: true, reason: "push-remote-pr-lookup" }, + { paseoHome }, + ); + + expect(getBranchUpstream(repoDir)).toBeNull(); + expect(factsTarget).toEqual({ headRef: "main", headRepositoryOwner: "chethanuk" }); + expect(requestedTargets).toEqual([{ headRef: "main", headRepositoryOwner: "chethanuk" }]); + }); + it("keeps the local branch lookup when same-repo tracking points at the base branch", async () => { execFileSync("git", ["checkout", "-b", "tender-parrot"], { cwd: repoDir }); execFileSync("git", ["remote", "add", "origin", "https://github.com/getpaseo/paseo.git"], { diff --git a/packages/server/src/utils/checkout-git.ts b/packages/server/src/utils/checkout-git.ts index 61dab9c79..208aef3a7 100644 --- a/packages/server/src/utils/checkout-git.ts +++ b/packages/server/src/utils/checkout-git.ts @@ -79,6 +79,15 @@ interface PullRequestLookupTargetBranchConfig { resolvedBaseRef: string | null; } +interface PullRequestLookupTargetPushConfig { + currentBranch: string; + pushRemoteName: string | null; + pushRefspec: string | null; + pushRemoteUrl: string | null; + originRemoteUrl: string | null; + resolvedBaseRef: string | null; +} + function getErrorStderr(error: Error): string { return "stderr" in error && typeof error.stderr === "string" ? error.stderr : ""; } @@ -1124,6 +1133,24 @@ async function getGitConfigValue( } } +async function getGitRemotePushUrl( + cwd: string, + remoteName: string, + context?: CheckoutContext, +): Promise { + try { + const { stdout } = await runGitCommand(["remote", "get-url", "--push", remoteName], { + cwd, + envOverlay: READ_ONLY_GIT_ENV, + logger: context?.logger, + }); + const value = stdout.trim(); + return value.length > 0 ? value : null; + } catch { + return null; + } +} + function parseBranchMergeHeadRef(mergeRef: string | null): string | null { const prefix = "refs/heads/"; if (!mergeRef?.startsWith(prefix)) { @@ -1156,7 +1183,14 @@ async function resolvePullRequestStatusLookupTarget( resolvedBaseRef: null, }); if (localBranchTarget.headRef === currentBranch) { - return localBranchTarget; + const pushTarget = await resolvePullRequestLookupTargetFromPushConfig( + cwd, + currentBranch, + null, + null, + context, + ); + return pushTarget ?? localBranchTarget; } const [branchRemoteUrl, originRemoteUrl, resolvedBaseRef] = await Promise.all([ @@ -1164,7 +1198,7 @@ async function resolvePullRequestStatusLookupTarget( getGitConfigValue(cwd, "remote.origin.url", context), getResolvedBaseRefForCwd(cwd, context), ]); - return buildPullRequestLookupTargetFromBranchConfig({ + const branchTarget = buildPullRequestLookupTargetFromBranchConfig({ currentBranch, branchRemoteName, branchMergeRef, @@ -1172,6 +1206,17 @@ async function resolvePullRequestStatusLookupTarget( originRemoteUrl, resolvedBaseRef, }); + if (branchTarget.headRef !== currentBranch || branchTarget.headRepositoryOwner) { + return branchTarget; + } + const pushTarget = await resolvePullRequestLookupTargetFromPushConfig( + cwd, + currentBranch, + originRemoteUrl, + resolvedBaseRef, + context, + ); + return pushTarget ?? branchTarget; } export async function resolveAbsoluteGitDir(cwd: string): Promise { @@ -1553,6 +1598,65 @@ function buildPullRequestLookupTargetFromBranchConfig( }; } +function buildPullRequestLookupTargetFromPushConfig( + input: PullRequestLookupTargetPushConfig, +): PullRequestStatusLookupTarget | null { + const pushedHeadRef = parseHeadPushRefspec(input.pushRefspec); + if (!input.pushRemoteName || !pushedHeadRef || pushedHeadRef === input.currentBranch) { + return null; + } + + const remoteRepo = input.pushRemoteUrl ? parseGitHubRepoFromRemote(input.pushRemoteUrl) : null; + const originRepo = input.originRemoteUrl + ? parseGitHubRepoFromRemote(input.originRemoteUrl) + : null; + const isSameRepo = Boolean(remoteRepo && originRepo && remoteRepo === originRepo); + const headRepositoryOwner = remoteRepo && !isSameRepo ? remoteRepo.split("/")[0] : null; + const normalizedBaseRef = input.resolvedBaseRef + ? normalizeLocalBranchRefName(input.resolvedBaseRef) + : null; + if (pushedHeadRef === normalizedBaseRef && !headRepositoryOwner) { + return null; + } + + return { + headRef: pushedHeadRef, + ...(headRepositoryOwner ? { headRepositoryOwner } : {}), + }; +} + +async function resolvePullRequestLookupTargetFromPushConfig( + cwd: string, + currentBranch: string, + knownOriginRemoteUrl: string | null, + knownResolvedBaseRef: string | null, + context?: CheckoutContext, +): Promise { + const pushRemoteName = await getGitConfigValue( + cwd, + `branch.${currentBranch}.pushRemote`, + context, + ); + if (!pushRemoteName) { + return null; + } + + const [pushRefspec, pushRemoteUrl, originRemoteUrl, resolvedBaseRef] = await Promise.all([ + getGitConfigValue(cwd, `remote.${pushRemoteName}.push`, context), + getGitConfigValue(cwd, `remote.${pushRemoteName}.url`, context), + knownOriginRemoteUrl === null ? getGitConfigValue(cwd, "remote.origin.url", context) : null, + knownResolvedBaseRef === null ? getResolvedBaseRefForCwd(cwd, context) : null, + ]); + return buildPullRequestLookupTargetFromPushConfig({ + currentBranch, + pushRemoteName, + pushRefspec, + pushRemoteUrl, + originRemoteUrl: knownOriginRemoteUrl ?? originRemoteUrl, + resolvedBaseRef: knownResolvedBaseRef ?? resolvedBaseRef, + }); +} + export async function getCheckoutSnapshotFacts( cwd: string, context?: CheckoutContext, @@ -1602,7 +1706,7 @@ export async function getCheckoutSnapshotFacts( ]); } } - const pullRequestLookupTarget = inspected.currentBranch + let pullRequestLookupTarget = inspected.currentBranch ? buildPullRequestLookupTargetFromBranchConfig({ currentBranch: inspected.currentBranch, branchRemoteName, @@ -1612,6 +1716,20 @@ export async function getCheckoutSnapshotFacts( resolvedBaseRef, }) : null; + if ( + inspected.currentBranch && + pullRequestLookupTarget?.headRef === inspected.currentBranch && + !pullRequestLookupTarget.headRepositoryOwner + ) { + pullRequestLookupTarget = + (await resolvePullRequestLookupTargetFromPushConfig( + cwd, + inspected.currentBranch, + inspected.remoteUrl, + resolvedBaseRef, + context, + )) ?? pullRequestLookupTarget; + } return { isGit: true, @@ -2689,6 +2807,27 @@ export async function pushCurrentBranch(cwd: string, github?: GitHubService): Pr if (!currentBranch || currentBranch === "HEAD") { throw new Error("Unable to determine current branch for push"); } + const configuredPushTarget = await getCurrentBranchConfiguredPushTarget(cwd, currentBranch); + if (configuredPushTarget) { + await runGitCommand( + ["push", configuredPushTarget.remoteName, `HEAD:refs/heads/${configuredPushTarget.headRef}`], + { cwd, timeout: 120_000 }, + ); + await refreshCurrentBranchTrackedRefAfterPush(cwd, currentBranch, configuredPushTarget); + github?.invalidate({ cwd }); + return; + } + + const upstreamTarget = await getCurrentBranchUpstreamPushTarget(cwd, currentBranch); + if (upstreamTarget) { + await runGitCommand( + ["push", "-u", upstreamTarget.remoteName, `HEAD:refs/heads/${upstreamTarget.headRef}`], + { cwd, timeout: 120_000 }, + ); + github?.invalidate({ cwd }); + return; + } + const hasRemote = await hasOriginRemote(cwd); if (!hasRemote) { throw new Error("Remote 'origin' is not configured."); @@ -2697,6 +2836,106 @@ export async function pushCurrentBranch(cwd: string, github?: GitHubService): Pr github?.invalidate({ cwd }); } +async function getCurrentBranchConfiguredPushTarget( + cwd: string, + currentBranch: string, +): Promise<{ remoteName: string; headRef: string } | null> { + const remoteName = await getGitConfigValue(cwd, `branch.${currentBranch}.pushRemote`); + const pushRefspec = remoteName ? await getGitConfigValue(cwd, `remote.${remoteName}.push`) : null; + const headRef = parseHeadPushRefspec(pushRefspec); + if (!remoteName || !headRef) { + return null; + } + const remoteUrl = await getGitConfigValue(cwd, `remote.${remoteName}.url`); + return remoteUrl ? { remoteName, headRef } : null; +} + +async function refreshCurrentBranchTrackedRefAfterPush( + cwd: string, + currentBranch: string, + pushedTarget: { remoteName: string; headRef: string }, +): Promise { + const trackingRemoteName = await getGitConfigValue(cwd, `branch.${currentBranch}.remote`); + const trackingMergeRef = await getGitConfigValue(cwd, `branch.${currentBranch}.merge`); + const trackingHeadRef = parseBranchMergeHeadRef(trackingMergeRef); + if (!trackingRemoteName && !trackingMergeRef) { + const updated = await updateRemoteTrackingRef( + cwd, + pushedTarget.remoteName, + pushedTarget.headRef, + ); + if (!updated) { + return; + } + await runGitCommand(["config", `branch.${currentBranch}.remote`, pushedTarget.remoteName], { + cwd, + }); + await runGitCommand( + ["config", `branch.${currentBranch}.merge`, `refs/heads/${pushedTarget.headRef}`], + { + cwd, + }, + ); + return; + } + if (!trackingRemoteName || trackingHeadRef !== pushedTarget.headRef) { + return; + } + + const [trackingRemotePushUrl, pushedRemotePushUrl] = await Promise.all([ + getGitRemotePushUrl(cwd, trackingRemoteName), + getGitRemotePushUrl(cwd, pushedTarget.remoteName), + ]); + if (!trackingRemotePushUrl || trackingRemotePushUrl !== pushedRemotePushUrl) { + return; + } + + await updateRemoteTrackingRef(cwd, trackingRemoteName, trackingHeadRef); +} + +async function updateRemoteTrackingRef( + cwd: string, + remoteName: string, + headRef: string, +): Promise { + const trackingRef = `refs/remotes/${remoteName}/${headRef}`; + const checkRef = await runGitCommand(["check-ref-format", trackingRef], { + cwd, + acceptExitCodes: [0, 1], + }); + if (checkRef.exitCode !== 0) { + return false; + } + await runGitCommand(["update-ref", trackingRef, "HEAD"], { cwd, timeout: 120_000 }); + return true; +} + +async function getCurrentBranchUpstreamPushTarget( + cwd: string, + currentBranch: string, +): Promise<{ remoteName: string; headRef: string } | null> { + const remoteName = await getGitConfigValue(cwd, `branch.${currentBranch}.remote`); + const mergeRef = remoteName + ? await getGitConfigValue(cwd, `branch.${currentBranch}.merge`) + : null; + const headRef = parseBranchMergeHeadRef(mergeRef); + if (!remoteName || !headRef) { + return null; + } + const remoteUrl = await getGitConfigValue(cwd, `remote.${remoteName}.url`); + return remoteUrl ? { remoteName, headRef } : null; +} + +function parseHeadPushRefspec(refspec: string | null): string | null { + const prefix = "HEAD:refs/heads/"; + const normalized = refspec?.trim().replace(/^\+/, ""); + if (!normalized?.startsWith(prefix)) { + return null; + } + const headRef = normalized.slice(prefix.length).trim(); + return headRef.length > 0 ? headRef : null; +} + export interface CreatePullRequestOptions { title: string; body?: string; diff --git a/packages/server/src/utils/worktree.ts b/packages/server/src/utils/worktree.ts index 8c3a62c2c..dde89cad0 100644 --- a/packages/server/src/utils/worktree.ts +++ b/packages/server/src/utils/worktree.ts @@ -166,6 +166,7 @@ export type WorktreeSource = baseRefName: string; localBranchName?: string; pushRemoteUrl?: string; + trackOriginHead?: boolean; }; export interface CreateWorktreeOptions { @@ -1201,6 +1202,13 @@ export const createWorktree = async ({ remote: sourcePlan.pushRemote, }); } + if (sourcePlan.trackingRemote) { + await configureWorktreeTrackingRemote({ + cwd, + branchName: sourcePlan.branchName, + remote: sourcePlan.trackingRemote, + }); + } writePaseoWorktreeMetadata(worktreePath, { baseRefName: sourcePlan.metadataBaseRefName }); @@ -1245,6 +1253,11 @@ interface WorktreeSourcePlan { name: string; url: string; headRef: string; + track: boolean; + }; + trackingRemote?: { + name: string; + headRef: string; }; } @@ -1309,20 +1322,42 @@ async function resolveWorktreeSourcePlan({ timeout: 120_000, }, ); + const trackingRemote = source.trackOriginHead + ? await tryFetchWorktreeTrackingRemote({ + cwd, + remoteName: "origin", + headRef: source.headRef, + }) + : undefined; + const remotePlan: Pick = {}; + if (source.pushRemoteUrl) { + const remoteName = `paseo-pr-${source.githubPrNumber}`; + remotePlan.pushRemote = { + name: remoteName, + url: source.pushRemoteUrl, + headRef: source.headRef, + track: true, + }; + } else if (source.trackOriginHead && localBranchName !== source.headRef) { + const originUrl = await getWorktreeRemotePushUrl(cwd, "origin"); + if (originUrl) { + remotePlan.pushRemote = { + name: `paseo-pr-${source.githubPrNumber}`, + url: originUrl, + headRef: source.headRef, + track: false, + }; + } + } + if (trackingRemote) { + remotePlan.trackingRemote = trackingRemote; + } return { branchName: localBranchName, metadataBaseRefName: normalizedBaseRefName, addArguments: [localBranchName], - ...(source.pushRemoteUrl - ? { - pushRemote: { - name: `paseo-pr-${source.githubPrNumber}`, - url: source.pushRemoteUrl, - headRef: source.headRef, - }, - } - : {}), + ...remotePlan, }; } } @@ -1335,6 +1370,7 @@ async function configureWorktreePushRemote(options: { name: string; url: string; headRef: string; + track: boolean; }; }): Promise { await runGitCommand(["config", `remote.${options.remote.name}.url`, options.remote.url], { @@ -1344,11 +1380,84 @@ async function configureWorktreePushRemote(options: { ["config", `remote.${options.remote.name}.push`, `HEAD:refs/heads/${options.remote.headRef}`], { cwd: options.cwd }, ); - await runGitCommand(["config", `branch.${options.branchName}.remote`, options.remote.name], { + await runGitCommand(["config", `branch.${options.branchName}.pushRemote`, options.remote.name], { cwd: options.cwd, }); + if (!options.remote.track) { + return; + } await runGitCommand( - ["config", `branch.${options.branchName}.merge`, `refs/heads/${options.remote.headRef}`], + [ + "config", + `remote.${options.remote.name}.fetch`, + `+refs/heads/${options.remote.headRef}:refs/remotes/${options.remote.name}/${options.remote.headRef}`, + ], + { cwd: options.cwd }, + ); + const trackingRemote = await tryFetchWorktreeTrackingRemote({ + cwd: options.cwd, + remoteName: options.remote.name, + headRef: options.remote.headRef, + }); + if (trackingRemote) { + await configureWorktreeTrackingRemote({ + cwd: options.cwd, + branchName: options.branchName, + remote: trackingRemote, + }); + } +} + +async function tryFetchWorktreeTrackingRemote(options: { + cwd: string; + remoteName: string; + headRef: string; +}): Promise<{ name: string; headRef: string } | undefined> { + const result = await runGitCommand( + [ + "fetch", + options.remoteName, + `+refs/heads/${options.headRef}:refs/remotes/${options.remoteName}/${options.headRef}`, + ], + { + cwd: options.cwd, + timeout: 120_000, + acceptExitCodes: [0, 1, 128], + }, + ); + return result.exitCode === 0 ? { name: options.remoteName, headRef: options.headRef } : undefined; +} + +async function getWorktreeRemotePushUrl( + cwd: string, + remoteName: string, +): Promise { + try { + const { stdout } = await runGitCommand(["remote", "get-url", "--push", remoteName], { + cwd, + }); + const url = stdout.trim(); + return url.length > 0 ? url : undefined; + } catch { + return undefined; + } +} + +async function configureWorktreeTrackingRemote(options: { + cwd: string; + branchName: string; + remote: { + name: string; + headRef: string; + }; +}): Promise { + await runGitCommand( + [ + "branch", + "--set-upstream-to", + `${options.remote.name}/${options.remote.headRef}`, + options.branchName, + ], { cwd: options.cwd }, ); }