diff --git a/apps/mobile/src/connection/environment-cache-store.ts b/apps/mobile/src/connection/environment-cache-store.ts index 6573c9e11879..ad5ef13b62d5 100644 --- a/apps/mobile/src/connection/environment-cache-store.ts +++ b/apps/mobile/src/connection/environment-cache-store.ts @@ -17,7 +17,10 @@ import * as Schema from "effect/Schema"; import * as MobileDatabase from "../persistence/mobile-database"; const SHELL_SNAPSHOT_CACHE_SCHEMA_VERSION = 1; -const THREAD_SNAPSHOT_CACHE_SCHEMA_VERSION = 2; +// v3 adds windowed (paginated) snapshots carrying `page` metadata; the bump +// makes pre-pagination clients discard the record instead of decoding a +// partial thread as complete (rollback safety). +const THREAD_SNAPSHOT_CACHE_SCHEMA_VERSION = 3; const SERVER_CONFIG_CACHE_SCHEMA_VERSION = 1; const VCS_REFS_CACHE_SCHEMA_VERSION = 1; diff --git a/apps/mobile/src/features/threads/ThreadComposer.tsx b/apps/mobile/src/features/threads/ThreadComposer.tsx index 65ec4ebba152..cbfe14f01d4c 100644 --- a/apps/mobile/src/features/threads/ThreadComposer.tsx +++ b/apps/mobile/src/features/threads/ThreadComposer.tsx @@ -103,6 +103,8 @@ export interface ThreadComposerProps { readonly threadSyncPhase?: "loading" | "syncing" | null; readonly selectedThread: OrchestrationThreadShell; readonly serverConfig: T3ServerConfig | null; + readonly queueCount: number; + readonly activeThreadBusy: boolean; readonly environmentId: EnvironmentId; readonly projectCwd: string | null; readonly editorRef?: RefObject; @@ -112,7 +114,7 @@ export interface ThreadComposerProps { readonly onRemoveDraftImage: (imageId: string) => void; readonly onStopThread: () => void; readonly onSendMessage: () => Promise; - readonly onStartNewThread: () => void; + readonly onStartNewThread?: () => void; readonly onUpdateModelSelection: (modelSelection: ModelSelection) => void; readonly onUpdateRuntimeMode: (runtimeMode: RuntimeMode) => void; readonly onUpdateInteractionMode: (interactionMode: ProviderInteractionMode) => void; @@ -269,6 +271,9 @@ const ComposerConnectionStatusPill = memo(function ComposerConnectionStatusPill( }); export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposerProps) { + // Upstream surface: queue depth + busy gate used by detail screen. + void props.queueCount; + void props.activeThreadBusy; const isDarkMode = useColorScheme() === "dark"; const foregroundColor = useThemeColor("--color-foreground"); const bodyText = useScaledTextRole("body"); @@ -530,7 +535,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer const handleSend = useCallback(async () => { if (parseStandaloneComposerSlashCommand(draftMessage) === "new") { onChangeDraftMessage(""); - onStartNewThread(); + onStartNewThread?.(); return; } const threadKey = scopedThreadKey(props.environmentId, props.selectedThread.id); @@ -588,7 +593,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer ); setComposerSelection({ start: result.cursor, end: result.cursor }); onChangeDraftMessage(result.text); - onStartNewThread(); + onStartNewThread?.(); return; } diff --git a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx index d5dc7d1988d1..929133eb03f1 100644 --- a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx @@ -16,7 +16,7 @@ import type { } from "@t3tools/contracts"; import * as Haptics from "expo-haptics"; import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; -import { Platform, StyleSheet, View, type GestureResponderEvent } from "react-native"; +import { Platform, View, type GestureResponderEvent } from "react-native"; import { KeyboardController, KeyboardStickyView } from "react-native-keyboard-controller"; import Animated, { FadeInDown, FadeOut } from "react-native-reanimated"; import { useSafeAreaInsets } from "react-native-safe-area-context"; @@ -32,7 +32,6 @@ import type { PendingUserInputDraftAnswer, ThreadFeedEntry, } from "../../lib/threadActivity"; -import { ComposerQueuedMessages } from "./ComposerQueuedMessages"; import { PendingApprovalCard } from "./PendingApprovalCard"; import { PendingUserInputCard } from "./PendingUserInputCard"; import { @@ -62,14 +61,15 @@ export interface ThreadDetailScreenProps { readonly connectionStateLabel: EnvironmentConnectionPhase; /** Message sync status for the selected thread (drives the composer status pill). */ readonly threadSyncStatus?: EnvironmentThreadStatus; - /** A send made now would be held in the steering queue, not open a turn. */ - readonly sendEntersQueue: boolean; - readonly hasMoreOlderActivities: boolean; - readonly loadingOlderActivities: boolean; - readonly onLoadOlderActivities: () => void; + /** Non-null when older turns exist beyond the loaded window. */ + readonly loadEarlier?: { readonly loading: boolean; readonly onLoadEarlier: () => void } | null; + /** True when the next send will be held in the server steering queue. */ + readonly sendEntersQueue?: boolean; + readonly activeThreadBusy: boolean; readonly environmentId: EnvironmentId; readonly projectWorkspaceRoot: string | null; readonly threadCwd: string | null; + readonly selectedThreadQueueCount: number; readonly serverConfig: T3ServerConfig | null; readonly layoutVariant?: LayoutVariant; readonly usesAutomaticContentInsets?: boolean; @@ -81,16 +81,6 @@ export interface ThreadDetailScreenProps { readonly onRemoveDraftImage: (imageId: string) => void; readonly onStopThread: () => void; readonly onSendMessage: () => Promise; - readonly composerQueueItems: ReadonlyArray<{ - readonly messageId: MessageId; - readonly text: string; - readonly attachmentCount: number; - readonly deliveryState: "waiting" | "sending" | "queued"; - readonly queueSource: "local" | "server"; - }>; - readonly onSteerQueuedMessage: (messageId: MessageId) => Promise; - readonly onEditQueuedMessage: (messageId: MessageId, source: "local" | "server") => Promise; - readonly onStartNewThread: () => void; readonly onReconnectEnvironment: () => void; readonly onUpdateThreadModelSelection: (modelSelection: ModelSelection) => void; readonly onUpdateThreadRuntimeMode: (runtimeMode: RuntimeMode) => void; @@ -257,11 +247,10 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread }, [freeze, selectedThreadKey]); useEffect(() => { - // Anchor as soon as the target row exists in the feed — including local - // outbox "Sending" bubbles painted before thread detail has finished loading. if ( anchorMessageId === null || lastScrolledAnchorMessageIdRef.current === anchorMessageId || + contentPresentationKind !== "ready" || !selectedThreadFeed.some((entry) => entry.type === "message" && entry.id === anchorMessageId) ) { return; @@ -300,9 +289,16 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread }); }); return () => cancelAnimationFrame(frame); - }, [anchorMessageId, freeze, selectedThreadFeed, scrollMessageToEnd, selectedThreadKey]); + }, [ + anchorMessageId, + freeze, + contentPresentationKind, + selectedThreadFeed, + scrollMessageToEnd, + selectedThreadKey, + ]); - const sendEntersQueue = props.sendEntersQueue; + const sendEntersQueue = props.sendEntersQueue === true; const handleSendMessage = useCallback(async () => { const targetThreadKey = selectedThreadKey; const sendWillQueue = sendEntersQueue; @@ -391,100 +387,83 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread usesAutomaticContentInsets={props.usesAutomaticContentInsets} onHeaderMaterialVisibilityChange={props.onHeaderMaterialVisibilityChange} skills={selectedProviderSkills} - hasMoreOlder={props.hasMoreOlderActivities} - loadingOlder={props.loadingOlderActivities} - onLoadOlder={props.onLoadOlderActivities} + loadEarlier={props.loadEarlier ?? null} /> ) : ( )} - {/* - Pin the composer to the bottom of a full-screen overlay host. - KeyboardStickyView only applies translateY for the IME — it must sit in a - full-height column (not `position: absolute; bottom: 0` on itself), or a - stale keyboard height leaves the input floating mid-thread with the feed - scrolling behind it. - */} + {/* Floating composer — sticks to keyboard via KeyboardStickyView */} {showContent ? ( - - - - {/* No paddingTop here: the overlay's measured height becomes the - list's bottom inset, so any padding above the pill/composer - pushes the resting content floor up by the same amount. */} - - - {props.activePendingApproval || props.activePendingUserInput ? ( - - {props.activePendingApproval ? ( - - ) : null} - {props.activePendingUserInput ? ( - - ) : null} - - ) : null} - { - void props.onSteerQueuedMessage(messageId); - }} - onEdit={(messageId, source) => { - void props.onEditQueuedMessage(messageId, source); - }} - /> - - - + + {/* No paddingTop here: the overlay's measured height becomes the + list's bottom inset, so any padding above the pill/composer + pushes the resting content floor up by the same amount. */} + + + {props.activePendingApproval || props.activePendingUserInput ? ( + + {props.activePendingApproval ? ( + + ) : null} + {props.activePendingUserInput ? ( + + ) : null} + + ) : null} - - + + + + ) : null} ); diff --git a/apps/mobile/src/features/threads/ThreadFeed.tsx b/apps/mobile/src/features/threads/ThreadFeed.tsx index 98e3fb032838..28df94b529bc 100644 --- a/apps/mobile/src/features/threads/ThreadFeed.tsx +++ b/apps/mobile/src/features/threads/ThreadFeed.tsx @@ -1,7 +1,7 @@ import * as Haptics from "expo-haptics"; import { KeyboardAwareLegendList } from "@legendapp/list/keyboard"; import { type LegendListRef } from "@legendapp/list/react-native"; -import { MessageId, type EnvironmentId, type ThreadId, type TurnId } from "@t3tools/contracts"; +import type { EnvironmentId, MessageId, ThreadId, TurnId } from "@t3tools/contracts"; import { CHAT_LIST_ANCHOR_OFFSET, resolveChatListAnchoredEndSpace } from "@t3tools/shared/chatList"; import { formatElapsed } from "@t3tools/shared/orchestrationTiming"; import { SymbolView } from "../../components/AppSymbol"; @@ -139,7 +139,6 @@ const WORKING_ROW_VERTICAL_EXTRAS = 24; // py-1 (8) + mb-4 (16) // remounts rows when they scroll back into view, and replaying an entrance for // old content would be its own kind of jank. const FRESH_ENTRY_WINDOW_MS = 3_000; -const FEED_END_THRESHOLD = 48; function isFreshTimestamp(input: string): boolean { const timestamp = Date.parse(input); return Number.isFinite(timestamp) && Date.now() - timestamp < FRESH_ENTRY_WINDOW_MS; @@ -165,10 +164,11 @@ export interface ThreadFeedProps { readonly usesAutomaticContentInsets?: boolean; readonly onHeaderMaterialVisibilityChange?: (visible: boolean) => void; readonly skills?: ReadonlyArray; - /** Older history beyond the live activity window can be lazy-loaded on scroll-up. */ - readonly hasMoreOlder?: boolean; - readonly loadingOlder?: boolean; - readonly onLoadOlder?: () => void; + /** Non-null when older turns exist beyond the loaded window. */ + readonly loadEarlier?: { + readonly loading: boolean; + readonly onLoadEarlier: () => void; + } | null; } function MessageAttachmentImage(props: { @@ -890,7 +890,6 @@ function renderFeedEntry( const styles = isUser ? markdownStyles.user : markdownStyles.assistant; const timestampLabel = formatMessageTime(isUser ? message.createdAt : message.updatedAt); const attachments = message.attachments ?? []; - const previewAttachments = entry.previewAttachments ?? []; const hasReviewCommentContext = message.text.includes(" ); })} - {previewAttachments.map((attachment) => ( - - ))} @@ -1336,8 +1327,6 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { const foldSettleSecondFrameRef = useRef(null); const disclosureAnchorKeyRef = useRef(null); const headerMaterialVisibleRef = useRef(false); - const isAtEndRef = useRef(true); - const userNavigationInProgressRef = useRef(false); const previousLatestTurnRef = useRef(props.latestTurn); const { width: windowWidth } = useWindowDimensions(); const { appearance } = useAppearancePreferences(); @@ -1346,8 +1335,24 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { ); const [viewportHeight, setViewportHeight] = useState(0); const [disclosureToggleSettling, setDisclosureToggleSettling] = useState(false); - const [isAtEnd, setIsAtEnd] = useState(true); - const [hasUnreadActivity, setHasUnreadActivity] = useState(false); + // Live-follow latch. LegendList's maintainScrollAtEnd alone re-pins the feed + // whenever the viewport drifts back inside its geometric threshold, which + // yanked users off history they were reading every time a stream chunk grew + // a row. Follow breaks when the user scrolls up and away, and re-arms only + // when the list actually returns to the end (or on send / thread switch). + const [endFollowEnabled, setEndFollowEnabled] = useState(true); + const endFollowEnabledRef = useRef(true); + // A "user scroll session" spans from drag start through the end of its + // momentum; only motion inside a session can break follow, so MVCP + // compensations and programmatic scrolls never strand a follower. + const userScrollSessionRef = useRef(false); + const setEndFollow = useCallback((enabled: boolean) => { + if (endFollowEnabledRef.current === enabled) { + return; + } + endFollowEnabledRef.current = enabled; + setEndFollowEnabled(enabled); + }, []); const [interactionState, setInteractionState] = useState<{ readonly copiedRowId: string | null; readonly expandedWorkGroups: Record; @@ -1390,10 +1395,8 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { ? navigationHeaderHeight || insets.top + 44 : topContentInset; - const isDarkMode = useColorScheme() === "dark"; const iconSubtleColor = useThemeColor("--color-icon-subtle"); const userBubbleColor = useThemeColor("--color-user-bubble"); - const scrollToLatestBackground = useThemeColor("--color-card"); const onMarkdownLinkPress = useCallback( (href: string) => { const presentation = resolveMarkdownLinkPresentation(href); @@ -1466,28 +1469,44 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { // UIKit's adjustedContentInset, so topContentInset is 0 here). Add the // header height back or the material toggles a full header too late. reportHeaderMaterialVisibility(event.nativeEvent.contentOffset.y + anchorTopInset > 6); - const { contentInset, contentOffset, contentSize, layoutMeasurement } = event.nativeEvent; + const { contentOffset, contentSize, layoutMeasurement } = event.nativeEvent; nearListEnd.value = contentSize.height - layoutMeasurement.height - contentOffset.y < layoutMeasurement.height; - const distanceFromEnd = - contentSize.height + contentInset.bottom - contentOffset.y - layoutMeasurement.height; - const nextIsAtEnd = distanceFromEnd <= FEED_END_THRESHOLD; - if (nextIsAtEnd) { - userNavigationInProgressRef.current = false; - } - if ( - isAtEndRef.current !== nextIsAtEnd && - (nextIsAtEnd || userNavigationInProgressRef.current) - ) { - isAtEndRef.current = nextIsAtEnd; - setIsAtEnd(nextIsAtEnd); - } - if (nextIsAtEnd) { - setHasUnreadActivity(false); + + // Latch bookkeeping. LegendList recomputes its inset-aware end distance + // before invoking this handler, so getState() is current. Returning to + // the end re-arms follow no matter who scrolled (the user, or our own + // scroll-to-end); moving away breaks it only during a user-initiated + // scroll session, so MVCP compensations and programmatic repositioning + // can never strand a follower. + const listState = props.listRef.current?.getState(); + if (listState) { + if (listState.isWithinMaintainScrollAtEndThreshold) { + setEndFollow(true); + } else if (userScrollSessionRef.current) { + setEndFollow(false); + } } }, - [reportHeaderMaterialVisibility, anchorTopInset, nearListEnd], + [reportHeaderMaterialVisibility, anchorTopInset, nearListEnd, props.listRef, setEndFollow], ); + const handleScrollBeginDrag = useCallback(() => { + userScrollSessionRef.current = true; + }, []); + // The session must survive past finger-lift so momentum that carries the + // user away from the end still breaks follow; a drag released with no + // momentum ends its session at the release itself, otherwise at momentum + // end. Leaving a session open would let a later animated maintain-scroll + // read as user motion and break follow spuriously. + const handleScrollEndDrag = useCallback((event: NativeSyntheticEvent) => { + const velocity = event.nativeEvent.velocity?.y ?? 0; + if (Math.abs(velocity) < 0.05) { + userScrollSessionRef.current = false; + } + }, []); + const handleMomentumScrollEnd = useCallback(() => { + userScrollSessionRef.current = false; + }, []); // Gated variant of the 180ms feed layout slide. Instant while browsing // history: maintainVisibleContentPosition compensates the scroll offset in @@ -1516,9 +1535,6 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { }; }; }, [nearListEnd]); - const handleScrollBeginDrag = useCallback(() => { - userNavigationInProgressRef.current = true; - }, []); const handleViewportLayout = useCallback((event: LayoutChangeEvent) => { const nextWidth = Math.round(event.nativeEvent.layout.width); const nextHeight = Math.round(event.nativeEvent.layout.height); @@ -1530,6 +1546,20 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { reportHeaderMaterialVisibility(false); }, [props.threadId, reportHeaderMaterialVisibility]); + // A thread switch opens pinned to the end; a send explicitly returns to the + // live edge (ThreadDetailScreen scrolls the new message into place). Both + // re-arm follow regardless of where the user had scrolled before. + useEffect(() => { + userScrollSessionRef.current = false; + setEndFollow(true); + }, [props.threadId, setEndFollow]); + useEffect(() => { + if (props.anchorMessageId !== null) { + userScrollSessionRef.current = false; + setEndFollow(true); + } + }, [props.anchorMessageId, setEndFollow]); + const expandedWorkGroupIds = useMemo(() => { const ids = new Set(); for (const [groupId, expanded] of Object.entries(expandedWorkGroups)) { @@ -1557,53 +1587,14 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { ], ); - const observedActivityRef = useRef({ - threadId: props.threadId, - feed: props.feed, - latestTurn: props.latestTurn, - }); - useEffect(() => { - const previous = observedActivityRef.current; - observedActivityRef.current = { - threadId: props.threadId, - feed: props.feed, - latestTurn: props.latestTurn, - }; - if (previous.threadId !== props.threadId) { - isAtEndRef.current = true; - setIsAtEnd(true); - setHasUnreadActivity(false); - return; - } - if ( - (previous.feed !== props.feed || previous.latestTurn !== props.latestTurn) && - !isAtEndRef.current - ) { - setHasUnreadActivity(true); - } - }, [props.feed, props.latestTurn, props.threadId]); - - const scrollToLatest = useCallback(() => { - isAtEndRef.current = true; - userNavigationInProgressRef.current = false; - setIsAtEnd(true); - setHasUnreadActivity(false); - props.listRef.current?.scrollToEnd({ animated: true }); - }, [props.listRef]); - - // Remount empty→filled once per thread open so initialScrollAtEnd lands under - // automatic insets. After the first filled mount for this threadId, keep the - // filled key even if the feed briefly empties during sync — remounting then - // feels like "conversation cleared and reloaded from scratch". - const listMountThreadIdRef = useRef(props.threadId); - const sawFilledFeedRef = useRef(props.feed.length > 0); - if (listMountThreadIdRef.current !== props.threadId) { - listMountThreadIdRef.current = props.threadId; - sawFilledFeedRef.current = props.feed.length > 0; - } else if (props.feed.length > 0) { - sawFilledFeedRef.current = true; - } - const listMountKey = `${props.threadId}:${sawFilledFeedRef.current ? "filled" : "empty"}`; + // The empty↔filled key below remounts the list, which resets its imperative + // content-inset override — and useKeyboardChatComposerInset (mounted above + // the remount boundary) deduplicates by height, so it never re-reports the + // composer inset to the fresh instance. Without this, the remounted list's + // initial scroll-to-end computes with a zero end inset and rests one + // composer-height short of the end. Layout effect: it must land before the + // list's first positioning tick or the one-shot initial scroll misses it. + const listMountKey = `${props.threadId}:${props.feed.length === 0 ? "empty" : "filled"}`; useLayoutEffect(() => { const bottom = props.contentInsetEndAdjustment.value; if (bottom > 0) { @@ -1636,15 +1627,6 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { ? props.latestTurn.turnId : null; - // Reaching the top (oldest) lazy-loads older history. The hook keys an - // in-flight guard by thread, so repeated fires during scroll coalesce. - const { hasMoreOlder, loadingOlder, onLoadOlder } = props; - const onStartReachedOlderHistory = useCallback(() => { - if (hasMoreOlder && !loadingOlder) { - onLoadOlder?.(); - } - }, [hasMoreOlder, loadingOlder, onLoadOlder]); - useEffect(() => { const previous = previousLatestTurnRef.current; previousLatestTurnRef.current = props.latestTurn; @@ -1929,7 +1911,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { // anchor scrolls also lets it correct a scroll that landed on a // stale end target once the anchor row finishes measuring. maintainScrollAtEnd={ - disclosureToggleSettling || !isAtEnd + disclosureToggleSettling || !endFollowEnabled ? false : { animated: true, @@ -1940,8 +1922,6 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { }, } } - // maintainVisibleContentPosition also keeps the viewport anchored - // when older history prepends at the top. maintainVisibleContentPosition={maintainVisibleContentPosition} data={presentedFeed} extraData={listAppearanceData} @@ -1980,86 +1960,33 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { alignItemsAtEnd initialScrollAtEnd onScroll={handleScroll} - onStartReached={onStartReachedOlderHistory} - onStartReachedThreshold={0.5} onScrollBeginDrag={handleScrollBeginDrag} + onScrollEndDrag={handleScrollEndDrag} + onMomentumScrollEnd={handleMomentumScrollEnd} scrollEventThrottle={16} - // Under automatic insets the spacer is UIKit's job, but the - // older-history spinner still belongs at the top of the content. ListHeaderComponent={ - usesNativeAutomaticInsets ? ( - loadingOlder ? ( - - ) : null - ) : ( - - {loadingOlder ? : null} - - ) + <> + {usesNativeAutomaticInsets ? null : } + {props.loadEarlier != null ? ( + + + {props.loadEarlier.loading ? "Loading earlier turns…" : "Load earlier turns"} + + + ) : null} + } contentContainerStyle={{ paddingTop: 12, paddingHorizontal: contentHorizontalPadding, }} /> - {!isAtEnd ? ( - - - - {hasUnreadActivity ? : null} - - {hasUnreadActivity ? "New activity" : "Scroll to latest"} - - - - ) : null} - {props.feed.length === 0 && hasMoreOlder ? ( - // The window can derive zero visible entries while older history - // exists — without scrollable content `onStartReached` can never - // fire, so give the user an explicit affordance instead of the - // empty-state placeholder. - - - {loadingOlder ? ( - - ) : ( - onLoadOlder?.()}> - Load older history - - )} - - - ) : null} {props.feed.length === 0 && - !hasMoreOlder && props.activeWorkStartedAt === null && props.contentPresentation.kind === "ready" ? ( diff --git a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx index d57583c368ec..4076bc2ecb2a 100644 --- a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx @@ -8,6 +8,10 @@ import { import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react"; import * as Option from "effect/Option"; import { EnvironmentId, ThreadId, type ProjectScript } from "@t3tools/contracts"; +import { + requestOlderThreadTurns, + threadHasOlderTurns, +} from "@t3tools/client-runtime/state/threads"; import { projectScriptCwd, projectScriptRuntimeEnv } from "@t3tools/shared/projectScripts"; import { Platform, ScrollView, View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; @@ -190,13 +194,25 @@ function ThreadRouteContent( useThreadSelection(); const selectedThreadDetailState = props.selectedThreadDetailState; const selectedThreadDetail = Option.getOrNull(selectedThreadDetailState.data); + // "Load earlier turns" header state for windowed (paginated) thread loads. + const loadEarlierTurns = useMemo(() => { + if (selectedThread === null || !threadHasOlderTurns(selectedThreadDetailState)) { + return null; + } + return { + loading: + selectedThreadDetailState.page._tag === "Some" && + selectedThreadDetailState.page.value.loadingOlder, + onLoadEarlier: () => { + requestOlderThreadTurns(selectedThread.environmentId, selectedThread.id); + }, + }; + }, [selectedThread, selectedThreadDetailState]); const { selectedThreadCwd } = useSelectedThreadWorktree(); const composer = useThreadComposerState(); const gitState = useSelectedThreadGitState(); const gitActions = useSelectedThreadGitActions(); - // Derive pending requests from the FULL loaded set (older pages + live - // window) so a prompt the user scrolled back to load still surfaces. - const requests = useSelectedThreadRequests(composer.mergedActivities); + const requests = useSelectedThreadRequests(); const interruptThreadTurn = useAtomCommand(threadEnvironment.interruptTurn, "thread interrupt"); const navigation = useNavigation(); const params = props.route.params; @@ -310,24 +326,6 @@ function ThreadRouteContent( [knownTerminalSessions, selectedThreadProject?.workspaceRoot], ); const selectedThreadDetailWorktreePath = selectedThreadDetail?.worktreePath ?? null; - const handleStartNewThread = useCallback(() => { - if (!selectedThread || !selectedThreadProject) return; - const worktreePath = resolvePreferredThreadWorktreePath({ - threadShellWorktreePath: selectedThread.worktreePath ?? null, - threadDetailWorktreePath: selectedThreadDetailWorktreePath, - }); - navigation.navigate("NewTaskSheet", { - screen: "NewTaskDraft", - params: { - environmentId: String(selectedThread.environmentId), - projectId: String(selectedThread.projectId), - title: selectedThreadProject.title, - workspaceMode: "local", - branch: selectedThread.branch ?? undefined, - worktreePath: worktreePath ?? undefined, - }, - }); - }, [navigation, selectedThread, selectedThreadDetailWorktreePath, selectedThreadProject]); const handleReconnectEnvironment = useCallback(() => { if (!environmentId) { return; @@ -788,14 +786,13 @@ function ThreadRouteContent( draftAttachments={composer.draftAttachments} connectionStateLabel={routeConnectionState} threadSyncStatus={selectedThreadDetailState.status} + loadEarlier={loadEarlierTurns} sendEntersQueue={composer.sendEntersQueue} - composerQueueItems={composer.composerQueueItems} - hasMoreOlderActivities={composer.hasMoreOlderActivities} - loadingOlderActivities={composer.loadingOlderActivities} - onLoadOlderActivities={composer.onLoadOlderActivities} + activeThreadBusy={composer.activeThreadBusy} environmentId={selectedThread.environmentId} projectWorkspaceRoot={selectedThreadProject?.workspaceRoot ?? null} threadCwd={selectedThreadCwd} + selectedThreadQueueCount={composer.selectedThreadQueueCount} layoutVariant={layout.variant} usesAutomaticContentInsets={usesNativeHeaderGlass} onOpenConnectionEditor={handleOpenConnectionEditor} @@ -806,9 +803,6 @@ function ThreadRouteContent( serverConfig={serverConfig} onStopThread={handleStopThread} onSendMessage={composer.onSendMessage} - onSteerQueuedMessage={composer.onSteerQueuedMessage} - onEditQueuedMessage={composer.onEditQueuedMessage} - onStartNewThread={handleStartNewThread} onReconnectEnvironment={handleReconnectEnvironment} onUpdateThreadModelSelection={composer.onUpdateModelSelection} onUpdateThreadRuntimeMode={composer.onUpdateRuntimeMode} diff --git a/apps/mobile/src/mobileSurfaceExistence.test.ts b/apps/mobile/src/mobileSurfaceExistence.test.ts index 517e4403ecea..1b2f9aa50591 100644 --- a/apps/mobile/src/mobileSurfaceExistence.test.ts +++ b/apps/mobile/src/mobileSurfaceExistence.test.ts @@ -67,7 +67,7 @@ describe("mobile surface existence (anti stack-drop)", () => { // The feed and the chip list both read the promoted detail, so one piece // of state moves the message and one revert puts it back. expect(composerState).toContain("promoteSteeredQueuedMessages(selectedThreadDetail"); - expect(composerState).toMatch(/buildThreadFeed\(\{ \.\.\.steeredDetail/); + expect(composerState).toContain("buildThreadFeed(steeredDetail)"); expect(composerState).toMatch(/timelineIds = new Set\(steeredDetail\?\.messages/); // Failure puts it back rather than leaving a bubble the agent never got. expect(composerState).toMatch( diff --git a/apps/mobile/src/state/use-thread-composer-state.ts b/apps/mobile/src/state/use-thread-composer-state.ts index 634718ae3f34..98558e166ff6 100644 --- a/apps/mobile/src/state/use-thread-composer-state.ts +++ b/apps/mobile/src/state/use-thread-composer-state.ts @@ -6,17 +6,12 @@ import { MessageId, type EnvironmentId, type ModelSelection, - type OrchestrationThreadActivity, type ProviderInteractionMode, type RuntimeMode, type ThreadId, } from "@t3tools/contracts"; import { safeErrorLogAttributes } from "@t3tools/client-runtime/errors"; import { isAtomCommandInterrupted } from "@t3tools/client-runtime/state/runtime"; -import { - useOlderThreadActivities, - type OlderActivitiesCursor, -} from "@t3tools/client-runtime/state/older-thread-activities"; import { sendEntersSteeringQueue } from "@t3tools/shared/chatList"; import { deriveActiveWorkStartedAt } from "@t3tools/shared/orchestrationTiming"; @@ -46,7 +41,6 @@ import { setPendingConnectionError, useRemoteConnectionStatus, } from "../state/use-remote-environment-registry"; -import { orchestrationEnvironment } from "../state/orchestration"; import { useSelectedThreadDetail } from "../state/use-thread-detail"; import { useThreadSelection } from "../state/use-thread-selection"; import { useAtomCommand } from "./use-atom-command"; @@ -54,7 +48,6 @@ import { threadEnvironment } from "./threads"; import { enqueueThreadOutboxMessage, removeThreadOutboxMessage } from "./thread-outbox"; import { useThreadOutboxMessages } from "./use-thread-outbox"; -const EMPTY_ACTIVITIES: ReadonlyArray = []; const EMPTY_MESSAGE_ID_SET: ReadonlySet = new Set(); /** Set-minus that keeps the current reference when nothing was removed. */ @@ -166,54 +159,12 @@ export function useThreadComposerState() { [queuedMessagesByThreadKey, selectedThreadKey], ); - // ── Older-history lazy-load (shared engine; see useOlderThreadActivities) ── - // The detail snapshot windows activities to the most recent page (the server - // sets `hasMoreActivities`); older pages are fetched on demand and prepended. - const loadThreadActivities = useAtomCommand(orchestrationEnvironment.loadThreadActivities, { - reportFailure: false, - }); const steerQueuedMessage = useAtomCommand(threadEnvironment.steerQueuedMessage, { label: "steer queued message", }); const removeServerQueuedMessage = useAtomCommand(threadEnvironment.removeQueuedMessage, { label: "remove queued message", }); - const selectedEnvironmentIdForActivities = selectedThreadShell?.environmentId ?? null; - const selectedThreadIdForActivities = selectedThreadShell?.id ?? null; - const loadOlderActivitiesPage = useCallback( - async (cursor: OlderActivitiesCursor) => { - if (selectedEnvironmentIdForActivities === null || selectedThreadIdForActivities === null) { - return null; - } - const result = await loadThreadActivities({ - environmentId: selectedEnvironmentIdForActivities, - input: { threadId: selectedThreadIdForActivities, ...cursor }, - }); - if (result._tag !== "Success") { - // Surface real failures (a spinner that quietly gives up reads as - // missing history); keep `hasMore` so scrolling back retries. - if (!isAtomCommandInterrupted(result)) { - setPendingConnectionError("Could not load older thread history."); - } - return null; - } - return result.value; - }, - [selectedEnvironmentIdForActivities, selectedThreadIdForActivities, loadThreadActivities], - ); - const { - mergedActivities, - hasMoreOlder: hasMoreOlderActivities, - loadingOlder: loadingOlderActivities, - loadOlder: onLoadOlderActivities, - } = useOlderThreadActivities({ - threadKey: selectedThreadShell - ? `${selectedThreadShell.environmentId}\u0000${selectedThreadShell.id}` - : null, - liveActivities: selectedThreadDetail?.activities ?? EMPTY_ACTIVITIES, - hasMoreLiveActivities: selectedThreadDetail?.hasMoreActivities ?? false, - loadPage: loadOlderActivitiesPage, - }); // "Send now" promotes a queued message into the conversation before the // server confirms the dispatch; the chip goes with it. See @@ -232,8 +183,8 @@ export function useThreadComposerState() { if (!steeredDetail) { return []; } - return buildThreadFeed({ ...steeredDetail, activities: mergedActivities }); - }, [steeredDetail, mergedActivities]); + return buildThreadFeed(steeredDetail); + }, [steeredDetail]); const composerQueueItems = useMemo(() => { type QueueItem = { @@ -561,8 +512,17 @@ export function useThreadComposerState() { [selectedThreadKey], ); + const selectedThreadForBusy = selectedThreadDetail ?? selectedThreadShell; + const selectedThreadQueueCount = composerQueueItems.length; + const activeThreadBusy = + !!selectedThreadForBusy && + (selectedThreadForBusy.session?.status === "running" || + selectedThreadForBusy.session?.status === "starting"); + return { selectedThreadFeed, + selectedThreadQueueCount, + activeThreadBusy, composerQueueItems, activeWorkStartedAt, draftMessage, @@ -571,13 +531,6 @@ export function useThreadComposerState() { runtimeMode, interactionMode, sendEntersQueue, - // Lazy-loaded older pages + the live window — the full loaded activity set. - // Request derivations must run over this (not the windowed live set alone) - // so prompts pulled in by scroll-up still surface, matching web. - mergedActivities, - hasMoreOlderActivities, - loadingOlderActivities, - onLoadOlderActivities, onChangeDraftMessage, onPickDraftImages, onPasteIntoDraft, diff --git a/apps/server/src/auth/RpcAuthorization.ts b/apps/server/src/auth/RpcAuthorization.ts index decb4b1154d8..f89d547d9e4d 100644 --- a/apps/server/src/auth/RpcAuthorization.ts +++ b/apps/server/src/auth/RpcAuthorization.ts @@ -24,7 +24,6 @@ export const RPC_REQUIRED_SCOPES = { [ORCHESTRATION_WS_METHODS.dispatchCommand]: AuthOrchestrationOperateScope, [ORCHESTRATION_WS_METHODS.getWorkflowScript]: AuthOrchestrationReadScope, [ORCHESTRATION_WS_METHODS.getTurnDiff]: AuthOrchestrationReadScope, - [ORCHESTRATION_WS_METHODS.getThreadActivities]: AuthOrchestrationReadScope, [ORCHESTRATION_WS_METHODS.getFullThreadDiff]: AuthOrchestrationReadScope, [ORCHESTRATION_WS_METHODS.searchThreads]: AuthOrchestrationReadScope, [ORCHESTRATION_WS_METHODS.subscribeShell]: AuthOrchestrationReadScope, diff --git a/apps/server/src/checkpointing/CheckpointDiffQuery.test.ts b/apps/server/src/checkpointing/CheckpointDiffQuery.test.ts index dab603f7f8e1..274641c4fddb 100644 --- a/apps/server/src/checkpointing/CheckpointDiffQuery.test.ts +++ b/apps/server/src/checkpointing/CheckpointDiffQuery.test.ts @@ -77,8 +77,6 @@ describe("CheckpointDiffQuery.layer", () => { Layer.succeed(ProjectionSnapshotQuery.ProjectionSnapshotQuery, { getCommandReadModel: () => Effect.die("CheckpointDiffQuery should not request the command read model"), - getThreadActivitiesPage: () => - Effect.die("CheckpointDiffQuery should not request thread activities"), getSnapshot: () => Effect.die("CheckpointDiffQuery should not request the full orchestration snapshot"), getShellSnapshot: () => @@ -190,8 +188,6 @@ describe("CheckpointDiffQuery.layer", () => { Layer.succeed(ProjectionSnapshotQuery.ProjectionSnapshotQuery, { getCommandReadModel: () => Effect.die("CheckpointDiffQuery should not request the command read model"), - getThreadActivitiesPage: () => - Effect.die("CheckpointDiffQuery should not request thread activities"), getSnapshot: () => Effect.die("CheckpointDiffQuery should not request the full orchestration snapshot"), getShellSnapshot: () => @@ -278,8 +274,6 @@ describe("CheckpointDiffQuery.layer", () => { Layer.succeed(ProjectionSnapshotQuery.ProjectionSnapshotQuery, { getCommandReadModel: () => Effect.die("CheckpointDiffQuery should not request the command read model"), - getThreadActivitiesPage: () => - Effect.die("CheckpointDiffQuery should not request thread activities"), getSnapshot: () => Effect.die("CheckpointDiffQuery should not request the full orchestration snapshot"), getShellSnapshot: () => @@ -351,8 +345,6 @@ describe("CheckpointDiffQuery.layer", () => { Layer.succeed(ProjectionSnapshotQuery.ProjectionSnapshotQuery, { getCommandReadModel: () => Effect.die("CheckpointDiffQuery should not request the command read model"), - getThreadActivitiesPage: () => - Effect.die("CheckpointDiffQuery should not request thread activities"), getSnapshot: () => Effect.die("CheckpointDiffQuery should not request the full orchestration snapshot"), getShellSnapshot: () => @@ -409,8 +401,6 @@ describe("CheckpointDiffQuery.layer", () => { Layer.succeed(ProjectionSnapshotQuery.ProjectionSnapshotQuery, { getCommandReadModel: () => Effect.die("CheckpointDiffQuery should not request the command read model"), - getThreadActivitiesPage: () => - Effect.die("CheckpointDiffQuery should not request thread activities"), getSnapshot: () => Effect.die("CheckpointDiffQuery should not request the full orchestration snapshot"), getShellSnapshot: () => diff --git a/apps/server/src/git/GitWorkflowService.ts b/apps/server/src/git/GitWorkflowService.ts index 7e0ca2fb92b8..5327b3a5cf14 100644 --- a/apps/server/src/git/GitWorkflowService.ts +++ b/apps/server/src/git/GitWorkflowService.ts @@ -75,6 +75,10 @@ export class GitWorkflowService extends Context.Service< readonly cwd: string; readonly remoteName: string; }) => Effect.Effect; + readonly remoteExists: (input: { + readonly cwd: string; + readonly remoteName: string; + }) => Effect.Effect; readonly resolveRemoteTrackingCommit: (input: { readonly cwd: string; readonly refName: string; @@ -385,6 +389,10 @@ export const make = Effect.gen(function* () { ensureGitCommand("GitWorkflowService.fetchRemote", input.cwd, { allowBare: true }).pipe( Effect.andThen(git.fetchRemote(input)), ), + remoteExists: (input) => + ensureGitCommand("GitWorkflowService.remoteExists", input.cwd).pipe( + Effect.andThen(git.remoteExists(input)), + ), resolveRemoteTrackingCommit: (input) => ensureGitCommand("GitWorkflowService.resolveRemoteTrackingCommit", input.cwd, { allowBare: true, diff --git a/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts b/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts index dcb66555caca..e184e3a072b5 100644 --- a/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts +++ b/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts @@ -281,7 +281,6 @@ describe("OrchestrationEngine", () => { getThreadDetailById: () => Effect.succeed(Option.none()), getThreadDetailSnapshot: () => Effect.succeed(Option.none()), searchThreads: () => Effect.succeed({ matches: [] }), - getThreadActivitiesPage: () => Effect.die("unused"), getThreadLifecycleById: () => Effect.succeed(Option.none()), }), ), diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts index f0aa2872951f..fabdf91d3a5d 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts @@ -1562,6 +1562,13 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { assert.deepEqual(settledRows, [ { state: "completed", completedAt: "2026-01-01T00:01:00.000Z" }, ]); + + const threadRows = yield* sql<{ readonly latestTurnId: string | null }>` + SELECT latest_turn_id AS "latestTurnId" + FROM projection_threads + WHERE thread_id = ${threadId} + `; + assert.deepEqual(threadRows, [{ latestTurnId: turnId }]); }), ); diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts index 38776a9ebda3..2fa916a9bde5 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts @@ -28,7 +28,6 @@ import { type ProjectionThreadProposedPlan, ProjectionThreadProposedPlanRepository, } from "../../persistence/Services/ProjectionThreadProposedPlans.ts"; -import { ProjectionQueuedMessageRepository } from "../../persistence/Services/ProjectionQueuedMessages.ts"; import { ProjectionThreadSessionRepository } from "../../persistence/Services/ProjectionThreadSessions.ts"; import { type ProjectionTurn, @@ -41,12 +40,10 @@ import { ProjectionStateRepositoryLive } from "../../persistence/Layers/Projecti import { ProjectionThreadActivityRepositoryLive } from "../../persistence/Layers/ProjectionThreadActivities.ts"; import { ProjectionThreadMessageRepositoryLive } from "../../persistence/Layers/ProjectionThreadMessages.ts"; import { ProjectionThreadProposedPlanRepositoryLive } from "../../persistence/Layers/ProjectionThreadProposedPlans.ts"; -import { ProjectionQueuedMessageRepositoryLive } from "../../persistence/Layers/ProjectionQueuedMessages.ts"; import { ProjectionThreadSessionRepositoryLive } from "../../persistence/Layers/ProjectionThreadSessions.ts"; import { ProjectionTurnRepositoryLive } from "../../persistence/Layers/ProjectionTurns.ts"; import { ProjectionThreadRepositoryLive } from "../../persistence/Layers/ProjectionThreads.ts"; import { ServerConfig } from "../../config.ts"; -import * as PrLookupFreeze from "../../git/PrLookupFreeze.ts"; import { OrchestrationProjectionPipeline, type OrchestrationProjectionPipelineShape, @@ -62,7 +59,6 @@ export const ORCHESTRATION_PROJECTOR_NAMES = { projects: "projection.projects", threads: "projection.threads", threadMessages: "projection.thread-messages", - queuedMessages: "projection.queued-messages", threadProposedPlans: "projection.thread-proposed-plans", threadActivities: "projection.thread-activities", threadSessions: "projection.thread-sessions", @@ -333,7 +329,7 @@ function retainProjectionProposedPlansAfterRevert( function collectThreadAttachmentRelativePaths( threadId: string, - messages: ReadonlyArray>, + messages: ReadonlyArray, ): Set { const threadSegment = toSafeThreadAttachmentSegment(threadId); if (!threadSegment) { @@ -480,7 +476,6 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti const projectionThreadRepository = yield* ProjectionThreadRepository; const projectionThreadMessageRepository = yield* ProjectionThreadMessageRepository; const projectionThreadProposedPlanRepository = yield* ProjectionThreadProposedPlanRepository; - const projectionQueuedMessageRepository = yield* ProjectionQueuedMessageRepository; const projectionThreadActivityRepository = yield* ProjectionThreadActivityRepository; const projectionThreadSessionRepository = yield* ProjectionThreadSessionRepository; const projectionTurnRepository = yield* ProjectionTurnRepository; @@ -489,7 +484,6 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; const serverConfig = yield* ServerConfig; - const prLookupFreeze = yield* PrLookupFreeze.PrLookupFreeze; const applyProjectsProjection: ProjectorDefinition["apply"] = Effect.fn( "applyProjectsProjection", @@ -568,11 +562,6 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti ]); let latestUserMessageAt: string | null = null; - // Rebuild origin + participants from projected user messages so shell stays - // consistent after resync/replay (not only first-write stamps). - type ParticipantRow = NonNullable<(typeof existingRow.value)["participantSummaries"]>[number]; - const rebuiltParticipants: Array = []; - let rebuiltOrigin = existingRow.value.originSource ?? null; for (const message of messages) { if ( message.role === "user" && @@ -580,53 +569,7 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti ) { latestUserMessageAt = message.createdAt; } - if (message.role !== "user" || message.source === undefined) { - continue; - } - if (rebuiltOrigin === null || rebuiltOrigin === undefined) { - rebuiltOrigin = message.source; - } - if (message.source.personId !== undefined && message.source.username !== undefined) { - const personId = message.source.personId; - const existingParticipantIndex = rebuiltParticipants.findIndex( - (entry) => entry.personId === personId, - ); - if (existingParticipantIndex === -1) { - rebuiltParticipants.push({ - personId, - username: message.source.username, - firstChannel: message.source.channel, - channels: [message.source.channel], - firstParticipatedAt: message.createdAt, - }); - } else { - const existingParticipant = rebuiltParticipants[existingParticipantIndex]!; - if (!existingParticipant.channels?.includes(message.source.channel)) { - rebuiltParticipants[existingParticipantIndex] = { - ...existingParticipant, - channels: [ - ...(existingParticipant.channels ?? - (existingParticipant.firstChannel === undefined - ? [] - : [existingParticipant.firstChannel])), - message.source.channel, - ], - }; - } - } - } - } - // Origin person first when present. - if (rebuiltOrigin?.personId !== undefined) { - const originId = rebuiltOrigin.personId; - rebuiltParticipants.sort((left, right) => { - if (left.personId === originId) return -1; - if (right.personId === originId) return 1; - return left.firstParticipatedAt.localeCompare(right.firstParticipatedAt); - }); } - const originSource = rebuiltOrigin; - const participantSummaries = rebuiltParticipants; const pendingApprovalCount = pendingApprovals.filter( (approval) => approval.status === "pending", @@ -643,8 +586,6 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti pendingApprovalCount, pendingUserInputCount, hasActionableProposedPlan: hasActionableProposedPlan ? 1 : 0, - originSource: originSource ?? null, - participantSummaries, }); }); @@ -720,17 +661,12 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti if (Option.isNone(existingRow)) { return; } - const wasSettled = existingRow.value.settledOverride === "settled"; yield* projectionThreadRepository.upsert({ ...existingRow.value, settledOverride: "settled", settledAt: event.payload.settledAt, updatedAt: event.payload.updatedAt, }); - // Idempotent re-settles must not double-count freeze interest. - if (!wasSettled) { - yield* prLookupFreeze.noteWorktreeSettled(existingRow.value.worktreePath); - } return; } @@ -741,16 +677,12 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti if (Option.isNone(existingRow)) { return; } - const wasSettled = existingRow.value.settledOverride === "settled"; yield* projectionThreadRepository.upsert({ ...existingRow.value, settledOverride: event.payload.reason === "user" ? "active" : null, settledAt: null, updatedAt: event.payload.updatedAt, }); - if (wasSettled) { - yield* prLookupFreeze.noteWorktreeUnsettled(existingRow.value.worktreePath); - } return; } @@ -891,8 +823,6 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti } case "thread.message-sent": - case "thread.message-queued": - case "thread.queued-message-removed": case "thread.proposed-plan-upserted": case "thread.activity-appended": case "thread.approval-response-requested": @@ -907,9 +837,11 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti ...existingRow.value, updatedAt: event.occurredAt, }); + // Streaming assistant deltas can arrive many times per second; skip the + // full shell history rescan (messages + activities + plans) for them. if ( event.type !== "thread.message-sent" || - event.payload.role !== "assistant" || + !("streaming" in event.payload) || !event.payload.streaming ) { yield* refreshThreadShellSummary(event.payload.threadId); @@ -924,14 +856,10 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti if (Option.isNone(existingRow)) { return; } - // Keep the completed turn pointer when the session clears activeTurnId - // (ready/idle/interrupted). Wiping latest_turn_id made response bridges - // that key off latestTurn miss already-finished turns after restart. - const nextLatestTurnId = - event.payload.session.activeTurnId ?? existingRow.value.latestTurnId; yield* projectionThreadRepository.upsert({ ...existingRow.value, - latestTurnId: nextLatestTurnId, + // activeTurnId describes current work; a terminal session must not erase history. + latestTurnId: event.payload.session.activeTurnId ?? existingRow.value.latestTurnId, updatedAt: event.occurredAt, }); yield* refreshThreadShellSummary(event.payload.threadId); @@ -1032,6 +960,8 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti text: nextText, ...(nextAttachments !== undefined ? { attachments: [...nextAttachments] } : {}), isStreaming: event.payload.streaming, + // Preserve identity provenance on first write; keep prior source when + // a streaming delta omits it so commit attribution still works. ...(event.payload.source !== undefined ? { source: event.payload.source } : previousMessage?.source !== undefined @@ -1043,58 +973,6 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti return; } - case "thread.messages-resynced": { - // Rebuild the transcript tail from an authoritative external source. - // Everything up to and including `afterMessageId` is known-good and is - // kept as-is; only what follows is replaced. - const existingRows = yield* projectionThreadMessageRepository.listByThreadId({ - threadId: event.payload.threadId, - }); - const anchorIndex = - event.payload.afterMessageId === null - ? -1 - : existingRows.findIndex((row) => row.messageId === event.payload.afterMessageId); - if (event.payload.afterMessageId !== null && anchorIndex === -1) { - // Anchor is gone (already reverted/pruned): applying the tail would - // graft it onto an unknown prefix, so leave the projection alone. - yield* Effect.logWarning( - "Skipping thread.messages-resynced: anchor message is not in the projection.", - { - threadId: event.payload.threadId, - afterMessageId: event.payload.afterMessageId, - reason: event.payload.reason, - }, - ); - return; - } - const keptRows = existingRows.slice(0, anchorIndex + 1); - const tailRows = event.payload.messages.map( - (message) => - ({ - messageId: message.id, - threadId: event.payload.threadId, - turnId: message.turnId, - role: message.role, - text: message.text, - ...(message.attachments !== undefined ? { attachments: message.attachments } : {}), - isStreaming: message.streaming, - createdAt: message.createdAt, - updatedAt: message.updatedAt, - }) satisfies ProjectionThreadMessage, - ); - yield* projectionThreadMessageRepository.deleteByThreadId({ - threadId: event.payload.threadId, - }); - yield* Effect.forEach( - [...keptRows, ...tailRows], - projectionThreadMessageRepository.upsert, - { - concurrency: 1, - }, - ).pipe(Effect.asVoid); - return; - } - case "thread.reverted": { const existingRows = yield* projectionThreadMessageRepository.listByThreadId({ threadId: event.payload.threadId, @@ -1121,17 +999,9 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti yield* Effect.forEach(keptRows, projectionThreadMessageRepository.upsert, { concurrency: 1, }).pipe(Effect.asVoid); - // Queued messages survive a revert (they are not part of the - // timeline), so their attachment files must survive pruning too. - const queuedRows = yield* projectionQueuedMessageRepository.listByThreadId({ - threadId: event.payload.threadId, - }); attachmentSideEffects.prunedThreadRelativePaths.set( event.payload.threadId, - collectThreadAttachmentRelativePaths(event.payload.threadId, [ - ...keptRows, - ...queuedRows, - ]), + collectThreadAttachmentRelativePaths(event.payload.threadId, keptRows), ); return; } @@ -1141,67 +1011,6 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti } }); - const applyQueuedMessagesProjection: ProjectorDefinition["apply"] = Effect.fn( - "applyQueuedMessagesProjection", - )(function* (event, attachmentSideEffects) { - switch (event.type) { - case "thread.message-queued": - yield* projectionQueuedMessageRepository.upsert({ - messageId: event.payload.messageId, - threadId: event.payload.threadId, - text: event.payload.text, - attachments: event.payload.attachments, - modelSelection: event.payload.modelSelection ?? null, - sourceProposedPlanThreadId: event.payload.sourceProposedPlan?.threadId ?? null, - sourceProposedPlanId: event.payload.sourceProposedPlan?.planId ?? null, - queuedAt: event.payload.queuedAt, - }); - return; - - case "thread.queued-message-removed": { - // A user removal orphans the removed message's attachment files — - // prune to what the timeline and remaining queue still reference. - // Dispatch removals keep everything: the same attachments re-enter - // the timeline via the paired thread.message-sent. - const removedQueuedMessage = - event.payload.reason === "user" - ? (yield* projectionQueuedMessageRepository.listByThreadId({ - threadId: event.payload.threadId, - })).find((entry) => entry.messageId === event.payload.messageId) - : undefined; - yield* projectionQueuedMessageRepository.deleteByMessageId({ - threadId: event.payload.threadId, - messageId: event.payload.messageId, - }); - if (removedQueuedMessage && (removedQueuedMessage.attachments?.length ?? 0) > 0) { - const retainedMessageRows = yield* projectionThreadMessageRepository.listByThreadId({ - threadId: event.payload.threadId, - }); - const retainedQueuedRows = yield* projectionQueuedMessageRepository.listByThreadId({ - threadId: event.payload.threadId, - }); - attachmentSideEffects.prunedThreadRelativePaths.set( - event.payload.threadId, - collectThreadAttachmentRelativePaths(event.payload.threadId, [ - ...retainedMessageRows, - ...retainedQueuedRows, - ]), - ); - } - return; - } - - case "thread.deleted": - yield* projectionQueuedMessageRepository.deleteByThreadId({ - threadId: event.payload.threadId, - }); - return; - - default: - return; - } - }); - const applyThreadProposedPlansProjection: ProjectorDefinition["apply"] = Effect.fn( "applyThreadProposedPlansProjection", )(function* (event, _attachmentSideEffects) { @@ -1341,22 +1150,19 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti case "thread.session-set": { const turnId = event.payload.session.activeTurnId; if (turnId === null || event.payload.session.status !== "running") { - // Leaving the "running" session status is the turn-end signal: - // settle still-running turns so their duration reflects the whole - // turn rather than the last assistant message. - const settledTurnState = settledTurnStateForSessionStatus(event.payload.session.status); - // Any settled status abandons an unadopted pending turn start — - // including "ready": a mid-turn steer re-arms the pending row - // without a fresh adoption, and a stale row would block queue - // drains (and re-arm the read model's pendingTurnStart flag on - // restart hydration). Ready-with-genuinely-pending starts never - // reach this projection: ingestion maps that shape to - // "starting" before dispatching the session set. - if (settledTurnState !== null) { + if ( + event.payload.session.status === "error" || + event.payload.session.status === "stopped" || + event.payload.session.status === "interrupted" + ) { yield* projectionTurnRepository.deletePendingTurnStartByThreadId({ threadId: event.payload.threadId, }); } + // Leaving the "running" session status is the turn-end signal: + // settle still-running turns so their duration reflects the whole + // turn rather than the last assistant message. + const settledTurnState = settledTurnStateForSessionStatus(event.payload.session.status); if (settledTurnState === null) { return; } @@ -1792,14 +1598,6 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti name: ORCHESTRATION_PROJECTOR_NAMES.projects, apply: applyProjectsProjection, }, - // queuedMessages must bootstrap before threadMessages: the revert - // handler in threadMessages reads the queued-message projection to - // retain queued attachments, so on replay that table has to be - // populated first or the prune deletes files still referenced. - { - name: ORCHESTRATION_PROJECTOR_NAMES.queuedMessages, - apply: applyQueuedMessagesProjection, - }, { name: ORCHESTRATION_PROJECTOR_NAMES.threadMessages, apply: applyThreadMessagesProjection, @@ -1930,13 +1728,9 @@ export const OrchestrationProjectionPipelineLive = Layer.effect( Layer.provideMerge(ProjectionThreadRepositoryLive), Layer.provideMerge(ProjectionThreadMessageRepositoryLive), Layer.provideMerge(ProjectionThreadProposedPlanRepositoryLive), - Layer.provideMerge(ProjectionQueuedMessageRepositoryLive), Layer.provideMerge(ProjectionThreadActivityRepositoryLive), Layer.provideMerge(ProjectionThreadSessionRepositoryLive), Layer.provideMerge(ProjectionTurnRepositoryLive), Layer.provideMerge(ProjectionPendingApprovalRepositoryLive), Layer.provideMerge(ProjectionStateRepositoryLive), - // Shared with GitManager via the same layer value at the server root - // (PrLookupFreezeLive). Tests that mount this layer alone provide their own. - Layer.provide(PrLookupFreeze.layer), ); diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts index 03edc5c63304..e935270c2fbf 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts @@ -20,6 +20,7 @@ import { ORCHESTRATION_PROJECTOR_NAMES } from "./ProjectionPipeline.ts"; import { OrchestrationProjectionSnapshotQueryLive } from "./ProjectionSnapshotQuery.ts"; import * as ThreadBackgroundLiveness from "../ThreadBackgroundLiveness.ts"; import { ProjectionSnapshotQuery } from "../Services/ProjectionSnapshotQuery.ts"; +import { encodeThreadDetailPageCursor } from "../threadDetailCursor.ts"; const asProjectId = (value: string): ProjectId => ProjectId.make(value); const asTurnId = (value: string): TurnId => TurnId.make(value); @@ -353,7 +354,6 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { createdAt: "2026-02-24T00:00:06.000Z", }, ], - hasMoreActivities: false, checkpoints: [ { turnId: asTurnId("turn-1"), @@ -1342,7 +1342,6 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { if (threadDetail._tag === "Some") { assert.deepEqual(threadDetail.value.activities, snapshot.threads[0]?.activities ?? []); // Well under the window — nothing older to lazy-load. - assert.equal(threadDetail.value.hasMoreActivities, false); } assert.deepEqual(snapshot.threads[0]?.activities ?? [], [ @@ -1522,254 +1521,6 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { }), ); - it.effect( - "windows thread-detail activities to the most recent 500 and pages older on demand", - () => - Effect.gen(function* () { - const snapshotQuery = yield* ProjectionSnapshotQuery; - const sql = yield* SqlClient.SqlClient; - - yield* sql`DELETE FROM projection_projects`; - yield* sql`DELETE FROM projection_threads`; - yield* sql`DELETE FROM projection_thread_activities`; - yield* sql`DELETE FROM projection_state`; - - yield* sql` - INSERT INTO projection_projects ( - project_id, title, workspace_root, default_model_selection_json, - scripts_json, created_at, updated_at, deleted_at - ) - VALUES ( - 'project-1', 'Project 1', '/tmp/project-1', - '{"provider":"codex","model":"gpt-5-codex"}', '[]', - '2026-04-01T00:00:00.000Z', '2026-04-01T00:00:01.000Z', NULL - ) - `; - - yield* sql` - INSERT INTO projection_threads ( - thread_id, project_id, title, model_selection_json, runtime_mode, - interaction_mode, branch, worktree_path, latest_turn_id, - latest_user_message_at, pending_approval_count, pending_user_input_count, - has_actionable_proposed_plan, created_at, updated_at, archived_at, deleted_at - ) - VALUES ( - 'thread-1', 'project-1', 'Thread 1', - '{"provider":"codex","model":"gpt-5-codex"}', 'full-access', 'default', - NULL, NULL, NULL, NULL, 0, 0, 0, - '2026-04-01T00:00:02.000Z', '2026-04-01T00:00:03.000Z', NULL, NULL - ) - `; - - // 600 activities (sequence 1..600); the detail load must return only the - // most recent 500 (sequence 101..600), re-sorted ascending for display. - const total = 600; - yield* Effect.forEach( - Array.from({ length: total }, (_unused, index) => index + 1), - (seq) => - sql` - INSERT INTO projection_thread_activities ( - activity_id, thread_id, turn_id, tone, kind, summary, payload_json, - sequence, created_at - ) - VALUES ( - ${`activity-${String(seq).padStart(4, "0")}`}, 'thread-1', NULL, - 'info', 'runtime.note', ${`act-${seq}`}, '{}', ${seq}, - '2026-04-01T00:01:00.000Z' - ) - `, - { discard: true }, - ); - - const threadDetail = yield* snapshotQuery.getThreadDetailById(ThreadId.make("thread-1")); - assert.equal(threadDetail._tag, "Some"); - if (threadDetail._tag === "Some") { - const activities = threadDetail.value.activities; - assert.equal(activities.length, 500); - assert.equal(activities[0]?.summary, "act-101"); - assert.equal(activities[0]?.sequence, 101); - assert.equal(activities.at(-1)?.summary, "act-600"); - // 600 > window, so the client is told older history can be lazy-loaded. - assert.equal(threadDetail.value.hasMoreActivities, true); - } - - // Lazy-load the page immediately older than the windowed view (cursor = - // oldest loaded sequence, 101): sequences 1..100, ascending, no more left. - const olderPage = yield* snapshotQuery.getThreadActivitiesPage({ - threadId: ThreadId.make("thread-1"), - beforeSequence: 101, - limit: 500, - }); - assert.equal(olderPage.activities.length, 100); - assert.equal(olderPage.activities[0]?.summary, "act-1"); - assert.equal(olderPage.activities.at(-1)?.summary, "act-100"); - assert.equal(olderPage.hasMore, false); - - // A bounded page returns the newest `limit` of the older set and reports - // that more remain (sequences 401..600, with 1..400 still older). - const boundedPage = yield* snapshotQuery.getThreadActivitiesPage({ - threadId: ThreadId.make("thread-1"), - beforeSequence: 601, - limit: 200, - }); - assert.equal(boundedPage.activities.length, 200); - assert.equal(boundedPage.activities[0]?.summary, "act-401"); - assert.equal(boundedPage.activities.at(-1)?.summary, "act-600"); - assert.equal(boundedPage.hasMore, true); - - yield* sql`DELETE FROM projection_thread_activities`; - - // Legacy rows may not have a sequence. They are still windowed in the - // detail load and must remain pageable by the deterministic created/id - // ordering used by the snapshot query. - yield* Effect.forEach( - Array.from({ length: total }, (_unused, index) => index + 1), - (seq) => - sql` - INSERT INTO projection_thread_activities ( - activity_id, thread_id, turn_id, tone, kind, summary, payload_json, - sequence, created_at - ) - VALUES ( - ${`unsequenced-${String(seq).padStart(4, "0")}`}, 'thread-1', NULL, - 'info', 'runtime.note', ${`legacy-act-${seq}`}, '{}', NULL, - '2026-04-01T00:01:00.000Z' - ) - `, - { discard: true }, - ); - - const legacyThreadDetail = yield* snapshotQuery.getThreadDetailById( - ThreadId.make("thread-1"), - ); - assert.equal(legacyThreadDetail._tag, "Some"); - if (legacyThreadDetail._tag === "Some") { - const activities = legacyThreadDetail.value.activities; - assert.equal(activities.length, 500); - assert.equal(activities[0]?.summary, "legacy-act-101"); - assert.equal(activities[0]?.sequence, undefined); - assert.equal(activities.at(-1)?.summary, "legacy-act-600"); - - const legacyOlderPage = yield* snapshotQuery.getThreadActivitiesPage({ - threadId: ThreadId.make("thread-1"), - beforeCreatedAt: activities[0]?.createdAt ?? "2026-04-01T00:01:00.000Z", - beforeActivityId: activities[0]?.id ?? asEventId("unsequenced-0101"), - limit: 500, - }); - assert.equal(legacyOlderPage.activities.length, 100); - assert.equal(legacyOlderPage.activities[0]?.summary, "legacy-act-1"); - assert.equal(legacyOlderPage.activities.at(-1)?.summary, "legacy-act-100"); - assert.equal(legacyOlderPage.hasMore, false); - } - - const legacyBoundedPage = yield* snapshotQuery.getThreadActivitiesPage({ - threadId: ThreadId.make("thread-1"), - beforeCreatedAt: "2026-04-01T00:01:00.000Z", - beforeActivityId: asEventId("unsequenced-0601"), - limit: 200, - }); - assert.equal(legacyBoundedPage.activities.length, 200); - assert.equal(legacyBoundedPage.activities[0]?.summary, "legacy-act-401"); - assert.equal(legacyBoundedPage.activities.at(-1)?.summary, "legacy-act-600"); - assert.equal(legacyBoundedPage.hasMore, true); - }), - ); - - it.effect("unsequenced cursor reaches all older rows without stranding sequenced ones", () => - // Regression for the "unsequenced cursor hides sequenced history" concern: - // sequenced rows always sort newer than NULL-sequence (legacy) rows, so when - // the oldest loaded row is unsequenced every sequenced row is already in the - // window — the `sequence IS NULL` cursor can't strand sequenced rows. - Effect.gen(function* () { - const snapshotQuery = yield* ProjectionSnapshotQuery; - const sql = yield* SqlClient.SqlClient; - yield* sql`DELETE FROM projection_projects`; - yield* sql`DELETE FROM projection_threads`; - yield* sql`DELETE FROM projection_thread_activities`; - yield* sql`DELETE FROM projection_state`; - yield* sql` - INSERT INTO projection_projects ( - project_id, title, workspace_root, default_model_selection_json, - scripts_json, created_at, updated_at, deleted_at - ) VALUES ( - 'project-1', 'Project 1', '/tmp/project-1', - '{"provider":"codex","model":"gpt-5-codex"}', '[]', - '2026-04-01T00:00:00.000Z', '2026-04-01T00:00:01.000Z', NULL - ) - `; - yield* sql` - INSERT INTO projection_threads ( - thread_id, project_id, title, model_selection_json, runtime_mode, - interaction_mode, branch, worktree_path, latest_turn_id, - latest_user_message_at, pending_approval_count, pending_user_input_count, - has_actionable_proposed_plan, created_at, updated_at, archived_at, deleted_at - ) VALUES ( - 'thread-1', 'project-1', 'Thread 1', - '{"provider":"codex","model":"gpt-5-codex"}', 'full-access', 'default', - NULL, NULL, NULL, NULL, 0, 0, 0, - '2026-04-01T00:00:02.000Z', '2026-04-01T00:00:03.000Z', NULL, NULL - ) - `; - // 600 legacy unsequenced rows (older) + 3 sequenced rows (newer). The - // window keeps the 3 sequenced + the most-recent 497 unsequenced, so the - // oldest loaded row is unsequenced and 103 older unsequenced remain. - yield* Effect.forEach( - Array.from({ length: 600 }, (_u, index) => index + 1), - (n) => - sql` - INSERT INTO projection_thread_activities ( - activity_id, thread_id, turn_id, tone, kind, summary, payload_json, - sequence, created_at - ) VALUES ( - ${`unseq-${String(n).padStart(4, "0")}`}, 'thread-1', NULL, - 'info', 'runtime.note', ${`unseq-${n}`}, '{}', NULL, - ${`2026-04-01T00:00:01.${String(n).padStart(3, "0")}Z`} - ) - `, - { discard: true }, - ); - yield* Effect.forEach( - [1, 2, 3], - (seq) => - sql` - INSERT INTO projection_thread_activities ( - activity_id, thread_id, turn_id, tone, kind, summary, payload_json, - sequence, created_at - ) VALUES ( - ${`seq-${seq}`}, 'thread-1', NULL, 'info', 'runtime.note', - ${`seq-${seq}`}, '{}', ${seq}, ${`2026-04-01T09:00:0${seq}.000Z`} - ) - `, - { discard: true }, - ); - - const detail = yield* snapshotQuery.getThreadDetailById(ThreadId.make("thread-1")); - assert.equal(detail._tag, "Some"); - if (detail._tag !== "Some") return; - const windowed = detail.value.activities; - assert.equal(windowed.length, 500); - // Sequenced rows are the newest (end of the ascending window); the oldest - // loaded row is unsequenced — exactly the case the concern is about. - assert.equal(windowed.at(-1)?.summary, "seq-3"); - assert.equal(windowed[0]?.sequence, undefined); - - // The client pages with the unsequenced cursor of the oldest loaded row. - const oldest = windowed[0]; - assert.ok(oldest); - const olderPage = yield* snapshotQuery.getThreadActivitiesPage({ - threadId: ThreadId.make("thread-1"), - beforeCreatedAt: oldest.createdAt, - beforeActivityId: oldest.id, - limit: 500, - }); - // The 103 older unsequenced rows come back, none are sequenced, and no - // sequenced row was stranded (all 3 are already in the window). - assert.equal(olderPage.activities.length, 103); - assert.equal(olderPage.hasMore, false); - assert.ok(olderPage.activities.every((a) => a.sequence === undefined)); - }), - ); - it.effect("uses projection_threads.latest_turn_id for bulk command and shell snapshots", () => Effect.gen(function* () { const snapshotQuery = yield* ProjectionSnapshotQuery; @@ -2410,3 +2161,407 @@ it.effect( }).pipe(Effect.provide(layer)); }, ); + +projectionSnapshotLayer("ProjectionSnapshotQuery windowed thread detail", (it) => { + // A thread shaped like real fan-out usage: user turns interleaved with + // subagent turns (no user pending message), plus a turnless straggler user + // message and a turnless activity anchored between turns. + // + // row turn pending msg anchor (requested_at) + // 1 turn-1 user-msg-1 T00 + // 2 turn-2 (subagent) T01 + // 3 turn-3 (subagent) T02 + // 4 turn-4 user-msg-4 T03 + // 5 turn-5 user-msg-5 T04 + // + // Straggler user message at T03.5 (turn_id NULL, not any pending_message_id) + // and a turnless activity at T03.6 — both belong to the page containing T03+. + const seedFanOutThread = Effect.fnUntraced(function* () { + const sql = yield* SqlClient.SqlClient; + + // Tests in this block share one in-memory database; reset before seeding. + yield* sql`DELETE FROM projection_projects`; + yield* sql`DELETE FROM projection_threads`; + yield* sql`DELETE FROM projection_turns`; + yield* sql`DELETE FROM projection_thread_messages`; + yield* sql`DELETE FROM projection_thread_activities`; + yield* sql`DELETE FROM projection_state`; + + yield* sql` + INSERT INTO projection_projects ( + project_id, title, workspace_root, scripts_json, created_at, updated_at, deleted_at + ) + VALUES ('project-w', 'Windowed', '/tmp/project-w', '[]', + '2026-03-01T00:00:00.000Z', '2026-03-01T00:00:00.000Z', NULL) + `; + yield* sql` + INSERT INTO projection_threads ( + thread_id, project_id, title, model_selection_json, runtime_mode, interaction_mode, + latest_turn_id, pending_approval_count, pending_user_input_count, + has_actionable_proposed_plan, created_at, updated_at, deleted_at + ) + VALUES ('thread-w', 'project-w', 'Windowed thread', + '{"provider":"codex","model":"gpt-5-codex"}', 'full-access', 'default', + 'turn-5', 0, 0, 0, '2026-03-01T00:00:00.000Z', '2026-03-01T00:00:10.000Z', NULL) + `; + + const turns: ReadonlyArray<{ + turn: string; + pendingMessage: string | null; + at: string; + }> = [ + { turn: "turn-1", pendingMessage: "user-msg-1", at: "2026-03-01T00:00:00.000Z" }, + { turn: "turn-2", pendingMessage: null, at: "2026-03-01T00:01:00.000Z" }, + { turn: "turn-3", pendingMessage: null, at: "2026-03-01T00:02:00.000Z" }, + { turn: "turn-4", pendingMessage: "user-msg-4", at: "2026-03-01T00:03:00.000Z" }, + { turn: "turn-5", pendingMessage: "user-msg-5", at: "2026-03-01T00:04:00.000Z" }, + ]; + for (const { turn, pendingMessage, at } of turns) { + yield* sql` + INSERT INTO projection_turns ( + thread_id, turn_id, pending_message_id, state, requested_at, started_at, completed_at, + checkpoint_files_json + ) + VALUES ('thread-w', ${turn}, ${pendingMessage}, 'completed', ${at}, ${at}, ${at}, '[]') + `; + if (pendingMessage !== null) { + yield* sql` + INSERT INTO projection_thread_messages ( + message_id, thread_id, turn_id, role, text, is_streaming, created_at, updated_at + ) + VALUES (${pendingMessage}, 'thread-w', NULL, 'user', ${"prompt for " + turn}, 0, ${at}, ${at}) + `; + } + yield* sql` + INSERT INTO projection_thread_messages ( + message_id, thread_id, turn_id, role, text, is_streaming, created_at, updated_at + ) + VALUES (${turn + "-reply"}, 'thread-w', ${turn}, 'assistant', ${"reply from " + turn}, 0, ${at}, ${at}) + `; + yield* sql` + INSERT INTO projection_thread_activities ( + activity_id, thread_id, turn_id, tone, kind, summary, payload_json, created_at + ) + VALUES (${turn + "-activity"}, 'thread-w', ${turn}, 'tool', 'tool.completed', + 'ran tool', '{"ok":true}', ${at}) + `; + } + + // Straggler user message sent while turn-4 ran: turn_id NULL and not any + // turn's pending_message_id. + yield* sql` + INSERT INTO projection_thread_messages ( + message_id, thread_id, turn_id, role, text, is_streaming, created_at, updated_at + ) + VALUES ('user-msg-straggler', 'thread-w', NULL, 'user', 'while you are at it', + 0, '2026-03-01T00:03:30.000Z', '2026-03-01T00:03:30.000Z') + `; + // Turnless activity in the same time range. + yield* sql` + INSERT INTO projection_thread_activities ( + activity_id, thread_id, turn_id, tone, kind, summary, payload_json, created_at + ) + VALUES ('turnless-activity', 'thread-w', NULL, 'info', 'context-window.updated', + 'usage', '{"usedTokens":1}', '2026-03-01T00:03:36.000Z') + `; + + for (const projector of Object.values(ORCHESTRATION_PROJECTOR_NAMES)) { + yield* sql` + INSERT INTO projection_state (projector, last_applied_sequence, updated_at) + VALUES (${projector}, 42, '2026-03-01T00:00:10.000Z') + `; + } + }); + + const threadW = ThreadId.make("thread-w"); + const messageIds = (snapshot: { thread: { messages: ReadonlyArray<{ id: string }> } }) => + snapshot.thread.messages.map((message) => message.id).toSorted(); + const activityIds = (snapshot: { thread: { activities: ReadonlyArray<{ id: string }> } }) => + snapshot.thread.activities.map((activity) => activity.id).toSorted(); + + it.effect("returns the full thread with no page metadata when no window is requested", () => + Effect.gen(function* () { + yield* seedFanOutThread(); + const snapshotQuery = yield* ProjectionSnapshotQuery; + + const snapshot = yield* snapshotQuery.getThreadDetailSnapshot(threadW); + assert.equal(snapshot._tag, "Some"); + if (snapshot._tag === "Some") { + assert.equal(snapshot.value.page, undefined); + assert.equal(snapshot.value.thread.messages.length, 9); + assert.equal(snapshot.value.thread.activities.length, 6); + assert.equal(snapshot.value.snapshotSequence, 42); + } + }), + ); + + it.effect("windows to the last N user-anchored turns with subagent turns riding along", () => + Effect.gen(function* () { + yield* seedFanOutThread(); + const snapshotQuery = yield* ProjectionSnapshotQuery; + + // turnLimit 2 walks back: turn-5 (user), turn-4 (user) -> window is + // rows 4..5. Subagent turns 2-3 are older than the 2nd user turn and + // stay out; the straggler message and turnless activity (T03.5/T03.6, + // after turn-4's anchor) ride along. + const snapshot = yield* snapshotQuery.getThreadDetailSnapshot(threadW, { turnLimit: 2 }); + assert.equal(snapshot._tag, "Some"); + if (snapshot._tag === "Some") { + assert.deepEqual(messageIds(snapshot.value), [ + "turn-4-reply", + "turn-5-reply", + "user-msg-4", + "user-msg-5", + "user-msg-straggler", + ]); + assert.deepEqual(activityIds(snapshot.value), [ + "turn-4-activity", + "turn-5-activity", + "turnless-activity", + ]); + assert.equal(snapshot.value.page?.hasMore, true); + assert.notEqual(snapshot.value.page?.beforeCursor, null); + assert.equal(snapshot.value.page?.snapshotSequence, 42); + } + }), + ); + + it.effect("subagent turns between user turns ride along inside the window", () => + Effect.gen(function* () { + yield* seedFanOutThread(); + const snapshotQuery = yield* ProjectionSnapshotQuery; + + // turnLimit 3 reaches user turn-1, dragging subagent turns 2-3 along: + // the full thread, so no further pages. + const snapshot = yield* snapshotQuery.getThreadDetailSnapshot(threadW, { turnLimit: 3 }); + assert.equal(snapshot._tag, "Some"); + if (snapshot._tag === "Some") { + assert.equal(snapshot.value.thread.messages.length, 9); + assert.equal(snapshot.value.thread.activities.length, 6); + assert.equal(snapshot.value.page?.hasMore, false); + assert.equal(snapshot.value.page?.beforeCursor, null); + } + }), + ); + + it.effect("cursors survive a projection rewrite that reassigns turn row ids", () => + Effect.gen(function* () { + // The revert projector (and any projection rebuild) deletes and + // re-upserts projection_turns, assigning fresh autoincrement row ids. + // The keyset cursor is derived from event content, so a page cursor + // minted before the rewrite must keep working after it. + yield* seedFanOutThread(); + const sql = yield* SqlClient.SqlClient; + const snapshotQuery = yield* ProjectionSnapshotQuery; + + const firstPage = yield* snapshotQuery.getThreadDetailSnapshot(threadW, { turnLimit: 2 }); + assert.equal(firstPage._tag, "Some"); + if (firstPage._tag !== "Some") return; + const cursor = firstPage.value.page?.beforeCursor; + assert.notEqual(cursor, null); + if (cursor === null || cursor === undefined) return; + + // Simulate the rewrite: delete and re-insert every turn row with the + // same content, which reassigns all row ids. + const turnRows = yield* sql` + SELECT thread_id, turn_id, pending_message_id, state, requested_at, started_at, + completed_at, checkpoint_files_json + FROM projection_turns WHERE thread_id = 'thread-w' ORDER BY row_id + `; + yield* sql`DELETE FROM projection_turns WHERE thread_id = 'thread-w'`; + for (const row of turnRows) { + yield* sql` + INSERT INTO projection_turns ( + thread_id, turn_id, pending_message_id, state, requested_at, started_at, + completed_at, checkpoint_files_json + ) + VALUES (${row.thread_id as string}, ${row.turn_id as string}, + ${row.pending_message_id as string | null}, ${row.state as string}, + ${row.requested_at as string}, ${row.started_at as string}, + ${row.completed_at as string}, ${row.checkpoint_files_json as string}) + `; + } + + const olderPage = yield* snapshotQuery.getThreadDetailSnapshot(threadW, { + turnLimit: 1, + beforeCursor: cursor, + }); + assert.equal(olderPage._tag, "Some"); + if (olderPage._tag === "Some") { + // Identical older slice to what the pre-rewrite cursor would return. + assert.deepEqual(messageIds(olderPage.value), [ + "turn-1-reply", + "turn-2-reply", + "turn-3-reply", + "user-msg-1", + ]); + assert.equal(olderPage.value.page?.hasMore, false); + } + }), + ); + + it.effect("beforeCursor returns the disjoint adjacent older slice", () => + Effect.gen(function* () { + yield* seedFanOutThread(); + const snapshotQuery = yield* ProjectionSnapshotQuery; + + const firstPage = yield* snapshotQuery.getThreadDetailSnapshot(threadW, { turnLimit: 2 }); + assert.equal(firstPage._tag, "Some"); + if (firstPage._tag !== "Some") return; + const cursor = firstPage.value.page?.beforeCursor; + assert.notEqual(cursor, null); + assert.notEqual(cursor, undefined); + if (cursor === null || cursor === undefined) return; + + // Older page: user turn-1 plus subagent turns 2-3 riding along. Disjoint + // from the first page: no turn-4/5 rows, no straggler. + const olderPage = yield* snapshotQuery.getThreadDetailSnapshot(threadW, { + turnLimit: 1, + beforeCursor: cursor, + }); + assert.equal(olderPage._tag, "Some"); + if (olderPage._tag === "Some") { + assert.deepEqual(messageIds(olderPage.value), [ + "turn-1-reply", + "turn-2-reply", + "turn-3-reply", + "user-msg-1", + ]); + assert.deepEqual(activityIds(olderPage.value), [ + "turn-1-activity", + "turn-2-activity", + "turn-3-activity", + ]); + assert.equal(olderPage.value.page?.hasMore, false); + assert.equal(olderPage.value.page?.beforeCursor, null); + } + }), + ); + + it.effect("a cursor for a different thread degrades to the first page", () => + Effect.gen(function* () { + yield* seedFanOutThread(); + const snapshotQuery = yield* ProjectionSnapshotQuery; + + const firstPage = yield* snapshotQuery.getThreadDetailSnapshot(threadW, { turnLimit: 2 }); + assert.equal(firstPage._tag, "Some"); + if (firstPage._tag !== "Some") return; + + const foreign = encodeThreadDetailPageCursor({ + threadId: ThreadId.make("thread-other"), + beforeAnchorAt: "2026-03-01T00:01:00.000Z", + beforeTurnId: "turn-2", + }); + const snapshot = yield* snapshotQuery.getThreadDetailSnapshot(threadW, { + turnLimit: 2, + beforeCursor: foreign, + }); + assert.equal(snapshot._tag, "Some"); + if (snapshot._tag === "Some") { + assert.deepEqual(messageIds(snapshot.value), messageIds(firstPage.value)); + } + }), + ); + + it.effect("a malformed cursor degrades to the first page instead of failing", () => + Effect.gen(function* () { + yield* seedFanOutThread(); + const snapshotQuery = yield* ProjectionSnapshotQuery; + + const snapshot = yield* snapshotQuery.getThreadDetailSnapshot(threadW, { + turnLimit: 2, + beforeCursor: "not-a-cursor", + }); + assert.equal(snapshot._tag, "Some"); + if (snapshot._tag === "Some") { + assert.equal(snapshot.value.page?.hasMore, true); + assert.equal(snapshot.value.thread.messages.length, 5); + } + }), + ); + + it.effect("windows never split below the raw-turn ceiling boundary contiguously", () => + Effect.gen(function* () { + yield* seedFanOutThread(); + const snapshotQuery = yield* ProjectionSnapshotQuery; + + // Page repeatedly with turnLimit 1 and assert the union of all pages is + // exactly the full thread with no duplicates (disjointness + coverage). + const seenMessages: string[] = []; + const seenActivities: string[] = []; + let cursor: string | undefined; + for (let page = 0; page < 10; page += 1) { + const snapshot = yield* snapshotQuery.getThreadDetailSnapshot(threadW, { + turnLimit: 1, + ...(cursor !== undefined ? { beforeCursor: cursor } : {}), + }); + assert.equal(snapshot._tag, "Some"); + if (snapshot._tag !== "Some") return; + seenMessages.push(...snapshot.value.thread.messages.map((message) => message.id)); + seenActivities.push(...snapshot.value.thread.activities.map((activity) => activity.id)); + const next = snapshot.value.page?.beforeCursor; + if (next === null || next === undefined) break; + cursor = next; + } + assert.equal(new Set(seenMessages).size, seenMessages.length); + assert.equal(new Set(seenActivities).size, seenActivities.length); + assert.equal(seenMessages.length, 9); + assert.equal(seenActivities.length, 6); + }), + ); + + it.effect("a thread with no turns returns its content unwindowed on the first page", () => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const snapshotQuery = yield* ProjectionSnapshotQuery; + + yield* sql`DELETE FROM projection_projects`; + yield* sql`DELETE FROM projection_threads`; + yield* sql`DELETE FROM projection_turns`; + yield* sql`DELETE FROM projection_thread_messages`; + yield* sql`DELETE FROM projection_thread_activities`; + yield* sql`DELETE FROM projection_state`; + + yield* sql` + INSERT INTO projection_projects ( + project_id, title, workspace_root, scripts_json, created_at, updated_at, deleted_at + ) + VALUES ('project-e', 'Empty', '/tmp/project-e', '[]', + '2026-03-02T00:00:00.000Z', '2026-03-02T00:00:00.000Z', NULL) + `; + yield* sql` + INSERT INTO projection_threads ( + thread_id, project_id, title, model_selection_json, runtime_mode, interaction_mode, + pending_approval_count, pending_user_input_count, has_actionable_proposed_plan, + created_at, updated_at, deleted_at + ) + VALUES ('thread-e', 'project-e', 'Turnless thread', + '{"provider":"codex","model":"gpt-5-codex"}', 'full-access', 'default', + 0, 0, 0, '2026-03-02T00:00:00.000Z', '2026-03-02T00:00:00.000Z', NULL) + `; + yield* sql` + INSERT INTO projection_thread_messages ( + message_id, thread_id, turn_id, role, text, is_streaming, created_at, updated_at + ) + VALUES ('pre-turn-msg', 'thread-e', NULL, 'user', 'first prompt', 0, + '2026-03-02T00:00:01.000Z', '2026-03-02T00:00:01.000Z') + `; + for (const projector of Object.values(ORCHESTRATION_PROJECTOR_NAMES)) { + yield* sql` + INSERT INTO projection_state (projector, last_applied_sequence, updated_at) + VALUES (${projector}, 7, '2026-03-02T00:00:01.000Z') + `; + } + + const snapshot = yield* snapshotQuery.getThreadDetailSnapshot(ThreadId.make("thread-e"), { + turnLimit: 5, + }); + assert.equal(snapshot._tag, "Some"); + if (snapshot._tag === "Some") { + assert.deepEqual(messageIds(snapshot.value), ["pre-turn-msg"]); + assert.equal(snapshot.value.page?.hasMore, false); + assert.equal(snapshot.value.page?.beforeCursor, null); + } + }), + ); +}); diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts index fda437cae5c5..2bf49b69020c 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts @@ -56,6 +56,10 @@ import { ProjectionThreadProposedPlan } from "../../persistence/Services/Project import { ProjectionQueuedMessage } from "../../persistence/Services/ProjectionQueuedMessages.ts"; import { ProjectionThreadSession } from "../../persistence/Services/ProjectionThreadSessions.ts"; import { ProjectionThread } from "../../persistence/Services/ProjectionThreads.ts"; +import { + decodeThreadDetailPageCursor, + encodeThreadDetailPageCursor, +} from "../threadDetailCursor.ts"; import * as RepositoryIdentityResolver from "../../project/RepositoryIdentityResolver.ts"; import { ORCHESTRATION_PROJECTOR_NAMES } from "./ProjectionPipeline.ts"; import { @@ -148,30 +152,38 @@ const ProjectIdLookupInput = Schema.Struct({ const ThreadIdLookupInput = Schema.Struct({ threadId: ThreadId, }); +// Windowed reads order turns by the stable keyset (anchor, turn key), where +// anchor is requested_at and turn key is +// COALESCE(turn_id, ''). Both are event-derived, so cursors survive the +// revert projector's row-id rewrite and full projection rebuilds. -/** - * Maximum number of most-recent activities loaded into a thread-detail snapshot. - * Bounds peak memory when opening a long-lived thread; older activities are - * fetched on demand (lazy-load, planned) and live ones stream in via events. - */ +/** Cap activities on a full (unwindowed) thread detail read. */ const THREAD_DETAIL_ACTIVITY_WINDOW = 500; - -// `beforeSequence`/`limit` are NonNegativeInt (not bare Number) to match the -// contract: the WHERE clause `(sequence < beforeSequence OR sequence IS NULL)` -// is only equivalent to the old `COALESCE(sequence, -1) < beforeSequence` when -// `beforeSequence` is non-negative — a negative cursor would silently match no -// sequenced rows and return only unsequenced ones. Validating here (not just at -// the RPC boundary) keeps any future non-RPC caller honest. -const ThreadActivitiesBeforeSequenceInput = Schema.Struct({ +const ThreadTurnWindowLookupInput = Schema.Struct({ threadId: ThreadId, - beforeSequence: NonNegativeInt, - limit: NonNegativeInt, + // Exclusive keyset upper bound. Sentinels "~"/"" mean unbounded ("~" sorts + // after every ISO timestamp). + beforeAnchorAt: Schema.String, + beforeTurnKey: Schema.String, + userTurnLimit: Schema.Number, + maxRawTurns: Schema.Number, +}); +const ProjectionTurnWindowRowSchema = Schema.Struct({ + // The turn's timeline anchor, used to bound rows that have no turn linkage + // (user messages and turnless activities) to the same page window. + anchorAt: Schema.String, + turnKey: Schema.String, }); -const ThreadActivitiesBeforeActivityInput = Schema.Struct({ +const ThreadTurnRangeLookupInput = Schema.Struct({ threadId: ThreadId, - beforeCreatedAt: IsoDateTime, - beforeActivityId: EventId, - limit: NonNegativeInt, + // Turn-linked rows are bounded by the keyset range [min, before) over + // (anchor, turn key); turnless rows by the matching [minAnchorAt, + // beforeAnchorAt) time range. Unbounded ends use sentinels: "" for the + // lower bound, "~" (sorts after ISO dates) for the upper bound. + minAnchorAt: Schema.String, + minTurnKey: Schema.String, + beforeAnchorAt: Schema.String, + beforeTurnKey: Schema.String, }); const ProjectionProjectLookupRowSchema = ProjectionProjectDbRowSchema; const ProjectionThreadIdLookupRowSchema = Schema.Struct({ @@ -1184,7 +1196,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { sequence DESC, created_at DESC, activity_id DESC - -- One extra beyond the window so the caller can report hasMoreActivities. + -- One extra beyond the window so we can drop the overflow row. LIMIT ${THREAD_DETAIL_ACTIVITY_WINDOW + 1} ) ORDER BY @@ -1194,81 +1206,6 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { `, }); - // Older-than-cursor page for lazy-load. Returns rows newest-first (DESC) so a - // simple LIMIT yields the page adjacent to the cursor; the caller reverses to - // ascending. `sequence IS NULL` (legacy unsequenced) rows sort last under - // `sequence DESC` (SQLite orders NULLs last in DESC) — the very oldest — so - // paging eventually reaches them. `beforeSequence` is a NonNegativeInt, so - // `(sequence < beforeSequence OR sequence IS NULL)` is equivalent to the old - // `COALESCE(sequence, -1) < beforeSequence` but lets the - // (thread_id, sequence, created_at, activity_id) index satisfy the ORDER BY - // directly instead of forcing a filesort over the whole thread. - const listThreadActivityRowsBeforeSequence = SqlSchema.findAll({ - Request: ThreadActivitiesBeforeSequenceInput, - Result: ProjectionThreadActivityDbRowSchema, - execute: ({ threadId, beforeSequence, limit }) => - sql` - SELECT - activity_id AS "activityId", - thread_id AS "threadId", - turn_id AS "turnId", - tone, - kind, - summary, - payload_json AS "payload", - sequence, - created_at AS "createdAt" - FROM projection_thread_activities - WHERE thread_id = ${threadId} - AND (sequence < ${beforeSequence} OR sequence IS NULL) - ORDER BY - sequence DESC, - created_at DESC, - activity_id DESC - LIMIT ${limit} - `, - }); - - // Legacy unsequenced (sequence NULL) rows are paged by a (created_at, - // activity_id) cursor. created_at is compared lexicographically as TEXT, which - // equals chronological order only because timestamps are canonical ISO-8601 - // (always UTC `Z`, fixed millisecond precision) — the same invariant every - // `ORDER BY created_at` in this layer (including the detail window above) - // already relies on, so the cursor stays consistent with how rows are - // displayed. activity_id breaks created_at ties; its ordering is arbitrary but - // matches the window's `activity_id` tiebreak, so pages never skip or repeat. - const listUnsequencedThreadActivityRowsBeforeActivity = SqlSchema.findAll({ - Request: ThreadActivitiesBeforeActivityInput, - Result: ProjectionThreadActivityDbRowSchema, - execute: ({ threadId, beforeCreatedAt, beforeActivityId, limit }) => - sql` - SELECT - activity_id AS "activityId", - thread_id AS "threadId", - turn_id AS "turnId", - tone, - kind, - summary, - payload_json AS "payload", - sequence, - created_at AS "createdAt" - FROM projection_thread_activities - WHERE thread_id = ${threadId} - AND sequence IS NULL - AND ( - created_at < ${beforeCreatedAt} - OR ( - created_at = ${beforeCreatedAt} - AND activity_id < ${beforeActivityId} - ) - ) - ORDER BY - created_at DESC, - activity_id DESC - LIMIT ${limit} - `, - }); - const getThreadSessionRowByThread = SqlSchema.findOneOption({ Request: ThreadIdLookupInput, Result: ProjectionThreadSessionDbRowSchema, @@ -1336,6 +1273,198 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { `, }); + // Resolves a page of recent turns for a windowed thread detail read. Walks + // back from the exclusive (beforeAnchorAt, beforeTurnKey) keyset boundary + // (sentinels "~"/"" mean unbounded, i.e. the first page) until it has seen + // `userTurnLimit` user-anchored turns — turns whose pending message is a + // user message; subagent/fan-out turns between them ride along — or hits the + // `maxRawTurns` ceiling that bounds pathological fan-out. The `candidates` + // CTE applies the keyset bound and LIMIT before the window functions run; + // its ORDER BY uses raw columns so the migration-037 + // (thread_id, requested_at, turn_id) index serves both range and order with + // no temp B-tree — the scan is genuinely bounded by the LIMIT. (Raw + // turn_id DESC places NULLs exactly where COALESCE-to-'' would, below every + // real id.) The caller derives the continuation cursor from the oldest + // returned row. + // Highest thread-DETAIL event sequence for this thread that the projection + // has applied (bounded by the global snapshot sequence read in the same + // transaction). This is the thread-scoped watermark a windowed page carries + // so clients can defer merging until their live subscription has caught up; + // the global sequence is not waitable per-thread. The event_type filter + // must match ws.ts's isThreadDetailEvent exactly: the subscription only + // delivers these types, so a watermark counting any other event could + // never be reached by the client and would park the page forever. Served + // by the event store's (aggregate_kind, stream_id, sequence) index. + const getThreadEventWatermarkRow = SqlSchema.findOneOption({ + Request: Schema.Struct({ threadId: ThreadId, maxSequence: Schema.Number }), + Result: Schema.Struct({ threadSequence: Schema.NullOr(Schema.Number) }), + execute: ({ threadId, maxSequence }) => + sql` + SELECT MAX(sequence) AS "threadSequence" + FROM orchestration_events + WHERE aggregate_kind = 'thread' + AND stream_id = ${threadId} + AND sequence <= ${maxSequence} + AND event_type IN ( + 'thread.message-sent', + 'thread.proposed-plan-upserted', + 'thread.activity-appended', + 'thread.turn-diff-completed', + 'thread.reverted', + 'thread.session-set' + ) + `, + }); + + const listTurnWindowRows = SqlSchema.findAll({ + Request: ThreadTurnWindowLookupInput, + Result: ProjectionTurnWindowRowSchema, + execute: ({ threadId, beforeAnchorAt, beforeTurnKey, userTurnLimit, maxRawTurns }) => + sql` + WITH candidates AS ( + SELECT + turns.requested_at AS anchor_at, + COALESCE(turns.turn_id, '') AS turn_key, + turns.pending_message_id + FROM projection_turns AS turns + WHERE turns.thread_id = ${threadId} + AND ( + turns.requested_at < ${beforeAnchorAt} + OR ( + turns.requested_at = ${beforeAnchorAt} + AND COALESCE(turns.turn_id, '') < ${beforeTurnKey} + ) + ) + ORDER BY turns.requested_at DESC, turns.turn_id DESC + LIMIT ${maxRawTurns} + ), + walked AS ( + SELECT + candidates.anchor_at, + candidates.turn_key, + CASE WHEN messages.role = 'user' THEN 1 ELSE 0 END AS is_user_turn, + SUM(CASE WHEN messages.role = 'user' THEN 1 ELSE 0 END) OVER ( + ORDER BY candidates.anchor_at DESC, candidates.turn_key DESC + ) AS user_turns_seen + FROM candidates + LEFT JOIN projection_thread_messages AS messages + ON messages.message_id = candidates.pending_message_id + ) + SELECT + anchor_at AS "anchorAt", + turn_key AS "turnKey" + FROM walked + WHERE user_turns_seen < ${userTurnLimit} + OR (user_turns_seen = ${userTurnLimit} AND is_user_turn = 1) + ORDER BY anchor_at ASC, turn_key ASC + `, + }); + + // Windowed variants of the two heavy collections. Turn-linked rows are + // bounded by the page's (anchor, turn key) keyset range over + // projection_turns; rows with no turn linkage (user messages always, and + // turnless activities like pre-turn context-window updates) are bounded by + // the matching turn-anchor time range so they land on the same page as the + // turns around them. Proposed plans and checkpoints stay unwindowed: they + // are metadata-scale. + const listThreadMessageRowsByThreadWindow = SqlSchema.findAll({ + Request: ThreadTurnRangeLookupInput, + Result: ProjectionThreadMessageDbRowSchema, + execute: ({ threadId, minAnchorAt, minTurnKey, beforeAnchorAt, beforeTurnKey }) => + sql` + SELECT + message_id AS "messageId", + thread_id AS "threadId", + turn_id AS "turnId", + role, + text, + attachments_json AS "attachments", + is_streaming AS "isStreaming", + source_json AS "source", + created_at AS "createdAt", + updated_at AS "updatedAt" + FROM projection_thread_messages + WHERE thread_id = ${threadId} + AND ( + turn_id IN ( + SELECT turn_id FROM projection_turns + WHERE thread_id = ${threadId} + AND turn_id IS NOT NULL + AND ( + requested_at > ${minAnchorAt} + OR ( + requested_at = ${minAnchorAt} + AND turn_id >= ${minTurnKey} + ) + ) + AND ( + requested_at < ${beforeAnchorAt} + OR ( + requested_at = ${beforeAnchorAt} + AND turn_id < ${beforeTurnKey} + ) + ) + ) + OR ( + turn_id IS NULL + AND created_at >= ${minAnchorAt} + AND created_at < ${beforeAnchorAt} + ) + ) + ORDER BY created_at ASC, message_id ASC + `, + }); + + const listThreadActivityRowsByThreadWindow = SqlSchema.findAll({ + Request: ThreadTurnRangeLookupInput, + Result: ProjectionThreadActivityDbRowSchema, + execute: ({ threadId, minAnchorAt, minTurnKey, beforeAnchorAt, beforeTurnKey }) => + sql` + SELECT + activity_id AS "activityId", + thread_id AS "threadId", + turn_id AS "turnId", + tone, + kind, + summary, + payload_json AS "payload", + sequence, + created_at AS "createdAt" + FROM projection_thread_activities + WHERE thread_id = ${threadId} + AND ( + turn_id IN ( + SELECT turn_id FROM projection_turns + WHERE thread_id = ${threadId} + AND turn_id IS NOT NULL + AND ( + requested_at > ${minAnchorAt} + OR ( + requested_at = ${minAnchorAt} + AND turn_id >= ${minTurnKey} + ) + ) + AND ( + requested_at < ${beforeAnchorAt} + OR ( + requested_at = ${beforeAnchorAt} + AND turn_id < ${beforeTurnKey} + ) + ) + ) + OR ( + turn_id IS NULL + AND created_at >= ${minAnchorAt} + AND created_at < ${beforeAnchorAt} + ) + ) + ORDER BY + sequence ASC, + created_at ASC, + activity_id ASC + `, + }); + const getFullThreadDiffContextRow = SqlSchema.findOneOption({ Request: FullThreadDiffContextLookupInput, Result: ProjectionFullThreadDiffContextRowSchema, @@ -1669,7 +1798,6 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { proposedPlans: proposedPlansByThread.get(row.threadId) ?? [], activities: activitiesByThread.get(row.threadId) ?? [], // The full snapshot is unwindowed, so there is never more to load. - hasMoreActivities: false, checkpoints: checkpointsByThread.get(row.threadId) ?? [], session: sessionsByThread.get(row.threadId) ?? null, ...(row.originSource !== null && row.originSource !== undefined @@ -2543,7 +2671,17 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { } satisfies OrchestrationThreadShell); }); - const getThreadDetailById: ProjectionSnapshotQueryShape["getThreadDetailById"] = (threadId) => + // Contiguous turn range bounding a windowed detail read; undefined loads the + // full thread. Resolved from a window request inside the snapshot + // transaction (see getThreadDetailSnapshot). + interface ThreadDetailBounds { + readonly minAnchorAt: string; + readonly minTurnKey: string; + readonly beforeAnchorAt: string; + readonly beforeTurnKey: string; + } + + const getThreadDetailByIdBounded = (threadId: ThreadId, bounds: ThreadDetailBounds | undefined) => Effect.gen(function* () { const [ threadRow, @@ -2564,7 +2702,10 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { ), ), ), - listThreadMessageRowsByThread({ threadId }).pipe( + (bounds === undefined + ? listThreadMessageRowsByThread({ threadId }) + : listThreadMessageRowsByThreadWindow({ threadId, ...bounds }) + ).pipe( Effect.mapError( toPersistenceSqlOrDecodeError( "ProjectionSnapshotQuery.getThreadDetailById:listMessages:query", @@ -2596,7 +2737,10 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { ), ), ), - listThreadActivityRowsByThread({ threadId }).pipe( + (bounds === undefined + ? listThreadActivityRowsByThread({ threadId }) + : listThreadActivityRowsByThreadWindow({ threadId, ...bounds }) + ).pipe( Effect.mapError( toPersistenceSqlOrDecodeError( "ProjectionSnapshotQuery.getThreadDetailById:listActivities:query", @@ -2685,7 +2829,6 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { ? activityRows.slice(activityRows.length - THREAD_DETAIL_ACTIVITY_WINDOW) : activityRows ).map(mapThreadActivityRow), - hasMoreActivities: activityRows.length > THREAD_DETAIL_ACTIVITY_WINDOW, checkpoints: checkpointRows.map((row) => ({ turnId: row.turnId, checkpointTurnCount: row.checkpointTurnCount, @@ -2714,23 +2857,139 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { ); }); + const getThreadDetailById: ProjectionSnapshotQueryShape["getThreadDetailById"] = (threadId) => + getThreadDetailByIdBounded(threadId, undefined); + + // Bounds pathological fan-out: one user turn that spawned hundreds of + // subagent turns still pages in bounded chunks, at the cost of splitting the + // fan-out group across pages (the cursor continues the same group). Also + // structurally bounds the window scan via the candidates CTE's LIMIT. + const THREAD_DETAIL_MAX_RAW_TURNS_PER_PAGE = 150; + // Sentinels for unbounded keyset ends; "~" sorts after any ISO timestamp. + const ANCHOR_UNBOUNDED = "~"; + const getThreadDetailSnapshot: ProjectionSnapshotQueryShape["getThreadDetailSnapshot"] = ( threadId, + window, ) => // Read the thread detail and the snapshot sequence within a single // transaction so the sequence is consistent with the returned state; a // projector update landing between two separate reads could otherwise return // a sequence ahead of the thread detail, causing the client to resume from - // too far and drop events. + // too far and drop events. Window resolution runs inside the same + // transaction so the page boundary is consistent with the returned rows. sql .withTransaction( Effect.gen(function* () { - const thread = yield* getThreadDetailById(threadId); + if (window?.turnLimit === undefined) { + const thread = yield* getThreadDetailById(threadId); + if (Option.isNone(thread)) { + return Option.none(); + } + const { snapshotSequence } = yield* getSnapshotSequence(); + return Option.some({ snapshotSequence, thread: thread.value }); + } + + // A malformed or foreign-thread cursor falls back to the first page + // rather than failing: the client's stale cursor after a revert or + // reconnect should degrade to "reload recent history", not error. + const decodedCursor = + window.beforeCursor === undefined + ? null + : decodeThreadDetailPageCursor(window.beforeCursor); + const cursor = decodedCursor?.threadId === threadId ? decodedCursor : null; + + const windowRows = yield* listTurnWindowRows({ + threadId, + beforeAnchorAt: cursor?.beforeAnchorAt ?? ANCHOR_UNBOUNDED, + beforeTurnKey: cursor?.beforeTurnId ?? "", + userTurnLimit: window.turnLimit, + maxRawTurns: THREAD_DETAIL_MAX_RAW_TURNS_PER_PAGE, + }).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "ProjectionSnapshotQuery.getThreadDetailSnapshot:listTurnWindow:query", + "ProjectionSnapshotQuery.getThreadDetailSnapshot:listTurnWindow:decodeRows", + ), + ), + ); + + const oldest = windowRows[0]; + // An empty window (no turns before the cursor, or a thread with no + // turns at all) still returns thread metadata with empty collections + // for turn-linked rows; turnless rows are bounded to the same empty + // range. The first page of a turnless thread stays unwindowed so + // pre-turn content (e.g. a just-created thread) is not hidden. + const bounds: ThreadDetailBounds | undefined = + oldest === undefined && cursor === null + ? undefined + : { + minAnchorAt: oldest?.anchorAt ?? "", + minTurnKey: oldest?.turnKey ?? "", + beforeAnchorAt: cursor?.beforeAnchorAt ?? ANCHOR_UNBOUNDED, + beforeTurnKey: cursor?.beforeTurnId ?? "", + }; + // Empty window behind a cursor: nothing older remains. + const emptyBounds = + oldest === undefined && cursor !== null + ? { minAnchorAt: "", minTurnKey: "", beforeAnchorAt: "", beforeTurnKey: "" } + : undefined; + + const thread = yield* getThreadDetailByIdBounded(threadId, emptyBounds ?? bounds); if (Option.isNone(thread)) { return Option.none(); } + + const hasMore = + oldest !== undefined && + (yield* listTurnWindowRows({ + threadId, + beforeAnchorAt: oldest.anchorAt, + beforeTurnKey: oldest.turnKey, + userTurnLimit: 1, + maxRawTurns: 1, + }).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "ProjectionSnapshotQuery.getThreadDetailSnapshot:probeOlder:query", + "ProjectionSnapshotQuery.getThreadDetailSnapshot:probeOlder:decodeRows", + ), + ), + )).length > 0; + const { snapshotSequence } = yield* getSnapshotSequence(); - return Option.some({ snapshotSequence, thread: thread.value }); + const watermarkRow = yield* getThreadEventWatermarkRow({ + threadId, + maxSequence: snapshotSequence, + }).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "ProjectionSnapshotQuery.getThreadDetailSnapshot:threadWatermark:query", + "ProjectionSnapshotQuery.getThreadDetailSnapshot:threadWatermark:decodeRow", + ), + ), + ); + const threadSequence = Option.match(watermarkRow, { + onNone: () => 0, + onSome: (row) => row.threadSequence ?? 0, + }); + return Option.some({ + snapshotSequence, + thread: thread.value, + page: { + beforeCursor: + hasMore && oldest !== undefined + ? encodeThreadDetailPageCursor({ + threadId, + beforeAnchorAt: oldest.anchorAt, + beforeTurnId: oldest.turnKey, + }) + : null, + hasMore, + snapshotSequence, + threadSequence, + }, + }); }), ) .pipe( @@ -2742,42 +3001,6 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { ), ), ); - const getThreadActivitiesPage: ProjectionSnapshotQueryShape["getThreadActivitiesPage"] = ( - input, - ) => - Effect.gen(function* () { - const limit = Math.min( - Math.max(1, input.limit ?? THREAD_DETAIL_ACTIVITY_WINDOW), - THREAD_DETAIL_ACTIVITY_WINDOW, - ); - // Fetch one extra to detect whether older activities remain. - const rowsEffect = - "beforeSequence" in input - ? listThreadActivityRowsBeforeSequence({ - threadId: input.threadId, - beforeSequence: input.beforeSequence, - limit: limit + 1, - }) - : listUnsequencedThreadActivityRowsBeforeActivity({ - threadId: input.threadId, - beforeCreatedAt: input.beforeCreatedAt, - beforeActivityId: input.beforeActivityId, - limit: limit + 1, - }); - const rows = yield* rowsEffect.pipe( - Effect.mapError( - toPersistenceSqlOrDecodeError( - "ProjectionSnapshotQuery.getThreadActivitiesPage:query", - "ProjectionSnapshotQuery.getThreadActivitiesPage:decodeRows", - ), - ), - ); - const hasMore = rows.length > limit; - // Rows are newest-first; keep the page closest to the cursor, then reverse - // to ascending for display. - const page = (hasMore ? rows.slice(0, limit) : rows).map(mapThreadActivityRow).toReversed(); - return { activities: page, hasMore }; - }); const getThreadLifecycleById: ProjectionSnapshotQueryShape["getThreadLifecycleById"] = ( threadId, @@ -2808,7 +3031,6 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { getThreadDetailById, getThreadLifecycleById, getThreadDetailSnapshot, - getThreadActivitiesPage, } satisfies ProjectionSnapshotQueryShape; }); diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts index 111db22cbf69..476927f79422 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts @@ -1727,6 +1727,9 @@ describe("ProviderCommandReactor", () => { createdAt: now, }), ); + // Queue-by-default holds follow-up turn.start commands while a session is + // still starting/running; settle so each message lands in the transcript. + await harness.settleSession(); await harness.runEffect( harness.engine.dispatch({ type: "thread.turn.start", @@ -1751,6 +1754,7 @@ describe("ProviderCommandReactor", () => { createdAt: "2026-01-01T00:00:01.000Z", }), ); + await harness.settleSession(); await harness.runEffect( harness.engine.dispatch({ type: "thread.turn.start", @@ -1775,6 +1779,7 @@ describe("ProviderCommandReactor", () => { createdAt: "2026-01-01T00:00:02.000Z", }), ); + await harness.settleSession(); await harness.runEffect( harness.engine.dispatch({ type: "thread.meta.update", diff --git a/apps/server/src/orchestration/Services/ProjectionSnapshotQuery.ts b/apps/server/src/orchestration/Services/ProjectionSnapshotQuery.ts index 26bb68d787c1..daece57e22d2 100644 --- a/apps/server/src/orchestration/Services/ProjectionSnapshotQuery.ts +++ b/apps/server/src/orchestration/Services/ProjectionSnapshotQuery.ts @@ -9,8 +9,6 @@ import type { CheckpointRef, OrchestrationCheckpointSummary, - OrchestrationGetThreadActivitiesInput, - OrchestrationGetThreadActivitiesResult, OrchestrationProject, OrchestrationProjectShell, OrchestrationReadModel, @@ -20,6 +18,7 @@ import type { OrchestrationShellSnapshot, OrchestrationThread, OrchestrationThreadDetailSnapshot, + OrchestrationThreadDetailWindow, OrchestrationThreadShell, ProjectId, ThreadId, @@ -194,9 +193,16 @@ export interface ProjectionSnapshotQueryShape { * sequence in one consistent transaction, so the returned `snapshotSequence` * exactly matches the state reflected in `thread` (no interleaving projector * update between the two reads). + * + * When `window` is provided, the thread's messages, activities, proposed + * plans, and checkpoints are bounded to a page of recent turns and the + * response carries `page` metadata (see `OrchestrationThreadDetailWindow`). + * Without a window the full thread is returned with no `page` field — + * pagination is strictly opt-in. */ readonly getThreadDetailSnapshot: ( threadId: ThreadId, + window?: OrchestrationThreadDetailWindow, ) => Effect.Effect, ProjectionRepositoryError>; /** @@ -205,9 +211,6 @@ export interface ProjectionSnapshotQueryShape { * sequence or unsequenced activity cursor, ascending, plus whether older ones * remain. */ - readonly getThreadActivitiesPage: ( - input: OrchestrationGetThreadActivitiesInput, - ) => Effect.Effect; /** * Read a thread's lifecycle markers regardless of its deleted/archived diff --git a/apps/server/src/orchestration/commandReadModel.test.ts b/apps/server/src/orchestration/commandReadModel.test.ts index 575266b51a34..5f1853325ea4 100644 --- a/apps/server/src/orchestration/commandReadModel.test.ts +++ b/apps/server/src/orchestration/commandReadModel.test.ts @@ -44,7 +44,6 @@ function makeThread( settledAt: null, snoozedUntil: null, snoozedAt: null, - hasMoreActivities: false, latestTurn: null, messages: [], queuedMessages: [], diff --git a/apps/server/src/orchestration/http.ts b/apps/server/src/orchestration/http.ts index 0ce742dadbe3..ead58a1b9679 100644 --- a/apps/server/src/orchestration/http.ts +++ b/apps/server/src/orchestration/http.ts @@ -92,7 +92,17 @@ export const orchestrationHttpApiLayer = HttpApiBuilder.group( // the only path that heals dropped ACP updates). yield* grokTranscriptResync.resyncThread(args.params.threadId); const snapshot = yield* projectionSnapshotQuery - .getThreadDetailSnapshot(args.params.threadId) + .getThreadDetailSnapshot( + args.params.threadId, + args.payload.turnLimit === undefined + ? undefined + : { + turnLimit: args.payload.turnLimit, + ...(args.payload.beforeCursor !== undefined + ? { beforeCursor: args.payload.beforeCursor } + : {}), + }, + ) .pipe( Effect.catch((cause) => failEnvironmentInternal("orchestration_thread_snapshot_failed", cause), diff --git a/apps/server/src/orchestration/threadDetailCursor.test.ts b/apps/server/src/orchestration/threadDetailCursor.test.ts new file mode 100644 index 000000000000..434d83e86b18 --- /dev/null +++ b/apps/server/src/orchestration/threadDetailCursor.test.ts @@ -0,0 +1,44 @@ +import { ThreadId } from "@t3tools/contracts"; +import { describe, expect, it } from "@effect/vitest"; + +import { + decodeThreadDetailPageCursor, + encodeThreadDetailPageCursor, +} from "./threadDetailCursor.ts"; + +describe("threadDetailCursor", () => { + it("round-trips a cursor", () => { + const cursor = { + threadId: ThreadId.make("thread-1"), + beforeAnchorAt: "2026-08-01T00:00:00.000Z", + beforeTurnId: "turn-9", + }; + expect(decodeThreadDetailPageCursor(encodeThreadDetailPageCursor(cursor))).toEqual(cursor); + }); + + it("round-trips empty boundary values", () => { + // The anchor is COALESCE(requested_at, started_at, '') and the turn key + // is COALESCE(turn_id, ''), so a server-minted cursor can legitimately + // carry empty strings; rejecting them would degrade a valid cursor to a + // first-page request that repeats recent history (review finding). + const cursor = { + threadId: ThreadId.make("thread-1"), + beforeAnchorAt: "", + beforeTurnId: "", + }; + expect(decodeThreadDetailPageCursor(encodeThreadDetailPageCursor(cursor))).toEqual(cursor); + }); + + it("rejects malformed input", () => { + expect(decodeThreadDetailPageCursor("not-base64-json")).toBeNull(); + expect(decodeThreadDetailPageCursor(Buffer.from("[]").toString("base64url"))).toBeNull(); + expect( + decodeThreadDetailPageCursor(Buffer.from(JSON.stringify({ t: "" })).toString("base64url")), + ).toBeNull(); + expect( + decodeThreadDetailPageCursor( + Buffer.from(JSON.stringify({ t: "thread-1", a: 5, i: "x" })).toString("base64url"), + ), + ).toBeNull(); + }); +}); diff --git a/apps/server/src/orchestration/threadDetailCursor.ts b/apps/server/src/orchestration/threadDetailCursor.ts new file mode 100644 index 000000000000..a7dcf231ee60 --- /dev/null +++ b/apps/server/src/orchestration/threadDetailCursor.ts @@ -0,0 +1,62 @@ +import type { ThreadId } from "@t3tools/contracts"; + +/** + * Opaque, exclusive cursor for windowed thread detail reads. Encodes the thread + * id and the keyset boundary of an already-delivered page: the boundary turn's + * anchor timestamp (`COALESCE(requested_at, started_at, '')`) and turn id. + * Passing it back requests the adjacent disjoint slice of strictly older turns + * under `(anchor, turn_id)` ordering. + * + * The boundary is deliberately NOT a `projection_turns.row_id`: row ids are + * rewritten by the revert projector (delete + re-upsert) and by projection + * rebuilds, which would silently invalidate every persisted cursor with no + * event emitted. The (anchor, turnId) pair is derived from event content, so + * cursors survive both and no client-side refresh machinery is needed. The + * anchor doubles as the time bound for rows with no turn linkage (straggler + * user messages, turnless activities). The thread id is embedded so a cursor + * can never be replayed against a different thread. Clients must treat the + * string as opaque. + */ +export interface ThreadDetailPageCursor { + readonly threadId: ThreadId; + readonly beforeAnchorAt: string; + /** Boundary turn id; "" for the rare turn row with a null turn_id. */ + readonly beforeTurnId: string; +} + +export function encodeThreadDetailPageCursor(cursor: ThreadDetailPageCursor): string { + return Buffer.from( + JSON.stringify({ t: cursor.threadId, a: cursor.beforeAnchorAt, i: cursor.beforeTurnId }), + ).toString("base64url"); +} + +/** + * Returns null for anything that is not a well-formed cursor. Callers degrade + * a malformed or foreign-thread cursor to a first-page request. + */ +export function decodeThreadDetailPageCursor(encoded: string): ThreadDetailPageCursor | null { + let parsed: unknown; + try { + parsed = JSON.parse(Buffer.from(encoded, "base64url").toString("utf8")); + } catch { + return null; + } + if (parsed === null || typeof parsed !== "object") { + return null; + } + const record = parsed as Record; + if (typeof record.t !== "string" || record.t.length === 0) { + return null; + } + // Empty strings are valid boundary values, not malformed input: the anchor + // is COALESCE(requested_at, started_at, ''), so a boundary turn with no + // timestamps encodes a: "" (and sorts before every real anchor, correctly + // ending the walk); the turn key is "" for a null turn_id. + if (typeof record.a !== "string") { + return null; + } + if (typeof record.i !== "string") { + return null; + } + return { threadId: record.t as ThreadId, beforeAnchorAt: record.a, beforeTurnId: record.i }; +} diff --git a/apps/server/src/persistence/MigrationBootstrap.ts b/apps/server/src/persistence/MigrationBootstrap.ts index 9dc85c5e389e..7818ae1554d2 100644 --- a/apps/server/src/persistence/MigrationBootstrap.ts +++ b/apps/server/src/persistence/MigrationBootstrap.ts @@ -5,6 +5,7 @@ import { MigrationError } from "effect/unstable/sql/Migrator"; import { forkMigrationTable } from "./ForkMigrations.ts"; import Migration0035 from "./Migrations/035_ProjectionThreadTitleRegeneration.ts"; import Migration0036 from "./Migrations/036_ProjectionThreadsPinned.ts"; +import Migration0037 from "./Migrations/037_ProjectionTurnsKeysetIndex.ts"; export const upstreamMigrationTable = "effect_sql_migrations"; export const legacyMigrationBackupTable = "effect_sql_migrations_backup_v1"; @@ -49,6 +50,7 @@ const upstreamNames = new Map([ [34, "ProjectionThreadsSnoozed"], [35, "ProjectionThreadTitleRegeneration"], [36, "ProjectionThreadsPinned"], + [37, "ProjectionTurnsKeysetIndex"], ]); const knownForkNames = new Map([ @@ -188,6 +190,7 @@ const bootstrapLegacyLedger = Effect.fn("MigrationBootstrap.bootstrapLegacyLedge if (tail.length > 0) { yield* Migration0035; yield* Migration0036; + yield* Migration0037; } const forkNames = new Set(legacyRows.map(({ name }) => name)); @@ -231,7 +234,7 @@ const bootstrapLegacyLedger = Effect.fn("MigrationBootstrap.bootstrapLegacyLedge yield* sql`INSERT INTO ${sql(upstreamMigrationTable)} ${sql.insert(copiedUpstreamRows)}`; } if (tail.length > 0) { - const reconciledRows = [35, 36] + const reconciledRows = [35, 36, 37] .filter((migration_id) => migration_id > canonicalPrefix) .map((migration_id) => ({ migration_id, name: upstreamNames.get(migration_id)! })); if (reconciledRows.length > 0) { diff --git a/apps/server/src/persistence/MigrationNamespaces.test.ts b/apps/server/src/persistence/MigrationNamespaces.test.ts index a0a3ca41c3c6..a82027865de0 100644 --- a/apps/server/src/persistence/MigrationNamespaces.test.ts +++ b/apps/server/src/persistence/MigrationNamespaces.test.ts @@ -8,8 +8,8 @@ describe("migration namespaces", () => { it("keeps upstream and fork manifests in independent ledgers", () => { assert.notEqual(upstreamMigrationTable, forkMigrationTable); assert.deepStrictEqual(migrationManifest.slice(-2), [ - [35, "ProjectionThreadTitleRegeneration"], [36, "ProjectionThreadsPinned"], + [37, "ProjectionTurnsKeysetIndex"], ]); assert.deepStrictEqual(forkMigrationManifest, [ [1, "ProjectionQueuedMessages"], diff --git a/apps/server/src/persistence/Migrations.ts b/apps/server/src/persistence/Migrations.ts index 04c547c0488d..1f12cb89361c 100644 --- a/apps/server/src/persistence/Migrations.ts +++ b/apps/server/src/persistence/Migrations.ts @@ -52,6 +52,7 @@ import Migration0033 from "./Migrations/033_ProjectionThreadsSettled.ts"; import Migration0034 from "./Migrations/034_ProjectionThreadsSnoozed.ts"; import Migration0035 from "./Migrations/035_ProjectionThreadTitleRegeneration.ts"; import Migration0036 from "./Migrations/036_ProjectionThreadsPinned.ts"; +import Migration0037 from "./Migrations/037_ProjectionTurnsKeysetIndex.ts"; /** * Migration loader with all migrations defined inline. @@ -100,6 +101,7 @@ export const migrationEntries = [ [34, "ProjectionThreadsSnoozed", Migration0034], [35, "ProjectionThreadTitleRegeneration", Migration0035], [36, "ProjectionThreadsPinned", Migration0036], + [37, "ProjectionTurnsKeysetIndex", Migration0037], ] as const; export const migrationManifest = migrationEntries.map(([id, name]) => [id, name] as const); diff --git a/apps/server/src/persistence/Migrations/037_ProjectionTurnsKeysetIndex.ts b/apps/server/src/persistence/Migrations/037_ProjectionTurnsKeysetIndex.ts new file mode 100644 index 000000000000..6b1ee7c03043 --- /dev/null +++ b/apps/server/src/persistence/Migrations/037_ProjectionTurnsKeysetIndex.ts @@ -0,0 +1,17 @@ +import * as Effect from "effect/Effect"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +/** + * Composite index for windowed thread detail reads. Pagination orders turns by + * the stable keyset (requested_at, turn_id); the pre-existing + * (thread_id, requested_at) index cannot serve the tiebreak order, forcing a + * temp B-tree over all of a thread's turns before the page LIMIT applies. + * With this index the candidates scan is genuinely bounded by the page size. + */ +export default Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + yield* sql` + CREATE INDEX IF NOT EXISTS idx_projection_turns_thread_keyset + ON projection_turns(thread_id, requested_at, turn_id) + `; +}); diff --git a/apps/server/src/persistence/Migrations/039_040_RepairRestackedProjectionThreads.test.ts b/apps/server/src/persistence/Migrations/039_040_RepairRestackedProjectionThreads.test.ts index 934eaaca79fd..ba6c1ac8015f 100644 --- a/apps/server/src/persistence/Migrations/039_040_RepairRestackedProjectionThreads.test.ts +++ b/apps/server/src/persistence/Migrations/039_040_RepairRestackedProjectionThreads.test.ts @@ -83,8 +83,8 @@ layer("b18 desktop migration namespace repair", (it) => { readonly name: string; }>`SELECT migration_id, name FROM ${sql(upstreamMigrationTable)} ORDER BY migration_id`; assert.deepStrictEqual(upstreamMigrations.slice(-2), [ - { migration_id: 35, name: "ProjectionThreadTitleRegeneration" }, { migration_id: 36, name: "ProjectionThreadsPinned" }, + { migration_id: 37, name: "ProjectionTurnsKeysetIndex" }, ]); const forkMigrations = yield* sql<{ diff --git a/apps/server/src/persistence/Migrations/ForkMigrationNamespaceRepaired.test.ts b/apps/server/src/persistence/Migrations/ForkMigrationNamespaceRepaired.test.ts index 8fda094dd329..aa6578076155 100644 --- a/apps/server/src/persistence/Migrations/ForkMigrationNamespaceRepaired.test.ts +++ b/apps/server/src/persistence/Migrations/ForkMigrationNamespaceRepaired.test.ts @@ -39,8 +39,8 @@ layer("fork migration namespace for a repaired database", (it) => { SELECT migration_id, name FROM ${sql(legacyMigrationBackupTable)} ORDER BY migration_id `; assert.deepStrictEqual(upstream.slice(-2), [ - { migration_id: 35, name: "ProjectionThreadTitleRegeneration" }, { migration_id: 36, name: "ProjectionThreadsPinned" }, + { migration_id: 37, name: "ProjectionTurnsKeysetIndex" }, ]); assert.deepStrictEqual(fork, [ { migration_id: 1, name: "ProjectionQueuedMessages" }, diff --git a/apps/server/src/persistence/Migrations/ForkMigrationNamespaceSmart.test.ts b/apps/server/src/persistence/Migrations/ForkMigrationNamespaceSmart.test.ts index d92a4ac0ec1e..03e4c54ee42c 100644 --- a/apps/server/src/persistence/Migrations/ForkMigrationNamespaceSmart.test.ts +++ b/apps/server/src/persistence/Migrations/ForkMigrationNamespaceSmart.test.ts @@ -72,8 +72,8 @@ layer("smart migration namespace repair", (it) => { SELECT migration_id, name FROM ${sql(upstreamMigrationTable)} ORDER BY migration_id `; assert.deepStrictEqual(upstream.slice(-2), [ - { migration_id: 35, name: "ProjectionThreadTitleRegeneration" }, { migration_id: 36, name: "ProjectionThreadsPinned" }, + { migration_id: 37, name: "ProjectionTurnsKeysetIndex" }, ]); const fork = yield* sql` SELECT migration_id, name FROM ${sql(forkMigrationTable)} ORDER BY migration_id diff --git a/apps/server/src/persistence/Migrations/ForkMigrationNamespaceT3vm.test.ts b/apps/server/src/persistence/Migrations/ForkMigrationNamespaceT3vm.test.ts index 42b8f4d63e80..16c9e58b1ec1 100644 --- a/apps/server/src/persistence/Migrations/ForkMigrationNamespaceT3vm.test.ts +++ b/apps/server/src/persistence/Migrations/ForkMigrationNamespaceT3vm.test.ts @@ -29,10 +29,10 @@ layer("t3vm migration namespace repair", (it) => { SELECT migration_id, name FROM ${sql(upstreamMigrationTable)} ORDER BY migration_id `; assert.deepStrictEqual(upstream.slice(-4), [ - { migration_id: 33, name: "ProjectionThreadsSettled" }, { migration_id: 34, name: "ProjectionThreadsSnoozed" }, { migration_id: 35, name: "ProjectionThreadTitleRegeneration" }, { migration_id: 36, name: "ProjectionThreadsPinned" }, + { migration_id: 37, name: "ProjectionTurnsKeysetIndex" }, ]); const fork = yield* sql` SELECT migration_id, name FROM ${sql(forkMigrationTable)} ORDER BY migration_id diff --git a/apps/server/src/process/externalLauncher.test.ts b/apps/server/src/process/externalLauncher.test.ts index 43ca40e9c7c8..36ef82643280 100644 --- a/apps/server/src/process/externalLauncher.test.ts +++ b/apps/server/src/process/externalLauncher.test.ts @@ -7,6 +7,7 @@ import * as Layer from "effect/Layer"; import * as Path from "effect/Path"; import * as Sink from "effect/Sink"; import * as Stream from "effect/Stream"; +import * as TestClock from "effect/testing/TestClock"; import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; @@ -155,6 +156,66 @@ it.effect("discovers editors through the service API", () => }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), ); +it.effect("memoizes editor discovery and refreshes after the cache window", () => { + let statCalls = 0; + const fileInfo = { type: "File" } as FileSystem.File.Info; + const launcherLayer = ExternalLauncher.layer.pipe( + Layer.provide( + Layer.mergeAll( + FileSystem.layerNoop({ + stat: () => + Effect.sync(() => { + statCalls += 1; + return fileInfo; + }), + }), + Path.layer, + Layer.succeed( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make(() => Effect.sync(() => makeMockDetachedHandle())), + ), + ), + ), + ); + + return Effect.gen(function* () { + const launcher = yield* ExternalLauncher.ExternalLauncher; + + const first = yield* launcher.resolveAvailableEditors(); + assert.equal(first.includes("vscode"), true); + const statCallsAfterFirstScan = statCalls; + assert.isAbove(statCallsAfterFirstScan, 0); + + // Past the shared command-resolution cache TTL (30s) but within the + // discovery cache window: the memoized set is reused without any scan. + yield* TestClock.adjust("31 seconds"); + const second = yield* launcher.resolveAvailableEditors(); + assert.deepEqual([...second], [...first]); + assert.equal(statCalls, statCallsAfterFirstScan); + + // Past the discovery cache window the next call rescans. + yield* TestClock.adjust("30 seconds"); + yield* launcher.resolveAvailableEditors(); + assert.isAbove(statCalls, statCallsAfterFirstScan); + }).pipe( + Effect.provide( + Layer.mergeAll( + launcherLayer, + Layer.succeed(HostProcessPlatform, "win32"), + ConfigProvider.layer( + ConfigProvider.fromEnv({ + env: { + PATH: "C:\\t3-editor-discovery-cache-test", + PATHEXT: ".COM;.EXE;.BAT;.CMD", + }, + }), + ), + TestClock.layer(), + ), + ), + ); +}); + it.effect("rejects unknown editors through the service API", () => Effect.gen(function* () { const launcher = yield* ExternalLauncher.ExternalLauncher; diff --git a/apps/server/src/process/externalLauncher.ts b/apps/server/src/process/externalLauncher.ts index 9c2f0e417d3d..2cac42f0fec6 100644 --- a/apps/server/src/process/externalLauncher.ts +++ b/apps/server/src/process/externalLauncher.ts @@ -298,6 +298,12 @@ const resolveAvailableEditors = Effect.fn("externalLauncher.resolveAvailableEdit return yield* buildAvailableEditors(platform, env); }); +// Editor discovery walks PATH for every known editor and runs for every +// client connect (the server config embeds the available editors). Memoize +// the discovered set for a bounded window so repeat connects skip even the +// per-command cache lookups in @t3tools/shared/shell. +const EDITOR_DISCOVERY_CACHE_TTL = "60 seconds"; + /** * ExternalLauncher - Service tag for browser/editor launch operations. */ @@ -443,8 +449,13 @@ export const make = Effect.gen(function* () { Effect.provideService(Path.Path, path), ); + const cachedAvailableEditors = yield* Effect.cachedWithTTL( + provideCommandResolutionServices(resolveAvailableEditors()), + EDITOR_DISCOVERY_CACHE_TTL, + ); + return ExternalLauncher.of({ - resolveAvailableEditors: () => provideCommandResolutionServices(resolveAvailableEditors()), + resolveAvailableEditors: () => cachedAvailableEditors, launchBrowser: (target) => launchBrowser(target).pipe( Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), diff --git a/apps/server/src/project/ProjectLifecycleScriptRunner.test.ts b/apps/server/src/project/ProjectLifecycleScriptRunner.test.ts index 398270a2e17d..0897a14d3a5e 100644 --- a/apps/server/src/project/ProjectLifecycleScriptRunner.test.ts +++ b/apps/server/src/project/ProjectLifecycleScriptRunner.test.ts @@ -41,7 +41,6 @@ const makeProjectionSnapshotQueryLayer = ( ) => Layer.succeed(ProjectionSnapshotQuery.ProjectionSnapshotQuery, { getCommandReadModel: () => Effect.die("unused"), - getThreadActivitiesPage: () => Effect.die("unused"), getSnapshot: () => Effect.die("unused"), getShellSnapshot: () => Effect.succeed({ diff --git a/apps/server/src/project/ProjectSetupScriptRunner.test.ts b/apps/server/src/project/ProjectSetupScriptRunner.test.ts index 32325d9d1947..494c1b74f80e 100644 --- a/apps/server/src/project/ProjectSetupScriptRunner.test.ts +++ b/apps/server/src/project/ProjectSetupScriptRunner.test.ts @@ -27,7 +27,6 @@ const makeProject = (scripts: OrchestrationProject["scripts"]): OrchestrationPro const makeProjectionSnapshotQueryLayer = (project: OrchestrationProject) => Layer.succeed(ProjectionSnapshotQuery.ProjectionSnapshotQuery, { getCommandReadModel: () => Effect.die("unused"), - getThreadActivitiesPage: () => Effect.die("unused"), getSnapshot: () => Effect.die("unused"), getShellSnapshot: () => Effect.die("unused"), getArchivedShellSnapshot: () => Effect.die("unused"), diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts index e69d51c17471..22422b270afc 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts @@ -1490,7 +1490,68 @@ describe("ClaudeAdapterLive", () => { ); }); - it.effect("interruptTurn stops every live task before interrupting the turn", () => { + it.effect("treats aborted_tools results as interrupted and hides ede_diagnostic errors", () => { + const harness = makeHarness(); + return Effect.gen(function* () { + const adapter = yield* ClaudeAdapter; + + const runtimeEventsFiber = yield* Stream.take(adapter.streamEvents, 6).pipe( + Stream.runCollect, + Effect.forkChild, + ); + + const session = yield* adapter.startSession({ + threadId: THREAD_ID, + provider: ProviderDriverKind.make("claudeAgent"), + runtimeMode: "full-access", + }); + + const turn = yield* adapter.sendTurn({ + threadId: session.threadId, + input: "hello", + attachments: [], + }); + + // Exact shape the CLI emits when Stop lands mid-tool-call: is_error + // is true and the only error is internal diagnostic telemetry. + harness.query.emit({ + type: "result", + subtype: "error_during_execution", + is_error: true, + errors: ["[ede_diagnostic] result_type=user last_content_type=n/a stop_reason=tool_use"], + stop_reason: "tool_use", + terminal_reason: "aborted_tools", + session_id: "sdk-session-abort-tools", + uuid: "result-abort-tools", + } as unknown as SDKMessage); + + const runtimeEvents = Array.from(yield* Fiber.join(runtimeEventsFiber)); + assert.deepEqual( + runtimeEvents.map((event) => event.type), + [ + "session.started", + "session.configured", + "session.state.changed", + "turn.started", + "thread.started", + "turn.completed", + ], + ); + + const turnCompleted = runtimeEvents[runtimeEvents.length - 1]; + assert.equal(turnCompleted?.type, "turn.completed"); + if (turnCompleted?.type === "turn.completed") { + assert.equal(String(turnCompleted.turnId), String(turn.turnId)); + assert.equal(turnCompleted.payload.state, "interrupted"); + assert.equal(turnCompleted.payload.errorMessage, undefined); + } + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(harness.layer), + ); + }); + + it.effect("interruptTurn settles every acknowledged live task before interrupting", () => { const harness = makeHarness(); return Effect.gen(function* () { const adapter = yield* ClaudeAdapter; @@ -1547,11 +1608,28 @@ describe("ClaudeAdapterLive", () => { yield* Fiber.join(taskEventsFiber); + const stoppedTaskEventFiber = yield* adapter.streamEvents.pipe( + Stream.filter((event) => event.type === "task.completed"), + Stream.take(1), + Stream.runCollect, + Effect.forkChild, + ); yield* adapter.interruptTurn(session.threadId); // Only the still-live task is stopped; interrupt always fires after. assert.deepEqual(harness.query.stopTaskCalls, ["task-live"]); assert.equal(harness.query.interruptCalls.length, 1); + + const stoppedTaskEvents = Array.from(yield* Fiber.join(stoppedTaskEventFiber)); + assert.equal(stoppedTaskEvents.length, 1); + const stoppedTaskEvent = stoppedTaskEvents[0]; + assert.equal(stoppedTaskEvent?.type, "task.completed"); + if (stoppedTaskEvent?.type === "task.completed") { + assert.equal(String(stoppedTaskEvent.payload.taskId), "task-live"); + assert.equal(stoppedTaskEvent.payload.status, "stopped"); + assert.equal(stoppedTaskEvent.payload.taskType, "local_agent"); + assert.equal(stoppedTaskEvent.payload.title, "Agent A"); + } }).pipe( Effect.provideService(Random.Random, makeDeterministicRandomService()), Effect.provide(harness.layer), @@ -2061,6 +2139,24 @@ describe("ClaudeAdapterLive", () => { session_id: "session", uuid: "roster", }, + { + type: "system", + subtype: "vcs_state_changed", + kind: "push", + cwd: "/tmp/worktree", + session_id: "session", + uuid: "vcs", + }, + { + type: "system", + subtype: "code_change_published", + provider: "github", + url: "https://github.com/pingdotgg/t3code/pull/1", + repo: "pingdotgg/t3code", + identifier: "1", + session_id: "session", + uuid: "ccp", + }, { type: "system", subtype: "task_updated", diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.ts b/apps/server/src/provider/Layers/ClaudeAdapter.ts index e0c950062a5c..9520743fb811 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.ts @@ -65,6 +65,7 @@ import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; import * as FileSystem from "effect/FileSystem"; import * as Fiber from "effect/Fiber"; +import * as Option from "effect/Option"; import * as Path from "effect/Path"; import * as Queue from "effect/Queue"; import * as Ref from "effect/Ref"; @@ -350,7 +351,29 @@ function resultErrorsText(result: SDKResultMessage): string { : ""; } +/** + * First user-facing error from a non-success result. "[ede_diagnostic] ..." + * entries are CLI-internal telemetry (the CLI hides them from its own UI too), + * so they must never become the error banner. + */ +function resultUserFacingError(result: SDKResultMessage): string | undefined { + if (result.subtype === "success" || !Array.isArray(result.errors)) { + return undefined; + } + return result.errors.find((error) => !error.startsWith("[ede_diagnostic]")); +} + function isInterruptedResult(result: SDKResultMessage): boolean { + // The CLI stamps user aborts explicitly: interrupting mid-tool-call yields + // "aborted_tools" (with an internal "[ede_diagnostic] ..." error and + // is_error: true), interrupting mid-stream yields "aborted_streaming". + if ( + result.terminal_reason === "aborted_tools" || + result.terminal_reason === "aborted_streaming" + ) { + return true; + } + const errors = resultErrorsText(result); if (errors.includes("interrupt")) { return true; @@ -2922,7 +2945,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( } const status = turnStatusFromResult(message); - const errorMessage = message.subtype === "success" ? undefined : message.errors[0]; + const errorMessage = resultUserFacingError(message); if (status === "failed") { yield* emitRuntimeError(context, errorMessage ?? "Claude turn failed."); @@ -3037,9 +3060,15 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( // error rows in client work logs. `background_tasks_changed` is a roster // snapshot ({tasks: [...]}) — the task_* lifecycle events carry the // authoritative per-agent data and the typed background_tasks control - // request is the reconciliation source. - if ((message.subtype as string) === "background_tasks_changed") { - return; + // request is the reconciliation source. `vcs_state_changed` + // ({kind: commit|push|rebase}) and `code_change_published` + // ({provider, url, repo}) are informational CLI notices; the work log + // already shows the underlying git/gh tool calls. + switch (message.subtype as string) { + case "background_tasks_changed": + case "vcs_state_changed": + case "code_change_published": + return; } switch (message.subtype) { @@ -4427,11 +4456,40 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( yield* Effect.forEach( liveIds, (taskId) => - Effect.tryPromise({ - // Invoke through the query object: SDK methods rely on `this`. - try: () => context.query.stopTask!(taskId), - catch: () => undefined, - }).pipe(Effect.timeoutOption("3 seconds"), Effect.ignore), + Effect.gen(function* () { + const stopAcknowledged = yield* Effect.tryPromise({ + // Invoke through the query object: SDK methods rely on `this`. + try: () => context.query.stopTask!(taskId), + catch: () => undefined, + }).pipe( + Effect.timeoutOption("3 seconds"), + Effect.orElseSucceed(() => Option.none()), + ); + if (Option.isNone(stopAcknowledged) || !context.liveTaskIds.delete(taskId)) { + return; + } + + // stopTask only acknowledges the control request. Its separate + // task_notification can lose the race with interrupt(), so make + // the acknowledged stop authoritative for the durable UI state. + const stamp = yield* makeEventStamp(); + yield* offerRuntimeEvent({ + type: "task.completed", + eventId: stamp.eventId, + provider: PROVIDER, + createdAt: stamp.createdAt, + threadId: context.session.threadId, + ...(context.turnState + ? { turnId: asCanonicalTurnId(context.turnState.turnId) } + : {}), + payload: { + taskId: RuntimeTaskId.make(taskId), + status: "stopped", + ...taskLinkageFor(context.taskAgents, taskId), + }, + providerRefs: nativeProviderRefs(context), + }); + }).pipe(Effect.ignore), { concurrency: 8, discard: true }, ).pipe(Effect.timeoutOption("10 seconds"), Effect.ignore); } diff --git a/apps/server/src/provider/Layers/ProviderSessionReaper.test.ts b/apps/server/src/provider/Layers/ProviderSessionReaper.test.ts index ab04fc032219..4fec5e6725c7 100644 --- a/apps/server/src/provider/Layers/ProviderSessionReaper.test.ts +++ b/apps/server/src/provider/Layers/ProviderSessionReaper.test.ts @@ -231,7 +231,6 @@ describe("ProviderSessionReaper", () => { getThreadDetailById: () => Effect.die("unused"), getThreadDetailSnapshot: () => Effect.die("unused"), searchThreads: () => Effect.succeed({ matches: [] }), - getThreadActivitiesPage: () => Effect.die("unused"), getThreadLifecycleById: () => Effect.die("unused"), }), ), diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index 509a44453aa3..c49e07b50eed 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -518,6 +518,9 @@ const buildAppUnderTest = (options?: { ...options?.layers?.vcsDriverRegistry, }); const gitVcsDriverLayer = Layer.mock(GitVcsDriver.GitVcsDriver)({ + // Default: assume origin exists so startFromOrigin bootstrap paths that + // only mock createWorktree keep working. Individual tests override. + remoteExists: () => Effect.succeed(true), ...options?.layers?.gitVcsDriver, }); const gitManagerLayer = Layer.mock(GitManager.GitManager)({ @@ -810,7 +813,6 @@ const buildAppUnderTest = (options?: { getThreadDetailById: () => Effect.succeed(Option.none()), getThreadDetailSnapshot: () => Effect.succeed(Option.none()), getThreadLifecycleById: () => Effect.succeed(Option.none()), - getThreadActivitiesPage: () => Effect.die("unused"), getCounts: () => Effect.succeed({ projectCount: 0, threadCount: 0 }), getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()), getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.none()), @@ -8248,6 +8250,122 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); + it.effect( + "falls back to the local base branch when startFromOrigin is set but no origin remote exists", + () => + Effect.gen(function* () { + const dispatchedCommands: Array = []; + const remoteExists = vi.fn( + (_: Parameters[0]) => + Effect.succeed(false), + ); + const fetchRemote = vi.fn( + (_: Parameters[0]) => Effect.void, + ); + const resolveRemoteTrackingCommit = vi.fn( + (_: Parameters[0]) => + Effect.succeed({ + commitSha: "0123456789abcdef0123456789abcdef01234567", + remoteRefName: "origin/main", + }), + ); + const createWorktree = vi.fn( + (_: Parameters[0]) => + Effect.succeed({ + worktree: { + refName: "t3code/bootstrap-refName", + path: "/tmp/bootstrap-worktree", + }, + }), + ); + + yield* buildAppUnderTest({ + layers: { + gitVcsDriver: { + remoteExists, + fetchRemote, + resolveRemoteTrackingCommit, + createWorktree, + }, + orchestrationEngine: { + dispatch: (command) => + Effect.sync(() => { + dispatchedCommands.push(command); + return { sequence: dispatchedCommands.length }; + }), + readEvents: () => Stream.empty, + }, + projectionSnapshotQuery: { + getThreadShellById: (threadId) => + Effect.succeed( + Option.some( + makeDefaultOrchestrationThreadShell({ + id: threadId, + branch: "t3code/bootstrap-refName", + worktreePath: "/tmp/bootstrap-worktree", + }), + ), + ), + }, + }, + }); + + const createdAt = "2026-01-01T00:00:00.000Z"; + const wsUrl = yield* getWsServerUrl("/ws"); + yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + client[ORCHESTRATION_WS_METHODS.dispatchCommand]({ + type: "thread.turn.start", + commandId: CommandId.make("cmd-bootstrap-turn-start-no-origin"), + threadId: ThreadId.make("thread-bootstrap-no-origin"), + message: { + messageId: MessageId.make("msg-bootstrap-no-origin"), + role: "user", + text: "hello", + attachments: [], + }, + modelSelection: defaultModelSelection, + runtimeMode: "full-access", + interactionMode: "default", + bootstrap: { + createThread: { + projectId: defaultProjectId, + title: "Bootstrap Thread", + modelSelection: defaultModelSelection, + runtimeMode: "full-access", + interactionMode: "default", + branch: "main", + worktreePath: null, + createdAt, + }, + prepareWorktree: { + projectCwd: "/tmp/project", + baseBranch: "main", + branch: "t3code/bootstrap-refName", + startFromOrigin: true, + }, + }, + createdAt, + }), + ), + ); + + assert.deepEqual(remoteExists.mock.calls[0]?.[0], { + cwd: "/tmp/project", + remoteName: "origin", + }); + assert.equal(fetchRemote.mock.calls.length, 0); + assert.equal(resolveRemoteTrackingCommit.mock.calls.length, 0); + assert.deepEqual(createWorktree.mock.calls[0]?.[0], { + cwd: "/tmp/project", + refName: "main", + newRefName: "t3code/bootstrap-refName", + baseRefName: "main", + path: null, + }); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + it.effect("records setup-script failures without aborting bootstrap turn start", () => Effect.gen(function* () { const dispatchedCommands: Array = []; diff --git a/apps/server/src/serverRuntimeStartup.test.ts b/apps/server/src/serverRuntimeStartup.test.ts index 6f470f393e10..61d10989c6b4 100644 --- a/apps/server/src/serverRuntimeStartup.test.ts +++ b/apps/server/src/serverRuntimeStartup.test.ts @@ -173,12 +173,11 @@ it.effect("launchStartupHeartbeat does not block the caller while counts are loa getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.none()), getThreadCheckpointContext: () => Effect.succeed(Option.none()), getFullThreadDiffContext: () => Effect.succeed(Option.none()), - getSessionStopContextById: () => Effect.succeed(Option.none()), getThreadShellById: () => Effect.succeed(Option.none()), + getSessionStopContextById: () => Effect.succeed(Option.none()), getThreadDetailById: () => Effect.succeed(Option.none()), getThreadDetailSnapshot: () => Effect.succeed(Option.none()), searchThreads: () => Effect.succeed({ matches: [] }), - getThreadActivitiesPage: () => Effect.die("unused"), getThreadLifecycleById: () => Effect.succeed(Option.none()), }), Effect.provideService(AnalyticsService.AnalyticsService, { @@ -240,12 +239,11 @@ it.effect("resolveAutoBootstrapWelcomeTargets returns existing project and threa getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.some(bootstrapThreadId)), getThreadCheckpointContext: () => Effect.succeed(Option.none()), getFullThreadDiffContext: () => Effect.succeed(Option.none()), - getSessionStopContextById: () => Effect.die("unused"), getThreadShellById: () => Effect.die("unused"), + getSessionStopContextById: () => Effect.succeed(Option.none()), getThreadDetailById: () => Effect.die("unused"), getThreadDetailSnapshot: () => Effect.die("unused"), searchThreads: () => Effect.succeed({ matches: [] }), - getThreadActivitiesPage: () => Effect.die("unused"), getThreadLifecycleById: () => Effect.die("unused"), }), Effect.provideService(OrchestrationEngine.OrchestrationEngineService, { @@ -288,12 +286,11 @@ it.effect("resolveAutoBootstrapWelcomeTargets creates a project and thread when getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.none()), getThreadCheckpointContext: () => Effect.succeed(Option.none()), getFullThreadDiffContext: () => Effect.succeed(Option.none()), - getSessionStopContextById: () => Effect.die("unused"), getThreadShellById: () => Effect.die("unused"), + getSessionStopContextById: () => Effect.succeed(Option.none()), getThreadDetailById: () => Effect.die("unused"), getThreadDetailSnapshot: () => Effect.die("unused"), searchThreads: () => Effect.succeed({ matches: [] }), - getThreadActivitiesPage: () => Effect.die("unused"), getThreadLifecycleById: () => Effect.die("unused"), }), Effect.provideService(OrchestrationEngine.OrchestrationEngineService, { @@ -342,12 +339,11 @@ it.effect("resolveAutoBootstrapWelcomeTargets preserves typed UUID generation fa getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.none()), getThreadCheckpointContext: () => Effect.succeed(Option.none()), getFullThreadDiffContext: () => Effect.succeed(Option.none()), - getSessionStopContextById: () => Effect.die("unused"), getThreadShellById: () => Effect.die("unused"), + getSessionStopContextById: () => Effect.succeed(Option.none()), getThreadDetailById: () => Effect.die("unused"), getThreadDetailSnapshot: () => Effect.die("unused"), searchThreads: () => Effect.succeed({ matches: [] }), - getThreadActivitiesPage: () => Effect.die("unused"), getThreadLifecycleById: () => Effect.die("unused"), }), Effect.provideService(OrchestrationEngine.OrchestrationEngineService, { diff --git a/apps/server/src/vcs/GitVcsDriver.ts b/apps/server/src/vcs/GitVcsDriver.ts index 32af3048435e..5a28b290ac13 100644 --- a/apps/server/src/vcs/GitVcsDriver.ts +++ b/apps/server/src/vcs/GitVcsDriver.ts @@ -170,6 +170,11 @@ export interface GitFetchRemoteInput { remoteName: string; } +export interface GitRemoteExistsInput { + cwd: string; + remoteName: string; +} + export interface GitResolveRemoteTrackingCommitInput { cwd: string; refName: string; @@ -245,6 +250,7 @@ export class GitVcsDriver extends Context.Service< readonly ensureRemote: (input: GitEnsureRemoteInput) => Effect.Effect; readonly resolvePrimaryRemoteName: (cwd: string) => Effect.Effect; readonly fetchRemote: (input: GitFetchRemoteInput) => Effect.Effect; + readonly remoteExists: (input: GitRemoteExistsInput) => Effect.Effect; readonly resolveRemoteTrackingCommit: ( input: GitResolveRemoteTrackingCommitInput, ) => Effect.Effect; diff --git a/apps/server/src/vcs/GitVcsDriverCore.ts b/apps/server/src/vcs/GitVcsDriverCore.ts index a89cd3e8dfe0..5a9082e8096f 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.ts @@ -1313,11 +1313,14 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* }, ).pipe(Effect.map((result) => result.exitCode === 0)); - const originRemoteExists = (cwd: string): Effect.Effect => - executeGit("GitVcsDriver.originRemoteExists", cwd, ["remote", "get-url", "origin"], { + const remoteExists: GitVcsDriver.GitVcsDriver["Service"]["remoteExists"] = (input) => + executeGit("GitVcsDriver.remoteExists", input.cwd, ["remote", "get-url", input.remoteName], { allowNonZeroExit: true, }).pipe(Effect.map((result) => result.exitCode === 0)); + const originRemoteExists = (cwd: string): Effect.Effect => + remoteExists({ cwd, remoteName: "origin" }); + const listRemoteNames = (cwd: string): Effect.Effect, GitCommandError> => runGitStdout("GitVcsDriver.listRemoteNames", cwd, ["remote"]).pipe( Effect.map(parseRemoteNamesInGitOrder), @@ -3208,6 +3211,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* ensureRemote: (input) => withListRefsInvalidation(input.cwd, ensureRemote(input)), resolvePrimaryRemoteName, fetchRemote: (input) => withListRefsInvalidation(input.cwd, fetchRemote(input)), + remoteExists, resolveRemoteTrackingCommit, fetchRemoteBranch: (input) => withListRefsInvalidation(input.cwd, fetchRemoteBranch(input)), fetchRemoteTrackingBranch: (input) => diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 8091e194658f..5f203afc85d9 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -34,7 +34,6 @@ import { OrchestrationGetFullThreadDiffError, OrchestrationGetSnapshotError, OrchestrationSearchThreadsError, - OrchestrationGetThreadActivitiesError, OrchestrationGetTurnDiffError, ORCHESTRATION_WS_METHODS, type ProjectId, @@ -1123,16 +1122,24 @@ const makeWsRpcLayer = ( : undefined; worktreeBaseRefName = undefined; } else if (prepareWorktree.startFromOrigin) { - yield* gitWorkflow.fetchRemote({ + // No origin remote: fall back to the local base branch instead of + // hanging on fetch/resolve of a missing remote. + const hasOrigin = yield* gitWorkflow.remoteExists({ cwd: prepareWorktree.projectCwd, remoteName: "origin", }); - const resolvedRemoteBase = yield* gitWorkflow.resolveRemoteTrackingCommit({ - cwd: prepareWorktree.projectCwd, - refName: prepareWorktree.baseBranch, - fallbackRemoteName: "origin", - }); - worktreeBaseRef = resolvedRemoteBase.commitSha; + if (hasOrigin) { + yield* gitWorkflow.fetchRemote({ + cwd: prepareWorktree.projectCwd, + remoteName: "origin", + }); + const resolvedRemoteBase = yield* gitWorkflow.resolveRemoteTrackingCommit({ + cwd: prepareWorktree.projectCwd, + refName: prepareWorktree.baseBranch, + fallbackRemoteName: "origin", + }); + worktreeBaseRef = resolvedRemoteBase.commitSha; + } } const worktree = yield* gitWorkflow.createWorktree({ cwd: prepareWorktree.projectCwd, @@ -1377,20 +1384,6 @@ const makeWsRpcLayer = ( ), { "rpc.aggregate": "orchestration" }, ), - [ORCHESTRATION_WS_METHODS.getThreadActivities]: (input) => - observeRpcEffect( - ORCHESTRATION_WS_METHODS.getThreadActivities, - projectionSnapshotQuery.getThreadActivitiesPage(input).pipe( - Effect.mapError( - (cause) => - new OrchestrationGetThreadActivitiesError({ - message: "Failed to load thread activities page", - cause, - }), - ), - ), - { "rpc.aggregate": "orchestration" }, - ), [ORCHESTRATION_WS_METHODS.getFullThreadDiff]: (input) => observeRpcEffect( ORCHESTRATION_WS_METHODS.getFullThreadDiff, @@ -1635,7 +1628,14 @@ const makeWsRpcLayer = ( } const snapshot = yield* projectionSnapshotQuery - .getThreadDetailSnapshot(input.threadId) + .getThreadDetailSnapshot( + input.threadId, + // Windowing the fallback snapshot is opt-in per subscription: + // clients that don't send turnLimit (including all + // pre-pagination clients) get the full thread, since they + // have no way to load older pages. + input.turnLimit === undefined ? undefined : { turnLimit: input.turnLimit }, + ) .pipe( Effect.mapError( (cause) => diff --git a/apps/web/src/components/BranchToolbar.logic.test.ts b/apps/web/src/components/BranchToolbar.logic.test.ts index ab198db86d2b..517675057634 100644 --- a/apps/web/src/components/BranchToolbar.logic.test.ts +++ b/apps/web/src/components/BranchToolbar.logic.test.ts @@ -17,6 +17,7 @@ import { resolvePreviousWorktreeLabel, resolvePreviousWorktreeSeed, shouldIncludeBranchPickerItem, + shouldShowComposerContextStrip, shouldShowEnvironmentIndicator, } from "./BranchToolbar.logic"; @@ -434,6 +435,38 @@ describe("shouldShowEnvironmentIndicator", () => { }); }); +describe("shouldShowComposerContextStrip", () => { + it("keeps the environment indicator visible for a non-Git project", () => { + expect( + shouldShowComposerContextStrip({ + hasActiveProject: true, + isGitRepo: false, + showEnvironmentIndicator: true, + }), + ).toBe(true); + }); + + it("hides the strip when a non-Git project has no environment indicator", () => { + expect( + shouldShowComposerContextStrip({ + hasActiveProject: true, + isGitRepo: false, + showEnvironmentIndicator: false, + }), + ).toBe(false); + }); + + it("shows Git controls without requiring an environment indicator", () => { + expect( + shouldShowComposerContextStrip({ + hasActiveProject: true, + isGitRepo: true, + showEnvironmentIndicator: false, + }), + ).toBe(true); + }); +}); + describe("resolveEffectiveEnvMode", () => { it("treats draft threads already attached to a worktree as current-checkout mode", () => { expect( diff --git a/apps/web/src/components/BranchToolbar.logic.ts b/apps/web/src/components/BranchToolbar.logic.ts index 443e56674a71..b09c6b6958a7 100644 --- a/apps/web/src/components/BranchToolbar.logic.ts +++ b/apps/web/src/components/BranchToolbar.logic.ts @@ -55,6 +55,14 @@ export function shouldShowEnvironmentIndicator(input: { return input.activeEnvironment !== null && !input.activeEnvironment.isPrimary; } +export function shouldShowComposerContextStrip(input: { + hasActiveProject: boolean; + isGitRepo: boolean; + showEnvironmentIndicator: boolean; +}): boolean { + return input.hasActiveProject && (input.isGitRepo || input.showEnvironmentIndicator); +} + export function resolveEnvModeLabel(mode: EnvMode): string { return mode === "worktree" ? "New worktree" : "Current checkout"; } diff --git a/apps/web/src/components/BranchToolbar.tsx b/apps/web/src/components/BranchToolbar.tsx index 773a3811fdfb..4876df165045 100644 --- a/apps/web/src/components/BranchToolbar.tsx +++ b/apps/web/src/components/BranchToolbar.tsx @@ -46,6 +46,7 @@ import { Separator } from "./ui/separator"; interface BranchToolbarProps { environmentId: EnvironmentId; threadId: ThreadId; + showGitControls: boolean; draftId?: DraftId; onWorkspaceTargetChange: (target: WorkspaceTarget) => void; effectiveEnvModeOverride?: EnvMode; @@ -319,6 +320,7 @@ function useLabelsOverflow(element: HTMLDivElement | null): boolean { export const BranchToolbar = memo(function BranchToolbar({ environmentId, threadId, + showGitControls, draftId, onWorkspaceTargetChange, effectiveEnvModeOverride, @@ -416,7 +418,7 @@ export const BranchToolbar = memo(function BranchToolbar({ data-compact={labelsOverflow ? "" : undefined} className="chat-composer-context-strip group/composer-context -mt-4 mx-auto flex w-[calc(100%-2.75rem)] max-w-[calc(48rem-2.75rem)] items-center gap-2 ps-1 pe-2 pt-5 pb-1" > - {isMobile ? ( + {isMobile && showGitControls ? ( - + {showGitControls ? ( + + ) : null} )} - + {showGitControls ? ( + + ) : null} )} - + {showGitControls ? ( + + ) : null} ); }); diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 4883779bc424..d3d3d9fe0bad 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -70,10 +70,6 @@ import { squashAtomCommandFailure, type AtomCommandResult, } from "@t3tools/client-runtime/state/runtime"; -import { - useOlderThreadActivities, - type OlderActivitiesCursor, -} from "@t3tools/client-runtime/state/older-thread-activities"; import * as Cause from "effect/Cause"; import { AsyncResult } from "effect/unstable/reactivity"; import { isTransportConnectionErrorMessage } from "@t3tools/client-runtime/errors"; @@ -239,9 +235,12 @@ import { primaryServerSettingsAtom, serverEnvironment, } from "../state/server"; -import { orchestrationEnvironment } from "../state/orchestration"; import { terminalEnvironment } from "../state/terminal"; -import { threadEnvironment } from "../state/threads"; +import { threadEnvironment, useEnvironmentThread } from "../state/threads"; +import { + requestOlderThreadTurns, + threadHasOlderTurns, +} from "@t3tools/client-runtime/state/threads"; import { vcsEnvironment } from "../state/vcs"; import { useEnvironments, usePrimaryEnvironment } from "../state/environments"; import { @@ -260,6 +259,7 @@ import { DraftHeroHeadline } from "./chat/DraftHeroHeadline"; import { ExpandedImageDialog } from "./chat/ExpandedImageDialog"; import { PullRequestThreadDialog } from "./PullRequestThreadDialog"; import { MessagesTimeline } from "./chat/MessagesTimeline"; +import { resolveTimelineIsAtEnd } from "./chat/MessagesTimeline.logic"; import { ChatHeader } from "./chat/ChatHeader"; import { PanelLayoutControls, RightPanelMaximizeControl } from "./chat/PanelLayoutControls"; import { type ExpandedImagePreview } from "./chat/ExpandedImagePreview"; @@ -268,6 +268,8 @@ import { resolveEffectiveEnvMode, resolveLocalCheckoutBranchMismatch, type WorkspaceTarget, + shouldShowComposerContextStrip, + shouldShowEnvironmentIndicator, } from "./BranchToolbar.logic"; import { getProviderStatusBannerKey, @@ -1280,7 +1282,25 @@ function ChatViewContent(props: ChatViewProps) { // Always resolve the pre-allocated route ref so draft routes can promote to a // live server thread without remounting (draft hero landing). const serverThread = useThread(routeThreadRef, { waitForShell: draftThread !== null }); - const serverThreadShell = useThreadShell(routeThreadRef); + const routeServerThreadShell = useThreadShell(routeKind === "server" ? routeThreadRef : null); + const serverThreadShell = routeServerThreadShell; + // Pagination window state for the routed server thread: drives the + // "load earlier turns" header when the loaded window has older history. + const routeThreadState = useEnvironmentThread( + routeKind === "server" ? routeThreadRef.environmentId : null, + routeKind === "server" ? routeThreadRef.threadId : null, + ); + const loadEarlierTurns = useMemo(() => { + if (routeKind !== "server" || !threadHasOlderTurns(routeThreadState)) { + return null; + } + return { + loading: routeThreadState.page._tag === "Some" && routeThreadState.page.value.loadingOlder, + onLoadEarlier: () => { + requestOlderThreadTurns(routeThreadRef.environmentId, routeThreadRef.threadId); + }, + }; + }, [routeKind, routeThreadRef, routeThreadState]); const markThreadVisited = useUiStateStore((store) => store.markThreadVisited); const settings = useEnvironmentSettings(environmentId); // New-thread defaults live in the primary environment's settings.json (the @@ -1896,6 +1916,14 @@ function ChatViewContent(props: ChatViewProps) { return envs; }, [activeProject, allProjects, projectGroupingSettings, primaryEnvironmentId, environmentById]); const hasMultipleEnvironments = logicalProjectEnvironments.length > 1; + const activeEnvironmentOption = + logicalProjectEnvironments.find( + (environment) => environment.environmentId === activeThread?.environmentId, + ) ?? null; + const showComposerEnvironmentIndicator = shouldShowEnvironmentIndicator({ + activeEnvironment: activeEnvironmentOption, + canPickEnvironment: hasMultipleEnvironments, + }); const openPullRequestDialog = useCallback( (reference?: string) => { @@ -2172,44 +2200,7 @@ function ChatViewContent(props: ChatViewProps) { const selectedProvider: ProviderDriverKind = lockedProvider ?? unlockedSelectedProvider; const phase = derivePhase(activeThread?.session ?? null); - // ── Older-history lazy-load ──────────────────────────────────────────────── - // The detail snapshot windows activities to the most recent page (the server - // sets `hasMoreActivities` when older ones exist); older pages are fetched on - // demand (infinite scroll-up) and prepended by the shared engine. Messages - // aren't windowed server-side, so this just back-fills the older tool - // activity. - const loadThreadActivities = useAtomCommand(orchestrationEnvironment.loadThreadActivities, { - reportFailure: false, - }); - const activeThreadEnvironmentIdForActivities = activeThread?.environmentId ?? null; - const activeThreadIdForActivities = activeThread?.id ?? null; - const loadOlderActivitiesPage = useCallback( - async (cursor: OlderActivitiesCursor) => { - if (activeThreadEnvironmentIdForActivities === null || activeThreadIdForActivities === null) { - return null; - } - const result = await loadThreadActivities({ - environmentId: activeThreadEnvironmentIdForActivities, - input: { threadId: activeThreadIdForActivities, ...cursor }, - }); - // Failures stay silent on web (the "Load older history" affordance itself - // is the retry surface); returning null keeps `hasMore` for the retry. - return result._tag === "Success" ? result.value : null; - }, - [activeThreadEnvironmentIdForActivities, activeThreadIdForActivities, loadThreadActivities], - ); - const { - mergedActivities: threadActivities, - hasMoreOlder: hasMoreOlderActivities, - loadingOlder: loadingOlderActivities, - progressVersion: olderHistoryCursorVersion, - loadOlder: loadOlderActivities, - } = useOlderThreadActivities({ - threadKey: activeThread ? `${activeThread.environmentId}\u0000${activeThread.id}` : null, - liveActivities: activeThread?.activities ?? EMPTY_ACTIVITIES, - hasMoreLiveActivities: activeThread?.hasMoreActivities ?? false, - loadPage: loadOlderActivitiesPage, - }); + const threadActivities = activeThread?.activities ?? EMPTY_ACTIVITIES; const workLogEntries = useMemo(() => deriveWorkLogEntries(threadActivities), [threadActivities]); // Native subagent fold: memoized by activity-list identity, shared by the @@ -2730,7 +2721,11 @@ function ChatViewContent(props: ChatViewProps) { terminalUiLaunchContext?.threadId === activeThreadId ? terminalUiLaunchContext : null; // Default true while loading to avoid toolbar flicker. const isGitRepo = gitStatusQuery.data?.isRepo ?? true; - const showComposerContextStrip = isGitRepo && activeProject !== null; + const showComposerContextStrip = shouldShowComposerContextStrip({ + hasActiveProject: activeProject !== null, + isGitRepo, + showEnvironmentIndicator: showComposerEnvironmentIndicator, + }); const initialDiffPanelGitScope = gitStatusQuery.data?.hasWorkingTreeChanges === true ? "unstaged" : "branch"; const diffPanelGitStatusResolutionKey = gitStatusQuery.data ? "resolved" : "pending"; @@ -3736,6 +3731,10 @@ function ChatViewContent(props: ChatViewProps) { new Debouncer(() => setShowScrollToBottom(true), { wait: 150 }), ); const timelineScrollModeRef = useRef("following-end"); + // State mirror of the follow mode refs. LegendList's maintainScrollAtEnd + // re-pins on its own (independent of the refs), so the timeline needs a + // render-visible flag to switch it off once the user scrolls away. + const [timelineLiveFollowEnabled, setTimelineLiveFollowEnabled] = useState(true); const pendingTimelineAnchorRef = useRef(null); const positionedTimelineAnchorRef = useRef(null); const settledTimelineAnchorRef = useRef(null); @@ -3752,6 +3751,7 @@ function ChatViewContent(props: ChatViewProps) { anchorUserScrollGenerationRef.current += 1; timelineScrollModeRef.current = "free-scrolling"; liveFollowUserScrollGenerationRef.current = null; + setTimelineLiveFollowEnabled(false); pendingTimelineAnchorRef.current = null; positionedTimelineAnchorRef.current = null; settledTimelineAnchorRef.current = null; @@ -3823,6 +3823,7 @@ function ChatViewContent(props: ChatViewProps) { isAtEndRef.current = true; timelineScrollModeRef.current = "following-end"; liveFollowUserScrollGenerationRef.current = anchorUserScrollGenerationRef.current; + setTimelineLiveFollowEnabled(true); pendingTimelineAnchorRef.current = null; activeTimelineAnchorIndexRef.current = null; showScrollDebouncer.current.cancel(); @@ -3833,37 +3834,120 @@ function ChatViewContent(props: ChatViewProps) { }, []); useEffect(() => { let removeListeners: (() => void) | null = null; - const frame = requestAnimationFrame(() => { - const scrollNode = legendListRef.current?.getScrollableNode(); - if (!scrollNode) { - return; - } - const handleManualNavigation = () => { - cancelTimelineLiveFollowForUserNavigationRef.current(); - }; - scrollNode.addEventListener("wheel", handleManualNavigation, { - passive: true, - }); - scrollNode.addEventListener("touchmove", handleManualNavigation, { - passive: true, - }); - scrollNode.addEventListener("pointerdown", handleManualNavigation, { - passive: true, + let frame: number | null = null; + const attach = (remainingAttempts: number) => { + frame = requestAnimationFrame(() => { + frame = null; + const scrollNode = legendListRef.current?.getScrollableNode(); + if (!scrollNode) { + // The list may not have mounted on the first frame after a thread + // switch — without a retry the opt-out listeners never attach and + // live-follow becomes impossible to escape for the whole thread. + if (remainingAttempts > 0) { + attach(remainingAttempts - 1); + } + return; + } + const handleManualNavigation = () => { + cancelTimelineLiveFollowForUserNavigationRef.current(); + }; + // The gestures below must only break follow when they can actually + // move the viewport away from the live edge. Follow now gates + // LegendList's maintainScrollAtEnd, so a spurious break while pinned + // at the end produces no scroll event, never re-arms, and streaming + // silently stops following. Underflowing content can't scroll at all, + // so nothing there should break follow. + const contentScrollsUp = () => timelineRealContentOverflowsViewport(); + // The follow re-arm band, not the strict flag: streaming growth makes + // isAtEnd flicker false for a frame before the follow scroll catches + // up, and a gesture landing in that window while still pinned would + // otherwise break follow with no scroll event left to re-arm it. + const viewportIsAwayFromEnd = () => + resolveTimelineIsAtEnd(legendListRef.current?.getState(), composerOverlayHeight) === + false; + // Only an upward wheel is a navigation intent; wheeling down while + // following either does nothing (at the end) or moves toward it. + const handleWheel = (event: WheelEvent) => { + if (event.deltaY < 0 && contentScrollsUp()) { + handleManualNavigation(); + } + }; + // Touch direction isn't observable here (touchmove fires on any + // finger motion, scrolling or not), so break only once the drag has + // actually carried the viewport out of the end band — an upward flick + // gets there within its first few events and later touchmoves break. + const handleTouchMove = () => { + if (viewportIsAwayFromEnd()) { + handleManualNavigation(); + } + }; + // Scrollbar drags produce no wheel/touch events; they are the only + // pointerdowns whose target is the scroll node itself rather than a + // message row. Content clicks break follow only away from the end + // (reading or selecting up there must hold position); clicking near + // the live edge keeps following. + const handlePointerDown = (event: PointerEvent) => { + if (event.target === scrollNode) { + if (contentScrollsUp()) { + handleManualNavigation(); + } + return; + } + if (viewportIsAwayFromEnd()) { + handleManualNavigation(); + } + }; + // Keyboard scrolling (PageUp/Home/ArrowUp) bypasses wheel and + // pointer events entirely; without this the timeline yanks back to + // the end on the next stream chunk. + const handleKeyDown = (event: KeyboardEvent) => { + switch (event.key) { + case "PageUp": + case "Home": + case "ArrowUp": + if (contentScrollsUp()) { + handleManualNavigation(); + } + break; + default: + break; + } + }; + scrollNode.addEventListener("wheel", handleWheel, { + passive: true, + }); + scrollNode.addEventListener("touchmove", handleTouchMove, { + passive: true, + }); + scrollNode.addEventListener("pointerdown", handlePointerDown, { + passive: true, + }); + scrollNode.addEventListener("keydown", handleKeyDown); + removeListeners = () => { + scrollNode.removeEventListener("wheel", handleWheel); + scrollNode.removeEventListener("touchmove", handleTouchMove); + scrollNode.removeEventListener("pointerdown", handlePointerDown); + scrollNode.removeEventListener("keydown", handleKeyDown); + }; }); - removeListeners = () => { - scrollNode.removeEventListener("wheel", handleManualNavigation); - scrollNode.removeEventListener("touchmove", handleManualNavigation); - scrollNode.removeEventListener("pointerdown", handleManualNavigation); - }; - }); + }; + attach(12); return () => { - cancelAnimationFrame(frame); + if (frame !== null) { + cancelAnimationFrame(frame); + } removeListeners?.(); }; - }, [activeThread?.id]); + }, [activeThread?.id, composerOverlayHeight, timelineRealContentOverflowsViewport]); const onTimelineAnchorReady = useCallback((messageId: MessageId, anchorIndex: number) => { + // Anchored-end space can be remeasured when the turn completes. Once the + // user has scrolled away (or returned to ordinary end-following), that + // remeasurement must not restart the send-time anchor positioning. + if (timelineScrollModeRef.current !== "anchoring-new-turn") { + return; + } if (pendingTimelineAnchorRef.current === messageId) { pendingTimelineAnchorRef.current = null; } @@ -3969,6 +4053,7 @@ function ChatViewContent(props: ChatViewProps) { if (isAtEnd) { timelineScrollModeRef.current = "following-end"; liveFollowUserScrollGenerationRef.current = anchorUserScrollGenerationRef.current; + setTimelineLiveFollowEnabled(true); showScrollDebouncer.current.cancel(); setShowScrollToBottom(false); setHasUnreadTimelineActivity(false); @@ -4075,6 +4160,7 @@ function ChatViewContent(props: ChatViewProps) { isAtEndRef.current = true; timelineScrollModeRef.current = "following-end"; liveFollowUserScrollGenerationRef.current = anchorUserScrollGenerationRef.current; + setTimelineLiveFollowEnabled(true); pendingTimelineAnchorRef.current = null; positionedTimelineAnchorRef.current = null; settledTimelineAnchorRef.current = null; @@ -5048,11 +5134,22 @@ function ChatViewContent(props: ChatViewProps) { isSendBusy || isConnecting || activeEnvironmentUnavailable || + threadDetailLoading || sendInFlightRef.current ) { notifyDirectAnnotationAttached(); return; } + if (activeEnvironmentUnavailable) { + toastManager.add( + stackedThreadToast({ + type: "warning", + title: "Not connected: message not sent", + description: "Reconnecting to the environment. Try again once it is connected.", + }), + ); + return; + } if (activePendingProgress) { if (directAnnotation) { notifyDirectAnnotationAttached(); @@ -5280,6 +5377,7 @@ function ChatViewContent(props: ChatViewProps) { isAtEndRef.current = true; timelineScrollModeRef.current = "anchoring-new-turn"; liveFollowUserScrollGenerationRef.current = anchorUserScrollGenerationRef.current; + setTimelineLiveFollowEnabled(true); pendingTimelineAnchorRef.current = messageIdForSend; activeTimelineAnchorIndexRef.current = null; showScrollDebouncer.current.cancel(); @@ -5896,6 +5994,7 @@ function ChatViewContent(props: ChatViewProps) { isAtEndRef.current = true; timelineScrollModeRef.current = "anchoring-new-turn"; liveFollowUserScrollGenerationRef.current = anchorUserScrollGenerationRef.current; + setTimelineLiveFollowEnabled(true); pendingTimelineAnchorRef.current = messageIdForSend; activeTimelineAnchorIndexRef.current = null; showScrollDebouncer.current.cancel(); @@ -6585,10 +6684,6 @@ function ChatViewContent(props: ChatViewProps) { activeTurnStartedAt={activeWorkStartedAt} listRef={legendListRef} timelineEntries={timelineEntries} - hasMoreOlder={hasMoreOlderActivities} - loadingOlder={loadingOlderActivities} - olderHistoryCursorVersion={olderHistoryCursorVersion} - onLoadOlder={loadOlderActivities} latestTurn={activeLatestTurn} runningTurnId={ activeThread.session?.status === "running" @@ -6612,11 +6707,12 @@ function ChatViewContent(props: ChatViewProps) { onAnchorReady={onTimelineAnchorReady} onAnchorSizeChanged={onTimelineAnchorSizeChanged} contentInsetEndAdjustment={composerOverlayHeight} - maintainScrollAtEnd={maintainTimelineAtEnd} + liveFollowEnabled={timelineLiveFollowEnabled} onIsAtEndChange={onIsAtEndChange} onManualNavigation={cancelTimelineLiveFollowForUserNavigation} hideEmptyPlaceholder={isDraftHeroState} topFadeEnabled={!hasTimelineTopBanner} + loadEarlier={loadEarlierTurns} /> {/* scroll to end pill — shown when user has scrolled away from the live edge */} @@ -6804,6 +6900,7 @@ function ChatViewContent(props: ChatViewProps) { ()); + const performSnooze = useCallback( + async ( + threadRef: ScopedThreadRef, + preset: SnoozePreset, + opts: { coSnoozingKeys?: ReadonlySet } = {}, + ) => { + const threadKey = scopedThreadKey(threadRef); + if (snoozingThreadKeysRef.current.has(threadKey)) { + return { status: "skipped" } as const; + } + snoozingThreadKeysRef.current.add(threadKey); + try { + // Snoozing the open thread moves you forward, same as settle — + // both park the thread you're done with for now. + const navigateAfterSnooze = planForwardNavigation(threadKey, opts.coSnoozingKeys); + const result = await snoozeThread(threadRef, preset.snoozedUntil); + if (result._tag === "Failure") { + // Never navigate away from a thread that did not snooze. + return isAtomCommandInterrupted(result) + ? ({ status: "interrupted" } as const) + : ({ status: "failure", error: squashAtomCommandFailure(result) } as const); + } + // Only move forward if the user is still on the snoozed thread — + // a navigation made during the await wins over ours. + if (routeThreadKeyRef.current === threadKey) { + navigateAfterSnooze?.(); + } + return { status: "success" } as const; + } finally { + snoozingThreadKeysRef.current.delete(threadKey); + } + }, + [planForwardNavigation, snoozeThread], + ); const attemptSnooze = useCallback( ( threadRef: ScopedThreadRef, @@ -2399,52 +2433,35 @@ export default function SidebarV2() { opts: { coSnoozingKeys?: ReadonlySet } = {}, ) => { void (async () => { - const threadKey = scopedThreadKey(threadRef); - if (snoozingThreadKeysRef.current.has(threadKey)) return; - snoozingThreadKeysRef.current.add(threadKey); - try { - // Snoozing the open thread moves you forward, same as settle — - // both park the thread you're done with for now. - const navigateAfterSnooze = planForwardNavigation(threadKey, opts.coSnoozingKeys); - const result = await snoozeThread(threadRef, preset.snoozedUntil); - if (result._tag === "Failure") { - // Never navigate away from a thread that did not snooze. - if (!isAtomCommandInterrupted(result)) { - const error = squashAtomCommandFailure(result); - toastManager.add( - stackedThreadToast({ - type: "error", - title: "Failed to snooze thread", - description: error instanceof Error ? error.message : "An error occurred.", - }), - ); - } - return; - } - // Snooze hides the row, so the toast is the only confirmation — - // and the Undo is the escape hatch for a mis-click. + const outcome = await performSnooze(threadRef, preset, opts); + if (outcome.status === "failure") { toastManager.add( stackedThreadToast({ - type: "success", - title: `Snoozed until ${snoozeWakeDescription(preset.snoozedUntil, new Date(), timestampFormat)}`, - timeout: 5_000, - actionProps: { - children: "Undo", - onClick: () => attemptUnsnooze(threadRef), - }, + type: "error", + title: "Failed to snooze thread", + description: + outcome.error instanceof Error ? outcome.error.message : "An error occurred.", }), ); - // Only move forward if the user is still on the snoozed thread — - // a navigation made during the await wins over ours. - if (routeThreadKeyRef.current === threadKey) { - navigateAfterSnooze?.(); - } - } finally { - snoozingThreadKeysRef.current.delete(threadKey); + return; } + if (outcome.status !== "success") return; + // Snooze hides the row, so the toast is the only confirmation — + // and the Undo is the escape hatch for a mis-click. + toastManager.add( + stackedThreadToast({ + type: "success", + title: `Snoozed until ${snoozeWakeDescription(preset.snoozedUntil, new Date(), timestampFormat)}`, + timeout: 5_000, + actionProps: { + children: "Undo", + onClick: () => attemptUnsnooze(threadRef), + }, + }), + ); })(); }, - [attemptUnsnooze, planForwardNavigation, snoozeThread, timestampFormat], + [attemptUnsnooze, performSnooze, timestampFormat], ); const removeFromSelection = useThreadSelectionStore((s) => s.removeFromSelection); @@ -2518,12 +2535,55 @@ export default function SidebarV2() { // Post-snooze navigation must skip threads snoozing in this same // batch — they are all leaving the card block together. const coSnoozingKeys = new Set(threadKeys); - for (const thread of snoozableThreads) { - attemptSnooze(scopeThreadRef(thread.environmentId, thread.id), preset, { - coSnoozingKeys, - }); - } clearSelection(); + const outcomes = await Promise.all( + snoozableThreads.map(async (thread) => { + const threadRef = scopeThreadRef(thread.environmentId, thread.id); + const outcome = await performSnooze(threadRef, preset, { coSnoozingKeys }); + return { outcome, threadRef }; + }), + ); + const snoozedThreadRefs = outcomes.flatMap(({ outcome, threadRef }) => + outcome.status === "success" ? [threadRef] : [], + ); + const failures = outcomes.flatMap(({ outcome }) => + outcome.status === "failure" ? [outcome.error] : [], + ); + + if (snoozedThreadRefs.length > 0) { + const snoozedCount = snoozedThreadRefs.length; + const failedCount = failures.length; + toastManager.add( + stackedThreadToast({ + type: failedCount > 0 ? "warning" : "success", + title: + failedCount > 0 + ? `Snoozed ${snoozedCount} of ${snoozableThreads.length} threads` + : `Snoozed ${snoozedCount} thread${snoozedCount === 1 ? "" : "s"}`, + description: + failedCount > 0 + ? `${failedCount} thread${failedCount === 1 ? "" : "s"} couldn't be snoozed.` + : undefined, + timeout: 5_000, + actionProps: { + children: "Undo", + onClick: () => { + for (const threadRef of snoozedThreadRefs) attemptUnsnooze(threadRef); + }, + }, + }), + ); + } else if (failures.length > 0) { + const firstError = failures[0]; + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Failed to snooze threads", + description: + firstError instanceof Error ? firstError.message : "An error occurred.", + }), + ); + } } return; } @@ -2619,8 +2679,10 @@ export default function SidebarV2() { confirmThreadDelete, deleteThread, markThreadUnread, + performSnooze, removeFromSelection, serverConfigs, + attemptUnsnooze, updateThreadMetadata, timestampFormat, ], diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts index 303f8b7006fb..6d74204bc1ca 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts @@ -1,107 +1,11 @@ import { describe, expect, it } from "vite-plus/test"; import { - clearSteerTimelineBoundaryStore, - observeSteerTextBoundary, -} from "@t3tools/shared/steerTimeline"; -import { - collapseConsecutiveDuplicateAssistantEntries, computeStableMessagesTimelineRows, computeMessageDurationStart, deriveMessagesTimelineRows, - interleaveTimelineEntriesForSteeredTurn, normalizeCompactToolLabel, - resolveOlderHistoryAutoLoad, resolveAssistantMessageCopyState, } from "./MessagesTimeline.logic"; -import type { TimelineEntry } from "../../session-logic"; -import { MessageId, TurnId } from "@t3tools/contracts"; - -describe("resolveOlderHistoryAutoLoad", () => { - it("does not retry continuously while a failed request leaves the viewport at the start", () => { - const first = resolveOlderHistoryAutoLoad({ - armed: true, - hasMore: true, - isAtStart: true, - loading: false, - observedProgressVersion: 0, - progressVersion: 0, - }); - expect(first).toEqual({ - armed: false, - observedProgressVersion: 0, - shouldLoad: true, - }); - - const afterFailure = resolveOlderHistoryAutoLoad({ - armed: first.armed, - hasMore: true, - isAtStart: true, - loading: false, - observedProgressVersion: first.observedProgressVersion, - progressVersion: 0, - }); - expect(afterFailure).toEqual({ - armed: false, - observedProgressVersion: 0, - shouldLoad: false, - }); - - const afterLeavingStart = resolveOlderHistoryAutoLoad({ - armed: afterFailure.armed, - hasMore: true, - isAtStart: false, - loading: false, - observedProgressVersion: afterFailure.observedProgressVersion, - progressVersion: 0, - }); - expect(afterLeavingStart).toEqual({ - armed: true, - observedProgressVersion: 0, - shouldLoad: false, - }); - - expect( - resolveOlderHistoryAutoLoad({ - armed: afterLeavingStart.armed, - hasMore: true, - isAtStart: true, - loading: false, - observedProgressVersion: afterLeavingStart.observedProgressVersion, - progressVersion: 0, - }), - ).toEqual({ - armed: false, - observedProgressVersion: 0, - shouldLoad: true, - }); - }); - - it("rearms at the start only after a page successfully advances the cursor", () => { - const afterFirstAttempt = resolveOlderHistoryAutoLoad({ - armed: true, - hasMore: true, - isAtStart: true, - loading: false, - observedProgressVersion: 0, - progressVersion: 0, - }); - - expect( - resolveOlderHistoryAutoLoad({ - armed: afterFirstAttempt.armed, - hasMore: true, - isAtStart: true, - loading: false, - observedProgressVersion: afterFirstAttempt.observedProgressVersion, - progressVersion: 1, - }), - ).toEqual({ - armed: false, - observedProgressVersion: 1, - shouldLoad: true, - }); - }); -}); describe("computeMessageDurationStart", () => { it("returns message createdAt when there is no preceding user message", () => { @@ -596,15 +500,6 @@ describe("deriveMessagesTimelineRows", () => { const collapsedRows = deriveMessagesTimelineRows({ timelineEntries, - latestTurn: { - turnId: "turn-1" as never, - state: "completed", - startedAt: "2026-01-01T00:00:00Z", - completedAt: "2026-01-01T00:00:22Z", - // The projection can retain the first commentary message here. The - // later assistant message must remain visible as the actual final. - assistantMessageId: "assistant-thought" as never, - }, isWorking: false, activeTurnStartedAt: null, turnDiffSummaryByAssistantMessageId: new Map(), @@ -646,115 +541,6 @@ describe("deriveMessagesTimelineRows", () => { ).toBeDefined(); }); - it("keeps a queue-drain final below the fold when the message turnId was flipped", () => { - const rows = deriveMessagesTimelineRows({ - timelineEntries: [ - { - id: "user-1", - kind: "message", - createdAt: "2026-01-01T00:00:00Z", - message: { - id: "user-1" as never, - role: "user" as const, - text: "Change the icons.", - turnId: null, - createdAt: "2026-01-01T00:00:00Z", - updatedAt: "2026-01-01T00:00:00Z", - streaming: false, - }, - }, - { - id: "assistant-status", - kind: "message", - createdAt: "2026-01-01T00:00:05Z", - message: { - id: "assistant-status" as never, - role: "assistant" as const, - text: "Replacing the segment bar…", - turnId: "turn-1" as never, - createdAt: "2026-01-01T00:00:05Z", - updatedAt: "2026-01-01T00:00:05Z", - streaming: false, - }, - }, - { - id: "work-1", - kind: "work", - createdAt: "2026-01-01T00:00:10Z", - entry: { - id: "work-1", - createdAt: "2026-01-01T00:00:10Z", - turnId: "turn-1" as never, - label: "Changed files", - tone: "tool" as const, - }, - }, - { - id: "assistant-final-misstamped", - kind: "message", - createdAt: "2026-01-01T00:00:20Z", - message: { - id: "assistant-final-misstamped" as never, - role: "assistant" as const, - text: "Done. No more segment bar.", - turnId: "turn-2" as never, - createdAt: "2026-01-01T00:00:20Z", - updatedAt: "2026-01-01T00:00:20Z", - streaming: false, - }, - }, - { - id: "user-2", - kind: "message", - createdAt: "2026-01-01T00:00:20Z", - message: { - id: "user-2" as never, - role: "user" as const, - text: "About the queue?", - turnId: null, - createdAt: "2026-01-01T00:00:20Z", - updatedAt: "2026-01-01T00:00:20Z", - streaming: false, - }, - }, - { - id: "assistant-next", - kind: "message", - createdAt: "2026-01-01T00:00:28Z", - message: { - id: "assistant-next" as never, - role: "assistant" as const, - text: "Queue is chips now.", - turnId: "turn-2" as never, - createdAt: "2026-01-01T00:00:28Z", - updatedAt: "2026-01-01T00:00:30Z", - streaming: false, - }, - }, - ], - isWorking: false, - activeTurnStartedAt: null, - turnDiffSummaryByAssistantMessageId: new Map(), - revertTurnCountByUserMessageId: new Map(), - latestTurn: { - turnId: "turn-2" as never, - state: "completed", - startedAt: "2026-01-01T00:00:20Z", - completedAt: "2026-01-01T00:00:30Z", - assistantMessageId: "assistant-next" as never, - }, - }); - - const ids = rows.map((row) => row.id); - expect(ids).toContain("turn-fold:turn-1"); - expect(ids).toContain("assistant-final-misstamped"); - expect(ids.indexOf("assistant-final-misstamped")).toBeGreaterThan( - ids.indexOf("turn-fold:turn-1"), - ); - expect(ids).not.toContain("assistant-status"); - expect(ids).not.toContain("work-1"); - }); - it("derives a sane duration for a steer-superseded turn with one instant commentary message", () => { // A steer ends the previous turn early: its only message completes the // instant it is created, and trailing work entries land after it. The @@ -1225,301 +1011,6 @@ describe("deriveMessagesTimelineRows", () => { expanded: true, }); }); - - it("keeps a clarifying-question exchange out of the collapsed work group", () => { - const userInput = { - requestId: "req-1", - answered: true, - questions: [ - { - id: "Approach?", - header: "Approach", - question: "Approach?", - multiSelect: false, - options: [{ label: "Ship it", description: "Merge as-is" }], - selectedLabels: ["Ship it"], - }, - ], - }; - const rows = deriveMessagesTimelineRows({ - timelineEntries: [ - { - id: "work-entry-1", - kind: "work" as const, - createdAt: "2026-01-01T00:00:01Z", - entry: { - id: "work-1", - createdAt: "2026-01-01T00:00:01Z", - label: "read", - tone: "tool" as const, - }, - }, - { - id: "user-input-entry", - kind: "work" as const, - createdAt: "2026-01-01T00:00:02Z", - entry: { - id: "user-input-1", - createdAt: "2026-01-01T00:00:02Z", - label: "Question: Approach", - tone: "info" as const, - userInput, - }, - }, - { - id: "work-entry-2", - kind: "work" as const, - createdAt: "2026-01-01T00:00:03Z", - entry: { - id: "work-2", - createdAt: "2026-01-01T00:00:03Z", - label: "edit", - tone: "tool" as const, - }, - }, - ], - isWorking: false, - activeTurnStartedAt: null, - turnDiffSummaryByAssistantMessageId: new Map(), - revertTurnCountByUserMessageId: new Map(), - }); - - expect(rows.map((row) => row.id)).toEqual(["work-entry-1", "user-input-entry", "work-entry-2"]); - expect(rows.find((row) => row.kind === "user-input")).toMatchObject({ userInput }); - }); - - it("keeps a clarifying-question exchange visible after its turn folds", () => { - const rows = deriveMessagesTimelineRows({ - timelineEntries: [ - { - id: "user-entry", - kind: "message" as const, - createdAt: "2026-01-01T00:00:00Z", - message: { - id: "user-1" as never, - role: "user" as const, - text: "ship the fix", - turnId: null, - createdAt: "2026-01-01T00:00:00Z", - updatedAt: "2026-01-01T00:00:00Z", - streaming: false, - }, - }, - { - id: "work-entry-1", - kind: "work" as const, - createdAt: "2026-01-01T00:00:05Z", - entry: { - id: "work-1", - createdAt: "2026-01-01T00:00:05Z", - turnId: "turn-1" as never, - label: "Ran command", - tone: "tool" as const, - }, - }, - { - id: "user-input-entry", - kind: "work" as const, - createdAt: "2026-01-01T00:00:08Z", - entry: { - id: "user-input-1", - createdAt: "2026-01-01T00:00:08Z", - turnId: "turn-1" as never, - label: "Question: Approach", - tone: "info" as const, - userInput: { - requestId: "req-1", - answered: true, - questions: [ - { - id: "Approach?", - header: "Approach", - question: "Approach?", - multiSelect: false, - options: [{ label: "Ship it", description: "Merge as-is" }], - selectedLabels: ["Ship it"], - }, - ], - }, - }, - }, - { - id: "assistant-final-entry", - kind: "message" as const, - createdAt: "2026-01-01T00:00:20Z", - message: { - id: "assistant-final" as never, - role: "assistant" as const, - text: "Shipped", - turnId: "turn-1" as never, - createdAt: "2026-01-01T00:00:20Z", - updatedAt: "2026-01-01T00:00:22Z", - streaming: false, - }, - }, - ], - isWorking: false, - activeTurnStartedAt: null, - turnDiffSummaryByAssistantMessageId: new Map(), - revertTurnCountByUserMessageId: new Map(), - }); - - expect(rows.map((row) => row.id)).toEqual([ - "user-entry", - "turn-fold:turn-1", - "user-input-entry", - "assistant-final-entry", - ]); - }); - - it("interleaves steer user messages between pre-steer and post-steer turn output", () => { - clearSteerTimelineBoundaryStore(); - const boundaryStore = new Map(); - // Observe the assistant text as it existed when the steer arrived. - // Production timeline entry ids match message ids (see deriveTimelineEntries). - observeSteerTextBoundary( - "active-assistant", - "steer-user", - "Tracing timeline ordering.".length, - boundaryStore, - ); - - const timelineEntries = [ - { - id: "settled-summary", - kind: "message" as const, - createdAt: "2026-01-01T00:00:30Z", - message: { - id: "settled-summary" as never, - role: "assistant" as const, - text: "Submit latch fix summary.", - turnId: "turn-0" as never, - createdAt: "2026-01-01T00:00:30Z", - updatedAt: "2026-01-01T00:00:40Z", - streaming: false, - }, - }, - { - id: "turn-start-user", - kind: "message" as const, - createdAt: "2026-01-01T00:01:00Z", - message: { - id: "turn-start-user" as never, - role: "user" as const, - text: "run my build/deploy script", - turnId: null, - createdAt: "2026-01-01T00:01:00Z", - updatedAt: "2026-01-01T00:01:00Z", - streaming: false, - }, - }, - { - id: "active-assistant", - kind: "message" as const, - createdAt: "2026-01-01T00:01:05Z", - message: { - id: "active-assistant" as never, - role: "assistant" as const, - text: "Tracing timeline ordering. Comparing solutions after the steer.", - turnId: "turn-1" as never, - createdAt: "2026-01-01T00:01:05Z", - updatedAt: "2026-01-01T00:09:00Z", - streaming: true, - }, - }, - { - id: "active-work", - kind: "work" as const, - createdAt: "2026-01-01T00:07:00Z", - entry: { - id: "active-work", - createdAt: "2026-01-01T00:07:00Z", - turnId: "turn-1" as never, - label: "Searched codebase", - tone: "tool" as const, - }, - }, - { - id: "steer-user", - kind: "message" as const, - createdAt: "2026-01-01T00:08:30Z", - message: { - id: "steer-user" as never, - role: "user" as const, - text: "how does our solution compare?", - turnId: null, - createdAt: "2026-01-01T00:08:30Z", - updatedAt: "2026-01-01T00:08:30Z", - streaming: false, - }, - }, - { - id: "post-steer-work", - kind: "work" as const, - createdAt: "2026-01-01T00:08:45Z", - entry: { - id: "post-steer-work", - createdAt: "2026-01-01T00:08:45Z", - turnId: "turn-1" as never, - label: "Compared solutions", - tone: "tool" as const, - }, - }, - ]; - - const interleaved = interleaveTimelineEntriesForSteeredTurn(timelineEntries, { - boundaryStore, - }); - - expect( - interleaved.map((entry) => ({ - id: entry.id, - text: entry.kind === "message" ? entry.message.text : undefined, - })), - ).toEqual([ - { id: "settled-summary", text: "Submit latch fix summary." }, - { id: "turn-start-user", text: "run my build/deploy script" }, - { id: "active-assistant::pre", text: "Tracing timeline ordering." }, - { id: "active-work", text: undefined }, - { id: "steer-user", text: "how does our solution compare?" }, - { - id: "active-assistant::after::steer-user", - text: " Comparing solutions after the steer.", - }, - { id: "post-steer-work", text: undefined }, - ]); - - clearSteerTimelineBoundaryStore(); - observeSteerTextBoundary("active-assistant", "steer-user", "Tracing timeline ordering.".length); - - const rows = deriveMessagesTimelineRows({ - timelineEntries, - latestTurn: { - turnId: "turn-1" as never, - state: "running", - startedAt: "2026-01-01T00:01:00Z", - completedAt: null, - }, - runningTurnId: "turn-1" as never, - isWorking: true, - activeTurnStartedAt: "2026-01-01T00:01:00Z", - turnDiffSummaryByAssistantMessageId: new Map(), - revertTurnCountByUserMessageId: new Map(), - }); - - expect(rows.map((row) => row.id)).toEqual([ - "settled-summary", - "turn-start-user", - "active-assistant::pre", - "active-work", - "steer-user", - "active-assistant::after::steer-user", - "post-steer-work", - "working-indicator-row", - ]); - - clearSteerTimelineBoundaryStore(); - }); }); describe("computeStableMessagesTimelineRows", () => { @@ -1680,73 +1171,3 @@ describe("computeStableMessagesTimelineRows", () => { expect(reordered.result).toEqual([initial.result[1], initial.result[0]]); }); }); - -describe("collapseConsecutiveDuplicateAssistantEntries", () => { - const turnId = TurnId.make("turn-1"); - - function assistantEntry( - id: string, - text: string, - createdAt: string, - ): Extract { - return { - id, - kind: "message", - createdAt, - message: { - id: MessageId.make(id), - role: "assistant", - text, - turnId, - streaming: false, - createdAt, - updatedAt: createdAt, - }, - }; - } - - function workEntry(id: string, createdAt: string): Extract { - return { - id, - kind: "work", - createdAt, - entry: { - id, - createdAt, - turnId, - label: "Ran command", - command: "echo hi", - tone: "tool", - }, - }; - } - - it("drops a status twin that reappears after tools (Grok sandwich)", () => { - const statusA = - "Aligning Bauhaus with Standard: skip label creation when nothing is still initial."; - const statusB = "Validation passed. Booting e2e."; - const collapsed = collapseConsecutiveDuplicateAssistantEntries([ - assistantEntry("a1", statusA, "2026-07-21T00:00:01Z"), - workEntry("w1", "2026-07-21T00:00:02Z"), - assistantEntry("a2", statusA, "2026-07-21T00:00:03Z"), - assistantEntry("a3", statusB, "2026-07-21T00:00:04Z"), - workEntry("w2", "2026-07-21T00:00:05Z"), - assistantEntry("a4", statusB, "2026-07-21T00:00:06Z"), - ]); - - expect( - collapsed - .filter((entry) => entry.kind === "message") - .map((entry) => (entry.kind === "message" ? entry.message.text : "")), - ).toEqual([statusA, statusB]); - expect(collapsed.map((entry) => entry.id)).toEqual(["a1", "w1", "a3", "w2"]); - }); - - it("keeps distinct consecutive assistant statuses", () => { - const collapsed = collapseConsecutiveDuplicateAssistantEntries([ - assistantEntry("a1", "first", "2026-07-21T00:00:01Z"), - assistantEntry("a2", "second", "2026-07-21T00:00:02Z"), - ]); - expect(collapsed.map((entry) => entry.id)).toEqual(["a1", "a2"]); - }); -}); diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.ts b/apps/web/src/components/chat/MessagesTimeline.logic.ts index 1d504427517f..c204499273ac 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.ts @@ -1,17 +1,10 @@ import * as Equal from "effect/Equal"; -import { - compareSteerTimelineSortable, - findMidTurnSteerUserIds, - splitAssistantTextAtSteers, - type SteerTimelineBoundaryStore, -} from "@t3tools/shared/steerTimeline"; import { formatDuration, workEntryIndicatesToolNeutralStatus, workLogEntryIsToolLike, type TimelineEntry, type WorkLogEntry, - type WorkLogUserInput, } from "../../session-logic"; import { type ChatMessage, type ProposedPlan, type TurnDiffSummary } from "../../types"; import { type MessageId, type OrchestrationLatestTurn, type TurnId } from "@t3tools/contracts"; @@ -25,56 +18,37 @@ export const TIMELINE_MINIMAP_PERSISTENT_GUTTER = 48; export interface TimelineEndState { readonly isAtEnd?: boolean; - readonly isNearEnd?: boolean; -} - -export interface OlderHistoryAutoLoadDecision { - readonly armed: boolean; - readonly observedProgressVersion: number; - readonly shouldLoad: boolean; + readonly contentLength?: number; + readonly scroll?: number; + readonly scrollLength?: number; } /** - * Treat reaching the start as an edge, not a continuously-true condition. - * A failed request leaves the viewport at the start, so level-triggered loading - * would immediately retry on every render. Leaving the start OR observing a - * successfully advanced page cursor rearms one future automatic request. The - * visible header control remains available for explicit retries while the edge - * is disarmed. + * Follow re-arm band above the hard bottom. Strict on purpose: LegendList's + * isNearEnd fires within half a viewport, which re-armed live-follow while the + * user was reading history and yanked them back down on the next stream chunk. + * A small pixel band (instead of the 1px isAtEnd epsilon alone) keeps re-arming + * reliable while streaming content is still growing under the viewport. */ -export function resolveOlderHistoryAutoLoad(input: { - readonly armed: boolean; - readonly hasMore: boolean; - readonly isAtStart: boolean; - readonly loading: boolean; - readonly observedProgressVersion: number; - readonly progressVersion: number; -}): OlderHistoryAutoLoadDecision { - const progressed = input.progressVersion !== input.observedProgressVersion; - const armed = input.armed || progressed; - if (!input.isAtStart) { - return { - armed: true, - observedProgressVersion: input.progressVersion, - shouldLoad: false, - }; +export const TIMELINE_FOLLOW_REARM_THRESHOLD_PX = 40; + +export function resolveTimelineIsAtEnd( + state: TimelineEndState | undefined, + endInset = 0, +): boolean | undefined { + if (!state) { + return undefined; } - if (!armed || !input.hasMore || input.loading) { - return { - armed, - observedProgressVersion: input.progressVersion, - shouldLoad: false, - }; + if (state.isAtEnd) { + return true; } - return { - armed: false, - observedProgressVersion: input.progressVersion, - shouldLoad: true, - }; -} - -export function resolveTimelineIsAtEnd(state: TimelineEndState | undefined): boolean | undefined { - return state?.isNearEnd ?? state?.isAtEnd; + const { contentLength, scroll, scrollLength } = state; + if (contentLength === undefined || scroll === undefined || scrollLength === undefined) { + return state.isAtEnd; + } + // contentLength includes the end inset (composer overlay), so subtract it to + // measure the distance to the real content bottom. + return contentLength - scroll - scrollLength - endInset <= TIMELINE_FOLLOW_REARM_THRESHOLD_PX; } export function resolveTimelineMinimapHeightStyle(itemCount: number): string { @@ -183,10 +157,7 @@ export interface TimelineDurationMessage { export type TimelineLatestTurn = Pick< OrchestrationLatestTurn, "turnId" | "state" | "startedAt" | "completedAt" -> & { - /** When set, preferred terminal assistant for this turn (fold / copy meta). */ - readonly assistantMessageId?: MessageId | null; -}; +>; export type MessagesTimelineRow = | { @@ -230,13 +201,6 @@ export type MessagesTimelineRow = createdAt: string; proposedPlan: ProposedPlan; } - | { - kind: "user-input"; - id: string; - createdAt: string; - entry: WorkLogEntry; - userInput: WorkLogUserInput; - } | { kind: "working"; id: string; createdAt: string | null }; export interface StableMessagesTimelineRowsState { @@ -283,10 +247,7 @@ export function resolveAssistantMessageCopyState({ }; } -function deriveTerminalAssistantMessageIds( - timelineEntries: ReadonlyArray, - preferredTerminalMessageIdByTurn: ReadonlyMap = new Map(), -) { +function deriveTerminalAssistantMessageIds(timelineEntries: ReadonlyArray) { const lastAssistantMessageIdByResponseKey = new Map(); let nullTurnResponseIndex = 0; @@ -309,17 +270,6 @@ function deriveTerminalAssistantMessageIds( lastAssistantMessageIdByResponseKey.set(responseKey, message.id); } - // Prefer the turn projection's assistant_message_id when present — it stays - // correct even if the message row was stamped with the next turn at drain. - for (const [turnId, messageId] of preferredTerminalMessageIdByTurn) { - const exists = timelineEntries.some( - (entry) => entry.kind === "message" && entry.message.id === messageId, - ); - if (exists) { - lastAssistantMessageIdByResponseKey.set(`turn:${turnId}`, messageId); - } - } - return new Set(lastAssistantMessageIdByResponseKey.values()); } @@ -409,64 +359,15 @@ function deriveTurnFolds(input: { } group.entries.push(entry); if (entry.kind === "message") { + if (input.terminalAssistantMessageIds.has(entry.message.id)) { + group.terminalEntry = entry; + } if (entry.message.streaming) { group.hasStreamingMessage = true; } } } - // Queue-drain / turn flip can stamp the previous turn's final assistant - // message with the *next* turn id at the same timestamp as the next user - // message. Re-home those orphans so the fold keeps the real final visible. - const userCreatedAts = input.timelineEntries - .filter( - (entry): entry is Extract => - entry.kind === "message" && entry.message.role === "user", - ) - .map((entry) => entry.createdAt); - const orderedTurns = [...groupsByTurnId.entries()].sort((left, right) => { - const leftAt = left[1].entries[0]?.createdAt ?? left[1].startBoundary ?? ""; - const rightAt = right[1].entries[0]?.createdAt ?? right[1].startBoundary ?? ""; - return leftAt.localeCompare(rightAt); - }); - for (let index = 1; index < orderedTurns.length; index += 1) { - const previous = orderedTurns[index - 1]; - const current = orderedTurns[index]; - if (!previous || !current) continue; - const [, previousGroup] = previous; - const [, currentGroup] = current; - const previousLastAt = - previousGroup.entries.at(-1)?.createdAt ?? previousGroup.startBoundary ?? null; - if (previousLastAt === null) continue; - const nextUserAt = userCreatedAts.find((createdAt) => createdAt >= previousLastAt) ?? null; - if (nextUserAt === null) continue; - const firstAssistantIndex = currentGroup.entries.findIndex( - (entry) => entry.kind === "message" && entry.message.role === "assistant", - ); - if (firstAssistantIndex < 0) continue; - const firstAssistant = currentGroup.entries[firstAssistantIndex]; - if (!firstAssistant || firstAssistant.kind !== "message") continue; - // True first tokens of the next turn always land *after* that user message. - if (firstAssistant.createdAt > nextUserAt) continue; - currentGroup.entries.splice(firstAssistantIndex, 1); - previousGroup.entries.push(firstAssistant); - } - - // Resolve terminal after re-homing: the last assistant in the group is the - // real final (status lines are earlier). latestTurn.assistantMessageId can - // still point at the first commentary message, so it must not override the - // chronological final. - for (const group of groupsByTurnId.values()) { - group.terminalEntry = null; - let lastAssistant: Extract | null = null; - for (const entry of group.entries) { - if (entry.kind === "message" && entry.message.role === "assistant") { - lastAssistant = entry; - } - } - group.terminalEntry = lastAssistant; - } - const foldsByAnchorEntryId = new Map(); for (const [turnId, group] of groupsByTurnId) { if (turnId === input.unsettledTurnId) { @@ -486,11 +387,6 @@ function deriveTurnFolds(input: { if (entry.kind === "work" && entry.entry.agentSpawn !== undefined) { continue; } - // A clarifying question and its answer record a decision the user made; - // keep them readable once the turn settles instead of folding them away. - if (entry.kind === "work" && entry.entry.userInput !== undefined) { - continue; - } hiddenEntryIds.add(entry.id); } if (hiddenEntryIds.size === 0) { @@ -539,198 +435,6 @@ function deriveTurnFolds(input: { return foldsByAnchorEntryId; } -function timelineEntryBelongsToTurn(entry: TimelineEntry, turnId: TurnId): boolean { - if (entry.kind === "work") { - return entry.entry.turnId === turnId; - } - if (entry.kind === "message" && entry.message.role !== "user") { - return entry.message.turnId === turnId; - } - if (entry.kind === "proposed-plan") { - return entry.proposedPlan.turnId === turnId; - } - return false; -} - -function collectTimelineTurnIds(timelineEntries: ReadonlyArray): TurnId[] { - const turnIds: TurnId[] = []; - const seen = new Set(); - for (const entry of timelineEntries) { - const turnId = - entry.kind === "work" - ? entry.entry.turnId - : entry.kind === "proposed-plan" - ? entry.proposedPlan.turnId - : entry.kind === "message" - ? entry.message.turnId - : null; - if (turnId == null || seen.has(String(turnId))) { - continue; - } - seen.add(String(turnId)); - turnIds.push(turnId); - } - return turnIds; -} - -/** - * Cursor/Codex steer while a turn is running reuses the active turn id and - * keeps appending assistant deltas to an early message row. Chronological sort - * alone parks that whole bubble above later steer user messages; the previous - * "move steers above the turn" workaround parked them before *all* turn work. - * - * Instead, interleave by `createdAt` and split assistant text at client-observed - * boundaries so steers sit between pre- and post-steer content (and between - * tools that started before/after the steer). - */ -export function interleaveTimelineEntriesForSteeredTurn( - timelineEntries: ReadonlyArray, - input: { - /** When set, only this turn is expanded. When omitted, every turn with steers is. */ - readonly unsettledTurnId?: TurnId | null; - readonly boundaryStore?: SteerTimelineBoundaryStore; - } = {}, -): TimelineEntry[] { - const turnIds = - input.unsettledTurnId !== undefined && input.unsettledTurnId !== null - ? [input.unsettledTurnId] - : collectTimelineTurnIds(timelineEntries); - - const steersByTurnId = new Map< - string, - ReadonlyArray<{ readonly id: string; readonly createdAt: string }> - >(); - const steerIdSet = new Set(); - - for (const turnId of turnIds) { - const steers = findMidTurnSteerUserIds({ - items: timelineEntries.map((entry) => ({ - id: entry.id, - createdAt: entry.createdAt, - isUser: entry.kind === "message" && entry.message.role === "user", - belongsToActiveTurn: timelineEntryBelongsToTurn(entry, turnId), - })), - }); - if (steers.length === 0) { - continue; - } - steersByTurnId.set(String(turnId), steers); - for (const steer of steers) { - steerIdSet.add(steer.id); - } - } - - if (steersByTurnId.size === 0) { - return [...timelineEntries].toSorted((left, right) => - left.createdAt.localeCompare(right.createdAt), - ); - } - - const expanded: Array = []; - - for (const entry of timelineEntries) { - if ( - entry.kind === "message" && - entry.message.role === "assistant" && - entry.message.turnId !== null - ) { - const steers = steersByTurnId.get(String(entry.message.turnId)); - if (steers !== undefined && steers.length > 0) { - const segments = splitAssistantTextAtSteers({ - assistantMessageId: entry.message.id, - assistantCreatedAt: entry.message.createdAt, - text: entry.message.text, - streaming: entry.message.streaming, - steers, - ...(input.boundaryStore !== undefined ? { boundaryStore: input.boundaryStore } : {}), - }); - - for (const segment of segments) { - expanded.push({ - id: segment.segmentId, - kind: "message", - createdAt: segment.sortAt, - sortRank: segment.sortRank, - message: { - ...entry.message, - text: segment.text, - streaming: segment.streaming, - // Segment sort key — keeps fold/duration helpers aligned with display order. - createdAt: segment.sortAt, - updatedAt: segment.streaming ? entry.message.updatedAt : segment.sortAt, - }, - }); - } - continue; - } - } - - expanded.push({ - ...entry, - sortRank: steerIdSet.has(entry.id) ? 1 : 0, - }); - } - - return expanded.toSorted((left, right) => - compareSteerTimelineSortable( - { id: left.id, sortAt: left.createdAt, sortRank: left.sortRank }, - { id: right.id, sortAt: right.createdAt, sortRank: right.sortRank }, - ), - ); -} - -/** @deprecated Use {@link interleaveTimelineEntriesForSteeredTurn}. */ -export const reorderTimelineEntriesForSteeredTurn = ( - timelineEntries: ReadonlyArray, - input: { - readonly unsettledTurnId?: TurnId | null; - readonly isWorking?: boolean; - readonly boundaryStore?: SteerTimelineBoundaryStore; - }, -): TimelineEntry[] => - interleaveTimelineEntriesForSteeredTurn(timelineEntries, { - ...(input.unsettledTurnId !== undefined ? { unsettledTurnId: input.unsettledTurnId } : {}), - ...(input.boundaryStore !== undefined ? { boundaryStore: input.boundaryStore } : {}), - }); - -/** - * Collapse assistant bubbles that re-surface the same body after tool activity - * within a turn. Grok multi-step ACP has been observed to re-emit the prior - * status line as a new message id after tools (A → tools → A → B); keep the - * first status and drop the twin so the timeline reads A → tools → B. - */ -export function collapseConsecutiveDuplicateAssistantEntries( - entries: ReadonlyArray, -): TimelineEntry[] { - const result: TimelineEntry[] = []; - for (const entry of entries) { - if (entry.kind !== "message" || entry.message.role !== "assistant") { - result.push(entry); - continue; - } - // Walk back across pure work rows so A → tools → A is detected. - let lookback = result.length - 1; - while (lookback >= 0 && result[lookback]?.kind === "work") { - lookback -= 1; - } - const priorAssistant = lookback >= 0 ? result[lookback] : undefined; - if ( - priorAssistant?.kind === "message" && - priorAssistant.message.role === "assistant" && - priorAssistant.message.turnId !== null && - priorAssistant.message.turnId === entry.message.turnId && - priorAssistant.message.text.replace(/\s+/g, " ").trim() === - entry.message.text.replace(/\s+/g, " ").trim() && - entry.message.text.trim().length > 0 - ) { - // Drop this later twin; keep the earlier status and intervening tools. - continue; - } - result.push(entry); - } - return result; -} - export function deriveMessagesTimelineRows(input: { timelineEntries: ReadonlyArray; latestTurn?: TimelineLatestTurn | null; @@ -743,32 +447,16 @@ export function deriveMessagesTimelineRows(input: { revertTurnCountByUserMessageId: ReadonlyMap; }): MessagesTimelineRow[] { const nextRows: MessagesTimelineRow[] = []; - // Always expand steers for every turn that has them (live + settled). The - // boundary store freezes pre-steer text on first observation so post-steer - // tokens keep rendering after the steer once the turn settles. - const displayTimelineEntries = collapseConsecutiveDuplicateAssistantEntries( - interleaveTimelineEntriesForSteeredTurn(input.timelineEntries), - ); const durationStartByMessageId = computeMessageDurationStart( - displayTimelineEntries.flatMap((entry) => (entry.kind === "message" ? [entry.message] : [])), - ); - const preferredTerminalMessageIdByTurn = new Map(); - if (input.latestTurn?.assistantMessageId != null) { - preferredTerminalMessageIdByTurn.set( - input.latestTurn.turnId, - input.latestTurn.assistantMessageId, - ); - } - const terminalAssistantMessageIds = deriveTerminalAssistantMessageIds( - displayTimelineEntries, - preferredTerminalMessageIdByTurn, + input.timelineEntries.flatMap((entry) => (entry.kind === "message" ? [entry.message] : [])), ); + const terminalAssistantMessageIds = deriveTerminalAssistantMessageIds(input.timelineEntries); const unsettledTurnId = deriveUnsettledTurnId( input.latestTurn ?? null, input.runningTurnId ?? null, ); const foldsByAnchorEntryId = deriveTurnFolds({ - timelineEntries: displayTimelineEntries, + timelineEntries: input.timelineEntries, terminalAssistantMessageIds, latestTurn: input.latestTurn ?? null, unsettledTurnId, @@ -782,8 +470,8 @@ export function deriveMessagesTimelineRows(input: { } } - for (let index = 0; index < displayTimelineEntries.length; index += 1) { - const timelineEntry = displayTimelineEntries[index]; + for (let index = 0; index < input.timelineEntries.length; index += 1) { + const timelineEntry = input.timelineEntries[index]; if (!timelineEntry) { continue; } @@ -805,28 +493,13 @@ export function deriveMessagesTimelineRows(input: { } if (timelineEntry.kind === "work") { - // Clarifying-question exchanges are conversation, not tool noise: they get - // their own row so neither work-group collapsing nor a turn fold hides them. - const userInput = timelineEntry.entry.userInput; - if (userInput) { - nextRows.push({ - kind: "user-input", - id: timelineEntry.id, - createdAt: timelineEntry.createdAt, - entry: timelineEntry.entry, - userInput, - }); - continue; - } - const groupedEntries = [timelineEntry.entry]; let cursor = index + 1; - while (cursor < displayTimelineEntries.length) { - const nextEntry = displayTimelineEntries[cursor]; + while (cursor < input.timelineEntries.length) { + const nextEntry = input.timelineEntries[cursor]; if ( !nextEntry || nextEntry.kind !== "work" || - nextEntry.entry.userInput !== undefined || collapsedEntryIds.has(nextEntry.id) || foldsByAnchorEntryId.has(nextEntry.id) ) { @@ -989,9 +662,6 @@ function isRowUnchanged(a: MessagesTimelineRow, b: MessagesTimelineRow): boolean case "work": return Equal.equals(a.groupedEntries, (b as typeof a).groupedEntries); - case "user-input": - return Equal.equals(a.userInput, (b as typeof a).userInput); - case "work-toggle": { const bw = b as typeof a; return ( diff --git a/apps/web/src/components/chat/MessagesTimeline.test.tsx b/apps/web/src/components/chat/MessagesTimeline.test.tsx index cd03feb82626..cf055f05b742 100644 --- a/apps/web/src/components/chat/MessagesTimeline.test.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.test.tsx @@ -9,7 +9,6 @@ vi.mock("@legendapp/list/react", async () => { const LegendList = (props: { data: Array<{ id: string }>; - extraData?: unknown; keyExtractor: (item: { id: string }) => string; renderItem: (args: { item: { id: string } }) => ReactNode; ListHeaderComponent?: ReactNode; @@ -49,7 +48,6 @@ vi.mock("@legendapp/list/react", async () => { return (
{}, onAnchorSizeChanged: () => {}, contentInsetEndAdjustment: 0, - maintainScrollAtEnd: true, + liveFollowEnabled: true, onIsAtEndChange: () => {}, onManualNavigation: () => {}, }; @@ -299,7 +297,7 @@ describe("MessagesTimeline", () => { expect(markup).toContain("1 changed file"); }); - it("uses LegendList isNearEnd when deciding whether the live edge is visible", async () => { + it("treats only the strict list end as the live edge", async () => { const { resolveTimelineIsAtEnd, resolveTimelineMinimapHasPersistentGutter, @@ -310,10 +308,36 @@ describe("MessagesTimeline", () => { resolveTimelineMinimapTopPercent, } = await import("./MessagesTimeline.logic"); - expect(resolveTimelineIsAtEnd({ isNearEnd: true, isAtEnd: false })).toBe(true); - expect(resolveTimelineIsAtEnd({ isNearEnd: false, isAtEnd: true })).toBe(false); expect(resolveTimelineIsAtEnd({ isAtEnd: true })).toBe(true); expect(resolveTimelineIsAtEnd(undefined)).toBeUndefined(); + // Within the pixel band above the content bottom counts as the end... + expect( + resolveTimelineIsAtEnd({ + isAtEnd: false, + contentLength: 2000, + scroll: 1170, + scrollLength: 800, + }), + ).toBe(true); + // ...but half a viewport up (LegendList's isNearEnd territory) does not. + expect( + resolveTimelineIsAtEnd({ + isAtEnd: false, + contentLength: 2000, + scroll: 900, + scrollLength: 800, + }), + ).toBe(false); + // The composer inset is part of contentLength and must not count as + // distance-to-end. + expect( + resolveTimelineIsAtEnd( + { isAtEnd: false, contentLength: 2100, scroll: 1170, scrollLength: 800 }, + 100, + ), + ).toBe(true); + // Geometry missing (older state shape): fall back to the strict flag. + expect(resolveTimelineIsAtEnd({ isAtEnd: false })).toBe(false); expect(resolveTimelineMinimapHeightStyle(5)).toBe("min(32px, calc(100vh - 18rem))"); expect(resolveTimelineMinimapTopPercent(2, 5)).toBe(50); @@ -398,7 +422,6 @@ describe("MessagesTimeline", () => { expect(markup).toContain('data-anchor-on-ready="true"'); expect(markup).not.toContain("data-anchor-max-size="); expect(markup).toContain('data-content-inset-end="144"'); - expect(markup).toContain('data-extra-data-matches-rows="true"'); expect(markup).toContain("[overflow-anchor:none]"); expect(markup).not.toContain('data-maintain-scroll-at-end="enabled"'); expect(markup).toContain('data-maintain-visible-content-position="object"'); @@ -428,22 +451,7 @@ describe("MessagesTimeline", () => { expect(markup).toContain('data-user-message-footer="true"'); }); - it("disables end maintenance after the user scrolls away", async () => { - const { MessagesTimeline } = await import("./MessagesTimeline"); - const markup = renderToStaticMarkup( - , - ); - - expect(markup).not.toContain('data-maintain-scroll-at-end="enabled"'); - expect(markup).toContain('data-maintain-visible-content-position="object"'); - }); - - it("does not render collapse controls for short user messages", async () => { - const { MessagesTimeline } = await import("./MessagesTimeline"); + it("does not render collapse controls for short user messages", () => { const markup = renderToStaticMarkup( { expect(markup).toContain("Work Log"); }); - it("renders a clarifying question with the answer it received", () => { - const markup = renderToStaticMarkup( - , - ); - - expect(markup).toContain("Approach"); - expect(markup).toContain("How should we proceed?"); - expect(markup).toContain("Iterate"); - expect(markup).toContain("Show options"); - // The chosen answer reads on its own; alternatives stay behind the toggle. - expect(markup).not.toContain("Another review round"); - }); - - it("marks an unanswered clarifying question as awaiting a reply", () => { - const markup = renderToStaticMarkup( - , - ); - - expect(markup).toContain("Awaiting your answer"); - expect(markup).not.toContain("Work Log"); - }); - it("formats changed file paths from the workspace root", () => { const markup = renderToStaticMarkup( { expect(markup).toContain("lucide-x"); expect(markup).toContain('aria-label="Tool call failed"'); }); - - it("offers a 'Load older history' control when older activity remains", async () => { - const { MessagesTimeline } = await import("./MessagesTimeline"); - const markup = renderToStaticMarkup( - , - ); - expect(markup).toContain("Load older history"); - }); - - it("shows a loading indicator while older history is being fetched", async () => { - const { MessagesTimeline } = await import("./MessagesTimeline"); - const markup = renderToStaticMarkup( - , - ); - expect(markup).toContain("Loading older history"); - }); - - it("renders no older-history control when none remains", async () => { - const { MessagesTimeline } = await import("./MessagesTimeline"); - const markup = renderToStaticMarkup( - , - ); - expect(markup).not.toContain("older history"); - }); }); diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index c53522fcd96c..a5fb03602046 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -76,7 +76,6 @@ import { computeStableMessagesTimelineRows, deriveMessagesTimelineRows, normalizeCompactToolLabel, - resolveOlderHistoryAutoLoad, resolveAssistantMessageCopyState, resolveTimelineIsAtEnd, resolveTimelineMinimapHasPersistentGutter, @@ -159,6 +158,33 @@ const TimelineRowCtx = createContext(null!); const TimelineRowActivityCtx = createContext(null!); const TIMELINE_LIST_HEADER =
; const TIMELINE_LIST_FADE_HEADER =
; + +// Header row shown when older turns exist beyond the loaded window. Plain +// button, no spinner animation; the label change is the loading indicator. +function TimelineLoadEarlierHeader({ + loading, + onLoadEarlier, + fade, +}: { + loading: boolean; + onLoadEarlier: () => void; + fade: boolean; +}) { + return ( +
+
+ +
+
+ ); +} const TIMELINE_LIST_FOOTER =
; const EMPTY_TIMELINE_SKILLS: ReadonlyArray> = []; @@ -193,17 +219,19 @@ interface MessagesTimelineProps { onAnchorReady: (messageId: MessageId, anchorIndex: number) => void; onAnchorSizeChanged: (messageId: MessageId, size: number) => void; contentInsetEndAdjustment: number; - maintainScrollAtEnd: boolean; + /** + * Whether the timeline should keep pinning to the live edge as content + * grows. Off while the user is reading history; LegendList's own + * maintainScrollAtEnd would otherwise re-pin regardless of ChatView's + * scroll-mode refs whenever the user drifts near the bottom. + */ + liveFollowEnabled: boolean; onIsAtEndChange: (isAtEnd: boolean) => void; onManualNavigation: () => void; hideEmptyPlaceholder?: boolean; topFadeEnabled?: boolean; - /** Older history beyond the live activity window can be lazy-loaded. */ - hasMoreOlder?: boolean; - loadingOlder?: boolean; - /** Increments after the older-history cursor advances or is reset. */ - olderHistoryCursorVersion?: number; - onLoadOlder?: () => void; + /** Non-null when older turns exist beyond the loaded window. */ + loadEarlier?: { readonly loading: boolean; readonly onLoadEarlier: () => void } | null; } // --------------------------------------------------------------------------- @@ -237,15 +265,12 @@ export const MessagesTimeline = memo(function MessagesTimeline({ onAnchorReady, onAnchorSizeChanged, contentInsetEndAdjustment, - maintainScrollAtEnd, + liveFollowEnabled, onIsAtEndChange, onManualNavigation, hideEmptyPlaceholder = false, topFadeEnabled = false, - hasMoreOlder = false, - loadingOlder = false, - olderHistoryCursorVersion = 0, - onLoadOlder, + loadEarlier = null, }: MessagesTimelineProps) { const [expandedTurnIds, setExpandedTurnIds] = useState>(new Set()); const [expandedWorkGroupIds, setExpandedWorkGroupIds] = useState>(new Set()); @@ -357,19 +382,6 @@ export const MessagesTimeline = memo(function MessagesTimeline({ ); const [minimapHasPersistentGutter, setMinimapHasPersistentGutter] = useState(false); const [minimapHitStripWidth, setMinimapHitStripWidth] = useState(0); - const olderHistoryAutoLoadArmedRef = useRef(true); - const olderHistoryObservedProgressVersionRef = useRef(olderHistoryCursorVersion); - const requestOlderHistory = useCallback(() => { - // Disarm before both automatic and explicit requests. If a request fails, - // prop changes while the viewport remains at the start must not trigger an - // immediate retry loop; the header button still permits a deliberate retry. - olderHistoryAutoLoadArmedRef.current = false; - onLoadOlder?.(); - }, [onLoadOlder]); - useEffect(() => { - olderHistoryAutoLoadArmedRef.current = true; - olderHistoryObservedProgressVersionRef.current = olderHistoryCursorVersion; - }, [routeThreadKey, olderHistoryCursorVersion]); const handleAnchorReady = useCallback( (info: { anchorIndex: number | undefined }) => { if (anchorMessageId !== null && info.anchorIndex !== undefined) { @@ -397,25 +409,10 @@ export const MessagesTimeline = memo(function MessagesTimeline({ const handleScroll = useCallback(() => { const state = listRef.current?.getState?.(); - const isAtEnd = resolveTimelineIsAtEnd(state); + const isAtEnd = resolveTimelineIsAtEnd(state, contentInsetEndAdjustment); if (isAtEnd !== undefined) { onIsAtEndChange(isAtEnd); } - // Reaching the top lazy-loads older history; maintainVisibleContentPosition - // (set on the list) keeps the viewport anchored when rows prepend. - const olderHistoryDecision = resolveOlderHistoryAutoLoad({ - armed: olderHistoryAutoLoadArmedRef.current, - hasMore: hasMoreOlder, - isAtStart: state?.isAtStart ?? false, - loading: loadingOlder, - observedProgressVersion: olderHistoryObservedProgressVersionRef.current, - progressVersion: olderHistoryCursorVersion, - }); - olderHistoryAutoLoadArmedRef.current = olderHistoryDecision.armed; - olderHistoryObservedProgressVersionRef.current = olderHistoryDecision.observedProgressVersion; - if (olderHistoryDecision.shouldLoad) { - requestOlderHistory(); - } if (!state || minimapItems.length === 0) { return; } @@ -438,16 +435,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ strip.dataset.inView = inView ? "true" : "false"; } - }, [ - listRef, - minimapItems, - minimapStripMap, - onIsAtEndChange, - hasMoreOlder, - loadingOlder, - olderHistoryCursorVersion, - requestOlderHistory, - ]); + }, [contentInsetEndAdjustment, listRef, minimapItems, minimapStripMap, onIsAtEndChange]); useEffect(() => { const frame = requestAnimationFrame(handleScroll); @@ -479,28 +467,6 @@ export const MessagesTimeline = memo(function MessagesTimeline({ }; }, [timelineViewportElement, rows.length]); - const listHeader = useMemo(() => { - if (loadingOlder) { - return ( -
- Loading older history… -
- ); - } - if (hasMoreOlder) { - return ( - - ); - } - return topFadeEnabled ? TIMELINE_LIST_FADE_HEADER : TIMELINE_LIST_HEADER; - }, [loadingOlder, hasMoreOlder, requestOlderHistory, topFadeEnabled]); - const sharedState = useMemo( () => ({ timestampFormat, @@ -550,11 +516,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ // from TimelineRowCtx, which propagates through LegendList's memo. const renderItem = useCallback( ({ item }: { item: MessagesTimelineRow }) => ( -
+
), @@ -565,21 +527,13 @@ export const MessagesTimeline = memo(function MessagesTimeline({ if (hideEmptyPlaceholder) { return null; } - // Only short-circuit to the empty state when there is genuinely nothing to - // fetch: the window can derive zero VISIBLE rows (e.g. only tool-neutral work - // entries) while older history still exists — the list must render then so - // its "Load older history" header stays reachable. - if (hasMoreOlder || loadingOlder) { - // Keep the list mounted so its older-history control remains reachable. - } else { - return ( -
-

- Send a message to start the conversation. -

-
- ); - } + return ( +
+

+ Send a message to start the conversation. +

+
+ ); } return ( @@ -589,12 +543,6 @@ export const MessagesTimeline = memo(function MessagesTimeline({ ref={listRef} data={rows} - // LegendList can retain a mounted container's previous child while - // anchored end-space is recomputed around a newly inserted turn. - // Re-running mounted renderers on each logical row-set change keeps - // the container content aligned with its current item key; memoized - // TimelineRowContent still skips unchanged rows. - extraData={rows} keyExtractor={keyExtractor} getItemType={getItemType} renderItem={renderItem} @@ -603,7 +551,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ {...(anchoredEndSpace ? { anchoredEndSpace } : {})} contentInsetEndAdjustment={contentInsetEndAdjustment} maintainScrollAtEnd={ - anchoredEndSpace || !maintainScrollAtEnd + anchoredEndSpace || !liveFollowEnabled ? false : { animated: false, @@ -623,7 +571,19 @@ export const MessagesTimeline = memo(function MessagesTimeline({ "scrollbar-gutter-both h-full min-h-0 overflow-x-hidden overscroll-y-contain px-3 [overflow-anchor:none] sm:px-5", topFadeEnabled && "chat-timeline-scroll-fade", )} - ListHeaderComponent={listHeader} + ListHeaderComponent={ + loadEarlier !== null ? ( + + ) : topFadeEnabled ? ( + TIMELINE_LIST_FADE_HEADER + ) : ( + TIMELINE_LIST_HEADER + ) + } ListFooterComponent={TIMELINE_LIST_FOOTER} /> [number]; type TimelineMessage = Extract["message"]; type TimelineWorkEntry = Extract["groupedEntries"][number]; type TimelineRow = MessagesTimelineRow; -type TimelineUserInputQuestion = Extract< - MessagesTimelineRow, - { kind: "user-input" } ->["userInput"]["questions"][number]; const TimelineRowContent = memo(function TimelineRowContent({ row }: { row: TimelineRow }) { return ( @@ -973,7 +929,6 @@ const TimelineRowContent = memo(function TimelineRowContent({ row }: { row: Time ) : null} {row.kind === "proposed-plan" ? : null} - {row.kind === "user-input" ? : null} {row.kind === "working" ? : null}
); @@ -1346,113 +1301,6 @@ function WorkGroupToggleTimelineRow({ ); } -/** - * A clarifying-question round trip: what the agent asked and what the user - * picked. The interactive prompt lives in the composer, so this row is the - * thread's only lasting record of the exchange. - */ -const UserInputTimelineRow = memo(function UserInputTimelineRow({ - row, -}: { - row: Extract; -}) { - const [showOptions, setShowOptions] = useState(false); - const { userInput } = row; - const hasUnpickedOptions = userInput.questions.some( - (question) => question.options.length > question.selectedLabels.length, - ); - - return ( -
- {userInput.questions.map((question, index) => ( -
0 && "mt-3 border-t border-border/40 pt-3")}> -
- - - {question.header} - -
-

{question.question}

- - {showOptions && question.options.length > 0 ? ( -
    - {question.options.map((option) => { - const picked = question.selectedLabels.includes(option.label); - return ( -
  • - {option.label} - {option.description && option.description !== option.label ? ( - {option.description} - ) : null} -
  • - ); - })} -
- ) : null} -
- ))} - {hasUnpickedOptions ? ( - - ) : null} -
- ); -}); - -function UserInputAnswer({ - answered, - question, -}: { - answered: boolean; - question: TimelineUserInputQuestion; -}) { - const { selectedLabels, customAnswer } = question; - - if (selectedLabels.length === 0 && !customAnswer) { - return ( -

- {answered ? "No answer recorded" : "Awaiting your answer"} -

- ); - } - - return ( -
- {selectedLabels.map((label) => ( -

- - {label} -

- ))} - {customAnswer ? ( -

- - {customAnswer} -

- ) : null} -
- ); -} - /** Subscribes directly to the UI state store for expand/collapse state, * so toggling re-renders only this component — not the entire list. */ const AssistantChangedFilesSection = memo(function AssistantChangedFilesSection({ diff --git a/apps/web/src/components/sidebar/SidebarUpdatePill.tsx b/apps/web/src/components/sidebar/SidebarUpdatePill.tsx index fe06175cc34a..fd1554136545 100644 --- a/apps/web/src/components/sidebar/SidebarUpdatePill.tsx +++ b/apps/web/src/components/sidebar/SidebarUpdatePill.tsx @@ -43,7 +43,7 @@ function SidebarUpdateReleaseNotesTooltip({
{tooltip}
-
+
{state.releaseNotes.map((releaseNote, index) => (
{index > 0 && } @@ -206,7 +206,9 @@ export function SidebarUpdatePill() { align="start" className={ state?.channel === "nightly" && state.releaseNotes.length > 0 - ? "max-w-none text-balance" + ? // pointer-events-auto overrides the positioner's pointer-events-none so the + // release notes stay open (and scrollable) when the cursor moves into them. + "pointer-events-auto max-w-none text-balance" : undefined } side="top" diff --git a/apps/web/src/connection/storage.ts b/apps/web/src/connection/storage.ts index c5f5f22bfdae..4ae476c1d11f 100644 --- a/apps/web/src/connection/storage.ts +++ b/apps/web/src/connection/storage.ts @@ -49,11 +49,12 @@ const StoredShellSnapshot = Schema.Struct({ snapshot: OrchestrationShellSnapshot, }); const StoredShellSnapshotJson = Schema.fromJsonString(StoredShellSnapshot); -// v3 invalidates pre-pagination warm caches: v2 entries may still hold the full -// unbounded activity history and would rehydrate multi-MB threads into the -// renderer heap. Older v1/v2 entries fail to decode and are treated as cold. -// v2 stored the snapshot sequence alongside the thread so a warm cache can +// v2 stores the snapshot sequence alongside the thread so a warm cache can // resume via `afterSequence` instead of re-downloading the full thread body. +// v3 adds windowed (paginated) snapshots carrying `page` metadata. The bump +// exists for rollback safety: a pre-pagination client would decode a windowed +// v2 record, silently drop the unknown `page` field, and treat the partial +// thread as complete forever. Older entries fail to decode → cold cache. const StoredThreadSnapshot = Schema.Struct({ schemaVersion: Schema.Literal(3), environmentId: EnvironmentId, diff --git a/packages/client-runtime/package.json b/packages/client-runtime/package.json index 0ad2e2ca4962..19d35411faca 100644 --- a/packages/client-runtime/package.json +++ b/packages/client-runtime/package.json @@ -147,10 +147,6 @@ "types": "./src/state/threadReducer.ts", "default": "./src/state/threadReducer.ts" }, - "./state/older-thread-activities": { - "types": "./src/state/olderThreadActivities.ts", - "default": "./src/state/olderThreadActivities.ts" - }, "./state/thread-sort": { "types": "./src/state/threadSort.ts", "default": "./src/state/threadSort.ts" diff --git a/packages/client-runtime/src/connection/supervisor.test.ts b/packages/client-runtime/src/connection/supervisor.test.ts index 53c38439bee2..5e50c44d9610 100644 --- a/packages/client-runtime/src/connection/supervisor.test.ts +++ b/packages/client-runtime/src/connection/supervisor.test.ts @@ -27,7 +27,6 @@ import { type SupervisorConnectionState, } from "./model.ts"; import * as RpcSession from "../rpc/session.ts"; -import * as ConnectionDiagnosticsLog from "./diagnosticsLog.ts"; import * as EnvironmentSupervisor from "./supervisor.ts"; import * as ConnectionWakeups from "./wakeups.ts"; @@ -117,13 +116,9 @@ const makeHarness = Effect.fn("TestConnectionHarness.make")(function* (options?: readonly ready?: (attempt: number) => Effect.Effect; readonly probe?: (attempt: number) => Effect.Effect; }) { - // `reportedNetworkStatus` drives the change stream while `liveNetworkStatus` - // answers status reads, so a test can simulate a platform listener that missed - // a transition while the app was suspended. - const reportedNetworkStatus = yield* SubscriptionRef.make( + const networkStatus = yield* SubscriptionRef.make( options?.networkStatus ?? "online", ); - const liveNetworkStatus = yield* Ref.make(options?.networkStatus ?? "online"); const prepareCount = yield* Ref.make(0); const sessionCount = yield* Ref.make(0); const releaseCount = yield* Ref.make(0); @@ -139,8 +134,8 @@ const makeHarness = Effect.fn("TestConnectionHarness.make")(function* (options?: >([]); const connectivity = Connectivity.Connectivity.of({ - status: Ref.get(liveNetworkStatus), - changes: SubscriptionRef.changes(reportedNetworkStatus), + status: SubscriptionRef.get(networkStatus), + changes: SubscriptionRef.changes(networkStatus), }); const prepare = Effect.fn("TestConnectionDriver.prepare")(function* (target: ConnectionTarget) { @@ -195,7 +190,6 @@ const makeHarness = Effect.fn("TestConnectionHarness.make")(function* (options?: ConnectionDriver.ConnectionDriver, ConnectionDriver.ConnectionDriver.of({ connect }), ), - ConnectionDiagnosticsLog.layer, ); return { @@ -203,13 +197,7 @@ const makeHarness = Effect.fn("TestConnectionHarness.make")(function* (options?: prepareCount, sessionCount, releaseCount, - setNetworkStatus: (status: NetworkStatus) => - Ref.set(liveNetworkStatus, status).pipe( - Effect.andThen(SubscriptionRef.set(reportedNetworkStatus, status)), - ), - // Changes the network the device is actually on without emitting a change - // event, mimicking a listener that was suspended while backgrounded. - setNetworkStatusWithoutNotifying: (status: NetworkStatus) => Ref.set(liveNetworkStatus, status), + setNetworkStatus: (status: NetworkStatus) => SubscriptionRef.set(networkStatus, status), wake: (reason: ConnectionWakeups.ConnectionWakeup) => SubscriptionRef.update(wakeups, (event) => ({ sequence: event.sequence + 1, @@ -260,7 +248,7 @@ describe("EnvironmentSupervisor", () => { const firstAttempt = spans.find((span) => span.name === "relay.connection.attempt"); expect(firstAttempt).toBeDefined(); - yield* TestClock.adjust("1 second"); + yield* TestClock.adjust("3 seconds"); yield* awaitState(supervisor.state, (state) => state.phase === "connected"); const attempts = spans.filter((span) => span.name === "relay.connection.attempt"); @@ -355,42 +343,6 @@ describe("EnvironmentSupervisor", () => { }), ); - it.effect("recovers from a network change the platform dropped while suspended", () => - Effect.gen(function* () { - const harness = yield* makeHarness({ networkStatus: "offline" }); - const supervisor = yield* EnvironmentSupervisor.make(TARGET_ENTRY, { - initiallyDesired: true, - }).pipe(Effect.provide(harness.dependencies)); - - yield* awaitState(supervisor.state, (state) => state.phase === "offline"); - - // The device regained connectivity while backgrounded, but the listener - // was suspended and never reported the transition. - yield* harness.setNetworkStatusWithoutNotifying("online"); - - // The supervisor reaches its offline state before the forked wakeup - // listener subscribes, so repeat the resume until it is observed. - yield* harness.wake("application-active").pipe( - Effect.andThen(Effect.yieldNow), - Effect.repeat({ - while: () => - SubscriptionRef.get(supervisor.state).pipe( - Effect.map((state) => state.phase === "offline"), - ), - }), - ); - - const ready = yield* awaitState(supervisor.state, (state) => state.phase === "connected"); - expect(ready).toMatchObject({ - desired: true, - network: "online", - phase: "connected", - lastFailure: null, - }); - expect(yield* Ref.get(harness.prepareCount)).toBe(1); - }), - ); - it.effect("retries forever with exponential backoff capped at sixteen seconds", () => Effect.gen(function* () { const harness = yield* makeHarness({ @@ -406,7 +358,7 @@ describe("EnvironmentSupervisor", () => { ); expect(yield* Ref.get(harness.prepareCount)).toBe(1); - for (const [index, delay] of [1_000, 2_000, 4_000, 8_000, 16_000, 16_000].entries()) { + for (const [index, delay] of [3_000, 4_000, 8_000, 16_000, 16_000, 16_000].entries()) { yield* TestClock.adjust(delay); yield* eventuallyState( supervisor.state, @@ -432,7 +384,7 @@ describe("EnvironmentSupervisor", () => { supervisor.state, (state) => state.phase === "backoff" && state.attempt === 1, ); - yield* TestClock.adjust("1 second"); + yield* TestClock.adjust("3 seconds"); const retrying = yield* awaitState( supervisor.state, @@ -537,7 +489,7 @@ describe("EnvironmentSupervisor", () => { }, }); - yield* TestClock.adjust("1 second"); + yield* TestClock.adjust("3 seconds"); yield* awaitState(supervisor.state, (state) => state.phase === "connected"); expect(yield* Ref.get(harness.prepareCount)).toBe(2); }).pipe(Effect.provide(TestClock.layer())), @@ -561,6 +513,43 @@ describe("EnvironmentSupervisor", () => { }), ); + it.effect("explicit retry starts a fresh backoff sequence", () => + Effect.gen(function* () { + const harness = yield* makeHarness({ + prepare: () => Effect.fail(transient()), + }); + const supervisor = yield* EnvironmentSupervisor.make(TARGET_ENTRY, { + initiallyDesired: true, + }).pipe(Effect.provide(harness.dependencies)); + + yield* awaitState( + supervisor.state, + (state) => state.phase === "backoff" && state.attempt === 1, + ); + yield* TestClock.adjust("3 seconds"); + yield* eventuallyState( + supervisor.state, + (state) => state.phase === "backoff" && state.attempt === 2, + ); + + yield* supervisor.retryNow; + yield* eventuallyState( + supervisor.state, + (state) => state.phase === "backoff" && state.attempt === 1, + ); + expect(yield* Ref.get(harness.prepareCount)).toBe(3); + + yield* TestClock.adjust("2999 millis"); + expect(yield* Ref.get(harness.prepareCount)).toBe(3); + yield* TestClock.adjust("1 milli"); + yield* eventuallyState( + supervisor.state, + (state) => state.phase === "backoff" && state.attempt === 2, + ); + expect(yield* Ref.get(harness.prepareCount)).toBe(4); + }).pipe(Effect.provide(TestClock.layer())), + ); + it.effect("keeps blocked failures idle until an external signal requests another attempt", () => Effect.gen(function* () { const harness = yield* makeHarness({ @@ -581,6 +570,38 @@ describe("EnvironmentSupervisor", () => { }).pipe(Effect.provide(TestClock.layer())), ); + it.effect("resets retries when activation wakes a blocked connection", () => + Effect.gen(function* () { + const harness = yield* makeHarness({ + prepare: (attempt) => + attempt === 1 + ? Effect.fail(transient()) + : attempt === 2 + ? Effect.fail(blocked()) + : Effect.succeed(PREPARED_CONNECTION), + }); + const supervisor = yield* EnvironmentSupervisor.make(TARGET_ENTRY, { + initiallyDesired: true, + }).pipe(Effect.provide(harness.dependencies)); + + yield* awaitState( + supervisor.state, + (state) => state.phase === "backoff" && state.attempt === 1, + ); + yield* TestClock.adjust("3 seconds"); + yield* awaitState( + supervisor.state, + (state) => state.phase === "blocked" && state.attempt === 2, + ); + + yield* harness.wake("application-active-reconnect"); + yield* awaitState( + supervisor.state, + (state) => state.phase === "connected" && state.attempt === 1, + ); + }).pipe(Effect.provide(TestClock.layer())), + ); + it.effect("releases a live session while offline and starts a new generation when online", () => Effect.gen(function* () { const harness = yield* makeHarness(); @@ -682,7 +703,7 @@ describe("EnvironmentSupervisor", () => { ); expect(Option.isNone(yield* SubscriptionRef.get(supervisor.prepared))).toBe(true); - yield* TestClock.adjust("1 second"); + yield* TestClock.adjust("3 seconds"); yield* awaitState( supervisor.state, (state) => state.phase === "connected" && state.generation === 2, @@ -707,7 +728,7 @@ describe("EnvironmentSupervisor", () => { (state) => state.phase === "backoff" && state.attempt === 1, ); - yield* TestClock.adjust("1 second"); + yield* TestClock.adjust("3 seconds"); yield* awaitState( supervisor.state, (state) => state.phase === "connected" && state.generation === 2, @@ -720,7 +741,7 @@ describe("EnvironmentSupervisor", () => { expect(secondFailure.retryAt).not.toBeNull(); - yield* TestClock.adjust("1 second"); + yield* TestClock.adjust("3 seconds"); expect(yield* Ref.get(harness.sessionCount)).toBe(2); yield* TestClock.adjust("1 second"); @@ -732,6 +753,101 @@ describe("EnvironmentSupervisor", () => { }).pipe(Effect.provide(TestClock.layer())), ); + it.effect("restarts the retry ladder when mobile returns to the foreground", () => + Effect.gen(function* () { + const harness = yield* makeHarness(); + const supervisor = yield* EnvironmentSupervisor.make(TARGET_ENTRY, { + initiallyDesired: true, + }).pipe(Effect.provide(harness.dependencies)); + + yield* awaitState(supervisor.state, (state) => state.phase === "connected"); + yield* harness.closeLatestSession(); + yield* awaitState( + supervisor.state, + (state) => state.phase === "backoff" && state.attempt === 1, + ); + yield* TestClock.adjust("3 seconds"); + yield* awaitState( + supervisor.state, + (state) => state.phase === "connected" && state.generation === 2, + ); + yield* harness.closeLatestSession(); + yield* awaitState( + supervisor.state, + (state) => state.phase === "backoff" && state.attempt === 2, + ); + + yield* harness.wake("application-active-reconnect"); + yield* awaitState( + supervisor.state, + (state) => state.phase === "connected" && state.generation === 3 && state.attempt === 1, + ); + yield* harness.closeLatestSession(); + yield* awaitState( + supervisor.state, + (state) => state.phase === "backoff" && state.attempt === 1, + ); + + expect(yield* Ref.get(harness.sessionCount)).toBe(3); + }).pipe(Effect.provide(TestClock.layer())), + ); + + it.effect("restarts the retry ladder when a long resume replaces a connected session", () => + Effect.gen(function* () { + const harness = yield* makeHarness(); + const supervisor = yield* EnvironmentSupervisor.make(TARGET_ENTRY, { + initiallyDesired: true, + }).pipe(Effect.provide(harness.dependencies)); + + yield* awaitState(supervisor.state, (state) => state.phase === "connected"); + yield* harness.closeLatestSession(); + yield* awaitState( + supervisor.state, + (state) => state.phase === "backoff" && state.attempt === 1, + ); + yield* TestClock.adjust("3 seconds"); + yield* awaitState( + supervisor.state, + (state) => state.phase === "connected" && state.generation === 2 && state.attempt === 2, + ); + + yield* harness.wake("application-active-reconnect"); + yield* awaitState( + supervisor.state, + (state) => state.phase === "connected" && state.generation === 3 && state.attempt === 1, + ); + }).pipe(Effect.provide(TestClock.layer())), + ); + + it.effect("restarts the retry ladder when a long resume interrupts connection setup", () => + Effect.gen(function* () { + const harness = yield* makeHarness({ + prepare: (attempt) => (attempt === 2 ? Effect.never : Effect.succeed(PREPARED_CONNECTION)), + }); + const supervisor = yield* EnvironmentSupervisor.make(TARGET_ENTRY, { + initiallyDesired: true, + }).pipe(Effect.provide(harness.dependencies)); + + yield* awaitState(supervisor.state, (state) => state.phase === "connected"); + yield* harness.closeLatestSession(); + yield* awaitState( + supervisor.state, + (state) => state.phase === "backoff" && state.attempt === 1, + ); + yield* TestClock.adjust("3 seconds"); + yield* awaitState( + supervisor.state, + (state) => state.phase === "connecting" && state.attempt === 2, + ); + + yield* harness.wake("application-active-reconnect"); + yield* awaitState( + supervisor.state, + (state) => state.phase === "connected" && state.generation === 2 && state.attempt === 1, + ); + }).pipe(Effect.provide(TestClock.layer())), + ); + it.effect("probes the active session without reconnecting on application activation", () => Effect.gen(function* () { const probeCount = yield* Ref.make(0); @@ -757,9 +873,66 @@ describe("EnvironmentSupervisor", () => { }), ); - it.effect("keeps the open session when the foreground liveness probe fails", () => + it.effect("immediately replaces a mobile session after a long background resume", () => + Effect.gen(function* () { + const probeCount = yield* Ref.make(0); + const harness = yield* makeHarness({ + probe: () => Ref.update(probeCount, (count) => count + 1), + }); + const supervisor = yield* EnvironmentSupervisor.make(TARGET_ENTRY, { + initiallyDesired: true, + }).pipe(Effect.provide(harness.dependencies)); + + yield* awaitState( + supervisor.state, + (state) => state.phase === "connected" && state.generation === 1, + ); + yield* harness.wake("application-active-reconnect"); + yield* awaitState( + supervisor.state, + (state) => state.phase === "connected" && state.generation === 2, + ); + + expect(yield* Ref.get(probeCount)).toBe(0); + expect(yield* Ref.get(harness.sessionCount)).toBe(2); + expect(yield* Ref.get(harness.releaseCount)).toBe(1); + }), + ); + + it.effect("replaces a mobile session when a long resume interrupts an active probe", () => + Effect.gen(function* () { + const harness = yield* makeHarness({ + probe: (attempt) => (attempt === 1 ? Effect.never : Effect.void), + }); + const supervisor = yield* EnvironmentSupervisor.make(TARGET_ENTRY, { + initiallyDesired: true, + }).pipe(Effect.provide(harness.dependencies)); + + yield* awaitState( + supervisor.state, + (state) => state.phase === "connected" && state.generation === 1, + ); + yield* harness.wake("application-active-probe"); + yield* Effect.yieldNow; + yield* harness.wake("application-active-reconnect"); + yield* awaitState( + supervisor.state, + (state) => state.phase === "connected" && state.generation === 2, + ); + + expect(yield* Ref.get(harness.sessionCount)).toBe(2); + expect(yield* Ref.get(harness.releaseCount)).toBe(1); + }), + ); + + it.effect("reconnects immediately when the foreground liveness probe fails", () => Effect.gen(function* () { + const allowReconnect = yield* Deferred.make(); const harness = yield* makeHarness({ + prepare: (attempt) => + attempt === 2 + ? Deferred.await(allowReconnect).pipe(Effect.as(PREPARED_CONNECTION)) + : Effect.succeed(PREPARED_CONNECTION), probe: (attempt) => attempt === 1 ? Effect.fail(transient("The live session is stale.")) : Effect.void, }); @@ -769,18 +942,32 @@ describe("EnvironmentSupervisor", () => { yield* awaitState(supervisor.state, (state) => state.phase === "connected"); yield* harness.wake("application-active"); - yield* Effect.yieldNow; + const reconnecting = yield* awaitState( + supervisor.state, + (state) => state.phase === "connecting", + ); + expect(reconnecting.attempt).toBe(1); + expect(Option.isNone(yield* SubscriptionRef.get(supervisor.session))).toBe(true); - expect(yield* Ref.get(harness.sessionCount)).toBe(1); - expect(yield* Ref.get(harness.releaseCount)).toBe(0); - expect((yield* SubscriptionRef.get(supervisor.state)).phase).toBe("connected"); - }), + // No TestClock advance: a failed wake probe skips the first backoff rung. + yield* Deferred.succeed(allowReconnect, undefined); + yield* awaitState( + supervisor.state, + (state) => state.phase === "connected" && state.generation === 2 && state.attempt === 1, + ); + + expect(yield* Ref.get(harness.sessionCount)).toBe(2); + expect(yield* Ref.get(harness.releaseCount)).toBe(1); + }).pipe(Effect.provide(TestClock.layer())), ); - it.effect("keeps the open session when the foreground liveness probe times out", () => + it.effect("keeps normal backoff when a reconnect after a failed wake probe also fails", () => Effect.gen(function* () { const harness = yield* makeHarness({ - probe: (attempt) => (attempt === 1 ? Effect.never : Effect.void), + prepare: (attempt) => + attempt === 2 ? Effect.fail(transient()) : Effect.succeed(PREPARED_CONNECTION), + probe: (attempt) => + attempt === 1 ? Effect.fail(transient("The live session is stale.")) : Effect.void, }); const supervisor = yield* EnvironmentSupervisor.make(TARGET_ENTRY, { initiallyDesired: true, @@ -788,11 +975,67 @@ describe("EnvironmentSupervisor", () => { yield* awaitState(supervisor.state, (state) => state.phase === "connected"); yield* harness.wake("application-active"); - yield* TestClock.adjust("15 seconds"); + // The immediate follow-up attempt fails: only the first attempt after + // the wake probe skips the ladder, so this failure backs off normally. + yield* awaitState( + supervisor.state, + (state) => state.phase === "backoff" && state.attempt === 1, + ); + yield* TestClock.adjust("2999 millis"); + expect(yield* Ref.get(harness.prepareCount)).toBe(2); + yield* TestClock.adjust("1 milli"); + yield* eventuallyState( + supervisor.state, + (state) => state.phase === "connected" && state.generation === 2, + ); + + expect(yield* Ref.get(harness.prepareCount)).toBe(3); + }).pipe(Effect.provide(TestClock.layer())), + ); + + it.effect("uses the full tolerance window for a stalled desktop foreground probe", () => + Effect.gen(function* () { + const harness = yield* makeHarness({ + probe: (attempt) => (attempt === 1 ? Effect.never : Effect.void), + }); + const supervisor = yield* EnvironmentSupervisor.make(TARGET_ENTRY, { + initiallyDesired: true, + }).pipe(Effect.provide(harness.dependencies)); + yield* awaitState(supervisor.state, (state) => state.phase === "connected"); + yield* harness.wake("application-active"); + yield* TestClock.adjust("14999 millis"); expect(yield* Ref.get(harness.sessionCount)).toBe(1); - expect(yield* Ref.get(harness.releaseCount)).toBe(0); - expect((yield* SubscriptionRef.get(supervisor.state)).phase).toBe("connected"); + yield* TestClock.adjust("1 milli"); + yield* awaitState( + supervisor.state, + (state) => state.phase === "connected" && state.generation === 2 && state.attempt === 1, + ); + + expect(yield* Ref.get(harness.sessionCount)).toBe(2); + }).pipe(Effect.provide(TestClock.layer())), + ); + + it.effect("quickly times out a stalled mobile foreground liveness probe", () => + Effect.gen(function* () { + const harness = yield* makeHarness({ + probe: (attempt) => (attempt === 1 ? Effect.never : Effect.void), + }); + const supervisor = yield* EnvironmentSupervisor.make(TARGET_ENTRY, { + initiallyDesired: true, + }).pipe(Effect.provide(harness.dependencies)); + + yield* awaitState(supervisor.state, (state) => state.phase === "connected"); + yield* harness.wake("application-active-probe"); + yield* TestClock.adjust("3 seconds"); + // The timed-out wake probe reconnects immediately without a backoff + // sleep: no further clock advance is needed. + yield* awaitState( + supervisor.state, + (state) => state.phase === "connected" && state.generation === 2 && state.attempt === 1, + ); + + expect(yield* Ref.get(harness.sessionCount)).toBe(2); }).pipe(Effect.provide(TestClock.layer())), ); diff --git a/packages/client-runtime/src/connection/supervisor.ts b/packages/client-runtime/src/connection/supervisor.ts index 3386cd12a5f2..85fda10ef1a7 100644 --- a/packages/client-runtime/src/connection/supervisor.ts +++ b/packages/client-runtime/src/connection/supervisor.ts @@ -27,10 +27,9 @@ import { } from "./model.ts"; import * as RpcSession from "../rpc/session.ts"; import { safeErrorLogAttributes } from "../errors/safeLog.ts"; -import * as ConnectionDiagnosticsLog from "./diagnosticsLog.ts"; import * as ConnectionWakeups from "./wakeups.ts"; -const RETRY_DELAYS_MS = [1_000, 2_000, 4_000, 8_000, 16_000] as const; +const RETRY_DELAYS_MS = [3_000, 4_000, 8_000, 16_000] as const; const CONNECTION_ESTABLISHMENT_TIMEOUT = "15 seconds"; const CONNECTION_PROBE_TIMEOUT = "15 seconds"; const MOBILE_CONNECTION_PROBE_TIMEOUT = "3 seconds"; @@ -226,28 +225,6 @@ export const make = Effect.fn("EnvironmentSupervisor.make")(function* ( const connectivity = yield* Connectivity.Connectivity; const driver = yield* ConnectionDriver.ConnectionDriver; const wakeups = yield* ConnectionWakeups.ConnectionWakeups; - const diagnosticsLog = yield* Effect.serviceOption( - ConnectionDiagnosticsLog.ConnectionDiagnosticsLog, - ); - - const recordDiagnostic = (input: { - readonly kind: ConnectionDiagnosticsLog.ConnectionDiagnosticKind; - readonly error: ConnectionAttemptError; - readonly attempt: number; - }) => - Option.match(diagnosticsLog, { - onNone: () => Effect.void, - onSome: (log) => - log.record({ - environmentId: target.environmentId, - label: target.label, - kind: input.kind, - reason: input.error.reason, - detail: input.error.detail, - traceId: input.error.traceId, - attempt: input.attempt, - }), - }); const initialIntent: SupervisorIntent = { desired: options?.initiallyDesired ?? false, network: yield* connectivity.status, @@ -255,6 +232,10 @@ export const make = Effect.fn("EnvironmentSupervisor.make")(function* ( const intent = yield* Ref.make(initialIntent); const signals = yield* Queue.unbounded(); const resetRetryState = yield* Ref.make(false); + // Set when a foreground wake probe fails or times out: the user is actively + // returning to the app on a dead transport, so the follow-up reconnect skips + // the first backoff rung instead of sleeping. + const wakeProbeFailed = yield* Ref.make(false); const state = yield* SubscriptionRef.make( !initialIntent.desired ? availableState(initialIntent, 0) @@ -452,18 +433,6 @@ export const make = Effect.fn("EnvironmentSupervisor.make")(function* ( }), ), }), - Effect.catch((error) => - Effect.logWarning( - "Foreground connection health check failed; keeping the open WebSocket lease.", - ).pipe( - Effect.annotateLogs({ - "environment.id": target.environmentId, - "environment.label": target.label, - "connection.probe.reason": error.reason, - "connection.probe.detail": error.detail, - }), - ), - ), Effect.forkChild, ); for (;;) { @@ -476,6 +445,9 @@ export const make = Effect.fn("EnvironmentSupervisor.make")(function* ( ), ); if (probeEvent._tag === "ProbeCompleted") { + if (Exit.isFailure(probeEvent.exit)) { + yield* Ref.set(wakeProbeFailed, true); + } yield* probeEvent.exit; break; } @@ -708,6 +680,9 @@ export const make = Effect.fn("EnvironmentSupervisor.make")(function* ( const outcome: AttemptOutcome = yield* Effect.scoped( runAttempt(attempt, nextGeneration, latestFailure, pendingRetry), ); + // Consumed on every iteration so a stale marker can never leak into a + // later, unrelated failure. + const failedWakeProbe = yield* Ref.getAndSet(wakeProbeFailed, false); if (outcome.established) { generation = nextGeneration; if (outcome.stable) { @@ -723,21 +698,9 @@ export const make = Effect.fn("EnvironmentSupervisor.make")(function* ( } const attemptSpan: Option.Option = outcome.failure.attemptSpan; - let error: ConnectionAttemptError = outcome.failure.error; - // Attach the environment label to short transport messages from the RPC layer. - if ( - error._tag === "ConnectionTransientError" && - (error.detail === "ping timeout" || error.detail === "ping timeout.") - ) { - error = new ConnectionTransientError({ - reason: error.reason, - detail: `${target.label} ping timeout.`, - ...(error.traceId !== undefined ? { traceId: error.traceId } : {}), - }); - } + const error: ConnectionAttemptError = outcome.failure.error; latestFailure = error; if (error._tag === "ConnectionBlockedError") { - yield* recordDiagnostic({ kind: "blocked", error, attempt }); const blockedIntent = yield* Ref.get(intent); yield* setState({ desired: blockedIntent.desired, @@ -756,6 +719,16 @@ export const make = Effect.fn("EnvironmentSupervisor.make")(function* ( continue; } + if (failedWakeProbe) { + // The wake probe found a dead transport while the user is returning to + // the app, so reconnect immediately instead of sleeping the first + // backoff rung. Only this first attempt skips the ladder; if it fails + // too, normal backoff resumes. + resetRetryLadder(); + yield* setState(connectingState(yield* Ref.get(intent), generation, 1, error)); + continue; + } + failureCount += 1; const delayMs = retryDelayMs(failureCount - 1); pendingRetry = Option.map(attemptSpan, (previousAttempt) => ({ @@ -764,11 +737,6 @@ export const make = Effect.fn("EnvironmentSupervisor.make")(function* ( delayMs, reason: error.reason, })); - yield* recordDiagnostic({ - kind: outcome.established ? "disconnect" : "connect_failed", - error, - attempt, - }); const failedIntent = yield* Ref.get(intent); yield* setState({ desired: failedIntent.desired, @@ -787,23 +755,18 @@ export const make = Effect.fn("EnvironmentSupervisor.make")(function* ( } }); - const applyNetworkStatus = Effect.fnUntraced(function* (network: NetworkStatus) { - const changed = yield* Ref.modify(intent, (current) => - current.network === network ? [false, current] : ([true, { ...current, network }] as const), - ); - if (changed) { - yield* signal({ _tag: "NetworkChanged", network }); - } - }); - - // The offline branch of `run` only waits for signals and re-reads the same - // cached network value, so a transition dropped while the app was suspended - // would otherwise strand this supervisor until the app restarted. - yield* Connectivity.followNetworkStatus({ - connectivity, - wakeups, - apply: applyNetworkStatus, - }); + yield* connectivity.changes.pipe( + Stream.runForEach((network) => + Ref.modify(intent, (current) => + current.network === network ? [false, current] : ([true, { ...current, network }] as const), + ).pipe( + Effect.flatMap((changed) => + changed ? signal({ _tag: "NetworkChanged", network }) : Effect.void, + ), + ), + ), + Effect.forkScoped, + ); yield* wakeups.changes.pipe( Stream.runForEach((reason) => signal({ _tag: "Wakeup", reason })), Effect.forkScoped, diff --git a/packages/client-runtime/src/rpc/session.test.ts b/packages/client-runtime/src/rpc/session.test.ts index 2a0df56f7855..0af5850bf6c7 100644 --- a/packages/client-runtime/src/rpc/session.test.ts +++ b/packages/client-runtime/src/rpc/session.test.ts @@ -261,62 +261,13 @@ describe("RpcSessionFactory", () => { expect(error).toBeInstanceOf(ConnectionTransientError); expect(error).toMatchObject({ reason: "transport", - message: "Test environment closed (1012 service restart).", + message: "Test environment disconnected.", }); yield* Effect.yieldNow; expect(sockets).toHaveLength(1); }), ); - it.effect("reports ping timeout instead of a bare disconnected message", () => - Effect.scoped( - Effect.gen(function* () { - const { factory, sockets } = yield* makeFactory(); - const session = yield* factory.connect(PREPARED); - const readyFiber = yield* Effect.forkChild(session.ready); - const socket = yield* awaitSocket(sockets); - - socket.open(); - yield* completeInitialConfig(socket); - yield* Fiber.join(readyFiber); - - // Effect RPC pinger: first 5s sends ping; second 5s without pong opens the timeout latch. - yield* TestClock.adjust("10 seconds"); - const error = yield* Effect.flip(session.closed); - - expect(error).toBeInstanceOf(ConnectionTransientError); - expect(error).toMatchObject({ - reason: "timeout", - message: "Test environment ping timeout.", - }); - }), - ), - ); - - it.effect("reports abnormal close codes from the socket failure path", () => - Effect.scoped( - Effect.gen(function* () { - const { factory, sockets } = yield* makeFactory(); - const session = yield* factory.connect(PREPARED); - const readyFiber = yield* Effect.forkChild(session.ready); - const socket = yield* awaitSocket(sockets); - - socket.open(); - yield* completeInitialConfig(socket); - yield* Fiber.join(readyFiber); - - socket.close(1006, ""); - const error = yield* Effect.flip(session.closed); - - expect(error).toBeInstanceOf(ConnectionTransientError); - expect(error).toMatchObject({ - reason: "transport", - message: "Test environment closed (1006 abnormal).", - }); - }), - ), - ); - it.effect("closes the websocket when the session scope is released", () => Effect.gen(function* () { const { factory, sockets } = yield* makeFactory(); @@ -336,6 +287,67 @@ describe("RpcSessionFactory", () => { }), ); + it.effect("tolerates two missed pong windows before closing the session", () => + Effect.gen(function* () { + const { factory, sockets } = yield* makeFactory(); + const session = yield* factory.connect(PREPARED); + const readyFiber = yield* Effect.forkChild(session.ready); + const closedFiber = yield* Effect.forkChild(Effect.flip(session.closed)); + const socket = yield* awaitSocket(sockets); + + socket.open(); + yield* completeInitialConfig(socket); + yield* Fiber.join(readyFiber); + + yield* TestClock.adjust("15 seconds"); + expect(closedFiber.pollUnsafe()).toBeUndefined(); + expect(socket.sent.slice(1).map((request) => decodeJson(request))).toEqual([ + { _tag: "Ping" }, + { _tag: "Ping" }, + { _tag: "Ping" }, + ]); + + yield* TestClock.adjust("5 seconds"); + const error = yield* Fiber.join(closedFiber); + expect(error).toBeInstanceOf(ConnectionTransientError); + expect(error).toMatchObject({ reason: "transport" }); + }).pipe(Effect.scoped, Effect.provide(TestClock.layer())), + ); + + it.effect("reaches ready when a newer server sends unknown config members", () => + Effect.gen(function* () { + const { factory, sockets } = yield* makeFactory(); + const session = yield* factory.connect(PREPARED); + const readyFiber = yield* Effect.forkChild(session.ready); + const socket = yield* awaitSocket(sockets); + socket.open(); + + const shortcut = { + key: "p", + metaKey: false, + ctrlKey: false, + shiftKey: false, + altKey: false, + modKey: true, + }; + yield* completeInitialConfig(socket, { + ...ENCODED_SERVER_CONFIG, + keybindings: [ + { command: "someFuture.toggle", shortcut }, + { command: "terminal.toggle", shortcut }, + ], + issues: [{ kind: "keybindings.future-issue", message: "From a newer server" }], + availableEditors: ["some-future-editor", "zed"], + }); + yield* Fiber.join(readyFiber); + + const config = yield* session.initialConfig; + expect(config.keybindings).toEqual([{ command: "terminal.toggle", shortcut }]); + expect(config.issues).toEqual([]); + expect(config.availableEditors).toEqual(["zed"]); + }), + ); + it.effect("uses the legacy config RPC for probes when the server lacks the capability", () => Effect.scoped( Effect.gen(function* () { @@ -392,8 +404,8 @@ describe("RpcSessionFactory", () => { expect(error).toBeInstanceOf(ConnectionTransientError); expect(error).toMatchObject({ - reason: "timeout", - message: "Test environment could not open WebSocket.", + reason: "transport", + message: "Test environment could not establish a WebSocket connection.", }); expect(sockets[0]?.readyState).toBe(TestWebSocket.CLOSED); }).pipe(Effect.provide(TestClock.layer())), diff --git a/packages/client-runtime/src/rpc/session.ts b/packages/client-runtime/src/rpc/session.ts index 3327e635b022..9625effa406f 100644 --- a/packages/client-runtime/src/rpc/session.ts +++ b/packages/client-runtime/src/rpc/session.ts @@ -3,7 +3,6 @@ import * as Context from "effect/Context"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; -import * as Option from "effect/Option"; import * as Schedule from "effect/Schedule"; import type * as Scope from "effect/Scope"; import * as RpcClient from "effect/unstable/rpc/RpcClient"; @@ -14,129 +13,15 @@ import { makeWsRpcProtocolClient, type WsRpcProtocolClient } from "./protocol.ts import type { ConnectionAttemptError, ConnectionTransientError, - ConnectionTransientReason, PreparedConnection, } from "../connection/model.ts"; import { ConnectionBlockedError, ConnectionTransientError as ConnectionTransientErrorClass, } from "../connection/model.ts"; -import { formatDisconnectDetail, type SocketCloseCapture } from "../connection/disconnectDetail.ts"; -import * as ConnectionDiagnosticsLog from "../connection/diagnosticsLog.ts"; const SOCKET_OPEN_TIMEOUT = "15 seconds"; -/** Mutable sink filled before onDisconnect so we never emit a bare "disconnected." */ -type DisconnectCauseSink = { - causeMessage?: string; - reason?: ConnectionTransientReason; - close?: SocketCloseCapture; -}; - -function socketHostFromUrl(socketUrl: string): string | undefined { - try { - return new URL(socketUrl).host; - } catch { - return undefined; - } -} - -function captureSocketClose( - webSocketConstructor: (url: string, protocols?: string | string[]) => globalThis.WebSocket, - sink: { current: SocketCloseCapture }, -): (url: string, protocols?: string | string[]) => globalThis.WebSocket { - return (url, protocols) => { - const socket = webSocketConstructor(url, protocols); - socket.addEventListener( - "close", - (event) => { - const closeEvent = event as CloseEvent; - sink.current = { - code: typeof closeEvent.code === "number" ? closeEvent.code : undefined, - reason: typeof closeEvent.reason === "string" ? closeEvent.reason : undefined, - }; - }, - { once: true }, - ); - return socket; - }; -} - -function causeTextOf(cause: unknown, fallback: string): string { - if (cause instanceof Error) return cause.message; - if (typeof cause === "string") return cause; - return fallback; -} - -function noteSocketError(sink: DisconnectCauseSink, error: Socket.SocketError): void { - const reason = error.reason; - switch (reason._tag) { - case "SocketCloseError": { - sink.close = { - code: reason.code, - reason: reason.closeReason, - }; - // Prefer close-code formatting over a generic SocketCloseError string. - sink.reason ??= "transport"; - return; - } - case "SocketOpenError": { - const causeText = causeTextOf(reason.cause, reason.kind); - const lower = causeText.toLowerCase(); - if (lower.includes("ping timeout")) { - sink.causeMessage = "ping timeout"; - sink.reason = "timeout"; - return; - } - // WebSocket openTimeout (not keepalive) — leave cause empty so formatters use open wording. - if (reason.kind === "Timeout" && (lower.includes("open") || lower.includes("waiting"))) { - sink.reason ??= "timeout"; - return; - } - sink.causeMessage ??= causeText; - sink.reason ??= lower.includes("timeout") ? "timeout" : "transport"; - return; - } - case "SocketReadError": - case "SocketWriteError": { - sink.causeMessage ??= causeTextOf(reason.cause, reason._tag); - sink.reason ??= "transport"; - return; - } - } -} - -function mergeCloseCapture( - fromEvent: SocketCloseCapture, - fromError: SocketCloseCapture | undefined, -): SocketCloseCapture { - return { - code: fromEvent.code ?? fromError?.code, - reason: fromEvent.reason ?? fromError?.reason, - }; -} - -/** - * Wrap a Socket so transport failures are recorded before ConnectionHooks.onDisconnect. - * onDisconnect alone only sees an empty close capture when the failure is a ping timeout - * (socket still open; browser close event fires later/async). - */ -function captureSocketFailures(socket: Socket.Socket, sink: DisconnectCauseSink): Socket.Socket { - return Socket.make({ - runRaw: (handler, options) => - socket.runRaw(handler, options).pipe( - Effect.tapError((error) => - Effect.sync(() => { - if (Socket.SocketError.is(error)) { - noteSocketError(sink, error); - } - }), - ), - ), - writer: socket.writer, - }); -} - export interface RpcSession { readonly client: WsRpcProtocolClient; readonly initialConfig: Effect.Effect; @@ -172,27 +57,16 @@ function mapSessionRpcError(error: InitialConfigError | ProbeError): ConnectionA reason: "remote-unavailable", detail: error.message, }); - case "RpcClientError": { - const lower = error.message.toLowerCase(); - if (lower.includes("ping timeout")) { - return new ConnectionTransientErrorClass({ - reason: "timeout", - detail: "ping timeout", - }); - } + case "RpcClientError": return new ConnectionTransientErrorClass({ reason: "transport", detail: error.message, }); - } } } export const make = Effect.gen(function* () { const webSocketConstructor = yield* Socket.WebSocketConstructor; - const diagnosticsLog = yield* Effect.serviceOption( - ConnectionDiagnosticsLog.ConnectionDiagnosticsLog, - ); const connect = Effect.fnUntraced(function* (connection: PreparedConnection) { yield* Effect.annotateCurrentSpan({ @@ -201,64 +75,40 @@ export const make = Effect.gen(function* () { const connected = yield* Deferred.make(); const disconnected = yield* Deferred.make(); - const closeCapture: { current: SocketCloseCapture } = { current: {} }; - const causeSink: DisconnectCauseSink = {}; - const trackedConstructor = captureSocketClose(webSocketConstructor, closeCapture); const hooks = RpcClient.ConnectionHooks.of({ onConnect: Deferred.succeed(connected, undefined).pipe(Effect.asVoid), - // Fork patch: runs before the protocol fails the socket with SocketOpenError(ping timeout). - onPingTimeout: Effect.sync(() => { - causeSink.causeMessage = "ping timeout"; - causeSink.reason = "timeout"; - }), onDisconnect: Deferred.isDone(connected).pipe( - Effect.flatMap((wasConnected) => { - const close = mergeCloseCapture(closeCapture.current, causeSink.close); - const detail = formatDisconnectDetail({ - label: connection.label, - wasConnected, - close, - causeMessage: causeSink.causeMessage, - }); - const error = new ConnectionTransientErrorClass({ - reason: causeSink.reason ?? "transport", - detail, - }); - const record = Option.match(diagnosticsLog, { - onNone: () => Effect.void, - onSome: (log) => - log.record({ - environmentId: connection.environmentId, - label: connection.label, - kind: wasConnected ? "disconnect" : "connect_failed", - reason: error.reason, - detail: error.detail, - closeCode: close.code, - closeReason: close.reason, - socketHost: socketHostFromUrl(connection.socketUrl), - }), - }); - return record.pipe(Effect.andThen(Deferred.fail(disconnected, error)), Effect.asVoid); - }), + Effect.flatMap((wasConnected) => + Deferred.fail( + disconnected, + new ConnectionTransientErrorClass({ + reason: "transport", + detail: wasConnected + ? `${connection.label} disconnected.` + : `${connection.label} could not establish a WebSocket connection.`, + }), + ), + ), + Effect.asVoid, ), }); - // Build socket, wrap to capture SocketError (close codes / open errors), then protocol. + const socketLayer = Socket.layerWebSocket(connection.socketUrl, { + openTimeout: SOCKET_OPEN_TIMEOUT, + }).pipe(Layer.provide(Layer.succeed(Socket.WebSocketConstructor, webSocketConstructor))); const protocolLayer = Layer.effect( RpcClient.Protocol, - Effect.gen(function* () { - const rawSocket = yield* Socket.makeWebSocket(connection.socketUrl, { - openTimeout: SOCKET_OPEN_TIMEOUT, - }).pipe(Effect.provideService(Socket.WebSocketConstructor, trackedConstructor)); - const socket = captureSocketFailures(rawSocket, causeSink); - return yield* RpcClient.makeProtocolSocket({ - retryTransientErrors: false, - retryPolicy: Schedule.recurs(0), - }).pipe( - Effect.provideService(Socket.Socket, socket), - Effect.provide(RpcSerialization.layerJson), - Effect.provideService(RpcClient.ConnectionHooks, hooks), - ); + RpcClient.makeProtocolSocket({ + retryTransientErrors: false, + retryPolicy: Schedule.recurs(0), }), + ).pipe( + Layer.provide( + Layer.mergeAll( + socketLayer, + RpcSerialization.layerJson, + Layer.succeed(RpcClient.ConnectionHooks, hooks), + ), + ), ); const protocolContext = yield* Layer.build(protocolLayer).pipe( Effect.withSpan("environment.websocket.connect"), diff --git a/packages/client-runtime/src/state/entities.test.ts b/packages/client-runtime/src/state/entities.test.ts index e01798654555..07d16da92f51 100644 --- a/packages/client-runtime/src/state/entities.test.ts +++ b/packages/client-runtime/src/state/entities.test.ts @@ -337,6 +337,7 @@ describe("environment entity projections", () => { data: Option.some(detail), status: "live", error: Option.none(), + page: Option.none(), }), ); @@ -365,6 +366,7 @@ describe("environment entity projections", () => { }), status: "live", error: Option.none(), + page: Option.none(), }), ); diff --git a/packages/client-runtime/src/state/olderThreadActivities.test.ts b/packages/client-runtime/src/state/olderThreadActivities.test.ts deleted file mode 100644 index 756a12cbfbcf..000000000000 Binary files a/packages/client-runtime/src/state/olderThreadActivities.test.ts and /dev/null differ diff --git a/packages/client-runtime/src/state/olderThreadActivities.ts b/packages/client-runtime/src/state/olderThreadActivities.ts deleted file mode 100644 index 269d05159c2d..000000000000 --- a/packages/client-runtime/src/state/olderThreadActivities.ts +++ /dev/null @@ -1,274 +0,0 @@ -import { useCallback, useLayoutEffect, useMemo, useRef, useState } from "react"; - -import type { OrchestrationThreadActivity } from "@t3tools/contracts"; - -import { liveWindowOldestActivityId, oldestActivityByChronology } from "./threadReducer.ts"; - -const EMPTY_ACTIVITIES: ReadonlyArray = []; - -/** - * Pagination cursor for a thread's older activities. Sequenced rows page by - * `beforeSequence`; legacy/unsequenced rows (the common case — `sequence` is - * absent on most real rows) page by the `(createdAt, activityId)` keyset. - */ -export type OlderActivitiesCursor = - | { readonly beforeSequence: number } - | { - readonly beforeCreatedAt: OrchestrationThreadActivity["createdAt"]; - readonly beforeActivityId: OrchestrationThreadActivity["id"]; - }; - -export interface OlderActivitiesPage { - readonly activities: ReadonlyArray; - readonly hasMore: boolean; -} - -export interface UseOlderThreadActivitiesOptions { - /** - * Identity of the thread the live window belongs to (e.g. - * `${environmentId}\0${threadId}`); null when no thread is selected. - * Changing it resets the lazy-loaded pages. - */ - readonly threadKey: string | null; - /** The server-windowed live activity set from the thread detail. */ - readonly liveActivities: ReadonlyArray; - /** The server's `hasMoreActivities` flag from the detail snapshot. */ - readonly hasMoreLiveActivities: boolean; - /** - * Fetch the page immediately older than the cursor. Resolve `null` to skip - * the page silently (a failure the caller already surfaced, or an - * interrupted command) — `hasMore` is left true so the user can retry. - * MUST be referentially stable (useCallback) for the load callback to be. - */ - readonly loadPage: (cursor: OlderActivitiesCursor) => Promise; -} - -export interface UseOlderThreadActivitiesResult { - /** Lazy-loaded older pages + the live window, oldest first. */ - readonly mergedActivities: ReadonlyArray; - /** Whether older history exists beyond everything loaded. */ - readonly hasMoreOlder: boolean; - readonly loadingOlder: boolean; - /** Increments whenever paging advances or the live window is reset. */ - readonly progressVersion: number; - /** Dispatch a load of the next older page (no-op while one is in flight). */ - readonly loadOlder: () => void; -} - -// ── Pure decision kernel (exported for unit tests) ────────────────────────── - -export interface LiveWindowShape { - readonly key: string | null; - /** Chronological-oldest activity id (an identity sentinel, not a lookup key). */ - readonly oldest: string | null; - readonly count: number; -} - -/** - * Whether the live window was RESHAPED rather than purely appended-to: a - * different thread, a re-snapshot (reconnect) that changes the window's - * chronological-oldest row, or a checkpoint revert that shrinks it. A pure - * append (same thread, same oldest, count not smaller) is NOT a reshape. - */ -export function didLiveWindowReshape(previous: LiveWindowShape, next: LiveWindowShape): boolean { - return ( - next.key !== previous.key || next.oldest !== previous.oldest || next.count < previous.count - ); -} - -/** - * The cursor for the page immediately older than `oldest`: sequenced rows page - * by `beforeSequence`; unsequenced rows (the common case) by the - * `(createdAt, activityId)` keyset. - */ -export function olderActivitiesCursorFor( - oldest: OrchestrationThreadActivity, -): OlderActivitiesCursor { - return oldest.sequence !== undefined - ? { beforeSequence: oldest.sequence } - : { beforeCreatedAt: oldest.createdAt, beforeActivityId: oldest.id }; -} - -/** - * The row the NEXT load should cursor from: the explicit cursor row already - * paged past when one exists (so an all-overlap page keeps advancing), else - * the chronologically-oldest loaded row — never index 0, which the reducer - * can fill with a newer row (unsequenced rows sort to the end). - */ -export function nextOlderActivitiesCursorRow( - pagedPast: OrchestrationThreadActivity | null, - merged: ReadonlyArray, -): OrchestrationThreadActivity | null { - return pagedPast ?? oldestActivityByChronology(merged); -} - -/** - * The page rows not already present in the loaded set (older pages + live - * window) — boundary overlap and mid-flight appends must never produce - * duplicate ids in the merged timeline. - */ -export function freshOlderActivities( - page: OlderActivitiesPage, - merged: ReadonlyArray, -): ReadonlyArray { - const seen = new Set(merged.map((activity) => activity.id)); - return page.activities.filter((activity) => !seen.has(activity.id)); -} - -/** - * The older-history lazy-load engine, shared by every client (web ChatView, - * the mobile composer, the TUI ChatView). The thread-detail snapshot windows - * activities to the most recent page; older pages are fetched on demand and - * prepended. - * - * One implementation holds all the hardening the per-client copies kept - * drifting on: - * - reset on live-window RESHAPE, not just thread switch: a reconnect - * re-snapshot changes the window's chronological-oldest row and a checkpoint - * revert shrinks it, but a plain append does neither (the reducer re-sorts - * unsequenced rows, so index 0 is not a stable boundary — the sentinel is - * {@link liveWindowOldestActivityId}); - * - a generation guard so a load resolving after a reset can't repopulate the - * cleared state; - * - a synchronous in-flight key so scroll-triggered duplicate dispatches - * coalesce before the loading state commits; - * - an explicit advancing cursor (the oldest row paged PAST), so an - * all-overlap page keeps paging instead of dead-ending while the server - * still reports more — the server cursor is strict, so it strictly - * decreases and paging cannot loop; - * - dedup against the LATEST merged set via a ref, so a live append or a - * prior prepend settling mid-flight can't produce duplicate ids; - * - `hasMore` stays true on a failed/skipped page (the history still exists; - * scrolling back retries). - */ -export function useOlderThreadActivities( - options: UseOlderThreadActivitiesOptions, -): UseOlderThreadActivitiesResult { - const { threadKey, liveActivities, hasMoreLiveActivities, loadPage } = options; - - const [olderActivities, setOlderActivities] = useState< - ReadonlyArray - >([]); - const [olderLoaded, setOlderLoaded] = useState(false); - const [olderHasMore, setOlderHasMore] = useState(false); - const [loadingOlder, setLoadingOlder] = useState(false); - const [progressVersion, setProgressVersion] = useState(0); - - // Order-independent oldest boundary: `liveActivities[0]` shifts when the - // reducer re-sorts unsequenced rows on the first live append, which would - // otherwise make a plain append look like a window reshape. - const liveOldestActivityId = useMemo( - () => liveWindowOldestActivityId(liveActivities), - [liveActivities], - ); - const liveActivityCount = liveActivities.length; - - // Bumps on every reset so a late in-flight load can't repopulate the - // freshly-cleared state (the thread key alone doesn't change on a - // same-thread window reshape). - const generationRef = useRef(0); - // The thread key of an in-flight load — coalesces the duplicate dispatches a - // fast scroll fires before the loading state updates. - const inFlightKeyRef = useRef(null); - // The oldest row we've paged past; advances even when a page dedupes to - // nothing. Reset on reshape. - const cursorRef = useRef(null); - const windowRef = useRef({ - key: threadKey, - oldest: liveOldestActivityId, - count: liveActivityCount, - }); - - // useLayoutEffect (not useEffect) so the cleared state commits before paint: - // otherwise a thread switch renders one frame with the previous thread's - // lazy-loaded pages still merged in, flashing stale rows. - useLayoutEffect(() => { - const previous = windowRef.current; - windowRef.current = { - key: threadKey, - oldest: liveOldestActivityId, - count: liveActivityCount, - }; - if (!didLiveWindowReshape(previous, windowRef.current)) { - return; - } - generationRef.current += 1; - inFlightKeyRef.current = null; - cursorRef.current = null; - setOlderActivities([]); - setOlderLoaded(false); - setOlderHasMore(false); - setLoadingOlder(false); - setProgressVersion((current) => current + 1); - }, [threadKey, liveOldestActivityId, liveActivityCount]); - - const mergedActivities = useMemo( - () => (olderActivities.length > 0 ? [...olderActivities, ...liveActivities] : liveActivities), - [olderActivities, liveActivities], - ); - // Latest merged set, read inside the async load handler so dedup runs - // against current state, not the snapshot captured at dispatch time. - const mergedActivitiesRef = useRef(mergedActivities); - mergedActivitiesRef.current = mergedActivities; - - // Before any page is loaded the server flag is authoritative; afterwards - // the latest page's `hasMore` is. - const hasMoreOlder = olderLoaded ? olderHasMore : threadKey !== null && hasMoreLiveActivities; - - const loadOlder = useCallback(() => { - if (threadKey === null || !hasMoreOlder) { - return; - } - const oldest = nextOlderActivitiesCursorRow(cursorRef.current, mergedActivitiesRef.current); - if (!oldest) { - return; - } - if (inFlightKeyRef.current === threadKey) { - return; // a load for this thread is already in flight - } - const cursor = olderActivitiesCursorFor(oldest); - const generation = generationRef.current; - inFlightKeyRef.current = threadKey; - setLoadingOlder(true); - void loadPage(cursor) - .then((page) => { - // The window/thread was reset while this was in flight — drop the page - // so it can't repopulate state cleared by the reset. - if (generationRef.current !== generation) { - return; - } - if (page === null) { - // Failed or interrupted (already surfaced by the caller). Keep - // `hasMore` — the history still exists and retrying is valid. - return; - } - // Advance the cursor even when every row dedupes away — the server - // cursor is strict, so it strictly decreases and paging can't loop. - const pageOldest = page.activities[0]; - if (pageOldest) { - cursorRef.current = pageOldest; - setProgressVersion((current) => current + 1); - } - const fresh = freshOlderActivities(page, mergedActivitiesRef.current); - if (fresh.length > 0) { - setOlderActivities((previous) => [...fresh, ...previous]); - } - setOlderLoaded(true); - setOlderHasMore(page.hasMore); - }) - .finally(() => { - if (generationRef.current === generation) { - inFlightKeyRef.current = null; - setLoadingOlder(false); - } - }); - }, [threadKey, hasMoreOlder, loadPage]); - - return { - mergedActivities: threadKey === null ? EMPTY_ACTIVITIES : mergedActivities, - hasMoreOlder, - loadingOlder, - progressVersion, - loadOlder, - }; -} diff --git a/packages/client-runtime/src/state/orchestration.ts b/packages/client-runtime/src/state/orchestration.ts index 7301526de4f9..b3e725a88504 100644 --- a/packages/client-runtime/src/state/orchestration.ts +++ b/packages/client-runtime/src/state/orchestration.ts @@ -20,10 +20,6 @@ export function createOrchestrationEnvironmentAtoms( idleTtlMs: 300_000, }), // Imperative lazy-load of older thread activities (infinite scroll-up). - loadThreadActivities: createEnvironmentRpcCommand(runtime, { - label: "environment-data:orchestration:thread-activities", - tag: ORCHESTRATION_WS_METHODS.getThreadActivities, - }), fullThreadDiff: createEnvironmentRpcQueryAtomFamily(runtime, { label: "environment-data:orchestration:full-thread-diff", tag: ORCHESTRATION_WS_METHODS.getFullThreadDiff, diff --git a/packages/client-runtime/src/state/shell-sync.test.ts b/packages/client-runtime/src/state/shell-sync.test.ts index e006fc3cd762..40e9bd80dc5b 100644 --- a/packages/client-runtime/src/state/shell-sync.test.ts +++ b/packages/client-runtime/src/state/shell-sync.test.ts @@ -150,34 +150,34 @@ describe("environment shell synchronization", () => { }), ); - it.effect("replaces a warm shell cache with an authoritative HTTP snapshot", () => + it.effect("requests a full socket snapshot when the HTTP refresh fails", () => Effect.gen(function* () { const cachedSnapshot: OrchestrationShellSnapshot = { snapshotSequence: 5, projects: [], - threads: [{ id: "stale-thread" } as never], + threads: [{ id: "cached-thread" } as never], updatedAt: "2026-06-06T00:00:00.000Z", }; - const httpSnapshot: OrchestrationShellSnapshot = { + const resetSnapshot: OrchestrationShellSnapshot = { ...cachedSnapshot, - snapshotSequence: 9, + snapshotSequence: 9_999, threads: [], updatedAt: "2026-06-07T00:00:00.000Z", }; const events = yield* Queue.unbounded(); - const capturedAfterSequence = yield* SubscriptionRef.make(undefined); - const capturedCompletionMarker = yield* Ref.make(undefined); - const loaderCalls = yield* SubscriptionRef.make(0); + const wakeups = yield* Queue.unbounded(); + const subscribeInputs = yield* Queue.unbounded<{ + readonly afterSequence?: number; + readonly requestCompletionMarker?: boolean; + }>(); + const loaderCalls = yield* Ref.make(0); const client = { [ORCHESTRATION_WS_METHODS.subscribeShell]: (input: { readonly afterSequence?: number; readonly requestCompletionMarker?: boolean; }) => Stream.unwrap( - Ref.set(capturedCompletionMarker, input.requestCompletionMarker).pipe( - Effect.andThen(SubscriptionRef.set(capturedAfterSequence, input.afterSequence)), - Effect.as(Stream.fromQueue(events)), - ), + Queue.offer(subscribeInputs, input).pipe(Effect.as(Stream.fromQueue(events))), ), } as unknown as WsRpcProtocolClient; const supervisorState = yield* SubscriptionRef.make(AVAILABLE_CONNECTION_STATE); @@ -208,57 +208,66 @@ describe("environment shell synchronization", () => { clear: () => Effect.void, }); const snapshotLoader = ShellSnapshotLoader.of({ - load: () => - SubscriptionRef.update(loaderCalls, (count) => count + 1).pipe( - Effect.as(Option.some(httpSnapshot)), - ), + load: () => Ref.update(loaderCalls, (count) => count + 1).pipe(Effect.as(Option.none())), }); const shellState = yield* makeEnvironmentShellState().pipe( Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor), Effect.provideService(Persistence.EnvironmentCacheStore, cache), Effect.provideService(ShellSnapshotLoader, snapshotLoader), + Effect.provideService( + ConnectionWakeups.ConnectionWakeups, + ConnectionWakeups.ConnectionWakeups.of({ changes: Stream.fromQueue(wakeups) }), + ), ); - // Wait until the subscription is established from the warm cache. - yield* SubscriptionRef.changes(capturedAfterSequence).pipe( - Stream.filter((value) => value !== undefined), - Stream.runHead, - ); - - expect(yield* SubscriptionRef.get(capturedAfterSequence)).toBe(9); - expect(yield* Ref.get(capturedCompletionMarker)).toBe(true); - expect(yield* SubscriptionRef.get(loaderCalls)).toBe(1); + const subscribeInput = yield* Queue.take(subscribeInputs); + expect(subscribeInput.afterSequence).toBeUndefined(); + expect(subscribeInput.requestCompletionMarker).toBe(true); + expect(yield* Ref.get(loaderCalls)).toBe(1); const synchronizing = yield* SubscriptionRef.get(shellState); expect(synchronizing.status).toBe("synchronizing"); - expect(Option.getOrThrow(synchronizing.snapshot)).toEqual(httpSnapshot); + expect(Option.getOrThrow(synchronizing.snapshot)).toEqual(cachedSnapshot); + yield* Queue.offer(events, { kind: "snapshot", snapshot: resetSnapshot }); yield* Queue.offer(events, { kind: "synchronized" }); yield* SubscriptionRef.changes(shellState).pipe( Stream.filter((value) => value.status === "live"), Stream.runHead, ); + + const live = yield* SubscriptionRef.get(shellState); + expect(Option.getOrThrow(live.snapshot)).toEqual(resetSnapshot); + expect(yield* Ref.get(loaderCalls)).toBe(1); + + yield* Queue.offer(wakeups, "application-active"); + const resumedInput = yield* Queue.take(subscribeInputs); + expect(resumedInput.afterSequence).toBe(resetSnapshot.snapshotSequence); + expect(resumedInput.requestCompletionMarker).toBe(true); + expect(yield* Ref.get(loaderCalls)).toBe(1); }), ); - it.effect("refreshes the authoritative shell snapshot when the app becomes active", () => + it.effect("resubscribes from the in-memory shell cursor when the app becomes active", () => Effect.gen(function* () { const events = yield* Queue.unbounded(); const wakeups = yield* Queue.unbounded(); const loaderCalls = yield* Ref.make(0); - const subscriptionCount = yield* Ref.make(0); + const capturedAfterSequences = yield* Ref.make>([]); const client = { - [ORCHESTRATION_WS_METHODS.subscribeShell]: () => + [ORCHESTRATION_WS_METHODS.subscribeShell]: (input: { readonly afterSequence?: number }) => Stream.unwrap( - Ref.update(subscriptionCount, (count) => count + 1).pipe( - Effect.as(Stream.fromQueue(events)), - ), + Ref.update(capturedAfterSequences, (captured) => [ + ...captured, + input.afterSequence, + ]).pipe(Effect.as(Stream.fromQueue(events))), ), } as unknown as WsRpcProtocolClient; const supervisorState = yield* SubscriptionRef.make(AVAILABLE_CONNECTION_STATE); + const activeSession = yield* SubscriptionRef.make(Option.some(session(client))); const supervisor = EnvironmentSupervisor.EnvironmentSupervisor.of({ target: TARGET, state: supervisorState, - session: yield* SubscriptionRef.make(Option.some(session(client))), + session: activeSession, prepared: yield* SubscriptionRef.make(Option.some(PREPARED)), connect: Effect.void, disconnect: Effect.void, @@ -296,54 +305,60 @@ describe("environment shell synchronization", () => { ), ); - yield* SubscriptionRef.changes(shellState).pipe( - Stream.filter( - (value) => - value.status === "synchronizing" && - Option.isSome(value.snapshot) && - value.snapshot.value.snapshotSequence === 10, - ), - Stream.runHead, - ); + // A new session starts from an authoritative HTTP snapshot. + for (let attempt = 0; attempt < 100; attempt += 1) { + if ((yield* Ref.get(capturedAfterSequences)).length >= 1) break; + yield* Effect.yieldNow; + } + expect(yield* Ref.get(capturedAfterSequences)).toEqual([10]); yield* Queue.offer(events, { kind: "synchronized" }); yield* SubscriptionRef.changes(shellState).pipe( Stream.filter((value) => value.status === "live"), Stream.runHead, ); - yield* Queue.offer(wakeups, "application-active"); + // A newer snapshot arrives on the stream and advances the cursor. + yield* Queue.offer(events, { + kind: "snapshot", + snapshot: { ...LIVE_SHELL_SNAPSHOT, snapshotSequence: 40 }, + }); yield* SubscriptionRef.changes(shellState).pipe( Stream.filter( - (value) => - value.status === "synchronizing" && - Option.isSome(value.snapshot) && - value.snapshot.value.snapshotSequence === 20, + (value) => Option.isSome(value.snapshot) && value.snapshot.value.snapshotSequence === 40, ), Stream.runHead, ); + yield* Queue.offer(wakeups, "application-active"); for (let attempt = 0; attempt < 100; attempt += 1) { - if ((yield* Ref.get(subscriptionCount)) >= 2) break; + if ((yield* Ref.get(capturedAfterSequences)).length >= 2) break; yield* Effect.yieldNow; } - - expect(yield* Ref.get(loaderCalls)).toBe(2); - expect(yield* Ref.get(subscriptionCount)).toBe(2); + expect(yield* Ref.get(capturedAfterSequences)).toEqual([10, 40]); + yield* Queue.offer(events, { kind: "synchronized" }); yield* Queue.offer(wakeups, "application-active-probe"); for (let attempt = 0; attempt < 100; attempt += 1) { - if ((yield* Ref.get(subscriptionCount)) >= 3) break; + if ((yield* Ref.get(capturedAfterSequences)).length >= 3) break; yield* Effect.yieldNow; } - expect(yield* Ref.get(loaderCalls)).toBe(3); - expect(yield* Ref.get(subscriptionCount)).toBe(3); + expect(yield* Ref.get(capturedAfterSequences)).toEqual([10, 40, 40]); yield* Queue.offer(wakeups, "application-active-reconnect"); for (let attempt = 0; attempt < 10; attempt += 1) { yield* Effect.yieldNow; } - expect(yield* Ref.get(loaderCalls)).toBe(3); - expect(yield* Ref.get(subscriptionCount)).toBe(3); + expect((yield* Ref.get(capturedAfterSequences)).length).toBe(3); + expect(yield* Ref.get(loaderCalls)).toBe(1); + + // Replacing the session performs another authoritative refresh. + yield* SubscriptionRef.set(activeSession, Option.some(session(client))); + for (let attempt = 0; attempt < 100; attempt += 1) { + if ((yield* Ref.get(capturedAfterSequences)).length >= 4) break; + yield* Effect.yieldNow; + } + expect(yield* Ref.get(capturedAfterSequences)).toEqual([10, 40, 40, 20]); + expect(yield* Ref.get(loaderCalls)).toBe(2); }), ); }); diff --git a/packages/client-runtime/src/state/shell.ts b/packages/client-runtime/src/state/shell.ts index a266af5f5f4e..c150bbb75b8c 100644 --- a/packages/client-runtime/src/state/shell.ts +++ b/packages/client-runtime/src/state/shell.ts @@ -21,6 +21,7 @@ import * as ConnectionWakeups from "../connection/wakeups.ts"; import { safeErrorLogAttributes } from "../errors/safeLog.ts"; import { EnvironmentCacheStore } from "../platform/persistence.ts"; import { subscribeDynamic } from "../rpc/client.ts"; +import type { RpcSession } from "../rpc/session.ts"; import { ShellSnapshotLoader } from "./shellSnapshotHttp.ts"; import { applyShellStreamEvent } from "./shellReducer.ts"; import type { EnvironmentCatalogState } from "./connections.ts"; @@ -71,6 +72,8 @@ export const makeEnvironmentShellState = Effect.fn("EnvironmentShellState.make") error: Option.none(), }); const awaitingCompletion = yield* Ref.make(false); + const lastAuthoritativeSession = yield* Ref.make(null); + const activeSubscriptionSession = yield* Ref.make(null); const persistence = yield* Queue.sliding(1); const persist = Effect.fn("EnvironmentShellState.persist")(function* ( @@ -166,6 +169,12 @@ export const makeEnvironmentShellState = Effect.fn("EnvironmentShellState.make") status: waiting ? "synchronizing" : "live", error: Option.none(), }); + if (item.kind === "snapshot") { + const session = yield* Ref.get(activeSubscriptionSession); + if (session !== null) { + yield* Ref.set(lastAuthoritativeSession, session); + } + } yield* Queue.offer(persistence, nextSnapshot); }); @@ -180,6 +189,7 @@ export const makeEnvironmentShellState = Effect.fn("EnvironmentShellState.make") subscribeDynamic( ORCHESTRATION_WS_METHODS.subscribeShell, Effect.fn("EnvironmentShellState.makeSubscribeInput")(function* (session) { + yield* Ref.set(activeSubscriptionSession, session); const supportsCompletionMarker = yield* session.initialConfig.pipe( Effect.map((config) => config.shellResumeCompletionMarker === true), Effect.orElseSucceed(() => false), @@ -187,30 +197,53 @@ export const makeEnvironmentShellState = Effect.fn("EnvironmentShellState.make") yield* Ref.set(awaitingCompletion, supportsCompletionMarker); yield* setSynchronizing; - const prepared = yield* SubscriptionRef.get(supervisor.prepared).pipe( - Effect.flatMap( - Option.match({ - onSome: Effect.succeed, - onNone: () => - SubscriptionRef.changes(supervisor.prepared).pipe( - Stream.filter(Option.isSome), - Stream.map((value) => value.value), - Stream.runHead, - Effect.map(Option.getOrThrow), - ), - }), - ), - ); - const httpSnapshot = yield* snapshotLoader.load(prepared); - if (Option.isSome(httpSnapshot)) { - yield* applyItem({ kind: "snapshot", snapshot: httpSnapshot.value }); - return { - afterSequence: httpSnapshot.value.snapshotSequence, - ...(supportsCompletionMarker ? { requestCompletionMarker: true as const } : {}), - }; + // Foreground resubscriptions on the same live session can resume from + // the in-memory cursor. A new session reloads the authoritative HTTP + // snapshot so a valid cursor cannot preserve incomplete cached data. + const hasAuthoritativeSnapshot = (yield* Ref.get(lastAuthoritativeSession)) === session; + let canResume = hasAuthoritativeSnapshot; + let current = yield* SubscriptionRef.get(state); + if (!hasAuthoritativeSnapshot || Option.isNone(current.snapshot)) { + const prepared = yield* SubscriptionRef.get(supervisor.prepared).pipe( + Effect.flatMap( + Option.match({ + onSome: Effect.succeed, + onNone: () => + SubscriptionRef.changes(supervisor.prepared).pipe( + Stream.filter(Option.isSome), + Stream.map((value) => value.value), + Stream.runHead, + Effect.map(Option.getOrThrow), + ), + }), + ), + ); + const httpSnapshot = yield* snapshotLoader.load(prepared); + if (Option.isSome(httpSnapshot)) { + yield* applyItem({ kind: "snapshot", snapshot: httpSnapshot.value }); + canResume = true; + current = yield* SubscriptionRef.get(state); + } } - return supportsCompletionMarker ? { requestCompletionMarker: true as const } : {}; + // If the authoritative refresh failed, omit the cached cursor so the + // socket fallback sends a complete snapshot for this new session. + if (!canResume || Option.isNone(current.snapshot)) { + return supportsCompletionMarker ? { requestCompletionMarker: true as const } : {}; + } + if (!supportsCompletionMarker) { + // Without a completion marker there is no synchronized signal for a + // resumed subscription, so report live immediately, like threads. + yield* SubscriptionRef.update(state, (value) => ({ + ...value, + status: "live" as const, + error: Option.none(), + })); + } + return { + afterSequence: current.snapshot.value.snapshotSequence, + ...(supportsCompletionMarker ? { requestCompletionMarker: true as const } : {}), + }; }), { onExpectedFailure: (cause) => setStreamError(Cause.squash(cause)), diff --git a/packages/client-runtime/src/state/threadSnapshotHttp.ts b/packages/client-runtime/src/state/threadSnapshotHttp.ts index f628fe9b6591..e5b28f7ba898 100644 --- a/packages/client-runtime/src/state/threadSnapshotHttp.ts +++ b/packages/client-runtime/src/state/threadSnapshotHttp.ts @@ -22,6 +22,16 @@ import { SNAPSHOT_HTTP_TIMEOUT_MS } from "./snapshotHttpPolicy.ts"; * WebSocket subscription's first frame. The response is gzip-compressible by * the transport and keeps the (potentially multi-KB) snapshot off the socket. */ +/** + * Optional turn window for a snapshot fetch. Only send a window to servers + * that advertise `threadSnapshotPagination`; older servers reject unknown + * query parameters. + */ +export interface ThreadSnapshotWindow { + readonly turnLimit: number; + readonly beforeCursor?: string; +} + export const fetchEnvironmentThreadSnapshot = Effect.fn( "clientRuntime.state.fetchEnvironmentThreadSnapshot", )(function* (input: { @@ -29,6 +39,7 @@ export const fetchEnvironmentThreadSnapshot = Effect.fn( readonly threadId: ThreadId; readonly signer: Option.Option; readonly timeoutMs?: number; + readonly window?: ThreadSnapshotWindow; }) { const requestUrl = environmentEndpointUrl( input.prepared.httpBaseUrl, @@ -48,6 +59,12 @@ export const fetchEnvironmentThreadSnapshot = Effect.fn( input.prepared.httpAuthorization, client.orchestration.threadSnapshot({ params: { threadId: input.threadId }, + payload: { + ...(input.window !== undefined ? { turnLimit: input.window.turnLimit } : {}), + ...(input.window?.beforeCursor !== undefined + ? { beforeCursor: input.window.beforeCursor } + : {}), + }, headers, }), ), @@ -68,6 +85,7 @@ export class ThreadSnapshotLoader extends Context.Service< readonly load: ( prepared: PreparedConnection, threadId: ThreadId, + window?: ThreadSnapshotWindow, ) => Effect.Effect>; } >()("@t3tools/client-runtime/state/threadSnapshotHttp/ThreadSnapshotLoader") {} @@ -85,8 +103,13 @@ export const threadSnapshotLoaderLayer: Layer.Layer< // connections work without one). const signer = yield* Effect.serviceOption(ManagedRelayDpopSigner); return ThreadSnapshotLoader.of({ - load: (prepared: PreparedConnection, threadId: ThreadId) => - fetchEnvironmentThreadSnapshot({ prepared, threadId, signer }).pipe( + load: (prepared: PreparedConnection, threadId: ThreadId, window?: ThreadSnapshotWindow) => + fetchEnvironmentThreadSnapshot({ + prepared, + threadId, + signer, + ...(window !== undefined ? { window } : {}), + }).pipe( Effect.map(Option.some), Effect.provideService(HttpClient.HttpClient, httpClient), // A genuinely missing thread (404) is expected — the socket diff --git a/packages/client-runtime/src/state/threadState.ts b/packages/client-runtime/src/state/threadState.ts index 89be139e9256..8ba9696ec576 100644 --- a/packages/client-runtime/src/state/threadState.ts +++ b/packages/client-runtime/src/state/threadState.ts @@ -3,14 +3,38 @@ import * as Option from "effect/Option"; export type EnvironmentThreadStatus = "empty" | "cached" | "synchronizing" | "live" | "deleted"; +/** + * Pagination state for a windowed thread. Present only when the loaded thread + * is a window (the server returned `page` metadata); absent means the thread is + * fully loaded — either the server predates pagination or the window reached + * the top. + */ +export interface EnvironmentThreadPageState { + /** Opaque exclusive cursor for the next older slice; null when fully loaded. */ + readonly beforeCursor: string | null; + readonly hasMore: boolean; + /** True while an older page fetch is in flight. */ + readonly loadingOlder: boolean; +} + export interface EnvironmentThreadState { readonly data: Option.Option; readonly status: EnvironmentThreadStatus; readonly error: Option.Option; + readonly page: Option.Option; } export const EMPTY_ENVIRONMENT_THREAD_STATE: EnvironmentThreadState = { data: Option.none(), status: "empty", error: Option.none(), + page: Option.none(), }; + +/** Whether the thread has older turns that can be loaded with more pages. */ +export function threadHasOlderTurns(state: EnvironmentThreadState): boolean { + return Option.match(state.page, { + onNone: () => false, + onSome: (page) => page.hasMore, + }); +} diff --git a/packages/client-runtime/src/state/threads-pagination.test.ts b/packages/client-runtime/src/state/threads-pagination.test.ts new file mode 100644 index 000000000000..b40ad21b876d --- /dev/null +++ b/packages/client-runtime/src/state/threads-pagination.test.ts @@ -0,0 +1,545 @@ +import { + EnvironmentId, + EventId, + ORCHESTRATION_WS_METHODS, + ProjectId, + ProviderInstanceId, + ThreadId, + TurnId, + type OrchestrationMessage, + type OrchestrationThread, + type OrchestrationThreadDetailSnapshot, + type OrchestrationThreadStreamItem, +} from "@t3tools/contracts"; +import { describe, expect, it } from "@effect/vitest"; +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import * as Queue from "effect/Queue"; +import * as Ref from "effect/Ref"; +import * as Stream from "effect/Stream"; +import * as SubscriptionRef from "effect/SubscriptionRef"; + +import type { WsRpcProtocolClient } from "../rpc/protocol.ts"; +import { + AVAILABLE_CONNECTION_STATE, + PrimaryConnectionTarget, + type PreparedConnection, + type SupervisorConnectionState, +} from "../connection/model.ts"; +import * as EnvironmentSupervisor from "../connection/supervisor.ts"; +import * as Persistence from "../platform/persistence.ts"; +import * as RpcSession from "../rpc/session.ts"; +import type { ThreadSnapshotWindow } from "./threadSnapshotHttp.ts"; +import { + INITIAL_THREAD_USER_TURN_LIMIT, + makeEnvironmentThreadState, + requestOlderThreadTurns, + ThreadSnapshotLoader, + type EnvironmentThreadState, +} from "./threads.ts"; + +const TARGET = new PrimaryConnectionTarget({ + environmentId: EnvironmentId.make("environment-1"), + label: "Test environment", + httpBaseUrl: "https://environment.example.test", + wsBaseUrl: "wss://environment.example.test", +}); +const THREAD_ID = ThreadId.make("thread-1"); +const PREPARED: PreparedConnection = { + environmentId: TARGET.environmentId, + label: TARGET.label, + httpBaseUrl: TARGET.httpBaseUrl, + socketUrl: TARGET.wsBaseUrl, + httpAuthorization: null, + target: TARGET, +}; + +function message(id: string, turnId: string, createdAt: string): OrchestrationMessage { + return { + id: id as OrchestrationMessage["id"], + role: "assistant", + text: `text of ${id}`, + turnId: TurnId.make(turnId), + streaming: false, + createdAt, + updatedAt: createdAt, + }; +} + +const OLDER_MESSAGE = message("message-old", "turn-1", "2026-04-01T00:00:00.000Z"); +const RECENT_MESSAGE = message("message-recent", "turn-2", "2026-04-01T01:00:00.000Z"); + +// Reverts retain turns via checkpoints with checkpointTurnCount <= the revert's +// turnCount, so both fixture turns carry one: reverting to turnCount 1 keeps +// turn-1 (the older page's turn) and discards turn-2 (the loaded window's). +function checkpoint(turnId: string, turnCount: number): OrchestrationThread["checkpoints"][number] { + return { + turnId: TurnId.make(turnId), + checkpointTurnCount: turnCount, + checkpointRef: + `checkpoint-${turnCount}` as OrchestrationThread["checkpoints"][number]["checkpointRef"], + status: "ready", + files: [], + assistantMessageId: null, + completedAt: "2026-04-01T01:00:00.000Z", + }; +} + +const BASE_THREAD: OrchestrationThread = { + id: THREAD_ID, + projectId: ProjectId.make("project-1"), + title: "Windowed thread", + modelSelection: { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5.4", + }, + runtimeMode: "full-access", + interactionMode: "default", + branch: "main", + worktreePath: null, + latestTurn: null, + createdAt: "2026-04-01T00:00:00.000Z", + updatedAt: "2026-04-01T00:00:00.000Z", + archivedAt: null, + settledOverride: null, + settledAt: null, + deletedAt: null, + messages: [RECENT_MESSAGE], + queuedMessages: [], + pendingTurnStart: null, + proposedPlans: [], + activities: [], + checkpoints: [checkpoint("turn-2", 2)], + session: null, +}; + +const WINDOWED_SNAPSHOT: OrchestrationThreadDetailSnapshot = { + snapshotSequence: 10, + thread: BASE_THREAD, + page: { beforeCursor: "cursor-1", hasMore: true, snapshotSequence: 10 }, +}; + +const OLDER_PAGE: OrchestrationThreadDetailSnapshot = { + snapshotSequence: 10, + thread: { + ...BASE_THREAD, + messages: [OLDER_MESSAGE], + checkpoints: [checkpoint("turn-1", 1)], + }, + page: { beforeCursor: null, hasMore: false, snapshotSequence: 10 }, +}; + +type LoaderResponse = Option.Option; + +const makeHarness = Effect.fn("TestThreadPagination.makeHarness")(function* (options?: { + readonly paginationCapability?: boolean; + readonly initialResponse?: LoaderResponse; + /** Cached snapshot returned by the cache store (simulates a warm cache). */ + readonly cached?: OrchestrationThreadDetailSnapshot; +}) { + const inputs = yield* Queue.unbounded(); + const observed = yield* Queue.unbounded(); + const loaderWindows = yield* Ref.make>([]); + const lastSubscribeInput = yield* Ref.make | undefined>(undefined); + const savedThreads = yield* Ref.make>([]); + // Older-page responses resolve through deferreds so tests can interleave + // live events with an in-flight page fetch. + const pendingPageResponses = yield* Queue.unbounded>(); + const supervisorState = yield* SubscriptionRef.make( + AVAILABLE_CONNECTION_STATE, + ); + const client = { + [ORCHESTRATION_WS_METHODS.subscribeThread]: (input: Record) => + Stream.unwrap(Ref.set(lastSubscribeInput, input).pipe(Effect.as(Stream.fromQueue(inputs)))), + } as unknown as WsRpcProtocolClient; + const session: RpcSession.RpcSession = { + client, + initialConfig: Effect.succeed({ + threadSnapshotPagination: options?.paginationCapability !== false, + } as never), + ready: Effect.void, + probe: Effect.void, + closed: Effect.never, + }; + const supervisorSession = yield* SubscriptionRef.make>( + Option.some(session), + ); + const prepared = yield* SubscriptionRef.make>( + Option.some(PREPARED), + ); + const snapshotLoader = ThreadSnapshotLoader.of({ + load: (_prepared, _threadId, window) => + Ref.update(loaderWindows, (current) => [...current, window]).pipe( + Effect.andThen( + window?.beforeCursor === undefined + ? Effect.succeed( + options?.initialResponse ?? Option.none(), + ) + : Deferred.make().pipe( + Effect.tap((deferred) => Queue.offer(pendingPageResponses, deferred)), + Effect.flatMap(Deferred.await), + ), + ), + ), + }); + const supervisor = EnvironmentSupervisor.EnvironmentSupervisor.of({ + target: TARGET, + state: supervisorState, + session: supervisorSession, + prepared, + connect: Effect.void, + disconnect: Effect.void, + retryNow: Effect.void, + } satisfies EnvironmentSupervisor.EnvironmentSupervisor["Service"]); + const cache = Persistence.EnvironmentCacheStore.of({ + loadShell: () => Effect.succeed(Option.none()), + saveShell: () => Effect.void, + loadThread: () => + Effect.succeed(options?.cached !== undefined ? Option.some(options.cached) : Option.none()), + saveThread: (_environmentId, thread) => + Ref.update(savedThreads, (current) => [...current, thread]), + removeThread: () => Effect.void, + loadServerConfig: () => Effect.succeed(Option.none()), + saveServerConfig: () => Effect.void, + loadVcsRefs: () => Effect.succeed(Option.none()), + saveVcsRefs: () => Effect.void, + removeVcsRefs: () => Effect.void, + clearVcsRefs: () => Effect.void, + clear: () => Effect.void, + }); + const threadState = yield* makeEnvironmentThreadState(THREAD_ID).pipe( + Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor), + Effect.provideService(Persistence.EnvironmentCacheStore, cache), + Effect.provideService(ThreadSnapshotLoader, snapshotLoader), + ); + yield* SubscriptionRef.changes(threadState).pipe( + Stream.runForEach((state) => Queue.offer(observed, state)), + Effect.forkScoped, + ); + + const awaitState = (predicate: (state: EnvironmentThreadState) => boolean) => + Queue.take(observed).pipe(Effect.repeat({ until: predicate })); + const resolveNextPage = (response: LoaderResponse) => + Queue.take(pendingPageResponses).pipe( + Effect.flatMap((deferred) => Deferred.succeed(deferred, response)), + ); + + return { + inputs, + observed, + awaitState, + resolveNextPage, + loaderWindows, + lastSubscribeInput, + savedThreads, + threadState, + }; +}); + +const hasMessage = (state: EnvironmentThreadState, id: string): boolean => + Option.match(state.data, { + onNone: () => false, + onSome: (thread) => thread.messages.some((entry) => entry.id === id), + }); + +const titleEvent = (title: string, sequence: number): OrchestrationThreadStreamItem => ({ + kind: "event", + event: { + eventId: EventId.make(`event-title-${sequence}`), + sequence, + occurredAt: "2026-04-01T01:30:00.000Z", + commandId: null, + causationEventId: null, + correlationId: null, + metadata: {}, + aggregateKind: "thread", + aggregateId: THREAD_ID, + type: "thread.meta-updated", + payload: { + threadId: THREAD_ID, + title, + updatedAt: "2026-04-01T01:30:00.000Z", + }, + }, +}); + +// Reverting to turnCount 1 retains only turns whose checkpoint count is <= 1: +// turn-1 survives, turn-2 (the loaded window's newest turn) is discarded. +const revertEvent = (sequence: number): OrchestrationThreadStreamItem => ({ + kind: "event", + event: { + eventId: EventId.make(`event-revert-${sequence}`), + sequence, + occurredAt: "2026-04-01T02:00:00.000Z", + commandId: null, + causationEventId: null, + correlationId: null, + metadata: {}, + aggregateKind: "thread", + aggregateId: THREAD_ID, + type: "thread.reverted", + payload: { + threadId: THREAD_ID, + turnCount: 1, + }, + }, +}); + +describe("thread pagination state", () => { + it.effect("windows the initial load when the server advertises pagination", () => + Effect.gen(function* () { + const harness = yield* makeHarness({ initialResponse: Option.some(WINDOWED_SNAPSHOT) }); + const state = yield* harness.awaitState((value) => Option.isSome(value.page)); + expect(Option.getOrThrow(state.page)).toEqual({ + beforeCursor: "cursor-1", + hasMore: true, + loadingOlder: false, + }); + const windows = yield* Ref.get(harness.loaderWindows); + expect(windows[0]?.turnLimit).toBe(INITIAL_THREAD_USER_TURN_LIMIT); + const subscribeInput = yield* Ref.get(harness.lastSubscribeInput); + expect(subscribeInput?.turnLimit).toBe(INITIAL_THREAD_USER_TURN_LIMIT); + }), + ); + + it.effect("does not send a window to servers without the capability", () => + Effect.gen(function* () { + const harness = yield* makeHarness({ + paginationCapability: false, + initialResponse: Option.some({ snapshotSequence: 10, thread: BASE_THREAD }), + }); + const state = yield* harness.awaitState((value) => Option.isSome(value.data)); + expect(Option.isNone(state.page)).toBe(true); + const windows = yield* Ref.get(harness.loaderWindows); + expect(windows[0]).toBeUndefined(); + const subscribeInput = yield* Ref.get(harness.lastSubscribeInput); + expect(subscribeInput?.turnLimit).toBeUndefined(); + }), + ); + + it.effect("merges an older page below the loaded window and clears the cursor", () => + Effect.gen(function* () { + const harness = yield* makeHarness({ initialResponse: Option.some(WINDOWED_SNAPSHOT) }); + yield* harness.awaitState((value) => Option.isSome(value.page)); + + expect(requestOlderThreadTurns(TARGET.environmentId, THREAD_ID)).toBe(true); + yield* harness.awaitState((value) => + Option.match(value.page, { onNone: () => false, onSome: (page) => page.loadingOlder }), + ); + yield* harness.resolveNextPage(Option.some(OLDER_PAGE)); + + const state = yield* harness.awaitState((value) => hasMessage(value, "message-old")); + const thread = Option.getOrThrow(state.data); + // Older rows land before the loaded window's rows. + expect(thread.messages.map((entry) => entry.id)).toEqual(["message-old", "message-recent"]); + expect(Option.getOrThrow(state.page)).toEqual({ + beforeCursor: null, + hasMore: false, + loadingOlder: false, + }); + }), + ); + + it.effect("discards an in-flight older page when a revert rewrites history", () => + Effect.gen(function* () { + const harness = yield* makeHarness({ initialResponse: Option.some(WINDOWED_SNAPSHOT) }); + yield* harness.awaitState((value) => Option.isSome(value.page)); + + requestOlderThreadTurns(TARGET.environmentId, THREAD_ID); + yield* harness.awaitState((value) => + Option.match(value.page, { onNone: () => false, onSome: (page) => page.loadingOlder }), + ); + // Revert lands while the page fetch is in flight and removes turn-2. + yield* Queue.offer(harness.inputs, revertEvent(11)); + yield* harness.awaitState((value) => !hasMessage(value, "message-recent")); + yield* harness.resolveNextPage(Option.some(OLDER_PAGE)); + + const state = yield* harness.awaitState((value) => + Option.match(value.page, { onNone: () => false, onSome: (page) => !page.loadingOlder }), + ); + // The stale page was dropped: no resurrected rows, cursor unchanged. + expect(hasMessage(state, "message-old")).toBe(false); + expect(Option.getOrThrow(state.page).beforeCursor).toBe("cursor-1"); + }), + ); + + it.effect("discards an in-flight older page when a fresh snapshot replaces the thread", () => + Effect.gen(function* () { + const harness = yield* makeHarness({ initialResponse: Option.some(WINDOWED_SNAPSHOT) }); + yield* harness.awaitState((value) => Option.isSome(value.page)); + + requestOlderThreadTurns(TARGET.environmentId, THREAD_ID); + yield* harness.awaitState((value) => + Option.match(value.page, { onNone: () => false, onSome: (page) => page.loadingOlder }), + ); + yield* Queue.offer(harness.inputs, { + kind: "snapshot", + snapshot: { + snapshotSequence: 20, + thread: { ...BASE_THREAD, title: "Replaced thread" }, + page: { beforeCursor: "cursor-2", hasMore: true, snapshotSequence: 20 }, + }, + }); + yield* harness.awaitState((value) => + Option.match(value.data, { + onNone: () => false, + onSome: (thread) => thread.title === "Replaced thread", + }), + ); + yield* harness.resolveNextPage(Option.some(OLDER_PAGE)); + + const state = yield* harness.awaitState((value) => + Option.match(value.page, { onNone: () => false, onSome: (page) => !page.loadingOlder }), + ); + expect(hasMessage(state, "message-old")).toBe(false); + // The replacement snapshot's cursor wins over the discarded page's. + expect(Option.getOrThrow(state.page).beforeCursor).toBe("cursor-2"); + }), + ); + + it.effect("discards an older page read from a projection behind the loaded state", () => + Effect.gen(function* () { + const harness = yield* makeHarness({ initialResponse: Option.some(WINDOWED_SNAPSHOT) }); + yield* harness.awaitState((value) => Option.isSome(value.page)); + + requestOlderThreadTurns(TARGET.environmentId, THREAD_ID); + yield* harness.awaitState((value) => + Option.match(value.page, { onNone: () => false, onSome: (page) => page.loadingOlder }), + ); + yield* harness.resolveNextPage(Option.some({ ...OLDER_PAGE, snapshotSequence: 5 })); + + const state = yield* harness.awaitState((value) => + Option.match(value.page, { onNone: () => false, onSome: (page) => !page.loadingOlder }), + ); + expect(hasMessage(state, "message-old")).toBe(false); + expect(Option.getOrThrow(state.page).beforeCursor).toBe("cursor-1"); + }), + ); + + it.effect("a merged history page never advances the live-event dedupe sequence", () => + Effect.gen(function* () { + const harness = yield* makeHarness({ initialResponse: Option.some(WINDOWED_SNAPSHOT) }); + yield* harness.awaitState((value) => Option.isSome(value.page)); + + requestOlderThreadTurns(TARGET.environmentId, THREAD_ID); + yield* harness.awaitState((value) => + Option.match(value.page, { onNone: () => false, onSome: (page) => page.loadingOlder }), + ); + // The page was captured at a newer projection sequence (12) than the + // loaded state (10); merging it must not swallow events 11-12. + yield* harness.resolveNextPage( + Option.some({ + ...OLDER_PAGE, + snapshotSequence: 12, + page: { beforeCursor: null, hasMore: false, snapshotSequence: 12 }, + }), + ); + yield* harness.awaitState((value) => hasMessage(value, "message-old")); + + // Event at sequence 11 must still apply after the merge: the revert + // discards turn-2, so the loaded window's row disappears while the + // merged older turn-1 row survives. If the merge had advanced the + // dedupe sequence to the page's 12, this event would be swallowed. + yield* Queue.offer(harness.inputs, revertEvent(11)); + const state = yield* harness.awaitState( + (value) => !hasMessage(value, "message-recent") && hasMessage(value, "message-old"), + ); + expect(hasMessage(state, "message-old")).toBe(true); + }), + ); + + it.effect("parks a page read ahead of the live state until events catch up", () => + Effect.gen(function* () { + // A page whose thread watermark is ahead of the loaded state may + // contain streaming content the subscription has not delivered yet + // (e.g. an out-of-window subagent turn mid-stream); merging it + // immediately and then replaying those deltas would duplicate text. + // The page parks until the live state reaches the watermark. + const harness = yield* makeHarness({ initialResponse: Option.some(WINDOWED_SNAPSHOT) }); + yield* harness.awaitState((value) => Option.isSome(value.page)); + + requestOlderThreadTurns(TARGET.environmentId, THREAD_ID); + yield* harness.awaitState((value) => + Option.match(value.page, { onNone: () => false, onSome: (page) => page.loadingOlder }), + ); + // Page watermark 11 > loaded sequence 10: must park, not merge. + yield* harness.resolveNextPage( + Option.some({ + ...OLDER_PAGE, + snapshotSequence: 11, + page: { beforeCursor: null, hasMore: false, snapshotSequence: 11, threadSequence: 11 }, + }), + ); + + // A live event at sequence 11 arrives; only then does the page merge. + yield* Queue.offer(harness.inputs, titleEvent("Advanced past watermark", 11)); + const state = yield* harness.awaitState((value) => hasMessage(value, "message-old")); + expect(hasMessage(state, "message-recent")).toBe(true); + expect(Option.getOrThrow(state.page).loadingOlder).toBe(false); + }), + ); + + it.effect("a revert keeps the page cursor and triggers no refresh fetch", () => + Effect.gen(function* () { + // Cursors are an (anchor, turnId) keyset derived from event content, so + // they survive the revert projector's row rewrite: the machine keeps + // the stored cursor and performs no snapshot re-fetch. The revert + // reducer's turn filtering alone handles loaded history. + const harness = yield* makeHarness({ initialResponse: Option.some(WINDOWED_SNAPSHOT) }); + yield* harness.awaitState((value) => Option.isSome(value.page)); + + yield* Queue.offer(harness.inputs, revertEvent(11)); + const state = yield* harness.awaitState((value) => !hasMessage(value, "message-recent")); + + expect(Option.getOrThrow(state.page).beforeCursor).toBe("cursor-1"); + const windows = yield* Ref.get(harness.loaderWindows); + // Only the initial load hit the loader — no post-revert refresh fetch. + expect(windows.length).toBe(1); + }), + ); + + it.effect("drops a windowed cache when the server lacks the pagination capability", () => + Effect.gen(function* () { + // Resuming a windowed cache via afterSequence against a pre-pagination + // server would render only the window forever with no way to load the + // rest: the machine must discard the cache and take a full snapshot. + const fullSnapshot: OrchestrationThreadDetailSnapshot = { + snapshotSequence: 20, + thread: { ...BASE_THREAD, title: "Full reload" }, + }; + const harness = yield* makeHarness({ + paginationCapability: false, + cached: WINDOWED_SNAPSHOT, + initialResponse: Option.some(fullSnapshot), + }); + + const state = yield* harness.awaitState((value) => + Option.match(value.data, { + onNone: () => false, + onSome: (thread) => thread.title === "Full reload", + }), + ); + expect(Option.isNone(state.page)).toBe(true); + // The subscription resumed from the fresh full snapshot, not the + // discarded windowed cache's watermark, and sent no window fields. + const subscribeInput = yield* Ref.get(harness.lastSubscribeInput); + expect(subscribeInput?.turnLimit).toBeUndefined(); + expect(subscribeInput?.afterSequence).toBe(20); + }), + ); + + it.effect("keeps a windowed cache when the server supports pagination", () => + Effect.gen(function* () { + const harness = yield* makeHarness({ cached: WINDOWED_SNAPSHOT }); + const state = yield* harness.awaitState((value) => Option.isSome(value.page)); + expect(Option.getOrThrow(state.page).beforeCursor).toBe("cursor-1"); + // Wait for the subscription (recorded when the WS method is invoked) + // before asserting its input. + const subscribeInput = yield* Ref.get(harness.lastSubscribeInput).pipe( + Effect.repeat({ until: (input) => input !== undefined }), + ); + expect(subscribeInput?.afterSequence).toBe(10); + }), + ); +}); diff --git a/packages/client-runtime/src/state/threads-sync.test.ts b/packages/client-runtime/src/state/threads-sync.test.ts index 2f6883d56f76..3f1404368e01 100644 --- a/packages/client-runtime/src/state/threads-sync.test.ts +++ b/packages/client-runtime/src/state/threads-sync.test.ts @@ -137,7 +137,6 @@ const makeHarness = Effect.fn("TestEnvironmentThreads.makeHarness")(function* (o readonly cached?: OrchestrationThread; readonly httpSnapshot?: Option.Option; readonly completionMarker?: boolean; - readonly eventBatchSize?: number; }) { const inputs = yield* Queue.unbounded(); const observed = yield* Queue.unbounded(); @@ -227,9 +226,7 @@ const makeHarness = Effect.fn("TestEnvironmentThreads.makeHarness")(function* (o clearVcsRefs: () => Effect.void, clear: () => Effect.void, }); - const threadState = yield* makeEnvironmentThreadState(THREAD_ID, { - eventBatchSize: options?.eventBatchSize ?? 1, - }).pipe( + const threadState = yield* makeEnvironmentThreadState(THREAD_ID).pipe( Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor), Effect.provideService(Persistence.EnvironmentCacheStore, cache), Effect.provideService(ThreadSnapshotLoader, snapshotLoader), @@ -397,84 +394,6 @@ describe("EnvironmentThreads", () => { }), ); - it.effect("applies a live burst in order with one state publication", () => - Effect.gen(function* () { - const harness = yield* makeHarness({ - cached: BASE_THREAD, - completionMarker: true, - eventBatchSize: 64, - }); - yield* awaitThreadState( - harness.observed, - (value) => value.status === "synchronizing" && Option.isSome(value.data), - ); - const publicationsBeforeBurst = yield* Ref.get(harness.statePublicationCount); - - const finalSequence = CACHED_SNAPSHOT_SEQUENCE + 63; - for (let sequence = CACHED_SNAPSHOT_SEQUENCE + 1; sequence <= finalSequence; sequence += 1) { - yield* Queue.offer( - harness.inputs, - titleUpdated( - sequence === finalSequence - ? "Final title" - : sequence === CACHED_SNAPSHOT_SEQUENCE + 1 - ? "First title" - : "Interim title", - sequence, - ), - ); - } - yield* Queue.offer(harness.inputs, synchronized()); - - const state = yield* awaitThreadState( - harness.observed, - (value) => - value.status === "live" && - Option.isSome(value.data) && - value.data.value.title === "Final title", - ); - - expect(Option.getOrThrow(state.data).title).toBe("Final title"); - expect(yield* Ref.get(harness.statePublicationCount)).toBe(publicationsBeforeBurst + 1); - }), - ); - - it.effect("persists a settled snapshot before a batched turn starts", () => - Effect.gen(function* () { - const harness = yield* makeHarness({ - cached: ACTIVE_THREAD, - eventBatchSize: 2, - }); - - yield* Queue.offer( - harness.inputs, - sessionUpdated("ready", CACHED_SNAPSHOT_SEQUENCE + 1, null), - ); - yield* Queue.offer( - harness.inputs, - sessionUpdated("running", CACHED_SNAPSHOT_SEQUENCE + 2, TurnId.make("turn-2")), - ); - yield* Queue.offer(harness.inputs, synchronized()); - - const state = yield* awaitThreadState( - harness.observed, - (value) => - value.status === "live" && - Option.isSome(value.data) && - value.data.value.session?.status === "running" && - value.data.value.session.activeTurnId === TurnId.make("turn-2"), - ); - - expect(Option.getOrThrow(state.data).session?.status).toBe("running"); - yield* TestClock.adjust("500 millis"); - yield* Effect.yieldNow; - - const saved = (yield* Ref.get(harness.savedThreads)).at(-1); - expect(saved?.snapshotSequence).toBe(CACHED_SNAPSHOT_SEQUENCE + 1); - expect(saved?.thread.session?.status).toBe("ready"); - }), - ); - it.effect("reduces live events and persists the latest thread", () => Effect.gen(function* () { const harness = yield* makeHarness({ cached: BASE_THREAD }); @@ -564,7 +483,10 @@ describe("EnvironmentThreads", () => { } expect(yield* Ref.get(harness.subscriptionCount)).toBe(2); - expect(yield* Ref.get(harness.loaderCalls)).toBe(1); + // Upstream makeSubscribeInput reloads over HTTP whenever data is still + // empty, including after a transient stream failure — so a second + // subscription attempt issues a second snapshot load. + expect(yield* Ref.get(harness.loaderCalls)).toBe(2); }), ); @@ -713,16 +635,40 @@ describe("EnvironmentThreads", () => { }), ); - it.effect("marks the thread deleted and stops retrying on a permanent deleted failure", () => + it.effect( + "surfaces a deleted-thread stream failure without wiping cached data until a delete event", + () => + Effect.gen(function* () { + const harness = yield* makeHarness({ cached: BASE_THREAD }); + yield* Queue.offer( + harness.inputs, + new OrchestrationGetSnapshotError({ + message: "Thread thread-1 was deleted", + reason: "thread-deleted", + }), + ); + + const state = yield* awaitThreadState(harness.observed, (value) => + Option.isSome(value.error), + ); + // Upstream setStreamError keeps any cached data and does not map + // OrchestrationGetSnapshotError reasons onto status "deleted". + expect(Option.getOrThrow(state.data)).toEqual(BASE_THREAD); + expect(state.status).toBe("cached"); + expect(Option.getOrThrow(state.error)).toContain("deleted"); + expect(yield* Ref.get(harness.removedThreads)).toEqual([]); + }), + ); + + it.effect("marks the thread deleted when a thread.deleted event arrives", () => Effect.gen(function* () { const harness = yield* makeHarness({ cached: BASE_THREAD }); - yield* Queue.offer( - harness.inputs, - new OrchestrationGetSnapshotError({ - message: "Thread thread-1 was deleted", - reason: "thread-deleted", - }), + yield* Queue.offer(harness.inputs, snapshot(BASE_THREAD)); + yield* awaitThreadState( + harness.observed, + (value) => value.status === "live" && Option.isSome(value.data), ); + yield* Queue.offer(harness.inputs, deleted()); const state = yield* awaitThreadState( harness.observed, @@ -730,16 +676,10 @@ describe("EnvironmentThreads", () => { ); expect(Option.isNone(state.data)).toBe(true); expect(yield* Ref.get(harness.removedThreads)).toEqual([THREAD_ID]); - - yield* TestClock.adjust("2 seconds"); - for (let attempt = 0; attempt < 100; attempt += 1) { - yield* Effect.yieldNow; - } - expect(yield* Ref.get(harness.subscriptionCount)).toBe(1); }), ); - it.effect("keeps cached data and stops retrying when the thread is archived", () => + it.effect("keeps cached data when the stream reports the thread is archived", () => Effect.gen(function* () { const harness = yield* makeHarness({ cached: BASE_THREAD }); yield* Queue.offer( @@ -755,14 +695,8 @@ describe("EnvironmentThreads", () => { ); expect(Option.getOrThrow(state.data)).toEqual(BASE_THREAD); expect(state.status).toBe("cached"); - expect(Option.getOrThrow(state.error)).toBe("Thread thread-1 is archived"); + expect(Option.getOrThrow(state.error)).toContain("archived"); expect(yield* Ref.get(harness.removedThreads)).toEqual([]); - - yield* TestClock.adjust("2 seconds"); - for (let attempt = 0; attempt < 100; attempt += 1) { - yield* Effect.yieldNow; - } - expect(yield* Ref.get(harness.subscriptionCount)).toBe(1); }), ); diff --git a/packages/client-runtime/src/state/threads.ts b/packages/client-runtime/src/state/threads.ts index b2bded785a58..4ba5a0e9df18 100644 --- a/packages/client-runtime/src/state/threads.ts +++ b/packages/client-runtime/src/state/threads.ts @@ -1,18 +1,19 @@ import { ORCHESTRATION_WS_METHODS, type EnvironmentId as EnvironmentIdType, - type OrchestrationGetSnapshotError, type OrchestrationThread, + type OrchestrationThreadDetailPage, type OrchestrationThreadDetailSnapshot, type OrchestrationThreadStreamItem, type ThreadId as ThreadIdType, } from "@t3tools/contracts"; import * as Cause from "effect/Cause"; -import * as Duration from "effect/Duration"; +import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as Option from "effect/Option"; import * as Queue from "effect/Queue"; import * as Ref from "effect/Ref"; +import * as Semaphore from "effect/Semaphore"; import * as Stream from "effect/Stream"; import * as SubscriptionRef from "effect/SubscriptionRef"; import { Atom } from "effect/unstable/reactivity"; @@ -23,130 +24,99 @@ import { EnvironmentSupervisor } from "../connection/supervisor.ts"; import * as ConnectionWakeups from "../connection/wakeups.ts"; import { EnvironmentCacheStore } from "../platform/persistence.ts"; import { subscribeDynamic } from "../rpc/client.ts"; -import { ThreadSnapshotLoader } from "./threadSnapshotHttp.ts"; +import { ThreadSnapshotLoader, type ThreadSnapshotWindow } from "./threadSnapshotHttp.ts"; import { parseThreadKey, threadKey } from "./entities.ts"; import { applyThreadDetailEvent } from "./threadReducer.ts"; import { THREAD_STATE_IDLE_TTL_MS } from "./threadRetention.ts"; import { followStreamInEnvironment } from "./runtime.ts"; import { EMPTY_ENVIRONMENT_THREAD_STATE, + type EnvironmentThreadPageState, type EnvironmentThreadState, type EnvironmentThreadStatus, } from "./threadState.ts"; -const THREAD_EVENT_BATCH_WINDOW = Duration.millis(16); -const THREAD_EVENT_BATCH_MAX_SIZE = 64; - -interface ThreadStreamBatchReduction { - readonly state: EnvironmentThreadState; - readonly lastSequence: number; - readonly awaitingCompletion: boolean; - readonly threadDeleted: boolean; - readonly reloadRequired: boolean; - readonly persistableSnapshot: OrchestrationThreadDetailSnapshot | null; +function statusWithoutLiveData(data: Option.Option): EnvironmentThreadStatus { + return Option.isSome(data) ? "cached" : "empty"; } -export interface EnvironmentThreadStateOptions { - readonly eventBatchSize?: number; +/** + * Turn window sizes for paginated thread loads: the initial page covers the + * last 10 user-anchored turns (subagent/fan-out turns ride along), each + * "load earlier" tap fetches 20 more. Sized so first paint on the heaviest + * observed threads stays around 100K gzipped while median threads load fully. + */ +export const INITIAL_THREAD_USER_TURN_LIMIT = 10; +export const OLDER_THREAD_PAGE_USER_TURN_LIMIT = 20; + +function pageStateFromSnapshot( + page: OrchestrationThreadDetailPage | undefined, +): Option.Option { + return page === undefined + ? Option.none() + : Option.some({ + beforeCursor: page.beforeCursor, + hasMore: page.hasMore, + loadingOlder: false, + }); } -function reduceThreadStreamItems( - currentState: EnvironmentThreadState, - currentSequence: number, - currentAwaitingCompletion: boolean, - items: ReadonlyArray, -): ThreadStreamBatchReduction { - let state = currentState; - let lastSequence = currentSequence; - let awaitingCompletion = currentAwaitingCompletion; - let thread = Option.getOrNull(currentState.data); - let threadDeleted = false; - let reloadRequired = false; - let persistableSnapshot: OrchestrationThreadDetailSnapshot | null = null; - - for (const item of items) { - if (item.kind === "synchronized") { - awaitingCompletion = false; - if (thread !== null && state.status !== "deleted") { - state = { - data: state.data, - status: "live", - error: Option.none(), - }; - } - continue; - } +interface ThreadOlderTurnRequestRegistry { + /** + * Registers the live state machine for a thread. Returns the deregistration + * cleanup; registration lives exactly as long as the machine's scope, and a + * successor machine for the same thread simply replaces the entry. + */ + readonly register: (key: string, handler: () => void) => () => void; + readonly request: (key: string) => boolean; +} - if (item.kind === "snapshot") { - lastSequence = item.snapshot.snapshotSequence; - thread = item.snapshot.thread; - threadDeleted = false; - persistableSnapshot = shouldPersistThread(thread) ? item.snapshot : null; - state = { - data: Option.some(thread), - status: awaitingCompletion ? "synchronizing" : "live", - error: Option.none(), +function makeThreadOlderTurnRequestRegistry(): ThreadOlderTurnRequestRegistry { + const handlers = new Map void>(); + return { + register: (key, handler) => { + handlers.set(key, handler); + return () => { + if (handlers.get(key) === handler) { + handlers.delete(key); + } }; - continue; - } - - if (item.event.sequence <= lastSequence) { - continue; - } - lastSequence = item.event.sequence; - - if (thread === null) { - if (item.event.type === "thread.deleted") { - awaitingCompletion = false; - threadDeleted = true; - persistableSnapshot = null; - state = { - data: Option.none(), - status: "deleted", - error: Option.none(), - }; - } - continue; - } - - const result = applyThreadDetailEvent(thread, item.event); - if (result.kind === "updated") { - thread = result.thread; - if (shouldPersistThread(thread)) { - persistableSnapshot = { snapshotSequence: lastSequence, thread }; + }, + request: (key) => { + const handler = handlers.get(key); + if (handler === undefined) { + return false; } - state = { - data: Option.some(thread), - status: awaitingCompletion ? "synchronizing" : "live", - error: Option.none(), - }; - } else if (result.kind === "deleted") { - awaitingCompletion = false; - thread = null; - threadDeleted = true; - persistableSnapshot = null; - state = { - data: Option.none(), - status: "deleted", - error: Option.none(), - }; - } else if (result.kind === "reload-required") { - reloadRequired = true; - } - } - - return { - state, - lastSequence, - awaitingCompletion, - threadDeleted, - reloadRequired, - persistableSnapshot, + handler(); + return true; + }, }; } -function statusWithoutLiveData(data: Option.Option): EnvironmentThreadStatus { - return Option.isSome(data) ? "cached" : "empty"; +const defaultOlderTurnRequestRegistry = makeThreadOlderTurnRequestRegistry(); + +/** + * Channel from UI actions to the live per-thread state machines. The machines + * resolve it from the Effect environment (overridable in tests); the default + * instance is shared with the sync `requestOlderThreadTurns` entry point so + * the apps get working wiring without providing anything. + */ +export class ThreadOlderTurnRequests extends Context.Reference( + "@t3tools/client-runtime/state/threads/ThreadOlderTurnRequests", + { defaultValue: () => defaultOlderTurnRequestRegistry }, +) {} + +/** + * Asks the live state machine for `threadId` to fetch the next older page. + * Returns false when no machine is live or no fetch was started (no cursor, + * already loading); callers render from `EnvironmentThreadState.page` and can + * treat false as "nothing to do". + */ +export function requestOlderThreadTurns( + environmentId: EnvironmentIdType, + threadId: ThreadIdType, +): boolean { + return defaultOlderTurnRequestRegistry.request(threadKey({ environmentId, threadId })); } function formatThreadError(cause: Cause.Cause): string { @@ -156,33 +126,6 @@ function formatThreadError(cause: Cause.Cause): string { : "Could not synchronize the thread."; } -/** - * Extract a permanent snapshot-unavailable reason from a subscription failure. - * "thread-missing" is intentionally not returned: the projection row may just - * not be written yet (a freshly created thread), so it stays retriable. - */ -function terminalSnapshotReason( - cause: Cause.Cause, -): "thread-deleted" | "thread-archived" | undefined { - for (const reason of cause.reasons) { - if (reason._tag !== "Fail") { - continue; - } - const error: unknown = reason.error; - if ( - typeof error === "object" && - error !== null && - (error as { readonly _tag?: unknown })._tag === "OrchestrationGetSnapshotError" - ) { - const snapshotReason = (error as OrchestrationGetSnapshotError).reason; - if (snapshotReason === "thread-deleted" || snapshotReason === "thread-archived") { - return snapshotReason; - } - } - } - return undefined; -} - function shouldPersistThread(thread: OrchestrationThread): boolean { const status = thread.session?.status; return status !== "starting" && status !== "running"; @@ -190,14 +133,12 @@ function shouldPersistThread(thread: OrchestrationThread): boolean { export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make")(function* ( threadId: ThreadIdType, - options?: EnvironmentThreadStateOptions, ) { const supervisor = yield* EnvironmentSupervisor; const cache = yield* EnvironmentCacheStore; const snapshotLoader = yield* ThreadSnapshotLoader; const wakeups = yield* Effect.serviceOption(ConnectionWakeups.ConnectionWakeups); const environmentId = supervisor.target.environmentId; - const eventBatchSize = options?.eventBatchSize ?? THREAD_EVENT_BATCH_MAX_SIZE; const cached = yield* cache.loadThread(environmentId, threadId).pipe( Effect.catch((error) => Effect.logWarning("Could not load cached thread.").pipe( @@ -215,6 +156,9 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make data: cachedThread, status: statusWithoutLiveData(cachedThread), error: Option.none(), + // A cached windowed snapshot restores its page cursor so "load earlier" + // works while rendering from cache; a cached full snapshot has no page. + page: Option.flatMap(cached, (snapshot) => pageStateFromSnapshot(snapshot.page)), }); // Seed the resume cursor from the cached snapshot so a warm cache can catch up // via `afterSequence` instead of re-downloading the full thread body. @@ -222,7 +166,25 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make Option.match(cached, { onNone: () => 0, onSome: (snapshot) => snapshot.snapshotSequence }), ); const awaitingCompletion = yield* Ref.make(false); - const httpSnapshotLoadAttempted = yield* Ref.make(false); + // Bumped whenever loaded history may have been rewritten out from under an + // in-flight older-page fetch (snapshot replacement, revert, deletion). A + // page response captured under an older epoch is discarded, not merged. + const historyEpoch = yield* Ref.make(0); + // Serializes stream-item application against older-page staleness checks + + // merges. Without it, a revert or snapshot processed between loadOlderTurns' + // epoch check and its merge could still slip resurrected history in. + const applyLock = yield* Semaphore.make(1); + // Whether the connected server accepts windowed reads; set per subscription + // from the session config. Gates loadOlderTurns so a reconnect to a + // pre-pagination server never sends unsupported window parameters. + const paginationSupported = yield* Ref.make(false); + // An older page whose thread watermark is ahead of the live state, parked + // until the subscription catches up (see mergeOlderPage's caller). At most + // one can exist because loadOlderTurns no-ops while loadingOlder is true. + const pendingOlderPage = yield* Ref.make<{ + readonly snapshot: OrchestrationThreadDetailSnapshot; + readonly epoch: number; + } | null>(null); const persistence = yield* Queue.sliding(1); const persist = Effect.fn("EnvironmentThreadState.persist")(function* ( @@ -267,6 +229,12 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make ); const setDisconnected = Effect.gen(function* () { yield* Ref.set(awaitingCompletion, false); + // The capability belongs to the session that advertised it. During a + // reconnect, a new prepared connection can exist before the new session's + // config arrives; leaving the old value would let loadOlderTurns send + // window parameters to a server that may not accept them (review + // finding). makeSubscribeInput re-sets it from the next session's config. + yield* Ref.set(paginationSupported, false); yield* SubscriptionRef.update(state, (current) => ({ ...current, status: current.status === "deleted" ? current.status : statusWithoutLiveData(current.data), @@ -284,85 +252,283 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make ), ); - const removeCachedThread = cache.removeThread(environmentId, threadId).pipe( - Effect.catch((error) => - Effect.logWarning("Could not remove the cached thread.").pipe( - Effect.annotateLogs({ - environmentId, - threadId, - error: error.message, + const setThread = Effect.fn("EnvironmentThreadState.setThread")(function* ( + thread: OrchestrationThread, + // "keep" preserves the current page state (live events touch only loaded + // recent turns); a snapshot or merged page passes its own page state. + page: Option.Option | "keep", + ) { + const waiting = yield* Ref.get(awaitingCompletion); + yield* SubscriptionRef.update(state, (current) => ({ + data: Option.some(thread), + status: waiting ? ("synchronizing" as const) : ("live" as const), + error: Option.none(), + page: page === "keep" ? current.page : page, + })); + // Active threads can update many times per second and retain large tool + // payloads. The server remains the source of truth while a turn is active; + // persist once it settles so cache encoding stays off the streaming path. + if (shouldPersistThread(thread)) { + const snapshotSequence = yield* SubscriptionRef.get(lastSequence); + const currentPage = yield* SubscriptionRef.get(state).pipe(Effect.map((value) => value.page)); + yield* Queue.offer(persistence, { + snapshotSequence, + thread, + // Persist the window boundary with the window's content so a cache + // restore can keep paging from where the loaded history ends. + ...Option.match(currentPage, { + onNone: () => ({}), + onSome: (value) => + ({ + page: { + beforeCursor: value.beforeCursor, + hasMore: value.hasMore, + snapshotSequence, + }, + }) as const, }), - ), - ), - ); + }); + } + }); - // A terminal `thread-deleted` subscription failure never reaches the item - // stream, so that path publishes the deleted state itself instead of going - // through the batch reducer. const setDeleted = Effect.fn("EnvironmentThreadState.setDeleted")(function* () { yield* Ref.set(awaitingCompletion, false); + yield* Ref.update(historyEpoch, (epoch) => epoch + 1); yield* SubscriptionRef.set(state, { data: Option.none(), status: "deleted", error: Option.none(), + page: Option.none(), }); - yield* removeCachedThread; + yield* cache.removeThread(environmentId, threadId).pipe( + Effect.catch((error) => + Effect.logWarning("Could not remove the cached thread.").pipe( + Effect.annotateLogs({ + environmentId, + threadId, + error: error.message, + }), + ), + ), + ); }); - // Re-read the thread from the server, replacing whatever we hold. Used when an - // event cannot be reconciled against the cached transcript ("reload-required"), - // and by the manual reload action. Failures leave the current state in place — - // the caller is already in a degraded path and a live subscription may recover. - const reloadFromServer = Effect.fn("EnvironmentThreadState.reloadFromServer")(function* () { - const prepared = yield* SubscriptionRef.get(supervisor.prepared); - if (Option.isNone(prepared)) { + // Body of applyItem, running under applyLock. + const applyItemLocked = Effect.fn("EnvironmentThreadState.applyItemLocked")(function* ( + item: OrchestrationThreadStreamItem, + ) { + if (item.kind === "synchronized") { + yield* Ref.set(awaitingCompletion, false); + yield* SubscriptionRef.update(state, (current) => + Option.isSome(current.data) && current.status !== "deleted" + ? { ...current, status: "live" as const, error: Option.none() } + : current, + ); return; } - const fresh = yield* snapshotLoader - .load(prepared.value, threadId) - .pipe(Effect.orElseSucceed(() => Option.none())); - if (Option.isNone(fresh)) { + + if (item.kind === "snapshot") { + // A fresh snapshot replaces all loaded history, including older + // pages: a turn reverted while disconnected would otherwise survive + // in the preserved history with no event left to remove it. The + // epoch bump discards any older-page fetch racing this snapshot. + yield* Ref.update(historyEpoch, (epoch) => epoch + 1); + yield* SubscriptionRef.set(lastSequence, item.snapshot.snapshotSequence); + yield* setThread(item.snapshot.thread, pageStateFromSnapshot(item.snapshot.page)); return; } - yield* SubscriptionRef.set(lastSequence, fresh.value.snapshotSequence); - yield* SubscriptionRef.set(state, { - data: Option.some(fresh.value.thread), - status: (yield* Ref.get(awaitingCompletion)) ? "synchronizing" : "live", - error: Option.none(), - }); - if (shouldPersistThread(fresh.value.thread)) { - yield* Queue.offer(persistence, fresh.value); + + const sequence = yield* SubscriptionRef.get(lastSequence); + if (item.event.sequence <= sequence) { + return; + } + yield* SubscriptionRef.set(lastSequence, item.event.sequence); + + const current = yield* SubscriptionRef.get(state); + if (Option.isNone(current.data)) { + if (item.event.type === "thread.deleted") { + yield* setDeleted(); + } + return; } + if (item.event.type === "thread.reverted") { + // A revert rewrites loaded history (whole turns disappear), so an + // older-page fetch in flight may straddle the removed range; the epoch + // bump discards it. The stored page cursor stays valid: cursors are an + // (anchor, turnId) keyset derived from event content, which survives + // the revert projector's row rewrite, so no refresh is needed — the + // revert reducer's turn filtering fully handles loaded history. + yield* Ref.update(historyEpoch, (epoch) => epoch + 1); + } + const result = applyThreadDetailEvent(current.data.value, item.event); + if (result.kind === "updated") { + yield* setThread(result.thread, "keep"); + } else if (result.kind === "deleted") { + yield* setDeleted(); + } + // The event may have advanced the live state past a parked page's + // watermark; merge it as soon as that happens. + yield* tryMergePendingOlderPage(); }); - const applyItems = Effect.fn("EnvironmentThreadState.applyItems")(function* ( - items: ReadonlyArray, + // Merges a parked older page once the live state has caught up to the + // page's thread watermark, or discards it if history was rewritten + // (epoch advanced) while it waited. Must run under applyLock. + const tryMergePendingOlderPage = Effect.fn("EnvironmentThreadState.tryMergePendingOlderPage")( + function* () { + const pending = yield* Ref.get(pendingOlderPage); + if (pending === null) { + return; + } + const epochNow = yield* Ref.get(historyEpoch); + if (epochNow !== pending.epoch) { + yield* Ref.set(pendingOlderPage, null); + yield* SubscriptionRef.update(state, (value) => ({ + ...value, + page: Option.map(value.page, (existing) => ({ ...existing, loadingOlder: false })), + })); + return; + } + const watermark = pending.snapshot.page?.threadSequence; + const loadedSequence = yield* SubscriptionRef.get(lastSequence); + if (watermark !== undefined && watermark > loadedSequence) { + return; + } + yield* Ref.set(pendingOlderPage, null); + yield* mergeOlderPage(pending.snapshot); + }, + ); + + const applyItem = Effect.fn("EnvironmentThreadState.applyItem")(function* ( + item: OrchestrationThreadStreamItem, ) { - const currentState = yield* SubscriptionRef.get(state); - const reduction = reduceThreadStreamItems( - currentState, - yield* SubscriptionRef.get(lastSequence), - yield* Ref.get(awaitingCompletion), - items, - ); + yield* applyLock.withPermits(1)(applyItemLocked(item)); + }); - yield* SubscriptionRef.set(lastSequence, reduction.lastSequence); - yield* Ref.set(awaitingCompletion, reduction.awaitingCompletion); - if (reduction.state !== currentState) { - yield* SubscriptionRef.set(state, reduction.state); + // Merges an older disjoint page below the currently loaded window. All four + // windowed collections prepend; identity dedupe guards the (server-bug or + // cursor-misuse) case of overlapping pages so a row never renders twice. + const mergeOlderPage = Effect.fn("EnvironmentThreadState.mergeOlderPage")(function* ( + snapshot: OrchestrationThreadDetailSnapshot, + ) { + // The merge is built inside the update callback so it composes with + // whatever thread value is current at commit time. The applyLock already + // serializes this against event application; the atomic build is defense + // in depth against future callers outside the lock. + let merged: OrchestrationThread | null = null; + yield* SubscriptionRef.update(state, (value) => { + if (Option.isNone(value.data)) { + return value; + } + const loaded = value.data.value; + const older = snapshot.thread; + const mergeById = ( + olderRows: ReadonlyArray, + loadedRows: ReadonlyArray, + ): ReadonlyArray => { + const seen = new Set(loadedRows.map((row) => row.id)); + return [...olderRows.filter((row) => !seen.has(row.id)), ...loadedRows]; + }; + const seenCheckpoints = new Set(loaded.checkpoints.map((row) => row.turnId)); + merged = { + // Thread metadata stays the loaded (newer) snapshot's; only the + // windowed collections gain rows from the older page. + ...loaded, + messages: mergeById(older.messages, loaded.messages), + activities: mergeById(older.activities, loaded.activities), + proposedPlans: mergeById(older.proposedPlans, loaded.proposedPlans), + checkpoints: [ + ...older.checkpoints.filter((row) => !seenCheckpoints.has(row.turnId)), + ...loaded.checkpoints, + ], + }; + return { + ...value, + data: Option.some(merged), + page: pageStateFromSnapshot(snapshot.page), + }; + }); + // Persist the widened window under the *loaded* watermark: the merged + // content is only known consistent with the state it merged into, not + // with the page's own (possibly newer) sequence. + if (merged !== null && shouldPersistThread(merged)) { + const snapshotSequence = yield* SubscriptionRef.get(lastSequence); + yield* Queue.offer(persistence, { + snapshotSequence, + thread: merged, + ...(snapshot.page === undefined ? {} : { page: { ...snapshot.page, snapshotSequence } }), + }); } + }); - if (reduction.threadDeleted) { - yield* removeCachedThread; + const loadOlderTurns = Effect.fn("EnvironmentThreadState.loadOlderTurns")(function* () { + // Gated on the connected server's capability: a reconnect to a + // pre-pagination server must never receive window parameters. + if (!(yield* Ref.get(paginationSupported))) { return; } - - if (reduction.persistableSnapshot !== null) { - yield* Queue.offer(persistence, reduction.persistableSnapshot); + const current = yield* SubscriptionRef.get(state); + const page = Option.getOrNull(current.page); + if (page === null || page.loadingOlder || !page.hasMore || page.beforeCursor === null) { + return; } - if (reduction.reloadRequired) { - yield* reloadFromServer(); + const prepared = Option.getOrNull(yield* SubscriptionRef.get(supervisor.prepared)); + if (prepared === null) { + return; } + const epochAtStart = yield* Ref.get(historyEpoch); + yield* SubscriptionRef.update(state, (value) => ({ + ...value, + page: Option.map(value.page, (existing) => ({ ...existing, loadingOlder: true })), + })); + const window: ThreadSnapshotWindow = { + turnLimit: OLDER_THREAD_PAGE_USER_TURN_LIMIT, + beforeCursor: page.beforeCursor, + }; + const response = yield* snapshotLoader.load(prepared, threadId, window); + // Staleness check and merge run under the same lock as stream-item + // application, so a revert/snapshot cannot land between them (TOCTOU + // review finding) — anything that rewrites history bumps the epoch + // before this permit is acquired. + yield* applyLock.withPermits(1)( + Effect.gen(function* () { + const epochNow = yield* Ref.get(historyEpoch); + const loadedSequence = yield* SubscriptionRef.get(lastSequence); + // A page carrying a sequence older than the loaded state was read + // from a projection behind what we render; merging it could + // resurrect turns a newer snapshot or revert already removed. + const stale = + epochNow !== epochAtStart || + Option.match(response, { + onNone: () => false, + onSome: (snapshot) => snapshot.snapshotSequence < loadedSequence, + }); + if (Option.isNone(response) || stale) { + yield* SubscriptionRef.update(state, (value) => ({ + ...value, + page: Option.map(value.page, (existing) => ({ ...existing, loadingOlder: false })), + })); + return; + } + // A page read AHEAD of the live state may include content (e.g. + // streaming deltas of an out-of-window turn) the subscription has + // not delivered yet; merging now and then replaying those events + // would duplicate them. Park the page until the live state reaches + // the page's thread-scoped watermark; loadingOlder stays true so + // the UI shows progress and no second fetch starts. Pages from + // pre-watermark servers (threadSequence absent) merge immediately, + // preserving the old behavior. + const watermark = response.value.page?.threadSequence; + if (watermark !== undefined && watermark > loadedSequence) { + yield* Ref.set(pendingOlderPage, { + snapshot: response.value, + epoch: epochNow, + }); + return; + } + yield* mergeOlderPage(response.value); + }), + ); }); yield* SubscriptionRef.changes(supervisor.state).pipe( @@ -390,14 +556,40 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make subscribeDynamic( ORCHESTRATION_WS_METHODS.subscribeThread, Effect.fn("EnvironmentThreadState.makeSubscribeInput")(function* (session) { - const supportsCompletionMarker = yield* session.initialConfig.pipe( - Effect.map((config) => config.threadResumeCompletionMarker === true), - Effect.orElseSucceed(() => false), + const config = yield* session.initialConfig.pipe( + Effect.orElseSucceed( + () => + ({}) as { + threadResumeCompletionMarker?: boolean; + threadSnapshotPagination?: boolean; + }, + ), ); + const supportsCompletionMarker = config.threadResumeCompletionMarker === true; + // Windowed loads are gated on the server capability: pre-pagination + // servers reject unknown query params, and a windowed WS fallback to + // such a server would silently hide history. + const supportsPagination = config.threadSnapshotPagination === true; + yield* Ref.set(paginationSupported, supportsPagination); yield* Ref.set(awaitingCompletion, supportsCompletionMarker); yield* setSynchronizing; let current = yield* SubscriptionRef.get(state); + // A windowed cache resuming against a server without pagination is a + // trap: afterSequence resume keeps only the window, and the missing + // older turns can never be loaded (the server has no cursor reads). + // Drop the window marker and treat the data as needing a full reload. + if (!supportsPagination && Option.isSome(current.page)) { + yield* Ref.update(historyEpoch, (epoch) => epoch + 1); + yield* SubscriptionRef.update(state, (value) => ({ + ...value, + data: Option.none(), + status: value.status === "deleted" ? value.status : ("empty" as const), + page: Option.none(), + })); + yield* SubscriptionRef.set(lastSequence, 0); + current = yield* SubscriptionRef.get(state); + } if (Option.isNone(current.data) && current.status !== "deleted") { const prepared = yield* SubscriptionRef.get(supervisor.prepared).pipe( Effect.flatMap( @@ -413,20 +605,14 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make }), ), ); - // The socket subscription may retry an expected domain failure (for - // example, while a newly-created thread is still being projected). - // Do not repeat the HTTP fallback on each socket retry: a missing - // snapshot otherwise produces a new 404 every 250ms. - const alreadyAttemptedHttpSnapshotLoad = yield* Ref.getAndSet( - httpSnapshotLoadAttempted, - true, + const httpSnapshot = yield* snapshotLoader.load( + prepared, + threadId, + supportsPagination ? { turnLimit: INITIAL_THREAD_USER_TURN_LIMIT } : undefined, ); - if (!alreadyAttemptedHttpSnapshotLoad) { - const httpSnapshot = yield* snapshotLoader.load(prepared, threadId); - if (Option.isSome(httpSnapshot)) { - yield* applyItems([{ kind: "snapshot", snapshot: httpSnapshot.value }]); - current = yield* SubscriptionRef.get(state); - } + if (Option.isSome(httpSnapshot)) { + yield* applyItem({ kind: "snapshot", snapshot: httpSnapshot.value }); + current = yield* SubscriptionRef.get(state); } } @@ -444,23 +630,37 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make threadId, ...(canResume ? { afterSequence: sequence } : {}), ...(supportsCompletionMarker ? { requestCompletionMarker: true as const } : {}), + // The WS fallback snapshot (sent when afterSequence is missing or + // the gap is too large) should be windowed the same as the HTTP + // path; without this a resume failure re-downloads the full thread. + ...(supportsPagination ? { turnLimit: INITIAL_THREAD_USER_TURN_LIMIT } : {}), }; }), { - // A permanently unavailable thread must not keep resubscribing: the - // server can never satisfy it, and the 250ms retry would hammer the - // socket until the state's idle TTL expires. - onExpectedFailure: (cause) => - terminalSnapshotReason(cause) === "thread-deleted" ? setDeleted() : setStreamError(cause), + onExpectedFailure: setStreamError, retryExpectedFailureAfter: "250 millis", - isExpectedFailureTerminal: (cause) => terminalSnapshotReason(cause) !== undefined, resubscribe: foregroundResubscriptions, }, - ).pipe( - Stream.groupedWithin(eventBatchSize, THREAD_EVENT_BATCH_WINDOW), - Stream.runForEach(applyItems), - ), + ).pipe(Stream.runForEach(applyItem)), + ); + + // Expose loadOlderTurns to UI actions through the request registry. + // Requests funnel through a sliding queue drained serially, so mashing + // "load earlier" coalesces (loadOlderTurns itself no-ops while a fetch is + // in flight). + const olderTurnRequestRegistry = yield* ThreadOlderTurnRequests; + const olderTurnRequests = yield* Queue.sliding(1); + yield* Stream.fromQueue(olderTurnRequests).pipe( + Stream.runForEach(() => loadOlderTurns()), + Effect.forkScoped, + ); + const deregister = olderTurnRequestRegistry.register( + threadKey({ environmentId, threadId }), + () => { + Queue.offerUnsafe(olderTurnRequests, undefined); + }, ); + yield* Effect.addFinalizer(() => Effect.sync(deregister)); yield* Effect.addFinalizer(() => Effect.all([SubscriptionRef.get(state), SubscriptionRef.get(lastSequence)]).pipe( @@ -468,7 +668,23 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make Option.match(current.data, { onNone: () => Effect.void, onSome: (thread) => - shouldPersistThread(thread) ? persist({ snapshotSequence, thread }) : Effect.void, + shouldPersistThread(thread) + ? persist({ + snapshotSequence, + thread, + ...Option.match(current.page, { + onNone: () => ({}), + onSome: (page) => + ({ + page: { + beforeCursor: page.beforeCursor, + hasMore: page.hasMore, + snapshotSequence, + }, + }) as const, + }), + }) + : Effect.void, }), ), ), diff --git a/packages/contracts/src/environmentHttp.ts b/packages/contracts/src/environmentHttp.ts index f95fb82808a4..c4fc47d60ec4 100644 --- a/packages/contracts/src/environmentHttp.ts +++ b/packages/contracts/src/environmentHttp.ts @@ -460,6 +460,16 @@ const EnvironmentOrchestrationThreadSnapshotParams = Schema.Struct({ threadId: ThreadId, }); +// Query-string window for windowed thread snapshots (GET payloads must encode +// to strings). Both fields optional: omitting them keeps the full-snapshot +// behavior, so pagination stays opt-in per request. +const EnvironmentOrchestrationThreadSnapshotQuery = { + turnLimit: Schema.optional( + Schema.FiniteFromString.check(Schema.isInt(), Schema.isGreaterThanOrEqualTo(1)), + ), + beforeCursor: Schema.optional(TrimmedNonEmptyString), +}; + export class EnvironmentOrchestrationHttpApi extends HttpApiGroup.make("orchestration") .add( HttpApiEndpoint.get("snapshot", "/api/orchestration/snapshot", { @@ -479,6 +489,7 @@ export class EnvironmentOrchestrationHttpApi extends HttpApiGroup.make("orchestr HttpApiEndpoint.get("threadSnapshot", "/api/orchestration/threads/:threadId", { headers: OptionalBearerHeaders, params: EnvironmentOrchestrationThreadSnapshotParams, + payload: EnvironmentOrchestrationThreadSnapshotQuery, success: OrchestrationThreadDetailSnapshot, error: EnvironmentOrchestrationThreadSnapshotErrors, }).middleware(EnvironmentAuthenticatedAuth), diff --git a/packages/contracts/src/orchestration.ts b/packages/contracts/src/orchestration.ts index 242cb7fafc25..93daf71e4f87 100644 --- a/packages/contracts/src/orchestration.ts +++ b/packages/contracts/src/orchestration.ts @@ -13,6 +13,7 @@ import { IsoDateTime, MessageId, NonNegativeInt, + PositiveInt, ProjectId, ProviderItemId, ThreadId, @@ -27,7 +28,6 @@ export const ORCHESTRATION_WS_METHODS = { dispatchCommand: "orchestration.dispatchCommand", getWorkflowScript: "orchestration.getWorkflowScript", getTurnDiff: "orchestration.getTurnDiff", - getThreadActivities: "orchestration.getThreadActivities", getFullThreadDiff: "orchestration.getFullThreadDiff", searchThreads: "orchestration.searchThreads", getArchivedShellSnapshot: "orchestration.getArchivedShellSnapshot", @@ -434,10 +434,6 @@ export const OrchestrationThread = Schema.Struct({ Schema.withDecodingDefault(Effect.succeed([])), ), activities: Schema.Array(OrchestrationThreadActivity), - // The detail snapshot windows `activities` to the most recent page; this is - // true when older activities exist beyond the window and can be lazy-loaded - // via the getThreadActivities RPC. Absent on lightweight (shell) threads. - hasMoreActivities: Schema.optional(Schema.Boolean), checkpoints: Schema.Array(OrchestrationCheckpointSummary), session: Schema.NullOr(OrchestrationSession), originSource: Schema.optional(Schema.NullOr(SourceRef)), @@ -587,12 +583,62 @@ export const OrchestrationSubscribeThreadInput = Schema.Struct({ * snapshot or catch-up replay and before it begins emitting live events. */ requestCompletionMarker: Schema.optionalKey(Schema.Boolean), + /** + * When provided, the fallback snapshot frame (sent when `afterSequence` is + * missing or the catch-up gap is too large) is windowed to the last + * `turnLimit` user-anchored turns and carries `page` metadata. Absent means + * the fallback snapshot is the full thread, preserving pre-pagination client + * behavior. Live events are unaffected either way. + */ + turnLimit: Schema.optionalKey(PositiveInt), }); export type OrchestrationSubscribeThreadInput = typeof OrchestrationSubscribeThreadInput.Type; +/** + * Bounds a thread detail read to a window of recent turns. `turnLimit` counts + * turns with a user pending message (subagent/fan-out turns between them ride + * along), so the window always contains the last N user prompts. `beforeCursor` + * requests the disjoint page of older turns strictly before a previously + * returned cursor. Requests without a window get the full thread; pagination is + * strictly opt-in so older clients keep today's behavior on both HTTP and the + * WebSocket fallback snapshot. + */ +export const OrchestrationThreadDetailWindow = Schema.Struct({ + turnLimit: Schema.optionalKey(PositiveInt), + beforeCursor: Schema.optionalKey(TrimmedNonEmptyString), +}); +export type OrchestrationThreadDetailWindow = typeof OrchestrationThreadDetailWindow.Type; + +/** + * Page metadata for a windowed thread detail read. `beforeCursor` is opaque and + * exclusive: passing it back returns the adjacent disjoint slice of older + * turns. `null` means the thread is fully loaded below this page. The + * `snapshotSequence` mirrors the top-level snapshot sequence so history pages + * can be sequence-checked against live state before merging. + */ +export const OrchestrationThreadDetailPage = Schema.Struct({ + beforeCursor: Schema.NullOr(TrimmedNonEmptyString), + hasMore: Schema.Boolean, + snapshotSequence: NonNegativeInt, + /** + * Highest event sequence applied to THIS thread at page read time. The + * global `snapshotSequence` advances with every thread's events, so a + * client cannot wait for it via its per-thread subscription; this + * thread-scoped watermark is reachable. A client merging an older page + * must first have applied live events up to it — otherwise a streaming + * turn outside the loaded window could have deltas replayed on top of + * page content that already includes them, duplicating text. + */ + threadSequence: Schema.optionalKey(NonNegativeInt), +}); +export type OrchestrationThreadDetailPage = typeof OrchestrationThreadDetailPage.Type; + export const OrchestrationThreadDetailSnapshot = Schema.Struct({ snapshotSequence: NonNegativeInt, thread: OrchestrationThread, + // Present only on windowed responses. Absent on full snapshots (and from + // pre-pagination servers), which clients treat as fully loaded. + page: Schema.optional(OrchestrationThreadDetailPage), }); export type OrchestrationThreadDetailSnapshot = typeof OrchestrationThreadDetailSnapshot.Type; @@ -1670,37 +1716,6 @@ export type OrchestrationGetTurnDiffInput = typeof OrchestrationGetTurnDiffInput export const OrchestrationGetTurnDiffResult = ThreadTurnDiff; export type OrchestrationGetTurnDiffResult = typeof OrchestrationGetTurnDiffResult.Type; -/** - * Cursor-paginated load of a thread's OLDER activities (lazy-load / infinite - * scroll). Sequenced activity uses `beforeSequence`, the `sequence` of the - * oldest activity the client currently holds. Legacy unsequenced activity uses - * the `(beforeCreatedAt, beforeActivityId)` pair from the oldest loaded - * activity. The server returns the page of activities immediately older than the - * cursor (chronological ascending) plus whether any remain beyond that. - */ -export const OrchestrationGetThreadActivitiesInput = Schema.Union([ - Schema.Struct({ - threadId: ThreadId, - beforeSequence: NonNegativeInt, - limit: Schema.optional(NonNegativeInt), - }), - Schema.Struct({ - threadId: ThreadId, - beforeCreatedAt: IsoDateTime, - beforeActivityId: EventId, - limit: Schema.optional(NonNegativeInt), - }), -]); -export type OrchestrationGetThreadActivitiesInput = - typeof OrchestrationGetThreadActivitiesInput.Type; - -export const OrchestrationGetThreadActivitiesResult = Schema.Struct({ - activities: Schema.Array(OrchestrationThreadActivity), - hasMore: Schema.Boolean, -}); -export type OrchestrationGetThreadActivitiesResult = - typeof OrchestrationGetThreadActivitiesResult.Type; - export const OrchestrationGetFullThreadDiffInput = Schema.Struct({ threadId: ThreadId, toTurnCount: NonNegativeInt, @@ -1797,10 +1812,6 @@ export const OrchestrationRpcSchemas = { input: OrchestrationGetTurnDiffInput, output: OrchestrationGetTurnDiffResult, }, - getThreadActivities: { - input: OrchestrationGetThreadActivitiesInput, - output: OrchestrationGetThreadActivitiesResult, - }, getFullThreadDiff: { input: OrchestrationGetFullThreadDiffInput, output: OrchestrationGetFullThreadDiffResult, @@ -1864,14 +1875,6 @@ export class OrchestrationGetTurnDiffError extends Schema.TaggedErrorClass()( - "OrchestrationGetThreadActivitiesError", - { - message: TrimmedNonEmptyString, - cause: Schema.optional(Schema.Defect()), - }, -) {} - export class OrchestrationGetFullThreadDiffError extends Schema.TaggedErrorClass()( "OrchestrationGetFullThreadDiffError", { diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index 6bfa80dc3971..4bd773d3a8a3 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -77,8 +77,6 @@ import { OrchestrationGetSnapshotError, OrchestrationSearchThreadsError, OrchestrationSearchThreadsInput, - OrchestrationGetThreadActivitiesError, - OrchestrationGetThreadActivitiesInput, OrchestrationGetTurnDiffError, OrchestrationGetTurnDiffInput, OrchestrationRpcSchemas, @@ -815,15 +813,6 @@ export const WsOrchestrationGetTurnDiffRpc = Rpc.make(ORCHESTRATION_WS_METHODS.g error: Schema.Union([OrchestrationGetTurnDiffError, EnvironmentAuthorizationError]), }); -export const WsOrchestrationGetThreadActivitiesRpc = Rpc.make( - ORCHESTRATION_WS_METHODS.getThreadActivities, - { - payload: OrchestrationGetThreadActivitiesInput, - success: OrchestrationRpcSchemas.getThreadActivities.output, - error: Schema.Union([OrchestrationGetThreadActivitiesError, EnvironmentAuthorizationError]), - }, -); - export const WsOrchestrationGetFullThreadDiffRpc = Rpc.make( ORCHESTRATION_WS_METHODS.getFullThreadDiff, { @@ -1009,7 +998,6 @@ export const WsRpcGroup = RpcGroup.make( WsOrchestrationDispatchCommandRpc, WsOrchestrationGetWorkflowScriptRpc, WsOrchestrationGetTurnDiffRpc, - WsOrchestrationGetThreadActivitiesRpc, WsOrchestrationGetFullThreadDiffRpc, WsOrchestrationSearchThreadsRpc, WsOrchestrationGetArchivedShellSnapshotRpc, diff --git a/packages/contracts/src/server.ts b/packages/contracts/src/server.ts index fac3474c5bab..5031a279d7fb 100644 --- a/packages/contracts/src/server.ts +++ b/packages/contracts/src/server.ts @@ -434,6 +434,12 @@ export const ServerConfig = Schema.Struct({ shellResumeCompletionMarker: Schema.optionalKey(Schema.Boolean), /** Whether thread subscriptions can emit an opt-in catch-up completion marker. */ threadResumeCompletionMarker: Schema.optionalKey(Schema.Boolean), + /** + * Whether thread detail reads accept a turn window (`turnLimit`/ + * `beforeCursor`) and return `page` metadata. Clients must not send window + * fields to servers that don't advertise this. + */ + threadSnapshotPagination: Schema.optionalKey(Schema.Boolean), }); export type ServerConfig = typeof ServerConfig.Type; diff --git a/packages/shared/src/observability.test.ts b/packages/shared/src/observability.test.ts index 4bd1070bf1f1..c58395393d37 100644 --- a/packages/shared/src/observability.test.ts +++ b/packages/shared/src/observability.test.ts @@ -21,6 +21,7 @@ import { makeTraceSink, type TraceRecord, type TraceSinkFlushStats, + truncateTraceAttributes, } from "./observability.ts"; describe("errorTag", () => { @@ -111,6 +112,31 @@ const makeTestLayer = (tracePath: string) => const nodeServicesIt = it.layer(NodeServices.layer); +describe("truncateTraceAttributes", () => { + it("clamps oversized strings at any depth without mutating the input", () => { + const stack = "s".repeat(2_000); + const attributes = { + "db.query.text": "q".repeat(2_000), + short: "ok", + error: { name: "Error", stack, nested: ["a".repeat(2_000)] }, + }; + const truncated = truncateTraceAttributes(attributes); + + assert.equal((truncated["db.query.text"] as string).length, 200 + "…[truncated]".length); + assert.equal(truncated["short"], "ok"); + const error = truncated["error"] as { stack: string; nested: Array }; + assert.equal(error.stack.length, 500 + "…[truncated]".length); + assert.equal(error.nested[0]?.length, 500 + "…[truncated]".length); + // Input is untouched: the live span's attributes are shared. + assert.equal(attributes.error.stack, stack); + }); + + it("returns the same reference when nothing exceeds the limits", () => { + const attributes = { short: "ok", nested: { fine: "also ok" } }; + assert.equal(truncateTraceAttributes(attributes), attributes); + }); +}); + describe("observability", () => { it("normalizes circular arrays, maps, and sets without recursing forever", () => { const array: Array = ["alpha"]; diff --git a/packages/shared/src/observability.ts b/packages/shared/src/observability.ts index e0a7595865d9..67057c548806 100644 --- a/packages/shared/src/observability.ts +++ b/packages/shared/src/observability.ts @@ -248,6 +248,61 @@ function formatTraceExit(exit: Exit.Exit): EffectTraceRecord[" }; } +const TRACE_ATTRIBUTE_MAX_LENGTH = 500; +const TRACE_ATTRIBUTE_TRUNCATED_LENGTH = 200; +const TRACE_ATTRIBUTE_TRUNCATION_SUFFIX = "…[truncated]"; +const ALWAYS_TRUNCATED_TRACE_ATTRIBUTES: ReadonlySet = new Set(["db.query.text"]); + +// Clamps strings nested inside already-normalized attribute values (arrays and +// plain objects from normalizeJsonValue, e.g. an Error's `stack`). Returns the +// input reference when nothing was clamped. +function truncateNestedValue(value: unknown): unknown { + if (typeof value === "string") { + return value.length <= TRACE_ATTRIBUTE_MAX_LENGTH + ? value + : `${value.slice(0, TRACE_ATTRIBUTE_MAX_LENGTH)}${TRACE_ATTRIBUTE_TRUNCATION_SUFFIX}`; + } + if (Array.isArray(value)) { + const truncated = value.map(truncateNestedValue); + return truncated.some((entry, index) => entry !== value[index]) ? truncated : value; + } + if (isPlainObject(value)) { + let truncated: Record | undefined; + for (const [key, entry] of Object.entries(value)) { + const next = truncateNestedValue(entry); + if (next === entry) continue; + truncated ??= { ...value }; + truncated[key] = next; + } + return truncated ?? value; + } + return value; +} + +/** + * Clamps oversized attribute values on the serialized trace record so the file + * sink stays small, including strings nested inside arrays and objects (e.g. + * error stacks). Returns a new record when anything was clamped; never + * mutates the input (the live span's attributes are shared with other tracers). + */ +export function truncateTraceAttributes(attributes: TraceAttributes): TraceAttributes { + let truncated: Record | undefined; + for (const [key, value] of Object.entries(attributes)) { + if (typeof value === "string" && ALWAYS_TRUNCATED_TRACE_ATTRIBUTES.has(key)) { + if (value.length <= TRACE_ATTRIBUTE_TRUNCATED_LENGTH) continue; + truncated ??= { ...attributes }; + truncated[key] = + `${value.slice(0, TRACE_ATTRIBUTE_TRUNCATED_LENGTH)}${TRACE_ATTRIBUTE_TRUNCATION_SUFFIX}`; + continue; + } + const next = truncateNestedValue(value); + if (next === value) continue; + truncated ??= { ...attributes }; + truncated[key] = next; + } + return truncated ?? attributes; +} + export function spanToTraceRecord(span: SerializableSpan): EffectTraceRecord { const status = span.status as Extract; const parentSpanId = Option.getOrUndefined(span.parent)?.spanId; @@ -263,16 +318,18 @@ export function spanToTraceRecord(span: SerializableSpan): EffectTraceRecord { startTimeUnixNano: String(status.startTime), endTimeUnixNano: String(status.endTime), durationMs: Number(status.endTime - status.startTime) / 1_000_000, - attributes: compactTraceAttributes(Object.fromEntries(span.attributes)), + attributes: truncateTraceAttributes( + compactTraceAttributes(Object.fromEntries(span.attributes)), + ), events: span.events.map(([name, startTime, attributes]) => ({ name, timeUnixNano: String(startTime), - attributes: compactTraceAttributes(attributes), + attributes: truncateTraceAttributes(compactTraceAttributes(attributes)), })), links: span.links.map((link) => ({ traceId: link.span.traceId, spanId: link.span.spanId, - attributes: compactTraceAttributes(link.attributes), + attributes: truncateTraceAttributes(compactTraceAttributes(link.attributes)), })), exit: formatTraceExit(status.exit), }; diff --git a/packages/shared/src/shell.ts b/packages/shared/src/shell.ts index cf2f2417ff4b..efdd05683abc 100644 --- a/packages/shared/src/shell.ts +++ b/packages/shared/src/shell.ts @@ -3,6 +3,7 @@ import * as NodeOS from "node:os"; import * as NodePath from "node:path"; import * as NodeChildProcess from "node:child_process"; import * as NodeFS from "node:fs"; +import * as Clock from "effect/Clock"; import * as Data from "effect/Data"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; @@ -491,6 +492,54 @@ function resolveCommandCandidates( return Array.from(new Set(candidates)); } +// Session bootstrap resolves the same commands over and over, each PATH scan +// costing hundreds of 'shell.isExecutableFile' filesystem probes (tens of +// thousands per connect). Memoize the scan outcome per +// (platform, PATH, PATHEXT, command) for a short window: repeat scans hit the +// cache while any change to the search environment invalidates immediately. +// Explicit-path resolution is never cached - callers probe paths they have +// just written (e.g. managed binary installs). A "not-found" outcome is also +// cached for the TTL, so a just-installed binary can stay invisible for up to +// 30s unless resolved by explicit path. +// TTL expiry uses the monotonic clock (Clock.currentTimeNanos) so backward +// wall-clock adjustments cannot keep expired entries alive. +const COMMAND_RESOLUTION_CACHE_TTL_NANOS = 30_000_000_000n; +const COMMAND_RESOLUTION_CACHE_MAX_ENTRIES = 512; +const COMMAND_RESOLUTION_CACHE_KEY_SEPARATOR = String.fromCharCode(0); + +interface CommandResolutionCacheEntry { + readonly resolvedPath: string | null; + readonly expiresAtNanos: bigint; +} + +// The cache lives in the Effect environment (like HostProcessPlatform above) +// so tests and embedders can provide an isolated instance; the default is a +// single process-wide map shared by all consumers. +export const CommandResolutionCache = Context.Reference>( + "@t3tools/shared/shell/CommandResolutionCache", + { + defaultValue: () => new Map(), + }, +); + +function cacheCommandResolution( + cache: Map, + cacheKey: string, + resolvedPath: string | null, + nowNanos: bigint, +): void { + if (cache.size >= COMMAND_RESOLUTION_CACHE_MAX_ENTRIES) { + const oldestKey = cache.keys().next().value; + if (oldestKey !== undefined) { + cache.delete(oldestKey); + } + } + cache.set(cacheKey, { + resolvedPath, + expiresAtNanos: nowNanos + COMMAND_RESOLUTION_CACHE_TTL_NANOS, + }); +} + const isExecutableFile = Effect.fn("shell.isExecutableFile")(function* ( filePath: string, platform: NodeJS.Platform, @@ -538,6 +587,20 @@ const resolveCommandPathForPlatform = Effect.fn("shell.resolveCommandPathForPlat if (pathValue.length === 0) { return yield* new CommandResolutionError({ command, reason: "not-found" }); } + + const cacheKey = [platform, pathValue, windowsPathExtensions.join(";"), command].join( + COMMAND_RESOLUTION_CACHE_KEY_SEPARATOR, + ); + const cache = yield* CommandResolutionCache; + const nowNanos = yield* Clock.currentTimeNanos; + const cached = cache.get(cacheKey); + if (cached !== undefined && cached.expiresAtNanos > nowNanos) { + if (cached.resolvedPath === null) { + return yield* new CommandResolutionError({ command, reason: "not-found" }); + } + return cached.resolvedPath; + } + const pathEntries: string[] = []; for (const entry of pathValue.split(pathDelimiterForPlatform(platform))) { const pathEntry = stripWrappingQuotes(entry.trim()); @@ -550,10 +613,12 @@ const resolveCommandPathForPlatform = Effect.fn("shell.resolveCommandPathForPlat for (const candidate of commandCandidates) { const candidatePath = path.join(pathEntry, candidate); if (yield* isExecutableFile(candidatePath, platform, windowsPathExtensions)) { + cacheCommandResolution(cache, cacheKey, candidatePath, nowNanos); return candidatePath; } } } + cacheCommandResolution(cache, cacheKey, null, nowNanos); return yield* new CommandResolutionError({ command, reason: "not-found" }); }); diff --git a/patches/effect@4.0.0-beta.103.patch b/patches/effect@4.0.0-beta.103.patch index 561db6f52630..a46ccf9c9764 100644 --- a/patches/effect@4.0.0-beta.103.patch +++ b/patches/effect@4.0.0-beta.103.patch @@ -278,32 +278,43 @@ index b536d0a..12ffac0 100644 }).pipe(Effect.flatMap(() => Effect.fail(new Socket.SocketError({ reason: new Socket.SocketCloseError({ code: 1000 -@@ -687,20 +716,20 @@ export const makeProtocolSocket = options => Protocol.make(Effect.fnUntraced(fun +@@ -687,20 +716,28 @@ export const makeProtocolSocket = options => Protocol.make(Effect.fnUntraced(fun }; })); const defaultRetryPolicy = /*#__PURE__*/Schedule.min([/*#__PURE__*/Schedule.exponential(500, 1.5), /*#__PURE__*/Schedule.spaced(5000)]); -const makePinger = /*#__PURE__*/Effect.fnUntraced(function* (writePing) { +const makePinger = /*#__PURE__*/Effect.fnUntraced(function* (writePing, hooks) { let recievedPong = true; ++ let missedPongs = 0; const latch = Latch.makeUnsafe(); const reset = () => { recievedPong = true; ++ missedPongs = 0; latch.closeUnsafe(); }; - const onPong = () => { -+ const onPong = Effect.sync(() => { - recievedPong = true; +- recievedPong = true; - }; ++ const onPong = Effect.sync(() => { ++ recievedPong = true; ++ missedPongs = 0; + }).pipe(Effect.andThen(hooks?.onPong ?? Effect.void)); yield* Effect.suspend(() => { - if (!recievedPong) return latch.open; - recievedPong = false; +- if (!recievedPong) return latch.open; +- recievedPong = false; - return writePing; ++ if (!recievedPong) { ++ missedPongs += 1; ++ if (missedPongs >= 3) return latch.open; ++ return (hooks?.onPing ?? Effect.void).pipe(Effect.andThen(writePing)); ++ } ++ recievedPong = false; ++ missedPongs = 0; + return (hooks?.onPing ?? Effect.void).pipe(Effect.andThen(writePing)); }).pipe(Effect.delay("5 seconds"), Effect.ignore, Effect.forever, Effect.interruptible, Effect.forkScoped); return { timeout: latch.await, -@@ -843,6 +872,11 @@ export const makeProtocolWorker = options => Protocol.make(Effect.fnUntraced(fun +@@ -843,6 +880,11 @@ export const makeProtocolWorker = options => Protocol.make(Effect.fnUntraced(fun * @since 4.0.0 */ export const layerProtocolWorker = /*#__PURE__*/flow(makeProtocolWorker, /*#__PURE__*/Layer.effect(Protocol)); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5500b9cfb0da..8273178a1b40 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -79,7 +79,7 @@ patchedDependencies: '@pierre/diffs@1.3.0-beta.10': 7ef7cb0cbabb17c15cdb137554068b36f15f6f5265e73fb51452aa3380db91aa '@react-native-menu/menu@2.0.0': 5ea3ae4bf1d9baf5443b65c269bb09621c27a68d556f713778f37b1e8d46aaae '@react-navigation/native-stack@7.17.6': c7fc101b78d434904425e5a24c22fb0042298dec6f807250486e784f3c717273 - effect@4.0.0-beta.103: a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9 + effect@4.0.0-beta.103: af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6 expo-modules-jsi@56.0.10: 9170f8074ae4e35a0a086e756c8f815794fd3abe51eac67ca3ba02804225ec1f react-native-gesture-handler@2.31.2: 808eb26f9e57cf4945efd3985af4d9c764da6f91f4c9764433cc868602bbf4d3 react-native-keyboard-controller@1.21.13: 20be72c84d74253acdcfefbc6defe36dc396944f1a44cab2bdd0e3cd572ae008 @@ -122,7 +122,7 @@ importers: version: 0.0.3 '@effect/platform-node': specifier: 4.0.0-beta.103 - version: 4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9))(ioredis@5.11.0)(utf-8-validate@6.0.6) + version: 4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6))(ioredis@5.11.0)(utf-8-validate@6.0.6) '@t3tools/client-runtime': specifier: workspace:* version: link:../../packages/client-runtime @@ -140,7 +140,7 @@ importers: version: link:../../packages/tailscale effect: specifier: 4.0.0-beta.103 - version: 4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9) + version: 4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6) electron: specifier: 41.5.0 version: 41.5.0 @@ -159,7 +159,7 @@ importers: devDependencies: '@effect/vitest': specifier: 4.0.0-beta.103 - version: 4.0.0-beta.103(patch_hash=a16b1e870d8c29e4a98b17cc4c638a4ff471753d8ca3487f78a22b759caf951b)(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9)) + version: 4.0.0-beta.103(patch_hash=a16b1e870d8c29e4a98b17cc4c638a4ff471753d8ca3487f78a22b759caf951b)(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6)) '@types/node': specifier: 24.12.4 version: 24.12.4 @@ -180,7 +180,7 @@ importers: dependencies: '@effect/platform-node': specifier: 4.0.0-beta.103 - version: 4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9))(ioredis@5.11.0)(utf-8-validate@6.0.6) + version: 4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6))(ioredis@5.11.0)(utf-8-validate@6.0.6) '@microsoft/teams.apps': specifier: 2.0.14 version: 2.0.14 @@ -195,10 +195,10 @@ importers: version: link:../../packages/shared dfx: specifier: 'catalog:' - version: 1.0.15(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9)) + version: 1.0.15(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6)) effect: specifier: 4.0.0-beta.103 - version: 4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9) + version: 4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6) playwright-core: specifier: 1.60.0 version: 1.60.0 @@ -208,7 +208,7 @@ importers: devDependencies: '@effect/vitest': specifier: 4.0.0-beta.103 - version: 4.0.0-beta.103(patch_hash=a16b1e870d8c29e4a98b17cc4c638a4ff471753d8ca3487f78a22b759caf951b)(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9)) + version: 4.0.0-beta.103(patch_hash=a16b1e870d8c29e4a98b17cc4c638a4ff471753d8ca3487f78a22b759caf951b)(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6)) '@types/node': specifier: 24.12.4 version: 24.12.4 @@ -248,7 +248,7 @@ importers: version: 4.1.2(expo-auth-session@56.0.14(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(expo-constants@56.0.18)(expo-crypto@56.0.4(expo@56.0.12))(expo-secure-store@56.0.4(expo@56.0.12))(expo-web-browser@56.0.5(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)))(expo@56.0.12)(react-dom@19.2.3(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3) '@effect/atom-react': specifier: 4.0.0-beta.103 - version: 4.0.0-beta.103(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9))(react@19.2.3)(scheduler@0.27.0) + version: 4.0.0-beta.103(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6))(react@19.2.3)(scheduler@0.27.0) '@expo-google-fonts/dm-sans': specifier: ^0.4.2 version: 0.4.2 @@ -323,7 +323,7 @@ importers: version: 8.0.3 effect: specifier: 4.0.0-beta.103 - version: 4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9) + version: 4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6) expo: specifier: ~56.0.12 version: 56.0.12(8895228379997a2a064f9644cda56ed0) @@ -471,7 +471,7 @@ importers: devDependencies: '@effect/vitest': specifier: 4.0.0-beta.103 - version: 4.0.0-beta.103(patch_hash=a16b1e870d8c29e4a98b17cc4c638a4ff471753d8ca3487f78a22b759caf951b)(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9)) + version: 4.0.0-beta.103(patch_hash=a16b1e870d8c29e4a98b17cc4c638a4ff471753d8ca3487f78a22b759caf951b)(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6)) '@pierre/trees': specifier: 1.0.0-beta.4 version: 1.0.0-beta.4(react-dom@19.2.3(react@19.2.3))(react@19.2.3) @@ -495,16 +495,16 @@ importers: version: 0.3.220(@anthropic-ai/sdk@0.93.0(zod@4.4.3))(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(zod@4.4.3) '@effect/platform-bun': specifier: 4.0.0-beta.103 - version: 4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9))(utf-8-validate@6.0.6) + version: 4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6))(utf-8-validate@6.0.6) '@effect/platform-node': specifier: 4.0.0-beta.103 - version: 4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9))(ioredis@5.11.0)(utf-8-validate@6.0.6) + version: 4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6))(ioredis@5.11.0)(utf-8-validate@6.0.6) '@effect/platform-node-shared': specifier: 4.0.0-beta.103 - version: 4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9))(utf-8-validate@6.0.6) + version: 4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6))(utf-8-validate@6.0.6) '@effect/sql-sqlite-bun': specifier: 4.0.0-beta.103 - version: 4.0.0-beta.103(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9)) + version: 4.0.0-beta.103(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6)) '@ff-labs/fff-node': specifier: 0.9.4 version: 0.9.4(patch_hash=2b16019ce7ab61aec6478dd02f79ef468cc1d5c51e9d00764f7d2ab8167210c8) @@ -516,7 +516,7 @@ importers: version: 1.3.0-beta.10(patch_hash=7ef7cb0cbabb17c15cdb137554068b36f15f6f5265e73fb51452aa3380db91aa)(@shikijs/themes@4.3.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) effect: specifier: 4.0.0-beta.103 - version: 4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9) + version: 4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6) node-pty: specifier: ^1.1.0 version: 1.1.0 @@ -526,7 +526,7 @@ importers: devDependencies: '@effect/vitest': specifier: 4.0.0-beta.103 - version: 4.0.0-beta.103(patch_hash=a16b1e870d8c29e4a98b17cc4c638a4ff471753d8ca3487f78a22b759caf951b)(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9)) + version: 4.0.0-beta.103(patch_hash=a16b1e870d8c29e4a98b17cc4c638a4ff471753d8ca3487f78a22b759caf951b)(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6)) '@t3tools/contracts': specifier: workspace:* version: link:../../packages/contracts @@ -571,7 +571,7 @@ importers: version: 3.4.12 effect: specifier: 4.0.0-beta.103 - version: 4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9) + version: 4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6) marked: specifier: ^15.0.12 version: 15.0.12 @@ -611,7 +611,7 @@ importers: version: 3.2.2(react@19.2.6) '@effect/atom-react': specifier: 4.0.0-beta.103 - version: 4.0.0-beta.103(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9))(react@19.2.6)(scheduler@0.27.0) + version: 4.0.0-beta.103(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6))(react@19.2.6)(scheduler@0.27.0) '@formkit/auto-animate': specifier: ^0.9.0 version: 0.9.0 @@ -647,7 +647,7 @@ importers: version: 0.7.1 effect: specifier: 4.0.0-beta.103 - version: 4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9) + version: 4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6) jose: specifier: 'catalog:' version: 6.2.2 @@ -687,10 +687,10 @@ importers: devDependencies: '@effect/platform-node': specifier: 4.0.0-beta.103 - version: 4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9))(ioredis@5.11.0)(utf-8-validate@6.0.6) + version: 4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6))(ioredis@5.11.0)(utf-8-validate@6.0.6) '@effect/vitest': specifier: 4.0.0-beta.103 - version: 4.0.0-beta.103(patch_hash=a16b1e870d8c29e4a98b17cc4c638a4ff471753d8ca3487f78a22b759caf951b)(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9)) + version: 4.0.0-beta.103(patch_hash=a16b1e870d8c29e4a98b17cc4c638a4ff471753d8ca3487f78a22b759caf951b)(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6)) '@rolldown/plugin-babel': specifier: ^0.2.0 version: 0.2.3(@babel/core@7.29.7)(@babel/plugin-transform-runtime@7.29.7(@babel/core@7.29.7))(@babel/runtime@7.29.7)(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(tsx@4.23.1)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(rolldown@1.1.5) @@ -738,7 +738,7 @@ importers: version: 3.14.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6) '@effect/sql-pg': specifier: 4.0.0-beta.103 - version: 4.0.0-beta.103(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9)) + version: 4.0.0-beta.103(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6)) '@noble/curves': specifier: 'catalog:' version: 1.9.1 @@ -756,23 +756,23 @@ importers: version: link:../../packages/shared alchemy: specifier: 2.0.0-beta.65 - version: 2.0.0-beta.65(8f212c6ff68a28a2886619dd53dd12ff) + version: 2.0.0-beta.65(640abc0ef264fc2fa89e88e85253c4ef) drizzle-orm: specifier: 1.0.0-rc.4 - version: 1.0.0-rc.4(@cloudflare/workers-types@4.20260604.1)(@effect/sql-d1@4.0.0-beta.101(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9)))(@effect/sql-pg@4.0.0-beta.103(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9)))(@effect/sql-sqlite-bun@4.0.0-beta.103(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9)))(@libsql/client@0.17.3(bufferutil@4.1.0)(utf-8-validate@6.0.6))(bun-types@1.3.14)(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9))(expo-sqlite@56.0.5(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6))(mysql2@3.22.4(@types/node@24.12.4))(pg@8.21.0)(zod@4.4.3) + version: 1.0.0-rc.4(@cloudflare/workers-types@4.20260604.1)(@effect/sql-d1@4.0.0-beta.101(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6)))(@effect/sql-pg@4.0.0-beta.103(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6)))(@effect/sql-sqlite-bun@4.0.0-beta.103(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6)))(@libsql/client@0.17.3(bufferutil@4.1.0)(utf-8-validate@6.0.6))(bun-types@1.3.14)(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6))(expo-sqlite@56.0.5(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6))(mysql2@3.22.4(@types/node@24.12.4))(pg@8.21.0)(zod@4.4.3) effect: specifier: 4.0.0-beta.103 - version: 4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9) + version: 4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6) devDependencies: '@cloudflare/workers-types': specifier: ^4.20260601.1 version: 4.20260604.1 '@effect/platform-node': specifier: 4.0.0-beta.103 - version: 4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9))(ioredis@5.11.0)(utf-8-validate@6.0.6) + version: 4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6))(ioredis@5.11.0)(utf-8-validate@6.0.6) '@effect/vitest': specifier: 4.0.0-beta.103 - version: 4.0.0-beta.103(patch_hash=a16b1e870d8c29e4a98b17cc4c638a4ff471753d8ca3487f78a22b759caf951b)(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9)) + version: 4.0.0-beta.103(patch_hash=a16b1e870d8c29e4a98b17cc4c638a4ff471753d8ca3487f78a22b759caf951b)(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6)) '@types/node': specifier: 24.12.4 version: 24.12.4 @@ -790,17 +790,17 @@ importers: dependencies: '@effect/platform-node': specifier: 4.0.0-beta.103 - version: 4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9))(ioredis@5.11.0)(utf-8-validate@6.0.6) + version: 4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6))(ioredis@5.11.0)(utf-8-validate@6.0.6) '@oxlint/plugins': specifier: ^1.63.0 version: 1.68.0 effect: specifier: 4.0.0-beta.103 - version: 4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9) + version: 4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6) devDependencies: '@effect/vitest': specifier: 4.0.0-beta.103 - version: 4.0.0-beta.103(patch_hash=a16b1e870d8c29e4a98b17cc4c638a4ff471753d8ca3487f78a22b759caf951b)(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9)) + version: 4.0.0-beta.103(patch_hash=a16b1e870d8c29e4a98b17cc4c638a4ff471753d8ca3487f78a22b759caf951b)(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6)) vite-plus: specifier: 'catalog:' version: 0.2.2(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(tsx@4.23.1)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) @@ -815,11 +815,11 @@ importers: version: link:../shared effect: specifier: 4.0.0-beta.103 - version: 4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9) + version: 4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6) devDependencies: '@effect/vitest': specifier: 4.0.0-beta.103 - version: 4.0.0-beta.103(patch_hash=a16b1e870d8c29e4a98b17cc4c638a4ff471753d8ca3487f78a22b759caf951b)(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9)) + version: 4.0.0-beta.103(patch_hash=a16b1e870d8c29e4a98b17cc4c638a4ff471753d8ca3487f78a22b759caf951b)(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6)) '@types/react': specifier: ~19.2.14 version: 19.2.16 @@ -834,11 +834,11 @@ importers: dependencies: effect: specifier: 4.0.0-beta.103 - version: 4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9) + version: 4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6) devDependencies: '@effect/vitest': specifier: 4.0.0-beta.103 - version: 4.0.0-beta.103(patch_hash=a16b1e870d8c29e4a98b17cc4c638a4ff471753d8ca3487f78a22b759caf951b)(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9)) + version: 4.0.0-beta.103(patch_hash=a16b1e870d8c29e4a98b17cc4c638a4ff471753d8ca3487f78a22b759caf951b)(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6)) vite-plus: specifier: 'catalog:' version: 0.2.2(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(tsx@4.23.1)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) @@ -847,17 +847,17 @@ importers: dependencies: effect: specifier: 4.0.0-beta.103 - version: 4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9) + version: 4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6) devDependencies: '@effect/openapi-generator': specifier: 'catalog:' - version: 4.0.0-beta.103(@effect/platform-node@4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9))(ioredis@5.11.0)(utf-8-validate@6.0.6))(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9)) + version: 4.0.0-beta.103(@effect/platform-node@4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6))(ioredis@5.11.0)(utf-8-validate@6.0.6))(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6)) '@effect/platform-node': specifier: 4.0.0-beta.103 - version: 4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9))(ioredis@5.11.0)(utf-8-validate@6.0.6) + version: 4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6))(ioredis@5.11.0)(utf-8-validate@6.0.6) '@effect/vitest': specifier: 4.0.0-beta.103 - version: 4.0.0-beta.103(patch_hash=a16b1e870d8c29e4a98b17cc4c638a4ff471753d8ca3487f78a22b759caf951b)(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9)) + version: 4.0.0-beta.103(patch_hash=a16b1e870d8c29e4a98b17cc4c638a4ff471753d8ca3487f78a22b759caf951b)(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6)) '@types/node': specifier: 24.12.4 version: 24.12.4 @@ -869,17 +869,17 @@ importers: dependencies: effect: specifier: 4.0.0-beta.103 - version: 4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9) + version: 4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6) devDependencies: '@effect/openapi-generator': specifier: 'catalog:' - version: 4.0.0-beta.103(@effect/platform-node@4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9))(ioredis@5.11.0)(utf-8-validate@6.0.6))(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9)) + version: 4.0.0-beta.103(@effect/platform-node@4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6))(ioredis@5.11.0)(utf-8-validate@6.0.6))(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6)) '@effect/platform-node': specifier: 4.0.0-beta.103 - version: 4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9))(ioredis@5.11.0)(utf-8-validate@6.0.6) + version: 4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6))(ioredis@5.11.0)(utf-8-validate@6.0.6) '@effect/vitest': specifier: 4.0.0-beta.103 - version: 4.0.0-beta.103(patch_hash=a16b1e870d8c29e4a98b17cc4c638a4ff471753d8ca3487f78a22b759caf951b)(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9)) + version: 4.0.0-beta.103(patch_hash=a16b1e870d8c29e4a98b17cc4c638a4ff471753d8ca3487f78a22b759caf951b)(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6)) '@types/node': specifier: 24.12.4 version: 24.12.4 @@ -900,7 +900,7 @@ importers: version: link:../contracts effect: specifier: 4.0.0-beta.103 - version: 4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9) + version: 4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6) jose: specifier: 'catalog:' version: 6.2.2 @@ -910,10 +910,10 @@ importers: devDependencies: '@effect/platform-node': specifier: 4.0.0-beta.103 - version: 4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9))(ioredis@5.11.0)(utf-8-validate@6.0.6) + version: 4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6))(ioredis@5.11.0)(utf-8-validate@6.0.6) '@effect/vitest': specifier: 4.0.0-beta.103 - version: 4.0.0-beta.103(patch_hash=a16b1e870d8c29e4a98b17cc4c638a4ff471753d8ca3487f78a22b759caf951b)(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9)) + version: 4.0.0-beta.103(patch_hash=a16b1e870d8c29e4a98b17cc4c638a4ff471753d8ca3487f78a22b759caf951b)(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6)) '@types/node': specifier: 24.12.4 version: 24.12.4 @@ -931,14 +931,14 @@ importers: version: link:../shared effect: specifier: 4.0.0-beta.103 - version: 4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9) + version: 4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6) devDependencies: '@effect/platform-node': specifier: 4.0.0-beta.103 - version: 4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9))(ioredis@5.11.0)(utf-8-validate@6.0.6) + version: 4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6))(ioredis@5.11.0)(utf-8-validate@6.0.6) '@effect/vitest': specifier: 4.0.0-beta.103 - version: 4.0.0-beta.103(patch_hash=a16b1e870d8c29e4a98b17cc4c638a4ff471753d8ca3487f78a22b759caf951b)(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9)) + version: 4.0.0-beta.103(patch_hash=a16b1e870d8c29e4a98b17cc4c638a4ff471753d8ca3487f78a22b759caf951b)(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6)) '@types/node': specifier: 24.12.4 version: 24.12.4 @@ -950,17 +950,17 @@ importers: dependencies: '@effect/platform-node': specifier: 4.0.0-beta.103 - version: 4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9))(ioredis@5.11.0)(utf-8-validate@6.0.6) + version: 4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6))(ioredis@5.11.0)(utf-8-validate@6.0.6) '@t3tools/shared': specifier: workspace:* version: link:../shared effect: specifier: 4.0.0-beta.103 - version: 4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9) + version: 4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6) devDependencies: '@effect/vitest': specifier: 4.0.0-beta.103 - version: 4.0.0-beta.103(patch_hash=a16b1e870d8c29e4a98b17cc4c638a4ff471753d8ca3487f78a22b759caf951b)(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9)) + version: 4.0.0-beta.103(patch_hash=a16b1e870d8c29e4a98b17cc4c638a4ff471753d8ca3487f78a22b759caf951b)(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6)) '@types/node': specifier: 24.12.4 version: 24.12.4 @@ -972,7 +972,7 @@ importers: dependencies: '@effect/platform-node': specifier: 4.0.0-beta.103 - version: 4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9))(ioredis@5.11.0)(utf-8-validate@6.0.6) + version: 4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6))(ioredis@5.11.0)(utf-8-validate@6.0.6) '@t3tools/contracts': specifier: workspace:* version: link:../packages/contracts @@ -984,7 +984,7 @@ importers: version: link:../packages/tailscale effect: specifier: 4.0.0-beta.103 - version: 4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9) + version: 4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6) pngjs: specifier: 7.0.0 version: 7.0.0 @@ -994,7 +994,7 @@ importers: devDependencies: '@effect/vitest': specifier: 4.0.0-beta.103 - version: 4.0.0-beta.103(patch_hash=a16b1e870d8c29e4a98b17cc4c638a4ff471753d8ca3487f78a22b759caf951b)(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9)) + version: 4.0.0-beta.103(patch_hash=a16b1e870d8c29e4a98b17cc4c638a4ff471753d8ca3487f78a22b759caf951b)(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6)) '@types/pngjs': specifier: 6.0.5 version: 6.0.5 @@ -11939,24 +11939,24 @@ snapshots: '@cloudflare/workers-types@5.20260726.1': {} - '@distilled.cloud/aws@0.30.2(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9))': + '@distilled.cloud/aws@0.30.2(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6))': dependencies: '@aws-crypto/crc32': 5.2.0 '@aws-crypto/util': 5.2.0 '@aws-sdk/credential-providers': 3.1062.0 '@aws-sdk/types': 3.973.10 - '@distilled.cloud/core': 0.30.2(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9)) + '@distilled.cloud/core': 0.30.2(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6)) '@smithy/shared-ini-file-loader': 4.5.6 '@smithy/types': 4.14.3 '@smithy/util-base64': 4.4.6 aws4fetch: 1.0.20 - effect: 4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9) + effect: 4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6) fast-xml-parser: 5.8.0 - '@distilled.cloud/axiom@0.30.2(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9))': + '@distilled.cloud/axiom@0.30.2(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6))': dependencies: - '@distilled.cloud/core': 0.30.2(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9)) - effect: 4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9) + '@distilled.cloud/core': 0.30.2(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6)) + effect: 4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6) '@distilled.cloud/cloudflare-rolldown-plugin@0.13.10(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(tsx@4.23.1)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(rolldown@1.1.5)(workerd@1.20260704.1)': dependencies: @@ -11969,48 +11969,48 @@ snapshots: transitivePeerDependencies: - workerd - '@distilled.cloud/cloudflare-runtime@0.13.10(@distilled.cloud/cloudflare@0.30.2(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9)))(@effect/platform-bun@4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9))(utf-8-validate@6.0.6))(@effect/platform-node@4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9))(ioredis@5.11.0)(utf-8-validate@6.0.6))(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9))': + '@distilled.cloud/cloudflare-runtime@0.13.10(@distilled.cloud/cloudflare@0.30.2(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6)))(@effect/platform-bun@4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6))(utf-8-validate@6.0.6))(@effect/platform-node@4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6))(ioredis@5.11.0)(utf-8-validate@6.0.6))(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6))': dependencies: '@alchemy.run/node-utils': 0.0.5 - '@distilled.cloud/cloudflare': 0.30.2(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9)) - effect: 4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9) + '@distilled.cloud/cloudflare': 0.30.2(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6)) + effect: 4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6) workerd: 1.20260704.1 optionalDependencies: - '@effect/platform-bun': 4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9))(utf-8-validate@6.0.6) - '@effect/platform-node': 4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9))(ioredis@5.11.0)(utf-8-validate@6.0.6) + '@effect/platform-bun': 4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6))(utf-8-validate@6.0.6) + '@effect/platform-node': 4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6))(ioredis@5.11.0)(utf-8-validate@6.0.6) - '@distilled.cloud/cloudflare-vite-plugin@0.13.10(ae521bb6d3e654dac541e4d0c80e2bbe)': + '@distilled.cloud/cloudflare-vite-plugin@0.13.10(2a81819c3753cd7ae4754d7e3425b68b)': dependencies: - '@distilled.cloud/cloudflare': 0.30.2(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9)) + '@distilled.cloud/cloudflare': 0.30.2(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6)) '@distilled.cloud/cloudflare-rolldown-plugin': 0.13.10(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(tsx@4.23.1)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(rolldown@1.1.5)(workerd@1.20260704.1) - '@distilled.cloud/cloudflare-runtime': 0.13.10(@distilled.cloud/cloudflare@0.30.2(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9)))(@effect/platform-bun@4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9))(utf-8-validate@6.0.6))(@effect/platform-node@4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9))(ioredis@5.11.0)(utf-8-validate@6.0.6))(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9)) - effect: 4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9) + '@distilled.cloud/cloudflare-runtime': 0.13.10(@distilled.cloud/cloudflare@0.30.2(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6)))(@effect/platform-bun@4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6))(utf-8-validate@6.0.6))(@effect/platform-node@4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6))(ioredis@5.11.0)(utf-8-validate@6.0.6))(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6)) + effect: 4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6) vite: '@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(tsx@4.23.1)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0)' optionalDependencies: - '@effect/platform-bun': 4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9))(utf-8-validate@6.0.6) - '@effect/platform-node': 4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9))(ioredis@5.11.0)(utf-8-validate@6.0.6) + '@effect/platform-bun': 4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6))(utf-8-validate@6.0.6) + '@effect/platform-node': 4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6))(ioredis@5.11.0)(utf-8-validate@6.0.6) transitivePeerDependencies: - rolldown - workerd - '@distilled.cloud/cloudflare@0.30.2(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9))': + '@distilled.cloud/cloudflare@0.30.2(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6))': dependencies: - '@distilled.cloud/core': 0.30.2(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9)) - effect: 4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9) + '@distilled.cloud/core': 0.30.2(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6)) + effect: 4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6) - '@distilled.cloud/core@0.30.2(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9))': + '@distilled.cloud/core@0.30.2(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6))': dependencies: - effect: 4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9) + effect: 4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6) - '@distilled.cloud/neon@0.30.2(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9))': + '@distilled.cloud/neon@0.30.2(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6))': dependencies: - '@distilled.cloud/core': 0.30.2(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9)) - effect: 4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9) + '@distilled.cloud/core': 0.30.2(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6)) + effect: 4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6) - '@distilled.cloud/planetscale@0.30.2(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9))': + '@distilled.cloud/planetscale@0.30.2(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6))': dependencies: - '@distilled.cloud/core': 0.30.2(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9)) - effect: 4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9) + '@distilled.cloud/core': 0.30.2(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6)) + effect: 4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6) '@dnd-kit/accessibility@3.1.1(react@19.2.6)': dependencies: @@ -12046,47 +12046,47 @@ snapshots: '@drizzle-team/brocli@0.12.0': {} - '@effect/atom-react@4.0.0-beta.103(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9))(react@19.2.3)(scheduler@0.27.0)': + '@effect/atom-react@4.0.0-beta.103(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6))(react@19.2.3)(scheduler@0.27.0)': dependencies: - effect: 4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9) + effect: 4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6) react: 19.2.3 scheduler: 0.27.0 - '@effect/atom-react@4.0.0-beta.103(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9))(react@19.2.6)(scheduler@0.27.0)': + '@effect/atom-react@4.0.0-beta.103(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6))(react@19.2.6)(scheduler@0.27.0)': dependencies: - effect: 4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9) + effect: 4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6) react: 19.2.6 scheduler: 0.27.0 - '@effect/openapi-generator@4.0.0-beta.103(@effect/platform-node@4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9))(ioredis@5.11.0)(utf-8-validate@6.0.6))(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9))': + '@effect/openapi-generator@4.0.0-beta.103(@effect/platform-node@4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6))(ioredis@5.11.0)(utf-8-validate@6.0.6))(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6))': dependencies: - '@effect/platform-node': 4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9))(ioredis@5.11.0)(utf-8-validate@6.0.6) - effect: 4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9) + '@effect/platform-node': 4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6))(ioredis@5.11.0)(utf-8-validate@6.0.6) + effect: 4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6) swagger2openapi: 7.0.8 transitivePeerDependencies: - encoding - '@effect/platform-bun@4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9))(utf-8-validate@6.0.6)': + '@effect/platform-bun@4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6))(utf-8-validate@6.0.6)': dependencies: - '@effect/platform-node-shared': 4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9))(utf-8-validate@6.0.6) - effect: 4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9) + '@effect/platform-node-shared': 4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6))(utf-8-validate@6.0.6) + effect: 4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6) transitivePeerDependencies: - bufferutil - utf-8-validate - '@effect/platform-node-shared@4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9))(utf-8-validate@6.0.6)': + '@effect/platform-node-shared@4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6))(utf-8-validate@6.0.6)': dependencies: '@types/ws': 8.18.1 - effect: 4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9) + effect: 4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6) ws: 8.21.0(bufferutil@4.1.0)(utf-8-validate@6.0.6) transitivePeerDependencies: - bufferutil - utf-8-validate - '@effect/platform-node@4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9))(ioredis@5.11.0)(utf-8-validate@6.0.6)': + '@effect/platform-node@4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6))(ioredis@5.11.0)(utf-8-validate@6.0.6)': dependencies: - '@effect/platform-node-shared': 4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9))(utf-8-validate@6.0.6) - effect: 4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9) + '@effect/platform-node-shared': 4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6))(utf-8-validate@6.0.6) + effect: 4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6) ioredis: 5.11.0 mime: 4.1.0 undici: 8.9.0 @@ -12094,14 +12094,14 @@ snapshots: - bufferutil - utf-8-validate - '@effect/sql-d1@4.0.0-beta.101(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9))': + '@effect/sql-d1@4.0.0-beta.101(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6))': dependencies: '@cloudflare/workers-types': 5.20260726.1 - effect: 4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9) + effect: 4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6) - '@effect/sql-pg@4.0.0-beta.103(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9))': + '@effect/sql-pg@4.0.0-beta.103(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6))': dependencies: - effect: 4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9) + effect: 4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6) pg: 8.22.0 pg-connection-string: 2.14.0 pg-cursor: 2.21.0(pg@8.22.0) @@ -12110,9 +12110,9 @@ snapshots: transitivePeerDependencies: - pg-native - '@effect/sql-sqlite-bun@4.0.0-beta.103(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9))': + '@effect/sql-sqlite-bun@4.0.0-beta.103(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6))': dependencies: - effect: 4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9) + effect: 4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6) '@effect/tsgo-darwin-arm64@0.13.2': optional: true @@ -12145,9 +12145,9 @@ snapshots: '@effect/tsgo-win32-arm64': 0.13.2 '@effect/tsgo-win32-x64': 0.13.2 - '@effect/vitest@4.0.0-beta.103(patch_hash=a16b1e870d8c29e4a98b17cc4c638a4ff471753d8ca3487f78a22b759caf951b)(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9))': + '@effect/vitest@4.0.0-beta.103(patch_hash=a16b1e870d8c29e4a98b17cc4c638a4ff471753d8ca3487f78a22b759caf951b)(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6))': dependencies: - effect: 4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9) + effect: 4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6) '@egjs/hammerjs@2.0.17': dependencies: @@ -15766,22 +15766,22 @@ snapshots: json-schema-traverse: 1.0.0 require-from-string: 2.0.2 - alchemy@2.0.0-beta.65(8f212c6ff68a28a2886619dd53dd12ff): + alchemy@2.0.0-beta.65(640abc0ef264fc2fa89e88e85253c4ef): dependencies: '@alchemy.run/node-utils': 0.0.5 '@aws-sdk/credential-providers': 3.1062.0 '@clack/prompts': 0.11.0 - '@distilled.cloud/aws': 0.30.2(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9)) - '@distilled.cloud/axiom': 0.30.2(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9)) - '@distilled.cloud/cloudflare': 0.30.2(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9)) + '@distilled.cloud/aws': 0.30.2(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6)) + '@distilled.cloud/axiom': 0.30.2(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6)) + '@distilled.cloud/cloudflare': 0.30.2(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6)) '@distilled.cloud/cloudflare-rolldown-plugin': 0.13.10(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(tsx@4.23.1)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(rolldown@1.1.5)(workerd@1.20260704.1) - '@distilled.cloud/cloudflare-runtime': 0.13.10(@distilled.cloud/cloudflare@0.30.2(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9)))(@effect/platform-bun@4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9))(utf-8-validate@6.0.6))(@effect/platform-node@4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9))(ioredis@5.11.0)(utf-8-validate@6.0.6))(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9)) - '@distilled.cloud/cloudflare-vite-plugin': 0.13.10(ae521bb6d3e654dac541e4d0c80e2bbe) - '@distilled.cloud/core': 0.30.2(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9)) - '@distilled.cloud/neon': 0.30.2(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9)) - '@distilled.cloud/planetscale': 0.30.2(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9)) - '@effect/sql-d1': 4.0.0-beta.101(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9)) - '@effect/vitest': 4.0.0-beta.103(patch_hash=a16b1e870d8c29e4a98b17cc4c638a4ff471753d8ca3487f78a22b759caf951b)(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9)) + '@distilled.cloud/cloudflare-runtime': 0.13.10(@distilled.cloud/cloudflare@0.30.2(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6)))(@effect/platform-bun@4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6))(utf-8-validate@6.0.6))(@effect/platform-node@4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6))(ioredis@5.11.0)(utf-8-validate@6.0.6))(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6)) + '@distilled.cloud/cloudflare-vite-plugin': 0.13.10(2a81819c3753cd7ae4754d7e3425b68b) + '@distilled.cloud/core': 0.30.2(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6)) + '@distilled.cloud/neon': 0.30.2(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6)) + '@distilled.cloud/planetscale': 0.30.2(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6)) + '@effect/sql-d1': 4.0.0-beta.101(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6)) + '@effect/vitest': 4.0.0-beta.103(patch_hash=a16b1e870d8c29e4a98b17cc4c638a4ff471753d8ca3487f78a22b759caf951b)(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6)) '@libsql/client': 0.17.3(bufferutil@4.1.0)(utf-8-validate@6.0.6) '@octokit/rest': 22.0.1 '@octokit/webhooks': 14.2.0 @@ -15791,7 +15791,7 @@ snapshots: '@types/aws-lambda': 8.10.161 aws4fetch: 1.0.20 capnweb: 0.6.1 - effect: 4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9) + effect: 4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6) fast-glob: 3.3.3 fast-xml-parser: 5.8.0 ink: 6.8.0(@types/react@19.2.16)(bufferutil@4.1.0)(react-devtools-core@6.1.5(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react@19.2.6)(utf-8-validate@6.0.6) @@ -15807,11 +15807,11 @@ snapshots: undici: 7.27.1 yaml: 2.9.0 optionalDependencies: - '@effect/platform-bun': 4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9))(utf-8-validate@6.0.6) - '@effect/platform-node': 4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9))(ioredis@5.11.0)(utf-8-validate@6.0.6) - '@effect/sql-pg': 4.0.0-beta.103(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9)) + '@effect/platform-bun': 4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6))(utf-8-validate@6.0.6) + '@effect/platform-node': 4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6))(ioredis@5.11.0)(utf-8-validate@6.0.6) + '@effect/sql-pg': 4.0.0-beta.103(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6)) drizzle-kit: 1.0.0-rc.4 - drizzle-orm: 1.0.0-rc.4(@cloudflare/workers-types@4.20260604.1)(@effect/sql-d1@4.0.0-beta.101(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9)))(@effect/sql-pg@4.0.0-beta.103(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9)))(@effect/sql-sqlite-bun@4.0.0-beta.103(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9)))(@libsql/client@0.17.3(bufferutil@4.1.0)(utf-8-validate@6.0.6))(bun-types@1.3.14)(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9))(expo-sqlite@56.0.5(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6))(mysql2@3.22.4(@types/node@24.12.4))(pg@8.21.0)(zod@4.4.3) + drizzle-orm: 1.0.0-rc.4(@cloudflare/workers-types@4.20260604.1)(@effect/sql-d1@4.0.0-beta.101(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6)))(@effect/sql-pg@4.0.0-beta.103(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6)))(@effect/sql-sqlite-bun@4.0.0-beta.103(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6)))(@libsql/client@0.17.3(bufferutil@4.1.0)(utf-8-validate@6.0.6))(bun-types@1.3.14)(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6))(expo-sqlite@56.0.5(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6))(mysql2@3.22.4(@types/node@24.12.4))(pg@8.21.0)(zod@4.4.3) vite: '@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(tsx@4.23.1)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0)' ws: 8.21.0(bufferutil@4.1.0)(utf-8-validate@6.0.6) transitivePeerDependencies: @@ -16786,10 +16786,10 @@ snapshots: dependencies: dequal: 2.0.3 - dfx@1.0.15(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9)): + dfx@1.0.15(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6)): dependencies: discord-api-types: 0.38.52 - effect: 4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9) + effect: 4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6) optionalDependencies: discord-verify: 1.2.0 @@ -16866,15 +16866,15 @@ snapshots: get-tsconfig: 4.14.0 jiti: 2.7.0 - drizzle-orm@1.0.0-rc.4(@cloudflare/workers-types@4.20260604.1)(@effect/sql-d1@4.0.0-beta.101(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9)))(@effect/sql-pg@4.0.0-beta.103(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9)))(@effect/sql-sqlite-bun@4.0.0-beta.103(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9)))(@libsql/client@0.17.3(bufferutil@4.1.0)(utf-8-validate@6.0.6))(bun-types@1.3.14)(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9))(expo-sqlite@56.0.5(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6))(mysql2@3.22.4(@types/node@24.12.4))(pg@8.21.0)(zod@4.4.3): + drizzle-orm@1.0.0-rc.4(@cloudflare/workers-types@4.20260604.1)(@effect/sql-d1@4.0.0-beta.101(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6)))(@effect/sql-pg@4.0.0-beta.103(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6)))(@effect/sql-sqlite-bun@4.0.0-beta.103(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6)))(@libsql/client@0.17.3(bufferutil@4.1.0)(utf-8-validate@6.0.6))(bun-types@1.3.14)(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6))(expo-sqlite@56.0.5(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6))(mysql2@3.22.4(@types/node@24.12.4))(pg@8.21.0)(zod@4.4.3): optionalDependencies: '@cloudflare/workers-types': 4.20260604.1 - '@effect/sql-d1': 4.0.0-beta.101(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9)) - '@effect/sql-pg': 4.0.0-beta.103(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9)) - '@effect/sql-sqlite-bun': 4.0.0-beta.103(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9)) + '@effect/sql-d1': 4.0.0-beta.101(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6)) + '@effect/sql-pg': 4.0.0-beta.103(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6)) + '@effect/sql-sqlite-bun': 4.0.0-beta.103(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6)) '@libsql/client': 0.17.3(bufferutil@4.1.0)(utf-8-validate@6.0.6) bun-types: 1.3.14 - effect: 4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9) + effect: 4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6) expo-sqlite: 56.0.5(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6) mysql2: 3.22.4(@types/node@24.12.4) pg: 8.21.0 @@ -16898,7 +16898,7 @@ snapshots: ee-first@1.1.1: {} - effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9): + effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6): dependencies: '@standard-schema/spec': 1.1.0 fast-check: 4.9.0