diff --git a/apps/mobile/src/components/ProviderIcon.tsx b/apps/mobile/src/components/ProviderIcon.tsx index 6c1b1038698..3e8d9d65221 100644 --- a/apps/mobile/src/components/ProviderIcon.tsx +++ b/apps/mobile/src/components/ProviderIcon.tsx @@ -1,5 +1,5 @@ import { useColorScheme } from "react-native"; -import { Path, Svg } from "react-native-svg"; +import { Circle, Path, Rect, Svg } from "react-native-svg"; type ProviderIconProps = { readonly provider: string | null | undefined; @@ -10,6 +10,42 @@ export function ProviderIcon(props: ProviderIconProps) { const isDarkMode = useColorScheme() === "dark"; const size = props.size ?? 16; + if (props.provider === "hermes") { + const color = isDarkMode ? "#f4f4f5" : "#18181b"; + return ( + + + + + ); + } + + if (props.provider === "openclaw") { + return ( + + + + + + + + + ); + } + if (props.provider === "claudeAgent") { return ( diff --git a/apps/mobile/src/features/home/HomeHeader.tsx b/apps/mobile/src/features/home/HomeHeader.tsx index 36cead9f8cc..029c741197e 100644 --- a/apps/mobile/src/features/home/HomeHeader.tsx +++ b/apps/mobile/src/features/home/HomeHeader.tsx @@ -17,6 +17,7 @@ import { useHardwareKeyboardCommand } from "../keyboard/hardwareKeyboardCommands import { withNativeGlassHeaderItem } from "../layout/native-glass-header-items"; import { createNativeMailSearchToolbarItem } from "../layout/native-mail-search-toolbar"; import type { HomeProjectSortOrder } from "./homeThreadList"; +import type { MobileWorkspace } from "../../lib/mobileWorkspace"; import { buildHomeListFilterMenu, type HomeListFilterMenuEnvironment, @@ -32,6 +33,7 @@ export type HomeHeaderEnvironment = HomeListFilterMenuEnvironment; export function HomeHeader(props: { readonly environments: ReadonlyArray; + readonly workspace: MobileWorkspace; readonly projects: ReadonlyArray; readonly searchQuery: string; readonly selectedEnvironmentId: EnvironmentId | null; @@ -40,6 +42,7 @@ export function HomeHeader(props: { readonly threadSortOrder: SidebarThreadSortOrder; readonly onSearchQueryChange: (query: string) => void; readonly onEnvironmentChange: (environmentId: EnvironmentId | null) => void; + readonly onWorkspaceChange: (workspace: MobileWorkspace) => void; readonly onProjectChange: (projectKey: string | null) => void; readonly onProjectSortOrderChange: (sortOrder: HomeProjectSortOrder) => void; readonly onThreadSortOrderChange: (sortOrder: SidebarThreadSortOrder) => void; @@ -59,6 +62,23 @@ function checkedMenuState(checked: boolean) { return checked ? ("on" as const) : undefined; } +function workspaceMenuActions(props: HomeHeaderProps): MenuAction[] { + return [ + { + id: "workspace:work", + title: "T3 Work", + subtitle: "Create, learn, and explore", + state: checkedMenuState(props.workspace === "work"), + }, + { + id: "workspace:code", + title: "T3 Code", + subtitle: "Build, debug, and ship", + state: checkedMenuState(props.workspace === "code"), + }, + ]; +} + /** Thread List v2 lays the list out in fixed creation order, so the sort/group filter controls would be silently ignored — hide them and key the "customized" icon state off the environment filter alone. */ @@ -148,6 +168,7 @@ function AndroidHomeHeader(props: HomeHeaderProps) { threadListV2Enabled, ], ); + const workspaceActions = useMemo(() => workspaceMenuActions(props), [props.workspace]); const handleMenuAction = useCallback( (event: { nativeEvent: { event: string } }) => { const id = event.nativeEvent.event; @@ -196,6 +217,13 @@ function AndroidHomeHeader(props: HomeHeaderProps) { }, [props], ); + const handleWorkspaceAction = useCallback( + ({ nativeEvent }: { readonly nativeEvent: { readonly event: string } }) => { + if (nativeEvent.event === "workspace:work") props.onWorkspaceChange("work"); + if (nativeEvent.event === "workspace:code") props.onWorkspaceChange("code"); + }, + [props], + ); return ( <> @@ -208,18 +236,29 @@ function AndroidHomeHeader(props: HomeHeaderProps) { > - - {/* Mirrors the desktop SidebarBrand: T3 mark + muted "Code". */} - - - Code - - - - Alpha + + + + + {props.workspace === "work" ? "Work" : "Code"} - - + + + + Alpha + + + + [ + { + type: "menu" as const, + label: `T3 ${props.workspace === "work" ? "Work" : "Code"}`, + accessibilityLabel: `Switch workspace. Current workspace: T3 ${props.workspace === "work" ? "Work" : "Code"}`, + icon: { type: "sfSymbol", name: "chevron.up.chevron.down" } as const, + identifier: "home-workspace", + sharesBackground: false, + variant: "plain" as const, + menu: { + items: [ + { + type: "action" as const, + label: "T3 Work", + description: "Create, learn, and explore", + onPress: () => props.onWorkspaceChange("work"), + state: props.workspace === "work" ? ("on" as const) : undefined, + }, + { + type: "action" as const, + label: "T3 Code", + description: "Build, debug, and ship", + onPress: () => props.onWorkspaceChange("code"), + state: props.workspace === "code" ? ("on" as const) : undefined, + }, + ], + }, + }, createNativeMailSearchToolbarItem({ composeButtonId: "home-new-task", composeSystemImageName: "square.and.pencil", diff --git a/apps/mobile/src/features/home/HomeRouteScreen.tsx b/apps/mobile/src/features/home/HomeRouteScreen.tsx index 9fa179f4c76..7463637239e 100644 --- a/apps/mobile/src/features/home/HomeRouteScreen.tsx +++ b/apps/mobile/src/features/home/HomeRouteScreen.tsx @@ -2,9 +2,17 @@ import * as Arr from "effect/Array"; import * as Order from "effect/Order"; import { useNavigation } from "@react-navigation/native"; import { useEffect, useMemo, useState } from "react"; +import { Alert } from "react-native"; import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader"; -import { useProjects, useThreadShells } from "../../state/entities"; +import { useProjects, useServerConfigs, useThreadShells } from "../../state/entities"; +import { + buildProviderDriverMap, + isHermesProviderInstance, + isMobileWorkspaceThread, + resolveHermesConversationTarget, +} from "../../lib/mobileWorkspace"; +import { useMobileWorkspace } from "../../state/preferences"; import { usePendingNewTasks } from "../../state/use-pending-new-tasks"; import { useWorkspaceState } from "../../state/workspace"; import { useSavedRemoteConnections } from "../../state/use-remote-environment-registry"; @@ -25,13 +33,35 @@ export function HomeRouteScreen() { const { layout } = useAdaptiveWorkspaceLayout(); const projects = useProjects(); const threads = useThreadShells(); + const serverConfigs = useServerConfigs(); + const [workspace, setWorkspace] = useMobileWorkspace(); + const providerDrivers = useMemo(() => buildProviderDriverMap(serverConfigs), [serverConfigs]); + const visibleThreads = useMemo( + () => threads.filter((thread) => isMobileWorkspaceThread(thread, workspace, providerDrivers)), + [providerDrivers, threads, workspace], + ); const { state: catalogState } = useWorkspaceState(); const { savedConnectionsById } = useSavedRemoteConnections(); const navigation = useNavigation(); const [searchQuery, setSearchQuery] = useState(""); const { archiveThread, confirmDeleteThread, settleThread, unsettleThread } = useThreadListActions(); - const pendingTasks = usePendingNewTasks(); + const allPendingTasks = usePendingNewTasks(); + const pendingTasks = useMemo( + () => + workspace === "code" + ? allPendingTasks + : allPendingTasks.filter( + (task) => + task.message.modelSelection !== undefined && + isHermesProviderInstance( + task.message.environmentId, + task.message.modelSelection.instanceId, + providerDrivers, + ), + ), + [allPendingTasks, providerDrivers, workspace], + ); const { openPendingTask, confirmDeletePendingTask } = usePendingTaskListActions(); const environments = useMemo( () => @@ -79,6 +109,35 @@ export function HomeRouteScreen() { setSelectedProjectKey(null); } }, [projectFilterOptions, selectedProjectKey]); + const startNewTask = () => { + if (workspace === "code") { + navigation.navigate("NewTaskSheet", { screen: "NewTask" }); + return; + } + const target = resolveHermesConversationTarget({ + projects, + serverConfigs, + requiredEnvironmentId: selectedEnvironmentId, + }); + if (!target) { + Alert.alert( + "Hermes is not ready", + "Enable and configure Hermes on a connected environment before starting a Work conversation.", + ); + return; + } + navigation.navigate("NewTaskSheet", { + screen: "NewTaskDraft", + params: { + environmentId: String(target.project.environmentId), + projectId: String(target.project.id), + title: "Hermes", + workspace: "work", + providerInstanceId: String(target.modelSelection.instanceId), + model: target.modelSelection.model, + }, + }); + }; // In split layouts the persistent sidebar IS the thread list — Home becomes // an empty detail pane so selecting a thread never transitions layouts. @@ -91,38 +150,36 @@ export function HomeRouteScreen() { navigation.navigate("NewTaskSheet", { screen: "NewTask" })} + onPress={startNewTask} /> } /> - navigation.navigate("NewTaskSheet", { screen: "NewTask" })} - /> + ); } return ( - navigation.navigate("NewTaskSheet", { screen: "NewTask" })} - > + <> {/* Restore the compact title in case the split branch blanked it. */} navigation.navigate("SettingsSheet", { screen: "Settings" })} onProjectSortOrderChange={setProjectSortOrder} onSearchQueryChange={setSearchQuery} - onStartNewTask={() => navigation.navigate("NewTaskSheet", { screen: "NewTask" })} + onStartNewTask={startNewTask} onThreadSortOrderChange={setThreadSortOrder} /> @@ -164,7 +221,7 @@ export function HomeRouteScreen() { }, }); }} - onStartNewTask={() => navigation.navigate("NewTaskSheet", { screen: "NewTask" })} + onStartNewTask={startNewTask} onThreadSortOrderChange={setThreadSortOrder} pendingTasks={pendingTasks} projectGroupingMode={listOptions.projectGroupingMode} @@ -173,8 +230,9 @@ export function HomeRouteScreen() { savedConnectionsById={savedConnectionsById} searchQuery={searchQuery} selectedEnvironmentId={selectedEnvironmentId} - selectedProjectKey={selectedProjectKey} - threads={threads} + selectedProjectKey={workspace === "work" ? null : selectedProjectKey} + threads={visibleThreads} + workspace={workspace} threadSortOrder={listOptions.threadSortOrder} /> diff --git a/apps/mobile/src/features/home/HomeScreen.tsx b/apps/mobile/src/features/home/HomeScreen.tsx index 8c308135f61..be14577b62e 100644 --- a/apps/mobile/src/features/home/HomeScreen.tsx +++ b/apps/mobile/src/features/home/HomeScreen.tsx @@ -25,6 +25,7 @@ import { EmptyState } from "../../components/EmptyState"; import type { WorkspaceState } from "../../state/workspaceModel"; import type { SavedRemoteConnection } from "../../lib/connection"; import { scopedProjectKey } from "../../lib/scopedEntities"; +import type { MobileWorkspace } from "../../lib/mobileWorkspace"; import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../native/native-glass"; import { mobilePreferencesAtom, updateMobilePreferencesAtom } from "../../state/preferences"; import { environmentServerConfigsAtom } from "../../state/server"; @@ -35,7 +36,7 @@ import { ThreadListRow, ThreadListShowMoreRow, } from "../threads/thread-list-items"; -import { ThreadListV2Row } from "../threads/thread-list-v2-items"; +import { ThreadListV2InboxHeader, ThreadListV2Row } from "../threads/thread-list-v2-items"; import { buildThreadListV2Items, THREAD_LIST_V2_SETTLED_INITIAL_COUNT, @@ -77,6 +78,7 @@ interface HomeScreenProps { readonly projectSortOrder: HomeProjectSortOrder; readonly threadSortOrder: SidebarThreadSortOrder; readonly projectGroupingMode: SidebarProjectGroupingMode; + readonly workspace: MobileWorkspace; readonly onSearchQueryChange: (query: string) => void; readonly onEnvironmentChange: (environmentId: EnvironmentId | null) => void; readonly onProjectChange: (projectKey: string | null) => void; @@ -182,8 +184,9 @@ export function HomeScreen(props: HomeScreenProps) { >(() => new Map()); const preferencesResult = useAtomValue(mobilePreferencesAtom); const threadListV2Enabled = - AsyncResult.isSuccess(preferencesResult) && - preferencesResult.value.threadListV2Enabled === true; + props.workspace === "work" || + (AsyncResult.isSuccess(preferencesResult) && + preferencesResult.value.threadListV2Enabled === true); const savePreferences = useAtomSet(updateMobilePreferencesAtom); const openSwipeableRef = useRef(null); const listRef = useRef(null); @@ -736,7 +739,11 @@ export function HomeScreen(props: HomeScreenProps) { ) : null; - if (!hasAnyThreads) { + const catalogNotReady = + props.catalogState.isLoadingConnections || + !props.catalogState.hasConnections || + !props.catalogState.hasLoadedShellSnapshot; + if (!hasAnyThreads && (props.workspace === "code" || catalogNotReady)) { return ( ))} + {props.workspace === "work" ? : null} ); diff --git a/apps/mobile/src/features/home/usePendingTaskListActions.ts b/apps/mobile/src/features/home/usePendingTaskListActions.ts index 3f0867ba0e2..bb5df0cdc30 100644 --- a/apps/mobile/src/features/home/usePendingTaskListActions.ts +++ b/apps/mobile/src/features/home/usePendingTaskListActions.ts @@ -20,6 +20,7 @@ export function usePendingTaskListActions(): { environmentId: String(pendingTask.message.environmentId), projectId: String(pendingTask.creation.projectId), pendingTaskId: String(pendingTask.message.messageId), + workspace: pendingTask.creation.prepareWorkspace === false ? "work" : "code", }, }); }, diff --git a/apps/mobile/src/features/threads/NewTaskDraftRouteScreen.tsx b/apps/mobile/src/features/threads/NewTaskDraftRouteScreen.tsx index 8e6819378a6..2787849f41b 100644 --- a/apps/mobile/src/features/threads/NewTaskDraftRouteScreen.tsx +++ b/apps/mobile/src/features/threads/NewTaskDraftRouteScreen.tsx @@ -1,4 +1,5 @@ import type { StaticScreenProps } from "@react-navigation/native"; +import { ProviderInstanceId } from "@t3tools/contracts"; import { useMemo } from "react"; import { NativeStackScreenOptions } from "../../native/StackHeader"; @@ -10,6 +11,9 @@ type NewTaskDraftRouteParams = { readonly title?: string | string[]; readonly pendingTaskId?: string | string[]; readonly incomingShareId?: string | string[]; + readonly workspace?: string | string[]; + readonly providerInstanceId?: string | string[]; + readonly model?: string | string[]; }; export function NewTaskDraftRouteScreen({ route }: StaticScreenProps) { @@ -32,7 +36,12 @@ export function NewTaskDraftRouteScreen({ route }: StaticScreenProps ); diff --git a/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx b/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx index e328bcef00e..ca39a046220 100644 --- a/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx +++ b/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx @@ -7,7 +7,7 @@ import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useThemeColor } from "../../lib/useThemeColor"; import { useFontFamily } from "../../lib/useFontFamily"; -import { EnvironmentId } from "@t3tools/contracts"; +import { EnvironmentId, type ModelSelection } from "@t3tools/contracts"; import { isAtomCommandInterrupted, squashAtomCommandFailure, @@ -72,6 +72,8 @@ export function NewTaskDraftScreen(props: { readonly pendingTaskId?: string; /** Durable native share inbox item to merge into this project draft. */ readonly incomingShareId?: string; + readonly workspace?: "work" | "code"; + readonly initialModelSelection?: ModelSelection; }) { const projects = useProjects(); const createProjectThread = useCreateProjectThread(); @@ -110,6 +112,7 @@ export function NewTaskDraftScreen(props: { const shareImportMountedRef = useRef(true); const latestDraftKeyRef = useRef(flow.draftKey); const latestIncomingShareIdRef = useRef(props.incomingShareId); + const isWorkConversation = props.workspace === "work"; latestDraftKeyRef.current = flow.draftKey; latestIncomingShareIdRef.current = props.incomingShareId; const isImportingShare = importingShareKey !== null; @@ -226,6 +229,46 @@ export function NewTaskDraftScreen(props: { // A new navigation to this mounted screen delivers a fresh initialProjectRef // reference — treat it as a new request and let it apply again. const lastInitialProjectRefRef = useRef(props.initialProjectRef); + const clearedWorkDraftRef = useRef(null); + const initializedWorkDraftRef = useRef(null); + + useEffect(() => { + flow.setDraftScope(isWorkConversation ? "work" : "project"); + }, [flow.setDraftScope, isWorkConversation]); + + useEffect(() => { + if ( + !isWorkConversation || + flow.draftScope !== "work" || + !flow.draftKey || + !props.initialModelSelection + ) { + return; + } + // Clear once when the Work draft is first seen, before the user can type. + if (clearedWorkDraftRef.current !== flow.draftKey) { + clearedWorkDraftRef.current = flow.draftKey; + clearComposerDraftContent(flow.draftKey); + } + if (initializedWorkDraftRef.current === flow.draftKey) { + return; + } + const requestedModelKey = `${props.initialModelSelection.instanceId}:${props.initialModelSelection.model}`; + // Model options load asynchronously; selecting an absent key is a no-op, + // so the draft only counts as initialized once the option is selectable. + if (!flow.modelOptions.some((option) => option.key === requestedModelKey)) { + return; + } + initializedWorkDraftRef.current = flow.draftKey; + flow.setSelectedModelKey(requestedModelKey); + }, [ + flow.draftKey, + flow.draftScope, + flow.modelOptions, + flow.setSelectedModelKey, + isWorkConversation, + props.initialModelSelection, + ]); useEffect(() => { // Pending-task editing owns project selection (and must not fall through @@ -304,8 +347,10 @@ export function NewTaskDraftScreen(props: { return; } loadedBranchesProjectKeyRef.current = projectKey; - void flow.loadBranches(); - }, [flow.loadBranches, selectedProject]); + if (!isWorkConversation) { + void flow.loadBranches(); + } + }, [flow.loadBranches, isWorkConversation, selectedProject]); useEffect(() => { const shareId = props.incomingShareId; @@ -860,7 +905,7 @@ export function NewTaskDraftScreen(props: { // finds no work and ends the card within seconds. armAgentAwarenessLiveActivityForLocalWork({ threadTitle: deriveThreadTitleFromPrompt(initialMessageText), - projectTitle: selectedProject.title, + projectTitle: isWorkConversation ? "Hermes" : selectedProject.title, }); const result = await createProjectThread({ project: selectedProject, @@ -869,6 +914,7 @@ export function NewTaskDraftScreen(props: { branch: selectedBranchName, worktreePath: workspaceMode === "worktree" ? null : selectedWorktreePath, startFromOrigin, + prepareWorkspace: isWorkConversation ? false : undefined, runtimeMode, interactionMode, initialMessageText, @@ -956,7 +1002,11 @@ export function NewTaskDraftScreen(props: { onFocus={() => setIsComposerFocused(true)} onBlur={() => setIsComposerFocused(false)} onPasteImages={(uris) => void handleNativePasteImages(uris)} - placeholder={`Describe a coding task in ${selectedProject.title}`} + placeholder={ + isWorkConversation + ? "Ask Hermes anything" + : `Describe a coding task in ${selectedProject.title}` + } // Same collapsed centering as ThreadComposer: native vertical gravity // in a pill-height box. singleLineCentered={!isExpanded} @@ -1017,24 +1067,36 @@ export function NewTaskDraftScreen(props: { label={selectedEnvironmentLabel} /> - handleWorkspaceMenuAction(nativeEvent.event)} - > - - + {isWorkConversation ? null : ( + handleWorkspaceMenuAction(nativeEvent.event)} + > + + + )} ); const startButton = ( void handleStart()} @@ -1051,7 +1113,10 @@ export function NewTaskDraftScreen(props: { return ( - navigation.goBack()} /> + navigation.goBack()} + /> @@ -1126,7 +1191,9 @@ export function NewTaskDraftScreen(props: { return ( - + {promptEditor} diff --git a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx index 7872f312493..2f4086d529e 100644 --- a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx +++ b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx @@ -23,9 +23,14 @@ import { SymbolView } from "../../components/AppSymbol"; import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../native/native-glass"; import { NativeStackScreenOptions } from "../../native/StackHeader"; import { scopedProjectKey, scopedThreadKey } from "../../lib/scopedEntities"; +import { + buildProviderDriverMap, + isHermesProviderInstance, + isMobileWorkspaceThread, +} from "../../lib/mobileWorkspace"; import { useThemeColor } from "../../lib/useThemeColor"; import { useProjects, useThreadShells } from "../../state/entities"; -import { mobilePreferencesAtom } from "../../state/preferences"; +import { mobilePreferencesAtom, useMobileWorkspace } from "../../state/preferences"; import { environmentServerConfigsAtom } from "../../state/server"; import { usePendingNewTasks, type PendingNewTask } from "../../state/use-pending-new-tasks"; import { useWorkspaceState } from "../../state/workspace"; @@ -63,7 +68,7 @@ import { ThreadListRow, ThreadListShowMoreRow, } from "./thread-list-items"; -import { ThreadListV2Row } from "./thread-list-v2-items"; +import { ThreadListV2InboxHeader, ThreadListV2Row } from "./thread-list-v2-items"; import { buildThreadListV2Items, THREAD_LIST_V2_SETTLED_INITIAL_COUNT, @@ -82,6 +87,7 @@ type SidebarListItem = readonly pendingTask: PendingNewTask; readonly isLast: boolean; } + | { readonly type: "v2-inbox"; readonly key: string } | { readonly type: "v2-thread"; readonly key: string; readonly item: ThreadListV2Item } | { readonly type: "v2-show-more"; readonly key: string; readonly hiddenCount: number }; @@ -197,10 +203,33 @@ function ThreadNavigationSidebarPane( const { archiveThread, confirmDeleteThread, settleThread, unsettleThread } = useThreadListActions(); const preferencesResult = useAtomValue(mobilePreferencesAtom); + const [workspace, setWorkspace] = useMobileWorkspace(); const threadListV2Enabled = - AsyncResult.isSuccess(preferencesResult) && - preferencesResult.value.threadListV2Enabled === true; - const pendingTasks = usePendingNewTasks(); + workspace === "work" || + (AsyncResult.isSuccess(preferencesResult) && + preferencesResult.value.threadListV2Enabled === true); + const serverConfigs = useAtomValue(environmentServerConfigsAtom); + const providerDrivers = useMemo(() => buildProviderDriverMap(serverConfigs), [serverConfigs]); + const visibleThreads = useMemo( + () => threads.filter((thread) => isMobileWorkspaceThread(thread, workspace, providerDrivers)), + [providerDrivers, threads, workspace], + ); + const allPendingTasks = usePendingNewTasks(); + const pendingTasks = useMemo( + () => + workspace === "code" + ? allPendingTasks + : allPendingTasks.filter( + (task) => + task.message.modelSelection !== undefined && + isHermesProviderInstance( + task.message.environmentId, + task.message.modelSelection.instanceId, + providerDrivers, + ), + ), + [allPendingTasks, providerDrivers, workspace], + ); const { openPendingTask, confirmDeletePendingTask } = usePendingTaskListActions(); const environments = useMemo( () => @@ -289,11 +318,11 @@ function ThreadNavigationSidebarPane( const scopedThreads = useMemo( () => selectedProjectRefs === null - ? threads - : threads.filter((thread) => + ? visibleThreads + : visibleThreads.filter((thread) => selectedProjectRefs.has(scopedProjectKey(thread.environmentId, thread.projectId)), ), - [selectedProjectRefs, threads], + [selectedProjectRefs, visibleThreads], ); const scopedPendingTasks = useMemo( () => @@ -414,7 +443,6 @@ function ThreadNavigationSidebarPane( }, [threadListV2Enabled]); // Threads on servers without the settlement capability never classify as // settled (the user could neither un-settle nor pin them). - const serverConfigs = useAtomValue(environmentServerConfigsAtom); const settlementEnvironmentIds = useMemo(() => { const supported = new Set(); for (const [environmentId, config] of serverConfigs) { @@ -437,9 +465,12 @@ function ThreadNavigationSidebarPane( if (!threadListV2Enabled) return { items: [], hiddenSettledCount: 0, snoozedCount: 0, nextSnoozeWakeAt: null }; return buildThreadListV2Items({ - threads: threads.filter((thread) => thread.archivedAt === null), + threads: visibleThreads, environmentId: options.selectedEnvironmentId, - projectRefs: selectedProjectScope === null ? null : selectedProjectScope.projectRefs, + projectRefs: + workspace === "work" || selectedProjectScope === null + ? null + : selectedProjectScope.projectRefs, searchQuery: props.searchQuery, changeRequestStateByKey, settlementEnvironmentIds, @@ -458,7 +489,8 @@ function ThreadNavigationSidebarPane( settlementEnvironmentIds, snoozeEnvironmentIds, threadListV2Enabled, - threads, + visibleThreads, + workspace, selectedProjectScope, ]); // Re-partition the moment the earliest snooze expires (clamped to the @@ -487,19 +519,29 @@ function ThreadNavigationSidebarPane( (pendingTask) => (options.selectedEnvironmentId === null || pendingTask.message.environmentId === options.selectedEnvironmentId) && - (selectedProjectRefs === null || + (workspace === "work" || + selectedProjectRefs === null || selectedProjectRefs.has( scopedProjectKey(pendingTask.message.environmentId, pendingTask.creation.projectId), )) && (v2SearchQuery.length === 0 || pendingTask.title.toLocaleLowerCase().includes(v2SearchQuery)), ); - const items: SidebarListItem[] = v2PendingTasks.map((pendingTask, index) => ({ - type: "v2-pending-task" as const, - key: `v2-pending:${pendingTask.message.messageId}`, - pendingTask, - isLast: index === v2PendingTasks.length - 1, - })); + const items: SidebarListItem[] = []; + if (workspace === "work") { + items.push({ + type: "v2-inbox", + key: "v2-inbox", + }); + } + items.push( + ...v2PendingTasks.map((pendingTask, index) => ({ + type: "v2-pending-task" as const, + key: `v2-pending:${pendingTask.message.messageId}`, + pendingTask, + isLast: index === v2PendingTasks.length - 1, + })), + ); for (const item of threadListV2Layout.items) { items.push({ type: "v2-thread" as const, @@ -527,6 +569,24 @@ function ThreadNavigationSidebarPane( const showsConnectionStatus = shouldShowWorkspaceConnectionStatus(catalogState); const listMenuActions = useMemo( () => [ + { + id: "workspace", + title: "Workspace", + subactions: [ + { + id: "workspace:work", + title: "T3 Work", + subtitle: "Create, learn, and explore", + state: workspace === "work" ? "on" : "off", + }, + { + id: "workspace:code", + title: "T3 Code", + subtitle: "Build, debug, and ship", + state: workspace === "code" ? "on" : "off", + }, + ], + }, { id: "environment", title: "Environment", @@ -547,7 +607,7 @@ function ThreadNavigationSidebarPane( })), ], }, - ...(projectFilterOptions.length === 0 + ...(workspace === "work" || projectFilterOptions.length === 0 ? [] : ([ { @@ -594,11 +654,26 @@ function ThreadNavigationSidebarPane( }, ] satisfies MenuAction[])), ], - [environments, options, projectFilterOptions, selectedProjectKey, threadListV2Enabled], + [ + environments, + options, + projectFilterOptions, + selectedProjectKey, + threadListV2Enabled, + workspace, + ], ); const handleListMenuAction = useCallback( ({ nativeEvent }: { readonly nativeEvent: { readonly event: string } }) => { const event = nativeEvent.event; + if (event === "workspace:work") { + setWorkspace("work"); + return; + } + if (event === "workspace:code") { + setWorkspace("code"); + return; + } if (event === "environment:all") { setSelectedEnvironmentId(null); return; @@ -642,6 +717,7 @@ function ThreadNavigationSidebarPane( setProjectSortOrder, setSelectedEnvironmentId, setThreadSortOrder, + setWorkspace, ], ); @@ -715,6 +791,9 @@ function ThreadNavigationSidebarPane( if (previous.type === "v2-show-more" && item.type === "v2-show-more") { return previous.hiddenCount === item.hiddenCount; } + if (previous.type === "v2-inbox" && item.type === "v2-inbox") { + return true; + } if (previous.type === "v2-pending-task" && item.type === "v2-pending-task") { return previous.pendingTask === item.pendingTask && previous.isLast === item.isLast; } @@ -722,9 +801,11 @@ function ThreadNavigationSidebarPane( previous.type === "v2-thread" || previous.type === "v2-show-more" || previous.type === "v2-pending-task" || + previous.type === "v2-inbox" || item.type === "v2-thread" || item.type === "v2-show-more" || - item.type === "v2-pending-task" + item.type === "v2-pending-task" || + item.type === "v2-inbox" ) { return false; } @@ -752,6 +833,8 @@ function ThreadNavigationSidebarPane( const renderListItem = useCallback( ({ item }: { readonly item: SidebarListItem }) => { switch (item.type) { + case "v2-inbox": + return ; case "v2-pending-task": return ( buildHomeListFilterMenu({ environments, - projects: projectFilterOptions, + projects: workspace === "work" ? [] : projectFilterOptions, selectedEnvironmentId: options.selectedEnvironmentId, - selectedProjectKey, + selectedProjectKey: workspace === "work" ? null : selectedProjectKey, projectSortOrder: options.projectSortOrder, threadSortOrder: options.threadSortOrder, onEnvironmentChange: setSelectedEnvironmentId, @@ -951,6 +1035,7 @@ function ThreadNavigationSidebarPane( setSelectedEnvironmentId, setThreadSortOrder, threadListV2Enabled, + workspace, ], ); const nativeHeaderItems = useMemo( @@ -958,9 +1043,11 @@ function ThreadNavigationSidebarPane( createSidebarHeaderItems({ filterIcon, filterMenu, + workspace, + onWorkspaceChange: setWorkspace, onOpenSettings: props.onOpenSettings, }), - [filterIcon, filterMenu, props.onOpenSettings], + [filterIcon, filterMenu, props.onOpenSettings, setWorkspace, workspace], ); // "No threads yet" over an inbox that is merely all-snoozed reads as // data loss; name the snoozed threads instead. @@ -993,6 +1080,7 @@ function ThreadNavigationSidebarPane( - Threads + T3 {workspace === "work" ? "Work" : "Code"} diff --git a/apps/mobile/src/features/threads/new-task-flow-provider.tsx b/apps/mobile/src/features/threads/new-task-flow-provider.tsx index 74fe2f4852a..e819499ab0b 100644 --- a/apps/mobile/src/features/threads/new-task-flow-provider.tsx +++ b/apps/mobile/src/features/threads/new-task-flow-provider.tsx @@ -57,6 +57,7 @@ import { EnvironmentProject } from "@t3tools/client-runtime/state/shell"; import { type VcsRef } from "@t3tools/client-runtime/state/vcs"; type WorkspaceMode = "local" | "worktree"; +type DraftScope = "project" | "work"; const EMPTY_BRANCH_REFS: ReadonlyArray = []; @@ -127,6 +128,7 @@ type NewTaskFlowContextValue = { readonly runtimeMode: RuntimeMode; readonly interactionMode: ProviderInteractionMode; readonly expandedProvider: string | null; + readonly draftScope: DraftScope; readonly environments: ReadonlyArray<{ readonly environmentId: EnvironmentId; readonly environmentLabel: string; @@ -163,6 +165,7 @@ type NewTaskFlowContextValue = { value: ReadonlyArray | undefined, ) => void; readonly setExpandedProvider: (value: string | null) => void; + readonly setDraftScope: (scope: DraftScope) => void; }; const NewTaskFlowContext = React.createContext(null); @@ -211,6 +214,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { const [submitting, setSubmitting] = useState(false); const [branchQuery, setBranchQuery] = useState(""); const [expandedProvider, setExpandedProvider] = useState(null); + const [draftScope, setDraftScope] = useState("project"); const [editingPendingTask, setEditingPendingTask] = useState(null); // Mirrors `editingPendingTask` synchronously so the unmount flush cannot act // on a task whose editing session already ended this render. @@ -342,7 +346,9 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { const selectedProjectDraftKey = editingPendingTask ? pendingTaskDraftKey(editingPendingTask.messageId) : selectedProject - ? `new-task:${scopedProjectKey(selectedProject.environmentId, selectedProject.id)}` + ? draftScope === "work" + ? `new-task:work:${selectedProject.environmentId}` + : `new-task:${scopedProjectKey(selectedProject.environmentId, selectedProject.id)}` : null; const selectedProjectDraft = useComposerDraft(selectedProjectDraftKey); const prompt = selectedProjectDraft.text; @@ -715,6 +721,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { ...((workspaceSelection?.startFromOrigin ?? startFromOrigin) ? { startFromOrigin: true } : {}), + ...(draftScope === "work" ? { prepareWorkspace: false } : {}), }, createdAt: metadata.createdAt, }; @@ -726,6 +733,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { selectedProject, selectedProjectDraftKey, startFromOrigin, + draftScope, ], ); @@ -840,6 +848,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { runtimeMode, interactionMode, expandedProvider, + draftScope, environments, selectedProject, modelOptions, @@ -871,6 +880,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { setInteractionMode, setSelectedModelOptions, setExpandedProvider, + setDraftScope, }), [ attachments, @@ -883,6 +893,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { editingPendingTask, environments, expandedProvider, + draftScope, filteredBranches, finishEditingPendingTask, interactionMode, @@ -902,6 +913,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { selectedProjectDraftKey, selectedProviderSkills, setSelectedModelOptions, + setDraftScope, selectedProject, selectedProjectKey, selectedWorktreePath, diff --git a/apps/mobile/src/features/threads/sidebar-native-header-items.ts b/apps/mobile/src/features/threads/sidebar-native-header-items.ts index b80fffda057..b7a09d1c955 100644 --- a/apps/mobile/src/features/threads/sidebar-native-header-items.ts +++ b/apps/mobile/src/features/threads/sidebar-native-header-items.ts @@ -39,9 +39,36 @@ function toNativeHeaderMenuItems(items: HomeListFilterMenu["items"]): NativeHead export function createSidebarHeaderItems(input: { readonly filterIcon: string; readonly filterMenu: HomeListFilterMenu; + readonly workspace: "work" | "code"; + readonly onWorkspaceChange: (workspace: "work" | "code") => void; readonly onOpenSettings: () => void; }): NativeStackHeaderItem[] { return [ + withNativeGlassHeaderItem({ + type: "menu", + label: "", + accessibilityLabel: `Switch workspace. Current workspace: T3 ${input.workspace === "work" ? "Work" : "Code"}`, + icon: sfSymbolIcon("rectangle.2.swap"), + menu: { + title: "Workspace", + items: [ + { + type: "action" as const, + label: "T3 Work", + description: "Create, learn, and explore", + state: input.workspace === "work" ? ("on" as const) : undefined, + onPress: () => input.onWorkspaceChange("work"), + }, + { + type: "action" as const, + label: "T3 Code", + description: "Build, debug, and ship", + state: input.workspace === "code" ? ("on" as const) : undefined, + onPress: () => input.onWorkspaceChange("code"), + }, + ], + }, + }), withNativeGlassHeaderItem({ type: "menu", label: "", diff --git a/apps/mobile/src/features/threads/thread-list-v2-items.tsx b/apps/mobile/src/features/threads/thread-list-v2-items.tsx index 90af04eb0ca..91015fbea04 100644 --- a/apps/mobile/src/features/threads/thread-list-v2-items.tsx +++ b/apps/mobile/src/features/threads/thread-list-v2-items.tsx @@ -85,6 +85,23 @@ export const ThreadListV2SettledDivider = memo(function ThreadListV2SettledDivid ); }); +export const ThreadListV2InboxHeader = memo(function ThreadListV2InboxHeader(props: { + readonly pane?: "screen" | "sidebar"; +}) { + const borderColor = useThemeColor("--color-border"); + return ( + + Inbox + + + ); +}); + export const ThreadListV2Row = memo(function ThreadListV2Row(props: { readonly thread: EnvironmentThreadShell; readonly variant: "card" | "slim"; @@ -154,6 +171,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { const selectedBackgroundColor = useThemeColor("--color-user-bubble"); const sidebarPane = props.pane === "sidebar"; const selected = props.selected === true; + const isHermes = props.providerDriver === "hermes"; const status = resolveThreadListV2Status(thread); const statusLabel = STATUS_LABEL_BY_STATUS[status]; @@ -216,7 +234,9 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { const cardContent = ( <> - {props.project ? ( + {isHermes ? ( + + ) : props.project ? ( - {props.projectTitle ?? props.project?.title ?? ""} + {isHermes ? "Hermes" : (props.projectTitle ?? props.project?.title ?? "")} )} - {pr ? ( + {!isHermes && pr ? ( ) : null} - {props.providerDriver ? ( + {props.providerDriver && !isHermes ? ( diff --git a/apps/mobile/src/features/threads/use-project-actions.ts b/apps/mobile/src/features/threads/use-project-actions.ts index 9d03dde59a9..db11e838e00 100644 --- a/apps/mobile/src/features/threads/use-project-actions.ts +++ b/apps/mobile/src/features/threads/use-project-actions.ts @@ -33,6 +33,7 @@ export function useCreateProjectThread() { readonly branch: string | null; readonly worktreePath: string | null; readonly startFromOrigin?: boolean; + readonly prepareWorkspace?: boolean; readonly runtimeMode: RuntimeMode; readonly interactionMode: ProviderInteractionMode; readonly initialMessageText: string; @@ -74,6 +75,7 @@ export function useCreateProjectThread() { branch: input.branch, worktreePath: input.worktreePath, startFromOrigin: input.startFromOrigin ?? false, + prepareWorkspace: input.prepareWorkspace, worktreeBranchName: buildTemporaryWorktreeBranchName(randomHex), }), }); diff --git a/apps/mobile/src/lib/mobileWorkspace.test.ts b/apps/mobile/src/lib/mobileWorkspace.test.ts new file mode 100644 index 00000000000..37c3801da92 --- /dev/null +++ b/apps/mobile/src/lib/mobileWorkspace.test.ts @@ -0,0 +1,247 @@ +import type { EnvironmentProject } from "@t3tools/client-runtime/state/shell"; +import { + EnvironmentId, + ProjectId, + ProviderDriverKind, + ProviderInstanceId, + ThreadId, + type ServerConfig, +} from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; + +import { + buildProviderDriverMap, + isMobileWorkspaceThread, + mobileProviderInstanceKey, + resolveHermesConversationTarget, +} from "./mobileWorkspace"; + +const environmentId = EnvironmentId.make("environment:local"); +const hermesInstanceId = ProviderInstanceId.make("hermes-primary"); +const rootThreadId = ThreadId.make("thread:root"); + +function project( + id: string, + workspaceRoot: string, + projectEnvironmentId = environmentId, +): EnvironmentProject { + return { + environmentId: projectEnvironmentId, + id: ProjectId.make(id), + title: id, + workspaceRoot, + defaultModelSelection: null, + scripts: [], + createdAt: "2026-07-26T00:00:00.000Z", + updatedAt: "2026-07-26T00:00:00.000Z", + }; +} + +function serverConfig(overrides: Partial = {}): ServerConfig { + return { + t3WorkDirectory: "/private/t3-work", + providers: [ + { + instanceId: hermesInstanceId, + driver: ProviderDriverKind.make("hermes"), + enabled: true, + installed: true, + status: "ready", + models: [ + { + slug: "default", + name: "Default", + isCustom: false, + capabilities: null, + }, + ], + }, + ], + ...overrides, + } as unknown as ServerConfig; +} + +describe("mobile workspace routing", () => { + it("recognizes custom Hermes instance ids from provider metadata", () => { + const configs = new Map([[environmentId, serverConfig()]]); + const drivers = buildProviderDriverMap(configs); + const thread: Parameters[0] = { + environmentId, + archivedAt: null, + lineage: { + parentThreadId: null, + relationshipToParent: null, + rootThreadId, + }, + providerInstanceId: hermesInstanceId, + modelSelection: { instanceId: hermesInstanceId, model: "default" }, + runtime: null, + }; + + expect(drivers.get(mobileProviderInstanceKey(environmentId, hermesInstanceId))).toBe("hermes"); + expect(isMobileWorkspaceThread(thread, "work", drivers)).toBe(true); + expect(isMobileWorkspaceThread(thread, "code", drivers)).toBe(false); + }); + + it("splits Hermes and non-Hermes threads between Work and Code", () => { + const codexInstanceId = ProviderInstanceId.make("codex"); + const config = serverConfig(); + const configs = new Map([ + [ + environmentId, + { + ...config, + providers: [ + ...config.providers, + { ...config.providers[0], instanceId: codexInstanceId, driver: "codex" }, + ], + } as ServerConfig, + ], + ]); + const drivers = buildProviderDriverMap(configs); + const codeThread: Parameters[0] = { + environmentId, + archivedAt: null, + lineage: { + parentThreadId: null, + relationshipToParent: null, + rootThreadId, + }, + providerInstanceId: codexInstanceId, + modelSelection: { instanceId: codexInstanceId, model: "default" }, + runtime: null, + }; + + expect(isMobileWorkspaceThread(codeThread, "code", drivers)).toBe(true); + expect(isMobileWorkspaceThread(codeThread, "work", drivers)).toBe(false); + }); + + it("excludes archived and subagent threads from both workspaces", () => { + const drivers = buildProviderDriverMap(new Map([[environmentId, serverConfig()]])); + const base: Parameters[0] = { + environmentId, + archivedAt: null, + lineage: { + parentThreadId: null, + relationshipToParent: null, + rootThreadId, + }, + providerInstanceId: hermesInstanceId, + modelSelection: { instanceId: hermesInstanceId, model: "default" }, + runtime: null, + }; + + expect( + isMobileWorkspaceThread({ ...base, archivedAt: "2026-07-26T00:00:00.000Z" }, "code", drivers), + ).toBe(false); + expect( + isMobileWorkspaceThread( + { + ...base, + lineage: { + ...base.lineage, + parentThreadId: rootThreadId, + relationshipToParent: "subagent", + }, + }, + "work", + drivers, + ), + ).toBe(false); + }); + + it("routes new Work conversations through the private backing project", () => { + const ordinaryProject = project("project:ordinary", "/workspace/repo"); + const backingProject = project("project:t3-work", "/private/t3-work"); + + expect( + resolveHermesConversationTarget({ + projects: [ordinaryProject, backingProject], + serverConfigs: new Map([[environmentId, serverConfig()]]), + requiredEnvironmentId: null, + }), + ).toEqual({ + project: backingProject, + modelSelection: { + instanceId: hermesInstanceId, + model: "default", + }, + }); + }); + + it("routes new Work conversations through the selected environment", () => { + const otherEnvironmentId = EnvironmentId.make("environment:other"); + const firstBackingProject = project( + "project:first-t3-work", + "/private/t3-work", + otherEnvironmentId, + ); + const selectedBackingProject = project("project:selected-t3-work", "/private/t3-work"); + + expect( + resolveHermesConversationTarget({ + projects: [firstBackingProject, selectedBackingProject], + serverConfigs: new Map([ + [otherEnvironmentId, serverConfig()], + [environmentId, serverConfig()], + ]), + requiredEnvironmentId: environmentId, + }), + ).toEqual({ + project: selectedBackingProject, + modelSelection: { + instanceId: hermesInstanceId, + model: "default", + }, + }); + }); + + it("falls back to a later ready Hermes provider when the first has no models", () => { + const backingProject = project("project:t3-work", "/private/t3-work"); + const modellessInstanceId = ProviderInstanceId.make("hermes-modelless"); + const config = serverConfig(); + const configs = new Map([ + [ + environmentId, + { + ...config, + providers: [ + { ...config.providers[0], instanceId: modellessInstanceId, models: [] }, + ...config.providers, + ], + } as ServerConfig, + ], + ]); + + expect( + resolveHermesConversationTarget({ + projects: [backingProject], + serverConfigs: configs, + requiredEnvironmentId: null, + }), + ).toEqual({ + project: backingProject, + modelSelection: { + instanceId: hermesInstanceId, + model: "default", + }, + }); + }); + + it("does not attach Work conversations to an arbitrary project while setup is incomplete", () => { + expect( + resolveHermesConversationTarget({ + projects: [project("project:ordinary", "/workspace/repo")], + serverConfigs: new Map([[environmentId, serverConfig()]]), + requiredEnvironmentId: null, + }), + ).toBeNull(); + expect( + resolveHermesConversationTarget({ + projects: [project("project:t3-work", "/private/t3-work")], + serverConfigs: new Map([[environmentId, serverConfig({ t3WorkDirectory: undefined })]]), + requiredEnvironmentId: null, + }), + ).toBeNull(); + }); +}); diff --git a/apps/mobile/src/lib/mobileWorkspace.ts b/apps/mobile/src/lib/mobileWorkspace.ts new file mode 100644 index 00000000000..a5da92b2221 --- /dev/null +++ b/apps/mobile/src/lib/mobileWorkspace.ts @@ -0,0 +1,132 @@ +import type { + EnvironmentProject, + EnvironmentThreadShell, +} from "@t3tools/client-runtime/state/shell"; +import { + isProviderAvailable, + type EnvironmentId, + type ModelSelection, + type ProviderDriverKind, + type ProviderInstanceId, + type ServerConfig, +} from "@t3tools/contracts"; + +export type MobileWorkspace = "work" | "code"; + +export function mobileProviderInstanceKey( + environmentId: EnvironmentId, + providerInstanceId: ProviderInstanceId, +): string { + return `${environmentId}\u0000${providerInstanceId}`; +} + +export function buildProviderDriverMap( + serverConfigs: ReadonlyMap, +): ReadonlyMap { + const drivers = new Map(); + for (const [environmentId, config] of serverConfigs) { + for (const provider of config.providers) { + drivers.set(mobileProviderInstanceKey(environmentId, provider.instanceId), provider.driver); + } + } + return drivers; +} + +export function isHermesThread( + thread: Pick< + EnvironmentThreadShell, + "environmentId" | "providerInstanceId" | "modelSelection" | "runtime" + >, + providerDrivers: ReadonlyMap, +): boolean { + const providerInstanceId = + thread.runtime?.providerInstanceId ?? + thread.providerInstanceId ?? + thread.modelSelection.instanceId; + const driver = providerDrivers.get( + mobileProviderInstanceKey(thread.environmentId, providerInstanceId), + ); + // Cached shells can arrive before server config. The canonical legacy + // instance id is a safe fallback; custom instance ids wait for metadata. + return driver === "hermes" || (driver === undefined && providerInstanceId === "hermes"); +} + +export function isHermesProviderInstance( + environmentId: EnvironmentId, + providerInstanceId: ProviderInstanceId, + providerDrivers: ReadonlyMap, +): boolean { + const driver = providerDrivers.get(mobileProviderInstanceKey(environmentId, providerInstanceId)); + return driver === "hermes" || (driver === undefined && providerInstanceId === "hermes"); +} + +export function isMobileWorkspaceThread( + thread: Pick< + EnvironmentThreadShell, + "archivedAt" | "environmentId" | "lineage" | "providerInstanceId" | "modelSelection" | "runtime" + >, + workspace: MobileWorkspace, + providerDrivers: ReadonlyMap, +): boolean { + if (thread.archivedAt !== null || thread.lineage.relationshipToParent === "subagent") { + return false; + } + const isHermes = isHermesThread(thread, providerDrivers); + return workspace === "work" ? isHermes : !isHermes; +} + +export interface HermesConversationTarget { + readonly project: EnvironmentProject; + readonly modelSelection: ModelSelection; +} + +/** + * Resolves the existing project shell used only to route a Hermes launch. + * Work UI never exposes this backing project, and `prepareWorkspace: false` + * prevents project/worktree setup from leaking into the conversation. + */ +export function resolveHermesConversationTarget(input: { + readonly projects: ReadonlyArray; + readonly serverConfigs: ReadonlyMap; + readonly requiredEnvironmentId: EnvironmentId | null; +}): HermesConversationTarget | null { + for (const [environmentId, config] of input.serverConfigs) { + if (input.requiredEnvironmentId !== null && environmentId !== input.requiredEnvironmentId) { + continue; + } + const workDirectory = config.t3WorkDirectory; + const project = + workDirectory === undefined + ? undefined + : input.projects.find( + (candidate) => + candidate.environmentId === environmentId && + candidate.workspaceRoot === workDirectory, + ); + if (!project) continue; + for (const provider of config.providers) { + if ( + provider.driver !== "hermes" || + !provider.enabled || + !provider.installed || + provider.status !== "ready" || + !isProviderAvailable(provider) + ) { + continue; + } + const model = + provider.models.find((candidate) => candidate.slug === "default") ?? + provider.models.find((candidate) => candidate.isDefault === true) ?? + provider.models[0]; + if (!model) continue; + return { + project, + modelSelection: { + instanceId: provider.instanceId, + model: model.slug, + }, + }; + } + } + return null; +} diff --git a/apps/mobile/src/lib/projectThreadStartTurn.test.ts b/apps/mobile/src/lib/projectThreadStartTurn.test.ts new file mode 100644 index 00000000000..31301d08d4c --- /dev/null +++ b/apps/mobile/src/lib/projectThreadStartTurn.test.ts @@ -0,0 +1,54 @@ +import { ProjectId, ProviderInstanceId } from "@t3tools/contracts"; +import { describe, expect, it, vi } from "vite-plus/test"; + +vi.mock("./uuid", () => ({ + uuidv4: () => "unused", +})); + +import { buildProjectThreadStartTurnInput } from "./projectThreadStartTurn"; + +const baseSpec = { + projectId: ProjectId.make("project:t3-work"), + projectCwd: "/private/t3-work", + threadId: "thread:work", + commandId: "command:work", + messageId: "message:work", + createdAt: "2026-07-26T00:00:00.000Z", + text: "Summarize my messages", + attachments: [], + modelSelection: { + instanceId: ProviderInstanceId.make("hermes-primary"), + model: "default", + }, + runtimeMode: "full-access", + interactionMode: "default", + workspaceMode: "local", + branch: null, + worktreePath: null, + startFromOrigin: false, + worktreeBranchName: "unused", +} as const; + +describe("project thread start turn", () => { + it("marks projectless Work launches to skip backing-project preparation", () => { + const input = buildProjectThreadStartTurnInput({ + ...baseSpec, + prepareWorkspace: false, + }); + + expect(input.bootstrap).toMatchObject({ + prepareWorkspace: false, + createThread: { + projectId: ProjectId.make("project:t3-work"), + worktreePath: null, + }, + }); + expect(input.bootstrap).not.toHaveProperty("prepareWorktree"); + }); + + it("omits the workspace override for ordinary project launches", () => { + expect(buildProjectThreadStartTurnInput(baseSpec).bootstrap).not.toHaveProperty( + "prepareWorkspace", + ); + }); +}); diff --git a/apps/mobile/src/lib/projectThreadStartTurn.ts b/apps/mobile/src/lib/projectThreadStartTurn.ts index 310f1818590..da367a68da0 100644 --- a/apps/mobile/src/lib/projectThreadStartTurn.ts +++ b/apps/mobile/src/lib/projectThreadStartTurn.ts @@ -36,6 +36,8 @@ export interface ProjectThreadStartTurnSpec { readonly branch: string | null; readonly worktreePath: string | null; readonly startFromOrigin: boolean; + /** False for conversation providers whose backing project is routing-only. */ + readonly prepareWorkspace?: boolean; /** Generated temp branch for worktree mode; unused for local mode. */ readonly worktreeBranchName: string; } @@ -63,6 +65,7 @@ export function buildProjectThreadStartTurnInput(spec: ProjectThreadStartTurnSpe runtimeMode: spec.runtimeMode, interactionMode: spec.interactionMode, bootstrap: { + ...(spec.prepareWorkspace === undefined ? {} : { prepareWorkspace: spec.prepareWorkspace }), createThread: { projectId: spec.projectId, title, diff --git a/apps/mobile/src/persistence/mobile-preferences.ts b/apps/mobile/src/persistence/mobile-preferences.ts index 4e576bb2fe1..359e35668b0 100644 --- a/apps/mobile/src/persistence/mobile-preferences.ts +++ b/apps/mobile/src/persistence/mobile-preferences.ts @@ -14,6 +14,7 @@ const PREFERENCES_KEY = "t3code.preferences"; const PREFERENCES_FALLBACK_KEY = "t3code.preferences.fallback"; export interface Preferences { + readonly workspace?: "work" | "code"; readonly liveActivitiesEnabled?: boolean; readonly baseFontSize?: number; readonly terminalFontSize?: number | null; @@ -80,6 +81,7 @@ function sanitizePreferences(parsed: Preferences): Preferences { collapsedProjectGroups?: readonly string[]; projectGroupingEnabled?: boolean; threadListV2Enabled?: boolean; + workspace?: "work" | "code"; } = {}; if (typeof parsed.liveActivitiesEnabled === "boolean") { @@ -112,6 +114,9 @@ function sanitizePreferences(parsed: Preferences): Preferences { if (typeof parsed.threadListV2Enabled === "boolean") { preferences.threadListV2Enabled = parsed.threadListV2Enabled; } + if (parsed.workspace === "work" || parsed.workspace === "code") { + preferences.workspace = parsed.workspace; + } return preferences; } diff --git a/apps/mobile/src/state/preferences.ts b/apps/mobile/src/state/preferences.ts index d173cf55be5..6cf57bef815 100644 --- a/apps/mobile/src/state/preferences.ts +++ b/apps/mobile/src/state/preferences.ts @@ -1,7 +1,10 @@ import * as Effect from "effect/Effect"; import { AsyncResult, Atom } from "effect/unstable/reactivity"; +import { useAtomSet, useAtomValue } from "@effect/atom-react"; +import { useCallback } from "react"; import { MobilePreferencesStore, type Preferences } from "../persistence/mobile-preferences"; +import type { MobileWorkspace } from "../lib/mobileWorkspace"; import * as Runtime from "../lib/runtime"; export { @@ -122,3 +125,20 @@ export const mobilePreferencesState = createMobilePreferencesState(mobilePrefere export const mobilePreferencesAtom = mobilePreferencesState.preferencesAtom; export const updateMobilePreferencesAtom = mobilePreferencesState.updatePreferencesAtom; + +export function useMobileWorkspace(): readonly [ + MobileWorkspace, + (workspace: MobileWorkspace) => void, +] { + const preferences = useAtomValue(mobilePreferencesAtom); + const updatePreferences = useAtomSet(updateMobilePreferencesAtom); + const workspace = + AsyncResult.isSuccess(preferences) && preferences.value.workspace === "work" ? "work" : "code"; + const setWorkspace = useCallback( + (nextWorkspace: MobileWorkspace) => { + updatePreferences({ workspace: nextWorkspace }); + }, + [updatePreferences], + ); + return [workspace, setWorkspace] as const; +} diff --git a/apps/mobile/src/state/thread-outbox-model.ts b/apps/mobile/src/state/thread-outbox-model.ts index 3ba61be3872..85318c9a853 100644 --- a/apps/mobile/src/state/thread-outbox-model.ts +++ b/apps/mobile/src/state/thread-outbox-model.ts @@ -34,6 +34,7 @@ const QueuedThreadCreationSchema = Schema.Struct({ branch: Schema.NullOr(Schema.String), worktreePath: Schema.NullOr(Schema.String), startFromOrigin: Schema.optional(Schema.Boolean), + prepareWorkspace: Schema.optional(Schema.Boolean), }); export const QueuedThreadMessageSchema = Schema.Struct({ @@ -64,6 +65,7 @@ export interface QueuedThreadCreation { readonly branch: string | null; readonly worktreePath: string | null; readonly startFromOrigin?: boolean; + readonly prepareWorkspace?: boolean; } export interface QueuedThreadMessage { diff --git a/apps/mobile/src/state/use-thread-outbox-drain.ts b/apps/mobile/src/state/use-thread-outbox-drain.ts index 20eda747315..429f3fd36e8 100644 --- a/apps/mobile/src/state/use-thread-outbox-drain.ts +++ b/apps/mobile/src/state/use-thread-outbox-drain.ts @@ -272,6 +272,7 @@ export function useThreadOutboxDrain(): void { branch: creation.branch, worktreePath: creation.worktreePath, startFromOrigin: creation.startFromOrigin ?? false, + prepareWorkspace: creation.prepareWorkspace, worktreeBranchName: buildTemporaryWorktreeBranchName(randomHex), }), }); diff --git a/apps/server/src/assets/AssetAccess.test.ts b/apps/server/src/assets/AssetAccess.test.ts index 42fd3f900e5..541ceda29b3 100644 --- a/apps/server/src/assets/AssetAccess.test.ts +++ b/apps/server/src/assets/AssetAccess.test.ts @@ -70,6 +70,96 @@ describe("AssetAccess", () => { }).pipe(Effect.provide(testLayer)), ); + it.effect("issues exact-file workspace URLs for video files", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-asset-video-" }); + const videoPath = path.join(root, "recordings", "demo.webm"); + yield* fileSystem.makeDirectory(path.join(root, "recordings"), { recursive: true }); + yield* fileSystem.writeFileString(videoPath, "webm-bytes"); + const canonicalVideoPath = yield* fileSystem.realPath(videoPath); + + const result = yield* issueAssetUrl({ + resource: { + _tag: "workspace-file", + threadId: ThreadId.make("thread-1"), + path: "recordings/demo.webm", + }, + workspaceRoot: root, + }); + const suffix = result.relativeUrl.slice(`${ASSET_ROUTE_PREFIX}/`.length); + const token = suffix.slice(0, suffix.indexOf("/")); + + expect(yield* resolveAsset(token, "demo.webm")).toEqual({ + kind: "file", + path: canonicalVideoPath, + }); + expect(yield* resolveAsset(token, "other.webm")).toBeNull(); + }).pipe(Effect.provide(testLayer)), + ); + + it.effect("issues browser artifact URLs by safe media file name only", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const config = yield* ServerConfig.ServerConfig; + const artifactPath = path.join(config.browserArtifactsDir, "browser-recording-demo.webm"); + yield* fileSystem.writeFileString(artifactPath, "webm-bytes"); + yield* fileSystem.writeFileString( + path.join(config.browserArtifactsDir, "notes.txt"), + "not media", + ); + + const result = yield* issueAssetUrl({ + resource: { _tag: "browser-artifact", fileName: "browser-recording-demo.webm" }, + }); + const suffix = result.relativeUrl.slice(`${ASSET_ROUTE_PREFIX}/`.length); + const token = suffix.slice(0, suffix.indexOf("/")); + const resolved = yield* resolveAsset(token, "browser-recording-demo.webm"); + expect(resolved?.kind).toBe("open-file"); + if (resolved?.kind !== "open-file") throw new Error("expected an opened browser artifact"); + expect(resolved.file.name).toBe("browser-recording-demo.webm"); + expect(yield* Effect.promise(() => new Response(resolved.file.stream()).text())).toBe( + "webm-bytes", + ); + + expect( + (yield* issueAssetUrl({ + resource: { _tag: "browser-artifact", fileName: "notes.txt" }, + }).pipe(Effect.flip))._tag, + ).toBe("AssetBrowserArtifactNotFoundError"); + expect( + (yield* issueAssetUrl({ + resource: { _tag: "browser-artifact", fileName: "../state.sqlite" }, + }).pipe(Effect.flip))._tag, + ).toBe("AssetBrowserArtifactNotFoundError"); + + const outside = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-browser-artifact-outside-", + }); + const outsideVideo = path.join(outside, "outside.webm"); + const artifactSymlink = path.join( + config.browserArtifactsDir, + "browser-recording-escape.webm", + ); + yield* fileSystem.writeFileString(outsideVideo, "outside-webm"); + yield* fileSystem.symlink(outsideVideo, artifactSymlink); + expect( + (yield* issueAssetUrl({ + resource: { + _tag: "browser-artifact", + fileName: "browser-recording-escape.webm", + }, + }).pipe(Effect.flip))._tag, + ).toBe("AssetBrowserArtifactNotFoundError"); + + yield* fileSystem.remove(artifactPath); + yield* fileSystem.symlink(outsideVideo, artifactPath); + expect(yield* resolveAsset(token, "browser-recording-demo.webm")).toBeNull(); + }).pipe(Effect.provide(testLayer)), + ); + it.effect("rejects workspace files outside the authorized root", () => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; diff --git a/apps/server/src/assets/AssetAccess.ts b/apps/server/src/assets/AssetAccess.ts index b469e0e315b..b2bf5412916 100644 --- a/apps/server/src/assets/AssetAccess.ts +++ b/apps/server/src/assets/AssetAccess.ts @@ -1,6 +1,7 @@ import type { AssetResource } from "@t3tools/contracts"; import { AssetAttachmentNotFoundError, + AssetBrowserArtifactNotFoundError, AssetPreviewTypeValidationError, AssetProjectFaviconInspectionError, AssetProjectFaviconNotFoundError, @@ -16,10 +17,15 @@ import { import { isWorkspaceImagePreviewPath, isWorkspacePreviewEntryPath, + isWorkspaceVideoPreviewPath, WORKSPACE_BROWSER_PREVIEW_EXTENSIONS, WORKSPACE_IMAGE_PREVIEW_EXTENSIONS, + WORKSPACE_VIDEO_PREVIEW_EXTENSIONS, } from "@t3tools/shared/filePreview"; import { PROJECT_FAVICON_FALLBACK_MARKER } from "@t3tools/shared/projectFavicon"; +// @effect-diagnostics-next-line nodeBuiltinImport:off - O_NOFOLLOW open and fd-backed streaming have no Effect FileSystem equivalent. +import * as NodeFS from "node:fs"; +import * as NodeStream from "node:stream"; import * as Clock from "effect/Clock"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; @@ -47,6 +53,7 @@ const ASSET_TOKEN_TTL_MS = 60 * 60 * 1000; const PREVIEW_ASSET_EXTENSIONS = new Set([ ...WORKSPACE_BROWSER_PREVIEW_EXTENSIONS, ...WORKSPACE_IMAGE_PREVIEW_EXTENSIONS, + ...WORKSPACE_VIDEO_PREVIEW_EXTENSIONS, ".css", ".js", ".mjs", @@ -77,6 +84,12 @@ const AssetClaimsSchema = Schema.Union([ attachmentId: Schema.String, expiresAt: Schema.Number, }), + Schema.Struct({ + version: Schema.Literal(1), + kind: Schema.Literal("browser-artifact"), + fileName: Schema.String, + expiresAt: Schema.Number, + }), Schema.Struct({ version: Schema.Literal(1), kind: Schema.Literal("project-favicon"), @@ -91,7 +104,18 @@ const AssetClaimsJson = Schema.fromJsonString(AssetClaimsSchema); const decodeAssetClaims = Schema.decodeUnknownOption(AssetClaimsJson); const encodeAssetClaims = Schema.encodeSync(AssetClaimsJson); -export type ResolvedAsset = { readonly kind: "file"; readonly path: string }; +export interface OpenedAssetFile { + readonly name: string; + readonly lastModified: number; + readonly size: number; + readonly type: string; + readonly stream: () => ReadableStream; + readonly close: () => Promise; +} + +export type ResolvedAsset = + | { readonly kind: "file"; readonly path: string } + | { readonly kind: "open-file"; readonly file: OpenedAssetFile }; function decodeClaims(encodedPayload: string): AssetClaims | null { try { @@ -120,6 +144,62 @@ const optionOnNotFound = ( }), ); +function normalizeBrowserArtifactFileName(fileName: string): string | null { + const trimmed = fileName.trim(); + if ( + trimmed.length === 0 || + trimmed.includes("/") || + trimmed.includes("\\") || + trimmed.includes("\0") || + trimmed.startsWith(".") + ) { + return null; + } + if (!isWorkspaceImagePreviewPath(trimmed) && !isWorkspaceVideoPreviewPath(trimmed)) { + return null; + } + return trimmed; +} + +const openBrowserArtifact = Effect.fn("AssetAccess.openBrowserArtifact")(function* ( + browserArtifactsDir: string, + fileName: string, +) { + const normalizedFileName = normalizeBrowserArtifactFileName(fileName); + if (normalizedFileName === null) return null; + + const path = yield* Path.Path; + const artifactPath = path.join(browserArtifactsDir, normalizedFileName); + const handle = yield* Effect.tryPromise(() => + NodeFS.promises.open(artifactPath, NodeFS.constants.O_RDONLY | NodeFS.constants.O_NOFOLLOW), + ).pipe( + Effect.map((value): NodeFS.promises.FileHandle | null => value), + Effect.orElseSucceed(() => null), + ); + if (handle === null) return null; + + const close = () => handle.close().catch(() => undefined); + const info = yield* Effect.tryPromise(() => handle.stat()).pipe( + Effect.map((value) => Option.some(value)), + Effect.orElseSucceed(() => Option.none()), + ); + if (Option.isNone(info) || !info.value.isFile()) { + yield* Effect.promise(close); + return null; + } + return { + name: normalizedFileName, + lastModified: info.value.mtimeMs, + size: info.value.size, + type: "", + stream: () => + NodeStream.Readable.toWeb( + handle.createReadStream({ autoClose: true }), + ) as ReadableStream, + close, + } satisfies OpenedAssetFile; +}); + const resolveCanonicalWorkspaceFile = Effect.fn("AssetAccess.resolveCanonicalWorkspaceFile")( function* (input: { readonly workspaceRoot: string; readonly relativePath: string }) { const fileSystem = yield* FileSystem.FileSystem; @@ -234,21 +314,23 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i }), ), ); - claims = isWorkspaceImagePreviewPath(resolved.relativePath) - ? { - version: 1, - kind: "workspace-file-exact", - workspaceRoot: canonicalWorkspaceRoot, - relativePath: resolved.relativePath, - expiresAt, - } - : { - version: 1, - kind: "workspace-file", - workspaceRoot: canonicalWorkspaceRoot, - baseRelativePath: path.dirname(resolved.relativePath), - expiresAt, - }; + claims = + isWorkspaceImagePreviewPath(resolved.relativePath) || + isWorkspaceVideoPreviewPath(resolved.relativePath) + ? { + version: 1, + kind: "workspace-file-exact", + workspaceRoot: canonicalWorkspaceRoot, + relativePath: resolved.relativePath, + expiresAt, + } + : { + version: 1, + kind: "workspace-file", + workspaceRoot: canonicalWorkspaceRoot, + baseRelativePath: path.dirname(resolved.relativePath), + expiresAt, + }; fileName = path.basename(resolved.relativePath); break; } @@ -272,6 +354,28 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i fileName = path.basename(attachmentPath); break; } + case "browser-artifact": { + const config = yield* ServerConfig.ServerConfig; + const artifactFileName = normalizeBrowserArtifactFileName(input.resource.fileName); + const artifact = + artifactFileName === null + ? null + : yield* openBrowserArtifact(config.browserArtifactsDir, artifactFileName); + if (artifactFileName === null || artifact === null) { + return yield* new AssetBrowserArtifactNotFoundError({ + resource: input.resource, + }); + } + yield* Effect.promise(artifact.close); + claims = { + version: 1, + kind: "browser-artifact", + fileName: artifactFileName, + expiresAt, + }; + fileName = artifactFileName; + break; + } case "project-favicon": { const workspaceRoot = yield* workspacePaths.normalizeWorkspaceRoot(input.resource.cwd).pipe( Effect.mapError( @@ -388,6 +492,16 @@ export const resolveAsset = Effect.fn("AssetAccess.resolveAsset")(function* ( : null; } + if (claims.kind === "browser-artifact") { + const config = yield* ServerConfig.ServerConfig; + const artifactFileName = normalizeBrowserArtifactFileName(claims.fileName); + if (!artifactFileName) return null; + const artifact = yield* openBrowserArtifact(config.browserArtifactsDir, artifactFileName); + return artifact !== null + ? ({ kind: "open-file", file: artifact } satisfies ResolvedAsset) + : null; + } + if (claims.kind === "project-favicon") { if (claims.relativePath === null) return null; const faviconPath = yield* resolveCanonicalWorkspaceFileForRequest({ diff --git a/apps/server/src/attachmentStore.ts b/apps/server/src/attachmentStore.ts index 597c75dd91b..233868f3279 100644 --- a/apps/server/src/attachmentStore.ts +++ b/apps/server/src/attachmentStore.ts @@ -10,7 +10,20 @@ import { } from "./attachmentPaths.ts"; import { inferImageExtension, SAFE_IMAGE_FILE_EXTENSIONS } from "./imageMime.ts"; -const ATTACHMENT_FILENAME_EXTENSIONS = [...SAFE_IMAGE_FILE_EXTENSIONS, ".bin"]; +const ATTACHMENT_FILENAME_EXTENSIONS = [ + ...SAFE_IMAGE_FILE_EXTENSIONS, + ".pdf", + ".mp4", + ".webm", + ".mov", + ".mkv", + ".txt", + ".json", + ".csv", + ".md", + ".zip", + ".bin", +]; const ATTACHMENT_ID_THREAD_SEGMENT_MAX_CHARS = 80; const ATTACHMENT_ID_THREAD_SEGMENT_PATTERN = "[a-z0-9_]+(?:-[a-z0-9_]+)*"; const ATTACHMENT_ID_UUID_PATTERN = "[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}"; @@ -74,6 +87,20 @@ export function attachmentRelativePath(attachment: ChatAttachment): string { }); return `${attachment.id}${extension}`; } + case "pdf": + return `${attachment.id}.pdf`; + case "video": + return `${attachment.id}${ + attachment.mimeType === "video/mp4" + ? ".mp4" + : attachment.mimeType === "video/webm" + ? ".webm" + : attachment.mimeType === "video/quicktime" + ? ".mov" + : ".bin" + }`; + case "file": + return `${attachment.id}.bin`; } } diff --git a/apps/server/src/config.ts b/apps/server/src/config.ts index 5a16e144ee4..c2a56e8070f 100644 --- a/apps/server/src/config.ts +++ b/apps/server/src/config.ts @@ -32,7 +32,13 @@ export interface ServerDerivedPaths { readonly settingsPath: string; readonly providerStatusCacheDir: string; readonly worktreesDir: string; + readonly t3WorkDir?: string; readonly attachmentsDir: string; + /** + * Browser evidence artifacts (screenshots/recordings). The desktop app + * writes recordings here too because both derive from the runtime state dir. + */ + readonly browserArtifactsDir: string; readonly logsDir: string; readonly serverLogPath: string; readonly serverTracePath: string; @@ -105,6 +111,7 @@ export const deriveServerPaths = Effect.fn(function* ( ); const dbPath = join(stateDir, "state.sqlite"); const attachmentsDir = join(stateDir, "attachments"); + const browserArtifactsDir = join(stateDir, "browser-artifacts"); const logsDir = join(stateDir, "logs"); const providerLogsDir = join(logsDir, "provider"); const providerStatusCacheDir = join(baseDir, "caches"); @@ -115,7 +122,9 @@ export const deriveServerPaths = Effect.fn(function* ( settingsPath: join(stateDir, "settings.json"), providerStatusCacheDir, worktreesDir: join(baseDir, "worktrees"), + t3WorkDir: join(baseDir, "t3-work"), attachmentsDir, + browserArtifactsDir, logsDir, serverLogPath: join(logsDir, "server.log"), serverTracePath: join(logsDir, "server.trace.ndjson"), @@ -140,7 +149,11 @@ export const ensureServerDirectories = Effect.fn(function* (derivedPaths: Server fs.makeDirectory(derivedPaths.providerLogsDir, { recursive: true }), fs.makeDirectory(derivedPaths.terminalLogsDir, { recursive: true }), fs.makeDirectory(derivedPaths.attachmentsDir, { recursive: true }), + fs.makeDirectory(derivedPaths.browserArtifactsDir, { recursive: true }), fs.makeDirectory(derivedPaths.worktreesDir, { recursive: true }), + ...(derivedPaths.t3WorkDir === undefined + ? [] + : [fs.makeDirectory(derivedPaths.t3WorkDir, { recursive: true })]), fs.makeDirectory(path.dirname(derivedPaths.keybindingsConfigPath), { recursive: true }), fs.makeDirectory(path.dirname(derivedPaths.settingsPath), { recursive: true }), fs.makeDirectory(derivedPaths.providerStatusCacheDir, { recursive: true }), diff --git a/apps/server/src/hermes/HermesConnectionSecurity.test.ts b/apps/server/src/hermes/HermesConnectionSecurity.test.ts new file mode 100644 index 00000000000..7a0118f59cc --- /dev/null +++ b/apps/server/src/hermes/HermesConnectionSecurity.test.ts @@ -0,0 +1,116 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { + assessHermesConnectionSecurity, + sanitizeHermesEndpoint, +} from "./HermesConnectionSecurity.ts"; + +const fingerprint = "ab:".repeat(31) + "ab"; + +const assess = (overrides: Partial[0]> = {}) => + assessHermesConnectionSecurity({ + endpoint: "ws://127.0.0.1:9119/api/ws", + gatewayToken: "local-token", + remoteGloballyEnabled: false, + remoteInstanceEnabled: false, + remotePairingToken: undefined, + remoteTlsCertificateSha256: undefined, + ...overrides, + }); + +describe("Hermes connection security", () => { + it("preserves authenticated loopback ws behavior", () => { + expect(assess()).toMatchObject({ + status: "ready", + scope: "loopback", + endpoint: "ws://127.0.0.1:9119/api/ws", + authToken: "local-token", + }); + }); + + it.each([ + "http://gateway.example.com/api/ws", + "https://gateway.example.com/api/ws", + "ws://gateway.example.com/api/ws", + "wss://user:secret@gateway.example.com/api/ws", + "wss://gateway.example.com/api/ws?TOKEN=secret", + ])("rejects an insecure or credential-bearing remote endpoint: %s", (endpoint) => { + expect( + assess({ + endpoint, + remoteGloballyEnabled: true, + remoteInstanceEnabled: true, + }), + ).toMatchObject({ status: "blocked", code: "invalid_endpoint" }); + }); + + it("requires independent global and instance remote opt-ins", () => { + expect(assess({ endpoint: "wss://gateway.example.com/api/ws" })).toMatchObject({ + status: "blocked", + code: "remote_disabled", + }); + expect( + assess({ + endpoint: "wss://gateway.example.com/api/ws", + remoteGloballyEnabled: true, + }), + ).toMatchObject({ status: "blocked", code: "remote_instance_disabled" }); + }); + + it("requires dedicated pairing and explicit certificate trust material", () => { + expect( + assess({ + endpoint: "wss://gateway.example.com/api/ws", + remoteGloballyEnabled: true, + remoteInstanceEnabled: true, + }), + ).toMatchObject({ status: "blocked", code: "remote_pairing_required" }); + + expect( + assess({ + endpoint: "wss://gateway.example.com/api/ws", + remoteGloballyEnabled: true, + remoteInstanceEnabled: true, + remotePairingToken: "local-token", + }), + ).toMatchObject({ status: "blocked", code: "remote_credential_reuse" }); + + expect( + assess({ + endpoint: "wss://gateway.example.com/api/ws", + remoteGloballyEnabled: true, + remoteInstanceEnabled: true, + remotePairingToken: "dedicated-pairing-token", + remoteTlsCertificateSha256: "not-a-fingerprint", + }), + ).toMatchObject({ status: "blocked", code: "remote_trust_required" }); + }); + + it("reports remote unsupported before creating a transport even when fully configured", () => { + const result = assess({ + endpoint: "wss://gateway.example.com/api/ws?tenant=private", + remoteGloballyEnabled: true, + remoteInstanceEnabled: true, + remotePairingToken: "dedicated-pairing-token", + remoteTlsCertificateSha256: fingerprint, + }); + + expect(result).toMatchObject({ + status: "unsupported", + code: "remote_verification_unsupported", + diagnosticEndpoint: "wss://gateway.example.com/api/ws?tenant=%3Credacted%3E", + }); + expect(JSON.stringify(result)).not.toContain("private"); + expect(JSON.stringify(result)).not.toContain("dedicated-pairing-token"); + expect(JSON.stringify(result)).not.toContain(fingerprint); + }); + + it("sanitizes all query values, userinfo, and fragments in diagnostics", () => { + const sanitized = sanitizeHermesEndpoint( + "wss://user:password@gateway.example.com/api/ws?token=secret&workspace=private#fragment", + ); + expect(sanitized).toBe( + "wss://gateway.example.com/api/ws?token=%3Credacted%3E&workspace=%3Credacted%3E", + ); + }); +}); diff --git a/apps/server/src/hermes/HermesConnectionSecurity.ts b/apps/server/src/hermes/HermesConnectionSecurity.ts new file mode 100644 index 00000000000..c5a8ccb6e2f --- /dev/null +++ b/apps/server/src/hermes/HermesConnectionSecurity.ts @@ -0,0 +1,189 @@ +export const HERMES_REMOTE_PAIRING_TOKEN_ENV = "HERMES_REMOTE_PAIRING_TOKEN"; +export const HERMES_REMOTE_TLS_CERT_SHA256_ENV = "HERMES_REMOTE_TLS_CERT_SHA256"; + +export type HermesEndpointScope = "loopback" | "remote"; + +export type HermesConnectionSecurityCode = + | "invalid_endpoint" + | "remote_disabled" + | "remote_instance_disabled" + | "remote_pairing_required" + | "remote_trust_required" + | "remote_credential_reuse" + | "remote_verification_unsupported"; + +export type HermesConnectionSecurityAssessment = + | { + readonly status: "ready"; + readonly scope: "loopback"; + readonly endpoint: string; + readonly diagnosticEndpoint: string; + readonly authToken: string; + } + | { + readonly status: "blocked" | "unsupported"; + readonly scope: HermesEndpointScope | undefined; + readonly code: HermesConnectionSecurityCode; + readonly diagnosticEndpoint: string; + readonly message: string; + }; + +export interface HermesConnectionSecurityInput { + readonly endpoint: string; + readonly gatewayToken: string | undefined; + readonly remoteGloballyEnabled: boolean; + readonly remoteInstanceEnabled: boolean; + readonly remotePairingToken: string | undefined; + readonly remoteTlsCertificateSha256: string | undefined; +} + +const LOOPBACK_HOSTS = new Set(["127.0.0.1", "localhost", "::1"]); +const SHA256_FINGERPRINT = /^(?:[0-9a-f]{64}|(?:[0-9a-f]{2}:){31}[0-9a-f]{2})$/iu; + +export function sanitizeHermesEndpoint(endpoint: string): string { + let parsed: URL; + try { + parsed = new URL(endpoint); + } catch { + return ""; + } + parsed.username = ""; + parsed.password = ""; + parsed.hash = ""; + for (const key of new Set(parsed.searchParams.keys())) { + parsed.searchParams.set(key, ""); + } + return parsed.toString(); +} + +export function hermesEndpointScope(endpoint: string): HermesEndpointScope | undefined { + try { + return LOOPBACK_HOSTS.has(new URL(endpoint).hostname) ? "loopback" : "remote"; + } catch { + return undefined; + } +} + +export function isRemoteHermesEndpoint(endpoint: string): boolean { + return hermesEndpointScope(endpoint) === "remote"; +} + +export function assessHermesConnectionSecurity( + input: HermesConnectionSecurityInput, +): HermesConnectionSecurityAssessment { + const diagnosticEndpoint = sanitizeHermesEndpoint(input.endpoint); + let endpoint: URL; + try { + endpoint = new URL(input.endpoint); + } catch { + return blocked( + "invalid_endpoint", + undefined, + diagnosticEndpoint, + "Hermes endpoint must be a valid WebSocket URL.", + ); + } + + const scope: HermesEndpointScope = LOOPBACK_HOSTS.has(endpoint.hostname) ? "loopback" : "remote"; + const hasQueryCredential = [...endpoint.searchParams.keys()].some( + (key) => key.toLowerCase() === "token", + ); + if ( + endpoint.username || + endpoint.password || + endpoint.hash || + hasQueryCredential || + (scope === "loopback" ? endpoint.protocol !== "ws:" : endpoint.protocol !== "wss:") + ) { + return blocked( + "invalid_endpoint", + scope, + diagnosticEndpoint, + scope === "loopback" + ? "Loopback Hermes endpoints must use credential-free ws://." + : "Remote Hermes endpoints must use credential-free wss://.", + ); + } + + if (scope === "loopback") { + const gatewayToken = input.gatewayToken?.trim(); + if (!gatewayToken) { + return blocked( + "invalid_endpoint", + scope, + diagnosticEndpoint, + "Loopback Hermes requires a sensitive HERMES_GATEWAY_TOKEN.", + ); + } + return { + status: "ready", + scope, + endpoint: endpoint.toString(), + diagnosticEndpoint, + authToken: gatewayToken, + }; + } + + if (!input.remoteGloballyEnabled) { + return blocked( + "remote_disabled", + scope, + diagnosticEndpoint, + "Remote Hermes is disabled by the independent server kill switch.", + ); + } + if (!input.remoteInstanceEnabled) { + return blocked( + "remote_instance_disabled", + scope, + diagnosticEndpoint, + "This Hermes instance has not explicitly enabled remote access.", + ); + } + + const pairingToken = input.remotePairingToken?.trim(); + if (!pairingToken) { + return blocked( + "remote_pairing_required", + scope, + diagnosticEndpoint, + `Remote Hermes requires a dedicated sensitive ${HERMES_REMOTE_PAIRING_TOKEN_ENV}.`, + ); + } + if (pairingToken === input.gatewayToken?.trim()) { + return blocked( + "remote_credential_reuse", + scope, + diagnosticEndpoint, + "Remote Hermes pairing material must be distinct from the local gateway credential.", + ); + } + + const fingerprint = input.remoteTlsCertificateSha256?.trim(); + if (!fingerprint || !SHA256_FINGERPRINT.test(fingerprint)) { + return blocked( + "remote_trust_required", + scope, + diagnosticEndpoint, + `Remote Hermes requires an explicit SHA-256 certificate fingerprint in ${HERMES_REMOTE_TLS_CERT_SHA256_ENV}.`, + ); + } + + return { + status: "unsupported", + scope, + code: "remote_verification_unsupported", + diagnosticEndpoint, + message: + "Remote Hermes is configured but unsupported: the current gateway/WebSocket transport cannot prove scoped pairing or verify the configured TLS certificate fingerprint.", + }; +} + +function blocked( + code: HermesConnectionSecurityCode, + scope: HermesEndpointScope | undefined, + diagnosticEndpoint: string, + message: string, +): HermesConnectionSecurityAssessment { + return { status: "blocked", scope, code, diagnosticEndpoint, message }; +} diff --git a/apps/server/src/hermes/HermesCron.test.ts b/apps/server/src/hermes/HermesCron.test.ts new file mode 100644 index 00000000000..5ad0b30a807 --- /dev/null +++ b/apps/server/src/hermes/HermesCron.test.ts @@ -0,0 +1,416 @@ +import { + HermesCronError, + ProviderInstanceConfigMap, + type HermesGatewayCompatibility, + type HermesGatewayCronListResult, + type HermesGatewayCronMutationResult, +} from "@t3tools/contracts"; +import { describe, expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; + +import * as ServerSettings from "../serverSettings.ts"; +import { + makeHermesCron, + projectHermesCronCapabilities, + projectHermesCronJob, +} from "./HermesCron.ts"; +import { + HermesGatewayConfigurationError, + HermesGatewayDuplicateOperationIdError, +} from "./HermesGatewayClient.ts"; + +const decodeProviderInstanceConfigMap = Schema.decodeUnknownSync(ProviderInstanceConfigMap); + +describe("HermesCron projection", () => { + it("keeps pinned legacy gateways limited to evidenced operations", () => { + expect( + projectHermesCronCapabilities({ + status: "legacy", + protocol: null, + inventory: null, + capabilities: ["cron.read", "cron.manage"], + reason: "legacy", + }), + ).toEqual({ + inventory: true, + create: true, + edit: false, + pause: false, + resume: false, + delete: true, + runNow: false, + }); + }); + + it("fails closed for unsupported gateways even when capabilities are advertised", () => { + expect( + projectHermesCronCapabilities({ + status: "unsupported", + protocol: null, + inventory: { + "cron.read": "supported", + "cron.manage": { operations: ["add", "remove", "update", "pause", "resume", "run"] }, + }, + capabilities: ["cron.read", "cron.manage"], + reason: "unsupported", + }), + ).toEqual({ + inventory: false, + create: false, + edit: false, + pause: false, + resume: false, + delete: false, + runNow: false, + }); + }); + + it("enables extension mutations only from advertised granular operations", () => { + expect( + projectHermesCronCapabilities({ + status: "supported", + protocol: { major: 1, minor: 2 }, + inventory: { + "cron.read": "supported", + "cron.manage": { operations: ["add", "remove", "update", "pause", "resume", "run"] }, + }, + capabilities: ["cron.read", "cron.manage"], + reason: "supported", + }), + ).toEqual({ + inventory: true, + create: true, + edit: true, + pause: true, + resume: true, + delete: true, + runNow: true, + }); + }); + + it("projects provenance and deterministically deduplicates cron executions", () => { + const job = projectHermesCronJob( + "hermes_work", + "work", + { + id: "job-1", + name: "Daily check", + schedule: "0 9 * * *", + prompt: "Check status", + enabled: true, + executions: [ + { run_id: "run-1", cursor: 4, status: "complete", started_at: "2026-01-01" }, + { run_id: "run-1", cursor: 4, status: "complete", started_at: "2026-01-01" }, + { status: "failed", started_at: "2026-01-02" }, + ], + }, + 0, + ); + + expect(job.identity).toBe("job-1"); + expect(job.executions).toHaveLength(2); + expect(job.executions[0]).toMatchObject({ + dedupeKey: "hermes-run:run-1", + provenance: { + scheduler: "hermes", + providerInstanceId: "hermes_work", + profileKey: "work", + jobIdentity: "job-1", + upstreamRunId: "run-1", + upstreamCursor: 4, + identityStrength: "upstream", + }, + }); + expect(job.executions[1]?.dedupeKey).toMatch(/^hermes-derived:/u); + }); + + it("marks jobs without upstream id or name as unaddressable", () => { + const first = projectHermesCronJob( + "hermes", + "default", + { schedule: "0 0 * * *", prompt: "x" }, + 2, + ); + const second = projectHermesCronJob( + "hermes", + "default", + { schedule: "0 0 * * *", prompt: "x" }, + 2, + ); + expect(first.identityStrength).toBe("missing"); + expect(first.identity).toBe(second.identity); + }); +}); + +describe("HermesCron mutate", () => { + const compatibility: HermesGatewayCompatibility = { + status: "supported", + protocol: { major: 1, minor: 2 }, + inventory: { + "cron.read": "supported", + "cron.manage": { operations: ["add", "remove", "update", "pause", "resume", "run"] }, + }, + capabilities: ["cron.read", "cron.manage"], + reason: "supported", + }; + + const settingsLayer = ServerSettings.layerTest({ + enableHermes: true, + providerInstances: decodeProviderInstanceConfigMap({ + hermes_main: { + driver: "hermes", + displayName: "Hermes", + enabled: true, + environment: [{ name: "HERMES_GATEWAY_TOKEN", value: "token-1", sensitive: true }], + config: { enabled: true, endpoint: "ws://127.0.0.1:9119/api/ws", profileKey: "work" }, + }, + }), + }); + + const runMutation = (listCronJobs: () => Promise) => + Effect.gen(function* () { + const cron = yield* makeHermesCron({ + clientFactory: () => ({ + compatibility, + connect: () => Promise.resolve(compatibility), + hasCapability: () => true, + listCronJobs, + manageCron: () => + Promise.resolve({ + success: true, + job_id: "job-1", + run_id: "run-1", + } satisfies HermesGatewayCronMutationResult), + close: () => {}, + }), + }); + return yield* cron.mutate({ + providerInstanceId: "hermes_main", + operation: "create", + operationId: "op-1", + name: "Daily check", + schedule: "0 9 * * *", + prompt: "Check status", + }); + }).pipe(Effect.provide(settingsLayer)); + + it.effect("returns the confirmed mutation when the follow-up inventory refresh fails", () => + Effect.gen(function* () { + const response = yield* runMutation(() => Promise.reject(new Error("refresh failed"))); + expect(response.upstreamJobId).toBe("job-1"); + expect(response.upstreamRunId).toBe("run-1"); + expect(response.provider.status).toBe("error"); + expect(response.provider.diagnostics).toContain( + "Cron mutation succeeded, but the follow-up cron inventory refresh failed.", + ); + }), + ); + + it.effect("projects the refreshed inventory when the follow-up read succeeds", () => + Effect.gen(function* () { + const response = yield* runMutation(() => + Promise.resolve({ success: true, jobs: [{ id: "job-1", name: "Daily check" }] }), + ); + expect(response.upstreamJobId).toBe("job-1"); + expect(response.provider.status).toBe("ready"); + expect(response.provider.jobs.map((job) => job.id)).toEqual(["job-1"]); + }), + ); + + it.effect("projects a throwing client factory as a per-provider error in list", () => + Effect.gen(function* () { + const cron = yield* makeHermesCron({ + clientFactory: () => { + throw new Error("factory blocked"); + }, + }); + const result = yield* cron.list(); + const main = result.providers.find( + (candidate) => candidate.providerInstanceId === "hermes_main", + ); + expect(main).toMatchObject({ + status: "error", + capabilities: { inventory: false, create: false }, + jobs: [], + }); + }).pipe(Effect.provide(settingsLayer)), + ); + + it.effect("projects an unsuccessful cron inventory response as a provider error", () => + Effect.gen(function* () { + const cron = yield* makeHermesCron({ + clientFactory: () => ({ + compatibility, + connect: () => Promise.resolve(compatibility), + hasCapability: () => true, + listCronJobs: () => + Promise.resolve({ success: false, jobs: [] } satisfies HermesGatewayCronListResult), + manageCron: () => Promise.reject(new Error("unused")), + close: () => {}, + }), + }); + const result = yield* cron.list(); + const main = result.providers.find( + (candidate) => candidate.providerInstanceId === "hermes_main", + ); + expect(main).toMatchObject({ status: "error", jobs: [] }); + expect(main?.diagnostics).toContain( + "Hermes gateway reported an unsuccessful cron inventory response.", + ); + }).pipe(Effect.provide(settingsLayer)), + ); + + it.effect("reuses one gateway client so a repeated operation id cannot replay", () => + Effect.gen(function* () { + let factoryCalls = 0; + let executedMutations = 0; + const usedOperationIds = new Set(); + const cron = yield* makeHermesCron({ + clientFactory: () => { + factoryCalls += 1; + return { + compatibility, + connect: () => Promise.resolve(compatibility), + hasCapability: () => true, + listCronJobs: () => + Promise.resolve({ success: true, jobs: [] } satisfies HermesGatewayCronListResult), + manageCron: (_params, options) => { + if (usedOperationIds.has(options.operationId)) { + return Promise.reject( + new HermesGatewayDuplicateOperationIdError( + `Hermes mutation operationId has already been used: ${options.operationId}`, + ), + ); + } + usedOperationIds.add(options.operationId); + executedMutations += 1; + return Promise.resolve({ + success: true, + job_id: "job-1", + } satisfies HermesGatewayCronMutationResult); + }, + close: () => {}, + }; + }, + }); + const input = { + providerInstanceId: "hermes_main", + operation: "run_now", + operationId: "op-repeated", + jobIdentity: "job-1", + } as const; + const first = yield* cron.mutate(input); + expect(first.upstreamJobId).toBe("job-1"); + const failure = yield* cron.mutate(input).pipe(Effect.flip); + expect(failure.code).toBe("invalid_input"); + expect(factoryCalls).toBe(1); + expect(executedMutations).toBe(1); + }).pipe(Effect.provide(settingsLayer)), + ); + + it.effect("closes and evicts the stale client when the connection identity changes", () => + Effect.gen(function* () { + let factoryCalls = 0; + const closedTokens: Array = []; + const cron = yield* makeHermesCron({ + clientFactory: ({ authToken }) => { + factoryCalls += 1; + return { + compatibility, + connect: () => Promise.resolve(compatibility), + hasCapability: () => true, + listCronJobs: () => + Promise.resolve({ success: true, jobs: [] } satisfies HermesGatewayCronListResult), + manageCron: () => + Promise.resolve({ + success: true, + job_id: "job-1", + } satisfies HermesGatewayCronMutationResult), + close: () => { + closedTokens.push(authToken); + }, + }; + }, + }); + const input = (operationId: string) => + ({ + providerInstanceId: "hermes_main", + operation: "run_now", + operationId, + jobIdentity: "job-1", + }) as const; + yield* cron.mutate(input("op-a")); + expect(factoryCalls).toBe(1); + expect(closedTokens).toEqual([]); + + const settingsService = yield* ServerSettings.ServerSettingsService; + yield* settingsService.updateSettings({ + providerInstances: decodeProviderInstanceConfigMap({ + hermes_main: { + driver: "hermes", + displayName: "Hermes", + enabled: true, + environment: [{ name: "HERMES_GATEWAY_TOKEN", value: "token-2", sensitive: true }], + config: { enabled: true, endpoint: "ws://127.0.0.1:9119/api/ws", profileKey: "work" }, + }, + }), + }); + yield* cron.mutate(input("op-b")); + expect(factoryCalls).toBe(2); + expect(closedTokens).toEqual(["token-1"]); + }).pipe(Effect.provide(settingsLayer)), + ); + + it.effect("maps non-duplicate configuration errors to a gateway diagnostic", () => + Effect.gen(function* () { + const cron = yield* makeHermesCron({ + clientFactory: () => ({ + compatibility, + connect: () => Promise.resolve(compatibility), + hasCapability: () => true, + listCronJobs: () => + Promise.resolve({ success: true, jobs: [] } satisfies HermesGatewayCronListResult), + manageCron: () => + Promise.reject( + new HermesGatewayConfigurationError("Hermes remote access is not paired."), + ), + close: () => {}, + }), + }); + const failure = yield* cron + .mutate({ + providerInstanceId: "hermes_main", + operation: "run_now", + operationId: "op-config", + jobIdentity: "job-1", + }) + .pipe(Effect.flip); + expect(failure.code).toBe("gateway_error"); + expect(failure.message).toContain("Hermes remote access is not paired."); + expect(failure.message).not.toContain("already used"); + }).pipe(Effect.provide(settingsLayer)), + ); + + it.effect("maps a throwing client factory to a typed error in mutate", () => + Effect.gen(function* () { + const cron = yield* makeHermesCron({ + clientFactory: () => { + throw new Error("factory blocked"); + }, + }); + const failure = yield* cron + .mutate({ + providerInstanceId: "hermes_main", + operation: "create", + operationId: "op-1", + name: "Daily check", + schedule: "0 9 * * *", + prompt: "Check status", + }) + .pipe(Effect.flip); + expect(failure).toBeInstanceOf(HermesCronError); + expect(failure.code).toBe("gateway_error"); + }).pipe(Effect.provide(settingsLayer)), + ); +}); diff --git a/apps/server/src/hermes/HermesCron.ts b/apps/server/src/hermes/HermesCron.ts new file mode 100644 index 00000000000..d1d5e65326e --- /dev/null +++ b/apps/server/src/hermes/HermesCron.ts @@ -0,0 +1,531 @@ +import { + HermesCronError, + type HermesCronCapabilities, + type HermesCronExecution, + type HermesCronJob, + type HermesCronListResult, + type HermesCronMutationInput, + type HermesCronMutationResponse, + type HermesCronProviderProjection, + type HermesGatewayCompatibility, + type HermesGatewayCronJob, + type HermesGatewayCronListResult, + type HermesGatewayCronMutationResult, +} from "@t3tools/contracts"; +import * as NodeCrypto from "node:crypto"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Schema from "effect/Schema"; + +import * as ServerSettings from "../serverSettings.ts"; +import { + HermesGatewayClient, + HermesGatewayConfigurationError, + HermesGatewayDuplicateOperationIdError, + HermesGatewayMutationIndeterminateError, + HermesGatewayMutationsBlockedError, + type HermesGatewayMutationOptions, + type HermesGatewayReadOptions, +} from "./HermesGatewayClient.ts"; +import { + hermesManageActionInventory, + resolveHermesProviderConnections, + type HermesProviderConnection, +} from "./HermesProviderDirectory.ts"; + +interface HermesCronGatewayClient { + readonly compatibility: HermesGatewayCompatibility | undefined; + connect(): Promise; + hasCapability(capability: string): boolean; + listCronJobs( + options?: Omit, + ): Promise; + manageCron( + params: Record, + options: Omit, + ): Promise; + close(): void; +} + +type HermesCronProviderConfig = HermesProviderConnection; + +export interface HermesCronOptions { + readonly clientFactory?: (input: { + readonly endpoint: string; + readonly authToken: string; + }) => HermesCronGatewayClient; +} + +export interface HermesCronShape { + readonly list: () => Effect.Effect; + readonly mutate: ( + input: HermesCronMutationInput, + ) => Effect.Effect; +} + +export class HermesCron extends Context.Service()( + "t3/hermes/HermesCron", +) {} + +const isHermesCronError = Schema.is(HermesCronError); +const digest = (value: unknown): string => + NodeCrypto.createHash("sha256").update(JSON.stringify(value)).digest("hex"); + +const record = (value: unknown): Record | undefined => + value !== null && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : undefined; +const string = (value: unknown): string | undefined => + typeof value === "string" && value.length > 0 ? value : undefined; +const stringOrNumber = (value: unknown): string | number | undefined => + typeof value === "string" || typeof value === "number" ? value : undefined; + +export function projectHermesCronCapabilities( + compatibility: HermesGatewayCompatibility, +): HermesCronCapabilities { + if (compatibility.status === "unsupported") { + return { + inventory: false, + create: false, + edit: false, + pause: false, + resume: false, + delete: false, + runNow: false, + }; + } + const capabilities = new Set(compatibility.capabilities); + // The gateway client only accepts cron.read for list and cron.manage for + // mutations, so the projection must not enable operations from granular + // aliases or legacy status that the client would reject. + const manage = capabilities.has("cron.manage"); + const inventoried = hermesManageActionInventory(compatibility, "cron.manage"); + // Pinned legacy gateways have no negotiated action inventory; only the + // evidenced list/add/remove operations are enabled for them. + const actions = + inventoried.size > 0 + ? inventoried + : compatibility.status === "legacy" + ? new Set(["add", "remove"]) + : inventoried; + const allows = (...names: ReadonlyArray) => + manage && (actions.size === 0 || names.some((name) => actions.has(name))); + return { + inventory: capabilities.has("cron.read"), + create: allows("add", "create"), + edit: allows("update", "edit"), + pause: allows("pause"), + resume: allows("resume"), + delete: allows("remove", "delete"), + runNow: allows("run", "run_now", "run-now"), + }; +} + +function projectExecution( + input: { + readonly providerInstanceId: string; + readonly profileKey: string; + readonly jobIdentity: string; + }, + value: unknown, +): HermesCronExecution | null { + const row = record(value); + if (!row) return null; + const upstreamRunId = string(row.run_id) ?? string(row.id) ?? null; + const upstreamCursor = stringOrNumber(row.cursor) ?? stringOrNumber(row.sequence) ?? null; + const startedAt = + stringOrNumber(row.started_at) ?? + stringOrNumber(row.startedAt) ?? + stringOrNumber(row.created_at) ?? + null; + const completedAt = + stringOrNumber(row.completed_at) ?? + stringOrNumber(row.completedAt) ?? + stringOrNumber(row.finished_at) ?? + null; + const status = string(row.status) ?? null; + const stableFields = { + jobIdentity: input.jobIdentity, + upstreamRunId, + upstreamCursor, + startedAt, + completedAt, + status, + }; + return { + dedupeKey: upstreamRunId + ? `hermes-run:${upstreamRunId}` + : `hermes-derived:${digest(stableFields)}`, + status, + startedAt, + completedAt, + provenance: { + scheduler: "hermes", + providerInstanceId: input.providerInstanceId, + profileKey: input.profileKey, + jobIdentity: input.jobIdentity, + upstreamRunId, + upstreamCursor, + identityStrength: upstreamRunId || upstreamCursor !== null ? "upstream" : "derived", + }, + }; +} + +export function projectHermesCronJob( + providerInstanceId: string, + profileKey: string, + job: HermesGatewayCronJob, + ordinal: number, +): HermesCronJob { + const id = job.id?.trim() || null; + const name = job.name?.trim() || null; + const identity = id ?? name ?? `unaddressable:${digest([job.schedule, job.prompt, ordinal])}`; + const executionRows = job.executions ?? job.runs ?? job.history ?? []; + const deduped = new Map(); + for (const value of executionRows) { + const projected = projectExecution( + { providerInstanceId, profileKey, jobIdentity: identity }, + value, + ); + if (projected) deduped.set(projected.dedupeKey, projected); + } + return { + identity, + identityStrength: id ? "id" : name ? "name" : "missing", + id, + name, + schedule: job.schedule ?? null, + prompt: job.prompt ?? null, + enabled: job.enabled ?? (job.paused === undefined ? null : !job.paused), + nextRunAt: job.next_run_at ?? null, + lastRunAt: job.last_run_at ?? null, + executions: [...deduped.values()], + }; +} + +function projectProvider(input: { + readonly config: HermesCronProviderConfig; + readonly compatibility: HermesGatewayCompatibility; + readonly result: HermesGatewayCronListResult; +}): HermesCronProviderProjection { + if (!input.result.success) { + return { + providerInstanceId: input.config.providerInstanceId, + displayName: input.config.displayName, + profileKey: input.config.profileKey, + status: "error", + protocolClassification: input.compatibility.status, + capabilities: projectHermesCronCapabilities(input.compatibility), + jobs: [], + diagnostics: ["Hermes gateway reported an unsuccessful cron inventory response."], + }; + } + const diagnostics: string[] = []; + const jobs = input.result.jobs.map((job, index) => + projectHermesCronJob(input.config.providerInstanceId, input.config.profileKey, job, index), + ); + const missingIdentity = jobs.filter((job) => job.identityStrength === "missing").length; + if (missingIdentity > 0) { + diagnostics.push( + `${missingIdentity} cron job(s) have no upstream id or name and cannot be safely mutated.`, + ); + } + if (!jobs.some((job) => job.executions.some((run) => run.provenance.upstreamCursor !== null))) { + diagnostics.push("Hermes does not expose a durable global cron execution cursor."); + } + if (input.compatibility.status === "legacy") { + diagnostics.push( + "Gateway capabilities are not negotiated; only pinned list/add/remove operations are enabled.", + ); + } + return { + providerInstanceId: input.config.providerInstanceId, + displayName: input.config.displayName, + profileKey: input.config.profileKey, + status: "ready", + protocolClassification: input.compatibility.status, + capabilities: projectHermesCronCapabilities(input.compatibility), + jobs, + diagnostics, + }; +} + +const unavailableProjection = ( + providerInstanceId: string, + displayName: string, + profileKey: string, + diagnostic: string, + status: "unavailable" | "error" = "unavailable", +): HermesCronProviderProjection => ({ + providerInstanceId, + displayName, + profileKey, + status, + protocolClassification: null, + capabilities: { + inventory: false, + create: false, + edit: false, + pause: false, + resume: false, + delete: false, + runNow: false, + }, + jobs: [], + diagnostics: [diagnostic], +}); + +function mutationCapability( + capabilities: HermesCronCapabilities, + operation: HermesCronMutationInput["operation"], +): boolean { + switch (operation) { + case "create": + return capabilities.create; + case "edit": + return capabilities.edit; + case "pause": + return capabilities.pause; + case "resume": + return capabilities.resume; + case "delete": + return capabilities.delete; + case "run_now": + return capabilities.runNow; + } +} + +function mutationParams(input: HermesCronMutationInput): Record { + switch (input.operation) { + case "create": + return { action: "add", name: input.name, schedule: input.schedule, prompt: input.prompt }; + case "edit": + return { + action: "update", + name: input.jobIdentity, + ...(input.name === undefined ? {} : { new_name: input.name }), + ...(input.schedule === undefined ? {} : { schedule: input.schedule }), + ...(input.prompt === undefined ? {} : { prompt: input.prompt }), + }; + case "pause": + return { action: "pause", name: input.jobIdentity }; + case "resume": + return { action: "resume", name: input.jobIdentity }; + case "delete": + return { action: "remove", name: input.jobIdentity }; + case "run_now": + return { action: "run", name: input.jobIdentity }; + } +} + +export const makeHermesCron = Effect.fn("HermesCron.make")(function* ( + options: HermesCronOptions = {}, +) { + const settingsService = yield* ServerSettings.ServerSettingsService; + const clientFactory = + options.clientFactory ?? + ((input: { readonly endpoint: string; readonly authToken: string }) => + new HermesGatewayClient(input)); + // Clients are shared per connection so mutation operationId fences survive + // across cron calls instead of dying with a per-call client. The fence only + // has to survive for the same connection identity: when an instance's + // endpoint or token changes, the superseded client is closed and evicted so + // stale connections do not accumulate. + const clients = new Map< + string, + { readonly connectionKey: string; readonly client: HermesCronGatewayClient } + >(); + const sharedClient = (config: HermesCronProviderConfig): HermesCronGatewayClient => { + const connectionKey = `${config.endpoint}\u0000${config.token}`; + const existing = clients.get(config.providerInstanceId); + if (existing !== undefined && existing.connectionKey === connectionKey) { + return existing.client; + } + existing?.client.close(); + const client = clientFactory({ endpoint: config.endpoint, authToken: config.token }); + clients.set(config.providerInstanceId, { connectionKey, client }); + return client; + }; + + const configuredProviders = Effect.fn("HermesCron.configuredProviders")(function* () { + const settings = yield* settingsService.getSettings.pipe( + Effect.mapError( + () => + new HermesCronError({ + code: "gateway_error", + message: "Could not read Hermes provider settings.", + }), + ), + ); + const directory = resolveHermesProviderConnections(settings); + return { + ready: directory.ready, + unavailable: directory.unavailable.map((provider) => + unavailableProjection( + provider.providerInstanceId, + provider.displayName, + provider.profileKey, + provider.diagnostic, + ), + ), + }; + }); + + const loadProvider = Effect.fn("HermesCron.loadProvider")(function* ( + config: HermesCronProviderConfig, + ) { + return yield* Effect.tryPromise({ + try: async () => { + const client = sharedClient(config); + const compatibility = await client.connect(); + const capabilities = projectHermesCronCapabilities(compatibility); + if (!capabilities.inventory) { + return unavailableProjection( + config.providerInstanceId, + config.displayName, + config.profileKey, + "Gateway does not advertise cron.read.", + ); + } + const result = await client.listCronJobs(); + return projectProvider({ config, compatibility, result }); + }, + catch: () => + new HermesCronError({ + code: "gateway_error", + providerInstanceId: config.providerInstanceId, + message: "Could not read native Hermes cron inventory.", + }), + }).pipe( + Effect.catch((error) => + Effect.succeed( + unavailableProjection( + config.providerInstanceId, + config.displayName, + config.profileKey, + error.message, + "error", + ), + ), + ), + ); + }); + + const list: HermesCronShape["list"] = Effect.fn("HermesCron.list")(function* () { + const configured = yield* configuredProviders(); + const available = yield* Effect.forEach(configured.ready, loadProvider, { concurrency: 4 }); + return { providers: [...available, ...configured.unavailable] }; + }); + + const mutate: HermesCronShape["mutate"] = Effect.fn("HermesCron.mutate")(function* (input) { + if ( + !input.operationId.trim() || + (input.operation === "create" && + (!input.name?.trim() || !input.schedule?.trim() || !input.prompt?.trim())) || + (input.operation !== "create" && !input.jobIdentity?.trim()) + ) { + return yield* new HermesCronError({ + code: "invalid_input", + providerInstanceId: input.providerInstanceId, + operation: input.operation, + message: "Cron mutation is missing required identity or job fields.", + }); + } + const configured = yield* configuredProviders(); + const config = configured.ready.find( + (candidate) => candidate.providerInstanceId === input.providerInstanceId, + ); + if (!config) { + const known = configured.unavailable.some( + (candidate) => candidate.providerInstanceId === input.providerInstanceId, + ); + return yield* new HermesCronError({ + code: known ? "provider_unavailable" : "provider_not_found", + providerInstanceId: input.providerInstanceId, + operation: input.operation, + message: known ? "Hermes provider is unavailable." : "Hermes provider was not found.", + }); + } + + return yield* Effect.tryPromise({ + try: async () => { + const client = sharedClient(config); + const compatibility = await client.connect(); + const capabilities = projectHermesCronCapabilities(compatibility); + if (!mutationCapability(capabilities, input.operation)) { + throw new HermesCronError({ + code: "unsupported_operation", + providerInstanceId: input.providerInstanceId, + operation: input.operation, + message: `Hermes gateway does not support cron ${input.operation}.`, + }); + } + const result = await client.manageCron(mutationParams(input), { + operationId: input.operationId, + }); + const inventory = await client.listCronJobs().catch(() => null); + return { + provider: inventory + ? projectProvider({ config, compatibility, result: inventory }) + : unavailableProjection( + config.providerInstanceId, + config.displayName, + config.profileKey, + "Cron mutation succeeded, but the follow-up cron inventory refresh failed.", + "error", + ), + upstreamJobId: result.job_id ?? result.job?.id ?? null, + upstreamRunId: result.run_id ?? null, + }; + }, + catch: (cause) => { + if (isHermesCronError(cause)) return cause; + if (cause instanceof HermesGatewayMutationIndeterminateError) { + return new HermesCronError({ + code: "indeterminate", + providerInstanceId: input.providerInstanceId, + operation: input.operation, + message: "Hermes cron mutation outcome is indeterminate; automatic replay is disabled.", + }); + } + if (cause instanceof HermesGatewayMutationsBlockedError) { + return new HermesCronError({ + code: "indeterminate", + providerInstanceId: input.providerInstanceId, + operation: input.operation, + message: + "Hermes cron mutations are blocked until indeterminate operations are reconciled.", + }); + } + if (cause instanceof HermesGatewayDuplicateOperationIdError) { + return new HermesCronError({ + code: "invalid_input", + providerInstanceId: input.providerInstanceId, + operation: input.operation, + message: + "Hermes cron mutation operation id was already used; duplicate submissions are not replayed.", + }); + } + if (cause instanceof HermesGatewayConfigurationError) { + return new HermesCronError({ + code: "gateway_error", + providerInstanceId: input.providerInstanceId, + operation: input.operation, + message: `Hermes cron gateway is not configured correctly: ${cause.message}`, + }); + } + return new HermesCronError({ + code: "gateway_error", + providerInstanceId: input.providerInstanceId, + operation: input.operation, + message: "Hermes cron gateway operation failed.", + }); + }, + }); + }); + + return HermesCron.of({ list, mutate }); +}); + +export const layer = Layer.effect(HermesCron, makeHermesCron()); diff --git a/apps/server/src/hermes/HermesGatewayClient.test.ts b/apps/server/src/hermes/HermesGatewayClient.test.ts new file mode 100644 index 00000000000..3d54f1d6ea7 --- /dev/null +++ b/apps/server/src/hermes/HermesGatewayClient.test.ts @@ -0,0 +1,1515 @@ +// @effect-diagnostics globalDate:off globalTimers:off - Transport tests use short deterministic waits. +import { describe, expect, it } from "vite-plus/test"; + +import { + HermesGatewayCapabilityError, + HermesGatewayClient, + HermesGatewayConfigurationError, + HermesGatewayConnectionError, + HermesGatewayMutationIndeterminateError, + HermesGatewayMutationsBlockedError, + classifyHermesGatewayReady, + type HermesGatewayLogEvent, + type HermesGatewaySocket, + type HermesGatewaySocketEvent, +} from "./HermesGatewayClient.ts"; + +class FakeSocket implements HermesGatewaySocket { + readyState = 0; + readonly sent: string[] = []; + readonly closeCalls: Array<{ readonly code: number; readonly reason?: string }> = []; + readonly endpoint: string; + private readonly listeners = new Map< + "open" | "message" | "close" | "error", + Array<{ readonly listener: (event: HermesGatewaySocketEvent) => void; readonly once: boolean }> + >(); + + constructor(endpoint: string) { + this.endpoint = endpoint; + } + + send(data: string): void { + if (this.readyState !== 1) throw new Error("socket is not open"); + this.sent.push(data); + } + + close(code = 1000, reason?: string): void { + this.closeCalls.push({ code, ...(reason === undefined ? {} : { reason }) }); + if (this.readyState === 3) return; + this.readyState = 3; + this.emit("close", { code }); + } + + addEventListener( + type: "open" | "message" | "close" | "error", + listener: (event: HermesGatewaySocketEvent) => void, + options?: { readonly once?: boolean }, + ): void { + const entries = this.listeners.get(type) ?? []; + entries.push({ listener, once: options?.once === true }); + this.listeners.set(type, entries); + } + + open(): void { + this.readyState = 1; + this.emit("open", {}); + } + + receive(frame: unknown): void { + this.emit("message", { data: JSON.stringify(frame) }); + } + + fail(): void { + this.emit("error", {}); + } + + private emit( + type: "open" | "message" | "close" | "error", + event: HermesGatewaySocketEvent, + ): void { + const entries = [...(this.listeners.get(type) ?? [])]; + this.listeners.set( + type, + entries.filter((entry) => !entry.once), + ); + for (const entry of entries) entry.listener(event); + } +} + +class FakeSocketFactory { + readonly sockets: FakeSocket[] = []; + + readonly create = (endpoint: string): FakeSocket => { + const socket = new FakeSocket(endpoint); + this.sockets.push(socket); + return socket; + }; +} + +const legacyReady = { + jsonrpc: "2.0", + method: "event", + params: { + type: "gateway.ready", + payload: { skin: "default" }, + }, +} as const; + +const stableMutationReady = { + jsonrpc: "2.0", + method: "event", + params: { + type: "gateway.ready", + payload: { + protocol: { + major: 1, + minor: 0, + capabilities: { + "mutation.stable_ids": "durable-v1", + "session.lifecycle": "supported", + "turn.interrupt": "supported", + "turn.prompt": "supported", + }, + }, + }, + }, +} as const; + +const fullyNegotiatedReady = { + jsonrpc: "2.0", + method: "event", + params: { + type: "gateway.ready", + payload: { + protocol: { + major: 1, + minor: 0, + capabilities: Object.fromEntries( + [ + "session.lifecycle", + "session.history", + "session.title", + "session.branch.latest", + "turn.prompt", + "turn.interrupt", + "commands.catalog", + "models.inventory", + "reasoning.effective_state", + "attachments.image", + "attachments.file", + "attachments.pdf", + "cron.read", + "cron.manage", + "profile.import", + ].map((capability) => [capability, "supported"]), + ), + }, + }, + }, +} as const; + +function success(id: string, result: unknown): unknown { + return { jsonrpc: "2.0", id, result }; +} + +function sentFrames(socket: FakeSocket): Array<{ + readonly id: string; + readonly method: string; + readonly params: Record; +}> { + return socket.sent.map((frame) => JSON.parse(frame)); +} + +async function openClient( + factory: FakeSocketFactory, + options: Partial[0]> = {}, + readyFrame: unknown = fullyNegotiatedReady, +): Promise<{ readonly client: HermesGatewayClient; readonly socket: FakeSocket }> { + const client = new HermesGatewayClient({ + endpoint: "ws://127.0.0.1:9119/api/ws", + authToken: "private-token", + socketFactory: factory.create, + reconnect: { maxAttempts: 0 }, + ...options, + }); + const connecting = client.connect(); + await Promise.resolve(); + const socket = factory.sockets[0]!; + socket.open(); + await Promise.resolve(); + socket.receive(readyFrame); + await connecting; + return { client, socket }; +} + +describe("HermesGatewayClient transport security", () => { + it("registers and revokes an ephemeral session MCP lease", async () => { + const factory = new FakeSocketFactory(); + const { client, socket } = await openClient( + factory, + {}, + { + jsonrpc: "2.0", + method: "event", + params: { + type: "gateway.ready", + payload: { + protocol: { + major: 1, + minor: 0, + capabilities: { + session_mcp: "ephemeral-lease-v1", + }, + }, + }, + }, + }, + ); + + const replacing = client.replaceSessionMcp( + { + session_id: "live-1", + servers: { + "t3-code": { + url: "http://127.0.0.1:43123/mcp", + headers: { Authorization: "Bearer scoped-token" }, + }, + }, + }, + { operationId: "mcp-replace" }, + ); + let frame = sentFrames(socket).at(-1)!; + expect(frame).toMatchObject({ + method: "session.mcp.replace", + params: { + session_id: "live-1", + servers: { + "t3-code": { + url: "http://127.0.0.1:43123/mcp", + headers: { Authorization: "Bearer scoped-token" }, + }, + }, + }, + }); + socket.receive( + success(frame.id, { + lease_id: "lease-1", + generation: 1, + servers: [{ name: "t3-code", runtime_name: "tui_session_lease_t3_code" }], + tool_names: ["mcp__tui_session_lease_t3_code__delegate_task"], + scope: { session_id: "live-1", session_key: "stored-1" }, + persisted: false, + history_recorded: false, + }), + ); + await expect(replacing).resolves.toMatchObject({ lease_id: "lease-1", generation: 1 }); + + const revoking = client.revokeSessionMcp("live-1", { operationId: "mcp-revoke" }); + frame = sentFrames(socket).at(-1)!; + expect(frame).toMatchObject({ + method: "session.mcp.revoke", + params: { session_id: "live-1" }, + }); + socket.receive( + success(frame.id, { + revoked: true, + lease_id: "lease-1", + persisted: false, + }), + ); + await expect(revoking).resolves.toEqual({ + revoked: true, + lease_id: "lease-1", + persisted: false, + }); + client.close(); + }); + + it("requires authenticated loopback ws by default and never logs credentials", async () => { + expect( + () => + new HermesGatewayClient({ + endpoint: "ws://example.com/api/ws", + authToken: "private-token", + }), + ).toThrow(HermesGatewayConfigurationError); + expect( + () => + new HermesGatewayClient({ + endpoint: "ws://localhost:9119/api/ws", + authToken: "", + }), + ).toThrow(HermesGatewayConfigurationError); + + const logs: HermesGatewayLogEvent[] = []; + const factory = new FakeSocketFactory(); + const { client, socket } = await openClient(factory, { + logger: (event) => logs.push(event), + }); + const request = client.mutate( + "prompt.submit", + { session_id: "session-1", text: "PRIVATE PROMPT" }, + { operationId: "operation-1" }, + ); + const frame = sentFrames(socket)[0]!; + socket.receive(success(frame.id, { text: "PRIVATE RESULT" })); + await request; + + const serializedLogs = JSON.stringify(logs); + expect(serializedLogs).not.toContain("private-token"); + expect(serializedLogs).not.toContain("PRIVATE PROMPT"); + expect(serializedLogs).not.toContain("PRIVATE RESULT"); + expect(serializedLogs).toContain("%3Credacted%3E"); + client.close(); + }); +}); + +describe("HermesGatewayClient protocol and ordering", () => { + it("does not manufacture optional or mutating capabilities for legacy gateways", () => { + expect(classifyHermesGatewayReady(legacyReady).capabilities).toEqual([]); + }); + + it("publishes negotiated version, capability, and reconnect health", async () => { + const factory = new FakeSocketFactory(); + const client = new HermesGatewayClient({ + endpoint: "ws://localhost:9119/api/ws?label=private-value", + authToken: "private-token", + socketFactory: factory.create, + reconnect: { maxAttempts: 0 }, + }); + const health: unknown[] = []; + client.onHealthChange((snapshot) => health.push(snapshot)); + const connecting = client.connect(); + await Promise.resolve(); + const socket = factory.sockets[0]!; + socket.open(); + await Promise.resolve(); + socket.receive({ + ...stableMutationReady, + params: { + ...stableMutationReady.params, + payload: { + ...stableMutationReady.params.payload, + server_version: "1.3.0", + }, + }, + }); + await connecting; + + expect(client.health).toMatchObject({ + state: "ready", + reconnectAttempt: 0, + protocolStatus: "supported", + protocolMajor: 1, + protocolMinor: 0, + serverVersion: "1.3.0", + writesBlocked: false, + indeterminateMutationCount: 0, + }); + expect(client.health.capabilities).toEqual([ + "mutation.stable_ids", + "session.lifecycle", + "turn.interrupt", + "turn.prompt", + ]); + expect(health).toHaveLength(3); + client.close(); + }); + + it("uses the pinned cron.manage list/add/remove wire protocol", async () => { + const factory = new FakeSocketFactory(); + const { client, socket } = await openClient(factory); + + const listing = client.listCronJobs(); + let frame = sentFrames(socket).at(-1)!; + expect(frame).toMatchObject({ method: "cron.manage", params: { action: "list" } }); + socket.receive(success(frame.id, { success: true, jobs: [] })); + await expect(listing).resolves.toEqual({ success: true, jobs: [] }); + + const adding = client.manageCron( + { action: "add", name: "job", schedule: "0 0 * * *", prompt: "check" }, + { operationId: "cron-add-1" }, + ); + frame = sentFrames(socket).at(-1)!; + expect(frame).toMatchObject({ + method: "cron.manage", + params: { action: "add", name: "job", schedule: "0 0 * * *", prompt: "check" }, + }); + socket.receive(success(frame.id, { success: true, job_id: "job-1" })); + await expect(adding).resolves.toEqual({ success: true, job_id: "job-1" }); + client.close(); + }); + + it("correlates out-of-order responses while serializing events in wire order", async () => { + const factory = new FakeSocketFactory(); + const { client, socket } = await openClient(factory); + const observed: string[] = []; + let releaseFirst!: () => void; + const firstMayFinish = new Promise((resolve) => { + releaseFirst = resolve; + }); + client.onEvent(async (event) => { + observed.push(`start:${event.sessionSequence}:${event.frame.params.type}`); + if (event.frame.params.type === "message.delta") await firstMayFinish; + observed.push(`end:${event.sessionSequence}:${event.frame.params.type}`); + }); + + socket.receive({ + jsonrpc: "2.0", + method: "event", + params: { + type: "message.delta", + session_id: "session-1", + payload: { text: "first" }, + }, + }); + socket.receive({ + jsonrpc: "2.0", + method: "event", + params: { + type: "message.complete", + session_id: "session-1", + payload: { text: "second" }, + }, + }); + await eventually(() => observed.length === 1); + expect(observed).toEqual(["start:1:message.delta"]); + releaseFirst(); + await eventually(() => observed.length === 4); + expect(observed).toEqual([ + "start:1:message.delta", + "end:1:message.delta", + "start:2:message.complete", + "end:2:message.complete", + ]); + + const sessions = client.read("session.list", {}); + const history = client.read("session.history", { session_id: "session-1" }); + const frames = sentFrames(socket); + socket.receive(success(frames[1]!.id, { count: 3 })); + socket.receive(success(frames[0]!.id, { sessions: [] })); + await expect(history).resolves.toEqual({ count: 3 }); + await expect(sessions).resolves.toEqual({ sessions: [] }); + client.close(); + }); + + it("preserves negotiated event identities and rejects unsupported protocol majors", async () => { + const factory = new FakeSocketFactory(); + const client = new HermesGatewayClient({ + endpoint: "ws://localhost:9119/api/ws", + authToken: "private-token", + socketFactory: factory.create, + reconnect: { maxAttempts: 0 }, + }); + const events: unknown[] = []; + client.onEvent((event) => { + events.push(event); + }); + const connecting = client.connect(); + await Promise.resolve(); + const socket = factory.sockets[0]!; + socket.open(); + await Promise.resolve(); + socket.receive({ + jsonrpc: "2.0", + method: "event", + params: { + type: "gateway.ready", + payload: { + protocol: { + major: 1, + minor: 4, + build_revision: "upstream-revision", + capabilities: { + version: "1", + "session.lifecycle": "supported", + "event.stable_ids": "supported", + "attachments.pdf": "unsupported", + branching: { mode: "latest", stable_boundaries: false }, + }, + }, + }, + event_id: "ready-event", + event_sequence: 7, + emitted_at: "2026-07-24T00:00:00Z", + session_key: "durable-1", + run_id: "run-1", + message_id: "message-1", + }, + }); + const compatibility = await connecting; + expect(compatibility.status).toBe("supported"); + expect(client.hasCapability("event.stable_ids")).toBe(true); + expect(client.hasCapability("attachments.pdf")).toBe(false); + expect(client.hasCapability("branching")).toBe(true); + expect(compatibility.inventory).toMatchObject({ + version: "1", + branching: { mode: "latest", stable_boundaries: false }, + }); + await eventually(() => events.length === 1); + expect(events[0]).toMatchObject({ + eventId: "ready-event", + eventSequence: 7, + cursor: 7, + emittedAt: "2026-07-24T00:00:00Z", + sessionKey: "durable-1", + runId: "run-1", + messageId: "message-1", + }); + client.close(); + + const rejectedFactory = new FakeSocketFactory(); + const rejected = new HermesGatewayClient({ + endpoint: "ws://127.0.0.1:9119/api/ws", + authToken: "private-token", + socketFactory: rejectedFactory.create, + reconnect: { maxAttempts: 0 }, + }); + const rejection = rejected.connect(); + await Promise.resolve(); + rejectedFactory.sockets[0]!.open(); + await Promise.resolve(); + rejectedFactory.sockets[0]!.receive({ + jsonrpc: "2.0", + method: "event", + params: { + type: "gateway.ready", + payload: { + protocol: { + major: 2, + minor: 0, + build_revision: "future", + capabilities: { + version: "1", + "session.lifecycle": "supported", + }, + }, + }, + }, + }); + await expect(rejection).rejects.toThrow("Unsupported Hermes gateway protocol major 2"); + expect(rejectedFactory.sockets[0]!.closeCalls).toEqual([ + { code: 4002, reason: "gateway handshake failed" }, + ]); + }); + + it("degrades a missing optional RPC independently", async () => { + const logs: HermesGatewayLogEvent[] = []; + const factory = new FakeSocketFactory(); + const { client, socket } = await openClient(factory, { + logger: (event) => logs.push(event), + }); + const commands = client.read("commands.catalog", {}); + const frame = sentFrames(socket)[0]!; + socket.receive({ + jsonrpc: "2.0", + id: frame.id, + error: { code: -32601, message: "PRIVATE METHOD ERROR" }, + }); + await expect(commands).rejects.toMatchObject({ code: -32601 }); + expect(client.hasCapability("commands.catalog")).toBe(false); + expect(client.hasCapability("session.history")).toBe(true); + await expect(client.read("commands.catalog", {})).rejects.toBeInstanceOf( + HermesGatewayCapabilityError, + ); + expect(JSON.stringify(logs)).not.toContain("PRIVATE METHOD ERROR"); + client.close(); + }); + + it("degrades cron.read when the cron list read is unimplemented", async () => { + const factory = new FakeSocketFactory(); + const { client, socket } = await openClient(factory); + const listing = client.listCronJobs(); + const frame = sentFrames(socket).at(-1)!; + socket.receive({ + jsonrpc: "2.0", + id: frame.id, + error: { code: -32601, message: "method not found" }, + }); + await expect(listing).rejects.toMatchObject({ code: -32601 }); + expect(client.hasCapability("cron.read")).toBe(false); + expect(client.hasCapability("cron.manage")).toBe(true); + await expect(client.listCronJobs()).rejects.toBeInstanceOf(HermesGatewayCapabilityError); + client.close(); + }); + + it("dispatches events to remaining listeners when one listener fails", async () => { + const factory = new FakeSocketFactory(); + const { client, socket } = await openClient(factory); + const observed: string[] = []; + client.onEvent(() => { + throw new Error("listener failure"); + }); + client.onEvent(async () => { + await Promise.reject(new Error("async listener failure")); + }); + client.onEvent((event) => { + observed.push(`${event.sessionSequence}:${event.frame.params.type}`); + }); + + socket.receive({ + jsonrpc: "2.0", + method: "event", + params: { type: "message.delta", session_id: "session-1", payload: { text: "one" } }, + }); + socket.receive({ + jsonrpc: "2.0", + method: "event", + params: { type: "message.complete", session_id: "session-1", payload: { text: "two" } }, + }); + await eventually(() => observed.length === 2); + expect(observed).toEqual(["1:message.delta", "2:message.complete"]); + client.close(); + }); + + it("exposes typed H4 session and prompt helpers", async () => { + const factory = new FakeSocketFactory(); + const { client, socket } = await openClient(factory); + + const createdPromise = client.createSession( + { source: "t3-work", close_on_disconnect: false }, + { operationId: "create-operation" }, + ); + let frame = sentFrames(socket).at(-1)!; + socket.receive( + success(frame.id, { + session_id: "live-1", + stored_session_id: "durable-1", + message_count: 0, + messages: [], + info: { model: "test-model", lazy: true }, + }), + ); + await expect(createdPromise).resolves.toMatchObject({ + session_id: "live-1", + stored_session_id: "durable-1", + }); + + const resumedPromise = client.resumeSession( + { session_id: "durable-1", close_on_disconnect: false }, + { operationId: "resume-operation" }, + ); + frame = sentFrames(socket).at(-1)!; + socket.receive( + success(frame.id, { + session_id: "live-2", + resumed: "durable-1", + message_count: 1, + messages: [{ message_id: "message-restored", role: "assistant", text: "restored" }], + info: { + model: "test-model", + title_revision: 3, + title_origin: "agent", + }, + running: false, + session_key: "durable-1", + started_at: 1, + status: "idle", + }), + ); + await expect(resumedPromise).resolves.toMatchObject({ + session_id: "live-2", + session_key: "durable-1", + messages: [{ message_id: "message-restored" }], + info: { title_revision: 3, title_origin: "agent" }, + }); + + const statusPromise = client.readSessionStatus({ session_id: "live-2" }); + frame = sentFrames(socket).at(-1)!; + socket.receive(success(frame.id, { output: "sanitized status" })); + await expect(statusPromise).resolves.toEqual({ output: "sanitized status" }); + + const historyPromise = client.readSessionHistory({ session_id: "live-2" }); + frame = sentFrames(socket).at(-1)!; + socket.receive( + success(frame.id, { + count: 1, + messages: [{ role: "assistant", text: "restored" }], + }), + ); + await expect(historyPromise).resolves.toMatchObject({ count: 1 }); + + const imagePromise = client.attachImageBytes( + { + session_id: "live-2", + content_base64: "iVBORw==", + filename: "image.png", + }, + { operationId: "image-operation" }, + ); + frame = sentFrames(socket).at(-1)!; + expect(frame).toMatchObject({ + method: "image.attach_bytes", + params: { + session_id: "live-2", + content_base64: "iVBORw==", + filename: "image.png", + }, + }); + socket.receive(success(frame.id, { attached: true, count: 1 })); + await expect(imagePromise).resolves.toEqual({ attached: true, count: 1 }); + + const filePromise = client.attachFile( + { session_id: "live-2", name: "notes.txt", data_url: "data:text/plain;base64,YQ==" }, + { operationId: "file-operation" }, + ); + frame = sentFrames(socket).at(-1)!; + expect(frame).toMatchObject({ + method: "file.attach", + params: { + session_id: "live-2", + name: "notes.txt", + data_url: "data:text/plain;base64,YQ==", + }, + }); + socket.receive(success(frame.id, { attached: true })); + await expect(filePromise).resolves.toEqual({ attached: true }); + + const pdfPromise = client.attachPdf( + { session_id: "live-2", filename: "report.pdf", content_base64: "JVBERg==" }, + { operationId: "pdf-operation" }, + ); + frame = sentFrames(socket).at(-1)!; + expect(frame).toMatchObject({ + method: "pdf.attach", + params: { + session_id: "live-2", + filename: "report.pdf", + content_base64: "JVBERg==", + }, + }); + socket.receive(success(frame.id, { attached: true })); + await expect(pdfPromise).resolves.toEqual({ attached: true }); + + const promptPromise = client.submitPrompt( + { session_id: "live-2", text: "private" }, + { operationId: "prompt-operation" }, + ); + frame = sentFrames(socket).at(-1)!; + expect(frame.method).toBe("prompt.submit"); + socket.receive( + success(frame.id, { + status: "streaming", + run_id: "run-1", + user_message_id: "message-user", + assistant_message_id: "message-assistant", + mutation_id: "mutation-1", + replayed: false, + mutation_status: "admitted", + }), + ); + await expect(promptPromise).resolves.toEqual({ + status: "streaming", + run_id: "run-1", + user_message_id: "message-user", + assistant_message_id: "message-assistant", + mutation_id: "mutation-1", + replayed: false, + mutation_status: "admitted", + }); + + const interruptPromise = client.interruptSession( + { session_id: "live-2" }, + { operationId: "interrupt-operation" }, + ); + frame = sentFrames(socket).at(-1)!; + expect(frame.method).toBe("session.interrupt"); + socket.receive(success(frame.id, { status: "interrupted" })); + await expect(interruptPromise).resolves.toEqual({ status: "interrupted" }); + client.close(); + }); + + it("decodes profile-scoped durable session discovery", async () => { + const factory = new FakeSocketFactory(); + const { client, socket } = await openClient(factory); + + const listed = client.listSessions({ profile: "work", limit: 20 }); + const request = sentFrames(socket).at(-1)!; + expect(request).toMatchObject({ + method: "session.list", + params: { profile: "work", limit: 20 }, + }); + socket.receive( + success(request.id, { + sessions: [ + { + id: "stored-1", + title: "Imported", + preview: "hello", + started_at: 123, + message_count: 2, + source: "tui", + }, + ], + }), + ); + + await expect(listed).resolves.toEqual({ + sessions: [ + { + id: "stored-1", + title: "Imported", + preview: "hello", + started_at: 123, + message_count: 2, + source: "tui", + }, + ], + }); + client.close(); + }); +}); + +describe("HermesGatewayClient recovery", () => { + it("coalesces concurrent initial connects onto one socket", async () => { + const factory = new FakeSocketFactory(); + const client = new HermesGatewayClient({ + endpoint: "ws://127.0.0.1:9119/api/ws", + authToken: "private-token", + socketFactory: factory.create, + reconnect: { maxAttempts: 0 }, + }); + + const first = client.connect(); + const second = client.connect(); + await Promise.resolve(); + expect(factory.sockets).toHaveLength(1); + factory.sockets[0]!.open(); + await Promise.resolve(); + factory.sockets[0]!.receive(legacyReady); + + await expect(Promise.all([first, second])).resolves.toHaveLength(2); + expect(factory.sockets).toHaveLength(1); + client.close(); + }); + + it("transitions to disconnected when the socket factory throws", async () => { + const client = new HermesGatewayClient({ + endpoint: "ws://127.0.0.1:9119/api/ws", + authToken: "private-token", + socketFactory: () => { + throw new Error("socket construction refused"); + }, + reconnect: { maxAttempts: 0 }, + }); + await expect(client.connect()).rejects.toThrow("socket construction refused"); + expect(client.health.state).toBe("disconnected"); + client.close(); + }); + + it("rejects connect when the socket never emits open", async () => { + const client = new HermesGatewayClient({ + endpoint: "ws://127.0.0.1:9119/api/ws", + authToken: "private-token", + socketFactory: () => ({ + readyState: 0, + addEventListener: () => {}, + send: () => {}, + close: () => {}, + }), + reconnect: { maxAttempts: 0 }, + openTimeoutMs: 5, + }); + + await expect(client.connect()).rejects.toThrow("Timed out opening gateway connection."); + expect(client.health.state).toBe("disconnected"); + client.close(); + }); + + it("queues reconnect-time reads and permits a known-unsent mutation retry", async () => { + const factory = new FakeSocketFactory(); + const { client, socket } = await openClient(factory, { + reconnect: { maxAttempts: 2, baseDelayMs: 1, maxDelayMs: 1 }, + }); + socket.close(1006); + + const read = client.read("session.list", {}); + await expect( + client.mutate( + "prompt.submit", + { session_id: "session-1", text: "private" }, + { operationId: "retry-after-reconnect" }, + ), + ).rejects.toThrow("not ready"); + expect(client.mutationRecord("retry-after-reconnect")?.state).toBe("not_sent"); + + await eventually(() => factory.sockets.length === 2); + const replacement = factory.sockets[1]!; + replacement.open(); + await Promise.resolve(); + replacement.receive(fullyNegotiatedReady); + await eventually(() => replacement.sent.length === 1); + const replayedRead = sentFrames(replacement)[0]!; + expect(replayedRead.method).toBe("session.list"); + replacement.receive(success(replayedRead.id, { sessions: [] })); + await expect(read).resolves.toEqual({ sessions: [] }); + + const retried = client.mutate( + "prompt.submit", + { session_id: "session-1", text: "private" }, + { operationId: "retry-after-reconnect" }, + ); + const retriedFrame = sentFrames(replacement)[1]!; + expect(retriedFrame.method).toBe("prompt.submit"); + replacement.receive(success(retriedFrame.id, { status: "streaming" })); + await expect(retried).resolves.toEqual({ status: "streaming" }); + expect(client.mutationRecord("retry-after-reconnect")?.state).toBe("confirmed"); + client.close(); + }); + + it("fails a queued read locally when the reconnected gateway drops its capability", async () => { + const factory = new FakeSocketFactory(); + const { client, socket } = await openClient(factory, { + reconnect: { maxAttempts: 2, baseDelayMs: 1, maxDelayMs: 1 }, + }); + socket.close(1006); + + const read = client.read("cron.list", {}, { requiredCapability: "cron.read" }); + await eventually(() => factory.sockets.length === 2); + const replacement = factory.sockets[1]!; + replacement.open(); + await Promise.resolve(); + replacement.receive(stableMutationReady); + + await expect(read).rejects.toBeInstanceOf(HermesGatewayCapabilityError); + expect(replacement.sent).toHaveLength(0); + client.close(); + }); + + it("still reconnects when a health listener throws during disconnect", async () => { + const factory = new FakeSocketFactory(); + const { client, socket } = await openClient(factory, { + reconnect: { maxAttempts: 2, baseDelayMs: 1, maxDelayMs: 1 }, + }); + const seen: string[] = []; + client.onHealthChange((snapshot) => { + if (snapshot.state !== "ready") throw new Error("listener failure"); + }); + client.onHealthChange((snapshot) => seen.push(snapshot.state)); + socket.close(1006); + + await eventually(() => factory.sockets.length === 2); + const replacement = factory.sockets[1]!; + replacement.open(); + await Promise.resolve(); + replacement.receive(fullyNegotiatedReady); + await eventually(() => client.state === "ready"); + expect(seen).toContain("reconnecting"); + expect(seen).toContain("ready"); + client.close(); + }); + + it("aborts a connect attempt when close() lands while onConnected is pending", async () => { + const factory = new FakeSocketFactory(); + let release!: () => void; + const gate = new Promise((resolve) => { + release = resolve; + }); + const client = new HermesGatewayClient({ + endpoint: "ws://127.0.0.1:9119/api/ws", + authToken: "private-token", + socketFactory: factory.create, + reconnect: { maxAttempts: 0 }, + supervisor: { onConnected: () => gate }, + }); + const connecting = client.connect(); + await Promise.resolve(); + const socket = factory.sockets[0]!; + socket.open(); + await Promise.resolve(); + socket.receive(fullyNegotiatedReady); + await eventually(() => client.state === "ready"); + client.close(); + release(); + await expect(connecting).rejects.toBeInstanceOf(HermesGatewayConnectionError); + expect(client.state).toBe("closed"); + }); + + it("uses mutation.status to release only an authoritatively completed local fence", async () => { + const factory = new FakeSocketFactory(); + const { client, socket } = await openClient(factory, {}, stableMutationReady); + const prompt = client.submitPrompt( + { session_id: "session-1", text: "private" }, + { + operationId: "prompt-recovery-operation", + mutationId: "prompt-recovery-mutation", + }, + ); + let frame = sentFrames(socket).at(-1)!; + socket.receive( + success(frame.id, { + mutation_id: "prompt-recovery-mutation", + mutation_status: "indeterminate", + run_id: "run-recovery", + replayed: true, + }), + ); + await expect(prompt).rejects.toBeInstanceOf(HermesGatewayMutationIndeterminateError); + + const reconciliation = client.reconcileMutation( + "prompt-recovery-operation", + "prompt-recovery-mutation", + ); + frame = sentFrames(socket).at(-1)!; + expect(frame).toMatchObject({ + method: "mutation.status", + params: { mutation_id: "prompt-recovery-mutation" }, + }); + const health: Array<{ writesBlocked: boolean; indeterminateMutationCount: number }> = []; + client.onHealthChange((snapshot) => + health.push({ + writesBlocked: snapshot.writesBlocked, + indeterminateMutationCount: snapshot.indeterminateMutationCount, + }), + ); + expect(health.at(-1)).toEqual({ writesBlocked: true, indeterminateMutationCount: 1 }); + socket.receive(success(frame.id, { mutation_status: "completed" })); + + await expect(reconciliation).resolves.toEqual({ mutation_status: "completed" }); + expect(client.mutationRecord("prompt-recovery-operation")).toBeUndefined(); + expect(client.writesBlocked).toBe(false); + expect(health.at(-1)).toEqual({ writesBlocked: false, indeterminateMutationCount: 0 }); + client.close(); + }); + + it("keeps the local fence for a mutation reconciled as still admitted", async () => { + const factory = new FakeSocketFactory(); + const { client, socket } = await openClient(factory, {}, stableMutationReady); + const prompt = client.submitPrompt( + { session_id: "session-1", text: "private" }, + { + operationId: "prompt-admitted-operation", + mutationId: "prompt-admitted-mutation", + }, + ); + let frame = sentFrames(socket).at(-1)!; + socket.receive( + success(frame.id, { + mutation_id: "prompt-admitted-mutation", + mutation_status: "indeterminate", + run_id: "run-admitted", + replayed: true, + }), + ); + await expect(prompt).rejects.toBeInstanceOf(HermesGatewayMutationIndeterminateError); + + const reconciliation = client.reconcileMutation( + "prompt-admitted-operation", + "prompt-admitted-mutation", + ); + frame = sentFrames(socket).at(-1)!; + socket.receive(success(frame.id, { mutation_status: "admitted" })); + await expect(reconciliation).resolves.toEqual({ mutation_status: "admitted" }); + + expect(client.mutationRecord("prompt-admitted-operation")?.state).toBe("pending"); + expect(client.writesBlocked).toBe(false); + await expect( + client.submitPrompt( + { session_id: "session-1", text: "must not race" }, + { operationId: "prompt-admitted-operation" }, + ), + ).rejects.toThrow("already been used"); + client.close(); + }); + + it("defaults reconciliation to the stored mutationId for the operation", async () => { + const factory = new FakeSocketFactory(); + const { client, socket } = await openClient(factory, {}, stableMutationReady); + const prompt = client.submitPrompt( + { session_id: "session-1", text: "private" }, + { + operationId: "prompt-stored-operation", + mutationId: "prompt-stored-mutation", + }, + ); + let frame = sentFrames(socket).at(-1)!; + socket.receive( + success(frame.id, { + mutation_id: "prompt-stored-mutation", + mutation_status: "indeterminate", + run_id: "run-stored", + replayed: true, + }), + ); + await expect(prompt).rejects.toBeInstanceOf(HermesGatewayMutationIndeterminateError); + + const reconciliation = client.reconcileMutation("prompt-stored-operation"); + frame = sentFrames(socket).at(-1)!; + expect(frame).toMatchObject({ + method: "mutation.status", + params: { mutation_id: "prompt-stored-mutation" }, + }); + socket.receive(success(frame.id, { mutation_status: "completed" })); + await expect(reconciliation).resolves.toEqual({ mutation_status: "completed" }); + client.close(); + }); + + it("does not release the fence when reconciling an unrelated mutation id", async () => { + const factory = new FakeSocketFactory(); + const { client, socket } = await openClient(factory, {}, stableMutationReady); + const prompt = client.submitPrompt( + { session_id: "session-1", text: "private" }, + { + operationId: "prompt-fenced-operation", + mutationId: "prompt-fenced-mutation", + }, + ); + let frame = sentFrames(socket).at(-1)!; + socket.receive( + success(frame.id, { + mutation_id: "prompt-fenced-mutation", + mutation_status: "indeterminate", + run_id: "run-fenced", + replayed: true, + }), + ); + await expect(prompt).rejects.toBeInstanceOf(HermesGatewayMutationIndeterminateError); + + const reconciliation = client.reconcileMutation("prompt-fenced-operation", "other-mutation"); + frame = sentFrames(socket).at(-1)!; + expect(frame).toMatchObject({ + method: "mutation.status", + params: { mutation_id: "other-mutation" }, + }); + socket.receive(success(frame.id, { mutation_status: "completed" })); + await expect(reconciliation).resolves.toEqual({ mutation_status: "completed" }); + + expect(client.mutationRecord("prompt-fenced-operation")?.state).toBe("indeterminate"); + expect(client.writesBlocked).toBe(true); + client.close(); + }); + + it("rejects sent mutations as indeterminate when the client is closed", async () => { + const factory = new FakeSocketFactory(); + const { client } = await openClient(factory, {}, stableMutationReady); + const prompt = client.submitPrompt( + { session_id: "session-1", text: "private" }, + { operationId: "prompt-closed-operation" }, + ); + const read = client.readSessionStatus({ session_id: "session-1" }); + client.close(); + await expect(prompt).rejects.toBeInstanceOf(HermesGatewayMutationIndeterminateError); + await expect(read).rejects.toBeInstanceOf(HermesGatewayConnectionError); + expect(client.mutationRecord("prompt-closed-operation")?.state).toBe("indeterminate"); + }); + + it("does not resurrect a client closed while beforeConnect is pending", async () => { + const factory = new FakeSocketFactory(); + let release!: () => void; + const gate = new Promise((resolve) => { + release = resolve; + }); + const client = new HermesGatewayClient({ + endpoint: "ws://127.0.0.1:9119/api/ws", + authToken: "private-token", + socketFactory: factory.create, + reconnect: { maxAttempts: 0 }, + supervisor: { beforeConnect: () => gate }, + }); + const connecting = client.connect(); + client.close(); + release(); + await expect(connecting).rejects.toBeInstanceOf(HermesGatewayConnectionError); + expect(factory.sockets).toHaveLength(0); + expect(client.state).toBe("closed"); + }); + + it("rejects an in-flight connect() when close() is called mid-handshake", async () => { + const factory = new FakeSocketFactory(); + const client = new HermesGatewayClient({ + endpoint: "ws://127.0.0.1:9119/api/ws", + authToken: "private-token", + socketFactory: factory.create, + reconnect: { maxAttempts: 0 }, + }); + const connecting = client.connect(); + await Promise.resolve(); + expect(factory.sockets).toHaveLength(1); + client.close(); + await expect(connecting).rejects.toBeInstanceOf(HermesGatewayConnectionError); + expect(client.state).toBe("closed"); + }); + + it("marks a status-less indeterminate replay and blocks later mutations", async () => { + const factory = new FakeSocketFactory(); + const { client, socket } = await openClient(factory, {}, stableMutationReady); + const prompt = client.submitPrompt( + { session_id: "session-1", text: "private" }, + { + operationId: "prompt-recovery-operation", + mutationId: "prompt-recovery-mutation", + }, + ); + const frame = sentFrames(socket).at(-1)!; + expect(frame.params.mutation_id).toBe("prompt-recovery-mutation"); + socket.receive( + success(frame.id, { + mutation_id: "prompt-recovery-mutation", + mutation_status: "indeterminate", + run_id: "run-recovery", + replayed: true, + }), + ); + + await expect(prompt).rejects.toBeInstanceOf(HermesGatewayMutationIndeterminateError); + expect(client.mutationRecord("prompt-recovery-operation")?.state).toBe("indeterminate"); + expect(client.writesBlocked).toBe(true); + await expect( + client.interrupt("session-1", { operationId: "blocked-interrupt" }), + ).rejects.toBeInstanceOf(HermesGatewayMutationsBlockedError); + expect(sentFrames(socket)).toHaveLength(1); + client.close(); + }); + + it.each(["complete", "interrupted", "error"] as const)( + "accepts and confirms a terminal %s prompt replay", + async (status) => { + const factory = new FakeSocketFactory(); + const { client, socket } = await openClient(factory, {}, stableMutationReady); + const operationId = `prompt-${status}-operation`; + const prompt = client.submitPrompt( + { session_id: "session-1", text: "private" }, + { + operationId, + mutationId: `prompt-${status}-mutation`, + }, + ); + const frame = sentFrames(socket).at(-1)!; + socket.receive( + success(frame.id, { + status, + mutation_id: `prompt-${status}-mutation`, + mutation_status: "completed", + run_id: `run-${status}`, + message_id: `message-${status}`, + }), + ); + + await expect(prompt).resolves.toMatchObject({ + status, + mutation_status: "completed", + run_id: `run-${status}`, + }); + expect(client.mutationRecord(operationId)?.state).toBe("confirmed"); + expect(client.writesBlocked).toBe(false); + client.close(); + }, + ); + + it("handles indeterminate replays before decoding create, resume, and interrupt results", async () => { + const cases = [ + { + operationId: "create-recovery-operation", + mutationId: "create-recovery-mutation", + invoke: (client: HermesGatewayClient) => + client.createSession( + { source: "t3-code" }, + { + operationId: "create-recovery-operation", + mutationId: "create-recovery-mutation", + }, + ), + }, + { + operationId: "resume-recovery-operation", + mutationId: "resume-recovery-mutation", + invoke: (client: HermesGatewayClient) => + client.resumeSession( + { session_id: "stored-session-1" }, + { + operationId: "resume-recovery-operation", + mutationId: "resume-recovery-mutation", + }, + ), + }, + { + operationId: "interrupt-recovery-operation", + mutationId: "interrupt-recovery-mutation", + invoke: (client: HermesGatewayClient) => + client.interrupt("session-1", { + operationId: "interrupt-recovery-operation", + mutationId: "interrupt-recovery-mutation", + }), + }, + ] as const; + + for (const testCase of cases) { + const factory = new FakeSocketFactory(); + const { client, socket } = await openClient(factory, {}, stableMutationReady); + const mutation = testCase.invoke(client); + const frame = sentFrames(socket).at(-1)!; + socket.receive( + success(frame.id, { + mutation_id: testCase.mutationId, + mutation_status: "indeterminate", + run_id: "", + replayed: true, + }), + ); + + await expect(mutation).rejects.toBeInstanceOf(HermesGatewayMutationIndeterminateError); + expect(client.mutationRecord(testCase.operationId)?.state).toBe("indeterminate"); + client.close(); + } + }); + + it("does not confirm an undecodable successful mutation response", async () => { + const factory = new FakeSocketFactory(); + const { client, socket } = await openClient(factory); + const created = client.createSession( + { source: "t3-code" }, + { operationId: "malformed-create-operation" }, + ); + const frame = sentFrames(socket).at(-1)!; + socket.receive(success(frame.id, { unexpected: true })); + + await expect(created).rejects.toBeInstanceOf(HermesGatewayMutationIndeterminateError); + expect(client.mutationRecord("malformed-create-operation")?.state).toBe("indeterminate"); + expect(client.writesBlocked).toBe(true); + client.close(); + }); + + it("replays reads after bounded reconnect but never replays an indeterminate mutation", async () => { + const factory = new FakeSocketFactory(); + const { client, socket } = await openClient(factory, { + reconnect: { maxAttempts: 2, baseDelayMs: 1, maxDelayMs: 1 }, + }); + const read = client.read("session.list", {}); + const mutation = client.mutate( + "prompt.submit", + { session_id: "session-1", text: "private" }, + { operationId: "prompt-operation" }, + ); + expect(sentFrames(socket).map((frame) => frame.method)).toEqual([ + "session.list", + "prompt.submit", + ]); + + socket.close(1006); + await expect(mutation).rejects.toBeInstanceOf(HermesGatewayMutationIndeterminateError); + expect(client.writesBlocked).toBe(true); + await eventually(() => factory.sockets.length === 2); + const replacement = factory.sockets[1]!; + replacement.open(); + await Promise.resolve(); + replacement.receive(fullyNegotiatedReady); + await eventually(() => replacement.sent.length === 1); + const replayed = sentFrames(replacement); + expect(replayed.map((frame) => frame.method)).toEqual(["session.list"]); + replacement.receive(success(replayed[0]!.id, { sessions: [] })); + await expect(read).resolves.toEqual({ sessions: [] }); + await expect( + client.mutate( + "prompt.submit", + { session_id: "session-1", text: "must not send" }, + { operationId: "prompt-operation-2" }, + ), + ).rejects.toBeInstanceOf(HermesGatewayMutationsBlockedError); + expect(replacement.sent).toHaveLength(1); + + client.acknowledgeIndeterminate("prompt-operation"); + const interrupt = client.interrupt("session-1", { operationId: "interrupt-operation" }); + const interruptFrame = sentFrames(replacement)[1]!; + expect(interruptFrame).toMatchObject({ + method: "session.interrupt", + params: { session_id: "session-1" }, + }); + replacement.receive(success(interruptFrame.id, { status: "interrupted" })); + await expect(interrupt).resolves.toEqual({ status: "interrupted" }); + client.close(); + }); + + it("bounds reconnect attempts and exposes process supervision hooks", async () => { + const factory = new FakeSocketFactory(); + const callbacks: string[] = []; + let exhausted!: () => void; + const exhaustedPromise = new Promise((resolve) => { + exhausted = resolve; + }); + const { socket } = await openClient(factory, { + reconnect: { maxAttempts: 2, baseDelayMs: 1, maxDelayMs: 1 }, + supervisor: { + beforeConnect: ({ attempt, reconnect }) => { + callbacks.push(`before:${attempt}:${reconnect}`); + }, + onConnected: ({ attempt }) => { + callbacks.push(`connected:${attempt}`); + }, + onDisconnected: ({ reconnecting }) => { + callbacks.push(`disconnected:${reconnecting}`); + return Promise.reject(new Error("supervisor disconnect failure")); + }, + onReconnectExhausted: ({ attempts }) => { + callbacks.push(`exhausted:${attempts}`); + exhausted(); + return Promise.reject(new Error("supervisor exhausted failure")); + }, + }, + socketFactory: (endpoint) => { + const candidate = factory.create(endpoint); + if (factory.sockets.length > 1) { + queueMicrotask(() => candidate.fail()); + } + return candidate; + }, + }); + socket.close(1006); + await exhaustedPromise; + + expect(factory.sockets).toHaveLength(3); + expect(callbacks).toEqual([ + "before:0:false", + "connected:0", + "disconnected:true", + "before:1:true", + "disconnected:true", + "before:2:true", + "disconnected:true", + "exhausted:2", + ]); + }); +}); + +const skillsReady = { + jsonrpc: "2.0", + method: "event", + params: { + type: "gateway.ready", + payload: { + protocol: { + major: 1, + minor: 0, + capabilities: { + "skills.manage": "supported", + "skills.reload": "supported", + }, + }, + }, + }, +} as const; + +describe("HermesGatewayClient skills", () => { + it("uses the skills.manage read and skills.reload mutation wire protocol", async () => { + const factory = new FakeSocketFactory(); + const { client, socket } = await openClient(factory, {}, skillsReady); + + const listing = client.listSkills(); + let frame = sentFrames(socket).at(-1)!; + expect(frame).toMatchObject({ method: "skills.manage", params: { action: "list" } }); + socket.receive(success(frame.id, { skills: [{ name: "notes", description: "Take notes" }] })); + await expect(listing).resolves.toEqual({ + skills: [{ name: "notes", description: "Take notes" }], + }); + + const searching = client.searchSkills("git"); + frame = sentFrames(socket).at(-1)!; + expect(frame).toMatchObject({ + method: "skills.manage", + params: { action: "search", query: "git" }, + }); + socket.receive(success(frame.id, { results: [{ name: "git-helper", description: "Git" }] })); + await expect(searching).resolves.toEqual({ + results: [{ name: "git-helper", description: "Git" }], + }); + + const inspecting = client.inspectSkill("git-helper"); + frame = sentFrames(socket).at(-1)!; + expect(frame).toMatchObject({ + method: "skills.manage", + params: { action: "inspect", query: "git-helper" }, + }); + socket.receive(success(frame.id, { info: { name: "git-helper" } })); + await expect(inspecting).resolves.toEqual({ info: { name: "git-helper" } }); + + const reloading = client.reloadSkills({ operationId: "skills-reload-1" }); + frame = sentFrames(socket).at(-1)!; + expect(frame).toMatchObject({ method: "skills.reload", params: {} }); + socket.receive( + success(frame.id, { + output: "Reloaded", + result: { added: [{ name: "new-skill" }], removed: [], total: 4 }, + }), + ); + await expect(reloading).resolves.toEqual({ + output: "Reloaded", + result: { added: [{ name: "new-skill" }], removed: [], total: 4 }, + }); + client.close(); + }); + + it("removes only the skills.manage capability after a -32601 response", async () => { + const factory = new FakeSocketFactory(); + const { client, socket } = await openClient(factory, {}, skillsReady); + + const listing = client.listSkills(); + const frame = sentFrames(socket).at(-1)!; + socket.receive({ + jsonrpc: "2.0", + id: frame.id, + error: { code: -32601, message: "method not found" }, + }); + await expect(listing).rejects.toThrow("skills.manage failed with code -32601"); + expect(client.hasCapability("skills.manage")).toBe(false); + expect(client.hasCapability("skills.reload")).toBe(true); + await expect(client.listSkills()).rejects.toBeInstanceOf(HermesGatewayCapabilityError); + client.close(); + }); + + it("refuses skills access on a legacy gateway without a negotiated inventory", async () => { + const factory = new FakeSocketFactory(); + const { client } = await openClient(factory, {}, legacyReady); + await expect(client.listSkills()).rejects.toBeInstanceOf(HermesGatewayCapabilityError); + await expect(client.reloadSkills({ operationId: "skills-reload-2" })).rejects.toBeInstanceOf( + HermesGatewayCapabilityError, + ); + client.close(); + }); +}); + +async function eventually(predicate: () => boolean, timeoutMs = 1_000): Promise { + const deadline = Date.now() + timeoutMs; + while (!predicate()) { + if (Date.now() >= deadline) throw new Error("Timed out waiting for test condition."); + await new Promise((resolve) => setTimeout(resolve, 1)); + } +} diff --git a/apps/server/src/hermes/HermesGatewayClient.ts b/apps/server/src/hermes/HermesGatewayClient.ts new file mode 100644 index 00000000000..f5a5ab22c3d --- /dev/null +++ b/apps/server/src/hermes/HermesGatewayClient.ts @@ -0,0 +1,1680 @@ +// @effect-diagnostics globalTimers:off - This transport owns bounded WebSocket timers. +import { + HermesGatewayEvent, + HermesGatewayCronListResult, + HermesGatewayCronMutationResult, + HermesGatewayApprovalRespondResult, + HermesGatewayClarificationRespondResult, + HermesGatewayCommandsCatalogResult, + HermesGatewayInboundFrame, + HermesGatewayInterruptResult, + HermesGatewayMutationOutcome, + HermesGatewayMutationStatusResult, + HermesGatewayModelOptionsResult, + HermesGatewayPromptSubmitResult, + HermesGatewaySessionCreateResult, + HermesGatewaySessionHistoryResult, + HermesGatewaySessionListResult, + HermesGatewaySessionBranchResult, + HermesGatewaySessionMcpLeaseResult, + HermesGatewaySessionMcpRevokeResult, + HermesGatewaySessionResumeResult, + HermesGatewaySessionStatusResult, + HermesGatewaySessionTitleResult, + HermesGatewayReasoningConfigResult, + HermesGatewayFastConfigResult, + HermesGatewaySkillsInspectResult, + HermesGatewaySkillsListResult, + HermesGatewaySkillsReloadResult, + HermesGatewaySkillsSearchResult, + type HermesGatewayApprovalRespondParams, + type HermesGatewayApprovalRespondResult as HermesGatewayApprovalRespondResultType, + type HermesGatewayCapabilityName, + type HermesGatewayCronListResult as HermesGatewayCronListResultType, + type HermesGatewayCronMutationResult as HermesGatewayCronMutationResultType, + type HermesGatewayCompatibility, + type HermesGatewayClarificationRespondParams, + type HermesGatewayClarificationRespondResult as HermesGatewayClarificationRespondResultType, + type HermesGatewayCommandsCatalogResult as HermesGatewayCommandsCatalogResultType, + type HermesGatewayEvent as HermesGatewayEventFrame, + type HermesGatewayInterruptParams, + type HermesGatewayInterruptResult as HermesGatewayInterruptResultType, + type HermesGatewayPromptSubmitParams, + type HermesGatewayPromptSubmitResult as HermesGatewayPromptSubmitResultType, + type HermesGatewayReadyEvent, + type HermesGatewayResponse, + type HermesGatewayMutationStatusResult as HermesGatewayMutationStatusResultType, + type HermesGatewayModelOptionsResult as HermesGatewayModelOptionsResultType, + type HermesGatewayReasoningConfigResult as HermesGatewayReasoningConfigResultType, + type HermesGatewayFastConfigResult as HermesGatewayFastConfigResultType, + type HermesGatewaySessionCreateParams, + type HermesGatewaySessionCreateResult as HermesGatewaySessionCreateResultType, + type HermesGatewaySessionHandleParams, + type HermesGatewaySessionHistoryResult as HermesGatewaySessionHistoryResultType, + type HermesGatewaySessionListParams, + type HermesGatewaySessionListResult as HermesGatewaySessionListResultType, + type HermesGatewaySessionMcpLeaseResult as HermesGatewaySessionMcpLeaseResultType, + type HermesGatewaySessionMcpParams, + type HermesGatewaySessionMcpRevokeResult as HermesGatewaySessionMcpRevokeResultType, + type HermesGatewaySessionBranchParams, + type HermesGatewaySessionBranchResult as HermesGatewaySessionBranchResultType, + type HermesGatewaySessionResumeParams, + type HermesGatewaySessionResumeResult as HermesGatewaySessionResumeResultType, + type HermesGatewaySessionStatusResult as HermesGatewaySessionStatusResultType, + type HermesGatewaySessionTitleParams, + type HermesGatewaySessionTitleResult as HermesGatewaySessionTitleResultType, + type HermesGatewaySkillsInspectResult as HermesGatewaySkillsInspectResultType, + type HermesGatewaySkillsListResult as HermesGatewaySkillsListResultType, + type HermesGatewaySkillsReloadResult as HermesGatewaySkillsReloadResultType, + type HermesGatewaySkillsSearchResult as HermesGatewaySkillsSearchResultType, + type HermesGatewayUnknownRecord, +} from "@t3tools/contracts"; +import * as Schema from "effect/Schema"; + +import { + assessHermesConnectionSecurity, + sanitizeHermesEndpoint, +} from "./HermesConnectionSecurity.ts"; + +export const HERMES_GATEWAY_SUPPORTED_PROTOCOL_MAJOR = 1; + +export const HERMES_GATEWAY_LEGACY_CAPABILITIES = + [] as const satisfies ReadonlyArray; + +export type HermesGatewayConnectionState = + | "disconnected" + | "connecting" + | "ready" + | "reconnecting" + | "closed"; + +export type HermesGatewayMutationState = "pending" | "confirmed" | "indeterminate" | "not_sent"; + +export interface HermesGatewayMutationRecord { + readonly operationId: string; + readonly method: string; + readonly state: HermesGatewayMutationState; + readonly mutationId?: string; +} + +export interface HermesGatewayHealth { + readonly state: HermesGatewayConnectionState; + readonly reconnectAttempt: number; + readonly protocolStatus: HermesGatewayCompatibility["status"] | "unknown"; + readonly protocolMajor: number | null; + readonly protocolMinor: number | null; + readonly serverVersion: string | null; + readonly capabilities: ReadonlyArray; + readonly writesBlocked: boolean; + readonly indeterminateMutationCount: number; +} + +export interface HermesGatewayOrderedEvent { + readonly transportSequence: number; + readonly sessionSequence: number; + readonly sessionId: string | undefined; + readonly eventId: string | undefined; + readonly eventSequence: number | undefined; + readonly emittedAt: string | undefined; + readonly sessionKey: string | undefined; + readonly runId: string | undefined; + readonly messageId: string | undefined; + readonly cursor: string | number | undefined; + readonly mutationId: string | undefined; + readonly frame: HermesGatewayEventFrame; +} + +export type HermesGatewayLogEvent = + | { + readonly type: "connection"; + readonly state: HermesGatewayConnectionState; + readonly endpoint: string; + readonly attempt: number; + } + | { + readonly type: "request"; + readonly method: string; + readonly requestId: string; + readonly operation: "read" | "mutation"; + } + | { + readonly type: "response"; + readonly method: string; + readonly requestId: string; + readonly outcome: "success" | "rpc_error"; + readonly errorCode?: number; + } + | { + readonly type: "protocol"; + readonly outcome: "invalid_frame" | "unknown_notification" | "capability_degraded"; + readonly method?: string; + readonly capability?: string; + } + | { + readonly type: "mutation"; + readonly operationId: string; + readonly method: string; + readonly state: HermesGatewayMutationState; + }; + +export interface HermesGatewaySupervisor { + readonly beforeConnect?: (context: { + readonly attempt: number; + readonly reconnect: boolean; + }) => void | Promise; + readonly onConnected?: (context: { + readonly attempt: number; + readonly reconnect: boolean; + readonly compatibility: HermesGatewayCompatibility; + }) => void | Promise; + readonly onDisconnected?: (context: { + readonly reconnecting: boolean; + readonly pendingReads: number; + readonly indeterminateMutations: ReadonlyArray; + }) => void | Promise; + readonly onReconnectExhausted?: (context: { readonly attempts: number }) => void | Promise; +} + +export interface HermesGatewaySocketEvent { + readonly data?: unknown; + readonly code?: number; +} + +export interface HermesGatewaySocket { + readonly readyState: number; + send(data: string): void; + close(code?: number, reason?: string): void; + addEventListener( + type: "open" | "message" | "close" | "error", + listener: (event: HermesGatewaySocketEvent) => void, + options?: { readonly once?: boolean }, + ): void; +} + +export type HermesGatewaySocketFactory = (endpoint: string) => HermesGatewaySocket; + +export interface HermesGatewayClientOptions { + readonly endpoint: string; + readonly authToken: string; + readonly socketFactory?: HermesGatewaySocketFactory; + readonly requestTimeoutMs?: number; + readonly readyTimeoutMs?: number; + readonly openTimeoutMs?: number; + readonly reconnect?: { + readonly maxAttempts?: number; + readonly baseDelayMs?: number; + readonly maxDelayMs?: number; + }; + readonly criticalCapabilities?: ReadonlyArray; + readonly logger?: (event: HermesGatewayLogEvent) => void; + readonly supervisor?: HermesGatewaySupervisor; +} + +export interface HermesGatewayReadOptions { + readonly signal?: AbortSignal; + readonly requiredCapability?: string; + readonly retryOnReconnect?: boolean; + readonly timeoutMs?: number; +} + +export interface HermesGatewayMutationOptions { + readonly operationId: string; + readonly mutationId?: string; + readonly signal?: AbortSignal; + readonly requiredCapability?: string; + readonly timeoutMs?: number; +} + +interface PendingRequest { + readonly id: string; + readonly method: string; + readonly params: HermesGatewayUnknownRecord; + readonly operation: "read" | "mutation"; + readonly operationId: string | undefined; + readonly mutationId: string | undefined; + readonly requiredCapability: string | undefined; + readonly retryOnReconnect: boolean; + readonly resolve: (value: unknown) => void; + readonly reject: (error: Error) => void; + readonly signal: AbortSignal | undefined; + readonly abortListener: (() => void) | undefined; + timeout: ReturnType | undefined; + sent: boolean; + awaitingReconnect: boolean; + timeoutMs: number; +} + +interface ReadyWaiter { + readonly resolve: (event: HermesGatewayReadyEvent) => void; + readonly reject: (error: Error) => void; + readonly timer: ReturnType; +} + +export class HermesGatewayConfigurationError extends Error { + override readonly name: string = "HermesGatewayConfigurationError"; +} + +/** + * A mutation reused an operationId that this client already fenced. Callers + * can treat this as caller input error, unlike other configuration failures. + */ +export class HermesGatewayDuplicateOperationIdError extends HermesGatewayConfigurationError { + override readonly name: string = "HermesGatewayDuplicateOperationIdError"; +} + +export class HermesGatewayConnectionError extends Error { + override readonly name = "HermesGatewayConnectionError"; +} + +export class HermesGatewayProtocolError extends Error { + override readonly name = "HermesGatewayProtocolError"; +} + +export class HermesGatewayCapabilityError extends Error { + override readonly name = "HermesGatewayCapabilityError"; + readonly capability: string; + constructor(capability: string) { + super(`Hermes gateway capability is unavailable: ${capability}`); + this.capability = capability; + } +} + +export class HermesGatewayRpcError extends Error { + override readonly name = "HermesGatewayRpcError"; + readonly code: number; + readonly method: string; + readonly disposition: "retryable" | "indeterminate" | "fatal" | undefined; + constructor( + code: number, + method: string, + disposition: "retryable" | "indeterminate" | "fatal" | undefined, + ) { + super(`Hermes gateway RPC ${method} failed with code ${code}.`); + this.code = code; + this.method = method; + this.disposition = disposition; + } +} + +export class HermesGatewayRequestCancelledError extends Error { + override readonly name = "HermesGatewayRequestCancelledError"; +} + +export class HermesGatewayMutationIndeterminateError extends Error { + override readonly name = "HermesGatewayMutationIndeterminateError"; + readonly operationId: string; + readonly method: string; + constructor(operationId: string, method: string) { + super(`Hermes mutation ${operationId} (${method}) has an indeterminate outcome.`); + this.operationId = operationId; + this.method = method; + } +} + +export class HermesGatewayMutationsBlockedError extends Error { + override readonly name = "HermesGatewayMutationsBlockedError"; + readonly operationIds: ReadonlyArray; + constructor(operationIds: ReadonlyArray) { + super("Hermes mutations are blocked until indeterminate operations are reconciled."); + this.operationIds = operationIds; + } +} + +const decodeInboundFrame = Schema.decodeUnknownSync(HermesGatewayInboundFrame); +const decodeGatewayEvent = Schema.decodeUnknownSync(HermesGatewayEvent); +const decodeMutationOutcome = Schema.decodeUnknownSync(HermesGatewayMutationOutcome); + +const METHOD_CAPABILITIES: Readonly> = { + "session.create": "session.lifecycle", + "session.list": "session.lifecycle", + "session.resume": "session.lifecycle", + "session.history": "session.history", + "session.title": "session.title", + "session.branch": "session.branch.latest", + "session.mcp.register": "session_mcp", + "session.mcp.replace": "session_mcp", + "session.mcp.revoke": "session_mcp", + "session.interrupt": "turn.interrupt", + "prompt.submit": "turn.prompt", + "mutation.status": "mutation.stable_ids", + "commands.catalog": "commands.catalog", + "model.options": "models.inventory", + "config.get": "reasoning.effective_state", + "image.attach_bytes": "attachments.image", + "file.attach": "attachments.file", + "pdf.attach": "attachments.pdf", + "approval.respond": "events.approvals", + "clarify.respond": "events.clarification", + "cron.manage": "cron.manage", + "skills.manage": "skills.manage", + "skills.reload": "skills.reload", +}; + +const SOCKET_OPEN = 1; + +function readyVersionFields( + payload: HermesGatewayReadyEvent["params"]["payload"], +): Pick { + return { + ...(payload.server_version === undefined ? {} : { serverVersion: payload.server_version }), + ...(payload.revision === undefined ? {} : { revision: payload.revision }), + }; +} + +export function classifyHermesGatewayReady( + event: HermesGatewayReadyEvent, +): HermesGatewayCompatibility { + const protocol = event.params.payload.protocol ?? null; + const advertised = protocol?.capabilities ?? event.params.payload.capabilities; + const capabilities = + advertised === undefined + ? [...HERMES_GATEWAY_LEGACY_CAPABILITIES] + : Array.isArray(advertised) + ? [...advertised] + : Object.entries(advertised) + .filter(([capability, value]) => capability !== "version" && capabilityEnabled(value)) + .map(([capability]) => capability) + .toSorted(); + + if (protocol === null) { + return { + status: "legacy", + protocol: null, + capabilities, + inventory: advertised ?? null, + reason: "Gateway did not advertise a negotiated protocol version.", + ...readyVersionFields(event.params.payload), + }; + } + if (protocol.major !== HERMES_GATEWAY_SUPPORTED_PROTOCOL_MAJOR) { + return { + status: "unsupported", + protocol, + capabilities, + inventory: advertised ?? null, + reason: `Unsupported Hermes gateway protocol major ${protocol.major}.`, + ...readyVersionFields(event.params.payload), + }; + } + return { + status: "supported", + protocol, + capabilities, + inventory: advertised ?? null, + reason: `Hermes gateway protocol ${protocol.major}.${protocol.minor} is supported.`, + ...readyVersionFields(event.params.payload), + }; +} + +export function sanitizeHermesGatewayEndpoint(endpoint: string): string { + return sanitizeHermesEndpoint(endpoint); +} + +export class HermesGatewayClient { + private readonly endpoint: string; + private readonly endpointLabel: string; + private readonly socketFactory: HermesGatewaySocketFactory; + private readonly requestTimeoutMs: number; + private readonly readyTimeoutMs: number; + private readonly openTimeoutMs: number; + private readonly maxReconnectAttempts: number; + private readonly reconnectBaseDelayMs: number; + private readonly reconnectMaxDelayMs: number; + private readonly criticalCapabilities: ReadonlyArray; + private readonly logger: ((event: HermesGatewayLogEvent) => void) | undefined; + private readonly supervisor: HermesGatewaySupervisor | undefined; + + private socket: HermesGatewaySocket | undefined; + private stateValue: HermesGatewayConnectionState = "disconnected"; + private compatibilityValue: HermesGatewayCompatibility | undefined; + private capabilities = new Set(); + private nextRequestId = 1; + private transportSequence = 0; + private readonly sessionSequences = new Map(); + private readonly pending = new Map(); + private readonly mutations = new Map(); + private readonly eventListeners = new Set< + (event: HermesGatewayOrderedEvent) => void | Promise + >(); + private eventDispatch = Promise.resolve(); + private readyWaiter: ReadyWaiter | undefined; + private connectTask: Promise | undefined; + private reconnectTask: Promise | undefined; + private manuallyClosed = false; + private connectionGeneration = 0; + private reconnectAttempt = 0; + private readonly healthListeners = new Set<(health: HermesGatewayHealth) => void>(); + + constructor(options: HermesGatewayClientOptions) { + this.endpoint = authenticatedEndpoint(options); + this.endpointLabel = sanitizeHermesGatewayEndpoint(this.endpoint); + this.socketFactory = options.socketFactory ?? defaultSocketFactory; + this.requestTimeoutMs = positive(options.requestTimeoutMs, 15_000); + this.readyTimeoutMs = positive(options.readyTimeoutMs, 10_000); + this.openTimeoutMs = positive(options.openTimeoutMs, 10_000); + this.maxReconnectAttempts = nonNegative(options.reconnect?.maxAttempts, 3); + this.reconnectBaseDelayMs = positive(options.reconnect?.baseDelayMs, 100); + this.reconnectMaxDelayMs = positive(options.reconnect?.maxDelayMs, 2_000); + this.criticalCapabilities = options.criticalCapabilities ?? []; + this.logger = options.logger; + this.supervisor = options.supervisor; + } + + get state(): HermesGatewayConnectionState { + return this.stateValue; + } + + get compatibility(): HermesGatewayCompatibility | undefined { + return this.compatibilityValue; + } + + get writesBlocked(): boolean { + return this.indeterminateOperationIds().length > 0; + } + + get health(): HermesGatewayHealth { + return { + state: this.stateValue, + reconnectAttempt: this.reconnectAttempt, + protocolStatus: this.compatibilityValue?.status ?? "unknown", + protocolMajor: this.compatibilityValue?.protocol?.major ?? null, + protocolMinor: this.compatibilityValue?.protocol?.minor ?? null, + serverVersion: this.compatibilityValue?.serverVersion ?? null, + capabilities: [...this.capabilities].toSorted(), + writesBlocked: this.writesBlocked, + indeterminateMutationCount: this.indeterminateOperationIds().length, + }; + } + + hasCapability(capability: string): boolean { + return this.capabilities.has(capability); + } + + mutationRecord(operationId: string): HermesGatewayMutationRecord | undefined { + return this.mutations.get(operationId); + } + + onEvent(listener: (event: HermesGatewayOrderedEvent) => void | Promise): () => void { + this.eventListeners.add(listener); + return () => this.eventListeners.delete(listener); + } + + onHealthChange(listener: (health: HermesGatewayHealth) => void): () => void { + this.healthListeners.add(listener); + listener(this.health); + return () => this.healthListeners.delete(listener); + } + + async connect(): Promise { + if (this.stateValue === "ready" && this.compatibilityValue) { + return this.compatibilityValue; + } + if (this.stateValue === "closed") { + throw new HermesGatewayConnectionError("Hermes gateway client is closed."); + } + if (this.connectTask) return this.connectTask; + if (this.reconnectTask) { + await this.reconnectTask; + if (this.stateValue === "ready" && this.compatibilityValue) { + return this.compatibilityValue; + } + throw new HermesGatewayConnectionError("Hermes gateway reconnect did not recover."); + } + this.manuallyClosed = false; + const task = this.connectAttempt(0, false).finally(() => { + if (this.connectTask === task) this.connectTask = undefined; + }); + this.connectTask = task; + return task; + } + + async read( + method: string, + params: HermesGatewayUnknownRecord, + options: HermesGatewayReadOptions = {}, + ): Promise { + const requiredCapability = options.requiredCapability ?? METHOD_CAPABILITIES[method]; + this.requireCapability(requiredCapability); + return this.sendRequest(method, params, { + operation: "read", + operationId: undefined, + mutationId: undefined, + requiredCapability, + signal: options.signal, + retryOnReconnect: options.retryOnReconnect ?? true, + timeoutMs: positive(options.timeoutMs, this.requestTimeoutMs), + }); + } + + async mutate( + method: string, + params: HermesGatewayUnknownRecord, + options: HermesGatewayMutationOptions, + ): Promise { + return this.mutateDecoded(method, params, options, (value) => value); + } + + private async mutateDecoded( + method: string, + params: HermesGatewayUnknownRecord, + options: HermesGatewayMutationOptions, + decode: (value: unknown) => Result, + ): Promise { + const requiredCapability = options.requiredCapability ?? METHOD_CAPABILITIES[method]; + this.requireCapability(requiredCapability); + const blocked = this.indeterminateOperationIds(); + if (blocked.length > 0) { + throw new HermesGatewayMutationsBlockedError(blocked); + } + if (!options.operationId.trim()) { + throw new HermesGatewayConfigurationError("Hermes mutation operationId is required."); + } + const existing = this.mutations.get(options.operationId); + const retryingKnownUnsent = + existing?.state === "not_sent" && + existing.method === method && + existing.mutationId === options.mutationId; + if (existing !== undefined && !retryingKnownUnsent) { + throw new HermesGatewayDuplicateOperationIdError( + `Hermes mutation operationId has already been used: ${options.operationId}`, + ); + } + if (options.mutationId && !this.hasCapability("mutation.stable_ids")) { + throw new HermesGatewayCapabilityError("mutation.stable_ids"); + } + + const wireParams = + options.mutationId === undefined + ? params + : { + ...params, + mutation_id: options.mutationId, + }; + this.setMutation({ + operationId: options.operationId, + method, + state: "pending", + ...(options.mutationId === undefined ? {} : { mutationId: options.mutationId }), + }); + + let responseReceived = false; + try { + const result = await this.sendRequest(method, wireParams, { + operation: "mutation", + operationId: options.operationId, + mutationId: options.mutationId, + requiredCapability, + signal: options.signal, + retryOnReconnect: false, + timeoutMs: positive(options.timeoutMs, this.requestTimeoutMs), + }); + responseReceived = true; + const outcome = decodeOptionalMutationOutcome(result); + if (outcome?.mutation_status === "indeterminate") { + this.markMutationIndeterminate(options.operationId); + throw new HermesGatewayMutationIndeterminateError(options.operationId, method); + } + if (outcome?.mutation_status === "completed") { + this.confirmMutation(options.operationId); + } + const decoded = decode(result); + this.confirmMutation(options.operationId); + return decoded; + } catch (error) { + const record = this.mutations.get(options.operationId); + if (record?.state === "pending") { + this.setMutation({ + ...record, + state: responseReceived ? "indeterminate" : "not_sent", + }); + if (responseReceived) { + throw new HermesGatewayMutationIndeterminateError(options.operationId, method); + } + } + throw error; + } + } + + async interrupt( + sessionId: string, + options: Omit, + ): Promise { + return this.interruptSession({ session_id: sessionId }, options); + } + + async createSession( + params: HermesGatewaySessionCreateParams, + options: Omit, + ): Promise { + return this.mutateDecoded( + "session.create", + params, + { + ...options, + requiredCapability: "session.lifecycle", + }, + (result) => decodeResult(HermesGatewaySessionCreateResult, result, "session.create"), + ); + } + + async resumeSession( + params: HermesGatewaySessionResumeParams, + options: Omit, + ): Promise { + return this.mutateDecoded( + "session.resume", + params, + { + ...options, + requiredCapability: "session.lifecycle", + }, + (result) => decodeResult(HermesGatewaySessionResumeResult, result, "session.resume"), + ); + } + + async registerSessionMcp( + params: HermesGatewaySessionMcpParams, + options: Omit, + ): Promise { + return this.mutateDecoded( + "session.mcp.register", + params, + { ...options, requiredCapability: "session_mcp" }, + (result) => decodeResult(HermesGatewaySessionMcpLeaseResult, result, "session.mcp.register"), + ); + } + + async replaceSessionMcp( + params: HermesGatewaySessionMcpParams, + options: Omit, + ): Promise { + return this.mutateDecoded( + "session.mcp.replace", + params, + { ...options, requiredCapability: "session_mcp" }, + (result) => decodeResult(HermesGatewaySessionMcpLeaseResult, result, "session.mcp.replace"), + ); + } + + async revokeSessionMcp( + sessionId: string, + options: Omit, + ): Promise { + return this.mutateDecoded( + "session.mcp.revoke", + { session_id: sessionId }, + { ...options, requiredCapability: "session_mcp" }, + (result) => decodeResult(HermesGatewaySessionMcpRevokeResult, result, "session.mcp.revoke"), + ); + } + + async readSessionStatus( + params: HermesGatewaySessionHandleParams, + options: Omit = {}, + ): Promise { + const result = await this.read("session.status", params, { + ...options, + requiredCapability: "session.lifecycle", + }); + return decodeResult(HermesGatewaySessionStatusResult, result, "session.status"); + } + + async readSessionHistory( + params: HermesGatewaySessionHandleParams, + options: Omit = {}, + ): Promise { + const result = await this.read("session.history", params, { + ...options, + requiredCapability: "session.history", + }); + return decodeResult(HermesGatewaySessionHistoryResult, result, "session.history"); + } + + async readSessionTitle( + params: Pick, + options: Omit = {}, + ): Promise { + const result = await this.read("session.title", params, { + ...options, + requiredCapability: "session.title", + }); + return decodeResult(HermesGatewaySessionTitleResult, result, "session.title"); + } + + async updateSessionTitle( + params: HermesGatewaySessionTitleParams & { readonly title: string }, + options: Omit, + ): Promise { + return this.mutateDecoded( + "session.title", + params, + { + ...options, + requiredCapability: "session.title", + }, + (result) => decodeResult(HermesGatewaySessionTitleResult, result, "session.title"), + ); + } + + async branchSession( + params: HermesGatewaySessionBranchParams, + options: Omit, + ): Promise { + return this.mutateDecoded( + "session.branch", + params, + { + ...options, + requiredCapability: "session.branch.latest", + }, + (result) => decodeResult(HermesGatewaySessionBranchResult, result, "session.branch"), + ); + } + + async listSessions( + params: HermesGatewaySessionListParams, + options: Omit = {}, + ): Promise { + const result = await this.read("session.list", params, { + ...options, + requiredCapability: "session.lifecycle", + retryOnReconnect: true, + }); + return decodeResult(HermesGatewaySessionListResult, result, "session.list"); + } + + async listCronJobs( + options: Omit = {}, + ): Promise { + const result = await this.read( + "cron.manage", + { action: "list" }, + { + ...options, + requiredCapability: "cron.read", + }, + ); + return decodeResult(HermesGatewayCronListResult, result, "cron.manage/list"); + } + + async manageCron( + params: HermesGatewayUnknownRecord, + options: Omit, + ): Promise { + return this.mutateDecoded( + "cron.manage", + params, + { ...options, requiredCapability: "cron.manage" }, + (result) => decodeResult(HermesGatewayCronMutationResult, result, "cron.manage"), + ); + } + + async listSkills( + options: Omit = {}, + ): Promise { + const result = await this.read( + "skills.manage", + { action: "list" }, + { ...options, requiredCapability: "skills.manage" }, + ); + return decodeResult(HermesGatewaySkillsListResult, result, "skills.manage/list"); + } + + async searchSkills( + query: string, + options: Omit = {}, + ): Promise { + const result = await this.read( + "skills.manage", + { action: "search", query }, + { ...options, requiredCapability: "skills.manage" }, + ); + return decodeResult(HermesGatewaySkillsSearchResult, result, "skills.manage/search"); + } + + async inspectSkill( + name: string, + options: Omit = {}, + ): Promise { + const result = await this.read( + "skills.manage", + { action: "inspect", query: name }, + { ...options, requiredCapability: "skills.manage" }, + ); + return decodeResult(HermesGatewaySkillsInspectResult, result, "skills.manage/inspect"); + } + + async reloadSkills( + options: Omit, + ): Promise { + return this.mutateDecoded( + "skills.reload", + {}, + { ...options, requiredCapability: "skills.reload" }, + (result) => decodeResult(HermesGatewaySkillsReloadResult, result, "skills.reload"), + ); + } + + async readCommandsCatalog( + sessionId?: string, + options: Omit = {}, + ): Promise { + const result = await this.read( + "commands.catalog", + sessionId === undefined ? {} : { session_id: sessionId }, + { ...options, requiredCapability: "commands.catalog" }, + ); + return decodeResult(HermesGatewayCommandsCatalogResult, result, "commands.catalog"); + } + + async readModelOptions( + params: { + readonly session_id?: string; + readonly explicit_only?: boolean; + readonly include_unconfigured?: boolean; + } = {}, + options: Omit = {}, + ): Promise { + const result = await this.read("model.options", params, { + ...options, + requiredCapability: "models.inventory", + }); + return decodeResult(HermesGatewayModelOptionsResult, result, "model.options"); + } + + async readReasoningConfig( + sessionId?: string, + options: Omit = {}, + ): Promise { + const result = await this.read( + "config.get", + { + key: "reasoning", + ...(sessionId === undefined ? {} : { session_id: sessionId }), + }, + { ...options, requiredCapability: "reasoning.effective_state" }, + ); + return decodeResult(HermesGatewayReasoningConfigResult, result, "config.get"); + } + + async readFastConfig( + sessionId?: string, + options: Omit = {}, + ): Promise { + const result = await this.read( + "config.get", + { + key: "fast", + ...(sessionId === undefined ? {} : { session_id: sessionId }), + }, + { ...options, requiredCapability: "models.inventory" }, + ); + return decodeResult(HermesGatewayFastConfigResult, result, "config.get"); + } + + async reconcileMutation( + operationId: string, + mutationId?: string, + ): Promise { + const trackedMutationId = this.mutations.get(operationId)?.mutationId ?? operationId; + const wireMutationId = mutationId ?? trackedMutationId; + const result = await this.read( + "mutation.status", + { mutation_id: wireMutationId }, + { + requiredCapability: "mutation.stable_ids", + retryOnReconnect: true, + }, + ); + const outcome = decodeResult(HermesGatewayMutationStatusResult, result, "mutation.status"); + if (wireMutationId !== trackedMutationId) return outcome; + const existing = this.mutations.get(operationId); + if (outcome.mutation_status === "indeterminate") { + this.setMutation({ + operationId, + method: existing?.method ?? "unknown", + state: "indeterminate", + mutationId: existing?.mutationId ?? operationId, + }); + } else if (outcome.mutation_status === "completed") { + if (this.mutations.delete(operationId)) this.emitHealth(); + } else { + this.setMutation({ + operationId, + method: existing?.method ?? "unknown", + state: "pending", + mutationId: existing?.mutationId ?? operationId, + }); + } + return outcome; + } + + async submitPrompt( + params: HermesGatewayPromptSubmitParams, + options: Omit, + ): Promise { + return this.mutateDecoded( + "prompt.submit", + params, + { + ...options, + requiredCapability: "turn.prompt", + }, + (result) => decodeResult(HermesGatewayPromptSubmitResult, result, "prompt.submit"), + ); + } + + async attachImageBytes( + params: { + readonly session_id: string; + readonly content_base64: string; + readonly filename?: string; + }, + options: Omit, + ): Promise { + return this.mutate("image.attach_bytes", params, { + ...options, + requiredCapability: "attachments.image", + }); + } + + async respondToApproval( + params: HermesGatewayApprovalRespondParams, + options: Omit, + ): Promise { + return this.mutateDecoded( + "approval.respond", + params, + { ...options, requiredCapability: "events.approvals" }, + (result) => decodeResult(HermesGatewayApprovalRespondResult, result, "approval.respond"), + ); + } + + async respondToClarification( + params: HermesGatewayClarificationRespondParams, + options: Omit, + ): Promise { + return this.mutateDecoded( + "clarify.respond", + params, + { ...options, requiredCapability: "events.clarification" }, + (result) => decodeResult(HermesGatewayClarificationRespondResult, result, "clarify.respond"), + ); + } + + async attachFile( + params: { + readonly session_id: string; + readonly name: string; + readonly data_url: string; + }, + options: Omit, + ): Promise { + return this.mutate("file.attach", params, { + ...options, + requiredCapability: "attachments.file", + }); + } + + async attachPdf( + params: { + readonly session_id: string; + readonly filename: string; + readonly content_base64: string; + }, + options: Omit, + ): Promise { + return this.mutate("pdf.attach", params, { + ...options, + requiredCapability: "attachments.pdf", + }); + } + + async interruptSession( + params: HermesGatewayInterruptParams, + options: Omit, + ): Promise { + return this.mutateDecoded( + "session.interrupt", + params, + { + ...options, + requiredCapability: "turn.interrupt", + }, + (result) => decodeResult(HermesGatewayInterruptResult, result, "session.interrupt"), + ); + } + + acknowledgeIndeterminate(operationId: string): void { + const record = this.mutations.get(operationId); + if (record?.state !== "indeterminate") { + throw new HermesGatewayConfigurationError( + `Hermes mutation is not indeterminate: ${operationId}`, + ); + } + this.mutations.delete(operationId); + this.emitHealth(); + } + + close(): void { + if (this.stateValue === "closed") return; + this.manuallyClosed = true; + this.setState("closed", 0); + this.rejectReady(new HermesGatewayConnectionError("Hermes gateway client closed.")); + const socket = this.socket; + this.socket = undefined; + socket?.close(1000, "client closed"); + for (const pending of this.pending.values()) { + const sentMutation = pending.operation === "mutation" && pending.sent; + this.rejectPending( + pending, + sentMutation && pending.operationId + ? new HermesGatewayMutationIndeterminateError(pending.operationId, pending.method) + : new HermesGatewayConnectionError("Hermes gateway client closed."), + sentMutation, + ); + } + } + + private async connectAttempt( + attempt: number, + reconnect: boolean, + ): Promise { + this.setState(reconnect ? "reconnecting" : "connecting", attempt); + const generationBefore = this.connectionGeneration; + await this.supervisor?.beforeConnect?.({ attempt, reconnect }); + if (this.manuallyClosed || generationBefore !== this.connectionGeneration) { + throw new HermesGatewayConnectionError("Hermes gateway client closed."); + } + const generation = ++this.connectionGeneration; + let socket: HermesGatewaySocket; + try { + socket = this.socketFactory(this.endpoint); + } catch (error) { + if (!reconnect) this.setState("disconnected", attempt); + throw error instanceof Error + ? error + : new HermesGatewayConnectionError("Hermes gateway socket creation failed."); + } + this.socket = socket; + + const opened = new Promise((resolve, reject) => { + const timer = setTimeout( + () => reject(new HermesGatewayConnectionError("Timed out opening gateway connection.")), + this.openTimeoutMs, + ); + socket.addEventListener( + "open", + () => { + clearTimeout(timer); + resolve(); + }, + { once: true }, + ); + socket.addEventListener( + "error", + () => { + clearTimeout(timer); + reject(new HermesGatewayConnectionError("Hermes gateway connection failed.")); + }, + { once: true }, + ); + socket.addEventListener( + "close", + () => { + clearTimeout(timer); + reject(new HermesGatewayConnectionError("Hermes gateway connection closed.")); + }, + { once: true }, + ); + }); + socket.addEventListener("message", (event) => this.handleMessage(event, generation)); + socket.addEventListener("close", (event) => this.handleClose(event, generation), { + once: true, + }); + + try { + await opened; + this.ensureAttemptActive(generation); + const ready = await this.waitForReady(); + this.ensureAttemptActive(generation); + const compatibility = classifyHermesGatewayReady(ready); + this.compatibilityValue = compatibility; + if (compatibility.status === "unsupported") { + throw new HermesGatewayProtocolError(compatibility.reason); + } + this.capabilities = new Set(compatibility.capabilities); + for (const capability of this.criticalCapabilities) { + this.requireCapability(capability); + } + this.setState("ready", attempt); + await this.supervisor?.onConnected?.({ attempt, reconnect, compatibility }); + this.ensureAttemptActive(generation); + this.replayPendingReads(); + return compatibility; + } catch (error) { + if (this.socket === socket) this.socket = undefined; + // The WHATWG WebSocket API only permits callers to send code 1000 or + // private-use codes in the 3000-4999 range. Undici correctly rejects + // reserved protocol code 1002 with InvalidAccessError, which would mask + // the actual handshake/connection failure we are trying to report. + socket.close(4002, "gateway handshake failed"); + this.rejectReady( + error instanceof Error + ? error + : new HermesGatewayConnectionError("Hermes gateway handshake failed."), + ); + if (generation === this.connectionGeneration && !this.manuallyClosed) { + this.connectionGeneration += 1; + if (this.stateValue === "connecting" || this.stateValue === "ready") { + this.handleConnectionFailure(); + } + } + throw error; + } + } + + private ensureAttemptActive(generation: number): void { + if (this.manuallyClosed || generation !== this.connectionGeneration) { + throw new HermesGatewayConnectionError("Hermes gateway client closed."); + } + } + + private waitForReady(): Promise { + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + this.readyWaiter = undefined; + reject(new HermesGatewayProtocolError("Timed out waiting for gateway.ready.")); + }, this.readyTimeoutMs); + this.readyWaiter = { resolve, reject, timer }; + }); + } + + private resolveReady(event: HermesGatewayReadyEvent): void { + const waiter = this.readyWaiter; + if (!waiter) return; + clearTimeout(waiter.timer); + this.readyWaiter = undefined; + waiter.resolve(event); + } + + private rejectReady(error: Error): void { + const waiter = this.readyWaiter; + if (!waiter) return; + clearTimeout(waiter.timer); + this.readyWaiter = undefined; + waiter.reject(error); + } + + private handleMessage(event: HermesGatewaySocketEvent, generation: number): void { + if (generation !== this.connectionGeneration || typeof event.data !== "string") return; + let decoded: HermesGatewayInboundFrame; + try { + decoded = decodeInboundFrame(JSON.parse(event.data)); + } catch { + this.logger?.({ type: "protocol", outcome: "invalid_frame" }); + return; + } + + if ("method" in decoded) { + if (decoded.method !== "event") { + this.logger?.({ + type: "protocol", + outcome: "unknown_notification", + method: decoded.method, + }); + return; + } + const gatewayEvent = decodeGatewayEvent(decoded); + if (gatewayEvent.params.type === "gateway.ready") { + this.resolveReady(decoded as HermesGatewayReadyEvent); + } + this.enqueueEvent(gatewayEvent); + return; + } + this.handleResponse(decoded); + } + + private handleResponse(response: HermesGatewayResponse): void { + if (response.id === null) return; + const requestId = String(response.id); + const pending = this.pending.get(requestId); + if (!pending) return; + this.pending.delete(requestId); + this.clearPendingResources(pending); + + if ("error" in response) { + const disposition = response.error.data?.disposition; + const capability = pending.requiredCapability ?? METHOD_CAPABILITIES[pending.method]; + if (response.error.code === -32601 && capability && this.capabilities.delete(capability)) { + this.logger?.({ + type: "protocol", + outcome: "capability_degraded", + method: pending.method, + capability, + }); + } + if (pending.operationId && disposition === "indeterminate") { + const record = this.mutations.get(pending.operationId); + if (record) this.setMutation({ ...record, state: "indeterminate" }); + } else if (pending.operationId) { + this.confirmMutation(pending.operationId); + } + this.logger?.({ + type: "response", + method: pending.method, + requestId, + outcome: "rpc_error", + errorCode: response.error.code, + }); + pending.reject(new HermesGatewayRpcError(response.error.code, pending.method, disposition)); + return; + } + + this.logger?.({ + type: "response", + method: pending.method, + requestId, + outcome: "success", + }); + pending.resolve(response.result); + } + + private enqueueEvent(frame: HermesGatewayEventFrame): void { + const transportSequence = ++this.transportSequence; + const sessionId = frame.params.session_id || undefined; + const sessionKey = sessionId ?? ""; + const sessionSequence = (this.sessionSequences.get(sessionKey) ?? 0) + 1; + this.sessionSequences.set(sessionKey, sessionSequence); + const ordered: HermesGatewayOrderedEvent = { + transportSequence, + sessionSequence, + sessionId, + eventId: frame.params.event_id, + eventSequence: frame.params.event_sequence, + emittedAt: frame.params.emitted_at, + sessionKey: frame.params.session_key, + runId: frame.params.run_id, + messageId: frame.params.message_id, + cursor: frame.params.event_sequence ?? frame.params.cursor, + mutationId: frame.params.mutation_id, + frame, + }; + this.eventDispatch = this.eventDispatch + .catch(() => undefined) + .then(async () => { + for (const listener of this.eventListeners) { + try { + await listener(ordered); + } catch { + // A failing observer must not block the remaining listeners. + } + } + }); + } + + private sendRequest( + method: string, + params: HermesGatewayUnknownRecord, + options: { + readonly operation: "read" | "mutation"; + readonly operationId: string | undefined; + readonly mutationId: string | undefined; + readonly requiredCapability: string | undefined; + readonly signal: AbortSignal | undefined; + readonly retryOnReconnect: boolean; + readonly timeoutMs: number; + }, + ): Promise { + const ready = this.stateValue === "ready" && this.socket?.readyState === SOCKET_OPEN; + const mayWaitForReconnect = + options.operation === "read" && + options.retryOnReconnect && + this.stateValue === "reconnecting"; + if (!ready && !mayWaitForReconnect) { + return Promise.reject(new HermesGatewayConnectionError("Hermes gateway is not ready.")); + } + if (options.signal?.aborted) { + return Promise.reject(new HermesGatewayRequestCancelledError("Hermes request cancelled.")); + } + + const id = String(this.nextRequestId++); + return new Promise((resolve, reject) => { + let pending: PendingRequest; + const abortListener = + options.signal === undefined + ? undefined + : () => { + const markIndeterminate = pending.operation === "mutation" && pending.sent; + this.rejectPending( + pending, + markIndeterminate && pending.operationId + ? new HermesGatewayMutationIndeterminateError(pending.operationId, pending.method) + : new HermesGatewayRequestCancelledError("Hermes request cancelled."), + markIndeterminate, + ); + }; + pending = { + id, + method, + params, + operation: options.operation, + operationId: options.operationId, + mutationId: options.mutationId, + requiredCapability: options.requiredCapability, + retryOnReconnect: options.retryOnReconnect, + resolve, + reject, + signal: options.signal, + abortListener, + timeout: undefined, + sent: false, + awaitingReconnect: false, + timeoutMs: options.timeoutMs, + }; + this.pending.set(id, pending); + options.signal?.addEventListener("abort", abortListener!, { once: true }); + this.sendPending(pending); + }); + } + + private sendPending(pending: PendingRequest): void { + const socket = this.socket; + if (!socket || socket.readyState !== SOCKET_OPEN) { + if (pending.operation === "read" && pending.retryOnReconnect) { + pending.awaitingReconnect = true; + return; + } + this.rejectPending( + pending, + new HermesGatewayConnectionError("Hermes gateway disconnected before send."), + false, + ); + return; + } + const frame = { + jsonrpc: "2.0", + id: pending.id, + method: pending.method, + params: pending.params, + } as const; + try { + socket.send(JSON.stringify(frame)); + pending.sent = true; + pending.awaitingReconnect = false; + pending.timeout = setTimeout(() => { + const markIndeterminate = pending.operation === "mutation"; + this.rejectPending( + pending, + markIndeterminate && pending.operationId + ? new HermesGatewayMutationIndeterminateError(pending.operationId, pending.method) + : new HermesGatewayConnectionError(`Hermes gateway read timed out: ${pending.method}`), + markIndeterminate, + ); + }, pending.timeoutMs); + this.logger?.({ + type: "request", + method: pending.method, + requestId: pending.id, + operation: pending.operation, + }); + } catch { + this.rejectPending( + pending, + new HermesGatewayConnectionError("Hermes gateway send failed before admission."), + false, + ); + } + } + + private rejectPending(pending: PendingRequest, error: Error, markIndeterminate: boolean): void { + if (!this.pending.delete(pending.id)) return; + this.clearPendingResources(pending); + if (markIndeterminate && pending.operationId) { + const record = this.mutations.get(pending.operationId); + if (record) { + this.setMutation({ ...record, state: "indeterminate" }); + } + } + pending.reject(error); + } + + private clearPendingResources(pending: PendingRequest): void { + if (pending.timeout) clearTimeout(pending.timeout); + pending.timeout = undefined; + if (pending.abortListener) { + pending.signal?.removeEventListener("abort", pending.abortListener); + } + } + + private handleClose(_event: HermesGatewaySocketEvent, generation: number): void { + if (generation !== this.connectionGeneration) return; + this.socket = undefined; + this.rejectReady(new HermesGatewayConnectionError("Hermes gateway disconnected.")); + if (this.manuallyClosed || this.stateValue === "closed") return; + this.handleConnectionFailure(); + } + + private handleConnectionFailure(): void { + this.socket = undefined; + for (const pending of this.pending.values()) { + this.clearPendingResources(pending); + if (pending.operation === "read" && pending.retryOnReconnect) { + pending.awaitingReconnect = true; + pending.sent = false; + continue; + } + this.rejectPending( + pending, + pending.operationId + ? new HermesGatewayMutationIndeterminateError(pending.operationId, pending.method) + : new HermesGatewayConnectionError("Hermes gateway disconnected."), + pending.operation === "mutation" && pending.sent, + ); + } + + const reconnecting = this.maxReconnectAttempts > 0; + this.setState(reconnecting ? "reconnecting" : "disconnected", 0); + void Promise.resolve() + .then(() => + this.supervisor?.onDisconnected?.({ + reconnecting, + pendingReads: [...this.pending.values()].filter( + (pending) => pending.operation === "read" && pending.awaitingReconnect, + ).length, + indeterminateMutations: this.indeterminateOperationIds(), + }), + ) + .catch(() => undefined); + if (reconnecting && !this.reconnectTask) { + this.reconnectTask = this.reconnectLoop().finally(() => { + this.reconnectTask = undefined; + }); + } else if (!reconnecting) { + this.rejectPendingReads(new HermesGatewayConnectionError("Hermes gateway disconnected.")); + } + } + + private async reconnectLoop(): Promise { + let lastError: Error = new HermesGatewayConnectionError("Hermes gateway disconnected."); + for (let attempt = 1; attempt <= this.maxReconnectAttempts; attempt += 1) { + await delay( + Math.min(this.reconnectBaseDelayMs * 2 ** (attempt - 1), this.reconnectMaxDelayMs), + ); + if (this.manuallyClosed) return; + try { + await this.connectAttempt(attempt, true); + return; + } catch (error) { + lastError = + error instanceof Error + ? error + : new HermesGatewayConnectionError("Hermes gateway reconnect failed."); + } + } + this.setState("disconnected", this.maxReconnectAttempts); + this.rejectPendingReads(lastError); + try { + await this.supervisor?.onReconnectExhausted?.({ + attempts: this.maxReconnectAttempts, + }); + } catch { + // Supervisor failures must not become unhandled rejections. + } + } + + private replayPendingReads(): void { + for (const pending of this.pending.values()) { + if (pending.operation === "read" && pending.awaitingReconnect) { + if (pending.requiredCapability && !this.capabilities.has(pending.requiredCapability)) { + this.rejectPending( + pending, + new HermesGatewayCapabilityError(pending.requiredCapability), + false, + ); + continue; + } + this.sendPending(pending); + } + } + } + + private rejectPendingReads(error: Error): void { + for (const pending of this.pending.values()) { + if (pending.operation === "read") { + this.rejectPending(pending, error, false); + } + } + } + + private requireCapability(capability: string | undefined): void { + if (capability && !this.capabilities.has(capability)) { + throw new HermesGatewayCapabilityError(capability); + } + } + + private confirmMutation(operationId: string): void { + const record = this.mutations.get(operationId); + if (record) this.setMutation({ ...record, state: "confirmed" }); + } + + private markMutationIndeterminate(operationId: string): void { + const record = this.mutations.get(operationId); + if (record) this.setMutation({ ...record, state: "indeterminate" }); + } + + private setMutation(record: HermesGatewayMutationRecord): void { + this.mutations.set(record.operationId, record); + this.logger?.({ + type: "mutation", + operationId: "", + method: record.method, + state: record.state, + }); + this.emitHealth(); + } + + private indeterminateOperationIds(): ReadonlyArray { + return [...this.mutations.values()] + .filter((record) => record.state === "indeterminate") + .map((record) => record.operationId) + .toSorted(); + } + + private setState(state: HermesGatewayConnectionState, attempt: number): void { + this.stateValue = state; + this.reconnectAttempt = attempt; + this.logger?.({ + type: "connection", + state, + endpoint: this.endpointLabel, + attempt, + }); + this.emitHealth(); + } + + private emitHealth(): void { + const health = this.health; + for (const listener of this.healthListeners) { + try { + listener(health); + } catch { + // A failing health listener must not block the remaining listeners. + } + } + } +} + +function authenticatedEndpoint(options: HermesGatewayClientOptions): string { + const assessment = assessHermesConnectionSecurity({ + endpoint: options.endpoint, + gatewayToken: options.authToken, + remoteGloballyEnabled: false, + remoteInstanceEnabled: false, + remotePairingToken: undefined, + remoteTlsCertificateSha256: undefined, + }); + if (assessment.status !== "ready") { + throw new HermesGatewayConfigurationError(assessment.message); + } + const endpoint = new URL(assessment.endpoint); + endpoint.searchParams.set("token", assessment.authToken); + return endpoint.toString(); +} + +function defaultSocketFactory(endpoint: string): HermesGatewaySocket { + return new WebSocket(endpoint) as unknown as HermesGatewaySocket; +} + +const compiledResultDecoders = new WeakMap unknown>(); + +function decodeResult( + schema: S, + value: unknown, + method: string, +): Schema.Schema.Type { + let decode = compiledResultDecoders.get(schema); + if (decode === undefined) { + decode = Schema.decodeUnknownSync(schema as never); + compiledResultDecoders.set(schema, decode); + } + try { + return decode(value) as Schema.Schema.Type; + } catch { + throw new HermesGatewayProtocolError(`Hermes gateway returned malformed ${method} result.`); + } +} + +function decodeOptionalMutationOutcome(value: unknown): HermesGatewayMutationOutcome | undefined { + try { + return decodeMutationOutcome(value); + } catch { + return undefined; + } +} + +function capabilityEnabled(value: unknown): boolean { + if (value === null || value === undefined || value === false) return false; + if (typeof value === "string") { + return !["", "disabled", "unsupported", "unavailable", "false"].includes( + value.trim().toLowerCase(), + ); + } + if (typeof value === "object" && !Array.isArray(value)) { + const enabled = (value as Readonly>).enabled; + return enabled !== false; + } + return true; +} + +function positive(value: number | undefined, fallback: number): number { + return value !== undefined && Number.isFinite(value) && value > 0 ? value : fallback; +} + +function nonNegative(value: number | undefined, fallback: number): number { + return value !== undefined && Number.isInteger(value) && value >= 0 ? value : fallback; +} + +function delay(milliseconds: number): Promise { + return new Promise((resolve) => setTimeout(resolve, milliseconds)); +} diff --git a/apps/server/src/hermes/HermesHistoryNormalization.test.ts b/apps/server/src/hermes/HermesHistoryNormalization.test.ts new file mode 100644 index 00000000000..1522cf352a7 --- /dev/null +++ b/apps/server/src/hermes/HermesHistoryNormalization.test.ts @@ -0,0 +1,337 @@ +// @effect-diagnostics nodeBuiltinImport:off +import * as NodeFS from "node:fs"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; + +import { it as effectIt } from "@effect/vitest"; +import { describe, expect, it } from "vite-plus/test"; +import * as Effect from "effect/Effect"; + +import { + hermesHistoryMediaRoots, + normalizeHermesHistoryMessage, + parseHermesHistoryText, + persistHermesHistoryMedia, +} from "./HermesHistoryNormalization.ts"; + +const PNG_BYTES = Uint8Array.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0, 0, 0, 0]); +const MP4_BYTES = Uint8Array.from([0, 0, 0, 24, 0x66, 0x74, 0x79, 0x70, 0x69, 0x73, 0x6f, 0x6d]); + +describe("Hermes imported history normalization", () => { + it("parses the installed sender, image-path, and screenshot persistence envelope", () => { + const path = "/Users/maria/.hermes/cache/images/img_c2f562760fbb.webp"; + expect( + parseHermesHistoryText({ + role: "user", + text: `[maria] rewrite better\n\n[Image attached at: ${path}]\n[screenshot]`, + }), + ).toEqual({ + text: "rewrite better", + media: [{ kind: "image", path }], + }); + }); + + it("preserves assistant text, reply envelopes, multiline content, and ordinary bracketed prose", () => { + const reply = ['[Replying to: "earlier message"]', "keep this", "and this"].join("\n"); + expect(parseHermesHistoryText({ role: "user", text: reply })).toEqual({ + text: reply, + media: [], + }); + expect(parseHermesHistoryText({ role: "user", text: "[Important] keep this" }).text).toBe( + "[Important] keep this", + ); + expect(parseHermesHistoryText({ role: "assistant", text: "[maria] assistant text" }).text).toBe( + "[maria] assistant text", + ); + expect( + parseHermesHistoryText({ + role: "user", + text: "[maria|transport-user-id]\nfirst line\nsecond line", + }).text, + ).toBe("first line\nsecond line"); + expect( + parseHermesHistoryText({ + role: "user", + text: "[Summary]\nfirst line\nsecond line", + }).text, + ).toBe("[Summary]\nfirst line\nsecond line"); + }); + + it("extracts upstream MEDIA directives from labeled assistant output", () => { + const root = "/Users/maria/Downloads/kimi-thumbnail-renditions"; + expect( + parseHermesHistoryText({ + role: "assistant", + text: [ + "Made 3 renditions.", + "", + `1. **Wall quote** MEDIA:${root}/01-wall-quote.jpg`, + `2. **Bottom-right** MEDIA:${root}/02-bottom-right.jpg`, + `**Comparison sheet** MEDIA:${root}/comparison-sheet.jpg`, + ].join("\n"), + }), + ).toEqual({ + text: [ + "Made 3 renditions.", + "", + "1. **Wall quote**", + "2. **Bottom-right**", + "**Comparison sheet**", + ].join("\n"), + media: [ + { kind: "image", path: `${root}/01-wall-quote.jpg` }, + { kind: "image", path: `${root}/02-bottom-right.jpg` }, + { kind: "image", path: `${root}/comparison-sheet.jpg` }, + ], + }); + }); + + it("captures two MEDIA directives on one line separately", () => { + expect( + parseHermesHistoryText({ + role: "assistant", + text: "Before MEDIA:/tmp/media one.png MEDIA:/tmp/media two.png after", + }), + ).toEqual({ + text: "Before after", + media: [ + { kind: "image", path: "/tmp/media one.png" }, + { kind: "image", path: "/tmp/media two.png" }, + ], + }); + expect( + parseHermesHistoryText({ + role: "assistant", + text: "MEDIA:/tmp/no-extension MEDIA:/tmp/real.png", + }), + ).toEqual({ + text: "", + media: [ + { kind: "image", path: "/tmp/real.png" }, + { kind: "file", path: "/tmp/no-extension" }, + ], + }); + }); + + it("honors quoted MEDIA paths and preserves examples in protected prose", () => { + const real = "/Users/maria/Downloads/rendered output/report.pdf"; + const text = [ + `Report MEDIA:"${real}"`, + "Use `MEDIA:/tmp/example.png` to attach an image.", + "> Example: MEDIA:/tmp/quoted.png", + "```text", + "MEDIA:/tmp/fenced.png", + "```", + 'log: {"old":"MEDIA:/tmp/stale.png"}', + ].join("\n"); + expect(parseHermesHistoryText({ role: "assistant", text })).toEqual({ + text: [ + "Report", + "Use `MEDIA:/tmp/example.png` to attach an image.", + "> Example: MEDIA:/tmp/quoted.png", + "```text", + "MEDIA:/tmp/fenced.png", + "```", + 'log: {"old":"MEDIA:/tmp/stale.png"}', + ].join("\n"), + media: [{ kind: "file", path: real }], + }); + expect( + parseHermesHistoryText({ + role: "user", + text: "Please explain MEDIA:/tmp/not-a-user-attachment.png", + }), + ).toEqual({ + text: "Please explain MEDIA:/tmp/not-a-user-attachment.png", + media: [], + }); + }); + + it("uses a safe placeholder when a screenshot has no recoverable media reference", () => { + expect(parseHermesHistoryText({ role: "user", text: "[maria] \n[screenshot]" }).text).toBe( + "[Image unavailable]", + ); + }); + + it("defaults to Hermes-owned media locations, not general user or temporary directories", () => { + const hermesHome = NodePath.join(NodePath.sep, "tmp", "hermes-profile"); + const roots = hermesHistoryMediaRoots({ hermesHome, profileKey: "default" }); + + expect(roots).toContain(NodePath.join(hermesHome, "cache", "images")); + expect(roots).toContain(NodePath.join(hermesHome, "cache", "videos")); + expect(roots).toContain(NodePath.join(hermesHome, "cache", "screenshots")); + expect(roots).toContain(NodePath.join(hermesHome, "image_cache")); + expect(roots).not.toContain(NodePath.join(hermesHome, "cache")); + expect(roots).not.toContain(NodeOS.tmpdir()); + expect(roots).not.toContain(NodePath.join(NodeOS.homedir(), "Desktop")); + expect(roots).not.toContain(NodePath.join(NodeOS.homedir(), "Documents")); + expect(roots).not.toContain(NodePath.join(NodeOS.homedir(), "Downloads")); + }); + + it("expands a tilde-prefixed hermes home into the user home directory", () => { + const roots = hermesHistoryMediaRoots({ + hermesHome: `~${NodePath.sep}.hermes`, + profileKey: "default", + }); + + expect(roots).toContain(NodePath.join(NodeOS.homedir(), ".hermes", "cache", "images")); + expect(roots).not.toContain(NodePath.resolve(`~${NodePath.sep}.hermes`, "cache", "images")); + }); + + effectIt.effect( + "allows Hermes cache media and denies arbitrary user files, traversal, and symlink escapes", + () => + Effect.gen(function* () { + const temp = yield* Effect.sync(() => + NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-hermes-history-")), + ); + return yield* Effect.gen(function* () { + const hermesHome = NodePath.join(temp, "profile"); + const cache = NodePath.join(hermesHome, "cache", "images"); + const attachmentsDir = NodePath.join(temp, "t3", "attachments"); + const outside = NodePath.join(temp, "outside.png"); + const image = NodePath.join(cache, "img_fixture.webp"); + const video = NodePath.join(hermesHome, "cache", "videos", "clip.mp4"); + const desktop = NodePath.join(temp, "Desktop", "predictable.png"); + const documents = NodePath.join(temp, "Documents", "predictable.png"); + const downloads = NodePath.join(temp, "Downloads", "predictable.png"); + const arbitraryTemp = NodePath.join(temp, "predictable-tmp.png"); + const traversal = `${cache}${NodePath.sep}..${NodePath.sep}..${NodePath.sep}..${NodePath.sep}outside.png`; + const unsupported = NodePath.join(cache, "not-media.txt"); + const oversized = NodePath.join(cache, "oversized.png"); + const symlink = NodePath.join(cache, "escaped.webp"); + NodeFS.mkdirSync(cache, { recursive: true }); + NodeFS.mkdirSync(NodePath.dirname(video), { recursive: true }); + NodeFS.mkdirSync(NodePath.dirname(desktop), { recursive: true }); + NodeFS.mkdirSync(NodePath.dirname(documents), { recursive: true }); + NodeFS.mkdirSync(NodePath.dirname(downloads), { recursive: true }); + NodeFS.writeFileSync(image, PNG_BYTES); + NodeFS.writeFileSync(video, MP4_BYTES); + NodeFS.writeFileSync(outside, PNG_BYTES); + NodeFS.writeFileSync(desktop, PNG_BYTES); + NodeFS.writeFileSync(documents, PNG_BYTES); + NodeFS.writeFileSync(downloads, PNG_BYTES); + NodeFS.writeFileSync(arbitraryTemp, PNG_BYTES); + NodeFS.writeFileSync(unsupported, "not media"); + NodeFS.writeFileSync(oversized, PNG_BYTES); + NodeFS.truncateSync(oversized, 20 * 1024 * 1024 + 1); + NodeFS.symlinkSync(outside, symlink); + + const persist = (sourcePath: string, expectedKind: "image" | "video" = "image") => + persistHermesHistoryMedia({ + sourcePath, + expectedKind, + approvedRoots: hermesHistoryMediaRoots({ + hermesHome, + profileKey: "default", + }), + attachmentsDir, + threadId: "thread:hermes:imported", + stableKey: "history-message-1:0", + }); + const first = yield* persist(image); + NodeFS.unlinkSync(image); + const replay = yield* persist(image); + + expect(first).toMatchObject({ + type: "image", + name: "img_fixture.webp", + mimeType: "image/png", + sizeBytes: PNG_BYTES.byteLength, + }); + expect(yield* persist(video, "video")).toMatchObject({ + type: "video", + name: "clip.mp4", + mimeType: "video/mp4", + sizeBytes: MP4_BYTES.byteLength, + }); + expect(replay).toEqual(first); + expect(NodeFS.readdirSync(attachmentsDir)).toHaveLength(2); + expect(yield* persist(outside)).toBeNull(); + expect(yield* persist(desktop)).toBeNull(); + expect(yield* persist(documents)).toBeNull(); + expect(yield* persist(downloads)).toBeNull(); + expect(yield* persist(arbitraryTemp)).toBeNull(); + expect(yield* persist(traversal)).toBeNull(); + expect(yield* persist(symlink)).toBeNull(); + expect(yield* persist(unsupported)).toBeNull(); + expect(yield* persist(oversized)).toBeNull(); + }).pipe( + Effect.ensuring(Effect.sync(() => NodeFS.rmSync(temp, { recursive: true, force: true }))), + ); + }), + ); + + effectIt.effect("persists an approved generated PDF and generic document", () => + Effect.gen(function* () { + const temp = yield* Effect.sync(() => + NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-hermes-media-output-")), + ); + return yield* Effect.gen(function* () { + const output = NodePath.join(temp, "output"); + const attachmentsDir = NodePath.join(temp, "attachments"); + const pdf = NodePath.join(output, "report.pdf"); + const markdown = NodePath.join(output, "notes.md"); + NodeFS.mkdirSync(output, { recursive: true }); + NodeFS.writeFileSync(pdf, "%PDF-1.7\nfixture"); + NodeFS.writeFileSync(markdown, "# Fixture\n"); + const approvedRoots = hermesHistoryMediaRoots({ + hermesHome: NodePath.join(temp, "hermes"), + profileKey: "default", + extraRoots: [output], + }); + const persist = (sourcePath: string, stableKey: string) => + persistHermesHistoryMedia({ + sourcePath, + expectedKind: "file", + approvedRoots, + attachmentsDir, + threadId: "thread:hermes:media-output", + stableKey, + }); + + expect(yield* persist(pdf, "pdf")).toMatchObject({ + type: "pdf", + mimeType: "application/pdf", + name: "report.pdf", + }); + expect(yield* persist(markdown, "markdown")).toMatchObject({ + type: "file", + mimeType: "text/markdown", + name: "notes.md", + }); + }).pipe( + Effect.ensuring(Effect.sync(() => NodeFS.rmSync(temp, { recursive: true, force: true }))), + ); + }), + ); + + effectIt.effect("degrades missing media without leaking its path", () => + Effect.gen(function* () { + const rawPath = "/Users/maria/.hermes/cache/images/missing-secret.webp"; + const result = yield* normalizeHermesHistoryMessage({ + role: "user", + text: `[maria] see this\n[Image attached at: ${rawPath}]\n[screenshot]`, + resolveMedia: () => Effect.succeed(null), + }); + expect(result).toEqual({ text: "see this\n\n[Image unavailable]", attachments: [] }); + expect(result.text).not.toContain(rawPath); + }), + ); + + effectIt.effect("hides an unsupported assistant MEDIA path and degrades safely", () => + Effect.gen(function* () { + const rawPath = "/Users/maria/Downloads/generated/Caddyfile"; + const result = yield* normalizeHermesHistoryMessage({ + role: "assistant", + text: `Generated the configuration.\nMEDIA:${rawPath}`, + resolveMedia: () => Effect.succeed(null), + }); + expect(result).toEqual({ + text: "Generated the configuration.\n\n[File unavailable]", + attachments: [], + }); + expect(result.text).not.toContain(rawPath); + }), + ); +}); diff --git a/apps/server/src/hermes/HermesHistoryNormalization.ts b/apps/server/src/hermes/HermesHistoryNormalization.ts new file mode 100644 index 00000000000..7aa76a03111 --- /dev/null +++ b/apps/server/src/hermes/HermesHistoryNormalization.ts @@ -0,0 +1,605 @@ +// @effect-diagnostics nodeBuiltinImport:off +import * as NodeFS from "node:fs"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; + +import { + ChatAttachmentId, + type ChatAttachment, + PROVIDER_SEND_TURN_MAX_FILE_BYTES, + PROVIDER_SEND_TURN_MAX_IMAGE_BYTES, +} from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; + +import { + attachmentRelativePath, + createDeterministicAttachmentId, + resolveAttachmentPathById, +} from "../attachmentStore.ts"; + +export type HermesHistoryMediaKind = "image" | "audio" | "video" | "file"; + +export interface HermesHistoryMediaReference { + readonly kind: HermesHistoryMediaKind; + readonly path: string; +} + +export interface ParsedHermesHistoryText { + readonly text: string; + readonly media: ReadonlyArray; +} + +const MEDIA_REFERENCE_LINE = + /^\[(Image attached at|User sent an image|User sent audio|User sent a video|User sent a file):\s*(.+)\]$/i; +const HERMES_MEDIA_DELIVERY_EXTENSIONS = [ + "jpeg", + "docx", + "webp", + "tiff", + "flac", + "pptx", + "xlsx", + "html", + "yaml", + "epub", + "opus", + "json", + "webm", + "m4a", + "odt", + "rtf", + "txt", + "csv", + "xml", + "yml", + "ppt", + "odp", + "key", + "tar", + "tgz", + "bz2", + "apk", + "ipa", + "png", + "jpg", + "gif", + "bmp", + "svg", + "mp4", + "mov", + "avi", + "mkv", + "mp3", + "wav", + "ogg", + "pdf", + "doc", + "md", + "xls", + "ods", + "tsv", + "zip", + "htm", + "gz", + "xz", + "7z", + "rar", +] as const; +const HERMES_MEDIA_DELIVERY_EXTENSION_PATTERN = HERMES_MEDIA_DELIVERY_EXTENSIONS.join("|"); +const HERMES_MEDIA_TAG = new RegExp( + [ + String.raw`["']?MEDIA:\s*(?`, + String.raw`\x60[^\x60\n]+\x60`, + String.raw`|"[^"\n]+"|'[^'\n]+'|(?:~\/|\/|[A-Za-z]:[/\\])\S+(?:[^\S\n]+(?!["']?MEDIA:)\S+)*?\.(?:${HERMES_MEDIA_DELIVERY_EXTENSION_PATTERN}))(?=[\s\x60"',;:)\]}]|$)["']?`, + ].join(""), + "giu", +); +const HERMES_MEDIA_FALLBACK_TAG = + /["']?MEDIA:\s*(?(?:~\/|\/|[A-Za-z]:[/\\])[^\s\n`"']+)["']?/giu; +const REDUNDANT_IMAGE_PLACEHOLDER_LINE = /^\[(?:image|screenshot)\]$/i; +const TRANSPORT_SENDER_PREFIX = /^\[([^\]\r\n]{1,80})\](?:[ \t]+|\r?\n)/; +const RESERVED_TRANSPORT_LABELS = new Set([ + "attachment", + "image", + "important", + "info", + "note", + "reply", + "replying to", + "screenshot", + "system", + "todo", + "warning", +]); + +function expandHomePrefix(path: string): string { + return path === "~" + ? NodeOS.homedir() + : path.startsWith(`~${NodePath.sep}`) + ? NodePath.join(NodeOS.homedir(), path.slice(2)) + : path; +} + +function mediaKind(label: string): HermesHistoryMediaKind { + const normalized = label.toLowerCase(); + if (normalized.includes("image")) return "image"; + if (normalized.includes("audio")) return "audio"; + if (normalized.includes("video")) return "video"; + return "file"; +} + +function mediaKindForPath(path: string): HermesHistoryMediaKind { + const extension = NodePath.extname(path).toLowerCase(); + if ([".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp", ".tiff"].includes(extension)) { + return "image"; + } + if ([".mp4", ".mov", ".avi", ".mkv", ".webm"].includes(extension)) return "video"; + if ([".mp3", ".wav", ".ogg", ".opus", ".m4a", ".flac"].includes(extension)) return "audio"; + return "file"; +} + +function normalizedMediaTagPath(raw: string): string { + let path = raw.trim(); + if ( + path.length >= 2 && + path[0] === path[path.length - 1] && + (path[0] === "`" || path[0] === '"' || path[0] === "'") + ) { + path = path.slice(1, -1).trim(); + } + return path.replace(/^[`"']+/u, "").replace(/[`"',.;:)}\]]+$/u, ""); +} + +function protectedMediaSpans(text: string): ReadonlyArray { + const spans: Array = []; + for (const match of text.matchAll(/```[^\n]*\n[\s\S]*?```/gu)) { + spans.push([match.index, match.index + match[0].length]); + } + for (const match of text.matchAll(/`[^`\n]+`/gu)) { + const prefix = text.slice(Math.max(0, match.index - 20), match.index); + if (!/MEDIA:\s*$/iu.test(prefix)) { + spans.push([match.index, match.index + match[0].length]); + } + } + for (const match of text.matchAll(/^>.*$/gmu)) { + spans.push([match.index, match.index + match[0].length]); + } + // Hermes deliberately ignores MEDIA references embedded in serialized tool + // result values so replaying stored JSON cannot re-deliver an old local file. + for (const match of text.matchAll(/(?<=:|,|\{|\[)\s*"((?:[^"\\\n]|\\.)*)"/gu)) { + const body = match[1]; + if (body?.match(/MEDIA:\s*(?:~\/|\/|[A-Za-z]:[/\\])/u)) { + spans.push([match.index, match.index + match[0].length]); + } + } + return spans; +} + +function isProtectedSpan( + start: number, + end: number, + spans: ReadonlyArray, +): boolean { + return spans.some( + ([protectedStart, protectedEnd]) => start < protectedEnd && end > protectedStart, + ); +} + +function extractAssistantMedia(text: string): ParsedHermesHistoryText { + if (!text.includes("MEDIA:")) { + return { + text: text.replaceAll("[[audio_as_voice]]", "").replaceAll("[[as_document]]", "").trim(), + media: [], + }; + } + const media: HermesHistoryMediaReference[] = []; + const removalSpans: Array = []; + const protectedSpans = protectedMediaSpans(text); + for (const match of text.matchAll(HERMES_MEDIA_TAG)) { + const path = normalizedMediaTagPath(match.groups?.path ?? ""); + const start = match.index; + const end = start + match[0].length; + if (!path || isProtectedSpan(start, end, protectedSpans)) continue; + media.push({ kind: mediaKindForPath(path), path }); + removalSpans.push([start, end]); + } + // Upstream also accepts extension-less and unknown-extension files after + // filesystem validation. Capturing the conservative, no-whitespace form + // here prevents a partially streamed local path from flashing in the UI; + // persistence remains the authority on whether it is safe and supported. + for (const match of text.matchAll(HERMES_MEDIA_FALLBACK_TAG)) { + const path = normalizedMediaTagPath(match.groups?.path ?? ""); + const start = match.index; + const end = start + match[0].length; + if ( + !path || + isProtectedSpan(start, end, protectedSpans) || + isProtectedSpan(start, end, removalSpans) + ) { + continue; + } + media.push({ kind: mediaKindForPath(path), path }); + removalSpans.push([start, end]); + } + + let visible = text; + for (const [start, end] of removalSpans.toSorted((left, right) => right[0] - left[0])) { + visible = `${visible.slice(0, start)}${visible.slice(end)}`; + } + visible = visible + .replaceAll("[[audio_as_voice]]", "") + .replaceAll("[[as_document]]", "") + .replace(/[ \t]+\n/gu, "\n") + .replace(/\n{3,}/gu, "\n\n") + .trim(); + return { text: visible, media }; +} + +function stripTransportSenderPrefix(text: string): string { + const match = TRANSPORT_SENDER_PREFIX.exec(text); + const label = match?.[1]?.trim(); + if ( + match === null || + label === undefined || + (!/\p{L}/u.test(label) && !label.includes("|")) || + /[:'"{}\r\n]/u.test(label) || + RESERVED_TRANSPORT_LABELS.has(label.toLowerCase()) || + (match[0].endsWith("\n") && !label.includes("|")) + ) { + return text; + } + return text.slice(match[0].length); +} + +/** + * Parses display artifacts emitted by Hermes transports plus its outbound + * MEDIA protocol. Protected examples remain prose, while real assistant/tool + * directives and native-image persistence lines become attachment references. + */ +export function parseHermesHistoryText(input: { + readonly role: "user" | "assistant" | "tool" | "system"; + readonly text: string; +}): ParsedHermesHistoryText { + if (input.role === "assistant" || input.role === "tool") { + return extractAssistantMedia(input.text); + } + if (input.role !== "user") return { text: input.text, media: [] }; + + const media: HermesHistoryMediaReference[] = []; + let sawScreenshotPlaceholder = false; + const visibleLines: string[] = []; + for (const line of input.text.split(/\r?\n/u)) { + const match = MEDIA_REFERENCE_LINE.exec(line.trim()); + if (match?.[1] !== undefined && match[2] !== undefined) { + media.push({ kind: mediaKind(match[1]), path: match[2].trim() }); + continue; + } + if (REDUNDANT_IMAGE_PLACEHOLDER_LINE.test(line.trim())) { + sawScreenshotPlaceholder = true; + continue; + } + visibleLines.push(line); + } + + let text = stripTransportSenderPrefix(visibleLines.join("\n")).trim(); + if (sawScreenshotPlaceholder && media.length === 0) { + text = [text, "[Image unavailable]"].filter(Boolean).join("\n\n"); + } + return { text, media }; +} + +function isPathInsideRoot(root: string, candidate: string): boolean { + const relative = NodePath.relative(root, candidate); + if (relative === "") return true; + return ( + !relative.startsWith(`..${NodePath.sep}`) && relative !== ".." && !NodePath.isAbsolute(relative) + ); +} + +function detectSupportedMedia(bytes: Uint8Array): { + readonly type: ChatAttachment["type"]; + readonly mimeType: string; +} | null { + const ascii = (start: number, end: number) => + Buffer.from(bytes.subarray(start, end)).toString("ascii"); + if ( + bytes.length >= 8 && + bytes[0] === 0x89 && + ascii(1, 4) === "PNG" && + bytes[4] === 0x0d && + bytes[5] === 0x0a && + bytes[6] === 0x1a && + bytes[7] === 0x0a + ) { + return { type: "image", mimeType: "image/png" }; + } + if (bytes.length >= 3 && bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff) { + return { type: "image", mimeType: "image/jpeg" }; + } + if (ascii(0, 6) === "GIF87a" || ascii(0, 6) === "GIF89a") { + return { type: "image", mimeType: "image/gif" }; + } + if (ascii(0, 4) === "RIFF" && ascii(8, 12) === "WEBP") { + return { type: "image", mimeType: "image/webp" }; + } + if (ascii(0, 2) === "BM") return { type: "image", mimeType: "image/bmp" }; + if (ascii(0, 4) === "II*\0" || ascii(0, 4) === "MM\0*") { + return { type: "image", mimeType: "image/tiff" }; + } + if (ascii(0, 5) === "%PDF-") return { type: "pdf", mimeType: "application/pdf" }; + if (ascii(4, 8) === "ftyp") { + const brand = ascii(8, 12); + if (/^(?:M4A |M4B |M4P )$/u.test(brand)) { + return { type: "file", mimeType: "audio/mp4" }; + } + return { + type: "video", + mimeType: brand === "qt " ? "video/quicktime" : "video/mp4", + }; + } + if (bytes.length >= 4 && bytes[0] === 0x1a && bytes[1] === 0x45 && bytes[2] === 0xdf) { + return { type: "video", mimeType: "video/webm" }; + } + if (ascii(0, 4) === "OggS") return { type: "file", mimeType: "audio/ogg" }; + if (ascii(0, 4) === "fLaC") return { type: "file", mimeType: "audio/flac" }; + if (ascii(0, 4) === "RIFF" && ascii(8, 12) === "WAVE") { + return { type: "file", mimeType: "audio/wav" }; + } + if ( + ascii(0, 3) === "ID3" || + (bytes.length >= 2 && bytes[0] === 0xff && (bytes[1]! & 0xe0) === 0xe0) + ) { + return { type: "file", mimeType: "audio/mpeg" }; + } + return null; +} + +const GENERIC_FILE_MIME_BY_EXTENSION: Readonly> = { + ".7z": "application/x-7z-compressed", + ".apk": "application/vnd.android.package-archive", + ".bz2": "application/x-bzip2", + ".csv": "text/csv", + ".doc": "application/msword", + ".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + ".epub": "application/epub+zip", + ".flac": "audio/flac", + ".gz": "application/gzip", + ".html": "text/html", + ".htm": "text/html", + ".ipa": "application/octet-stream", + ".json": "application/json", + ".key": "application/octet-stream", + ".m4a": "audio/mp4", + ".md": "text/markdown", + ".odp": "application/vnd.oasis.opendocument.presentation", + ".ods": "application/vnd.oasis.opendocument.spreadsheet", + ".odt": "application/vnd.oasis.opendocument.text", + ".ogg": "audio/ogg", + ".opus": "audio/ogg", + ".ppt": "application/vnd.ms-powerpoint", + ".pptx": "application/vnd.openxmlformats-officedocument.presentationml.presentation", + ".rar": "application/vnd.rar", + ".rtf": "application/rtf", + ".svg": "image/svg+xml", + ".tar": "application/x-tar", + ".tgz": "application/gzip", + ".tsv": "text/tab-separated-values", + ".txt": "text/plain", + ".xls": "application/vnd.ms-excel", + ".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + ".xml": "application/xml", + ".xz": "application/x-xz", + ".yaml": "application/yaml", + ".yml": "application/yaml", + ".zip": "application/zip", +}; + +function supportedGenericFile(path: string): { + readonly type: ChatAttachment["type"]; + readonly mimeType: string; +} | null { + const mimeType = GENERIC_FILE_MIME_BY_EXTENSION[NodePath.extname(path).toLowerCase()]; + return mimeType === undefined ? null : { type: "file", mimeType }; +} + +function safeAttachmentName(path: string, mimeType: string): string { + const fallback = + mimeType === "image/png" + ? "image.png" + : mimeType === "image/jpeg" + ? "image.jpg" + : mimeType === "image/webp" + ? "image.webp" + : mimeType === "application/pdf" + ? "document.pdf" + : "attachment"; + const name = Array.from(NodePath.basename(path), (character) => { + const code = character.charCodeAt(0); + return code <= 0x1f || code === 0x7f ? "_" : character; + }) + .join("") + .trim(); + return (name || fallback).slice(0, 255); +} + +export function hermesHistoryMediaRoots(input: { + readonly hermesHome?: string | undefined; + readonly profileKey: string; + readonly extraRoots?: ReadonlyArray | undefined; +}): ReadonlyArray { + const defaultHome = NodePath.join(NodeOS.homedir(), ".hermes"); + const configuredHome = NodePath.resolve( + expandHomePrefix(input.hermesHome?.trim() || defaultHome), + ); + const homes = new Set([configuredHome]); + if (/^[a-z0-9][a-z0-9_-]{0,63}$/i.test(input.profileKey) && input.profileKey !== "default") { + homes.add(NodePath.join(configuredHome, "profiles", input.profileKey)); + } + const roots = [...homes].flatMap((home) => [ + NodePath.join(home, "cache", "images"), + NodePath.join(home, "cache", "audio"), + NodePath.join(home, "cache", "videos"), + NodePath.join(home, "cache", "video"), + NodePath.join(home, "cache", "documents"), + NodePath.join(home, "cache", "screenshots"), + NodePath.join(home, "cache", "vision"), + NodePath.join(home, "image_cache"), + NodePath.join(home, "audio_cache"), + NodePath.join(home, "video_cache"), + NodePath.join(home, "document_cache"), + NodePath.join(home, "browser_screenshots"), + NodePath.join(home, "temp_video_files"), + NodePath.join(home, "temp_vision_images"), + ]); + // HERMES_MEDIA_ALLOW_DIRS is an explicit, provider-instance-scoped operator + // extension point. Deliberately do not trust general user output locations + // or the entire Hermes home/cache tree. + for (const root of input.extraRoots ?? []) { + const expanded = expandHomePrefix(root); + if (NodePath.isAbsolute(expanded)) roots.push(NodePath.resolve(expanded)); + } + return [...new Set(roots)]; +} + +export const persistHermesHistoryMedia = Effect.fn("persistHermesHistoryMedia")(function* (input: { + readonly sourcePath: string; + readonly expectedKind: HermesHistoryMediaKind; + readonly approvedRoots: ReadonlyArray; + readonly attachmentsDir: string; + readonly threadId: string; + readonly stableKey: string; +}): Effect.fn.Return { + if (input.sourcePath.includes("\0")) return null; + + return yield* Effect.tryPromise({ + try: async () => { + const expandedSourcePath = expandHomePrefix(input.sourcePath); + if (!NodePath.isAbsolute(expandedSourcePath)) return null; + const resolvedSourcePath = NodePath.resolve(expandedSourcePath); + const configuredRoots = input.approvedRoots.map((root) => NodePath.resolve(root)); + if (!configuredRoots.some((root) => isPathInsideRoot(root, resolvedSourcePath))) return null; + const rawId = createDeterministicAttachmentId( + input.threadId, + `${input.stableKey}:${resolvedSourcePath}`, + ); + if (rawId === null) return null; + + const attachmentFromBytes = ( + bytes: Uint8Array, + sourceName: string, + ): ChatAttachment | null => { + const detected = detectSupportedMedia(bytes) ?? supportedGenericFile(sourceName); + if ( + detected === null || + (input.expectedKind === "image" && detected.type !== "image") || + (input.expectedKind === "video" && detected.type !== "video") || + (input.expectedKind === "audio" && !detected.mimeType.startsWith("audio/")) + ) { + return null; + } + const maxBytes = + detected.type === "image" + ? PROVIDER_SEND_TURN_MAX_IMAGE_BYTES + : PROVIDER_SEND_TURN_MAX_FILE_BYTES; + if (bytes.byteLength > maxBytes) return null; + return { + type: detected.type, + id: ChatAttachmentId.make(rawId), + name: safeAttachmentName(sourceName, detected.mimeType), + mimeType: detected.mimeType, + sizeBytes: bytes.byteLength, + }; + }; + + // Hermes prunes transport caches. Once T3 has safely imported a file, + // keep using that durable copy even if the original cache entry is gone. + const existingPath = resolveAttachmentPathById({ + attachmentsDir: input.attachmentsDir, + attachmentId: rawId, + }); + if (existingPath !== null) { + const existingStat = await NodeFS.promises.stat(existingPath); + if (existingStat.isFile() && existingStat.size <= PROVIDER_SEND_TURN_MAX_FILE_BYTES) { + const existingAttachment = attachmentFromBytes( + await NodeFS.promises.readFile(existingPath), + input.sourcePath, + ); + if (existingAttachment !== null) return existingAttachment; + } + } + + const canonicalRoots = ( + await Promise.all( + configuredRoots.map((root) => NodeFS.promises.realpath(root).catch(() => null)), + ) + ).filter((root): root is string => root !== null); + const canonicalSource = await NodeFS.promises.realpath(resolvedSourcePath); + if (!canonicalRoots.some((root) => isPathInsideRoot(root, canonicalSource))) return null; + + const before = await NodeFS.promises.stat(canonicalSource); + if (!before.isFile() || before.size > PROVIDER_SEND_TURN_MAX_FILE_BYTES) return null; + const handle = await NodeFS.promises.open( + canonicalSource, + NodeFS.constants.O_RDONLY | (NodeFS.constants.O_NOFOLLOW ?? 0), + ); + let bytes: Uint8Array; + try { + const opened = await handle.stat(); + if ( + !opened.isFile() || + opened.dev !== before.dev || + opened.ino !== before.ino || + opened.size !== before.size + ) { + return null; + } + bytes = await handle.readFile(); + } finally { + await handle.close(); + } + + const attachment = attachmentFromBytes(bytes, canonicalSource); + if (attachment === null) return null; + await NodeFS.promises.mkdir(input.attachmentsDir, { recursive: true }); + await NodeFS.promises.writeFile( + NodePath.join(input.attachmentsDir, attachmentRelativePath(attachment)), + bytes, + ); + return attachment; + }, + catch: () => null, + }).pipe(Effect.orElseSucceed(() => null)); +}); + +export const normalizeHermesHistoryMessage = Effect.fn("normalizeHermesHistoryMessage")( + function* (input: { + readonly role: "user" | "assistant" | "tool" | "system"; + readonly text: string; + readonly resolveMedia: ( + media: HermesHistoryMediaReference, + index: number, + ) => Effect.Effect; + }) { + const parsed = parseHermesHistoryText({ role: input.role, text: input.text }); + const resolved = yield* Effect.forEach( + parsed.media.map((media, index) => ({ media, index })), + ({ media, index }) => input.resolveMedia(media, index), + { concurrency: 2 }, + ); + const attachments = resolved.filter( + (attachment): attachment is ChatAttachment => attachment !== null, + ); + const missingKinds = parsed.media + .filter((_, index) => resolved[index] === null) + .map((media) => media.kind); + const placeholders = [...new Set(missingKinds)].map((kind) => + kind === "image" + ? "[Image unavailable]" + : `[${kind[0]!.toUpperCase()}${kind.slice(1)} unavailable]`, + ); + return { + text: [parsed.text, ...placeholders].filter(Boolean).join("\n\n"), + attachments, + }; + }, +); diff --git a/apps/server/src/hermes/HermesImportHydration.test.ts b/apps/server/src/hermes/HermesImportHydration.test.ts new file mode 100644 index 00000000000..e6c0cbb4d43 --- /dev/null +++ b/apps/server/src/hermes/HermesImportHydration.test.ts @@ -0,0 +1,276 @@ +import { describe, expect, it } from "@effect/vitest"; +import type { HermesGatewayHistoryMessage } from "@t3tools/contracts"; +import { + HERMES_IMPORT_MAX_OUTPUT_CHARS, + HERMES_IMPORT_TRUNCATION_MARKER, + extractHermesReasoningText, + hydrateImportedHermesActivities, + isSyntheticHermesTranscriptRow, + normalizeImportedHermesUserText, + parseHermesToolArguments, + truncateHermesToolOutput, +} from "./HermesImportHydration.ts"; + +function assistantCall(input: { + readonly id: string; + readonly name: string; + readonly args: string; + readonly text?: string; +}): HermesGatewayHistoryMessage { + return { + role: "assistant", + text: input.text ?? "", + tool_calls: [{ id: input.id, function: { name: input.name, arguments: input.args } }], + }; +} + +function toolResult(input: { + readonly callId: string; + readonly text: string; +}): HermesGatewayHistoryMessage { + return { role: "tool", text: input.text, tool_call_id: input.callId }; +} + +describe("parseHermesToolArguments", () => { + it("parses valid JSON into structured input", () => { + expect(parseHermesToolArguments('{"command":"ls -la"}')).toEqual({ command: "ls -la" }); + }); + + it("preserves malformed payloads as raw strings without throwing", () => { + expect(parseHermesToolArguments("{not json")).toEqual({ raw: "{not json" }); + }); + + it("treats empty and missing arguments as empty input", () => { + expect(parseHermesToolArguments("")).toEqual({}); + expect(parseHermesToolArguments(undefined)).toEqual({}); + }); +}); + +describe("truncateHermesToolOutput", () => { + it("bounds oversized output with an explicit marker", () => { + const oversized = "x".repeat(HERMES_IMPORT_MAX_OUTPUT_CHARS + 100); + const truncated = truncateHermesToolOutput(oversized); + expect(truncated.endsWith(HERMES_IMPORT_TRUNCATION_MARKER)).toBe(true); + expect(truncated.length).toBe( + HERMES_IMPORT_MAX_OUTPUT_CHARS + HERMES_IMPORT_TRUNCATION_MARKER.length, + ); + }); + + it("leaves bounded output untouched", () => { + expect(truncateHermesToolOutput("small")).toBe("small"); + }); +}); + +describe("extractHermesReasoningText", () => { + it("reads plain reasoning strings", () => { + expect(extractHermesReasoningText({ role: "assistant", reasoning: "thinking..." })).toBe( + "thinking...", + ); + }); + + it("reads structured reasoning arrays", () => { + expect( + extractHermesReasoningText({ + role: "assistant", + reasoning_details: [{ text: "step one" }, { text: "step two" }], + }), + ).toBe("step one\nstep two"); + }); + + it("returns empty for messages without reasoning", () => { + expect(extractHermesReasoningText({ role: "assistant", text: "hi" })).toBe(""); + }); +}); + +describe("normalizeImportedHermesUserText", () => { + it("strips shared-session sender prefixes", () => { + expect(normalizeImportedHermesUserText("[maria] hello there")).toBe("hello there"); + }); + + it("strips mirror delivery prefixes", () => { + expect(normalizeImportedHermesUserText("[Delivered from another session] the update")).toBe( + "the update", + ); + }); + + it("keeps only the addressed message from channel backfill blocks", () => { + expect( + normalizeImportedHermesUserText("older channel chatter\n\n[New message]\n[maria] what now?"), + ).toBe("what now?"); + }); + + it("keeps only the addressed message from observed group context", () => { + expect( + normalizeImportedHermesUserText( + [ + "[Observed Telegram group context - context only, not requests]", + "someone: unrelated", + "", + "[Current addressed message - answer only this unless it explicitly asks you to use the observed context]", + "the real ask", + ].join("\n"), + ), + ).toBe("the real ask"); + }); + + it("reduces attachment envelopes to compact markers", () => { + expect( + normalizeImportedHermesUserText( + "[User sent an image: https://cdn.example/img.png]\ncheck this out", + ), + ).toBe("[Attachment: https://cdn.example/img.png]\ncheck this out"); + expect( + normalizeImportedHermesUserText( + "[The user sent a document: 'report.pdf'. It is saved at: /tmp/report.pdf. Use tools to read it.]", + ), + ).toBe("[Attachment: report.pdf]"); + }); + + it("does not treat transport markers as sender names", () => { + expect(normalizeImportedHermesUserText("[User sent a file: notes.txt]")).toBe( + "[Attachment: notes.txt]", + ); + }); + + it("leaves plain messages untouched", () => { + expect(normalizeImportedHermesUserText("just a normal [bracketed later] message")).toBe( + "just a normal [bracketed later] message", + ); + }); +}); + +describe("hydrateImportedHermesActivities", () => { + it("pairs tool calls with results into completed activities", () => { + const { activities, hiddenOrdinals } = hydrateImportedHermesActivities([ + { role: "user", text: "run it" }, + assistantCall({ id: "call-1", name: "custom_tool", args: '{"value":1}' }), + toolResult({ callId: "call-1", text: "done" }), + ]); + expect(activities).toHaveLength(1); + const activity = activities[0]!; + expect(activity.kind).toBe("dynamic_tool"); + if (activity.kind === "dynamic_tool") { + expect(activity.toolName).toBe("custom_tool"); + expect(activity.input).toEqual({ value: 1 }); + expect(activity.output).toBe("done"); + expect(activity.status).toBe("completed"); + } + // The bare-call assistant row and the tool-result row are subsumed. + expect(hiddenOrdinals).toEqual(new Set([1, 2])); + }); + + it("maps terminal tools to command executions", () => { + const { activities } = hydrateImportedHermesActivities([ + assistantCall({ id: "c", name: "terminal", args: '{"command":"git status"}' }), + toolResult({ callId: "c", text: "clean" }), + ]); + expect(activities[0]).toMatchObject({ + kind: "command_execution", + input: "git status", + output: "clean", + status: "completed", + }); + }); + + it("maps file tools to file changes", () => { + const { activities } = hydrateImportedHermesActivities([ + assistantCall({ id: "c", name: "edit_file", args: '{"path":"src/app.ts"}' }), + toolResult({ callId: "c", text: "ok" }), + ]); + expect(activities[0]).toMatchObject({ kind: "file_change", fileName: "src/app.ts" }); + }); + + it("maps web search tools to web searches", () => { + const { activities } = hydrateImportedHermesActivities([ + assistantCall({ id: "c", name: "web_search", args: '{"query":"effect ts"}' }), + toolResult({ callId: "c", text: "results" }), + ]); + expect(activities[0]).toMatchObject({ kind: "web_search", patterns: ["effect ts"] }); + }); + + it("keeps unmatched calls as stopped activities", () => { + const { activities } = hydrateImportedHermesActivities([ + assistantCall({ id: "orphan", name: "terminal", args: '{"command":"sleep 100"}' }), + ]); + expect(activities[0]).toMatchObject({ + kind: "command_execution", + input: "sleep 100", + status: "cancelled", + }); + }); + + it("rehydrates reasoning as an activity", () => { + const { activities } = hydrateImportedHermesActivities([ + { role: "assistant", text: "answer", reasoning: "chain of thought" }, + ]); + expect(activities[0]).toMatchObject({ kind: "reasoning", text: "chain of thought" }); + }); + + it("preserves stock normalized tool rows as generic activities", () => { + const { activities, hiddenOrdinals } = hydrateImportedHermesActivities([ + { role: "tool", name: "notes", context: "saved a note" }, + ]); + expect(activities[0]).toMatchObject({ + kind: "dynamic_tool", + toolName: "notes", + input: { context: "saved a note" }, + }); + expect(hiddenOrdinals.has(0)).toBe(true); + }); + + it("suppresses synthetic transcript rows", () => { + const synthetic: HermesGatewayHistoryMessage = { + role: "user", + text: "[delegation completed]", + display_kind: "async_delegation_complete", + }; + expect(isSyntheticHermesTranscriptRow(synthetic)).toBe(true); + const { activities, hiddenOrdinals } = hydrateImportedHermesActivities([synthetic]); + expect(activities).toHaveLength(0); + expect(hiddenOrdinals.has(0)).toBe(true); + }); + + it("suppresses gateway-forged delegation completion notifications", () => { + const { activities, hiddenOrdinals } = hydrateImportedHermesActivities([ + { role: "user", text: "[ASYNC DELEGATION COMPLETE — deleg-42]\nResult summary..." }, + { role: "user", text: '[IMPORTANT: Background process 7 matched watch pattern "done"]' }, + ]); + expect(activities).toHaveLength(0); + expect(hiddenOrdinals).toEqual(new Set([0, 1])); + }); + + it("keeps visible assistant text rows in the transcript while extracting their calls", () => { + const { hiddenOrdinals } = hydrateImportedHermesActivities([ + assistantCall({ id: "c", name: "terminal", args: '{"command":"ls"}', text: "running ls" }), + toolResult({ callId: "c", text: "files" }), + ]); + expect(hiddenOrdinals.has(0)).toBe(false); + expect(hiddenOrdinals.has(1)).toBe(true); + }); + + it("is deterministic: stable keys and history-ordered output", () => { + const history: ReadonlyArray = [ + assistantCall({ id: "b", name: "terminal", args: '{"command":"two"}' }), + toolResult({ callId: "b", text: "2" }), + { role: "assistant", text: "", reasoning: "later thought" }, + ]; + const first = hydrateImportedHermesActivities(history); + const second = hydrateImportedHermesActivities(history); + expect(first).toEqual(second); + expect(first.activities.map((activity) => activity.ordinal)).toEqual( + first.activities.map((activity) => activity.ordinal).sort((a, b) => a - b), + ); + }); + + it("handles malformed tool_call payload shapes without throwing", () => { + const { activities } = hydrateImportedHermesActivities([ + { + role: "assistant", + text: "", + tool_calls: [null, "junk", { function: {} }, { id: "x", function: { name: "t" } }], + }, + ]); + expect(activities).toHaveLength(1); + expect(activities[0]).toMatchObject({ kind: "dynamic_tool", status: "cancelled" }); + }); +}); diff --git a/apps/server/src/hermes/HermesImportHydration.ts b/apps/server/src/hermes/HermesImportHydration.ts new file mode 100644 index 00000000000..8a87c398e4e --- /dev/null +++ b/apps/server/src/hermes/HermesImportHydration.ts @@ -0,0 +1,468 @@ +import type { HermesGatewayHistoryMessage } from "@t3tools/contracts"; + +/** + * Rehydrates imported Hermes history into native T3 activity descriptors. + * + * Hermes `session.history` interleaves assistant reasoning, structured tool + * calls, and tool-result rows with the plain transcript. This module pairs + * calls with results by tool-call id, maps tool categories onto the native + * turn-item presentations, and reports which transcript rows are subsumed by + * a rehydrated activity so they can be hidden from the displayed + * conversation while keeping their history ordinals stable. + */ + +export const HERMES_IMPORT_MAX_OUTPUT_CHARS = 20_000; +export const HERMES_IMPORT_TRUNCATION_MARKER = "\n\n[output truncated]"; + +/** + * Synthetic transcript rows Hermes injects for its own timeline (model + * switches, delegation completion pings, auto-continue prompts). They are + * not conversation content and are suppressed from the imported transcript. + */ +const SYNTHETIC_DISPLAY_KINDS = new Set([ + "model_switch", + "async_delegation_complete", + "auto_continue", +]); + +export type HermesImportedActivity = + | { + readonly kind: "reasoning"; + readonly key: string; + readonly ordinal: number; + readonly text: string; + } + | { + readonly kind: "command_execution"; + readonly key: string; + readonly ordinal: number; + readonly status: "completed" | "cancelled"; + readonly title: string | null; + readonly input: string; + readonly output: string | undefined; + } + | { + readonly kind: "file_change"; + readonly key: string; + readonly ordinal: number; + readonly status: "completed" | "cancelled"; + readonly title: string | null; + readonly fileName: string; + } + | { + readonly kind: "web_search"; + readonly key: string; + readonly ordinal: number; + readonly status: "completed" | "cancelled"; + readonly title: string | null; + readonly patterns: ReadonlyArray; + } + | { + readonly kind: "dynamic_tool"; + readonly key: string; + readonly ordinal: number; + readonly status: "completed" | "cancelled"; + readonly title: string | null; + readonly toolName: string | null; + readonly input: unknown; + readonly output: string | undefined; + }; + +export interface HermesImportHydrationResult { + readonly activities: ReadonlyArray; + /** + * History ordinals whose transcript rows are fully represented by a + * rehydrated activity (tool results, synthetic notifications). They stay in + * Hermes storage for positional stability but are hidden from the displayed + * conversation. + */ + readonly hiddenOrdinals: ReadonlySet; +} + +interface ParsedToolCall { + readonly callId: string; + readonly name: string; + readonly input: unknown; + readonly ordinal: number; + readonly indexInMessage: number; +} + +/** + * Parses tool-call arguments without ever throwing: valid JSON becomes the + * structured input, anything else is preserved as the raw string. + */ +export function parseHermesToolArguments(raw: unknown): unknown { + if (typeof raw !== "string") return raw ?? {}; + const trimmed = raw.trim(); + if (trimmed.length === 0) return {}; + try { + return JSON.parse(trimmed) as unknown; + } catch { + return { raw }; + } +} + +export function truncateHermesToolOutput(output: string): string { + if (output.length <= HERMES_IMPORT_MAX_OUTPUT_CHARS) return output; + return output.slice(0, HERMES_IMPORT_MAX_OUTPUT_CHARS) + HERMES_IMPORT_TRUNCATION_MARKER; +} + +function asRecord(value: unknown): Record | null { + return typeof value === "object" && value !== null && !Array.isArray(value) + ? (value as Record) + : null; +} + +function readString(record: Record, key: string): string | null { + const value = record[key]; + return typeof value === "string" && value.length > 0 ? value : null; +} + +function extractToolCalls(message: HermesGatewayHistoryMessage, ordinal: number): ParsedToolCall[] { + if (!Array.isArray(message.tool_calls)) return []; + const calls: ParsedToolCall[] = []; + for (const [indexInMessage, entry] of message.tool_calls.entries()) { + const record = asRecord(entry); + if (record === null) continue; + const fn = asRecord(record["function"]); + const name = (fn === null ? null : readString(fn, "name")) ?? readString(record, "name"); + if (name === null) continue; + const callId = readString(record, "id") ?? `${ordinal}:${indexInMessage}`; + calls.push({ + callId, + name, + input: parseHermesToolArguments(fn?.["arguments"]), + ordinal, + indexInMessage, + }); + } + return calls; +} + +const TERMINAL_TOOL_NAMES = new Set([ + "terminal", + "bash", + "shell", + "run_command", + "execute_command", + "run_terminal_cmd", + "terminal_command", + "exec", +]); + +const FILE_TOOL_NAMES = new Set([ + "write_file", + "edit_file", + "create_file", + "apply_patch", + "str_replace", + "str_replace_editor", + "file_edit", + "write", + "edit", +]); + +const WEB_SEARCH_TOOL_NAMES = new Set([ + "web_search", + "search_web", + "brave_search", + "google_search", + "web-search", + "websearch", +]); + +function commandFromInput(input: unknown): string | null { + const record = asRecord(input); + if (record === null) return typeof input === "string" ? input : null; + return ( + readString(record, "command") ?? + readString(record, "cmd") ?? + readString(record, "input") ?? + readString(record, "script") + ); +} + +function fileNameFromInput(input: unknown): string | null { + const record = asRecord(input); + if (record === null) return null; + return ( + readString(record, "path") ?? + readString(record, "file_path") ?? + readString(record, "filename") ?? + readString(record, "file") + ); +} + +function searchPatternFromInput(input: unknown): string | null { + const record = asRecord(input); + if (record === null) return typeof input === "string" ? input : null; + return readString(record, "query") ?? readString(record, "q") ?? readString(record, "search"); +} + +function activityFromPairedCall(input: { + readonly call: ParsedToolCall; + readonly output: string | undefined; + readonly status: "completed" | "cancelled"; + readonly ordinal: number; +}): HermesImportedActivity { + const { call, status, ordinal } = input; + const key = `tool:${call.ordinal}:${call.indexInMessage}:${call.callId}`; + const normalizedName = call.name.toLowerCase(); + const output = input.output === undefined ? undefined : truncateHermesToolOutput(input.output); + + if (TERMINAL_TOOL_NAMES.has(normalizedName)) { + const command = commandFromInput(call.input); + if (command !== null) { + return { + kind: "command_execution", + key, + ordinal, + status, + title: command, + input: command, + output, + }; + } + } + + if (FILE_TOOL_NAMES.has(normalizedName)) { + const fileName = fileNameFromInput(call.input); + if (fileName !== null) { + return { kind: "file_change", key, ordinal, status, title: fileName, fileName }; + } + } + + if (WEB_SEARCH_TOOL_NAMES.has(normalizedName)) { + const pattern = searchPatternFromInput(call.input); + return { + kind: "web_search", + key, + ordinal, + status, + title: pattern, + patterns: pattern === null ? [] : [pattern], + }; + } + + return { + kind: "dynamic_tool", + key, + ordinal, + status, + title: call.name, + toolName: call.name, + input: call.input, + output, + }; +} + +function coerceReasoningText(value: unknown): string { + if (typeof value === "string") return value; + if (Array.isArray(value)) { + return value + .map((entry) => coerceReasoningText(entry)) + .filter((text) => text.length > 0) + .join("\n"); + } + const record = asRecord(value); + if (record === null) return ""; + for (const key of ["text", "content", "summary", "reasoning"]) { + const nested = record[key]; + if (nested !== undefined) { + const text = coerceReasoningText(nested); + if (text.length > 0) return text; + } + } + return ""; +} + +export function extractHermesReasoningText(message: HermesGatewayHistoryMessage): string { + for (const value of [ + message.reasoning, + message.reasoning_content, + message.reasoning_details, + message.codex_reasoning_items, + ]) { + if (value === undefined || value === null) continue; + const text = coerceReasoningText(value).trim(); + if (text.length > 0) return text; + } + return ""; +} + +/** + * Gateway-forged notification rows (async delegation completions, background + * process watch matches) are replayed to the model as user turns but were + * never typed by a person. + */ +const SYNTHETIC_NOTIFICATION_PATTERN = + /^\[(?:ASYNC DELEGATION (?:BATCH )?COMPLETE|IMPORTANT: Background process)\b/u; + +export function isSyntheticHermesTranscriptRow(message: HermesGatewayHistoryMessage): boolean { + if (message.display_kind !== undefined && SYNTHETIC_DISPLAY_KINDS.has(message.display_kind)) { + return true; + } + return ( + message.role === "user" && SYNTHETIC_NOTIFICATION_PATTERN.test((message.text ?? "").trimStart()) + ); +} + +const OBSERVED_CONTEXT_HEADER_PATTERN = /^\[Observed [^\]\n]+ group context[^\]\n]*\]$/u; +const ADDRESSED_MESSAGE_HEADER_PATTERN = /^\[Current addressed message[^\]\n]*\]$/u; +const NEW_MESSAGE_HEADER = "[New message]"; +const DELIVERED_FROM_PREFIX = /^\[Delivered from [^\]\n]+\]\s*/u; +const SENDER_PREFIX = /^\[(?[^\]\n]{1,120})\]\s+/u; +const SENDER_PREFIX_EXCLUSIONS = + /^(?:User sent |The user sent |Delivered from |New message$|Observed |Current addressed message|IMPORTANT:|ASYNC DELEGATION )/u; +const ATTACHMENT_ENVELOPE_LINE = + /^\[User sent (?:an image|audio|a video|a file): (?[^\]\n]+)\]$/u; +const ATTACHMENT_DOCUMENT_LINE = + /^\[The user sent (?:a text document|a document|an audio file attachment|a video attachment): '(?[^'\n]+)'\.[^\]]*\]$/u; + +/** + * Strips Hermes gateway transport framing from an imported user message: + * observed-group-context backfill, "[New message]" routing headers, + * "[Delivered from ...]" mirror prefixes, shared-session "[Sender] " name + * prefixes, and attachment delivery envelopes (reduced to a compact + * "[Attachment: ...]" marker). Applies only to inherited transcript rows; + * native T3 messages are never passed through this. + */ +export function normalizeImportedHermesUserText(text: string): string { + let value = text; + + // Channel backfill blocks: keep only the addressed message. + const newMessageIndex = value.indexOf(`\n\n${NEW_MESSAGE_HEADER}\n`); + if (newMessageIndex !== -1) { + value = value.slice(newMessageIndex + NEW_MESSAGE_HEADER.length + 3); + } + const lines = value.split("\n"); + if (lines.some((line) => OBSERVED_CONTEXT_HEADER_PATTERN.test(line.trim()))) { + const addressedIndex = lines.findIndex((line) => + ADDRESSED_MESSAGE_HEADER_PATTERN.test(line.trim()), + ); + if (addressedIndex !== -1) { + value = lines.slice(addressedIndex + 1).join("\n"); + } + } + + value = value.replace(DELIVERED_FROM_PREFIX, ""); + + const sender = SENDER_PREFIX.exec(value); + if (sender?.groups?.["name"] !== undefined) { + const name = sender.groups["name"]; + if (!SENDER_PREFIX_EXCLUSIONS.test(name)) { + value = value.slice(sender[0].length); + } + } + + value = value + .split("\n") + .map((line) => { + const trimmed = line.trim(); + const envelope = ATTACHMENT_ENVELOPE_LINE.exec(trimmed); + if (envelope?.groups?.["target"] !== undefined) { + return `[Attachment: ${envelope.groups["target"]}]`; + } + const documentEnvelope = ATTACHMENT_DOCUMENT_LINE.exec(trimmed); + if (documentEnvelope?.groups?.["name"] !== undefined) { + return `[Attachment: ${documentEnvelope.groups["name"]}]`; + } + return line; + }) + .join("\n"); + + return value.trim(); +} + +/** + * Walks the inherited portion of an imported Hermes history and produces the + * native activities plus the transcript rows they subsume. Deterministic: + * activity keys derive from history ordinals and tool-call ids, and the + * output is ordered by history position. + */ +export function hydrateImportedHermesActivities( + messages: ReadonlyArray, +): HermesImportHydrationResult { + const activities: HermesImportedActivity[] = []; + const hiddenOrdinals = new Set(); + const pendingCalls = new Map(); + + for (const [ordinal, message] of messages.entries()) { + if (isSyntheticHermesTranscriptRow(message)) { + hiddenOrdinals.add(ordinal); + continue; + } + + if (message.role === "assistant") { + const reasoningText = extractHermesReasoningText(message); + if (reasoningText.length > 0) { + activities.push({ + kind: "reasoning", + key: `reasoning:${ordinal}`, + ordinal, + text: reasoningText, + }); + } + for (const call of extractToolCalls(message, ordinal)) { + pendingCalls.set(call.callId, call); + } + if ((message.text ?? "").trim().length === 0) { + hiddenOrdinals.add(ordinal); + } + continue; + } + + if (message.role === "tool") { + hiddenOrdinals.add(ordinal); + const callId = message.tool_call_id; + const pending = callId === undefined ? undefined : pendingCalls.get(callId); + if (pending !== undefined && callId !== undefined) { + pendingCalls.delete(callId); + activities.push( + activityFromPairedCall({ + call: pending, + output: message.text ?? message.context, + status: "completed", + ordinal, + }), + ); + continue; + } + const toolName = message.tool_name ?? message.name; + const preview = message.context ?? message.text; + if (toolName !== undefined || preview !== undefined) { + activities.push({ + kind: "dynamic_tool", + key: `tool-result:${ordinal}`, + ordinal, + status: "completed", + title: toolName ?? null, + toolName: toolName ?? null, + input: preview === undefined ? {} : { context: preview }, + output: undefined, + }); + } + } + } + + // Calls that never received a result were interrupted mid-run; keep them + // visible as stopped activities instead of silently dropping them. + for (const call of pendingCalls.values()) { + activities.push( + activityFromPairedCall({ + call, + output: undefined, + status: "cancelled", + ordinal: call.ordinal, + }), + ); + } + + activities.sort((left, right) => + left.ordinal !== right.ordinal + ? left.ordinal - right.ordinal + : left.key.localeCompare(right.key), + ); + + return { activities, hiddenOrdinals }; +} diff --git a/apps/server/src/hermes/HermesOperational.test.ts b/apps/server/src/hermes/HermesOperational.test.ts new file mode 100644 index 00000000000..81812d3f054 --- /dev/null +++ b/apps/server/src/hermes/HermesOperational.test.ts @@ -0,0 +1,166 @@ +import { describe, expect, it } from "vite-plus/test"; +import { HermesSettings, type HermesGatewayCompatibility } from "@t3tools/contracts"; +import * as Schema from "effect/Schema"; + +import { + assessHermesOnboarding, + buildHermesOperationalDiagnostics, + deriveHermesUpgradeGate, + projectHermesFeatureDiagnostics, + projectHermesRecoveryControls, + sanitizeHermesImportProgress, +} from "./HermesOperational.ts"; + +const decodeHermesSettings = Schema.decodeSync(HermesSettings); + +const settings = decodeHermesSettings({ + endpoint: "ws://127.0.0.1:9119/api/ws", + profileKey: "work", +}); + +const supported: HermesGatewayCompatibility = { + status: "supported", + protocol: { major: 1, minor: 3 }, + capabilities: [ + "session.lifecycle", + "session.history", + "turn.prompt", + "turn.interrupt", + "attachments.image", + ], + inventory: null, + reason: "supported", + serverVersion: "1.3.0", +}; + +describe("Hermes operational onboarding and gates", () => { + it("validates local onboarding without exposing the gateway token", () => { + const result = assessHermesOnboarding(settings, { + gatewayToken: "private-gateway-token", + remoteGloballyEnabled: false, + remotePairingToken: undefined, + remoteTlsCertificateSha256: undefined, + }); + + expect(result.status).toBe("ready"); + expect(result.diagnosticEndpoint).not.toContain("private-gateway-token"); + }); + + it("requires an upgrade for unsupported protocols or missing recovery capabilities", () => { + expect( + deriveHermesUpgradeGate({ + ...supported, + protocol: { major: 2, minor: 0 }, + status: "unsupported", + }).status, + ).toBe("upgrade_required"); + expect( + deriveHermesUpgradeGate({ + ...supported, + capabilities: ["session.lifecycle"], + }), + ).toMatchObject({ + status: "upgrade_required", + missingCapabilities: ["session.history", "turn.prompt", "turn.interrupt"], + }); + }); + + it("keeps optional features off unless both instance switch and capability agree", () => { + const enabled = decodeHermesSettings({ + ...settings, + attachmentsEnabled: true, + importEnabled: true, + proactiveEnabled: true, + }); + const diagnostics = projectHermesFeatureDiagnostics(enabled, supported); + + expect(diagnostics.find((entry) => entry.feature === "attachments")).toMatchObject({ + requested: true, + available: true, + }); + expect(diagnostics.find((entry) => entry.feature === "import")).toMatchObject({ + requested: true, + available: false, + missingCapabilities: ["profile.import"], + }); + expect(diagnostics.find((entry) => entry.feature === "proactive")).toMatchObject({ + requested: true, + available: false, + }); + }); + + it("recognizes the negotiated ephemeral session MCP lease capability", () => { + const enabled = decodeHermesSettings({ + ...settings, + mcpEnabled: true, + }); + const diagnostics = projectHermesFeatureDiagnostics(enabled, { + ...supported, + capabilities: [...supported.capabilities, "session_mcp"], + }); + + expect(diagnostics.find((entry) => entry.feature === "mcp")).toMatchObject({ + requested: true, + available: true, + missingCapabilities: [], + }); + }); +}); + +describe("Hermes recovery and sanitized diagnostics", () => { + it("never offers process stop for an externally started gateway", () => { + const controls = projectHermesRecoveryControls({ + connectionState: "ready", + compatibility: supported, + processOwnership: "external", + ownedProcessStopAvailable: true, + }); + + expect(controls.find((control) => control.control === "reconnect")?.supported).toBe(true); + expect(controls.find((control) => control.control === "stop_owned_process")).toMatchObject({ + supported: false, + reason: "Hermes was started externally; T3 does not own or stop this process.", + }); + expect(controls.find((control) => control.control === "revoke_all")?.supported).toBe(false); + }); + + it("redacts every endpoint query value and emits counts instead of import details", () => { + const diagnostics = buildHermesOperationalDiagnostics({ + endpoint: "ws://127.0.0.1:9119/api/ws?token=secret&label=private", + profileKey: "work", + settings, + compatibility: supported, + connection: { + state: "ready", + reconnectAttempt: 0, + protocolStatus: "supported", + protocolMajor: 1, + protocolMinor: 3, + serverVersion: "1.3.0", + capabilities: supported.capabilities, + writesBlocked: false, + indeterminateMutationCount: 0, + }, + }); + const progress = sanitizeHermesImportProgress({ + status: "running", + completed: 3, + total: 10, + attempt: 1, + path: "/private/profile", + error: "secret", + }); + + expect(diagnostics.endpoint).not.toContain("secret"); + expect(diagnostics.endpoint).not.toContain("private"); + expect(diagnostics.processOwnership).toBe("external"); + expect(progress).toEqual({ + status: "running", + completed: 3, + total: 10, + attempt: 1, + canRetry: false, + canCancel: true, + }); + }); +}); diff --git a/apps/server/src/hermes/HermesOperational.ts b/apps/server/src/hermes/HermesOperational.ts new file mode 100644 index 00000000000..49477e875c2 --- /dev/null +++ b/apps/server/src/hermes/HermesOperational.ts @@ -0,0 +1,279 @@ +import type { HermesGatewayCompatibility, HermesSettings } from "@t3tools/contracts"; + +import type { HermesGatewayConnectionState, HermesGatewayHealth } from "./HermesGatewayClient.ts"; +import { + assessHermesConnectionSecurity, + sanitizeHermesEndpoint, + type HermesConnectionSecurityInput, +} from "./HermesConnectionSecurity.ts"; + +export const HERMES_CORE_CAPABILITIES = [ + "session.lifecycle", + "session.history", + "turn.prompt", + "turn.interrupt", +] as const; + +export type HermesFeatureName = "remote" | "import" | "mcp" | "attachments" | "proactive" | "voice"; + +export interface HermesFeatureDiagnostic { + readonly feature: HermesFeatureName; + readonly requested: boolean; + readonly available: boolean; + readonly missingCapabilities: ReadonlyArray; + readonly reason: string; +} + +export type HermesUpgradeGate = + | { readonly status: "ready"; readonly reason: string } + | { readonly status: "degraded"; readonly reason: string } + | { + readonly status: "upgrade_required"; + readonly reason: string; + readonly missingCapabilities: ReadonlyArray; + }; + +export type HermesRecoveryControlName = + | "reconnect" + | "revoke_all" + | "quarantine" + | "pause_ingestion" + | "stop_owned_process"; + +export interface HermesRecoveryControl { + readonly control: HermesRecoveryControlName; + readonly supported: boolean; + readonly reason: string; +} + +export interface HermesOperationalDiagnostics { + readonly connection: HermesGatewayHealth; + readonly endpoint: string; + readonly profileConfigured: boolean; + readonly upgradeGate: HermesUpgradeGate; + readonly features: ReadonlyArray; + readonly recoveryControls: ReadonlyArray; + readonly processOwnership: "external" | "t3_owned"; +} + +export interface HermesImportProgress { + readonly status: "pending" | "running" | "completed" | "failed" | "cancelled" | "unknown"; + readonly completed: number | null; + readonly total: number | null; + readonly attempt: number | null; + readonly canRetry: boolean; + readonly canCancel: boolean; +} + +const FEATURE_CAPABILITIES: Readonly< + Record, ReadonlyArray> +> = { + import: ["profile.import"], + mcp: ["session_mcp"], + proactive: ["cron.events.global_cursor", "events.stable_ids"], + voice: ["voice"], +}; + +const RECOVERY_CAPABILITIES: Readonly< + Record, string> +> = { + revoke_all: "auth.revoke_all", + quarantine: "gateway.quarantine", + pause_ingestion: "ingestion.pause", +}; + +export function assessHermesOnboarding( + settings: HermesSettings, + security: Omit, +) { + return assessHermesConnectionSecurity({ + ...security, + endpoint: settings.endpoint, + remoteInstanceEnabled: settings.remoteAccessEnabled, + }); +} + +export function deriveHermesUpgradeGate( + compatibility: HermesGatewayCompatibility | undefined, +): HermesUpgradeGate { + if (compatibility === undefined) { + return { + status: "degraded", + reason: "Gateway version and capabilities have not been negotiated.", + }; + } + if (compatibility.status === "unsupported") { + return { + status: "upgrade_required", + reason: compatibility.reason, + missingCapabilities: [], + }; + } + if (compatibility.status === "legacy") { + return { + status: "degraded", + reason: + "Gateway does not advertise a protocol version; optional and destructive operations remain unavailable.", + }; + } + const available = new Set(compatibility.capabilities); + const missingCapabilities = HERMES_CORE_CAPABILITIES.filter( + (capability) => !available.has(capability), + ); + return missingCapabilities.length === 0 + ? { status: "ready", reason: compatibility.reason } + : { + status: "upgrade_required", + reason: "Gateway is missing capabilities required for safe session recovery.", + missingCapabilities, + }; +} + +export function projectHermesFeatureDiagnostics( + settings: HermesSettings, + compatibility: HermesGatewayCompatibility | undefined, +): ReadonlyArray { + const available = new Set(compatibility?.capabilities ?? []); + const requested: Readonly> = { + remote: settings.remoteAccessEnabled, + import: settings.importEnabled, + mcp: settings.mcpEnabled, + attachments: settings.attachmentsEnabled, + proactive: settings.proactiveEnabled, + voice: settings.voiceEnabled, + }; + const required = (feature: HermesFeatureName): ReadonlyArray => { + if (feature === "remote") return []; + if (feature === "attachments") { + return ["attachments.image|attachments.file|attachments.pdf"]; + } + return FEATURE_CAPABILITIES[feature]; + }; + return (Object.keys(requested) as HermesFeatureName[]).map((feature) => { + const missingCapabilities = + feature === "attachments" + ? [...available].some((capability) => capability.startsWith("attachments.")) + ? [] + : required(feature) + : required(feature).filter((capability) => !available.has(capability)); + const isAvailable = + feature === "remote" + ? false + : compatibility?.status === "supported" && missingCapabilities.length === 0; + const reason = !requested[feature] + ? "Disabled for this instance." + : feature === "remote" + ? "Remote transport remains blocked until scoped pairing and TLS pin verification are implemented." + : isAvailable + ? "Enabled and advertised by the gateway." + : compatibility?.status === "legacy" + ? "Unavailable without a negotiated capability inventory." + : "Requested but not advertised by the gateway."; + return { + feature, + requested: requested[feature], + available: requested[feature] && isAvailable, + missingCapabilities, + reason, + }; + }); +} + +export function projectHermesRecoveryControls(input: { + readonly connectionState: HermesGatewayConnectionState; + readonly compatibility: HermesGatewayCompatibility | undefined; + readonly processOwnership: "external" | "t3_owned"; + readonly ownedProcessStopAvailable: boolean; +}): ReadonlyArray { + const capabilities = new Set(input.compatibility?.capabilities ?? []); + const controls: HermesRecoveryControl[] = [ + { + control: "reconnect", + supported: input.connectionState !== "closed", + reason: + input.connectionState === "closed" + ? "The client has been permanently closed." + : "Reconnects only the T3-owned WebSocket client; it does not restart Hermes.", + }, + ]; + for (const control of ["revoke_all", "quarantine", "pause_ingestion"] as const) { + const capability = RECOVERY_CAPABILITIES[control]; + const supported = input.compatibility?.status === "supported" && capabilities.has(capability); + controls.push({ + control, + supported, + reason: supported + ? `Gateway advertises ${capability}.` + : `Gateway does not advertise ${capability}; no operation is exposed.`, + }); + } + const canStopOwnedProcess = + input.processOwnership === "t3_owned" && input.ownedProcessStopAvailable; + controls.push({ + control: "stop_owned_process", + supported: canStopOwnedProcess, + reason: canStopOwnedProcess + ? "Available for a process launched and tracked by this T3 runtime." + : input.processOwnership === "external" + ? "Hermes was started externally; T3 does not own or stop this process." + : "No verified owned-process stop handle is available.", + }); + return controls; +} + +export function buildHermesOperationalDiagnostics(input: { + readonly endpoint: string; + readonly profileKey: string; + readonly settings: HermesSettings; + readonly connection: HermesGatewayHealth; + readonly compatibility: HermesGatewayCompatibility | undefined; + readonly processOwnership?: "external" | "t3_owned"; + readonly ownedProcessStopAvailable?: boolean; +}): HermesOperationalDiagnostics { + const processOwnership = input.processOwnership ?? "external"; + return { + connection: input.connection, + endpoint: sanitizeHermesEndpoint(input.endpoint), + profileConfigured: input.profileKey.trim().length > 0, + upgradeGate: deriveHermesUpgradeGate(input.compatibility), + features: projectHermesFeatureDiagnostics(input.settings, input.compatibility), + recoveryControls: projectHermesRecoveryControls({ + connectionState: input.connection.state, + compatibility: input.compatibility, + processOwnership, + ownedProcessStopAvailable: input.ownedProcessStopAvailable === true, + }), + processOwnership, + }; +} + +export function sanitizeHermesImportProgress(value: unknown): HermesImportProgress { + const record = isRecord(value) ? value : {}; + const status = readImportStatus(record.status); + return { + status, + completed: readNonNegativeNumber(record.completed), + total: readNonNegativeNumber(record.total), + attempt: readNonNegativeNumber(record.attempt), + canRetry: status === "failed", + canCancel: status === "pending" || status === "running", + }; +} + +function readImportStatus(value: unknown): HermesImportProgress["status"] { + return value === "pending" || + value === "running" || + value === "completed" || + value === "failed" || + value === "cancelled" + ? value + : "unknown"; +} + +function readNonNegativeNumber(value: unknown): number | null { + return typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : null; +} + +function isRecord(value: unknown): value is Readonly> { + return typeof value === "object" && value !== null && !Array.isArray(value); +} diff --git a/apps/server/src/hermes/HermesProactiveEventRepository.test.ts b/apps/server/src/hermes/HermesProactiveEventRepository.test.ts new file mode 100644 index 00000000000..37a5aeefee5 --- /dev/null +++ b/apps/server/src/hermes/HermesProactiveEventRepository.test.ts @@ -0,0 +1,397 @@ +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; +import type { SqlError } from "effect/unstable/sql/SqlError"; + +import { runMigrations } from "../persistence/Migrations.ts"; +import * as NodeSqliteClient from "../persistence/NodeSqliteClient.ts"; +import { + HermesProactiveEventRepository, + classifyHermesProactiveCapability, + layer as repositoryLayer, +} from "./HermesProactiveEventRepository.ts"; + +const T0 = "2026-07-25T12:00:00.000Z"; +const T1 = "2026-07-25T12:01:00.000Z"; +const T2 = "2026-07-25T12:02:00.000Z"; +const T3 = "2026-07-25T12:03:00.000Z"; +const decodeUnknownJsonString = Schema.decodeUnknownEffect(Schema.UnknownFromJsonString); + +function testLayer(databaseLayer: Layer.Layer) { + return repositoryLayer.pipe(Layer.provideMerge(databaseLayer)); +} + +const memory = it.layer(testLayer(NodeSqliteClient.layerMemory())); + +const legacyCompatibility = { + status: "legacy" as const, + protocol: null, + capabilities: ["cron.read", "cron.manage"], + inventory: null, + reason: "Pinned gateway does not advertise protocol capabilities.", +}; + +const readyCompatibility = { + status: "supported" as const, + protocol: { + major: 1, + minor: 1, + capabilities: ["cron.events.global_cursor", "events.stable_ids"], + }, + capabilities: ["cron.events.global_cursor", "events.stable_ids"], + inventory: ["cron.events.global_cursor", "events.stable_ids"], + reason: "Future gateway advertises the required durable feed.", +}; + +function registerReady( + repository: HermesProactiveEventRepository["Service"], + profileKey = "profile:default", +) { + return repository.registerSource({ + providerInstanceId: "hermes-local", + profileKey, + compatibility: readyCompatibility, + now: T0, + }); +} + +function event(externalEventId = "event:1") { + return { + externalEventId, + externalCursor: "cursor:1", + eventKind: "cron.completed", + title: "Background task completed", + body: "A Hermes background task produced a result.", + projectId: "project:1", + threadId: null, + occurredAt: T0, + }; +} + +memory("HermesProactiveEventRepository", (it) => { + it.effect("fails closed with explicit diagnostics for the pinned legacy gateway", () => + Effect.gen(function* () { + yield* runMigrations({ toMigrationInclusive: 45 }); + const repository = yield* HermesProactiveEventRepository; + const source = yield* repository.registerSource({ + providerInstanceId: "hermes-local", + profileKey: "profile:default", + compatibility: legacyCompatibility, + now: T0, + }); + + assert.strictEqual(source.state, "degraded"); + assert.strictEqual(source.diagnosticCode, "missing_capability_inventory"); + assert.deepStrictEqual(source.missingCapabilities, [ + "cron.events.global_cursor", + "events.stable_ids", + ]); + + const result = yield* repository.ingestPage({ + sourceId: source.sourceId, + expectedCursor: null, + nextCursor: "cursor:1", + gatewayRevision: null, + protocolMajor: null, + protocolMinor: null, + receivedAt: T1, + events: [event()], + }); + assert.deepStrictEqual(result, { + status: "degraded", + diagnosticCode: "missing_capability_inventory", + }); + + const sql = yield* SqlClient.SqlClient; + const rows = yield* sql<{ count: number }>` + SELECT COUNT(*) AS count FROM hermes_proactive_events + `; + assert.strictEqual(rows[0]?.count, 0); + }), + ); + + it.effect("classifies each missing upstream guarantee without optimistic fallback", () => + Effect.sync(() => { + assert.deepStrictEqual( + classifyHermesProactiveCapability({ + ...readyCompatibility, + capabilities: ["events.stable_ids"], + inventory: ["events.stable_ids"], + }), + { + state: "degraded", + diagnosticCode: "missing_durable_global_cursor", + missingCapabilities: ["cron.events.global_cursor"], + }, + ); + assert.deepStrictEqual( + classifyHermesProactiveCapability({ + ...readyCompatibility, + capabilities: ["cron.events.global_cursor"], + inventory: ["cron.events.global_cursor"], + }), + { + state: "degraded", + diagnosticCode: "missing_stable_event_ids", + missingCapabilities: ["events.stable_ids"], + }, + ); + }), + ); + + it.effect( + "atomically advances a durable cursor and deduplicates stable upstream identities", + () => + Effect.gen(function* () { + yield* runMigrations({ toMigrationInclusive: 45 }); + const repository = yield* HermesProactiveEventRepository; + const source = yield* registerReady(repository, "profile:delivery"); + + const applied = yield* repository.ingestPage({ + sourceId: source.sourceId, + expectedCursor: null, + nextCursor: "cursor:1", + gatewayRevision: "future-revision", + protocolMajor: 1, + protocolMinor: 1, + receivedAt: T1, + events: [event(), event()], + }); + assert.deepStrictEqual(applied, { + status: "applied", + inserted: 1, + duplicates: 1, + checkpointCursor: "cursor:1", + checkpointSequence: 1, + }); + + const replay = yield* repository.ingestPage({ + sourceId: source.sourceId, + expectedCursor: null, + nextCursor: "cursor:1", + gatewayRevision: "future-revision", + protocolMajor: 1, + protocolMinor: 1, + receivedAt: T2, + events: [event()], + }); + assert.deepStrictEqual(replay, { + status: "already_applied", + checkpointCursor: "cursor:1", + checkpointSequence: 1, + }); + + const stale = yield* repository.ingestPage({ + sourceId: source.sourceId, + expectedCursor: null, + nextCursor: "cursor:2", + gatewayRevision: "future-revision", + protocolMajor: 1, + protocolMinor: 1, + receivedAt: T2, + events: [], + }); + assert.deepStrictEqual(stale, { + status: "stale_checkpoint", + checkpointCursor: "cursor:1", + checkpointSequence: 1, + }); + + const sql = yield* SqlClient.SqlClient; + const rows = yield* sql<{ + event_id: string; + provenance_json: string; + outbox_count: number; + }>` + SELECT + event.event_id, + event.provenance_json, + COUNT(outbox.outbox_id) AS outbox_count + FROM hermes_proactive_events AS event + JOIN hermes_notification_outbox AS outbox ON outbox.event_id = event.event_id + GROUP BY event.event_id, event.provenance_json + `; + assert.lengthOf(rows, 1); + assert.match(rows[0]!.event_id, /^hermes-event:[0-9a-f]{64}$/); + assert.strictEqual(rows[0]!.outbox_count, 1); + const provenance = yield* decodeUnknownJsonString(rows[0]!.provenance_json); + assert.deepInclude(provenance, { + provider: "hermes", + externalEventId: "event:1", + externalCursor: "cursor:1", + gatewayRevision: "future-revision", + }); + + const cleanupClaim = yield* repository.claimNotification({ + workerId: "worker:test-cleanup", + now: T3, + leaseExpiresAt: "2026-07-25T12:04:00.000Z", + }); + assert.isTrue(Option.isSome(cleanupClaim)); + if (Option.isSome(cleanupClaim)) { + assert.isTrue( + yield* repository.deadLetterNotification({ + outboxId: cleanupClaim.value.outboxId, + workerId: "worker:test-cleanup", + now: T3, + errorCode: "test_cleanup", + }), + ); + } + }), + ); + + it.effect( + "leases, retries, fences, and projects outbox entries into Work and in-app records", + () => + Effect.gen(function* () { + yield* runMigrations({ toMigrationInclusive: 45 }); + const repository = yield* HermesProactiveEventRepository; + const source = yield* registerReady(repository); + yield* repository.ingestPage({ + sourceId: source.sourceId, + expectedCursor: null, + nextCursor: "cursor:1", + gatewayRevision: "future-revision", + protocolMajor: 1, + protocolMinor: 1, + receivedAt: T0, + events: [event()], + }); + + const first = yield* repository.claimNotification({ + workerId: "worker:a", + now: T0, + leaseExpiresAt: T1, + }); + assert.isTrue(Option.isSome(first)); + if (Option.isNone(first)) return; + assert.strictEqual(first.value.attemptCount, 1); + + assert.isFalse( + yield* repository.deliverInApp({ + outboxId: first.value.outboxId, + workerId: "worker:b", + now: T0, + }), + ); + assert.isTrue( + yield* repository.retryNotification({ + outboxId: first.value.outboxId, + workerId: "worker:a", + now: T0, + availableAt: T2, + errorCode: "projection_busy", + }), + ); + + const tooEarly = yield* repository.claimNotification({ + workerId: "worker:b", + now: T1, + leaseExpiresAt: T2, + }); + assert.isTrue(Option.isNone(tooEarly)); + + const retried = yield* repository.claimNotification({ + workerId: "worker:b", + now: T2, + leaseExpiresAt: T3, + }); + assert.isTrue(Option.isSome(retried)); + if (Option.isNone(retried)) return; + assert.strictEqual(retried.value.attemptCount, 2); + assert.isTrue( + yield* repository.deliverInApp({ + outboxId: retried.value.outboxId, + workerId: "worker:b", + now: T2, + }), + ); + + const workItems = yield* repository.listWorkItems(); + const notifications = yield* repository.listInAppNotifications(); + assert.lengthOf(workItems, 1); + assert.lengthOf(notifications, 1); + assert.strictEqual(workItems[0]!.eventId, retried.value.eventId); + assert.strictEqual(notifications[0]!.workItemId, workItems[0]!.workItemId); + assert.strictEqual(notifications[0]!.status, "unread"); + + const exhausted = yield* repository.claimNotification({ + workerId: "worker:c", + now: T3, + leaseExpiresAt: "2026-07-25T12:04:00.000Z", + }); + assert.isTrue(Option.isNone(exhausted)); + }), + ); + + it.effect("rejects outbox commits from a worker whose lease has expired", () => + Effect.gen(function* () { + yield* runMigrations({ toMigrationInclusive: 45 }); + const repository = yield* HermesProactiveEventRepository; + const source = yield* registerReady(repository, "profile:lease-expiry"); + yield* repository.ingestPage({ + sourceId: source.sourceId, + expectedCursor: null, + nextCursor: "cursor:1", + gatewayRevision: "future-revision", + protocolMajor: 1, + protocolMinor: 1, + receivedAt: T0, + events: [event()], + }); + + const claimed = yield* repository.claimNotification({ + workerId: "worker:expired", + now: T0, + leaseExpiresAt: T1, + }); + assert.isTrue(Option.isSome(claimed)); + if (Option.isNone(claimed)) return; + + assert.isFalse( + yield* repository.deliverInApp({ + outboxId: claimed.value.outboxId, + workerId: "worker:expired", + now: T2, + }), + ); + assert.isFalse( + yield* repository.retryNotification({ + outboxId: claimed.value.outboxId, + workerId: "worker:expired", + now: T2, + availableAt: T3, + errorCode: "lease_expired", + }), + ); + assert.isFalse( + yield* repository.deadLetterNotification({ + outboxId: claimed.value.outboxId, + workerId: "worker:expired", + now: T2, + errorCode: "lease_expired", + }), + ); + + const reclaimed = yield* repository.claimNotification({ + workerId: "worker:fresh", + now: T2, + leaseExpiresAt: T3, + }); + assert.isTrue(Option.isSome(reclaimed)); + if (Option.isNone(reclaimed)) return; + assert.strictEqual(reclaimed.value.outboxId, claimed.value.outboxId); + assert.isTrue( + yield* repository.deliverInApp({ + outboxId: reclaimed.value.outboxId, + workerId: "worker:fresh", + now: T2, + }), + ); + }), + ); +}); diff --git a/apps/server/src/hermes/HermesProactiveEventRepository.ts b/apps/server/src/hermes/HermesProactiveEventRepository.ts new file mode 100644 index 00000000000..38f8a28f7da --- /dev/null +++ b/apps/server/src/hermes/HermesProactiveEventRepository.ts @@ -0,0 +1,798 @@ +import { + HermesInAppNotification, + HermesNotificationOutboxEntry, + HermesProactiveEventProvenance, + HermesProactiveRequiredCapabilities, + HermesProactiveSourceStatus, + HermesProactiveWorkItem, + type HermesGatewayCompatibility, + type HermesNotificationOutboxEntry as HermesNotificationOutboxEntryType, + type HermesProactiveDiagnosticCode, + type HermesProactiveSourceStatus as HermesProactiveSourceStatusType, +} from "@t3tools/contracts"; +import * as NodeCrypto from "node:crypto"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +const decodeSource = Schema.decodeUnknownEffect(HermesProactiveSourceStatus); +const decodeOutbox = Schema.decodeUnknownEffect(HermesNotificationOutboxEntry); +const decodeWorkItem = Schema.decodeUnknownEffect(HermesProactiveWorkItem); +const decodeNotification = Schema.decodeUnknownEffect(HermesInAppNotification); +const encodeProvenance = Schema.encodeEffect(HermesProactiveEventProvenance); +const MissingCapabilitiesJson = Schema.fromJsonString(Schema.Array(Schema.String)); +const ProvenanceJson = Schema.fromJsonString(HermesProactiveEventProvenance); +const decodeMissingCapabilitiesJson = Schema.decodeUnknownSync(MissingCapabilitiesJson); +const encodeMissingCapabilitiesJson = Schema.encodeSync(MissingCapabilitiesJson); +const encodeProvenanceJson = Schema.encodeSync(ProvenanceJson); + +export interface RegisterHermesProactiveSourceInput { + readonly providerInstanceId: string; + readonly profileKey: string; + readonly compatibility: HermesGatewayCompatibility; + readonly now: string; +} + +export interface HermesProactiveIncomingEvent { + readonly externalEventId: string; + readonly externalCursor: string; + readonly eventKind: string; + readonly title: string; + readonly body: string; + readonly projectId: string | null; + readonly threadId: string | null; + readonly occurredAt: string; +} + +export interface IngestHermesProactivePageInput { + readonly sourceId: string; + readonly expectedCursor: string | null; + readonly nextCursor: string; + readonly gatewayRevision: string | null; + readonly protocolMajor: number | null; + readonly protocolMinor: number | null; + readonly receivedAt: string; + readonly events: ReadonlyArray; +} + +export type IngestHermesProactivePageResult = + | { + readonly status: "applied"; + readonly inserted: number; + readonly duplicates: number; + readonly checkpointCursor: string; + readonly checkpointSequence: number; + } + | { + readonly status: "already_applied"; + readonly checkpointCursor: string; + readonly checkpointSequence: number; + } + | { + readonly status: "stale_checkpoint"; + readonly checkpointCursor: string | null; + readonly checkpointSequence: number; + } + | { + readonly status: "degraded"; + readonly diagnosticCode: HermesProactiveDiagnosticCode; + }; + +export interface ClaimHermesNotificationInput { + readonly workerId: string; + readonly now: string; + readonly leaseExpiresAt: string; +} + +export class HermesProactiveEventRepositoryError extends Schema.TaggedErrorClass()( + "HermesProactiveEventRepositoryError", + { + operation: Schema.String, + detail: Schema.String, + cause: Schema.optional(Schema.Defect()), + }, +) { + override get message(): string { + return `Hermes proactive event repository failed in ${this.operation}: ${this.detail}`; + } +} + +export interface HermesProactiveEventRepositoryShape { + readonly registerSource: ( + input: RegisterHermesProactiveSourceInput, + ) => Effect.Effect; + readonly getSource: ( + sourceId: string, + ) => Effect.Effect< + Option.Option, + HermesProactiveEventRepositoryError + >; + readonly ingestPage: ( + input: IngestHermesProactivePageInput, + ) => Effect.Effect; + readonly claimNotification: ( + input: ClaimHermesNotificationInput, + ) => Effect.Effect< + Option.Option, + HermesProactiveEventRepositoryError + >; + readonly deliverInApp: (input: { + readonly outboxId: string; + readonly workerId: string; + readonly now: string; + }) => Effect.Effect; + readonly retryNotification: (input: { + readonly outboxId: string; + readonly workerId: string; + readonly now: string; + readonly availableAt: string; + readonly errorCode: string; + }) => Effect.Effect; + readonly deadLetterNotification: (input: { + readonly outboxId: string; + readonly workerId: string; + readonly now: string; + readonly errorCode: string; + }) => Effect.Effect; + readonly listWorkItems: () => Effect.Effect< + ReadonlyArray, + HermesProactiveEventRepositoryError + >; + readonly listInAppNotifications: () => Effect.Effect< + ReadonlyArray, + HermesProactiveEventRepositoryError + >; +} + +export class HermesProactiveEventRepository extends Context.Service< + HermesProactiveEventRepository, + HermesProactiveEventRepositoryShape +>()("t3/hermes/HermesProactiveEventRepository") {} + +interface SourceRow { + readonly source_id: string; + readonly provider_instance_id: string; + readonly profile_key: string; + readonly capability_state: string; + readonly diagnostic_code: string; + readonly missing_capabilities_json: string; + readonly checkpoint_cursor: string | null; + readonly checkpoint_sequence: number; + readonly last_checked_at: string; + readonly updated_at: string; +} + +interface OutboxRow { + readonly outbox_id: string; + readonly event_id: string; + readonly state: string; + readonly attempt_count: number; + readonly available_at: string; + readonly lease_owner: string | null; + readonly lease_expires_at: string | null; + readonly last_error_code: string | null; + readonly created_at: string; + readonly updated_at: string; + readonly delivered_at: string | null; +} + +interface EventRow { + readonly event_id: string; + readonly title: string; + readonly body: string; + readonly project_id: string | null; + readonly thread_id: string | null; + readonly occurred_at: string; +} + +interface WorkItemRow { + readonly work_item_id: string; + readonly event_id: string; + readonly project_id: string | null; + readonly thread_id: string | null; + readonly title: string; + readonly summary: string; + readonly status: string; + readonly occurred_at: string; + readonly created_at: string; + readonly updated_at: string; +} + +interface NotificationRow { + readonly notification_id: string; + readonly event_id: string; + readonly work_item_id: string; + readonly project_id: string | null; + readonly thread_id: string | null; + readonly title: string; + readonly body: string; + readonly status: string; + readonly created_at: string; + readonly updated_at: string; +} + +function stableId(namespace: string, ...parts: ReadonlyArray): string { + const digest = NodeCrypto.createHash("sha256") + .update([namespace, ...parts].map((part) => `${part.length}:${part}`).join("|")) + .digest("hex"); + return `${namespace}:${digest}`; +} + +export function classifyHermesProactiveCapability(compatibility: HermesGatewayCompatibility): { + readonly state: "ready" | "degraded"; + readonly diagnosticCode: HermesProactiveDiagnosticCode; + readonly missingCapabilities: ReadonlyArray; +} { + if (compatibility.inventory === null) { + return { + state: "degraded", + diagnosticCode: "missing_capability_inventory", + missingCapabilities: [...HermesProactiveRequiredCapabilities], + }; + } + const available = new Set(compatibility.capabilities); + const missingCapabilities = HermesProactiveRequiredCapabilities.filter( + (capability) => !available.has(capability), + ); + if (missingCapabilities.includes("cron.events.global_cursor")) { + return { + state: "degraded", + diagnosticCode: "missing_durable_global_cursor", + missingCapabilities, + }; + } + if (missingCapabilities.includes("events.stable_ids")) { + return { + state: "degraded", + diagnosticCode: "missing_stable_event_ids", + missingCapabilities, + }; + } + return { state: "ready", diagnosticCode: "ready", missingCapabilities: [] }; +} + +function sourceFromRow(row: SourceRow) { + return decodeSource({ + sourceId: row.source_id, + providerInstanceId: row.provider_instance_id, + profileKey: row.profile_key, + state: row.capability_state, + diagnosticCode: row.diagnostic_code, + missingCapabilities: decodeMissingCapabilitiesJson(row.missing_capabilities_json), + checkpointCursor: row.checkpoint_cursor, + checkpointSequence: row.checkpoint_sequence, + lastCheckedAt: row.last_checked_at, + updatedAt: row.updated_at, + }); +} + +function outboxFromRow(row: OutboxRow) { + return decodeOutbox({ + outboxId: row.outbox_id, + eventId: row.event_id, + state: row.state, + attemptCount: row.attempt_count, + availableAt: row.available_at, + leaseOwner: row.lease_owner, + leaseExpiresAt: row.lease_expires_at, + lastErrorCode: row.last_error_code, + createdAt: row.created_at, + updatedAt: row.updated_at, + deliveredAt: row.delivered_at, + }); +} + +const isRepositoryError = Schema.is(HermesProactiveEventRepositoryError); + +function repositoryError(operation: string, detail: string, cause?: unknown) { + return new HermesProactiveEventRepositoryError({ + operation, + detail, + ...(cause === undefined ? {} : { cause }), + }); +} + +function mapRepositoryError(operation: string, detail: string) { + return (cause: unknown): HermesProactiveEventRepositoryError => + isRepositoryError(cause) ? cause : repositoryError(operation, detail, cause); +} + +export const layer: Layer.Layer = + Layer.effect( + HermesProactiveEventRepository, + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + const loadSource = Effect.fn("HermesProactiveEventRepository.loadSource")(function* ( + sourceId: string, + ) { + const rows = yield* sql` + SELECT + source_id, + provider_instance_id, + profile_key, + capability_state, + diagnostic_code, + missing_capabilities_json, + checkpoint_cursor, + checkpoint_sequence, + last_checked_at, + updated_at + FROM hermes_proactive_sources + WHERE source_id = ${sourceId} + LIMIT 1 + `; + const row = rows[0]; + return row === undefined ? Option.none() : Option.some(yield* sourceFromRow(row)); + }); + + const registerSource: HermesProactiveEventRepositoryShape["registerSource"] = (input) => + Effect.gen(function* () { + const sourceId = stableId( + "hermes-source", + input.providerInstanceId, + input.profileKey, + "global-cron-events", + ); + const classification = classifyHermesProactiveCapability(input.compatibility); + yield* sql` + INSERT INTO hermes_proactive_sources ( + source_id, + provider_instance_id, + profile_key, + capability_state, + diagnostic_code, + missing_capabilities_json, + checkpoint_cursor, + checkpoint_sequence, + last_checked_at, + created_at, + updated_at + ) + VALUES ( + ${sourceId}, + ${input.providerInstanceId}, + ${input.profileKey}, + ${classification.state}, + ${classification.diagnosticCode}, + ${encodeMissingCapabilitiesJson(classification.missingCapabilities)}, + NULL, + 0, + ${input.now}, + ${input.now}, + ${input.now} + ) + ON CONFLICT(provider_instance_id, profile_key) + DO UPDATE SET + capability_state = excluded.capability_state, + diagnostic_code = excluded.diagnostic_code, + missing_capabilities_json = excluded.missing_capabilities_json, + last_checked_at = excluded.last_checked_at, + updated_at = excluded.updated_at + `; + const source = yield* loadSource(sourceId); + if (Option.isNone(source)) { + return yield* repositoryError("registerSource", "Source disappeared after upsert."); + } + return source.value; + }).pipe( + Effect.mapError(mapRepositoryError("registerSource", "Could not register the source.")), + ); + + const getSource: HermesProactiveEventRepositoryShape["getSource"] = (sourceId) => + loadSource(sourceId).pipe( + Effect.mapError(mapRepositoryError("getSource", "Could not load the source.")), + ); + + const ingestPage: HermesProactiveEventRepositoryShape["ingestPage"] = (input) => + sql + .withTransaction( + Effect.gen(function* () { + const sourceOption = yield* loadSource(input.sourceId); + if (Option.isNone(sourceOption)) { + return yield* repositoryError("ingestPage", "Source is not registered."); + } + const source = sourceOption.value; + if (source.state === "degraded") { + return { + status: "degraded", + diagnosticCode: source.diagnosticCode, + } as const; + } + if (input.nextCursor.length === 0) { + return yield* repositoryError("ingestPage", "A durable next cursor is required."); + } + if (source.checkpointCursor === input.nextCursor) { + return { + status: "already_applied", + checkpointCursor: input.nextCursor, + checkpointSequence: source.checkpointSequence, + } as const; + } + if (source.checkpointCursor !== input.expectedCursor) { + return { + status: "stale_checkpoint", + checkpointCursor: source.checkpointCursor, + checkpointSequence: source.checkpointSequence, + } as const; + } + + let inserted = 0; + for (const event of input.events) { + if (event.externalEventId.length === 0 || event.externalCursor.length === 0) { + return yield* repositoryError( + "ingestPage", + "Every proactive event requires a stable id and durable cursor.", + ); + } + const eventId = stableId("hermes-event", input.sourceId, event.externalEventId); + const provenance = yield* encodeProvenance({ + provider: "hermes", + providerInstanceId: source.providerInstanceId, + profileKey: source.profileKey, + sourceId: source.sourceId, + externalEventId: event.externalEventId, + externalCursor: event.externalCursor, + gatewayRevision: input.gatewayRevision, + protocolMajor: input.protocolMajor, + protocolMinor: input.protocolMinor, + ingestedAt: input.receivedAt, + }); + const rows = yield* sql<{ event_id: string }>` + INSERT INTO hermes_proactive_events ( + event_id, + source_id, + external_event_id, + external_cursor, + event_kind, + title, + body, + project_id, + thread_id, + occurred_at, + received_at, + provenance_json + ) + VALUES ( + ${eventId}, + ${input.sourceId}, + ${event.externalEventId}, + ${event.externalCursor}, + ${event.eventKind}, + ${event.title}, + ${event.body}, + ${event.projectId}, + ${event.threadId}, + ${event.occurredAt}, + ${input.receivedAt}, + ${encodeProvenanceJson(provenance)} + ) + ON CONFLICT(source_id, external_event_id) DO NOTHING + RETURNING event_id + `; + if (rows.length === 0) continue; + inserted += 1; + yield* sql` + INSERT INTO hermes_notification_outbox ( + outbox_id, + event_id, + state, + attempt_count, + available_at, + lease_owner, + lease_expires_at, + last_error_code, + created_at, + updated_at, + delivered_at + ) + VALUES ( + ${stableId("hermes-outbox", eventId)}, + ${eventId}, + 'pending', + 0, + ${input.receivedAt}, + NULL, + NULL, + NULL, + ${input.receivedAt}, + ${input.receivedAt}, + NULL + ) + `; + } + + const advanced = yield* sql<{ checkpoint_sequence: number }>` + UPDATE hermes_proactive_sources + SET checkpoint_cursor = ${input.nextCursor}, + checkpoint_sequence = checkpoint_sequence + 1, + updated_at = ${input.receivedAt} + WHERE source_id = ${input.sourceId} + AND ( + checkpoint_cursor = ${input.expectedCursor} + OR (checkpoint_cursor IS NULL AND ${input.expectedCursor} IS NULL) + ) + RETURNING checkpoint_sequence + `; + const checkpoint = advanced[0]; + if (checkpoint === undefined) { + return yield* repositoryError("ingestPage", "Checkpoint compare-and-set failed."); + } + return { + status: "applied", + inserted, + duplicates: input.events.length - inserted, + checkpointCursor: input.nextCursor, + checkpointSequence: checkpoint.checkpoint_sequence, + } as const; + }), + ) + .pipe(Effect.mapError(mapRepositoryError("ingestPage", "Could not ingest the page."))); + + const claimNotification: HermesProactiveEventRepositoryShape["claimNotification"] = (input) => + sql + .withTransaction( + Effect.gen(function* () { + const candidates = yield* sql<{ outbox_id: string }>` + SELECT outbox_id + FROM hermes_notification_outbox + WHERE + ( + state IN ('pending', 'retry') + AND available_at <= ${input.now} + ) + OR ( + state = 'processing' + AND lease_expires_at <= ${input.now} + ) + ORDER BY available_at ASC, created_at ASC, outbox_id ASC + LIMIT 1 + `; + const candidate = candidates[0]; + if (candidate === undefined) return Option.none(); + const claimed = yield* sql` + UPDATE hermes_notification_outbox + SET state = 'processing', + attempt_count = attempt_count + 1, + lease_owner = ${input.workerId}, + lease_expires_at = ${input.leaseExpiresAt}, + updated_at = ${input.now} + WHERE outbox_id = ${candidate.outbox_id} + AND ( + ( + state IN ('pending', 'retry') + AND available_at <= ${input.now} + ) + OR ( + state = 'processing' + AND lease_expires_at <= ${input.now} + ) + ) + RETURNING * + `; + const row = claimed[0]; + return row === undefined ? Option.none() : Option.some(yield* outboxFromRow(row)); + }), + ) + .pipe( + Effect.mapError( + mapRepositoryError("claimNotification", "Could not claim a notification."), + ), + ); + + const deliverInApp: HermesProactiveEventRepositoryShape["deliverInApp"] = (input) => + sql + .withTransaction( + Effect.gen(function* () { + const events = yield* sql` + SELECT + event.event_id, + event.title, + event.body, + event.project_id, + event.thread_id, + event.occurred_at + FROM hermes_notification_outbox AS outbox + JOIN hermes_proactive_events AS event ON event.event_id = outbox.event_id + WHERE outbox.outbox_id = ${input.outboxId} + AND outbox.state = 'processing' + AND outbox.lease_owner = ${input.workerId} + LIMIT 1 + `; + const event = events[0]; + if (event === undefined) return false; + const workItemId = stableId("hermes-work", event.event_id); + yield* sql` + INSERT INTO hermes_proactive_work_items ( + work_item_id, + event_id, + project_id, + thread_id, + title, + summary, + status, + occurred_at, + created_at, + updated_at + ) + VALUES ( + ${workItemId}, + ${event.event_id}, + ${event.project_id}, + ${event.thread_id}, + ${event.title}, + ${event.body}, + 'unread', + ${event.occurred_at}, + ${input.now}, + ${input.now} + ) + ON CONFLICT(event_id) DO NOTHING + `; + yield* sql` + INSERT INTO hermes_in_app_notifications ( + notification_id, + event_id, + work_item_id, + project_id, + thread_id, + title, + body, + status, + created_at, + updated_at + ) + VALUES ( + ${stableId("hermes-notification", event.event_id)}, + ${event.event_id}, + ${workItemId}, + ${event.project_id}, + ${event.thread_id}, + ${event.title}, + ${event.body}, + 'unread', + ${input.now}, + ${input.now} + ) + ON CONFLICT(event_id) DO NOTHING + `; + const delivered = yield* sql<{ outbox_id: string }>` + UPDATE hermes_notification_outbox + SET state = 'delivered', + lease_owner = NULL, + lease_expires_at = NULL, + updated_at = ${input.now}, + delivered_at = ${input.now} + WHERE outbox_id = ${input.outboxId} + AND state = 'processing' + AND lease_owner = ${input.workerId} + AND lease_expires_at > ${input.now} + RETURNING outbox_id + `; + return delivered.length === 1; + }), + ) + .pipe( + Effect.mapError( + mapRepositoryError("deliverInApp", "Could not deliver the notification."), + ), + ); + + const retryNotification: HermesProactiveEventRepositoryShape["retryNotification"] = (input) => + sql<{ outbox_id: string }>` + UPDATE hermes_notification_outbox + SET state = 'retry', + available_at = ${input.availableAt}, + lease_owner = NULL, + lease_expires_at = NULL, + last_error_code = ${input.errorCode}, + updated_at = ${input.now} + WHERE outbox_id = ${input.outboxId} + AND state = 'processing' + AND lease_owner = ${input.workerId} + AND lease_expires_at > ${input.now} + RETURNING outbox_id + `.pipe( + Effect.map((rows) => rows.length === 1), + Effect.mapError(mapRepositoryError("retryNotification", "Could not schedule the retry.")), + ); + + const deadLetterNotification: HermesProactiveEventRepositoryShape["deadLetterNotification"] = + (input) => + sql<{ outbox_id: string }>` + UPDATE hermes_notification_outbox + SET state = 'dead_letter', + lease_owner = NULL, + lease_expires_at = NULL, + last_error_code = ${input.errorCode}, + updated_at = ${input.now} + WHERE outbox_id = ${input.outboxId} + AND state = 'processing' + AND lease_owner = ${input.workerId} + AND lease_expires_at > ${input.now} + RETURNING outbox_id + `.pipe( + Effect.map((rows) => rows.length === 1), + Effect.mapError( + mapRepositoryError( + "deadLetterNotification", + "Could not dead-letter the notification.", + ), + ), + ); + + const listWorkItems: HermesProactiveEventRepositoryShape["listWorkItems"] = () => + sql` + SELECT * + FROM hermes_proactive_work_items + ORDER BY occurred_at DESC, work_item_id ASC + `.pipe( + Effect.flatMap((rows) => + Effect.forEach( + rows, + (row) => + decodeWorkItem({ + workItemId: row.work_item_id, + eventId: row.event_id, + projectId: row.project_id, + threadId: row.thread_id, + title: row.title, + summary: row.summary, + status: row.status, + occurredAt: row.occurred_at, + createdAt: row.created_at, + updatedAt: row.updated_at, + }), + { concurrency: 1 }, + ), + ), + Effect.mapError(mapRepositoryError("listWorkItems", "Could not list work items.")), + ); + + const listInAppNotifications: HermesProactiveEventRepositoryShape["listInAppNotifications"] = + () => + sql` + SELECT * + FROM hermes_in_app_notifications + ORDER BY created_at DESC, notification_id ASC + `.pipe( + Effect.flatMap((rows) => + Effect.forEach( + rows, + (row) => + decodeNotification({ + notificationId: row.notification_id, + eventId: row.event_id, + workItemId: row.work_item_id, + projectId: row.project_id, + threadId: row.thread_id, + title: row.title, + body: row.body, + status: row.status, + createdAt: row.created_at, + updatedAt: row.updated_at, + }), + { concurrency: 1 }, + ), + ), + Effect.mapError( + mapRepositoryError("listInAppNotifications", "Could not list in-app notifications."), + ), + ); + + return HermesProactiveEventRepository.of({ + registerSource, + getSource, + ingestPage, + claimNotification, + deliverInApp, + retryNotification, + deadLetterNotification, + listWorkItems, + listInAppNotifications, + }); + }), + ); diff --git a/apps/server/src/hermes/HermesProviderDirectory.test.ts b/apps/server/src/hermes/HermesProviderDirectory.test.ts new file mode 100644 index 00000000000..715e981d1b4 --- /dev/null +++ b/apps/server/src/hermes/HermesProviderDirectory.test.ts @@ -0,0 +1,70 @@ +import { ServerSettings } from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; +import * as Schema from "effect/Schema"; + +import { resolveHermesProviderConnections } from "./HermesProviderDirectory.ts"; +import { DEFAULT_HERMES_SERVE_ENDPOINT } from "./HermesServeRuntime.ts"; + +const decodeServerSettings = Schema.decodeUnknownSync(ServerSettings); + +const gatewayEnvironment = [{ name: "HERMES_GATEWAY_TOKEN", value: "token-1", sensitive: true }]; + +const settingsWith = (input: { + readonly config: Record; + readonly environment?: ReadonlyArray>; + readonly enableRemoteHermes?: boolean; +}): ServerSettings => + decodeServerSettings({ + enableHermes: true, + ...(input.enableRemoteHermes === undefined + ? {} + : { enableRemoteHermes: input.enableRemoteHermes }), + providerInstances: { + hermes_main: { + driver: "hermes", + displayName: "Hermes", + enabled: true, + environment: input.environment ?? gatewayEnvironment, + config: { enabled: true, profileKey: "work", ...input.config }, + }, + }, + }); + +describe("resolveHermesProviderConnections", () => { + it("treats an empty endpoint as the default loopback gateway endpoint", () => { + const directory = resolveHermesProviderConnections(settingsWith({ config: { endpoint: "" } })); + expect( + directory.unavailable.filter((provider) => provider.providerInstanceId === "hermes_main"), + ).toEqual([]); + expect( + directory.ready.find((provider) => provider.providerInstanceId === "hermes_main"), + ).toMatchObject({ + endpoint: DEFAULT_HERMES_SERVE_ENDPOINT, + token: "token-1", + }); + }); + + it("does not hand the local gateway token to remote endpoints when remote Hermes is disabled", () => { + const directory = resolveHermesProviderConnections( + settingsWith({ config: { endpoint: "wss://hermes.example.com/api/ws" } }), + ); + expect(directory.ready).toEqual([]); + expect( + directory.unavailable.some((provider) => provider.providerInstanceId === "hermes_main"), + ).toBe(true); + }); + + it("blocks remote endpoints without dedicated pairing material even when remote access is enabled", () => { + const directory = resolveHermesProviderConnections( + settingsWith({ + config: { endpoint: "wss://hermes.example.com/api/ws", remoteAccessEnabled: true }, + enableRemoteHermes: true, + }), + ); + expect(directory.ready).toEqual([]); + expect( + directory.unavailable.find((provider) => provider.providerInstanceId === "hermes_main") + ?.diagnostic, + ).toContain("HERMES_REMOTE_PAIRING_TOKEN"); + }); +}); diff --git a/apps/server/src/hermes/HermesProviderDirectory.ts b/apps/server/src/hermes/HermesProviderDirectory.ts new file mode 100644 index 00000000000..a162d7c976c --- /dev/null +++ b/apps/server/src/hermes/HermesProviderDirectory.ts @@ -0,0 +1,143 @@ +import { + HermesSettings, + type HermesGatewayCompatibility, + type ServerSettings, +} from "@t3tools/contracts"; +import * as Schema from "effect/Schema"; + +import { deriveProviderInstanceConfigMap } from "../provider/Layers/ProviderInstanceRegistryHydration.ts"; +import { + HERMES_REMOTE_PAIRING_TOKEN_ENV, + HERMES_REMOTE_TLS_CERT_SHA256_ENV, + assessHermesConnectionSecurity, +} from "./HermesConnectionSecurity.ts"; +import { resolveHermesServeEndpoint } from "./HermesServeRuntime.ts"; + +export interface HermesProviderConnection { + readonly providerInstanceId: string; + readonly displayName: string; + readonly profileKey: string; + readonly endpoint: string; + readonly token: string; +} + +export interface UnavailableHermesProvider { + readonly providerInstanceId: string; + readonly displayName: string; + readonly profileKey: string; + readonly diagnostic: string; +} + +export interface HermesProviderDirectory { + readonly ready: ReadonlyArray; + readonly unavailable: ReadonlyArray; +} + +const decodeHermesSettings = Schema.decodeUnknownSync(HermesSettings); + +type SensitiveEnvironment = ReadonlyArray<{ + readonly name: string; + readonly value: string; + readonly sensitive: boolean; +}>; + +const sensitiveEnvironmentValue = (environment: SensitiveEnvironment, name: string) => + environment.find( + (variable) => variable.name === name && variable.sensitive && variable.value.trim().length > 0, + )?.value; + +const gatewayTokenFromEnvironment = (environment: SensitiveEnvironment) => + sensitiveEnvironmentValue(environment, "HERMES_GATEWAY_TOKEN"); + +export function resolveHermesProviderConnections( + settings: ServerSettings, +): HermesProviderDirectory { + const instances = deriveProviderInstanceConfigMap(settings); + const ready: HermesProviderConnection[] = []; + const unavailable: UnavailableHermesProvider[] = []; + for (const [providerInstanceId, instance] of Object.entries(instances)) { + if (instance.driver !== "hermes") continue; + let config: HermesSettings; + try { + config = decodeHermesSettings(instance.config ?? {}); + } catch { + unavailable.push({ + providerInstanceId, + displayName: instance.displayName ?? providerInstanceId, + profileKey: "unknown", + diagnostic: "Hermes provider settings are invalid.", + }); + continue; + } + const displayName = instance.displayName ?? providerInstanceId; + const environment = instance.environment ?? []; + const token = gatewayTokenFromEnvironment(environment); + if (instance.enabled !== true || !settings.enableHermes) { + unavailable.push({ + providerInstanceId, + displayName, + profileKey: config.profileKey, + diagnostic: "Hermes is disabled.", + }); + } else if (!token) { + unavailable.push({ + providerInstanceId, + displayName, + profileKey: config.profileKey, + diagnostic: "Hermes gateway endpoint or sensitive token is not configured.", + }); + } else { + const security = assessHermesConnectionSecurity({ + endpoint: resolveHermesServeEndpoint(config.endpoint), + gatewayToken: token, + remoteGloballyEnabled: settings.enableRemoteHermes, + remoteInstanceEnabled: config.remoteAccessEnabled, + remotePairingToken: sensitiveEnvironmentValue(environment, HERMES_REMOTE_PAIRING_TOKEN_ENV), + remoteTlsCertificateSha256: sensitiveEnvironmentValue( + environment, + HERMES_REMOTE_TLS_CERT_SHA256_ENV, + ), + }); + if (security.status === "ready") { + ready.push({ + providerInstanceId, + displayName, + profileKey: config.profileKey, + endpoint: security.endpoint, + token: security.authToken, + }); + } else { + unavailable.push({ + providerInstanceId, + displayName, + profileKey: config.profileKey, + diagnostic: security.message, + }); + } + } + } + return { ready, unavailable }; +} + +export function hermesManageActionInventory( + compatibility: HermesGatewayCompatibility, + capability: string, +): ReadonlySet { + const actions = new Set(); + const inventory = compatibility.inventory; + const manage = + inventory !== null && typeof inventory === "object" && !Array.isArray(inventory) + ? (inventory as Readonly>)[capability] + : undefined; + const manageRecord = + manage !== null && typeof manage === "object" && !Array.isArray(manage) + ? (manage as Readonly>) + : undefined; + for (const candidate of [manageRecord?.actions, manageRecord?.operations]) { + if (!Array.isArray(candidate)) continue; + for (const action of candidate) { + if (typeof action === "string") actions.add(action.toLowerCase()); + } + } + return actions; +} diff --git a/apps/server/src/hermes/HermesServeRuntime.test.ts b/apps/server/src/hermes/HermesServeRuntime.test.ts new file mode 100644 index 00000000000..427e93cab90 --- /dev/null +++ b/apps/server/src/hermes/HermesServeRuntime.test.ts @@ -0,0 +1,218 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, describe, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; + +import { + DEFAULT_HERMES_SERVE_ENDPOINT, + makeHermesServeRuntime, + resolveHermesServeEndpoint, +} from "./HermesServeRuntime.ts"; + +describe("HermesServeRuntime", () => { + it("uses the standard loopback gateway when no endpoint is configured", () => { + assert.equal(resolveHermesServeEndpoint(""), DEFAULT_HERMES_SERVE_ENDPOINT); + assert.equal( + resolveHermesServeEndpoint(" ws://127.0.0.1:19119/api/ws "), + "ws://127.0.0.1:19119/api/ws", + ); + }); + + it.effect("attaches to an already-running compatible Hermes gateway", () => + Effect.scoped( + Effect.gen(function* () { + let starts = 0; + const runtime = yield* makeHermesServeRuntime({ + endpoint: "", + authToken: "shared-token", + managedServerEnabled: true, + processEnvironment: {}, + probe: async () => undefined, + endpointReachable: async () => true, + start: () => { + starts += 1; + return Effect.succeed({ + isRunning: Effect.succeed(true), + kill: () => Effect.void, + }); + }, + }); + + const connection = yield* runtime.ensureReady; + assert.equal(connection.endpoint, DEFAULT_HERMES_SERVE_ENDPOINT); + assert.equal(connection.ownership, "external"); + assert.equal(starts, 0); + }), + ).pipe(Effect.provide(NodeServices.layer)), + ); + + it.effect("starts and owns Hermes Serve only when no endpoint is listening", () => + Effect.gen(function* () { + let ready = false; + let starts = 0; + let kills = 0; + yield* Effect.scoped( + Effect.gen(function* () { + const runtime = yield* makeHermesServeRuntime({ + endpoint: "ws://127.0.0.1:19119/api/ws", + authToken: "managed-token", + managedServerEnabled: true, + processEnvironment: {}, + startupPollInterval: "1 millis", + probe: async () => { + if (!ready) throw new Error("not ready"); + }, + endpointReachable: async () => false, + start: () => { + starts += 1; + ready = true; + return Effect.succeed({ + isRunning: Effect.succeed(true), + kill: () => + Effect.sync(() => { + kills += 1; + }), + }); + }, + }); + + const connection = yield* runtime.ensureReady; + assert.equal(connection.ownership, "t3_owned"); + assert.equal(starts, 1); + assert.equal(runtime.currentOwnership(), "t3_owned"); + }), + ); + assert.equal(kills, 1); + }).pipe(Effect.provide(NodeServices.layer)), + ); + + it.effect("does not replace a reachable gateway that rejects the configured token", () => + Effect.scoped( + Effect.gen(function* () { + let starts = 0; + const runtime = yield* makeHermesServeRuntime({ + endpoint: "", + authToken: "wrong-token", + managedServerEnabled: true, + processEnvironment: {}, + probe: async () => { + throw new Error("unauthorized"); + }, + endpointReachable: async () => true, + start: () => { + starts += 1; + return Effect.succeed({ + isRunning: Effect.succeed(true), + kill: () => Effect.void, + }); + }, + }); + + const result = yield* Effect.result(runtime.ensureReady); + assert.equal(result._tag, "Failure"); + if (result._tag === "Failure") { + assert.include(result.failure.message, "already running"); + } + assert.equal(starts, 0); + }), + ).pipe(Effect.provide(NodeServices.layer)), + ); + + it.effect("stops a lingering owned process before launching a replacement", () => + Effect.gen(function* () { + let starts = 0; + let kills = 0; + let healthy = false; + let processListening = false; + yield* Effect.scoped( + Effect.gen(function* () { + const runtime = yield* makeHermesServeRuntime({ + endpoint: "ws://127.0.0.1:19122/api/ws", + authToken: "managed-token", + managedServerEnabled: true, + processEnvironment: {}, + startupPollInterval: "1 millis", + probe: async () => { + if (!healthy) throw new Error("not ready"); + }, + endpointReachable: async () => processListening, + start: () => { + starts += 1; + healthy = true; + processListening = true; + return Effect.succeed({ + isRunning: Effect.succeed(true), + kill: () => + Effect.sync(() => { + kills += 1; + }), + }); + }, + }); + + const first = yield* runtime.ensureReady; + assert.equal(first.ownership, "t3_owned"); + assert.equal(starts, 1); + + // The managed child stays alive but its TCP listener drops, so the + // endpoint reads as unreachable while the old process still runs. + healthy = false; + processListening = false; + const second = yield* runtime.ensureReady; + assert.equal(second.ownership, "t3_owned"); + assert.equal(starts, 2); + assert.equal(kills, 1); + }), + ); + assert.equal(kills, 2); + }).pipe(Effect.provide(NodeServices.layer)), + ); + + it.effect("relaunches its own unhealthy managed process rather than reporting a conflict", () => + Effect.gen(function* () { + let starts = 0; + let kills = 0; + let healthy = false; + let processListening = false; + yield* Effect.scoped( + Effect.gen(function* () { + const runtime = yield* makeHermesServeRuntime({ + endpoint: "ws://127.0.0.1:19121/api/ws", + authToken: "managed-token", + managedServerEnabled: true, + processEnvironment: {}, + startupPollInterval: "1 millis", + probe: async () => { + if (!healthy) throw new Error("not ready"); + }, + endpointReachable: async () => processListening, + start: () => { + starts += 1; + healthy = true; + processListening = true; + return Effect.succeed({ + isRunning: Effect.succeed(true), + kill: () => + Effect.sync(() => { + kills += 1; + processListening = false; + }), + }); + }, + }); + + const first = yield* runtime.ensureReady; + assert.equal(first.ownership, "t3_owned"); + assert.equal(starts, 1); + + // The managed child keeps its socket open but stops answering probes. + healthy = false; + const second = yield* runtime.ensureReady; + assert.equal(second.ownership, "t3_owned"); + assert.equal(starts, 2); + assert.equal(kills, 1); + }), + ); + assert.equal(kills, 2); + }).pipe(Effect.provide(NodeServices.layer)), + ); +}); diff --git a/apps/server/src/hermes/HermesServeRuntime.ts b/apps/server/src/hermes/HermesServeRuntime.ts new file mode 100644 index 00000000000..9c3260a2825 --- /dev/null +++ b/apps/server/src/hermes/HermesServeRuntime.ts @@ -0,0 +1,325 @@ +import * as NodeNet from "node:net"; +import type * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; +import * as Scope from "effect/Scope"; +import * as Semaphore from "effect/Semaphore"; +import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; + +import { HermesGatewayClient } from "./HermesGatewayClient.ts"; + +export const DEFAULT_HERMES_SERVE_ENDPOINT = "ws://127.0.0.1:9119/api/ws"; + +export type HermesServeOwnership = "external" | "t3_owned"; + +export interface HermesServeConnection { + readonly endpoint: string; + readonly authToken: string; + readonly ownership: HermesServeOwnership; +} + +export interface HermesServeRuntimeShape { + readonly effectiveEndpoint: string; + readonly ensureReady: Effect.Effect; + readonly currentOwnership: () => HermesServeOwnership | null; +} + +interface HermesOwnedProcessHandle { + readonly isRunning: Effect.Effect; + readonly kill: () => Effect.Effect; +} + +class HermesOwnedProcessError extends Schema.TaggedErrorClass()( + "HermesOwnedProcessError", + { + cause: Schema.Defect(), + }, +) {} + +export class HermesServeRuntimeError extends Schema.TaggedErrorClass()( + "HermesServeRuntimeError", + { + code: Schema.Literals([ + "authentication_required", + "endpoint_in_use", + "managed_start_disabled", + "managed_start_failed", + "remote_unreachable", + "unreachable", + ]), + message: Schema.String, + }, +) {} + +class HermesServeProbeError extends Schema.TaggedErrorClass()( + "HermesServeProbeError", + { + cause: Schema.Defect(), + }, +) {} + +export interface MakeHermesServeRuntimeOptions { + readonly endpoint: string; + readonly authToken: string | undefined; + readonly managedServerEnabled: boolean; + readonly processEnvironment: NodeJS.ProcessEnv; + readonly probe?: (input: { + readonly endpoint: string; + readonly authToken: string; + }) => Promise; + readonly endpointReachable?: (endpoint: string) => Promise; + readonly start?: (input: { + readonly host: string; + readonly port: number; + readonly authToken: string; + }) => Effect.Effect; + readonly startupAttempts?: number; + readonly startupPollInterval?: Duration.Input; +} + +export function resolveHermesServeEndpoint(endpoint: string): string { + return endpoint.trim() || DEFAULT_HERMES_SERVE_ENDPOINT; +} + +function localServeTarget( + endpoint: string, +): { readonly host: string; readonly port: number } | null { + try { + const parsed = new URL(endpoint); + if (parsed.protocol !== "ws:") return null; + if (!["127.0.0.1", "localhost", "::1", "[::1]"].includes(parsed.hostname)) return null; + const port = parsed.port ? Number(parsed.port) : 80; + if (!Number.isSafeInteger(port) || port < 1 || port > 65_535) return null; + return { host: parsed.hostname === "[::1]" ? "::1" : parsed.hostname, port }; + } catch { + return null; + } +} + +async function defaultEndpointReachable(endpoint: string): Promise { + const target = localServeTarget(endpoint); + if (target === null) return false; + return new Promise((resolve) => { + const socket = NodeNet.createConnection(target); + const finish = (reachable: boolean) => { + socket.removeAllListeners(); + socket.destroy(); + resolve(reachable); + }; + socket.setTimeout(1_500); + socket.once("connect", () => finish(true)); + socket.once("timeout", () => finish(false)); + socket.once("error", () => finish(false)); + }); +} + +async function defaultProbe(input: { + readonly endpoint: string; + readonly authToken: string; +}): Promise { + const client = new HermesGatewayClient({ + endpoint: input.endpoint, + authToken: input.authToken, + reconnect: { maxAttempts: 0 }, + }); + try { + await client.connect(); + } finally { + client.close(); + } +} + +export const makeHermesServeRuntime = Effect.fn("makeHermesServeRuntime")(function* ( + options: MakeHermesServeRuntimeOptions, +) { + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const ownerScope = yield* Scope.Scope; + const mutex = yield* Semaphore.make(1); + const effectiveEndpoint = resolveHermesServeEndpoint(options.endpoint); + const probe = options.probe ?? defaultProbe; + const endpointReachable = options.endpointReachable ?? defaultEndpointReachable; + const startupAttempts = options.startupAttempts ?? 80; + const startupPollInterval = options.startupPollInterval ?? "150 millis"; + let connection: HermesServeConnection | null = null; + let ownedProcess: HermesOwnedProcessHandle | null = null; + + const probeEffect = (authToken: string) => + Effect.tryPromise({ + try: () => probe({ endpoint: effectiveEndpoint, authToken }), + catch: (cause) => new HermesServeProbeError({ cause }), + }); + + const start = + options.start ?? + ((input: { readonly host: string; readonly port: number; readonly authToken: string }) => + spawner + .spawn( + ChildProcess.make( + "hermes", + ["serve", "--host", input.host, "--port", String(input.port)], + { + env: { + ...options.processEnvironment, + HERMES_DASHBOARD_SESSION_TOKEN: input.authToken, + }, + extendEnv: false, + detached: false, + stdin: "ignore", + stdout: "ignore", + stderr: "ignore", + forceKillAfter: "5 seconds", + }, + ), + ) + .pipe( + Effect.provideService(Scope.Scope, ownerScope), + Effect.mapError((cause) => new HermesOwnedProcessError({ cause })), + Effect.map((handle) => ({ + isRunning: handle.isRunning.pipe( + Effect.mapError((cause) => new HermesOwnedProcessError({ cause })), + ), + kill: () => + handle + .kill({ forceKillAfter: "5 seconds" }) + .pipe(Effect.mapError((cause) => new HermesOwnedProcessError({ cause }))), + })), + )); + + const stopOwnedProcess = Effect.fn("HermesServeRuntime.stopOwnedProcess")(function* () { + const handle = ownedProcess; + ownedProcess = null; + if (handle !== null) { + yield* handle.kill().pipe(Effect.orElseSucceed(() => undefined)); + } + }); + + yield* Effect.addFinalizer(() => + mutex.withPermit( + Effect.gen(function* () { + connection = null; + yield* stopOwnedProcess(); + }), + ), + ); + + const ensureReady = mutex.withPermit( + Effect.gen(function* () { + const authToken = options.authToken?.trim(); + if (!authToken) { + return yield* new HermesServeRuntimeError({ + code: "authentication_required", + message: + "Hermes requires a gateway token. Add HERMES_GATEWAY_TOKEN in T3 Work so T3 can attach to or launch Hermes Serve securely.", + }); + } + + if (connection !== null) { + const cachedProbe = yield* Effect.result(probeEffect(authToken)); + if (cachedProbe._tag === "Success") return connection; + connection = null; + if (ownedProcess !== null) { + const stillRunning = yield* ownedProcess.isRunning.pipe( + Effect.orElseSucceed(() => false), + ); + if (!stillRunning) ownedProcess = null; + } + } + + const attachProbe = yield* Effect.result(probeEffect(authToken)); + if (attachProbe._tag === "Success") { + connection = { + endpoint: effectiveEndpoint, + authToken, + ownership: ownedProcess === null ? "external" : "t3_owned", + }; + return connection; + } + + const reachable = yield* Effect.promise(() => + endpointReachable(effectiveEndpoint).catch(() => false), + ); + if (reachable) { + // A listener that T3 itself launched is not a conflicting external + // instance; stop the unhealthy owned process and relaunch it below. + const ownedStillRunning = + ownedProcess === null + ? false + : yield* ownedProcess.isRunning.pipe(Effect.orElseSucceed(() => false)); + if (!ownedStillRunning) { + return yield* new HermesServeRuntimeError({ + code: "endpoint_in_use", + message: + "A Hermes Serve instance is already running at this endpoint, but it rejected the configured gateway token or protocol handshake. Use the token from that Hermes instance, then refresh.", + }); + } + yield* stopOwnedProcess(); + } + + const target = localServeTarget(effectiveEndpoint); + if (target === null) { + return yield* new HermesServeRuntimeError({ + code: "remote_unreachable", + message: + "The configured Hermes gateway is unreachable. T3 only auto-starts credentialed loopback Hermes Serve instances.", + }); + } + if (!options.managedServerEnabled) { + return yield* new HermesServeRuntimeError({ + code: "managed_start_disabled", + message: + "Hermes Serve is not reachable and automatic startup is disabled for this provider.", + }); + } + + // A previously owned process can survive with its TCP listener + // temporarily unreachable; always stop it before launching a + // replacement so the old child is never orphaned. + yield* stopOwnedProcess(); + + const started = yield* Effect.result( + start({ ...target, authToken }).pipe( + Effect.mapError( + () => + new HermesServeRuntimeError({ + code: "managed_start_failed", + message: + "T3 could not launch `hermes serve`. Make sure the Hermes CLI is installed and available on PATH.", + }), + ), + ), + ); + if (started._tag === "Failure") { + return yield* started.failure; + } + ownedProcess = started.success; + + for (let attempt = 0; attempt < startupAttempts; attempt += 1) { + const ready = yield* Effect.result(probeEffect(authToken)); + if (ready._tag === "Success") { + connection = { + endpoint: effectiveEndpoint, + authToken, + ownership: "t3_owned", + }; + return connection; + } + const stillRunning = yield* ownedProcess.isRunning.pipe(Effect.orElseSucceed(() => false)); + if (!stillRunning) break; + yield* Effect.sleep(startupPollInterval); + } + + yield* stopOwnedProcess(); + return yield* new HermesServeRuntimeError({ + code: "managed_start_failed", + message: + "T3 launched `hermes serve`, but the gateway did not become ready before the startup timeout.", + }); + }), + ); + + return { + effectiveEndpoint, + ensureReady, + currentOwnership: () => connection?.ownership ?? null, + } satisfies HermesServeRuntimeShape; +}); diff --git a/apps/server/src/hermes/HermesSessionBindingRepository.test.ts b/apps/server/src/hermes/HermesSessionBindingRepository.test.ts new file mode 100644 index 00000000000..6ef9cf3f3d1 --- /dev/null +++ b/apps/server/src/hermes/HermesSessionBindingRepository.test.ts @@ -0,0 +1,765 @@ +// @effect-diagnostics nodeBuiltinImport:off - Restart coverage needs a real file-backed SQLite database. +import * as NodeFS from "node:fs"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; + +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; +import type { SqlError } from "effect/unstable/sql/SqlError"; + +import { runMigrations } from "../persistence/Migrations.ts"; +import * as NodeSqliteClient from "../persistence/NodeSqliteClient.ts"; +import { + HermesSessionBindingRepository, + layer as HermesSessionBindingRepositoryLayer, +} from "./HermesSessionBindingRepository.ts"; + +const T0 = "2026-07-24T12:00:00.000Z"; +const T1 = "2026-07-24T12:00:10.000Z"; +const T2 = "2026-07-24T12:00:20.000Z"; +const T3 = "2026-07-24T12:00:30.000Z"; +const T4 = "2026-07-24T12:00:40.000Z"; +const DIGEST_A = "a".repeat(64); +const DIGEST_B = "b".repeat(64); + +function testLayer(databaseLayer: Layer.Layer) { + return HermesSessionBindingRepositoryLayer.pipe(Layer.provideMerge(databaseLayer)); +} + +const memory = it.layer(testLayer(NodeSqliteClient.layerMemory())); + +function createBinding( + repository: HermesSessionBindingRepository["Service"], + overrides: Partial[0]> = {}, +) { + return repository.createBinding({ + bindingId: "hermes-binding:1", + providerInstanceId: "hermes-local", + profileKey: "profile:default", + projectId: "project:1", + storedSessionKey: "stored:conversation-1", + threadId: "thread:1", + protocolClassification: "supported", + protocolMajor: 1, + protocolMinor: 4, + capabilities: ["turn.prompt", "session.lifecycle", "turn.prompt"], + reconciliationCursor: "cursor:7", + reconciliationFingerprint: "fingerprint:7", + now: T0, + ...overrides, + }); +} + +memory("HermesSessionBindingRepository", (it) => { + it.effect("keeps session imports idempotent and enforces one Main per profile", () => + Effect.gen(function* () { + yield* runMigrations({ toMigrationInclusive: 46 }); + const repository = yield* HermesSessionBindingRepository; + + const first = yield* repository.prepareSessionImport({ + importId: "import:session:1", + providerInstanceId: "hermes-local", + profileKey: "profile:default", + projectId: "project:1", + importKind: "session", + storedSessionKey: "stored:1", + threadId: "thread:import:1", + now: T0, + }); + const replay = yield* repository.prepareSessionImport({ + importId: "import:session:replay", + providerInstanceId: "hermes-local", + profileKey: "profile:default", + projectId: "project:1", + importKind: "session", + storedSessionKey: "stored:1", + threadId: "thread:import:other", + now: T1, + }); + assert.strictEqual(replay.importId, first.importId); + assert.strictEqual(replay.threadId, first.threadId); + + assert.isTrue( + yield* repository.transitionSessionImport({ + importId: first.importId, + from: "prepared", + to: "thread_created", + now: T1, + }), + ); + assert.isTrue( + yield* repository.transitionSessionImport({ + importId: first.importId, + from: "thread_created", + to: "completed", + now: T2, + }), + ); + const imported = yield* repository.getSessionImportByStoredIdentity({ + providerInstanceId: "hermes-local", + profileKey: "profile:default", + projectId: "project:1", + storedSessionKey: "stored:1", + }); + assert.isTrue(Option.isSome(imported)); + if (Option.isSome(imported)) assert.strictEqual(imported.value.state, "completed"); + + const main = yield* repository.prepareSessionImport({ + importId: "import:main:1", + providerInstanceId: "hermes-local", + profileKey: "profile:default", + projectId: "project:1", + importKind: "main", + storedSessionKey: null, + threadId: "thread:main:1", + now: T0, + }); + const competingMain = yield* repository.prepareSessionImport({ + importId: "import:main:2", + providerInstanceId: "hermes-local", + profileKey: "profile:default", + projectId: "project:1", + importKind: "main", + storedSessionKey: null, + threadId: "thread:main:2", + now: T1, + }); + assert.strictEqual(competingMain.importId, main.importId); + assert.strictEqual(competingMain.threadId, "thread:main:1"); + }), + ); + + it.effect("clears every local Hermes binding and import so sessions can be imported again", () => + Effect.gen(function* () { + yield* runMigrations({ toMigrationInclusive: 46 }); + const repository = yield* HermesSessionBindingRepository; + + yield* repository.prepareSessionImport({ + importId: "import:session:reset", + providerInstanceId: "hermes-local", + profileKey: "profile:reset", + projectId: "project:1", + importKind: "session", + storedSessionKey: "stored:reset", + threadId: "thread:reset", + now: T0, + }); + yield* repository.prepareSessionImport({ + importId: "import:other-profile", + providerInstanceId: "hermes-local", + profileKey: "profile:other", + projectId: "project:1", + importKind: "session", + storedSessionKey: "stored:other-profile", + threadId: "thread:other-profile", + now: T0, + }); + yield* repository.prepareSessionImport({ + importId: "import:other-provider", + providerInstanceId: "hermes-other", + profileKey: "profile:default", + projectId: "project:1", + importKind: "session", + storedSessionKey: "stored:other-provider", + threadId: "thread:other-provider", + now: T0, + }); + yield* createBinding(repository, { + bindingId: "binding:reset", + profileKey: "profile:reset", + storedSessionKey: "stored:reset", + threadId: "thread:reset", + }); + yield* createBinding(repository, { + bindingId: "binding:other-project", + profileKey: "profile:reset", + projectId: "project:other", + storedSessionKey: "stored:other", + threadId: "thread:other", + }); + + const scope = { + providerInstanceId: "hermes-local", + profileKey: "profile:reset", + projectId: "project:1", + }; + assert.deepEqual(yield* repository.listHistoryThreadIds(scope), ["thread:reset"]); + assert.strictEqual(yield* repository.clearHistoryRecords(scope), 1); + assert.isTrue( + Option.isNone( + yield* repository.getSessionImportByStoredIdentity({ + providerInstanceId: "hermes-local", + profileKey: "profile:reset", + projectId: "project:1", + storedSessionKey: "stored:reset", + }), + ), + ); + assert.isTrue(Option.isNone(yield* repository.getByThreadId("thread:reset"))); + assert.isTrue(Option.isSome(yield* repository.getByThreadId("thread:other"))); + assert.isTrue( + Option.isSome( + yield* repository.getSessionImportByStoredIdentity({ + providerInstanceId: "hermes-local", + profileKey: "profile:other", + projectId: "project:1", + storedSessionKey: "stored:other-profile", + }), + ), + ); + assert.isTrue( + Option.isSome( + yield* repository.getSessionImportByStoredIdentity({ + providerInstanceId: "hermes-other", + profileKey: "profile:default", + projectId: "project:1", + storedSessionKey: "stored:other-provider", + }), + ), + ); + assert.deepEqual(yield* repository.listHistoryThreadIds(scope), []); + }), + ); + + it.effect("rejects a scoped reset while a mutation is unsettled and preserves its records", () => + Effect.gen(function* () { + yield* runMigrations({ toMigrationInclusive: 46 }); + const repository = yield* HermesSessionBindingRepository; + yield* createBinding(repository); + const lease = yield* repository.acquireOwnerLease({ + bindingId: "hermes-binding:1", + ownerKey: "reset-test-owner", + expectedGeneration: 0, + now: T0, + expiresAt: T4, + }); + assert.isTrue(Option.isSome(lease)); + const prepared = yield* repository.prepareMutationIntent({ + bindingId: "hermes-binding:1", + ownerKey: "reset-test-owner", + generation: 1, + now: T1, + operationId: "operation:unsettled-reset", + mutationKind: "prompt", + method: "prompt.submit", + payloadDigest: DIGEST_A, + }); + assert.strictEqual(prepared.status, "prepared"); + + const error = yield* Effect.flip( + repository.clearHistoryRecords({ + providerInstanceId: "hermes-local", + profileKey: "profile:default", + projectId: "project:1", + }), + ); + assert.include(error.detail, "unsettled Hermes mutation"); + assert.isTrue(Option.isSome(yield* repository.getByThreadId("thread:1"))); + assert.isTrue( + Option.isSome(yield* repository.getMutationIntent("operation:unsettled-reset")), + ); + assert.isTrue( + yield* repository.transitionMutationIntent({ + bindingId: "hermes-binding:1", + ownerKey: "reset-test-owner", + generation: 1, + now: T2, + operationId: "operation:unsettled-reset", + from: "prepared", + to: "rejected", + }), + ); + yield* repository.clearHistoryRecords({ + providerInstanceId: "hermes-local", + profileKey: "profile:default", + projectId: "project:1", + }); + }), + ); + + it.effect( + "enforces both durable identity uniqueness domains and stores negotiation metadata", + () => + Effect.gen(function* () { + yield* runMigrations({ toMigrationInclusive: 46 }); + const repository = yield* HermesSessionBindingRepository; + + assert.isTrue(yield* createBinding(repository)); + assert.isFalse( + yield* createBinding(repository, { + bindingId: "hermes-binding:duplicate-identity", + threadId: "thread:2", + }), + ); + assert.isFalse( + yield* createBinding(repository, { + bindingId: "hermes-binding:duplicate-thread", + storedSessionKey: "stored:conversation-2", + }), + ); + + const byThread = yield* repository.getByThreadId("thread:1"); + assert.isTrue(Option.isSome(byThread)); + if (Option.isNone(byThread)) return; + assert.deepStrictEqual(byThread.value.capabilities, ["session.lifecycle", "turn.prompt"]); + assert.strictEqual(byThread.value.protocolClassification, "supported"); + assert.strictEqual(byThread.value.projectId, "project:1"); + assert.strictEqual(byThread.value.protocolMajor, 1); + assert.strictEqual(byThread.value.protocolMinor, 4); + assert.strictEqual(byThread.value.reconciliationCursor, "cursor:7"); + assert.strictEqual(byThread.value.reconciliationFingerprint, "fingerprint:7"); + assert.strictEqual(byThread.value.leaseGeneration, 0); + + const byIdentity = yield* repository.getByStoredIdentity({ + providerInstanceId: "hermes-local", + profileKey: "profile:default", + storedSessionKey: "stored:conversation-1", + }); + assert.isTrue(Option.isSome(byIdentity)); + if (Option.isSome(byIdentity)) { + assert.strictEqual(byIdentity.value.bindingId, "hermes-binding:1"); + } + }), + ); + + it.effect("uses generation and expiry CAS to fence lease-owned metadata writes", () => + Effect.gen(function* () { + yield* runMigrations({ toMigrationInclusive: 46 }); + const repository = yield* HermesSessionBindingRepository; + yield* createBinding(repository); + + const first = yield* repository.acquireOwnerLease({ + bindingId: "hermes-binding:1", + ownerKey: "owner:a", + expectedGeneration: 0, + now: T0, + expiresAt: T2, + }); + assert.isTrue(Option.isSome(first)); + if (Option.isNone(first)) return; + assert.strictEqual(first.value.generation, 1); + + assert.isTrue( + yield* repository.updateNegotiation({ + bindingId: "hermes-binding:1", + ownerKey: "owner:a", + generation: 1, + now: T1, + protocolClassification: "legacy", + protocolMajor: null, + protocolMinor: null, + capabilities: ["session.lifecycle"], + }), + ); + assert.isTrue( + yield* repository.updateReconciliation({ + bindingId: "hermes-binding:1", + ownerKey: "owner:a", + generation: 1, + now: T1, + cursor: "cursor:8", + fingerprint: "fingerprint:8", + }), + ); + + const busy = yield* repository.acquireOwnerLease({ + bindingId: "hermes-binding:1", + ownerKey: "owner:b", + expectedGeneration: 1, + now: T1, + expiresAt: T3, + }); + assert.isTrue(Option.isNone(busy)); + + const takeover = yield* repository.acquireOwnerLease({ + bindingId: "hermes-binding:1", + ownerKey: "owner:b", + expectedGeneration: 1, + now: T2, + expiresAt: T4, + }); + assert.isTrue(Option.isSome(takeover)); + if (Option.isNone(takeover)) return; + assert.strictEqual(takeover.value.generation, 2); + + assert.isFalse( + yield* repository.renewOwnerLease({ + bindingId: "hermes-binding:1", + ownerKey: "owner:a", + generation: 1, + now: T2, + expiresAt: T4, + }), + ); + assert.isFalse( + yield* repository.updateReconciliation({ + bindingId: "hermes-binding:1", + ownerKey: "owner:a", + generation: 1, + now: T2, + cursor: "stale-cursor", + fingerprint: "stale-fingerprint", + }), + ); + }), + ); +}); + +it.effect("records the inherited history boundary once and preserves it afterwards", () => + Effect.gen(function* () { + yield* runMigrations({ toMigrationInclusive: 49 }); + const repository = yield* HermesSessionBindingRepository; + + const prepared = yield* repository.prepareSessionImport({ + importId: "import:session:boundary", + providerInstanceId: "hermes-local", + profileKey: "profile:default", + projectId: "project:1", + importKind: "session", + storedSessionKey: "stored:boundary", + threadId: "thread:boundary", + now: T0, + }); + assert.strictEqual(prepared.inheritedMessageCount, null); + + const recorded = yield* repository.setSessionImportInheritedCount({ + importId: prepared.importId, + inheritedMessageCount: 7, + now: T1, + }); + assert.strictEqual(recorded, 7); + + // A later hydration seeing a longer history must not move the boundary. + const preserved = yield* repository.setSessionImportInheritedCount({ + importId: prepared.importId, + inheritedMessageCount: 12, + now: T2, + }); + assert.strictEqual(preserved, 7); + + const reread = yield* repository.getSessionImportByStoredIdentity({ + providerInstanceId: "hermes-local", + profileKey: "profile:default", + projectId: "project:1", + storedSessionKey: "stored:boundary", + }); + assert.isTrue(Option.isSome(reread)); + if (Option.isSome(reread)) assert.strictEqual(reread.value.inheritedMessageCount, 7); + }).pipe(Effect.provide(testLayer(NodeSqliteClient.layerMemory())), Effect.scoped), +); + +it.effect("persists ambiguous mutation recovery across a file-backed SQLite restart", () => { + const directory = NodeFS.mkdtempSync( + NodePath.join(NodeOS.tmpdir(), "t3-hermes-session-binding-"), + ); + const databasePath = NodePath.join(directory, "state.sqlite"); + const privatePrompt = "PRIVATE PROMPT THAT MUST NEVER REACH SQLITE"; + + const firstRuntime = Effect.gen(function* () { + yield* runMigrations({ toMigrationInclusive: 46 }); + const repository = yield* HermesSessionBindingRepository; + yield* createBinding(repository); + const lease = yield* repository.acquireOwnerLease({ + bindingId: "hermes-binding:1", + ownerKey: "owner:first-runtime", + expectedGeneration: 0, + now: T0, + expiresAt: T2, + }); + assert.isTrue(Option.isSome(lease)); + + const prepared = yield* repository.prepareMutationIntent({ + bindingId: "hermes-binding:1", + ownerKey: "owner:first-runtime", + generation: 1, + now: T0, + operationId: "operation:prompt-1", + mutationKind: "prompt", + method: "prompt.submit", + payloadDigest: DIGEST_A, + }); + assert.strictEqual(prepared.status, "prepared"); + + const concurrent = yield* repository.prepareMutationIntent({ + bindingId: "hermes-binding:1", + ownerKey: "owner:first-runtime", + generation: 1, + now: T1, + operationId: "operation:prompt-2", + mutationKind: "prompt", + method: "prompt.submit", + payloadDigest: DIGEST_B, + }); + assert.deepStrictEqual(concurrent, { + status: "unsettled_prompt", + operationId: "operation:prompt-1", + }); + + assert.isTrue( + yield* repository.transitionMutationIntent({ + bindingId: "hermes-binding:1", + ownerKey: "owner:first-runtime", + generation: 1, + now: T1, + operationId: "operation:prompt-1", + from: "prepared", + to: "admitted", + }), + ); + assert.isTrue( + yield* repository.transitionMutationIntent({ + bindingId: "hermes-binding:1", + ownerKey: "owner:first-runtime", + generation: 1, + now: T1, + operationId: "operation:prompt-1", + from: "admitted", + to: "indeterminate", + }), + ); + }).pipe( + Effect.provide(testLayer(NodeSqliteClient.layer({ filename: databasePath }))), + Effect.scoped, + ); + + const secondRuntime = Effect.gen(function* () { + yield* runMigrations({ toMigrationInclusive: 46 }); + const repository = yield* HermesSessionBindingRepository; + const unsettled = yield* repository.listUnsettledMutationIntents("hermes-binding:1"); + assert.deepStrictEqual( + unsettled.map(({ operationId, state, payloadDigest }) => ({ + operationId, + state, + payloadDigest, + })), + [ + { + operationId: "operation:prompt-1", + state: "indeterminate", + payloadDigest: DIGEST_A, + }, + ], + ); + + const takeover = yield* repository.acquireOwnerLease({ + bindingId: "hermes-binding:1", + ownerKey: "owner:second-runtime", + expectedGeneration: 1, + now: T2, + expiresAt: T4, + }); + assert.isTrue(Option.isSome(takeover)); + if (Option.isNone(takeover)) return; + assert.strictEqual(takeover.value.generation, 2); + + const stillBlocked = yield* repository.prepareMutationIntent({ + bindingId: "hermes-binding:1", + ownerKey: "owner:second-runtime", + generation: 2, + now: T2, + operationId: "operation:prompt-2", + mutationKind: "prompt", + method: "prompt.submit", + payloadDigest: DIGEST_B, + }); + assert.strictEqual(stillBlocked.status, "unsettled_prompt"); + + assert.isTrue( + yield* repository.transitionMutationIntent({ + bindingId: "hermes-binding:1", + ownerKey: "owner:second-runtime", + generation: 2, + now: T2, + operationId: "operation:prompt-1", + from: "indeterminate", + to: "reconciled", + }), + ); + const afterReconciliation = yield* repository.prepareMutationIntent({ + bindingId: "hermes-binding:1", + ownerKey: "owner:second-runtime", + generation: 2, + now: T3, + operationId: "operation:prompt-2", + mutationKind: "prompt", + method: "prompt.submit", + payloadDigest: DIGEST_B, + }); + assert.strictEqual(afterReconciliation.status, "prepared"); + + const sql = yield* SqlClient.SqlClient; + const stored = yield* sql<{ + readonly payload_digest: string; + readonly state: string; + }>` + SELECT payload_digest, state + FROM hermes_mutation_intents + WHERE operation_id = 'operation:prompt-2' + `; + assert.deepStrictEqual(stored, [{ payload_digest: DIGEST_B, state: "prepared" }]); + }).pipe( + Effect.provide(testLayer(NodeSqliteClient.layer({ filename: databasePath }))), + Effect.scoped, + ); + + return Effect.gen(function* () { + yield* firstRuntime; + yield* secondRuntime; + const databaseBytes = NodeFS.readFileSync(databasePath); + assert.notInclude(databaseBytes.toString("utf8"), privatePrompt); + }).pipe( + Effect.ensuring( + Effect.sync(() => { + NodeFS.rmSync(directory, { recursive: true, force: true }); + }), + ), + ); +}); + +it.effect( + "recovers a prepared pre-binding create and atomically attaches it to the binding", + () => { + const directory = NodeFS.mkdtempSync( + NodePath.join(NodeOS.tmpdir(), "t3-hermes-prebinding-create-"), + ); + const databasePath = NodePath.join(directory, "state.sqlite"); + + const firstRuntime = Effect.gen(function* () { + yield* runMigrations({ toMigrationInclusive: 46 }); + const repository = yield* HermesSessionBindingRepository; + const prepared = yield* repository.prepareSessionCreateIntent({ + operationId: "operation:create-1", + providerInstanceId: "hermes-local", + profileKey: "profile:default", + projectId: "project:1", + threadId: "thread:1", + runId: "run:1", + attemptId: "attempt:1", + messageId: "message:1", + method: "session.create", + payloadDigest: DIGEST_A, + now: T0, + }); + assert.strictEqual(prepared.status, "prepared"); + + const bindings = yield* repository.getByThreadId("thread:1"); + assert.isTrue(Option.isNone(bindings)); + }).pipe( + Effect.provide(testLayer(NodeSqliteClient.layer({ filename: databasePath }))), + Effect.scoped, + ); + + const secondRuntime = Effect.gen(function* () { + yield* runMigrations({ toMigrationInclusive: 46 }); + const repository = yield* HermesSessionBindingRepository; + const recovered = yield* repository.getMutationIntent("operation:create-1"); + assert.isTrue(Option.isSome(recovered)); + if (Option.isNone(recovered)) return; + assert.deepStrictEqual( + { + bindingId: recovered.value.bindingId, + providerInstanceId: recovered.value.providerInstanceId, + profileKey: recovered.value.profileKey, + projectId: recovered.value.projectId, + threadId: recovered.value.threadId, + runId: recovered.value.runId, + attemptId: recovered.value.attemptId, + messageId: recovered.value.messageId, + payloadDigest: recovered.value.payloadDigest, + state: recovered.value.state, + }, + { + bindingId: null, + providerInstanceId: "hermes-local", + profileKey: "profile:default", + projectId: "project:1", + threadId: "thread:1", + runId: "run:1", + attemptId: "attempt:1", + messageId: "message:1", + payloadDigest: DIGEST_A, + state: "prepared", + }, + ); + + const concurrent = yield* repository.prepareSessionCreateIntent({ + operationId: "operation:create-2", + providerInstanceId: "hermes-local", + profileKey: "profile:default", + projectId: "project:1", + threadId: "thread:1", + method: "session.create", + payloadDigest: DIGEST_B, + now: T1, + }); + assert.deepStrictEqual(concurrent, { + status: "unsettled_create", + operationId: "operation:create-1", + }); + + assert.isTrue( + yield* repository.transitionSessionCreateIntent({ + operationId: "operation:create-1", + from: "prepared", + to: "admitted", + now: T1, + }), + ); + + const mismatchedAttach = yield* Effect.result( + createBinding(repository, { + projectId: "project:mismatch", + createOperationId: "operation:create-1", + now: T2, + }), + ); + assert.strictEqual(mismatchedAttach._tag, "Failure"); + assert.isTrue(Option.isNone(yield* repository.getByThreadId("thread:1"))); + + assert.isTrue( + yield* createBinding(repository, { + createOperationId: "operation:create-1", + now: T2, + }), + ); + const binding = yield* repository.getByThreadId("thread:1"); + assert.isTrue(Option.isSome(binding)); + if (Option.isSome(binding)) { + assert.strictEqual(binding.value.projectId, "project:1"); + assert.strictEqual(binding.value.storedSessionKey, "stored:conversation-1"); + } + + const confirmed = yield* repository.getMutationIntent("operation:create-1"); + assert.isTrue(Option.isSome(confirmed)); + if (Option.isSome(confirmed)) { + assert.strictEqual(confirmed.value.bindingId, "hermes-binding:1"); + assert.strictEqual(confirmed.value.state, "confirmed"); + assert.strictEqual(confirmed.value.settledAt, T2); + } + + assert.isTrue( + yield* createBinding(repository, { + createOperationId: "operation:create-1", + now: T3, + }), + ); + }).pipe( + Effect.provide(testLayer(NodeSqliteClient.layer({ filename: databasePath }))), + Effect.scoped, + ); + + return Effect.gen(function* () { + yield* firstRuntime; + yield* secondRuntime; + }).pipe( + Effect.ensuring( + Effect.sync(() => { + NodeFS.rmSync(directory, { recursive: true, force: true }); + }), + ), + ); + }, +); diff --git a/apps/server/src/hermes/HermesSessionBindingRepository.ts b/apps/server/src/hermes/HermesSessionBindingRepository.ts new file mode 100644 index 00000000000..f94a4e9bcfa --- /dev/null +++ b/apps/server/src/hermes/HermesSessionBindingRepository.ts @@ -0,0 +1,1417 @@ +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +export const HermesProtocolClassification = Schema.Literals(["legacy", "supported", "unsupported"]); +export type HermesProtocolClassification = typeof HermesProtocolClassification.Type; + +export const HermesMutationIntentState = Schema.Literals([ + "prepared", + "admitted", + "confirmed", + "indeterminate", + "reconciled", + "rejected", +]); +export type HermesMutationIntentState = typeof HermesMutationIntentState.Type; + +export const HermesSessionBinding = Schema.Struct({ + bindingId: Schema.String, + providerInstanceId: Schema.String, + profileKey: Schema.String, + projectId: Schema.String, + storedSessionKey: Schema.String, + threadId: Schema.String, + protocolClassification: HermesProtocolClassification, + protocolMajor: Schema.NullOr(Schema.Number), + protocolMinor: Schema.NullOr(Schema.Number), + capabilities: Schema.Array(Schema.String), + reconciliationCursor: Schema.NullOr(Schema.String), + reconciliationFingerprint: Schema.NullOr(Schema.String), + titleRevision: Schema.Number, + titleOrigin: Schema.NullOr(Schema.String), + parentBindingId: Schema.NullOr(Schema.String), + branchBoundaryMode: Schema.NullOr(Schema.Literal("latest_only")), + branchBoundaryMessageId: Schema.NullOr(Schema.String), + branchBoundaryMessageCount: Schema.NullOr(Schema.Number), + leaseOwnerKey: Schema.NullOr(Schema.String), + leaseGeneration: Schema.Number, + leaseExpiresAt: Schema.NullOr(Schema.String), + createdAt: Schema.String, + updatedAt: Schema.String, +}); +export type HermesSessionBinding = typeof HermesSessionBinding.Type; + +export const HermesMutationIntent = Schema.Struct({ + operationId: Schema.String, + bindingId: Schema.NullOr(Schema.String), + providerInstanceId: Schema.String, + profileKey: Schema.String, + projectId: Schema.String, + threadId: Schema.String, + runId: Schema.NullOr(Schema.String), + attemptId: Schema.NullOr(Schema.String), + messageId: Schema.NullOr(Schema.String), + mutationKind: Schema.String, + method: Schema.String, + payloadDigest: Schema.String, + ownerGeneration: Schema.Number, + state: HermesMutationIntentState, + preparedAt: Schema.String, + admittedAt: Schema.NullOr(Schema.String), + settledAt: Schema.NullOr(Schema.String), + updatedAt: Schema.String, +}); +export type HermesMutationIntent = typeof HermesMutationIntent.Type; + +export const HermesSessionImport = Schema.Struct({ + importId: Schema.String, + providerInstanceId: Schema.String, + profileKey: Schema.String, + projectId: Schema.String, + importKind: Schema.Literals(["session", "main"]), + storedSessionKey: Schema.NullOr(Schema.String), + threadId: Schema.String, + state: Schema.Literals(["prepared", "thread_created", "completed"]), + inheritedMessageCount: Schema.NullOr(Schema.Number), + createdAt: Schema.String, + updatedAt: Schema.String, +}); +export type HermesSessionImport = typeof HermesSessionImport.Type; + +export interface PrepareHermesSessionImportInput { + readonly importId: string; + readonly providerInstanceId: string; + readonly profileKey: string; + readonly projectId: string; + readonly importKind: "session" | "main"; + readonly storedSessionKey: string | null; + readonly threadId: string; + readonly now: string; +} + +export interface CreateHermesSessionBindingInput { + readonly bindingId: string; + readonly providerInstanceId: string; + readonly profileKey: string; + readonly projectId: string; + readonly storedSessionKey: string; + readonly threadId: string; + readonly protocolClassification: HermesProtocolClassification; + readonly protocolMajor: number | null; + readonly protocolMinor: number | null; + readonly capabilities: ReadonlyArray; + readonly reconciliationCursor: string | null; + readonly reconciliationFingerprint: string | null; + readonly titleRevision?: number; + readonly titleOrigin?: string | null; + readonly parentBindingId?: string | null; + readonly branchBoundaryMode?: "latest_only" | null; + readonly branchBoundaryMessageId?: string | null; + readonly branchBoundaryMessageCount?: number | null; + readonly now: string; + /** + * When supplied, binding creation and confirmation/attachment of this + * pre-binding `session_create` intent commit in one transaction. + */ + readonly createOperationId?: string; +} + +export interface HermesBindingStoredIdentity { + readonly providerInstanceId: string; + readonly profileKey: string; + readonly storedSessionKey: string; +} + +export interface HermesHistoryScope { + readonly providerInstanceId: string; + readonly profileKey: string; + readonly projectId: string; +} + +export interface HermesLeaseFence { + readonly bindingId: string; + readonly ownerKey: string; + readonly generation: number; + /** + * Canonical UTC ISO timestamp used both for expiry comparison and auditing. + */ + readonly now: string; +} + +export interface HermesOwnerLease { + readonly bindingId: string; + readonly ownerKey: string; + readonly generation: number; + readonly expiresAt: string; +} + +export interface AcquireHermesOwnerLeaseInput { + readonly bindingId: string; + readonly ownerKey: string; + readonly expectedGeneration: number; + readonly now: string; + readonly expiresAt: string; +} + +export interface UpdateHermesNegotiationInput extends HermesLeaseFence { + readonly protocolClassification: HermesProtocolClassification; + readonly protocolMajor: number | null; + readonly protocolMinor: number | null; + readonly capabilities: ReadonlyArray; +} + +export interface UpdateHermesReconciliationInput extends HermesLeaseFence { + readonly cursor: string | null; + readonly fingerprint: string | null; +} + +export interface UpdateHermesTitleStateInput extends HermesLeaseFence { + readonly revision: number; + readonly origin: string; +} + +export interface PrepareHermesMutationIntentInput extends HermesLeaseFence { + readonly operationId: string; + readonly mutationKind: string; + readonly method: string; + /** + * Digest of the external write payload. Raw payloads, prompts, transcripts, + * credentials, and gateway session ids must never be passed to this service. + */ + readonly payloadDigest: string; +} + +export interface PrepareHermesSessionCreateIntentInput { + readonly operationId: string; + readonly providerInstanceId: string; + readonly profileKey: string; + readonly projectId: string; + readonly threadId: string; + readonly runId?: string | null; + readonly attemptId?: string | null; + readonly messageId?: string | null; + readonly method: string; + readonly payloadDigest: string; + readonly now: string; +} + +export type PrepareHermesMutationIntentResult = + | { + readonly status: "prepared"; + readonly intent: HermesMutationIntent; + } + | { + readonly status: "lease_not_held"; + } + | { + readonly status: "operation_exists"; + readonly intent: HermesMutationIntent; + } + | { + readonly status: "unsettled_prompt"; + readonly operationId: string; + }; + +export type PrepareHermesSessionCreateIntentResult = + | { + readonly status: "prepared"; + readonly intent: HermesMutationIntent; + } + | { + readonly status: "operation_exists"; + readonly intent: HermesMutationIntent; + } + | { + readonly status: "unsettled_create"; + readonly operationId: string; + }; + +export interface TransitionHermesMutationIntentInput extends HermesLeaseFence { + readonly operationId: string; + readonly from: HermesMutationIntentState; + readonly to: HermesMutationIntentState; +} + +export interface TransitionHermesSessionCreateIntentInput { + readonly operationId: string; + readonly from: "prepared" | "admitted"; + readonly to: "admitted" | "indeterminate" | "rejected"; + readonly now: string; +} + +export class HermesSessionBindingRepositoryError extends Schema.TaggedErrorClass()( + "HermesSessionBindingRepositoryError", + { + operation: Schema.String, + detail: Schema.String, + cause: Schema.optional(Schema.Defect()), + }, +) { + override get message(): string { + return `Hermes session binding repository failed in ${this.operation}: ${this.detail}`; + } +} + +export interface HermesSessionBindingRepositoryShape { + readonly createBinding: ( + input: CreateHermesSessionBindingInput, + ) => Effect.Effect; + readonly getByThreadId: ( + threadId: string, + ) => Effect.Effect, HermesSessionBindingRepositoryError>; + readonly getByStoredIdentity: ( + identity: HermesBindingStoredIdentity, + ) => Effect.Effect, HermesSessionBindingRepositoryError>; + readonly updateNegotiation: ( + input: UpdateHermesNegotiationInput, + ) => Effect.Effect; + readonly updateReconciliation: ( + input: UpdateHermesReconciliationInput, + ) => Effect.Effect; + readonly updateTitleState: ( + input: UpdateHermesTitleStateInput, + ) => Effect.Effect; + readonly acquireOwnerLease: ( + input: AcquireHermesOwnerLeaseInput, + ) => Effect.Effect, HermesSessionBindingRepositoryError>; + readonly renewOwnerLease: ( + input: HermesLeaseFence & { readonly expiresAt: string }, + ) => Effect.Effect; + readonly releaseOwnerLease: ( + input: Omit & { readonly now: string }, + ) => Effect.Effect; + readonly prepareMutationIntent: ( + input: PrepareHermesMutationIntentInput, + ) => Effect.Effect; + readonly prepareSessionCreateIntent: ( + input: PrepareHermesSessionCreateIntentInput, + ) => Effect.Effect; + readonly transitionSessionCreateIntent: ( + input: TransitionHermesSessionCreateIntentInput, + ) => Effect.Effect; + readonly transitionMutationIntent: ( + input: TransitionHermesMutationIntentInput, + ) => Effect.Effect; + readonly getMutationIntent: ( + operationId: string, + ) => Effect.Effect, HermesSessionBindingRepositoryError>; + readonly listUnsettledMutationIntents: ( + bindingId: string, + ) => Effect.Effect, HermesSessionBindingRepositoryError>; + readonly prepareSessionImport: ( + input: PrepareHermesSessionImportInput, + ) => Effect.Effect; + readonly getSessionImportByStoredIdentity: ( + identity: HermesBindingStoredIdentity & { readonly projectId: string }, + ) => Effect.Effect, HermesSessionBindingRepositoryError>; + readonly getMainSessionImport: (input: { + readonly providerInstanceId: string; + readonly profileKey: string; + readonly projectId: string; + }) => Effect.Effect, HermesSessionBindingRepositoryError>; + readonly transitionSessionImport: (input: { + readonly importId: string; + readonly from: "prepared" | "thread_created"; + readonly to: "thread_created" | "completed"; + readonly now: string; + }) => Effect.Effect; + /** + * Records the inherited-history boundary exactly once. Later hydrations of + * the same import keep the original boundary so native T3 messages appended + * after the import never receive imported-transcript normalization. + */ + readonly setSessionImportInheritedCount: (input: { + readonly importId: string; + readonly inheritedMessageCount: number; + readonly now: string; + }) => Effect.Effect; + readonly listHistoryThreadIds: ( + scope: HermesHistoryScope, + ) => Effect.Effect, HermesSessionBindingRepositoryError>; + readonly clearHistoryRecords: ( + scope: HermesHistoryScope, + ) => Effect.Effect; +} + +export class HermesSessionBindingRepository extends Context.Service< + HermesSessionBindingRepository, + HermesSessionBindingRepositoryShape +>()("t3/hermes/HermesSessionBindingRepository") {} + +interface BindingRow { + readonly binding_id: string; + readonly provider_instance_id: string; + readonly profile_key: string; + readonly project_id: string; + readonly stored_session_key: string; + readonly thread_id: string; + readonly protocol_classification: string; + readonly protocol_major: number | null; + readonly protocol_minor: number | null; + readonly capabilities_json: string; + readonly reconciliation_cursor: string | null; + readonly reconciliation_fingerprint: string | null; + readonly title_revision: number; + readonly title_origin: string | null; + readonly parent_binding_id: string | null; + readonly branch_boundary_mode: string | null; + readonly branch_boundary_message_id: string | null; + readonly branch_boundary_message_count: number | null; + readonly lease_owner_key: string | null; + readonly lease_generation: number; + readonly lease_expires_at: string | null; + readonly created_at: string; + readonly updated_at: string; +} + +interface IntentRow { + readonly operation_id: string; + readonly binding_id: string | null; + readonly provider_instance_id: string; + readonly profile_key: string; + readonly project_id: string; + readonly thread_id: string; + readonly run_id: string | null; + readonly attempt_id: string | null; + readonly message_id: string | null; + readonly mutation_kind: string; + readonly method: string; + readonly payload_digest: string; + readonly owner_generation: number; + readonly state: string; + readonly prepared_at: string; + readonly admitted_at: string | null; + readonly settled_at: string | null; + readonly updated_at: string; +} + +interface ImportRow { + readonly import_id: string; + readonly provider_instance_id: string; + readonly profile_key: string; + readonly project_id: string; + readonly import_kind: string; + readonly stored_session_key: string | null; + readonly thread_id: string; + readonly state: string; + readonly inherited_message_count: number | null; + readonly created_at: string; + readonly updated_at: string; +} + +const decodeCapabilities = Schema.decodeUnknownEffect( + Schema.fromJsonString(Schema.Array(Schema.String)), +); +const decodeBinding = Schema.decodeUnknownEffect(HermesSessionBinding); +const decodeIntent = Schema.decodeUnknownEffect(HermesMutationIntent); +const decodeImport = Schema.decodeUnknownEffect(HermesSessionImport); +const isRepositoryError = Schema.is(HermesSessionBindingRepositoryError); + +function repositoryError(operation: string, detail: string, cause?: unknown) { + return new HermesSessionBindingRepositoryError({ + operation, + detail, + ...(cause === undefined ? {} : { cause }), + }); +} + +function mapRepositoryError(operation: string, detail: string) { + return (cause: unknown): HermesSessionBindingRepositoryError => + isRepositoryError(cause) ? cause : repositoryError(operation, detail, cause); +} + +const bindingFromRow = Effect.fn("HermesSessionBindingRepository.bindingFromRow")(function* ( + row: BindingRow, +) { + const capabilities = yield* decodeCapabilities(row.capabilities_json); + return yield* decodeBinding({ + bindingId: row.binding_id, + providerInstanceId: row.provider_instance_id, + profileKey: row.profile_key, + projectId: row.project_id, + storedSessionKey: row.stored_session_key, + threadId: row.thread_id, + protocolClassification: row.protocol_classification, + protocolMajor: row.protocol_major, + protocolMinor: row.protocol_minor, + capabilities, + reconciliationCursor: row.reconciliation_cursor, + reconciliationFingerprint: row.reconciliation_fingerprint, + titleRevision: row.title_revision, + titleOrigin: row.title_origin, + parentBindingId: row.parent_binding_id, + branchBoundaryMode: row.branch_boundary_mode, + branchBoundaryMessageId: row.branch_boundary_message_id, + branchBoundaryMessageCount: row.branch_boundary_message_count, + leaseOwnerKey: row.lease_owner_key, + leaseGeneration: row.lease_generation, + leaseExpiresAt: row.lease_expires_at, + createdAt: row.created_at, + updatedAt: row.updated_at, + }); +}); + +const intentFromRow = Effect.fn("HermesSessionBindingRepository.intentFromRow")(function* ( + row: IntentRow, +) { + return yield* decodeIntent({ + operationId: row.operation_id, + bindingId: row.binding_id, + providerInstanceId: row.provider_instance_id, + profileKey: row.profile_key, + projectId: row.project_id, + threadId: row.thread_id, + runId: row.run_id, + attemptId: row.attempt_id, + messageId: row.message_id, + mutationKind: row.mutation_kind, + method: row.method, + payloadDigest: row.payload_digest, + ownerGeneration: row.owner_generation, + state: row.state, + preparedAt: row.prepared_at, + admittedAt: row.admitted_at, + settledAt: row.settled_at, + updatedAt: row.updated_at, + }); +}); + +const importFromRow = Effect.fn("HermesSessionBindingRepository.importFromRow")(function* ( + row: ImportRow, +) { + return yield* decodeImport({ + importId: row.import_id, + providerInstanceId: row.provider_instance_id, + profileKey: row.profile_key, + projectId: row.project_id, + importKind: row.import_kind, + storedSessionKey: row.stored_session_key, + threadId: row.thread_id, + state: row.state, + inheritedMessageCount: row.inherited_message_count ?? null, + createdAt: row.created_at, + updatedAt: row.updated_at, + }); +}); + +function optionFromRows( + rows: ReadonlyArray, + decode: (row: A) => Effect.Effect, +): Effect.Effect, E, R> { + const row = rows[0]; + return row === undefined + ? Effect.succeed(Option.none()) + : decode(row).pipe(Effect.map((value) => Option.some(value))); +} + +function normalizedCapabilities(capabilities: ReadonlyArray): string { + return JSON.stringify([...new Set(capabilities)].toSorted()); +} + +function validateProtocolVersion( + operation: string, + major: number | null, + minor: number | null, +): Effect.Effect { + if ((major === null) !== (minor === null)) { + return Effect.fail( + repositoryError( + operation, + "Protocol major and minor must either both be set or both be null.", + ), + ); + } + if ( + (major !== null && (!Number.isInteger(major) || major < 0)) || + (minor !== null && (!Number.isInteger(minor) || minor < 0)) + ) { + return Effect.fail( + repositoryError(operation, "Protocol major and minor must be non-negative integers."), + ); + } + return Effect.void; +} + +const SHA256_DIGEST = /^[a-f0-9]{64}$/; + +const allowedTransitions: Readonly>> = { + prepared: new Set(["admitted", "indeterminate", "reconciled", "rejected"]), + admitted: new Set(["confirmed", "indeterminate", "rejected"]), + confirmed: new Set(), + indeterminate: new Set(["reconciled", "rejected"]), + reconciled: new Set(), + rejected: new Set(), +}; + +export const make = Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + const createBinding = Effect.fn("HermesSessionBindingRepository.createBinding")( + function* (input: CreateHermesSessionBindingInput) { + yield* validateProtocolVersion("createBinding", input.protocolMajor, input.protocolMinor); + return yield* sql.withTransaction( + Effect.gen(function* () { + const rows = yield* sql<{ readonly binding_id: string }>` + INSERT INTO hermes_session_bindings ( + binding_id, + provider_instance_id, + profile_key, + project_id, + stored_session_key, + thread_id, + protocol_classification, + protocol_major, + protocol_minor, + capabilities_json, + reconciliation_cursor, + reconciliation_fingerprint, + title_revision, + title_origin, + parent_binding_id, + branch_boundary_mode, + branch_boundary_message_id, + branch_boundary_message_count, + lease_owner_key, + lease_generation, + lease_expires_at, + created_at, + updated_at + ) VALUES ( + ${input.bindingId}, + ${input.providerInstanceId}, + ${input.profileKey}, + ${input.projectId}, + ${input.storedSessionKey}, + ${input.threadId}, + ${input.protocolClassification}, + ${input.protocolMajor}, + ${input.protocolMinor}, + ${normalizedCapabilities(input.capabilities)}, + ${input.reconciliationCursor}, + ${input.reconciliationFingerprint}, + ${input.titleRevision ?? 0}, + ${input.titleOrigin ?? null}, + ${input.parentBindingId ?? null}, + ${input.branchBoundaryMode ?? null}, + ${input.branchBoundaryMessageId ?? null}, + ${input.branchBoundaryMessageCount ?? null}, + NULL, + 0, + NULL, + ${input.now}, + ${input.now} + ) + ON CONFLICT DO NOTHING + RETURNING binding_id + `; + if (rows.length === 0) { + if (input.createOperationId === undefined) return false; + const alreadyAttached = yield* sql<{ readonly operation_id: string }>` + SELECT intent.operation_id + FROM hermes_mutation_intents AS intent + INNER JOIN hermes_session_bindings AS binding + ON binding.binding_id = intent.binding_id + WHERE intent.operation_id = ${input.createOperationId} + AND intent.state = 'confirmed' + AND intent.mutation_kind = 'session_create' + AND binding.binding_id = ${input.bindingId} + AND binding.provider_instance_id = ${input.providerInstanceId} + AND binding.profile_key = ${input.profileKey} + AND binding.project_id = ${input.projectId} + AND binding.stored_session_key = ${input.storedSessionKey} + AND binding.thread_id = ${input.threadId} + `; + return alreadyAttached.length === 1; + } + if (input.createOperationId === undefined) return true; + + const attached = yield* sql<{ readonly operation_id: string }>` + UPDATE hermes_mutation_intents + SET + binding_id = ${input.bindingId}, + state = 'confirmed', + settled_at = ${input.now}, + updated_at = ${input.now} + WHERE operation_id = ${input.createOperationId} + AND binding_id IS NULL + AND provider_instance_id = ${input.providerInstanceId} + AND profile_key = ${input.profileKey} + AND project_id = ${input.projectId} + AND thread_id = ${input.threadId} + AND mutation_kind = 'session_create' + AND state IN ('prepared', 'admitted', 'indeterminate') + RETURNING operation_id + `; + if (attached.length !== 1) { + return yield* repositoryError( + "createBinding", + "The pre-binding session-create intent was missing or did not match the binding identity.", + ); + } + return true; + }), + ); + }, + Effect.mapError(mapRepositoryError("createBinding", "Could not create the binding.")), + ); + + const getByThreadId = Effect.fn("HermesSessionBindingRepository.getByThreadId")( + function* (threadId: string) { + const rows = yield* sql` + SELECT * + FROM hermes_session_bindings + WHERE thread_id = ${threadId} + `; + return yield* optionFromRows(rows, bindingFromRow); + }, + Effect.mapError(mapRepositoryError("getByThreadId", "Could not load the binding.")), + ); + + const getByStoredIdentity = Effect.fn("HermesSessionBindingRepository.getByStoredIdentity")( + function* (identity: HermesBindingStoredIdentity) { + const rows = yield* sql` + SELECT * + FROM hermes_session_bindings + WHERE provider_instance_id = ${identity.providerInstanceId} + AND profile_key = ${identity.profileKey} + AND stored_session_key = ${identity.storedSessionKey} + `; + return yield* optionFromRows(rows, bindingFromRow); + }, + Effect.mapError(mapRepositoryError("getByStoredIdentity", "Could not load the binding.")), + ); + + const updateNegotiation = Effect.fn("HermesSessionBindingRepository.updateNegotiation")( + function* (input: UpdateHermesNegotiationInput) { + yield* validateProtocolVersion("updateNegotiation", input.protocolMajor, input.protocolMinor); + const rows = yield* sql<{ readonly binding_id: string }>` + UPDATE hermes_session_bindings + SET + protocol_classification = ${input.protocolClassification}, + protocol_major = ${input.protocolMajor}, + protocol_minor = ${input.protocolMinor}, + capabilities_json = ${normalizedCapabilities(input.capabilities)}, + updated_at = ${input.now} + WHERE binding_id = ${input.bindingId} + AND lease_owner_key = ${input.ownerKey} + AND lease_generation = ${input.generation} + AND lease_expires_at > ${input.now} + RETURNING binding_id + `; + return rows.length === 1; + }, + Effect.mapError(mapRepositoryError("updateNegotiation", "Could not update negotiation.")), + ); + + const updateReconciliation = Effect.fn("HermesSessionBindingRepository.updateReconciliation")( + function* (input: UpdateHermesReconciliationInput) { + const rows = yield* sql<{ readonly binding_id: string }>` + UPDATE hermes_session_bindings + SET + reconciliation_cursor = ${input.cursor}, + reconciliation_fingerprint = ${input.fingerprint}, + updated_at = ${input.now} + WHERE binding_id = ${input.bindingId} + AND lease_owner_key = ${input.ownerKey} + AND lease_generation = ${input.generation} + AND lease_expires_at > ${input.now} + RETURNING binding_id + `; + return rows.length === 1; + }, + Effect.mapError(mapRepositoryError("updateReconciliation", "Could not update reconciliation.")), + ); + + const updateTitleState = Effect.fn("HermesSessionBindingRepository.updateTitleState")( + function* (input: UpdateHermesTitleStateInput) { + const rows = yield* sql<{ readonly binding_id: string }>` + UPDATE hermes_session_bindings + SET + title_revision = ${input.revision}, + title_origin = ${input.origin}, + updated_at = ${input.now} + WHERE binding_id = ${input.bindingId} + AND lease_owner_key = ${input.ownerKey} + AND lease_generation = ${input.generation} + AND lease_expires_at > ${input.now} + AND title_revision < ${input.revision} + RETURNING binding_id + `; + return rows.length === 1; + }, + Effect.mapError(mapRepositoryError("updateTitleState", "Could not update title state.")), + ); + + const acquireOwnerLease = Effect.fn("HermesSessionBindingRepository.acquireOwnerLease")( + function* (input: AcquireHermesOwnerLeaseInput) { + if (input.expiresAt <= input.now) { + return yield* repositoryError( + "acquireOwnerLease", + "Lease expiry must be later than the compare-and-swap timestamp.", + ); + } + const rows = yield* sql<{ + readonly binding_id: string; + readonly lease_owner_key: string; + readonly lease_generation: number; + readonly lease_expires_at: string; + }>` + UPDATE hermes_session_bindings + SET + lease_owner_key = ${input.ownerKey}, + lease_generation = lease_generation + 1, + lease_expires_at = ${input.expiresAt}, + updated_at = ${input.now} + WHERE binding_id = ${input.bindingId} + AND lease_generation = ${input.expectedGeneration} + AND ( + lease_owner_key IS NULL + OR lease_expires_at <= ${input.now} + OR lease_owner_key = ${input.ownerKey} + ) + RETURNING binding_id, lease_owner_key, lease_generation, lease_expires_at + `; + const row = rows[0]; + return row === undefined + ? Option.none() + : Option.some({ + bindingId: row.binding_id, + ownerKey: row.lease_owner_key, + generation: row.lease_generation, + expiresAt: row.lease_expires_at, + }); + }, + Effect.mapError(mapRepositoryError("acquireOwnerLease", "Could not acquire the owner lease.")), + ); + + const renewOwnerLease = Effect.fn("HermesSessionBindingRepository.renewOwnerLease")( + function* (input: HermesLeaseFence & { readonly expiresAt: string }) { + if (input.expiresAt <= input.now) { + return yield* repositoryError( + "renewOwnerLease", + "Lease expiry must be later than the compare-and-swap timestamp.", + ); + } + const rows = yield* sql<{ readonly binding_id: string }>` + UPDATE hermes_session_bindings + SET lease_expires_at = ${input.expiresAt}, updated_at = ${input.now} + WHERE binding_id = ${input.bindingId} + AND lease_owner_key = ${input.ownerKey} + AND lease_generation = ${input.generation} + AND lease_expires_at > ${input.now} + RETURNING binding_id + `; + return rows.length === 1; + }, + Effect.mapError(mapRepositoryError("renewOwnerLease", "Could not renew the owner lease.")), + ); + + const releaseOwnerLease = Effect.fn("HermesSessionBindingRepository.releaseOwnerLease")( + function* (input: Omit & { readonly now: string }) { + const rows = yield* sql<{ readonly binding_id: string }>` + UPDATE hermes_session_bindings + SET lease_owner_key = NULL, lease_expires_at = NULL, updated_at = ${input.now} + WHERE binding_id = ${input.bindingId} + AND lease_owner_key = ${input.ownerKey} + AND lease_generation = ${input.generation} + RETURNING binding_id + `; + return rows.length === 1; + }, + Effect.mapError(mapRepositoryError("releaseOwnerLease", "Could not release the owner lease.")), + ); + + const getMutationIntent = Effect.fn("HermesSessionBindingRepository.getMutationIntent")( + function* (operationId: string) { + const rows = yield* sql` + SELECT * + FROM hermes_mutation_intents + WHERE operation_id = ${operationId} + `; + return yield* optionFromRows(rows, intentFromRow); + }, + Effect.mapError(mapRepositoryError("getMutationIntent", "Could not load the mutation intent.")), + ); + + const prepareMutationIntent = Effect.fn("HermesSessionBindingRepository.prepareMutationIntent")( + function* (input: PrepareHermesMutationIntentInput) { + if (!SHA256_DIGEST.test(input.payloadDigest)) { + return yield* repositoryError( + "prepareMutationIntent", + "payloadDigest must be a lowercase SHA-256 hex digest.", + ); + } + const rows = yield* sql` + INSERT INTO hermes_mutation_intents ( + operation_id, + binding_id, + provider_instance_id, + profile_key, + project_id, + thread_id, + run_id, + attempt_id, + message_id, + mutation_kind, + method, + payload_digest, + owner_generation, + state, + prepared_at, + admitted_at, + settled_at, + updated_at + ) + SELECT + ${input.operationId}, + binding_id, + provider_instance_id, + profile_key, + project_id, + thread_id, + NULL, + NULL, + NULL, + ${input.mutationKind}, + ${input.method}, + ${input.payloadDigest}, + ${input.generation}, + 'prepared', + ${input.now}, + NULL, + NULL, + ${input.now} + FROM hermes_session_bindings + WHERE binding_id = ${input.bindingId} + AND lease_owner_key = ${input.ownerKey} + AND lease_generation = ${input.generation} + AND lease_expires_at > ${input.now} + ON CONFLICT DO NOTHING + RETURNING * + `; + const prepared = rows[0]; + if (prepared !== undefined) { + return { + status: "prepared", + intent: yield* intentFromRow(prepared), + } satisfies PrepareHermesMutationIntentResult; + } + + const existing = yield* getMutationIntent(input.operationId); + if (Option.isSome(existing)) { + return { + status: "operation_exists", + intent: existing.value, + } satisfies PrepareHermesMutationIntentResult; + } + + if (input.mutationKind === "prompt") { + const unsettled = yield* sql<{ readonly operation_id: string }>` + SELECT operation_id + FROM hermes_mutation_intents + WHERE binding_id = ${input.bindingId} + AND mutation_kind = 'prompt' + AND state IN ('prepared', 'admitted', 'indeterminate') + ORDER BY prepared_at ASC, operation_id ASC + LIMIT 1 + `; + if (unsettled[0] !== undefined) { + return { + status: "unsettled_prompt", + operationId: unsettled[0].operation_id, + } satisfies PrepareHermesMutationIntentResult; + } + } + return { status: "lease_not_held" } satisfies PrepareHermesMutationIntentResult; + }, + Effect.mapError( + mapRepositoryError("prepareMutationIntent", "Could not prepare the mutation intent."), + ), + ); + + const prepareSessionCreateIntent = Effect.fn( + "HermesSessionBindingRepository.prepareSessionCreateIntent", + )( + function* (input: PrepareHermesSessionCreateIntentInput) { + if (!SHA256_DIGEST.test(input.payloadDigest)) { + return yield* repositoryError( + "prepareSessionCreateIntent", + "payloadDigest must be a lowercase SHA-256 hex digest.", + ); + } + const rows = yield* sql` + INSERT INTO hermes_mutation_intents ( + operation_id, + binding_id, + provider_instance_id, + profile_key, + project_id, + thread_id, + run_id, + attempt_id, + message_id, + mutation_kind, + method, + payload_digest, + owner_generation, + state, + prepared_at, + admitted_at, + settled_at, + updated_at + ) VALUES ( + ${input.operationId}, + NULL, + ${input.providerInstanceId}, + ${input.profileKey}, + ${input.projectId}, + ${input.threadId}, + ${input.runId ?? null}, + ${input.attemptId ?? null}, + ${input.messageId ?? null}, + 'session_create', + ${input.method}, + ${input.payloadDigest}, + 0, + 'prepared', + ${input.now}, + NULL, + NULL, + ${input.now} + ) + ON CONFLICT DO NOTHING + RETURNING * + `; + const prepared = rows[0]; + if (prepared !== undefined) { + return { + status: "prepared", + intent: yield* intentFromRow(prepared), + } satisfies PrepareHermesSessionCreateIntentResult; + } + + const existing = yield* getMutationIntent(input.operationId); + if (Option.isSome(existing)) { + return { + status: "operation_exists", + intent: existing.value, + } satisfies PrepareHermesSessionCreateIntentResult; + } + + const unsettled = yield* sql<{ readonly operation_id: string }>` + SELECT operation_id + FROM hermes_mutation_intents + WHERE provider_instance_id = ${input.providerInstanceId} + AND profile_key = ${input.profileKey} + AND project_id = ${input.projectId} + AND thread_id = ${input.threadId} + AND mutation_kind = 'session_create' + AND state IN ('prepared', 'admitted', 'indeterminate') + ORDER BY prepared_at ASC, operation_id ASC + LIMIT 1 + `; + return { + status: "unsettled_create", + operationId: unsettled[0]!.operation_id, + } satisfies PrepareHermesSessionCreateIntentResult; + }, + Effect.mapError( + mapRepositoryError( + "prepareSessionCreateIntent", + "Could not prepare the pre-binding session-create intent.", + ), + ), + ); + + const transitionSessionCreateIntent = Effect.fn( + "HermesSessionBindingRepository.transitionSessionCreateIntent", + )( + function* (input: TransitionHermesSessionCreateIntentInput) { + const valid = + (input.from === "prepared" && + (input.to === "admitted" || input.to === "indeterminate" || input.to === "rejected")) || + (input.from === "admitted" && (input.to === "indeterminate" || input.to === "rejected")); + if (!valid) { + return yield* repositoryError( + "transitionSessionCreateIntent", + `Invalid pre-binding session-create transition ${input.from} -> ${input.to}.`, + ); + } + const terminal = input.to === "rejected"; + const rows = yield* sql<{ readonly operation_id: string }>` + UPDATE hermes_mutation_intents + SET + state = ${input.to}, + admitted_at = CASE + WHEN ${input.to} = 'admitted' THEN COALESCE(admitted_at, ${input.now}) + ELSE admitted_at + END, + settled_at = CASE WHEN ${terminal ? 1 : 0} = 1 THEN ${input.now} ELSE NULL END, + updated_at = ${input.now} + WHERE operation_id = ${input.operationId} + AND binding_id IS NULL + AND mutation_kind = 'session_create' + AND state = ${input.from} + RETURNING operation_id + `; + return rows.length === 1; + }, + Effect.mapError( + mapRepositoryError( + "transitionSessionCreateIntent", + "Could not transition the pre-binding session-create intent.", + ), + ), + ); + + const transitionMutationIntent = Effect.fn( + "HermesSessionBindingRepository.transitionMutationIntent", + )( + function* (input: TransitionHermesMutationIntentInput) { + if (!allowedTransitions[input.from].has(input.to)) { + return yield* repositoryError( + "transitionMutationIntent", + `Invalid mutation intent transition ${input.from} -> ${input.to}.`, + ); + } + const terminal = + input.to === "confirmed" || input.to === "reconciled" || input.to === "rejected"; + const rows = yield* sql<{ readonly operation_id: string }>` + UPDATE hermes_mutation_intents + SET + state = ${input.to}, + admitted_at = CASE + WHEN ${input.to} = 'admitted' THEN COALESCE(admitted_at, ${input.now}) + ELSE admitted_at + END, + settled_at = CASE WHEN ${terminal ? 1 : 0} = 1 THEN ${input.now} ELSE NULL END, + updated_at = ${input.now} + WHERE operation_id = ${input.operationId} + AND binding_id = ${input.bindingId} + AND state = ${input.from} + AND EXISTS ( + SELECT 1 + FROM hermes_session_bindings AS binding + WHERE binding.binding_id = hermes_mutation_intents.binding_id + AND binding.lease_owner_key = ${input.ownerKey} + AND binding.lease_generation = ${input.generation} + AND binding.lease_expires_at > ${input.now} + ) + RETURNING operation_id + `; + return rows.length === 1; + }, + Effect.mapError( + mapRepositoryError("transitionMutationIntent", "Could not transition the mutation intent."), + ), + ); + + const listUnsettledMutationIntents = Effect.fn( + "HermesSessionBindingRepository.listUnsettledMutationIntents", + )( + function* (bindingId: string) { + const rows = yield* sql` + SELECT * + FROM hermes_mutation_intents + WHERE binding_id = ${bindingId} + AND state IN ('prepared', 'admitted', 'indeterminate') + ORDER BY prepared_at ASC, operation_id ASC + `; + return yield* Effect.forEach(rows, intentFromRow, { concurrency: 1 }); + }, + Effect.mapError( + mapRepositoryError( + "listUnsettledMutationIntents", + "Could not list unsettled mutation intents.", + ), + ), + ); + + const prepareSessionImport = Effect.fn("HermesSessionBindingRepository.prepareSessionImport")( + function* (input: PrepareHermesSessionImportInput) { + const inserted = yield* sql` + INSERT INTO hermes_session_imports ( + import_id, + provider_instance_id, + profile_key, + project_id, + import_kind, + stored_session_key, + thread_id, + state, + created_at, + updated_at + ) VALUES ( + ${input.importId}, + ${input.providerInstanceId}, + ${input.profileKey}, + ${input.projectId}, + ${input.importKind}, + ${input.storedSessionKey}, + ${input.threadId}, + 'prepared', + ${input.now}, + ${input.now} + ) + ON CONFLICT DO NOTHING + RETURNING * + `; + if (inserted[0] !== undefined) return yield* importFromRow(inserted[0]); + + const existing = + input.importKind === "main" + ? yield* sql` + SELECT * FROM hermes_session_imports + WHERE provider_instance_id = ${input.providerInstanceId} + AND profile_key = ${input.profileKey} + AND project_id = ${input.projectId} + AND import_kind = 'main' + LIMIT 1 + ` + : yield* sql` + SELECT * FROM hermes_session_imports + WHERE provider_instance_id = ${input.providerInstanceId} + AND profile_key = ${input.profileKey} + AND project_id = ${input.projectId} + AND stored_session_key = ${input.storedSessionKey} + AND import_kind = 'session' + LIMIT 1 + `; + if (existing[0] === undefined) { + return yield* repositoryError( + "prepareSessionImport", + "Import identity conflicted with a different durable row.", + ); + } + return yield* importFromRow(existing[0]); + }, + Effect.mapError( + mapRepositoryError("prepareSessionImport", "Could not prepare the session import."), + ), + ); + + const getSessionImportByStoredIdentity = Effect.fn( + "HermesSessionBindingRepository.getSessionImportByStoredIdentity", + )( + function* (identity: HermesBindingStoredIdentity & { readonly projectId: string }) { + const rows = yield* sql` + SELECT * FROM hermes_session_imports + WHERE provider_instance_id = ${identity.providerInstanceId} + AND profile_key = ${identity.profileKey} + AND project_id = ${identity.projectId} + AND stored_session_key = ${identity.storedSessionKey} + AND import_kind = 'session' + LIMIT 1 + `; + return rows[0] === undefined ? Option.none() : Option.some(yield* importFromRow(rows[0])); + }, + Effect.mapError( + mapRepositoryError( + "getSessionImportByStoredIdentity", + "Could not read the stored-session import.", + ), + ), + ); + + const getMainSessionImport = Effect.fn("HermesSessionBindingRepository.getMainSessionImport")( + function* (input: { + readonly providerInstanceId: string; + readonly profileKey: string; + readonly projectId: string; + }) { + const rows = yield* sql` + SELECT * FROM hermes_session_imports + WHERE provider_instance_id = ${input.providerInstanceId} + AND profile_key = ${input.profileKey} + AND project_id = ${input.projectId} + AND import_kind = 'main' + LIMIT 1 + `; + return rows[0] === undefined ? Option.none() : Option.some(yield* importFromRow(rows[0])); + }, + Effect.mapError( + mapRepositoryError("getMainSessionImport", "Could not read the Hermes Main import."), + ), + ); + + const transitionSessionImport = Effect.fn( + "HermesSessionBindingRepository.transitionSessionImport", + )( + function* (input: { + readonly importId: string; + readonly from: "prepared" | "thread_created"; + readonly to: "thread_created" | "completed"; + readonly now: string; + }) { + const valid = + (input.from === "prepared" && input.to === "thread_created") || + (input.from === "thread_created" && input.to === "completed"); + if (!valid) { + return yield* repositoryError( + "transitionSessionImport", + `Invalid session import transition ${input.from} -> ${input.to}.`, + ); + } + const rows = yield* sql<{ readonly import_id: string }>` + UPDATE hermes_session_imports + SET state = ${input.to}, updated_at = ${input.now} + WHERE import_id = ${input.importId} + AND state = ${input.from} + RETURNING import_id + `; + return rows.length === 1; + }, + Effect.mapError( + mapRepositoryError("transitionSessionImport", "Could not transition the session import."), + ), + ); + + const setSessionImportInheritedCount = Effect.fn( + "HermesSessionBindingRepository.setSessionImportInheritedCount", + )( + function* (input: { + readonly importId: string; + readonly inheritedMessageCount: number; + readonly now: string; + }) { + if (!Number.isSafeInteger(input.inheritedMessageCount) || input.inheritedMessageCount < 0) { + return yield* repositoryError( + "setSessionImportInheritedCount", + "Inherited message count must be a non-negative integer.", + ); + } + yield* sql` + UPDATE hermes_session_imports + SET inherited_message_count = ${input.inheritedMessageCount}, updated_at = ${input.now} + WHERE import_id = ${input.importId} + AND inherited_message_count IS NULL + `; + const rows = yield* sql<{ readonly inherited_message_count: number | null }>` + SELECT inherited_message_count + FROM hermes_session_imports + WHERE import_id = ${input.importId} + `; + const recorded = rows[0]?.inherited_message_count; + if (recorded === null || recorded === undefined) { + return yield* repositoryError( + "setSessionImportInheritedCount", + "Import row was missing while recording the inherited boundary.", + ); + } + return recorded; + }, + Effect.mapError( + mapRepositoryError( + "setSessionImportInheritedCount", + "Could not record the inherited-history boundary.", + ), + ), + ); + + const listHistoryThreadIds = Effect.fn("HermesSessionBindingRepository.listHistoryThreadIds")( + function* (scope: HermesHistoryScope) { + const rows = yield* sql<{ readonly thread_id: string }>` + SELECT thread_id + FROM hermes_session_bindings + WHERE provider_instance_id = ${scope.providerInstanceId} + AND profile_key = ${scope.profileKey} + AND project_id = ${scope.projectId} + UNION + SELECT thread_id + FROM hermes_session_imports + WHERE provider_instance_id = ${scope.providerInstanceId} + AND profile_key = ${scope.profileKey} + AND project_id = ${scope.projectId} + `; + return rows.map((row) => row.thread_id); + }, + Effect.mapError( + mapRepositoryError("listHistoryThreadIds", "Could not list locally owned Hermes threads."), + ), + ); + + const clearHistoryRecords = Effect.fn("HermesSessionBindingRepository.clearHistoryRecords")( + function* (scope: HermesHistoryScope) { + return yield* sql.withTransaction( + Effect.gen(function* () { + const unsettled = yield* sql<{ readonly operation_id: string }>` + SELECT operation_id + FROM hermes_mutation_intents + WHERE provider_instance_id = ${scope.providerInstanceId} + AND profile_key = ${scope.profileKey} + AND project_id = ${scope.projectId} + AND state IN ('prepared', 'admitted', 'indeterminate') + LIMIT 1 + `; + if (unsettled[0] !== undefined) { + return yield* repositoryError( + "clearHistoryRecords", + `History reset is blocked by unsettled Hermes mutation ${unsettled[0].operation_id}.`, + ); + } + const rows = yield* sql<{ readonly count: number }>` + SELECT COUNT(*) AS count + FROM hermes_session_imports + WHERE provider_instance_id = ${scope.providerInstanceId} + AND profile_key = ${scope.profileKey} + AND project_id = ${scope.projectId} + `; + yield* sql` + DELETE FROM hermes_mutation_intents + WHERE provider_instance_id = ${scope.providerInstanceId} + AND profile_key = ${scope.profileKey} + AND project_id = ${scope.projectId} + `; + yield* sql` + DELETE FROM hermes_session_bindings + WHERE provider_instance_id = ${scope.providerInstanceId} + AND profile_key = ${scope.profileKey} + AND project_id = ${scope.projectId} + `; + yield* sql` + DELETE FROM hermes_session_imports + WHERE provider_instance_id = ${scope.providerInstanceId} + AND profile_key = ${scope.profileKey} + AND project_id = ${scope.projectId} + `; + return rows[0]?.count ?? 0; + }), + ); + }, + Effect.mapError( + mapRepositoryError("clearHistoryRecords", "Could not clear T3 Work history records."), + ), + ); + + return HermesSessionBindingRepository.of({ + createBinding, + getByThreadId, + getByStoredIdentity, + updateNegotiation, + updateReconciliation, + updateTitleState, + acquireOwnerLease, + renewOwnerLease, + releaseOwnerLease, + prepareMutationIntent, + prepareSessionCreateIntent, + transitionSessionCreateIntent, + transitionMutationIntent, + getMutationIntent, + listUnsettledMutationIntents, + prepareSessionImport, + getSessionImportByStoredIdentity, + getMainSessionImport, + transitionSessionImport, + setSessionImportInheritedCount, + listHistoryThreadIds, + clearHistoryRecords, + }); +}); + +export const layer: Layer.Layer = + Layer.effect(HermesSessionBindingRepository, make); diff --git a/apps/server/src/hermes/HermesSessionCatalog.test.ts b/apps/server/src/hermes/HermesSessionCatalog.test.ts new file mode 100644 index 00000000000..1e490d333e4 --- /dev/null +++ b/apps/server/src/hermes/HermesSessionCatalog.test.ts @@ -0,0 +1,188 @@ +import { describe, expect } from "vite-plus/test"; +import { + ProviderInstanceId, + type HermesGatewayCompatibility, + type HermesGatewaySessionListResult, +} from "@t3tools/contracts"; +import { it as effectIt } from "@effect/vitest"; +import * as Effect from "effect/Effect"; + +import { makeHermesSessionCatalog } from "./HermesSessionCatalog.ts"; +import { HermesServeRuntimeError } from "./HermesServeRuntime.ts"; + +const supportedCompatibility: HermesGatewayCompatibility = { + status: "supported", + protocol: { major: 1, minor: 0 }, + capabilities: ["profile.import", "session.lifecycle"], + inventory: ["profile.import", "session.lifecycle"], + reason: "complete negotiation", +}; + +const makeFakeClient = (compatibility: HermesGatewayCompatibility) => { + const calls: string[] = []; + return { + calls, + client: { + connect: async () => { + calls.push("connect"); + return compatibility; + }, + listSessions: async (): Promise => { + calls.push("listSessions"); + return { sessions: [] }; + }, + close: () => { + calls.push("close"); + }, + }, + }; +}; + +const catalogInput = { + providerInstanceId: ProviderInstanceId.make("hermes-test"), + endpoint: "ws://127.0.0.1:18789", + authToken: "gateway-token", + remoteGloballyEnabled: true, + remoteInstanceEnabled: true, + remotePairingToken: undefined, + remoteTlsCertificateSha256: undefined, + profileKey: "main", + importEnabled: true, +} as const; + +describe("HermesSessionCatalog", () => { + effectIt.effect( + "refuses to connect to a non-loopback endpoint when remote access is disabled", + () => + Effect.gen(function* () { + const fake = makeFakeClient(supportedCompatibility); + const catalog = makeHermesSessionCatalog({ + ...catalogInput, + endpoint: "wss://hermes.example.com:18789", + remoteInstanceEnabled: false, + clientFactory: () => fake.client, + }); + const error = yield* Effect.flip(catalog.list(10)); + expect(error.code).toBe("provider_not_configured"); + expect(error.message).toBe( + "Hermes session discovery is blocked by the gateway connection policy.", + ); + expect(error.cause).toMatchObject({ + status: "blocked", + code: "remote_instance_disabled", + }); + expect(fake.calls).toEqual([]); + }), + ); + + effectIt.effect("refuses a remote endpoint even when remote access is enabled", () => + Effect.gen(function* () { + const fake = makeFakeClient(supportedCompatibility); + const catalog = makeHermesSessionCatalog({ + ...catalogInput, + endpoint: "wss://hermes.example.com:18789", + clientFactory: () => fake.client, + }); + const error = yield* Effect.flip(catalog.list(10)); + expect(error.code).toBe("provider_not_configured"); + expect(fake.calls).toEqual([]); + }), + ); + + effectIt.effect("rejects gateways that do not advertise profile.import before listing", () => + Effect.gen(function* () { + const fake = makeFakeClient({ + ...supportedCompatibility, + capabilities: ["session.lifecycle"], + inventory: ["session.lifecycle"], + }); + const catalog = makeHermesSessionCatalog({ + ...catalogInput, + clientFactory: () => fake.client, + }); + const error = yield* Effect.flip(catalog.list(10)); + expect(error.code).toBe("import_failed"); + expect(error.message).toContain("profile.import"); + expect(fake.calls).toEqual(["connect", "close"]); + }), + ); + + effectIt.effect("rejects legacy gateways without a negotiated inventory before listing", () => + Effect.gen(function* () { + const fake = makeFakeClient({ + status: "legacy", + protocol: null, + capabilities: [], + inventory: null, + reason: "no negotiation", + }); + const catalog = makeHermesSessionCatalog({ + ...catalogInput, + clientFactory: () => fake.client, + }); + const error = yield* Effect.flip(catalog.list(10)); + expect(error.code).toBe("import_failed"); + expect(error.message).toContain("evidence-backed"); + expect(fake.calls).toEqual(["connect", "close"]); + }), + ); + + effectIt.effect("starts the managed serve runtime before discovery when provided", () => + Effect.gen(function* () { + const fake = makeFakeClient(supportedCompatibility); + const order: string[] = []; + const catalog = makeHermesSessionCatalog({ + ...catalogInput, + ensureReady: Effect.sync(() => { + order.push("ensure-ready"); + return { + endpoint: catalogInput.endpoint, + authToken: catalogInput.authToken, + ownership: "t3_owned" as const, + }; + }), + clientFactory: (options) => { + order.push(`client:${options.endpoint}`); + return fake.client; + }, + }); + const snapshot = yield* catalog.list(10); + expect(snapshot.sessions).toEqual([]); + expect(order).toEqual(["ensure-ready", `client:${catalogInput.endpoint}/`]); + }), + ); + + effectIt.effect("surfaces managed startup failures as gateway errors", () => + Effect.gen(function* () { + const fake = makeFakeClient(supportedCompatibility); + const catalog = makeHermesSessionCatalog({ + ...catalogInput, + ensureReady: Effect.fail( + new HermesServeRuntimeError({ + code: "managed_start_failed", + message: "T3 could not launch `hermes serve`.", + }), + ), + clientFactory: () => fake.client, + }); + const error = yield* Effect.flip(catalog.list(10)); + expect(error.code).toBe("gateway_error"); + expect(error.message).toContain("hermes serve"); + expect(fake.calls).toEqual([]); + }), + ); + + effectIt.effect("lists sessions from a ready loopback gateway with negotiated import", () => + Effect.gen(function* () { + const fake = makeFakeClient(supportedCompatibility); + const catalog = makeHermesSessionCatalog({ + ...catalogInput, + clientFactory: () => fake.client, + }); + const snapshot = yield* catalog.list(10); + expect(snapshot.profileKey).toBe("main"); + expect(snapshot.sessions).toEqual([]); + expect(fake.calls).toEqual(["connect", "listSessions", "close"]); + }), + ); +}); diff --git a/apps/server/src/hermes/HermesSessionCatalog.ts b/apps/server/src/hermes/HermesSessionCatalog.ts new file mode 100644 index 00000000000..733f188c660 --- /dev/null +++ b/apps/server/src/hermes/HermesSessionCatalog.ts @@ -0,0 +1,153 @@ +import { + type HermesGatewayCompatibility, + type HermesGatewayStoredSessionSummary, + HermesSessionsError, + type ProviderInstanceId, +} from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; + +import { assessHermesConnectionSecurity } from "./HermesConnectionSecurity.ts"; +import { HermesGatewayClient } from "./HermesGatewayClient.ts"; +import type { HermesServeConnection, HermesServeRuntimeError } from "./HermesServeRuntime.ts"; + +const HERMES_IMPORT_REQUIRED_CAPABILITIES = ["profile.import", "session.lifecycle"] as const; + +const isHermesSessionsError = Schema.is(HermesSessionsError); + +export function hermesImportCapabilityError( + compatibility: HermesGatewayCompatibility, +): string | null { + const available = new Set(compatibility.capabilities); + const missing = HERMES_IMPORT_REQUIRED_CAPABILITIES.filter( + (capability) => !available.has(capability), + ); + if ( + compatibility.status === "supported" && + compatibility.inventory !== null && + missing.length === 0 + ) { + return null; + } + return compatibility.status === "legacy" || compatibility.inventory === null + ? "Hermes import requires an evidence-backed negotiated capability inventory." + : `Hermes import is unavailable because the gateway did not advertise: ${missing.join(", ")}.`; +} + +export interface HermesSessionCatalogSnapshot { + readonly providerInstanceId: ProviderInstanceId; + readonly profileKey: string; + readonly compatibility: HermesGatewayCompatibility; + readonly sessions: ReadonlyArray; +} + +export interface HermesSessionCatalogShape { + readonly profileKey: string; + readonly importEnabled: boolean; + readonly list: ( + limit: number, + ) => Effect.Effect; +} + +export function makeHermesSessionCatalog(input: { + readonly providerInstanceId: ProviderInstanceId; + readonly endpoint: string; + readonly authToken: string | undefined; + readonly remoteGloballyEnabled: boolean; + readonly remoteInstanceEnabled: boolean; + readonly remotePairingToken: string | undefined; + readonly remoteTlsCertificateSha256: string | undefined; + readonly profileKey: string; + readonly importEnabled: boolean; + readonly ensureReady?: Effect.Effect; + readonly clientFactory?: (options: { + readonly endpoint: string; + readonly authToken: string; + }) => Pick; +}): HermesSessionCatalogShape { + return { + profileKey: input.profileKey, + importEnabled: input.importEnabled, + list: Effect.fn("HermesSessionCatalog.list")(function* (limit) { + if (input.authToken === undefined || input.endpoint.trim().length === 0) { + return yield* new HermesSessionsError({ + code: "provider_not_configured", + message: "Hermes session discovery requires a configured endpoint and gateway token.", + }); + } + // Route discovery through the serve runtime so a managed local Hermes + // instance is started before the gateway is contacted. + const connection = + input.ensureReady === undefined + ? undefined + : yield* input.ensureReady.pipe( + Effect.mapError( + (cause) => + new HermesSessionsError({ + code: + cause.code === "authentication_required" + ? "provider_not_configured" + : "gateway_error", + message: cause.message, + cause, + }), + ), + ); + const security = assessHermesConnectionSecurity({ + endpoint: connection?.endpoint ?? input.endpoint, + gatewayToken: connection?.authToken ?? input.authToken, + remoteGloballyEnabled: input.remoteGloballyEnabled, + remoteInstanceEnabled: input.remoteInstanceEnabled, + remotePairingToken: input.remotePairingToken, + remoteTlsCertificateSha256: input.remoteTlsCertificateSha256, + }); + if (security.status !== "ready") { + return yield* new HermesSessionsError({ + code: "provider_not_configured", + message: "Hermes session discovery is blocked by the gateway connection policy.", + cause: security, + }); + } + const client = + input.clientFactory?.({ endpoint: security.endpoint, authToken: security.authToken }) ?? + new HermesGatewayClient({ + endpoint: security.endpoint, + authToken: security.authToken, + }); + return yield* Effect.tryPromise({ + try: async () => { + try { + const compatibility = await client.connect(); + const capabilityError = hermesImportCapabilityError(compatibility); + if (capabilityError !== null) { + throw new HermesSessionsError({ + code: "import_failed", + message: capabilityError, + }); + } + const result = await client.listSessions({ + profile: input.profileKey, + limit, + }); + return { + providerInstanceId: input.providerInstanceId, + profileKey: input.profileKey, + compatibility, + sessions: result.sessions, + }; + } finally { + client.close(); + } + }, + catch: (cause) => + isHermesSessionsError(cause) + ? cause + : new HermesSessionsError({ + code: "gateway_error", + message: "Hermes session discovery failed.", + cause, + }), + }); + }), + }; +} diff --git a/apps/server/src/hermes/HermesSessionImportService.test.ts b/apps/server/src/hermes/HermesSessionImportService.test.ts new file mode 100644 index 00000000000..51c3d61c986 --- /dev/null +++ b/apps/server/src/hermes/HermesSessionImportService.test.ts @@ -0,0 +1,748 @@ +import { describe, expect, it } from "vite-plus/test"; +import { + ProjectId, + ProviderDriverKind, + ProviderInstanceId, + ThreadId, + type OrchestrationV2Command, +} from "@t3tools/contracts"; +import { it as effectIt } from "@effect/vitest"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import * as Stream from "effect/Stream"; + +import { + HERMES_IMPORT_TRANSPORT_SOURCES, + classifyHermesImportedSession, + hermesImportCapabilityError, + isHermesImportTransportSource, + isHermesSessionWithinImportAge, + make, +} from "./HermesSessionImportService.ts"; +import { + HermesSessionBindingRepository, + type HermesSessionImport, +} from "./HermesSessionBindingRepository.ts"; +import { ProviderInstanceRegistry } from "../provider/Services/ProviderInstanceRegistry.ts"; +import type { ProviderInstance } from "../provider/ProviderDriver.ts"; +import { OrchestratorV2 } from "../orchestration-v2/Orchestrator.ts"; +import { ThreadManagementService } from "../orchestration-v2/ThreadManagementService.ts"; +import { ProjectService } from "../project/ProjectService.ts"; +import { ServerConfig } from "../config.ts"; + +const TEST_T3_WORK_DIRECTORY = "/test/t3-work"; + +const testProjectService = (projectId: ProjectId) => + ProjectService.of({ + getByWorkspaceRoot: () => + Effect.succeed( + Option.some({ + id: projectId, + workspaceRoot: TEST_T3_WORK_DIRECTORY, + } as never), + ), + } as never); + +const testServerConfig = ServerConfig.of({ + t3WorkDir: TEST_T3_WORK_DIRECTORY, +} as never); + +describe("Hermes transport session import policy", () => { + it("recognizes every pinned built-in messaging transport without importing local surfaces", () => { + expect(HERMES_IMPORT_TRANSPORT_SOURCES).toContain("discord"); + expect(HERMES_IMPORT_TRANSPORT_SOURCES).toContain("telegram"); + expect(HERMES_IMPORT_TRANSPORT_SOURCES).toContain("slack"); + expect(HERMES_IMPORT_TRANSPORT_SOURCES).toContain("whatsapp"); + expect(HERMES_IMPORT_TRANSPORT_SOURCES).toContain("matrix"); + expect(isHermesImportTransportSource(" Discord ")).toBe(true); + expect(isHermesImportTransportSource("tui")).toBe(false); + expect(isHermesImportTransportSource("cli")).toBe(false); + expect(isHermesImportTransportSource("local")).toBe(false); + expect(isHermesImportTransportSource("custom-unverified-source")).toBe(false); + }); + + it("keeps the inclusive 72-hour boundary unsettled and settles only older sessions", () => { + const now = Date.UTC(2026, 6, 26, 12); + const cutoffSeconds = (now - 72 * 60 * 60 * 1_000) / 1_000; + + expect(classifyHermesImportedSession(cutoffSeconds, now)).toBe("unsettled"); + expect(classifyHermesImportedSession(cutoffSeconds + 1, now)).toBe("unsettled"); + expect(classifyHermesImportedSession(cutoffSeconds - 1, now)).toBe("settled"); + }); + + it("keeps the inclusive selected-age boundary and rejects older timestamps", () => { + const now = Date.UTC(2026, 6, 26, 12); + const cutoffSeconds = (now - 24 * 60 * 60 * 1_000) / 1_000; + + expect(isHermesSessionWithinImportAge(cutoffSeconds, 1, now)).toBe(true); + expect(isHermesSessionWithinImportAge(cutoffSeconds + 1, 1, now)).toBe(true); + expect(isHermesSessionWithinImportAge(cutoffSeconds - 1, 1, now)).toBe(false); + }); + + it("requires explicit negotiated import and session-list evidence", () => { + expect( + hermesImportCapabilityError({ + status: "legacy", + protocol: null, + capabilities: [], + inventory: null, + reason: "no negotiation", + }), + ).toContain("evidence-backed"); + expect( + hermesImportCapabilityError({ + status: "supported", + protocol: { major: 1, minor: 0 }, + capabilities: ["session.lifecycle"], + inventory: ["session.lifecycle"], + reason: "partial negotiation", + }), + ).toContain("profile.import"); + expect( + hermesImportCapabilityError({ + status: "supported", + protocol: { major: 1, minor: 0 }, + capabilities: ["profile.import", "session.lifecycle"], + inventory: ["profile.import", "session.lifecycle"], + reason: "complete negotiation", + }), + ).toBeNull(); + }); + + effectIt.effect("rejects discovery when profile import is disabled at the server boundary", () => + Effect.gen(function* () { + const registry = ProviderInstanceRegistry.of({ + getInstance: () => + Effect.succeed({ + driverKind: ProviderDriverKind.make("hermes"), + hermesSessionCatalog: { + profileKey: "disabled-profile", + importEnabled: false, + }, + } as never), + listInstances: Effect.succeed([]), + listUnavailable: Effect.succeed([]), + streamChanges: Stream.empty, + subscribeChanges: Effect.never, + }); + const service = yield* make.pipe( + Effect.provideService(ProviderInstanceRegistry, registry), + Effect.provideService(HermesSessionBindingRepository, {} as never), + Effect.provideService(ThreadManagementService, {} as never), + Effect.provideService( + ProjectService, + testProjectService(ProjectId.make("internal-work-backing")), + ), + Effect.provideService(ServerConfig, testServerConfig), + ); + const error = yield* Effect.flip( + service.discover({ providerInstanceId: ProviderInstanceId.make("hermes-disabled") }), + ); + expect(error.message).toContain("disabled"); + }), + ); + + effectIt.effect( + "imports only transport sessions, settles old shells, and replays as already imported", + () => + Effect.gen(function* () { + const providerInstanceId = ProviderInstanceId.make("hermes-work"); + const profileKey = "private-work"; + const recentSeconds = 0; + const imports = new Map(); + const bindings = new Set(); + const commands: OrchestrationV2Command[] = []; + + const repository = { + getSessionImportByStoredIdentity: (identity: { readonly storedSessionKey: string }) => + Effect.succeed( + Option.fromUndefinedOr( + [...imports.values()].find( + (row) => row.storedSessionKey === identity.storedSessionKey, + ), + ), + ), + getMainSessionImport: () => + Effect.succeed( + Option.fromUndefinedOr( + [...imports.values()].find((row) => row.importKind === "main"), + ), + ), + prepareSessionImport: (input: { + readonly importId: string; + readonly providerInstanceId: string; + readonly profileKey: string; + readonly projectId: string; + readonly importKind: "session" | "main"; + readonly storedSessionKey: string | null; + readonly threadId: string; + readonly now: string; + }) => + Effect.sync(() => { + const existing = [...imports.values()].find( + (row) => + row.providerInstanceId === input.providerInstanceId && + row.profileKey === input.profileKey && + row.importKind === input.importKind && + row.storedSessionKey === input.storedSessionKey, + ); + if (existing) return existing; + const row: HermesSessionImport = { + ...input, + inheritedMessageCount: null, + state: "prepared", + createdAt: input.now, + updatedAt: input.now, + }; + imports.set(row.importId, row); + return row; + }), + transitionSessionImport: (input: { + readonly importId: string; + readonly to: "thread_created" | "completed"; + readonly now: string; + }) => + Effect.sync(() => { + const row = imports.get(input.importId); + if (!row) return false; + imports.set(input.importId, { ...row, state: input.to, updatedAt: input.now }); + return true; + }), + getByStoredIdentity: (identity: { readonly storedSessionKey: string }) => + Effect.succeed( + bindings.has(identity.storedSessionKey) + ? Option.some({ storedSessionKey: identity.storedSessionKey }) + : Option.none(), + ), + createBinding: (input: { readonly storedSessionKey: string }) => + Effect.sync(() => { + if (bindings.has(input.storedSessionKey)) return false; + bindings.add(input.storedSessionKey); + return true; + }), + } as unknown as HermesSessionBindingRepository["Service"]; + const registry = ProviderInstanceRegistry.of({ + getInstance: () => + Effect.succeed({ + driverKind: ProviderDriverKind.make("hermes"), + hermesSessionCatalog: { + profileKey, + importEnabled: true, + list: () => + Effect.succeed({ + providerInstanceId, + profileKey, + compatibility: { + status: "supported", + protocol: { major: 1, minor: 0 }, + capabilities: ["profile.import", "session.lifecycle"], + inventory: ["profile.import", "session.lifecycle"], + reason: "test negotiation", + }, + sessions: [ + { + id: "discord-recent", + title: "Recent Discord", + preview: "", + started_at: recentSeconds, + message_count: 2, + source: "discord", + }, + { + id: "telegram-old", + title: "Old Telegram", + preview: "", + started_at: -73 * 60 * 60, + message_count: 4, + source: "telegram", + }, + { + id: "local-code", + title: "Local code", + preview: "", + started_at: recentSeconds, + message_count: 1, + source: "tui", + }, + ], + }), + }, + } as never), + listInstances: Effect.succeed([]), + listUnavailable: Effect.succeed([]), + streamChanges: Stream.empty, + subscribeChanges: Effect.never, + }); + const threadManagement = { + dispatch: (command: OrchestrationV2Command) => + Effect.sync(() => { + commands.push(command); + return {} as never; + }), + } as unknown as ThreadManagementService["Service"]; + + const service = yield* make.pipe( + Effect.provideService(ProviderInstanceRegistry, registry), + Effect.provideService(HermesSessionBindingRepository, repository), + Effect.provideService(ThreadManagementService, threadManagement), + Effect.provideService( + ProjectService, + testProjectService(ProjectId.make("internal-work-backing")), + ), + Effect.provideService(ServerConfig, testServerConfig), + ); + const input = { + providerInstanceId, + backingProjectId: ProjectId.make("internal-work-backing"), + selection: { type: "all" as const }, + activeWithinDays: 7, + }; + + const first = yield* service.importSessions(input); + const replay = yield* service.importSessions(input); + const forgedProjectError = yield* Effect.flip( + service.importSessions({ + ...input, + backingProjectId: ProjectId.make("project:other-environment"), + }), + ); + + expect(first.imported.map((item) => [item.storedSessionId, item.settlement])).toEqual([ + ["discord-recent", "unsettled"], + ["telegram-old", "settled"], + ]); + expect(replay.imported.map((item) => item.status)).toEqual([ + "already_imported", + "already_imported", + ]); + expect(commands.filter((command) => command.type === "thread.create")).toHaveLength(3); + const settles = commands.filter((command) => command.type === "thread.settle"); + expect(settles).toHaveLength(1); + // Settled imports carry the upstream started_at, not the import time, + // so sidebar age labels stay historical. + expect(settles[0]).toMatchObject({ settledAt: expect.stringMatching(/^\d{4}-/) }); + expect(bindings).toEqual(new Set(["discord-recent", "telegram-old"])); + expect(forgedProjectError.message).toContain("this environment's T3 Work project"); + + const firstThreadIds = first.imported.map((item) => item.threadId); + imports.clear(); + bindings.clear(); + const reimportedAfterReset = yield* service.importSessions(input); + expect(reimportedAfterReset.imported.map((item) => item.threadId)).not.toEqual( + firstThreadIds, + ); + }), + ); + + effectIt.effect("resets only the canonical project, provider, and profile owned shells", () => + Effect.gen(function* () { + const deleted: string[] = []; + let cleared = false; + const repository = { + listHistoryThreadIds: () => + Effect.succeed([ + "thread:owned", + "thread:other-project", + "thread:other-provider", + "thread:non-hermes", + ]), + clearHistoryRecords: (scope: unknown) => + Effect.sync(() => { + expect(scope).toEqual({ + providerInstanceId: "hermes-custom", + profileKey: "profile-a", + projectId: "project:work", + }); + cleared = true; + return 3; + }), + } as unknown as HermesSessionBindingRepository["Service"]; + const registry = ProviderInstanceRegistry.of({ + getInstance: () => + Effect.succeed({ + driverKind: ProviderDriverKind.make("hermes"), + hermesSessionCatalog: { + profileKey: "profile-a", + importEnabled: false, + }, + } as ProviderInstance), + listInstances: Effect.succeed([ + { + instanceId: ProviderInstanceId.make("hermes-custom"), + driverKind: ProviderDriverKind.make("hermes"), + } as never, + ]), + listUnavailable: Effect.succeed([]), + streamChanges: Stream.empty, + subscribeChanges: Effect.never, + }); + const threadManagement = { + getShellSnapshot: () => + Effect.succeed({ + schemaVersion: 1, + snapshotSequence: 1, + threads: [ + { + id: ThreadId.make("thread:work:1"), + projectId: ProjectId.make("project:work"), + providerInstanceId: ProviderInstanceId.make("codex"), + }, + { + id: ThreadId.make("thread:owned"), + projectId: ProjectId.make("project:work"), + providerInstanceId: ProviderInstanceId.make("hermes-custom"), + }, + { + id: ThreadId.make("thread:other-project"), + projectId: ProjectId.make("project:other"), + providerInstanceId: ProviderInstanceId.make("hermes-custom"), + }, + { + id: ThreadId.make("thread:other-provider"), + projectId: ProjectId.make("project:work"), + providerInstanceId: ProviderInstanceId.make("hermes-other"), + }, + { + id: ThreadId.make("thread:non-hermes"), + projectId: ProjectId.make("project:work"), + providerInstanceId: ProviderInstanceId.make("codex"), + }, + ], + archivedThreads: [ + { + id: ThreadId.make("thread:work:2"), + projectId: ProjectId.make("project:work"), + providerInstanceId: ProviderInstanceId.make("codex"), + }, + ], + } as never), + dispatch: (command: OrchestrationV2Command) => + Effect.sync(() => { + if (command.type === "thread.delete") deleted.push(command.threadId); + return {} as never; + }), + } as unknown as ThreadManagementService["Service"]; + const service = yield* make.pipe( + Effect.provideService(ProviderInstanceRegistry, registry), + Effect.provideService(HermesSessionBindingRepository, repository), + Effect.provideService(ThreadManagementService, threadManagement), + Effect.provideService(ProjectService, testProjectService(ProjectId.make("project:work"))), + Effect.provideService(ServerConfig, testServerConfig), + ); + + const result = yield* service.resetHistory({ + providerInstanceId: ProviderInstanceId.make("hermes-custom"), + backingProjectId: ProjectId.make("project:work"), + operationId: "reset:test", + }); + + expect(deleted).toEqual(["thread:owned"]); + expect(cleared).toBe(true); + expect(result).toEqual({ deletedThreadCount: 1, clearedImportCount: 3 }); + }), + ); + + effectIt.effect("creates the main T3 Work thread when no sessions match the age cutoff", () => + Effect.gen(function* () { + const providerInstanceId = ProviderInstanceId.make("hermes-work-empty"); + const imports = new Map(); + const commands: OrchestrationV2Command[] = []; + const repository = { + getMainSessionImport: () => + Effect.succeed( + Option.fromUndefinedOr([...imports.values()].find((row) => row.importKind === "main")), + ), + prepareSessionImport: (input: { + readonly importId: string; + readonly providerInstanceId: string; + readonly profileKey: string; + readonly projectId: string; + readonly importKind: "session" | "main"; + readonly storedSessionKey: string | null; + readonly threadId: string; + readonly now: string; + }) => + Effect.sync(() => { + const row: HermesSessionImport = { + ...input, + inheritedMessageCount: null, + state: "prepared", + createdAt: input.now, + updatedAt: input.now, + }; + imports.set(row.importId, row); + return row; + }), + transitionSessionImport: (input: { + readonly importId: string; + readonly to: "thread_created" | "completed"; + readonly now: string; + }) => + Effect.sync(() => { + const row = imports.get(input.importId); + if (!row) return false; + imports.set(input.importId, { ...row, state: input.to, updatedAt: input.now }); + return true; + }), + } as unknown as HermesSessionBindingRepository["Service"]; + const registry = ProviderInstanceRegistry.of({ + getInstance: () => + Effect.succeed({ + driverKind: ProviderDriverKind.make("hermes"), + hermesSessionCatalog: { + profileKey: "empty-profile", + importEnabled: true, + list: () => + Effect.succeed({ + providerInstanceId, + profileKey: "empty-profile", + compatibility: { + status: "supported", + protocol: { major: 1, minor: 0 }, + capabilities: ["profile.import", "session.lifecycle"], + inventory: ["profile.import", "session.lifecycle"], + reason: "test negotiation", + }, + sessions: [ + { + id: "old-discord", + title: "Too old", + preview: "", + started_at: -2 * 24 * 60 * 60, + message_count: 1, + source: "discord", + }, + ], + }), + }, + } as never), + listInstances: Effect.succeed([]), + listUnavailable: Effect.succeed([]), + streamChanges: Stream.empty, + subscribeChanges: Effect.never, + }); + const threadManagement = { + dispatch: (command: OrchestrationV2Command) => + Effect.sync(() => { + commands.push(command); + return {} as never; + }), + } as unknown as ThreadManagementService["Service"]; + + const service = yield* make.pipe( + Effect.provideService(ProviderInstanceRegistry, registry), + Effect.provideService(HermesSessionBindingRepository, repository), + Effect.provideService(ThreadManagementService, threadManagement), + Effect.provideService( + ProjectService, + testProjectService(ProjectId.make("internal-work-backing")), + ), + Effect.provideService(ServerConfig, testServerConfig), + ); + const result = yield* service.importSessions({ + providerInstanceId, + backingProjectId: ProjectId.make("internal-work-backing"), + selection: { type: "all" }, + activeWithinDays: 1, + }); + + expect(result.imported).toEqual([]); + expect(result.mainThreadId).not.toBeNull(); + expect(commands.filter((command) => command.type === "thread.create")).toHaveLength(1); + }), + ); + + effectIt.effect( + "propagates upstream started_at into imported thread timestamps and hydrates at import time", + () => + Effect.gen(function* () { + const providerInstanceId = ProviderInstanceId.make("hermes-work-timestamps"); + const profileKey = "private-work"; + const startedAtSeconds = -3600; + const imports = new Map(); + const boundThreadIds = new Set(); + const bindingNows: string[] = []; + const commands: OrchestrationV2Command[] = []; + const hydrated: ThreadId[] = []; + + const repository = { + getSessionImportByStoredIdentity: () => Effect.succeed(Option.none()), + prepareSessionImport: (input: { + readonly importId: string; + readonly providerInstanceId: string; + readonly profileKey: string; + readonly projectId: string; + readonly importKind: "session" | "main"; + readonly storedSessionKey: string | null; + readonly threadId: string; + readonly now: string; + }) => + Effect.sync(() => { + const row: HermesSessionImport = { + importId: input.importId, + providerInstanceId: input.providerInstanceId, + profileKey: input.profileKey, + projectId: input.projectId, + importKind: input.importKind, + storedSessionKey: input.storedSessionKey, + threadId: input.threadId, + inheritedMessageCount: null, + state: "prepared", + createdAt: input.now, + updatedAt: input.now, + }; + imports.set(row.importId, row); + return row; + }), + transitionSessionImport: () => Effect.succeed(true), + getMainSessionImport: () => Effect.succeed(Option.none()), + getByStoredIdentity: () => Effect.succeed(Option.none()), + getByThreadId: (threadId: string) => + Effect.succeed( + boundThreadIds.has(threadId) + ? Option.some({ + providerInstanceId: String(providerInstanceId), + }) + : Option.none(), + ), + createBinding: (input: { readonly threadId: string; readonly now: string }) => + Effect.sync(() => { + boundThreadIds.add(input.threadId); + bindingNows.push(input.now); + return true; + }), + } as unknown as HermesSessionBindingRepository["Service"]; + const registry = ProviderInstanceRegistry.of({ + getInstance: () => + Effect.succeed({ + driverKind: ProviderDriverKind.make("hermes"), + hermesSessionCatalog: { + profileKey, + importEnabled: true, + list: () => + Effect.succeed({ + providerInstanceId, + profileKey, + compatibility: { + status: "supported", + protocol: { major: 1, minor: 0 }, + capabilities: ["profile.import", "session.lifecycle"], + inventory: ["profile.import", "session.lifecycle"], + reason: "test negotiation", + }, + sessions: [ + { + id: "discord-hour-old", + title: "Hour-old Discord", + preview: "", + started_at: startedAtSeconds, + message_count: 2, + source: "discord", + }, + ], + }), + }, + } as never), + listInstances: Effect.succeed([]), + listUnavailable: Effect.succeed([]), + streamChanges: Stream.empty, + subscribeChanges: Effect.never, + }); + const threadManagement = { + dispatch: (command: OrchestrationV2Command) => + Effect.sync(() => { + commands.push(command); + return {} as never; + }), + } as unknown as ThreadManagementService["Service"]; + const orchestrator = { + hydrateProviderThreadSnapshot: (input: { readonly threadId: ThreadId }) => + Effect.sync(() => { + hydrated.push(input.threadId); + }), + } as unknown as OrchestratorV2["Service"]; + + const service = yield* make.pipe( + Effect.provideService(ProviderInstanceRegistry, registry), + Effect.provideService(HermesSessionBindingRepository, repository), + Effect.provideService(ThreadManagementService, threadManagement), + Effect.provideService(OrchestratorV2, orchestrator), + Effect.provideService( + ProjectService, + testProjectService(ProjectId.make("internal-work-backing")), + ), + Effect.provideService(ServerConfig, testServerConfig), + ); + + const result = yield* service.importSessions({ + providerInstanceId, + backingProjectId: ProjectId.make("internal-work-backing"), + selection: { type: "all" }, + activeWithinDays: 7, + ensureMain: false, + }); + + const expectedStartedAt = DateTime.makeUnsafe(startedAtSeconds * 1_000); + const creates = commands.filter( + (command): command is Extract => + command.type === "thread.create", + ); + expect(creates).toHaveLength(1); + expect(creates[0]!.createdAt).toEqual(expectedStartedAt); + expect(bindingNows).toEqual([DateTime.formatIso(expectedStartedAt)]); + expect(hydrated).toEqual(result.imported.map((item) => item.threadId)); + expect(hydrated).toHaveLength(1); + }), + ); + + effectIt.effect( + "hydrates an already-completed imported thread whenever its durable binding is opened", + () => + Effect.gen(function* () { + const threadId = ThreadId.make("thread:hermes:already-imported"); + const hydrated: ThreadId[] = []; + const repository = { + getByThreadId: () => + Effect.succeed( + Option.some({ + bindingId: "existing-binding", + providerInstanceId: "hermes-work", + }), + ), + } as unknown as HermesSessionBindingRepository["Service"]; + const registry = ProviderInstanceRegistry.of({ + getInstance: () => Effect.sync(() => undefined as never), + listInstances: Effect.succeed([]), + listUnavailable: Effect.succeed([]), + streamChanges: Stream.empty, + subscribeChanges: Effect.never, + }); + const threadManagement = { + dispatch: () => Effect.die("unused dispatch"), + } as unknown as ThreadManagementService["Service"]; + const orchestrator = { + hydrateProviderThreadSnapshot: (input: { readonly threadId: ThreadId }) => + Effect.sync(() => { + hydrated.push(input.threadId); + }), + } as unknown as OrchestratorV2["Service"]; + + const service = yield* make.pipe( + Effect.provideService(ProviderInstanceRegistry, registry), + Effect.provideService(HermesSessionBindingRepository, repository), + Effect.provideService(ThreadManagementService, threadManagement), + Effect.provideService(OrchestratorV2, orchestrator), + Effect.provideService( + ProjectService, + testProjectService(ProjectId.make("internal-work-backing")), + ), + Effect.provideService(ServerConfig, testServerConfig), + ); + yield* service.hydrateThread(threadId); + yield* service.hydrateThread(threadId); + + expect(hydrated).toEqual([threadId, threadId]); + }), + ); +}); diff --git a/apps/server/src/hermes/HermesSessionImportService.ts b/apps/server/src/hermes/HermesSessionImportService.ts new file mode 100644 index 00000000000..92d6af2baaf --- /dev/null +++ b/apps/server/src/hermes/HermesSessionImportService.ts @@ -0,0 +1,596 @@ +import { + CommandId, + type HermesDiscoveredSession, + type HermesHistoryResetInput, + type HermesHistoryResetResult, + type HermesSessionDiscoveryInput, + type HermesSessionDiscoveryResult, + type HermesSessionImportCapabilities, + type HermesSessionImportInput, + type HermesSessionImportResult, + HermesSessionsError, + ProjectId, + ProviderDriverKind, + ProviderInstanceId, + ThreadId, +} from "@t3tools/contracts"; +import * as NodeCrypto from "node:crypto"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; + +import { ServerConfig } from "../config.ts"; +import { OrchestratorV2 } from "../orchestration-v2/Orchestrator.ts"; +import { ThreadManagementService } from "../orchestration-v2/ThreadManagementService.ts"; +import { ProjectService } from "../project/ProjectService.ts"; +import { ProviderInstanceRegistry } from "../provider/Services/ProviderInstanceRegistry.ts"; +import { + HermesSessionBindingRepository, + type HermesSessionImport, +} from "./HermesSessionBindingRepository.ts"; +import { + hermesImportCapabilityError, + type HermesSessionCatalogSnapshot, +} from "./HermesSessionCatalog.ts"; + +export { hermesImportCapabilityError } from "./HermesSessionCatalog.ts"; + +const HERMES = ProviderDriverKind.make("hermes"); +const MAX_DISCOVERY_LIMIT = 10_000; +const DAY_MS = 24 * 60 * 60 * 1_000; +export const HERMES_IMPORT_UNSETTLED_WINDOW_MS = 72 * 60 * 60 * 1_000; + +/** + * Built-in messaging transports declared by the pinned Hermes Platform enum. + * `local` is deliberately absent: T3 Work onboarding imports transport + * conversations, not CLI/TUI/ACP/code sessions. Unknown/custom source labels + * remain visible to Hermes itself but are not guessed to be transports here. + */ +export const HERMES_IMPORT_TRANSPORT_SOURCES = [ + "telegram", + "discord", + "whatsapp", + "whatsapp_cloud", + "slack", + "signal", + "mattermost", + "matrix", + "homeassistant", + "email", + "sms", + "dingtalk", + "api_server", + "webhook", + "msgraph_webhook", + "feishu", + "wecom", + "wecom_callback", + "weixin", + "bluebubbles", + "qqbot", + "yuanbao", + "relay", +] as const; + +const HERMES_IMPORT_TRANSPORT_SOURCE_SET = new Set(HERMES_IMPORT_TRANSPORT_SOURCES); + +export function isHermesImportTransportSource(source: string): boolean { + return HERMES_IMPORT_TRANSPORT_SOURCE_SET.has(source.trim().toLowerCase()); +} + +export function classifyHermesImportedSession( + startedAtSeconds: number, + nowMillis: number, +): "unsettled" | "settled" { + return startedAtSeconds * 1_000 >= nowMillis - HERMES_IMPORT_UNSETTLED_WINDOW_MS + ? "unsettled" + : "settled"; +} + +export function isHermesSessionWithinImportAge( + startedAtSeconds: number, + activeWithinDays: number, + nowMillis: number, +): boolean { + return startedAtSeconds * 1_000 >= nowMillis - activeWithinDays * DAY_MS; +} + +export const HERMES_SESSION_IMPORT_CAPABILITIES: HermesSessionImportCapabilities = { + discovery: true, + lazyHistory: true, + transportSources: [...HERMES_IMPORT_TRANSPORT_SOURCES], + activityTimestamp: { + field: "started_at", + limitation: + "The pinned session.list response omits last_active; classification uses its only timestamp, started_at.", + }, + childSessionLineage: { + available: false, + reason: + "The pinned session.list response does not expose parent_session_id, so imported child lineage cannot be reconstructed safely.", + }, + copyChildSession: { + available: false, + reason: + "The pinned session.branch method copies only the latest live head and has no stable source message/run boundary.", + }, +}; + +const digest = (...parts: ReadonlyArray): string => + NodeCrypto.createHash("sha256").update(JSON.stringify(parts)).digest("hex"); + +const freshImportIdentity = (kind: "main" | "session") => { + const id = NodeCrypto.randomUUID(); + return { + importId: `hermes-import:${kind}:${id}`, + threadId: ThreadId.make(`thread:hermes:${kind}:${id}`), + } as const; +}; + +const isHermesSessionsError = Schema.is(HermesSessionsError); + +function asImportError(cause: unknown): HermesSessionsError { + return isHermesSessionsError(cause) + ? cause + : new HermesSessionsError({ + code: "import_failed", + message: "Hermes session import failed.", + cause, + }); +} + +function asHistoryResetError(cause: unknown): HermesSessionsError { + return isHermesSessionsError(cause) + ? cause + : new HermesSessionsError({ + code: "history_reset_failed", + message: "T3 Work history reset failed.", + cause, + }); +} + +export interface HermesSessionImportServiceShape { + readonly hydrateThread: (threadId: ThreadId) => Effect.Effect; + readonly discover: ( + input: HermesSessionDiscoveryInput, + ) => Effect.Effect; + readonly importSessions: ( + input: HermesSessionImportInput, + ) => Effect.Effect; + readonly resetHistory: ( + input: HermesHistoryResetInput, + ) => Effect.Effect; +} + +export const make = Effect.gen(function* () { + const instances = yield* ProviderInstanceRegistry; + const repository = yield* HermesSessionBindingRepository; + const threads = yield* ThreadManagementService; + const projects = yield* ProjectService; + const config = yield* ServerConfig; + const orchestrator = yield* Effect.serviceOption(OrchestratorV2); + + const hydrateThread = Effect.fn("HermesSessionImportService.hydrateThread")( + function* (threadId: ThreadId) { + if (Option.isNone(orchestrator)) return true; + const binding = yield* repository.getByThreadId(String(threadId)); + if (Option.isNone(binding)) return true; + // Hydration is retried because a transient gateway failure here would + // otherwise leave a completed import with no projected transcript — + // unrecoverable once the upstream session vanishes. + yield* orchestrator.value + .hydrateProviderThreadSnapshot({ + threadId, + providerInstanceId: ProviderInstanceId.make(binding.value.providerInstanceId), + }) + .pipe(Effect.retry({ times: 3 })); + return true; + }, + Effect.catchCause((cause) => + Effect.logError("Unable to hydrate imported Hermes thread history", { + cause, + }).pipe(Effect.as(false)), + ), + ); + + const resolveCatalog = Effect.fn("HermesSessionImportService.resolveCatalog")(function* ( + providerInstanceId: HermesSessionDiscoveryInput["providerInstanceId"], + ) { + const instance = yield* instances.getInstance(providerInstanceId); + if (instance === undefined) { + return yield* new HermesSessionsError({ + code: "provider_not_found", + message: `Provider instance ${providerInstanceId} was not found.`, + }); + } + if (instance.driverKind !== HERMES || instance.hermesSessionCatalog === undefined) { + return yield* new HermesSessionsError({ + code: "provider_not_hermes", + message: `Provider instance ${providerInstanceId} is not a Hermes provider.`, + }); + } + return instance.hermesSessionCatalog; + }); + + const resolveImportCatalog = Effect.fn("HermesSessionImportService.resolveImportCatalog")( + function* (providerInstanceId: HermesSessionDiscoveryInput["providerInstanceId"]) { + const catalog = yield* resolveCatalog(providerInstanceId); + if (catalog.importEnabled) return catalog; + return yield* new HermesSessionsError({ + code: "import_failed", + message: `Hermes profile import is disabled for provider instance ${providerInstanceId}.`, + }); + }, + ); + + const resolveCanonicalProject = Effect.fn("HermesSessionImportService.resolveCanonicalProject")( + function* () { + const workspaceRoot = config.t3WorkDir; + if (workspaceRoot === undefined) { + return yield* new HermesSessionsError({ + code: "import_failed", + message: "This environment does not expose a canonical T3 Work directory.", + }); + } + const project = yield* projects.getByWorkspaceRoot(workspaceRoot); + if (Option.isNone(project)) { + return yield* new HermesSessionsError({ + code: "import_failed", + message: "The canonical T3 Work backing project is not available in this environment.", + }); + } + return project.value; + }, + ); + + const resolveBackingProject = Effect.fn("HermesSessionImportService.resolveBackingProject")( + function* (callerProjectId: ProjectId) { + const project = yield* resolveCanonicalProject(); + if (project.id !== callerProjectId) { + return yield* new HermesSessionsError({ + code: "import_failed", + message: "The requested backing project is not this environment's T3 Work project.", + }); + } + return project; + }, + ); + + const assertImportCompatibility = Effect.fn( + "HermesSessionImportService.assertImportCompatibility", + )(function* (snapshot: HermesSessionCatalogSnapshot) { + const error = hermesImportCapabilityError(snapshot.compatibility); + if (error !== null) { + return yield* new HermesSessionsError({ + code: "import_failed", + message: error, + }); + } + }); + + const discover = Effect.fn("HermesSessionImportService.discover")(function* ( + input: HermesSessionDiscoveryInput, + ) { + const catalog = yield* resolveImportCatalog(input.providerInstanceId); + const backingProject = yield* resolveCanonicalProject(); + const snapshot = yield* catalog.list( + Math.max(1, Math.min(MAX_DISCOVERY_LIMIT, Math.trunc(input.limit ?? 200))), + ); + yield* assertImportCompatibility(snapshot); + const nowMillis = DateTime.toEpochMillis(yield* DateTime.now); + const sessions = yield* Effect.forEach( + snapshot.sessions.filter((session) => isHermesImportTransportSource(session.source)), + Effect.fnUntraced(function* (session) { + const imported = yield* repository.getSessionImportByStoredIdentity({ + providerInstanceId: String(input.providerInstanceId), + profileKey: snapshot.profileKey, + projectId: String(backingProject.id), + storedSessionKey: session.id, + }); + return { + storedSessionId: session.id, + title: session.title, + preview: session.preview, + startedAt: session.started_at, + settlement: classifyHermesImportedSession(session.started_at, nowMillis), + messageCount: session.message_count, + source: session.source, + importedThreadId: Option.isSome(imported) ? ThreadId.make(imported.value.threadId) : null, + } satisfies HermesDiscoveredSession; + }), + { concurrency: 16 }, + ); + const main = yield* repository.getMainSessionImport({ + providerInstanceId: String(input.providerInstanceId), + profileKey: snapshot.profileKey, + projectId: String(backingProject.id), + }); + return { + providerInstanceId: input.providerInstanceId, + profileKey: snapshot.profileKey, + sessions, + capabilities: HERMES_SESSION_IMPORT_CAPABILITIES, + mainThreadId: Option.isSome(main) ? ThreadId.make(main.value.threadId) : null, + } satisfies HermesSessionDiscoveryResult; + }, Effect.mapError(asImportError)); + + const createThreadForImport = Effect.fn("HermesSessionImportService.createThreadForImport")( + function* (input: { + readonly row: HermesSessionImport; + readonly projectId: ProjectId; + readonly title: string; + readonly isMain: boolean; + readonly createdAt?: DateTime.Utc; + }) { + if (input.row.state !== "prepared") return; + yield* threads.dispatch({ + type: "thread.create", + createdBy: "system", + creationSource: "provider", + commandId: CommandId.make(`command:${input.row.importId}:create-thread`), + threadId: ThreadId.make(input.row.threadId), + projectId: input.projectId, + title: input.title.trim() || "Untitled Hermes session", + modelSelection: { + instanceId: ProviderInstanceId.make(input.row.providerInstanceId), + model: "default", + }, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + ...(input.createdAt === undefined ? {} : { createdAt: input.createdAt }), + }); + if (input.isMain) { + yield* threads.dispatch({ + type: "thread.metadata.update", + commandId: CommandId.make(`command:${input.row.importId}:mark-main`), + threadId: ThreadId.make(input.row.threadId), + pinned: true, + workInboxRole: "main", + }); + } + const now = DateTime.formatIso(yield* DateTime.now); + yield* repository.transitionSessionImport({ + importId: input.row.importId, + from: "prepared", + to: "thread_created", + now, + }); + }, + ); + + const ensureMain = Effect.fn("HermesSessionImportService.ensureMain")(function* (input: { + readonly providerInstanceId: HermesSessionImportInput["providerInstanceId"]; + readonly profileKey: string; + readonly projectId: ProjectId; + }) { + const identity = freshImportIdentity("main"); + const now = DateTime.formatIso(yield* DateTime.now); + let row = yield* repository.prepareSessionImport({ + importId: identity.importId, + providerInstanceId: String(input.providerInstanceId), + profileKey: input.profileKey, + projectId: String(input.projectId), + importKind: "main", + storedSessionKey: null, + threadId: String(identity.threadId), + now, + }); + yield* createThreadForImport({ + row, + projectId: input.projectId, + title: "Main", + isMain: true, + }); + if (row.state === "prepared") { + row = { + ...row, + state: "thread_created", + updatedAt: DateTime.formatIso(DateTime.nowUnsafe()), + }; + } + if (row.state === "thread_created") { + yield* repository.transitionSessionImport({ + importId: row.importId, + from: "thread_created", + to: "completed", + now: DateTime.formatIso(yield* DateTime.now), + }); + } + return ThreadId.make(row.threadId); + }); + + const importSessions = Effect.fn("HermesSessionImportService.importSessions")(function* ( + input: HermesSessionImportInput, + ) { + const catalog = yield* resolveImportCatalog(input.providerInstanceId); + const backingProject = yield* resolveBackingProject(input.backingProjectId); + const requestedLimit = + input.selection.type === "recent" + ? Math.max(1, Math.min(MAX_DISCOVERY_LIMIT, Math.trunc(input.selection.limit ?? 20))) + : MAX_DISCOVERY_LIMIT; + const snapshot = yield* catalog.list(requestedLimit); + yield* assertImportCompatibility(snapshot); + const nowMillis = DateTime.toEpochMillis(yield* DateTime.now); + const transportSessions = snapshot.sessions.filter( + (session) => + isHermesImportTransportSource(session.source) && + isHermesSessionWithinImportAge(session.started_at, input.activeWithinDays, nowMillis), + ); + const selectedSessionIds = + input.selection.type === "selected" ? input.selection.sessionIds : null; + const selected = + selectedSessionIds !== null + ? transportSessions.filter((session) => selectedSessionIds.includes(session.id)) + : transportSessions; + if (selectedSessionIds !== null && selected.length !== new Set(selectedSessionIds).size) { + return yield* new HermesSessionsError({ + code: "import_failed", + message: + "One or more selected Hermes sessions were not returned by the configured profile.", + }); + } + + const imported = yield* Effect.forEach( + selected, + Effect.fnUntraced(function* (session) { + const settlement = classifyHermesImportedSession(session.started_at, nowMillis); + const identity = freshImportIdentity("session"); + const now = DateTime.formatIso(yield* DateTime.now); + let row = yield* repository.prepareSessionImport({ + importId: identity.importId, + providerInstanceId: String(input.providerInstanceId), + profileKey: snapshot.profileKey, + projectId: String(backingProject.id), + importKind: "session", + storedSessionKey: session.id, + threadId: String(identity.threadId), + now, + }); + const threadId = ThreadId.make(row.threadId); + const wasCompleted = row.state === "completed"; + // session.list has no last_active; started_at is the only upstream + // timestamp and becomes the imported thread's historical time. + const startedAt = + session.started_at !== 0 ? DateTime.makeUnsafe(session.started_at * 1_000) : undefined; + yield* createThreadForImport({ + row, + projectId: backingProject.id, + title: session.title || session.preview || "Hermes session", + isMain: false, + ...(startedAt === undefined ? {} : { createdAt: startedAt }), + }); + if (row.state === "prepared") { + row = { ...row, state: "thread_created", updatedAt: now }; + } + if (row.state === "thread_created") { + if (settlement === "settled") { + yield* threads.dispatch({ + type: "thread.settle", + commandId: CommandId.make(`command:${row.importId}:classify-settled`), + threadId, + ...(startedAt === undefined ? {} : { settledAt: DateTime.formatIso(startedAt) }), + }); + } + const binding = yield* repository.getByStoredIdentity({ + providerInstanceId: String(input.providerInstanceId), + profileKey: snapshot.profileKey, + storedSessionKey: session.id, + }); + if (Option.isNone(binding)) { + const created = yield* repository.createBinding({ + bindingId: `hermes-binding:${digest(row.threadId)}`, + providerInstanceId: String(input.providerInstanceId), + profileKey: snapshot.profileKey, + projectId: String(backingProject.id), + storedSessionKey: session.id, + threadId: String(threadId), + protocolClassification: snapshot.compatibility.status, + protocolMajor: snapshot.compatibility.protocol?.major ?? null, + protocolMinor: snapshot.compatibility.protocol?.minor ?? null, + capabilities: snapshot.compatibility.capabilities, + reconciliationCursor: null, + reconciliationFingerprint: null, + // Imported bindings carry the upstream started_at so projected + // history timestamps stay historical rather than import time. + now: DateTime.formatIso(startedAt ?? (yield* DateTime.now)), + }); + if (!created) { + return yield* new HermesSessionsError({ + code: "import_failed", + message: `Hermes session ${session.id} conflicted with another imported thread.`, + }); + } + } + // Complete the ledger row only once history is projected; a failed + // hydrate stays in thread_created so re-importing retries it instead + // of leaving a completed import with an empty transcript. + const hydrated = yield* hydrateThread(threadId); + if (hydrated) { + yield* repository.transitionSessionImport({ + importId: row.importId, + from: "thread_created", + to: "completed", + now: DateTime.formatIso(yield* DateTime.now), + }); + } + } + return { + storedSessionId: session.id, + threadId, + settlement, + status: wasCompleted ? ("already_imported" as const) : ("imported" as const), + }; + }), + { concurrency: 1 }, + ); + let ensuredMainThreadId: ThreadId | null = null; + if (input.ensureMain === false) { + const existingMain = yield* repository.getMainSessionImport({ + providerInstanceId: String(input.providerInstanceId), + profileKey: snapshot.profileKey, + projectId: String(backingProject.id), + }); + ensuredMainThreadId = Option.isSome(existingMain) + ? ThreadId.make(existingMain.value.threadId) + : null; + } else { + ensuredMainThreadId = yield* ensureMain({ + providerInstanceId: input.providerInstanceId, + profileKey: snapshot.profileKey, + projectId: backingProject.id, + }); + } + return { + providerInstanceId: input.providerInstanceId, + profileKey: snapshot.profileKey, + imported, + mainThreadId: ensuredMainThreadId, + capabilities: HERMES_SESSION_IMPORT_CAPABILITIES, + } satisfies HermesSessionImportResult; + }, Effect.mapError(asImportError)); + + const resetHistory = Effect.fn("HermesSessionImportService.resetHistory")(function* ( + input: HermesHistoryResetInput, + ) { + const catalog = yield* resolveCatalog(input.providerInstanceId); + const backingProject = yield* resolveBackingProject(input.backingProjectId); + const scope = { + providerInstanceId: String(input.providerInstanceId), + profileKey: catalog.profileKey, + projectId: String(backingProject.id), + }; + const snapshot = yield* threads.getShellSnapshot(); + const historyThreadIds = new Set(yield* repository.listHistoryThreadIds(scope)); + const targets = [...snapshot.threads, ...snapshot.archivedThreads].filter( + (thread) => + historyThreadIds.has(String(thread.id)) && + String(thread.projectId) === scope.projectId && + String(thread.providerInstanceId) === scope.providerInstanceId, + ); + const clearedImportCount = yield* repository.clearHistoryRecords(scope); + yield* Effect.forEach( + targets, + (thread) => + threads.dispatch({ + type: "thread.delete", + commandId: CommandId.make(`${input.operationId}:delete:${thread.id}`), + threadId: thread.id, + }), + { concurrency: 1, discard: true }, + ); + return { + deletedThreadCount: targets.length, + clearedImportCount, + } satisfies HermesHistoryResetResult; + }, Effect.mapError(asHistoryResetError)); + + return { + hydrateThread, + discover, + importSessions, + resetHistory, + } satisfies HermesSessionImportServiceShape; +}); diff --git a/apps/server/src/hermes/HermesSkills.test.ts b/apps/server/src/hermes/HermesSkills.test.ts new file mode 100644 index 00000000000..2e455e6b94f --- /dev/null +++ b/apps/server/src/hermes/HermesSkills.test.ts @@ -0,0 +1,343 @@ +import { + HermesSkillsError, + ProviderInstanceConfigMap, + type HermesGatewayCompatibility, +} from "@t3tools/contracts"; +import { describe, expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; + +import * as ServerSettings from "../serverSettings.ts"; +import { + HermesGatewayConnectionError, + HermesGatewayMutationsBlockedError, +} from "./HermesGatewayClient.ts"; +import { + flattenHermesGatewaySkillsList, + makeHermesSkills, + projectHermesSkillEntry, + projectHermesSkillsCapabilities, +} from "./HermesSkills.ts"; + +const decodeProviderInstanceConfigMap = Schema.decodeUnknownSync(ProviderInstanceConfigMap); + +const supportedCompatibility: HermesGatewayCompatibility = { + status: "supported", + protocol: { major: 1, minor: 0 }, + inventory: { + "skills.manage": "supported", + "skills.reload": "supported", + }, + capabilities: ["skills.manage", "skills.reload"], + reason: "supported", +}; + +const legacyCompatibility: HermesGatewayCompatibility = { + status: "legacy", + protocol: null, + inventory: null, + capabilities: [], + reason: "legacy", +}; + +describe("HermesSkills projection", () => { + it("never synthesizes skills capabilities for legacy gateways", () => { + expect( + projectHermesSkillsCapabilities({ + ...legacyCompatibility, + capabilities: ["skills.manage", "skills.reload"], + }), + ).toEqual({ inventory: false, search: false, inspect: false, reload: false }); + }); + + it("derives capabilities from the negotiated inventory only", () => { + expect(projectHermesSkillsCapabilities(supportedCompatibility)).toEqual({ + inventory: true, + search: true, + inspect: true, + reload: true, + }); + expect( + projectHermesSkillsCapabilities({ + ...supportedCompatibility, + inventory: { "skills.manage": { actions: ["list", "inspect"] } }, + capabilities: ["skills.manage"], + }), + ).toEqual({ inventory: true, search: false, inspect: true, reload: false }); + }); + + it("projects skill entries from strings and records, dropping unnamed rows", () => { + expect(projectHermesSkillEntry("notes")).toEqual({ name: "notes", description: null }); + expect(projectHermesSkillEntry({ name: "git", description: "Git helper" })).toEqual({ + name: "git", + description: "Git helper", + }); + expect(projectHermesSkillEntry({ description: "unnamed" })).toBeNull(); + expect(projectHermesSkillEntry(42)).toBeNull(); + }); + + it("flattens category-map list responses and passes arrays through", () => { + expect( + flattenHermesGatewaySkillsList({ + coding: ["git", "notes"], + research: ["web"], + odd: "solo", + }), + ).toEqual(["git", "notes", "web", "solo"]); + expect(flattenHermesGatewaySkillsList(["git", { name: "notes" }])).toEqual([ + "git", + { name: "notes" }, + ]); + }); +}); + +describe("HermesSkills service", () => { + const settingsLayer = ServerSettings.layerTest({ + enableHermes: true, + providerInstances: decodeProviderInstanceConfigMap({ + hermes_main: { + driver: "hermes", + displayName: "Hermes", + enabled: true, + environment: [{ name: "HERMES_GATEWAY_TOKEN", value: "token-1", sensitive: true }], + config: { enabled: true, endpoint: "ws://127.0.0.1:9119/api/ws", profileKey: "work" }, + }, + }), + }); + + const makeClient = ( + compatibility: HermesGatewayCompatibility, + overrides: Record = {}, + ) => ({ + compatibility, + connect: () => Promise.resolve(compatibility), + hasCapability: (capability: string) => compatibility.capabilities.includes(capability), + listSkills: () => + Promise.resolve({ skills: ["notes", { name: "git", description: "Git helper" }, {}] }), + searchSkills: () => Promise.resolve({ results: [{ name: "hub-skill", description: "Hub" }] }), + inspectSkill: () => Promise.resolve({ info: { name: "hub-skill" } }), + reloadSkills: () => + Promise.resolve({ + output: "Reloaded", + result: { added: [{ name: "fresh" }], removed: ["stale"], total: 3 }, + }), + close: () => {}, + ...overrides, + }); + + it.effect("lists skills from a category-map response", () => + Effect.gen(function* () { + const skills = yield* makeHermesSkills({ + clientFactory: () => + makeClient(supportedCompatibility, { + listSkills: () => + Promise.resolve({ skills: { coding: ["git", "notes"], research: ["web"] } }), + }), + }); + const result = yield* skills.list(); + const main = result.providers.find( + (candidate) => candidate.providerInstanceId === "hermes_main", + ); + expect(main).toMatchObject({ + status: "ready", + skills: [ + { name: "git", description: null }, + { name: "notes", description: null }, + { name: "web", description: null }, + ], + }); + expect(main?.diagnostics).toEqual([]); + }).pipe(Effect.provide(settingsLayer)), + ); + + it.effect("lists installed skills for a negotiated gateway", () => + Effect.gen(function* () { + const skills = yield* makeHermesSkills({ + clientFactory: () => makeClient(supportedCompatibility), + }); + const result = yield* skills.list(); + const main = result.providers.find( + (candidate) => candidate.providerInstanceId === "hermes_main", + ); + expect(main).toMatchObject({ + providerInstanceId: "hermes_main", + profileKey: "work", + status: "ready", + protocolClassification: "supported", + capabilities: { inventory: true, search: true, inspect: true, reload: true }, + skills: [ + { name: "notes", description: null }, + { name: "git", description: "Git helper" }, + ], + }); + expect(main?.diagnostics).toEqual([ + "1 skill entr(ies) have no usable name and were omitted.", + ]); + }).pipe(Effect.provide(settingsLayer)), + ); + + it.effect("keeps legacy gateways blocked with an explicit diagnostic", () => + Effect.gen(function* () { + const skills = yield* makeHermesSkills({ + clientFactory: () => makeClient(legacyCompatibility), + }); + const result = yield* skills.list(); + const main = result.providers.find( + (candidate) => candidate.providerInstanceId === "hermes_main", + ); + expect(main).toMatchObject({ + status: "unavailable", + protocolClassification: "legacy", + capabilities: { inventory: false, search: false, inspect: false, reload: false }, + skills: [], + }); + expect(main?.diagnostics[0]).toContain("blocked"); + }).pipe(Effect.provide(settingsLayer)), + ); + + it.effect("maps skills hub search results per provider", () => + Effect.gen(function* () { + const skills = yield* makeHermesSkills({ + clientFactory: () => makeClient(supportedCompatibility), + }); + const result = yield* skills.search({ providerInstanceId: "hermes_main", query: "hub" }); + expect(result.results).toEqual([{ name: "hub-skill", description: "Hub" }]); + }).pipe(Effect.provide(settingsLayer)), + ); + + it.effect("rejects search for unknown providers", () => + Effect.gen(function* () { + const skills = yield* makeHermesSkills({ + clientFactory: () => makeClient(supportedCompatibility), + }); + const failure = yield* skills + .search({ providerInstanceId: "missing", query: "hub" }) + .pipe(Effect.flip); + expect(failure).toBeInstanceOf(HermesSkillsError); + expect(failure.code).toBe("provider_not_found"); + }).pipe(Effect.provide(settingsLayer)), + ); + + it.effect("rejects reload when the gateway does not advertise skills.reload", () => + Effect.gen(function* () { + const skills = yield* makeHermesSkills({ + clientFactory: () => + makeClient({ + ...supportedCompatibility, + inventory: { "skills.manage": "supported" }, + capabilities: ["skills.manage"], + }), + }); + const failure = yield* skills + .reload({ providerInstanceId: "hermes_main", operationId: "op-1" }) + .pipe(Effect.flip); + expect(failure.code).toBe("unsupported_operation"); + }).pipe(Effect.provide(settingsLayer)), + ); + + it.effect("maps the reload result into added/removed names and totals", () => + Effect.gen(function* () { + const skills = yield* makeHermesSkills({ + clientFactory: () => makeClient(supportedCompatibility), + }); + const result = yield* skills.reload({ + providerInstanceId: "hermes_main", + operationId: "op-1", + }); + expect(result).toEqual({ + added: ["fresh"], + removed: ["stale"], + total: 3, + output: "Reloaded", + }); + }).pipe(Effect.provide(settingsLayer)), + ); + + it.effect("maps blocked gateway writes to mutations_blocked instead of indeterminate", () => + Effect.gen(function* () { + const skills = yield* makeHermesSkills({ + clientFactory: () => + makeClient(supportedCompatibility, { + reloadSkills: () => Promise.reject(new HermesGatewayMutationsBlockedError(["op-old"])), + }), + }); + const failure = yield* skills + .reload({ providerInstanceId: "hermes_main", operationId: "op-1" }) + .pipe(Effect.flip); + expect(failure.code).toBe("mutations_blocked"); + }).pipe(Effect.provide(settingsLayer)), + ); + + it.effect("projects a throwing client factory as a per-provider error in list", () => + Effect.gen(function* () { + const skills = yield* makeHermesSkills({ + clientFactory: () => { + throw new Error("factory blocked"); + }, + }); + const result = yield* skills.list(); + const main = result.providers.find( + (candidate) => candidate.providerInstanceId === "hermes_main", + ); + expect(main).toMatchObject({ + status: "error", + capabilities: { inventory: false, search: false, inspect: false, reload: false }, + skills: [], + }); + }).pipe(Effect.provide(settingsLayer)), + ); + + it.effect("maps a throwing client factory to a typed error in search", () => + Effect.gen(function* () { + const skills = yield* makeHermesSkills({ + clientFactory: () => { + throw new Error("factory blocked"); + }, + }); + const failure = yield* skills + .search({ providerInstanceId: "hermes_main", query: "hub" }) + .pipe(Effect.flip); + expect(failure).toBeInstanceOf(HermesSkillsError); + expect(failure.code).toBe("gateway_error"); + }).pipe(Effect.provide(settingsLayer)), + ); + + it.effect("reports connection failures as unreachable instead of a capability problem", () => + Effect.gen(function* () { + const skills = yield* makeHermesSkills({ + clientFactory: () => + makeClient(supportedCompatibility, { + connect: () => Promise.reject(new HermesGatewayConnectionError("socket closed")), + }), + }); + const result = yield* skills.list(); + const main = result.providers.find( + (candidate) => candidate.providerInstanceId === "hermes_main", + ); + expect(main).toMatchObject({ status: "error" }); + expect(main?.diagnostics).toContain("Could not connect to the Hermes gateway."); + + const failure = yield* skills + .search({ providerInstanceId: "hermes_main", query: "hub" }) + .pipe(Effect.flip); + expect(failure.code).toBe("gateway_error"); + expect(failure.message).toBe("Could not connect to the Hermes gateway."); + }).pipe(Effect.provide(settingsLayer)), + ); + + it.effect("maps gateway failures into a bounded gateway_error", () => + Effect.gen(function* () { + const skills = yield* makeHermesSkills({ + clientFactory: () => + makeClient(supportedCompatibility, { + searchSkills: () => Promise.reject(new Error("boom")), + }), + }); + const failure = yield* skills + .search({ providerInstanceId: "hermes_main", query: "hub" }) + .pipe(Effect.flip); + expect(failure.code).toBe("gateway_error"); + expect(failure.message).toBe("Hermes skills gateway operation failed."); + }).pipe(Effect.provide(settingsLayer)), + ); +}); diff --git a/apps/server/src/hermes/HermesSkills.ts b/apps/server/src/hermes/HermesSkills.ts new file mode 100644 index 00000000000..cb34d92e6aa --- /dev/null +++ b/apps/server/src/hermes/HermesSkills.ts @@ -0,0 +1,465 @@ +import { + HermesSkillsError, + type HermesGatewayCompatibility, + type HermesGatewaySkillsInspectResult, + type HermesGatewaySkillsListResult, + type HermesGatewaySkillsReloadResult, + type HermesGatewaySkillsSearchResult, + type HermesSkillEntry, + type HermesSkillsCapabilities, + type HermesSkillsInspectInput, + type HermesSkillsInspectResult as HermesSkillsInspectResponse, + type HermesSkillsListResult, + type HermesSkillsProviderProjection, + type HermesSkillsReloadInput, + type HermesSkillsReloadResponse, + type HermesSkillsSearchInput, + type HermesSkillsSearchResult, +} from "@t3tools/contracts"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Schema from "effect/Schema"; + +import * as ServerSettings from "../serverSettings.ts"; +import { + HermesGatewayClient, + HermesGatewayConnectionError, + HermesGatewayMutationIndeterminateError, + HermesGatewayMutationsBlockedError, + type HermesGatewayMutationOptions, + type HermesGatewayReadOptions, +} from "./HermesGatewayClient.ts"; +import { + hermesManageActionInventory, + resolveHermesProviderConnections, + type HermesProviderConnection, +} from "./HermesProviderDirectory.ts"; + +interface HermesSkillsGatewayClient { + readonly compatibility: HermesGatewayCompatibility | undefined; + connect(): Promise; + hasCapability(capability: string): boolean; + listSkills( + options?: Omit, + ): Promise; + searchSkills( + query: string, + options?: Omit, + ): Promise; + inspectSkill( + name: string, + options?: Omit, + ): Promise; + reloadSkills( + options: Omit, + ): Promise; + close(): void; +} + +type HermesSkillsProviderConfig = HermesProviderConnection; + +export interface HermesSkillsOptions { + readonly clientFactory?: (input: { + readonly endpoint: string; + readonly authToken: string; + }) => HermesSkillsGatewayClient; +} + +export interface HermesSkillsShape { + readonly list: () => Effect.Effect; + readonly search: ( + input: HermesSkillsSearchInput, + ) => Effect.Effect; + readonly inspect: ( + input: HermesSkillsInspectInput, + ) => Effect.Effect; + readonly reload: ( + input: HermesSkillsReloadInput, + ) => Effect.Effect; +} + +export class HermesSkills extends Context.Service()( + "t3/hermes/HermesSkills", +) {} + +const isHermesSkillsError = Schema.is(HermesSkillsError); + +const record = (value: unknown): Record | undefined => + value !== null && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : undefined; +const string = (value: unknown): string | undefined => + typeof value === "string" && value.length > 0 ? value : undefined; + +/** + * Skills readiness comes only from the negotiated capability inventory. A + * legacy gateway without negotiation never receives synthetic skills + * capabilities and stays blocked. + */ +export function projectHermesSkillsCapabilities( + compatibility: HermesGatewayCompatibility, +): HermesSkillsCapabilities { + if (compatibility.status !== "supported") { + return { inventory: false, search: false, inspect: false, reload: false }; + } + const capabilities = new Set(compatibility.capabilities); + const actions = hermesManageActionInventory(compatibility, "skills.manage"); + const manage = capabilities.has("skills.manage"); + const supports = (action: string) => manage && (actions.size === 0 || actions.has(action)); + return { + inventory: supports("list"), + search: supports("search"), + inspect: supports("inspect"), + reload: capabilities.has("skills.reload"), + }; +} + +/** + * `skills.manage list` responses are either a flat entry array or a + * category -> entries map; flatten the map shape into one entry list. + */ +export function flattenHermesGatewaySkillsList( + skills: ReadonlyArray | Readonly>, +): ReadonlyArray { + if (Array.isArray(skills)) return skills; + const flattened: unknown[] = []; + for (const values of Object.values(skills)) { + if (Array.isArray(values)) flattened.push(...values); + else flattened.push(values); + } + return flattened; +} + +export function projectHermesSkillEntry(value: unknown): HermesSkillEntry | null { + if (typeof value === "string") { + return value.length > 0 ? { name: value, description: null } : null; + } + const row = record(value); + if (!row) return null; + const name = string(row.name) ?? string(row.id) ?? string(row.skill); + if (!name) return null; + return { name, description: string(row.description) ?? null }; +} + +const gatewayUnreachableMessage = "Could not connect to the Hermes gateway."; + +const isGatewayConnectionFailure = (cause: unknown): boolean => + cause instanceof HermesGatewayConnectionError; + +const blockedCapabilities: HermesSkillsCapabilities = { + inventory: false, + search: false, + inspect: false, + reload: false, +}; + +const unavailableProjection = ( + providerInstanceId: string, + displayName: string, + profileKey: string, + diagnostic: string, + status: "unavailable" | "error" = "unavailable", + protocolClassification: HermesGatewayCompatibility["status"] | null = null, +): HermesSkillsProviderProjection => ({ + providerInstanceId, + displayName, + profileKey, + status, + protocolClassification, + capabilities: blockedCapabilities, + skills: [], + diagnostics: [diagnostic], +}); + +export const makeHermesSkills = Effect.fn("HermesSkills.make")(function* ( + options: HermesSkillsOptions = {}, +) { + const settingsService = yield* ServerSettings.ServerSettingsService; + const clientFactory = + options.clientFactory ?? + ((input: { readonly endpoint: string; readonly authToken: string }) => + new HermesGatewayClient(input)); + + const configuredProviders = Effect.fn("HermesSkills.configuredProviders")(function* () { + const settings = yield* settingsService.getSettings.pipe( + Effect.mapError( + () => + new HermesSkillsError({ + code: "gateway_error", + message: "Could not read Hermes provider settings.", + }), + ), + ); + const directory = resolveHermesProviderConnections(settings); + return { + ready: directory.ready, + unavailable: directory.unavailable.map((provider) => + unavailableProjection( + provider.providerInstanceId, + provider.displayName, + provider.profileKey, + provider.diagnostic, + ), + ), + }; + }); + + const blockedProjection = ( + config: HermesSkillsProviderConfig, + compatibility: HermesGatewayCompatibility, + ): HermesSkillsProviderProjection | null => { + if (compatibility.status === "legacy") { + return unavailableProjection( + config.providerInstanceId, + config.displayName, + config.profileKey, + "Gateway capabilities are not negotiated; skills access stays blocked without a negotiated skills.manage capability.", + "unavailable", + compatibility.status, + ); + } + const capabilities = projectHermesSkillsCapabilities(compatibility); + if (!capabilities.inventory) { + return unavailableProjection( + config.providerInstanceId, + config.displayName, + config.profileKey, + "Gateway does not advertise skills.manage.", + "unavailable", + compatibility.status, + ); + } + return null; + }; + + const loadProvider = Effect.fn("HermesSkills.loadProvider")(function* ( + config: HermesSkillsProviderConfig, + ) { + return yield* Effect.tryPromise({ + try: async () => { + const client = clientFactory({ endpoint: config.endpoint, authToken: config.token }); + try { + const compatibility = await client.connect(); + const blocked = blockedProjection(config, compatibility); + if (blocked) return blocked; + const result = await client.listSkills(); + const entries = flattenHermesGatewaySkillsList(result.skills); + const skills: HermesSkillEntry[] = []; + for (const value of entries) { + const entry = projectHermesSkillEntry(value); + if (entry) skills.push(entry); + } + const diagnostics: string[] = []; + const dropped = entries.length - skills.length; + if (dropped > 0) { + diagnostics.push(`${dropped} skill entr(ies) have no usable name and were omitted.`); + } + return { + providerInstanceId: config.providerInstanceId, + displayName: config.displayName, + profileKey: config.profileKey, + status: "ready", + protocolClassification: compatibility.status, + capabilities: projectHermesSkillsCapabilities(compatibility), + skills, + diagnostics, + } satisfies HermesSkillsProviderProjection; + } finally { + client.close(); + } + }, + catch: (cause) => + new HermesSkillsError({ + code: "gateway_error", + providerInstanceId: config.providerInstanceId, + message: isGatewayConnectionFailure(cause) + ? gatewayUnreachableMessage + : "Could not read native Hermes skills inventory.", + }), + }).pipe( + Effect.catch((error) => + Effect.succeed( + unavailableProjection( + config.providerInstanceId, + config.displayName, + config.profileKey, + error.message, + "error", + ), + ), + ), + ); + }); + + const resolveConfig = Effect.fn("HermesSkills.resolveConfig")(function* ( + providerInstanceId: string, + operation: "search" | "inspect" | "reload", + ) { + const configured = yield* configuredProviders(); + const config = configured.ready.find( + (candidate) => candidate.providerInstanceId === providerInstanceId, + ); + if (!config) { + const known = configured.unavailable.some( + (candidate) => candidate.providerInstanceId === providerInstanceId, + ); + return yield* new HermesSkillsError({ + code: known ? "provider_unavailable" : "provider_not_found", + providerInstanceId, + operation, + message: known ? "Hermes provider is unavailable." : "Hermes provider was not found.", + }); + } + return config; + }); + + const withReadyClient = ( + config: HermesSkillsProviderConfig, + operation: "search" | "inspect" | "reload", + capability: (capabilities: HermesSkillsCapabilities) => boolean, + run: (client: HermesSkillsGatewayClient) => Promise, + ): Effect.Effect => { + return Effect.tryPromise({ + try: async () => { + const client = clientFactory({ endpoint: config.endpoint, authToken: config.token }); + try { + const compatibility = await client.connect(); + const capabilities = projectHermesSkillsCapabilities(compatibility); + if (compatibility.status !== "supported" || !capability(capabilities)) { + throw new HermesSkillsError({ + code: "unsupported_operation", + providerInstanceId: config.providerInstanceId, + operation, + message: `Hermes gateway does not support skills ${operation}.`, + }); + } + return await run(client); + } finally { + client.close(); + } + }, + catch: (cause) => { + if (isHermesSkillsError(cause)) return cause; + if (cause instanceof HermesGatewayMutationsBlockedError) { + return new HermesSkillsError({ + code: "mutations_blocked", + providerInstanceId: config.providerInstanceId, + operation, + message: + "Hermes gateway writes are blocked by an unresolved mutation; retry after it is reconciled.", + }); + } + if (cause instanceof HermesGatewayMutationIndeterminateError) { + return new HermesSkillsError({ + code: "indeterminate", + providerInstanceId: config.providerInstanceId, + operation, + message: "Hermes skills reload outcome is indeterminate; automatic replay is disabled.", + }); + } + return new HermesSkillsError({ + code: "gateway_error", + providerInstanceId: config.providerInstanceId, + operation, + message: isGatewayConnectionFailure(cause) + ? gatewayUnreachableMessage + : "Hermes skills gateway operation failed.", + }); + }, + }); + }; + + const list: HermesSkillsShape["list"] = Effect.fn("HermesSkills.list")(function* () { + const configured = yield* configuredProviders(); + const available = yield* Effect.forEach(configured.ready, loadProvider, { concurrency: 4 }); + return { providers: [...available, ...configured.unavailable] }; + }); + + const search: HermesSkillsShape["search"] = Effect.fn("HermesSkills.search")(function* (input) { + if (!input.query.trim()) { + return yield* new HermesSkillsError({ + code: "invalid_input", + providerInstanceId: input.providerInstanceId, + operation: "search", + message: "Skills search requires a non-empty query.", + }); + } + const config = yield* resolveConfig(input.providerInstanceId, "search"); + return yield* withReadyClient( + config, + "search", + (capabilities) => capabilities.search, + async (client) => { + const result = await client.searchSkills(input.query.trim()); + return { + results: result.results.map((entry) => ({ + name: entry.name, + description: entry.description ?? null, + })), + }; + }, + ); + }); + + const inspect: HermesSkillsShape["inspect"] = Effect.fn("HermesSkills.inspect")( + function* (input) { + if (!input.name.trim()) { + return yield* new HermesSkillsError({ + code: "invalid_input", + providerInstanceId: input.providerInstanceId, + operation: "inspect", + message: "Skills inspect requires a skill name.", + }); + } + const config = yield* resolveConfig(input.providerInstanceId, "inspect"); + return yield* withReadyClient( + config, + "inspect", + (capabilities) => capabilities.inspect, + async (client) => { + const result = await client.inspectSkill(input.name.trim()); + return { info: result.info }; + }, + ); + }, + ); + + const reload: HermesSkillsShape["reload"] = Effect.fn("HermesSkills.reload")(function* (input) { + if (!input.operationId.trim()) { + return yield* new HermesSkillsError({ + code: "invalid_input", + providerInstanceId: input.providerInstanceId, + operation: "reload", + message: "Skills reload requires an operation id.", + }); + } + const config = yield* resolveConfig(input.providerInstanceId, "reload"); + return yield* withReadyClient( + config, + "reload", + (capabilities) => capabilities.reload, + async (client) => { + const result = await client.reloadSkills({ operationId: input.operationId }); + const names = (values: ReadonlyArray | undefined) => { + const collected: string[] = []; + for (const value of values ?? []) { + const entry = projectHermesSkillEntry(value); + if (entry) collected.push(entry.name); + } + return collected; + }; + return { + added: names(result.result?.added), + removed: names(result.result?.removed), + total: result.result?.total ?? null, + output: result.output ?? null, + }; + }, + ); + }); + + return HermesSkills.of({ list, search, inspect, reload }); +}); + +export const layer = Layer.effect(HermesSkills, makeHermesSkills()); diff --git a/apps/server/src/http.ts b/apps/server/src/http.ts index 77eef955d55..63f1278cdc9 100644 --- a/apps/server/src/http.ts +++ b/apps/server/src/http.ts @@ -201,6 +201,15 @@ export const assetRouteLayer = HttpRouter.add( if (!asset) { return HttpServerResponse.text("Not Found", { status: 404 }); } + if (asset.kind === "open-file") { + return yield* HttpServerResponse.fileWeb(asset.file, { + status: 200, + headers: { + "Cache-Control": "private, max-age=3600", + "X-Content-Type-Options": "nosniff", + }, + }); + } return yield* HttpServerResponse.file(asset.path, { status: 200, headers: { diff --git a/apps/server/src/mcp/McpHttpServer.test.ts b/apps/server/src/mcp/McpHttpServer.test.ts index e1cdf992f08..e08bf22a32e 100644 --- a/apps/server/src/mcp/McpHttpServer.test.ts +++ b/apps/server/src/mcp/McpHttpServer.test.ts @@ -1,13 +1,28 @@ +// @effect-diagnostics nodeBuiltinImport:off +import * as NodeFS from "node:fs"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; + import { expect, it } from "@effect/vitest"; import { NodeHttpServer } from "@effect/platform-node"; import * as NodeServices from "@effect/platform-node/NodeServices"; -import { EnvironmentId, PreviewTabId, ProviderInstanceId, ThreadId } from "@t3tools/contracts"; +import { + EnvironmentId, + PreviewTabId, + ProjectId, + ProviderInstanceId, + ThreadId, +} from "@t3tools/contracts"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; import * as Stream from "effect/Stream"; import { McpSchema, McpServer } from "effect/unstable/ai"; import { HttpBody, HttpClient, HttpRouter, HttpServerResponse } from "effect/unstable/http"; +import * as ServerConfig from "../config.ts"; +import * as ProjectionSnapshotQuery from "../orchestration/Services/ProjectionSnapshotQuery.ts"; +import * as WorkspacePaths from "../workspace/WorkspacePaths.ts"; import * as McpHttpServer from "./McpHttpServer.ts"; import * as McpInvocationContext from "./McpInvocationContext.ts"; import * as PreviewAutomationBroker from "./PreviewAutomationBroker.ts"; @@ -16,12 +31,15 @@ const environmentId = EnvironmentId.make("environment-mcp-test"); const threadId = ThreadId.make("thread-mcp-test"); const tabId = PreviewTabId.make("tab-mcp-test"); const alternateTabId = PreviewTabId.make("tab-mcp-alternate"); +const testWorkspaceRoot = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "mcp-snapshot-test-")); const invocation = { + credentialId: "credential-mcp-test", environmentId, threadId, providerSessionId: "provider-session-mcp-test", providerInstanceId: ProviderInstanceId.make("codex"), capabilities: new Set(["preview"] as const), + audience: "urn:t3-code:mcp:environment-mcp-test", issuedAt: 1, }; const client = McpSchema.McpServerClient.of({ @@ -33,9 +51,44 @@ const client = McpSchema.McpServerClient.of({ }, getClient: Effect.die("unused"), }); +const stubProjectionSnapshotQueryLayer = Layer.succeed( + ProjectionSnapshotQuery.ProjectionSnapshotQuery, + { + getCommandReadModel: () => Effect.die("unused"), + getSnapshot: () => Effect.die("unused"), + getShellSnapshot: () => Effect.die("unused"), + getShellSnapshotWithoutEnrichment: () => Effect.die("unused"), + getArchivedShellSnapshot: () => Effect.die("unused"), + getSnapshotSequence: () => Effect.die("unused"), + getCounts: () => Effect.die("unused"), + getActiveProjectByWorkspaceRoot: () => Effect.die("unused"), + getProjectShellById: () => Effect.die("unused"), + getFirstActiveThreadIdByProjectId: () => Effect.die("unused"), + getThreadCheckpointContext: (contextThreadId) => + Effect.succeed( + contextThreadId === threadId + ? Option.some({ + threadId, + projectId: ProjectId.make("project-mcp-test"), + workspaceRoot: testWorkspaceRoot, + worktreePath: null, + checkpoints: [], + }) + : Option.none(), + ), + getFullThreadDiffContext: () => Effect.die("unused"), + getThreadShellById: () => Effect.die("unused"), + getThreadDetailById: () => Effect.die("unused"), + getThreadDetailSnapshot: () => Effect.die("unused"), + }, +); const TestLayer = McpHttpServer.PreviewToolkitRegistrationLive.pipe( Layer.provideMerge(McpServer.McpServer.layer), Layer.provideMerge(PreviewAutomationBroker.layer.pipe(Layer.provide(NodeServices.layer))), + Layer.provideMerge(stubProjectionSnapshotQueryLayer), + Layer.provideMerge(WorkspacePaths.layer), + Layer.provideMerge(ServerConfig.layerTest(process.cwd(), { prefix: "t3-mcp-http-test-" })), + Layer.provideMerge(NodeServices.layer), ); it("normalizes empty successful notification responses to accepted", () => { @@ -269,3 +322,98 @@ it.effect("registers annotated tools and preserves authenticated request context }), ).pipe(Effect.provide(TestLayer)), ); + +it.effect("saves snapshot screenshots without returning raw base64 metadata", () => + Effect.scoped( + Effect.gen(function* () { + const server = yield* McpServer.McpServer; + const broker = yield* PreviewAutomationBroker.PreviewAutomationBroker; + const events = yield* broker.connect({ + clientId: "mcp-save-client", + environmentId, + }); + yield* Stream.runForEach(events, (event) => + event.type === "connected" + ? Effect.void + : broker.respond({ + clientId: "mcp-save-client", + connectionId: event.connectionId, + requestId: event.request.requestId, + ok: true, + result: { + url: "http://example.test/", + title: "Example", + loading: false, + visibleText: "Example", + interactiveElements: [], + accessibilityTree: {}, + consoleEntries: [], + networkEntries: [], + actionTimeline: [], + screenshot: { + mimeType: "image/png", + data: Buffer.from("png-bytes").toString("base64"), + width: 10, + height: 5, + }, + }, + }), + ).pipe(Effect.forkScoped); + yield* Effect.yieldNow; + + const callSnapshot = (arguments_: Record) => + server + .callTool({ name: "preview_snapshot", arguments: arguments_ }) + .pipe( + Effect.provideService(McpInvocationContext.McpInvocationContext, invocation), + Effect.provideService(McpSchema.McpServerClient, client), + ); + + const saved = yield* callSnapshot({ savePath: "evidence/login.png" }); + expect(saved.isError).toBe(false); + expect(saved.structuredContent).toMatchObject({ + savedScreenshotPath: "evidence/login.png", + screenshot: { mimeType: "image/png", width: 10, height: 5 }, + }); + expect( + (saved.structuredContent as { screenshot?: { data?: unknown } }).screenshot?.data, + ).toBeUndefined(); + expect( + NodeFS.readFileSync(NodePath.join(testWorkspaceRoot, "evidence/login.png"), "utf8"), + ).toBe("png-bytes"); + + expect((yield* callSnapshot({ savePath: "../outside.png" })).isError).toBe(true); + const outsideDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "mcp-outside-")); + NodeFS.symlinkSync(outsideDir, NodePath.join(testWorkspaceRoot, "escape-dir")); + expect((yield* callSnapshot({ savePath: "escape-dir/sub/leak.png" })).isError).toBe(true); + expect(NodeFS.existsSync(NodePath.join(outsideDir, "sub"))).toBe(false); + + const victimPath = NodePath.join(outsideDir, "victim.png"); + NodeFS.writeFileSync(victimPath, "original"); + NodeFS.symlinkSync(victimPath, NodePath.join(testWorkspaceRoot, "evidence", "escape.png")); + expect((yield* callSnapshot({ savePath: "evidence/escape.png" })).isError).toBe(true); + expect(NodeFS.readFileSync(victimPath, "utf8")).toBe("original"); + + const danglingTarget = NodePath.join(outsideDir, "not-created.png"); + NodeFS.symlinkSync( + danglingTarget, + NodePath.join(testWorkspaceRoot, "evidence", "dangling.png"), + ); + expect((yield* callSnapshot({ savePath: "evidence/dangling.png" })).isError).toBe(true); + expect(NodeFS.existsSync(danglingTarget)).toBe(false); + + expect((yield* callSnapshot({ save: true, savePath: "evidence/login.png" })).isError).toBe( + true, + ); + + const artifact = yield* callSnapshot({ save: true }); + expect(artifact.isError).toBe(false); + const artifactPath = (artifact.structuredContent as { savedScreenshotPath?: string }) + .savedScreenshotPath; + const config = yield* ServerConfig.ServerConfig; + expect(artifactPath).toBeDefined(); + expect(NodePath.dirname(artifactPath!)).toBe(config.browserArtifactsDir); + expect(NodeFS.readFileSync(artifactPath!, "utf8")).toBe("png-bytes"); + }), + ).pipe(Effect.provide(TestLayer)), +); diff --git a/apps/server/src/mcp/McpHttpServer.ts b/apps/server/src/mcp/McpHttpServer.ts index 3a236937232..a0d058604e1 100644 --- a/apps/server/src/mcp/McpHttpServer.ts +++ b/apps/server/src/mcp/McpHttpServer.ts @@ -1,8 +1,10 @@ import * as Cause from "effect/Cause"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; +import * as Path from "effect/Path"; import * as Sink from "effect/Sink"; import * as Stream from "effect/Stream"; import type * as Types from "effect/Types"; @@ -10,6 +12,9 @@ import { McpSchema, McpServer, Tool } from "effect/unstable/ai"; import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"; import packageJson from "../../package.json" with { type: "json" }; +import * as ServerConfig from "../config.ts"; +import * as ProjectionSnapshotQuery from "../orchestration/Services/ProjectionSnapshotQuery.ts"; +import * as WorkspacePaths from "../workspace/WorkspacePaths.ts"; import * as McpInvocationContext from "./McpInvocationContext.ts"; import * as OrchestratorMcpService from "./OrchestratorMcpService.ts"; import * as McpSessionRegistry from "./McpSessionRegistry.ts"; @@ -79,7 +84,7 @@ const makeMcpAuthMiddleware = McpSessionRegistry.McpSessionRegistry.pipe( authorization?.startsWith("Bearer ") === true ? authorization.slice("Bearer ".length).trim() : ""; - const invocation = yield* registry.resolve(token); + const invocation = yield* registry.resolve(token, registry.audience); if (!invocation) return unauthorized; return yield* httpEffect.pipe( Effect.provideService(McpInvocationContext.McpInvocationContext, invocation), @@ -128,6 +133,13 @@ const previewSnapshotFailure = (cause: Cause.Cause) => { const registerPreviewSnapshot = Effect.fn("McpHttpServer.registerPreviewSnapshot")(function* () { const server = yield* McpServer.McpServer; const broker = yield* PreviewAutomationBroker.PreviewAutomationBroker; + const snapshotHandlerContext = yield* Effect.context< + | ServerConfig.ServerConfig + | ProjectionSnapshotQuery.ProjectionSnapshotQuery + | WorkspacePaths.WorkspacePaths + | FileSystem.FileSystem + | Path.Path + >(); const built = yield* PreviewSnapshotToolkit; const tool = PreviewSnapshotTool; yield* server.addTool({ @@ -159,6 +171,7 @@ const registerPreviewSnapshot = Effect.fn("McpHttpServer.registerPreviewSnapshot Effect.flatMap(Effect.fromOption), Effect.provideService(PreviewAutomationBroker.PreviewAutomationBroker, broker), Effect.provideService(McpInvocationContext.McpInvocationContext, invocation), + Effect.provideContext(snapshotHandlerContext), Effect.matchCauseEffect({ onFailure: previewSnapshotFailure, onSuccess: ({ encodedResult }) => { diff --git a/apps/server/src/mcp/McpInvocationContext.test.ts b/apps/server/src/mcp/McpInvocationContext.test.ts index 569917325be..5e9f0b13bfb 100644 --- a/apps/server/src/mcp/McpInvocationContext.test.ts +++ b/apps/server/src/mcp/McpInvocationContext.test.ts @@ -11,11 +11,13 @@ import * as McpInvocationContext from "./McpInvocationContext.ts"; it.effect("reports the scoped credential context when preview capability is unavailable", () => { const invocation: McpInvocationContext.McpInvocationScope = { + credentialId: "credential-1", environmentId: EnvironmentId.make("environment-1"), threadId: ThreadId.make("thread-1"), providerSessionId: "provider-session-1", providerInstanceId: ProviderInstanceId.make("codex"), capabilities: new Set(), + audience: "urn:t3-code:mcp:environment-1", issuedAt: 1, }; diff --git a/apps/server/src/mcp/McpInvocationContext.ts b/apps/server/src/mcp/McpInvocationContext.ts index 4f28874f199..62b4b31b2a9 100644 --- a/apps/server/src/mcp/McpInvocationContext.ts +++ b/apps/server/src/mcp/McpInvocationContext.ts @@ -11,11 +11,14 @@ export const ALL_MCP_CAPABILITIES = ["preview", "orchestration", "worktree"] as export type McpCapability = (typeof ALL_MCP_CAPABILITIES)[number]; export interface McpInvocationScope { + /** Opaque, non-secret handle suitable for audit correlation and revocation. */ + readonly credentialId: string; readonly environmentId: EnvironmentId; readonly threadId: ThreadId; readonly providerSessionId: string; readonly providerInstanceId: ProviderInstanceId; readonly capabilities: ReadonlySet; + readonly audience: string; readonly issuedAt: number; } diff --git a/apps/server/src/mcp/McpProviderSession.ts b/apps/server/src/mcp/McpProviderSession.ts index d5dc582046c..5872f52023d 100644 --- a/apps/server/src/mcp/McpProviderSession.ts +++ b/apps/server/src/mcp/McpProviderSession.ts @@ -1,12 +1,17 @@ import type { EnvironmentId, ProviderInstanceId, ThreadId } from "@t3tools/contracts"; export interface McpProviderSessionConfig { + /** Opaque audit/revocation handle. This is not the bearer credential. */ + readonly credentialId?: string; readonly environmentId: EnvironmentId; readonly threadId: ThreadId; readonly providerSessionId: string; readonly providerInstanceId: ProviderInstanceId; readonly endpoint: string; readonly authorizationHeader: string; + readonly audience?: string; + readonly capabilities?: ReadonlyArray<"preview" | "orchestration" | "worktree">; + readonly issuedAt?: number; } const sessionsByThread = new Map(); diff --git a/apps/server/src/mcp/McpSessionRegistry.test.ts b/apps/server/src/mcp/McpSessionRegistry.test.ts index f8e275461a5..116945c4601 100644 --- a/apps/server/src/mcp/McpSessionRegistry.test.ts +++ b/apps/server/src/mcp/McpSessionRegistry.test.ts @@ -36,17 +36,19 @@ it.effect("stores only a token hash, resolves the bearer token, and revokes by t const issued = yield* registry.issue({ threadId, providerInstanceId: ProviderInstanceId.make("codex"), + capabilities: new Set(["preview", "orchestration"]), }); expect(issued.config.endpoint).toBe("http://127.0.0.1:43123/mcp"); const token = issued.config.authorizationHeader.replace(/^Bearer\s+/, ""); expect(token.length).toBeGreaterThan(20); - const resolved = yield* registry.resolve(token); + const resolved = yield* registry.resolve(token, registry.audience); expect(resolved?.threadId).toBe(threadId); - expect(resolved?.capabilities).toEqual(new Set(["preview", "orchestration", "worktree"])); + expect(resolved?.capabilities).toEqual(new Set(["preview", "orchestration"])); + expect(resolved?.audience).toBe(registry.audience); yield* registry.revokeThread(threadId); - expect(yield* registry.resolve(token)).toBeUndefined(); + expect(yield* registry.resolve(token, registry.audience)).toBeUndefined(); timestamp += 2_000; }), @@ -66,27 +68,64 @@ it.effect("builds MCP endpoints from the bound server host", () => const issued = yield* registry.issue({ threadId: ThreadId.make(`thread-${hostname}`), providerInstanceId: ProviderInstanceId.make("codex"), + capabilities: new Set(["preview"]), }); expect(issued.config.endpoint).toBe(expectedEndpoint); } }), ); -it.effect("keeps credentials valid until they are explicitly revoked", () => +it.effect("remains valid without wall-clock expiry and emits only audit-safe metadata", () => Effect.gen(function* () { let timestamp = 1_000; - const registry = yield* makeRegistry(() => timestamp); + const audit: Array = []; + const registry = yield* McpSessionRegistry.__testing + .make({ now: () => timestamp, audit: (event) => audit.push(event) }) + .pipe( + Effect.provideService(HttpServer.HttpServer, fakeHttpServer), + Effect.provideService(ServerEnvironment.ServerEnvironment, fakeEnvironment), + Effect.provide(NodeServices.layer), + ); const issued = yield* registry.issue({ threadId: ThreadId.make("thread-2"), providerInstanceId: ProviderInstanceId.make("claude"), + capabilities: new Set(["orchestration"]), }); const token = issued.config.authorizationHeader.replace(/^Bearer\s+/, ""); - const resolved = yield* registry.resolve(token); + const resolved = yield* registry.resolve(token, registry.audience); expect(resolved?.providerSessionId).toBe(issued.config.providerSessionId); + expect(yield* registry.resolve(token, "urn:t3-code:mcp:other")).toBeUndefined(); timestamp += 365 * 24 * 60 * 60 * 1_000; - expect(yield* registry.resolve(token)).toEqual(resolved); + expect(yield* registry.resolve(token, registry.audience)).toEqual(resolved); + expect(audit.map((event) => event.type)).toEqual([ + "issued", + "resolved", + "audience_denied", + "resolved", + ]); + expect(audit.some((event) => Object.values(event).includes(token))).toBe(false); + }), +); + +it.effect("rotates a thread credential atomically and revokes the predecessor", () => + Effect.gen(function* () { + const registry = yield* makeRegistry(() => 1_000); + const request = { + threadId: ThreadId.make("thread-rotate"), + providerInstanceId: ProviderInstanceId.make("codex"), + capabilities: new Set(["preview"] as const), + }; + const first = yield* registry.issue(request); + const second = yield* registry.rotate({ + ...request, + capabilities: new Set(["preview", "orchestration"]), + }); + const firstToken = first.config.authorizationHeader.replace(/^Bearer\s+/, ""); + const secondToken = second.config.authorizationHeader.replace(/^Bearer\s+/, ""); - yield* registry.revokeProviderSession(issued.config.providerSessionId); - expect(yield* registry.resolve(token)).toBeUndefined(); + expect(yield* registry.resolve(firstToken, registry.audience)).toBeUndefined(); + expect((yield* registry.resolve(secondToken, registry.audience))?.capabilities).toEqual( + new Set(["preview", "orchestration"]), + ); }), ); diff --git a/apps/server/src/mcp/McpSessionRegistry.testkit.ts b/apps/server/src/mcp/McpSessionRegistry.testkit.ts index a5dc099310b..278e3ffc30c 100644 --- a/apps/server/src/mcp/McpSessionRegistry.testkit.ts +++ b/apps/server/src/mcp/McpSessionRegistry.testkit.ts @@ -7,6 +7,7 @@ import { McpSessionRegistry } from "./McpSessionRegistry.ts"; export const layer = Layer.succeed( McpSessionRegistry, McpSessionRegistry.of({ + audience: "urn:t3-code:mcp:environment:mcp-test", issue: ({ threadId, providerInstanceId }) => Effect.succeed({ config: { @@ -18,6 +19,17 @@ export const layer = Layer.succeed( authorizationHeader: `Bearer mcp-test:${threadId}`, }, }), + rotate: ({ threadId, providerInstanceId }) => + Effect.succeed({ + config: { + environmentId: EnvironmentId.make("environment:mcp-test"), + threadId, + providerSessionId: `mcp-test:${threadId}`, + providerInstanceId, + endpoint: "http://127.0.0.1/mcp", + authorizationHeader: `Bearer mcp-test:${threadId}`, + }, + }), resolve: () => Effect.succeed(undefined), revokeProviderSession: () => Effect.void, revokeThread: () => Effect.void, diff --git a/apps/server/src/mcp/McpSessionRegistry.ts b/apps/server/src/mcp/McpSessionRegistry.ts index 069cf608345..09c889df013 100644 --- a/apps/server/src/mcp/McpSessionRegistry.ts +++ b/apps/server/src/mcp/McpSessionRegistry.ts @@ -14,6 +14,7 @@ import * as McpProviderSession from "./McpProviderSession.ts"; export interface McpCredentialRequest { readonly threadId: ThreadId; readonly providerInstanceId: ProviderInstanceId; + readonly capabilities: ReadonlySet; } export interface McpIssuedCredential { @@ -21,11 +22,15 @@ export interface McpIssuedCredential { } export interface McpSessionRegistryShape { - /** Credentials have no time-based expiry and must be explicitly revoked by their owner. */ + /** Credentials remain valid until their owning thread/session lifecycle revokes them. */ readonly issue: (request: McpCredentialRequest) => Effect.Effect; + /** Atomically replaces every credential owned by the thread. */ + readonly rotate: (request: McpCredentialRequest) => Effect.Effect; readonly resolve: ( rawToken: string, + audience: string, ) => Effect.Effect; + readonly audience: string; readonly revokeProviderSession: (providerSessionId: string) => Effect.Effect; readonly revokeThread: (threadId: ThreadId) => Effect.Effect; readonly revokeAll: Effect.Effect; @@ -44,10 +49,43 @@ interface RegistryState { readonly records: ReadonlyMap; } +type ResolveOutcome = + | { readonly type: "missing" } + | { + readonly type: "resolved" | "audience_denied"; + readonly scope: McpInvocationContext.McpInvocationScope; + }; + export interface McpSessionRegistryOptions { readonly now?: () => number; + readonly audit?: (event: McpCredentialAuditEvent) => void; } +export type McpCredentialAuditEvent = + | { + readonly type: "issued" | "rotated"; + readonly credentialId: string; + readonly threadId: ThreadId; + readonly providerInstanceId: ProviderInstanceId; + readonly providerSessionId: string; + readonly capabilities: ReadonlyArray; + readonly audience: string; + readonly issuedAt: number; + } + | { + readonly type: "resolved" | "audience_denied"; + readonly credentialId: string; + readonly providerSessionId: string; + readonly audience: string; + readonly occurredAt: number; + } + | { + readonly type: "revoked"; + readonly credentialIds: ReadonlyArray; + readonly reason: "provider_session" | "thread" | "rotation" | "all"; + readonly occurredAt: number; + }; + const bytesToHex = (bytes: Uint8Array): string => Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join(""); @@ -73,6 +111,18 @@ const makeWithOptions = Effect.fn("McpSessionRegistry.make")(function* ( const httpServer = yield* HttpServer.HttpServer; const state = yield* SynchronizedRef.make({ records: new Map() }); const currentTimeMillis = options.now ? Effect.sync(options.now) : Clock.currentTimeMillis; + const audit = (event: McpCredentialAuditEvent) => + Effect.sync(() => { + options.audit?.(event); + }).pipe( + Effect.catchCause((cause) => + Effect.logWarning("mcp.credential-audit-hook-failed", { + eventType: event.type, + cause, + }), + ), + ); + const audience = `urn:t3-code:mcp:${environmentId}`; const endpoint = httpServer.address._tag === "TcpAddress" ? `http://${getHttpMcpEndpointHost(httpServer.address.hostname)}:${httpServer.address.port}/mcp` @@ -83,64 +133,149 @@ const makeWithOptions = Effect.fn("McpSessionRegistry.make")(function* ( .digest("SHA-256", new TextEncoder().encode(token)) .pipe(Effect.map(bytesToHex), Effect.orDie); - const issue: McpSessionRegistryShape["issue"] = Effect.fn("McpSessionRegistry.issue")( - function* (request) { - const issuedAt = yield* currentTimeMillis; - const providerSessionId = yield* crypto.randomUUIDv4.pipe(Effect.orDie); - const rawToken = yield* crypto.randomBytes(32).pipe(Effect.map(tokenFromBytes), Effect.orDie); - const tokenHash = yield* hashToken(rawToken); - const scope: McpInvocationContext.McpInvocationScope = { + const mint = Effect.fn("McpSessionRegistry.mint")(function* ( + request: McpCredentialRequest, + rotation: boolean, + ) { + const issuedAt = yield* currentTimeMillis; + const providerSessionId = yield* crypto.randomUUIDv4.pipe(Effect.orDie); + const credentialId = yield* crypto.randomUUIDv4.pipe(Effect.orDie); + const rawToken = yield* crypto.randomBytes(32).pipe(Effect.map(tokenFromBytes), Effect.orDie); + const tokenHash = yield* hashToken(rawToken); + const scope: McpInvocationContext.McpInvocationScope = { + credentialId, + environmentId, + threadId: ThreadId.make(request.threadId), + providerSessionId, + providerInstanceId: ProviderInstanceId.make(request.providerInstanceId), + capabilities: new Set( + McpInvocationContext.ALL_MCP_CAPABILITIES.filter((capability) => + request.capabilities.has(capability), + ), + ), + audience, + issuedAt, + }; + const revokedCredentialIds = yield* SynchronizedRef.modify(state, ({ records }) => { + const next = new Map(records); + const revoked = rotation + ? Array.from(next) + .filter(([, record]) => record.scope.threadId === request.threadId) + .map(([hash, record]) => { + next.delete(hash); + return record.scope.credentialId; + }) + : []; + next.set(tokenHash, { scope }); + return [revoked, { records: next }] as const; + }); + if (revokedCredentialIds.length > 0) { + yield* audit({ + type: "revoked", + credentialIds: revokedCredentialIds, + reason: "rotation", + occurredAt: issuedAt, + }); + } + const capabilities = [...scope.capabilities].toSorted(); + yield* audit({ + type: rotation ? "rotated" : "issued", + credentialId, + threadId: scope.threadId, + providerInstanceId: scope.providerInstanceId, + providerSessionId, + capabilities, + audience, + issuedAt, + }); + return { + config: { + credentialId, environmentId, - threadId: ThreadId.make(request.threadId), + threadId: scope.threadId, providerSessionId, - providerInstanceId: ProviderInstanceId.make(request.providerInstanceId), - capabilities: new Set(McpInvocationContext.ALL_MCP_CAPABILITIES), + providerInstanceId: scope.providerInstanceId, + endpoint, + authorizationHeader: `Bearer ${rawToken}`, + audience, + capabilities, issuedAt, - }; - yield* SynchronizedRef.update(state, ({ records }) => { - const next = new Map(records); - next.set(tokenHash, { scope }); - return { records: next }; - }); - return { - config: { - environmentId, - threadId: scope.threadId, - providerSessionId, - providerInstanceId: scope.providerInstanceId, - endpoint, - authorizationHeader: `Bearer ${rawToken}`, - }, - }; - }, - ); + }, + }; + }); + + const issue: McpSessionRegistryShape["issue"] = (request) => mint(request, false); + const rotate: McpSessionRegistryShape["rotate"] = (request) => mint(request, true); const resolve: McpSessionRegistryShape["resolve"] = Effect.fn("McpSessionRegistry.resolve")( - function* (rawToken) { + function* (rawToken, requestedAudience) { if (rawToken.length === 0) return undefined; const tokenHash = yield* hashToken(rawToken); - const record = (yield* SynchronizedRef.get(state)).records.get(tokenHash); - return record?.scope; + const now = yield* currentTimeMillis; + const outcome = yield* SynchronizedRef.modify( + state, + ({ records }): readonly [ResolveOutcome, RegistryState] => { + const record = records.get(tokenHash); + if (record === undefined) { + return [{ type: "missing" }, { records }]; + } + if (record.scope.audience !== requestedAudience) { + return [{ type: "audience_denied", scope: record.scope }, { records }]; + } + return [{ type: "resolved", scope: record.scope }, { records }]; + }, + ); + if (outcome.type === "missing") return undefined; + yield* audit({ + type: outcome.type, + credentialId: outcome.scope.credentialId, + providerSessionId: outcome.scope.providerSessionId, + audience: requestedAudience, + occurredAt: now, + }); + return outcome.type === "resolved" ? outcome.scope : undefined; }, ); - const revokeWhere = (predicate: (record: CredentialRecord) => boolean) => - SynchronizedRef.update(state, ({ records }) => ({ - records: new Map(Array.from(records).filter(([, record]) => !predicate(record))), - })); + const revokeWhere = ( + predicate: (record: CredentialRecord) => boolean, + reason: Extract["reason"], + ) => + Effect.gen(function* () { + const now = yield* currentTimeMillis; + const revoked = yield* SynchronizedRef.modify(state, ({ records }) => { + const credentialIds: Array = []; + const next = new Map( + Array.from(records).filter(([, record]) => { + if (!predicate(record)) return true; + credentialIds.push(record.scope.credentialId); + return false; + }), + ); + return [credentialIds, { records: next }] as const; + }); + if (revoked.length > 0) { + yield* audit({ type: "revoked", credentialIds: revoked, reason, occurredAt: now }); + } + }); return McpSessionRegistry.of({ + audience, issue, + rotate, resolve, revokeProviderSession: Effect.fn("McpSessionRegistry.revokeProviderSession")( function* (providerSessionId) { - yield* revokeWhere((record) => record.scope.providerSessionId === providerSessionId); + yield* revokeWhere( + (record) => record.scope.providerSessionId === providerSessionId, + "provider_session", + ); }, ), revokeThread: Effect.fn("McpSessionRegistry.revokeThread")(function* (threadId) { - yield* revokeWhere((record) => record.scope.threadId === threadId); + yield* revokeWhere((record) => record.scope.threadId === threadId, "thread"); }), - revokeAll: SynchronizedRef.set(state, { records: new Map() }), + revokeAll: revokeWhere(() => true, "all"), }); }); @@ -168,9 +303,7 @@ export const issueActiveMcpCredential = ( request: McpCredentialRequest, ): Effect.Effect => activeMcpSessionRegistry - ? activeMcpSessionRegistry - .revokeThread(request.threadId) - .pipe(Effect.andThen(activeMcpSessionRegistry.issue(request))) + ? activeMcpSessionRegistry.rotate(request) : Effect.sync((): McpIssuedCredential | undefined => undefined); export const revokeActiveMcpThread = (threadId: ThreadId): Effect.Effect => diff --git a/apps/server/src/mcp/OrchestratorMcpToolkit.integration.test.ts b/apps/server/src/mcp/OrchestratorMcpToolkit.integration.test.ts index 78b4d86edb0..c4382d80018 100644 --- a/apps/server/src/mcp/OrchestratorMcpToolkit.integration.test.ts +++ b/apps/server/src/mcp/OrchestratorMcpToolkit.integration.test.ts @@ -570,11 +570,13 @@ describe("orchestrator MCP toolkit", () => { expect(parentRun?.status).toBe("running"); const invocation: McpInvocationContext.McpInvocationScope = { + credentialId: "credential-orchestrator-test", environmentId: EnvironmentId.make("environment:mcp-orchestrator"), threadId: parentThreadId, providerSessionId: "mcp-provider-session-parent", providerInstanceId: codexInstanceId, capabilities: new Set(["orchestration"]), + audience: "urn:t3-code:mcp:environment-orchestrator", issuedAt: 1, }; const invoke = (name: string, args: Record) => diff --git a/apps/server/src/mcp/PreviewAutomationBroker.test.ts b/apps/server/src/mcp/PreviewAutomationBroker.test.ts index 3bc0fd71308..0bb71a0f554 100644 --- a/apps/server/src/mcp/PreviewAutomationBroker.test.ts +++ b/apps/server/src/mcp/PreviewAutomationBroker.test.ts @@ -25,11 +25,13 @@ import * as PreviewAutomationBroker from "./PreviewAutomationBroker.ts"; const makeBroker = PreviewAutomationBroker.make.pipe(Effect.provide(NodeServices.layer)); const scope = { + credentialId: "credential-preview-test", environmentId: EnvironmentId.make("environment-1"), threadId: ThreadId.make("thread-1"), providerSessionId: "provider-session-1", providerInstanceId: ProviderInstanceId.make("codex"), capabilities: new Set(["preview"] as const), + audience: "urn:t3-code:mcp:environment-1", issuedAt: 1, }; diff --git a/apps/server/src/mcp/WorktreeMcpService.test.ts b/apps/server/src/mcp/WorktreeMcpService.test.ts index 1917d9f507a..ace42677fa4 100644 --- a/apps/server/src/mcp/WorktreeMcpService.test.ts +++ b/apps/server/src/mcp/WorktreeMcpService.test.ts @@ -45,11 +45,13 @@ const workspaceRoot = "/repo/project"; const makeScope = ( capabilities: ReadonlySet, ): McpInvocationContext.McpInvocationScope => ({ + credentialId: "credential-worktree-test", environmentId, threadId, providerSessionId: "provider-session-worktree-test", providerInstanceId: ProviderInstanceId.make("claudeAgent"), capabilities, + audience: "urn:t3-code:mcp:environment-worktree-test", issuedAt: 1, }); diff --git a/apps/server/src/mcp/toolkits/preview/handlers.ts b/apps/server/src/mcp/toolkits/preview/handlers.ts index 1c7ff6f9cd9..4e29e44ada1 100644 --- a/apps/server/src/mcp/toolkits/preview/handlers.ts +++ b/apps/server/src/mcp/toolkits/preview/handlers.ts @@ -1,4 +1,11 @@ +// @effect-diagnostics nodeBuiltinImport:off +import * as NodeCrypto from "node:crypto"; + +import * as Clock from "effect/Clock"; import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; import type { PreviewAutomationOperation, PreviewAutomationOpenInput, @@ -10,7 +17,11 @@ import type { PreviewAutomationStatus, PreviewTabId, } from "@t3tools/contracts"; +import { PreviewAutomationScreenshotSaveError } from "@t3tools/contracts"; +import * as ServerConfig from "../../../config.ts"; +import * as ProjectionSnapshotQuery from "../../../orchestration/Services/ProjectionSnapshotQuery.ts"; +import * as WorkspacePaths from "../../../workspace/WorkspacePaths.ts"; import * as McpInvocationContext from "../../McpInvocationContext.ts"; import * as PreviewAutomationBroker from "../../PreviewAutomationBroker.ts"; import { PreviewSnapshotToolkit, PreviewStandardToolkit, PreviewToolkit } from "./tools.ts"; @@ -60,6 +71,162 @@ const invokeTargeted = ( return invoke(operation, operationInput, timeoutMs, tabId); }; +const writeScreenshotFile = (input: { + readonly absolutePath: string; + readonly screenshotBase64: string; +}) => + Effect.scoped( + Effect.gen(function* () { + const path = yield* Path.Path; + const fileSystem = yield* FileSystem.FileSystem; + const targetDirectory = path.dirname(input.absolutePath); + yield* fileSystem.makeDirectory(targetDirectory, { recursive: true }); + const tempDirectory = yield* fileSystem.makeTempDirectoryScoped({ + directory: targetDirectory, + prefix: `${path.basename(input.absolutePath)}.`, + }); + const tempPath = path.join(tempDirectory, "screenshot.tmp"); + yield* fileSystem.writeFile( + tempPath, + new Uint8Array(Buffer.from(input.screenshotBase64, "base64")), + ); + // Rename replaces an existing symlink itself instead of following it, + // closing the check/write race around workspace save destinations. + yield* fileSystem.rename(tempPath, input.absolutePath); + }), + ); + +const saveSnapshotScreenshotArtifact = Effect.fn("PreviewToolkit.saveSnapshotScreenshotArtifact")( + function* (input: { + readonly scope: McpInvocationContext.McpInvocationScope; + readonly screenshotBase64: string; + }) { + const config = yield* ServerConfig.ServerConfig; + const fileName = `browser-screenshot-${(yield* Clock.currentTimeMillis).toString(36)}-${NodeCrypto.randomUUID().slice(0, 8)}.png`; + const path = yield* Path.Path; + const absolutePath = path.join(config.browserArtifactsDir, fileName); + yield* writeScreenshotFile({ absolutePath, screenshotBase64: input.screenshotBase64 }).pipe( + Effect.mapError( + (cause) => + new PreviewAutomationScreenshotSaveError({ + operation: "snapshot", + environmentId: input.scope.environmentId, + threadId: input.scope.threadId, + providerSessionId: input.scope.providerSessionId, + providerInstanceId: input.scope.providerInstanceId, + savePath: fileName, + reason: "failed to write the screenshot artifact", + cause, + }), + ), + ); + return absolutePath; + }, +); + +const saveSnapshotScreenshot = Effect.fn("PreviewToolkit.saveSnapshotScreenshot")( + function* (input: { + readonly scope: McpInvocationContext.McpInvocationScope; + readonly savePath: string; + readonly screenshotBase64: string; + }) { + const { savePath, scope } = input; + const fail = (reason: string, cause?: unknown) => + new PreviewAutomationScreenshotSaveError({ + operation: "snapshot", + environmentId: scope.environmentId, + threadId: scope.threadId, + providerSessionId: scope.providerSessionId, + providerInstanceId: scope.providerInstanceId, + savePath, + reason, + ...(cause === undefined ? {} : { cause }), + }); + if (!savePath.toLowerCase().endsWith(".png")) { + return yield* fail("savePath must end with .png"); + } + + const projectionSnapshotQuery = yield* ProjectionSnapshotQuery.ProjectionSnapshotQuery; + const threadContext = yield* projectionSnapshotQuery + .getThreadCheckpointContext(scope.threadId) + .pipe(Effect.mapError((cause) => fail("failed to resolve the thread workspace", cause))); + if (Option.isNone(threadContext)) { + return yield* fail("thread was not found"); + } + const workspaceRoot = threadContext.value.worktreePath ?? threadContext.value.workspaceRoot; + + const workspacePaths = yield* WorkspacePaths.WorkspacePaths; + const resolved = yield* workspacePaths + .resolveRelativePathWithinRoot({ workspaceRoot, relativePath: savePath }) + .pipe( + Effect.mapError((cause) => + fail("savePath must be a relative path inside the workspace", cause), + ), + ); + + const path = yield* Path.Path; + const fileSystem = yield* FileSystem.FileSystem; + const realPathOrNull = (target: string) => + fileSystem.realPath(target).pipe( + Effect.map((canonical): string | null => canonical), + Effect.catchTags({ + PlatformError: (error) => + error.reason._tag === "NotFound" + ? Effect.succeed(null) + : Effect.fail(fail("failed to resolve the save path", error)), + }), + ); + const isOutsideRoot = (canonicalRoot: string, canonical: string) => { + const relative = path.relative(canonicalRoot, canonical); + return relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative); + }; + + const canonicalRoot = yield* fileSystem + .realPath(workspaceRoot) + .pipe(Effect.mapError((cause) => fail("failed to resolve the workspace root", cause))); + + const lexicalParent = path.dirname(resolved.absolutePath); + let existingAncestor = lexicalParent; + let canonicalAncestor = yield* realPathOrNull(existingAncestor); + while (canonicalAncestor === null) { + const parent = path.dirname(existingAncestor); + if (parent === existingAncestor) { + return yield* fail("failed to resolve the save directory"); + } + existingAncestor = parent; + canonicalAncestor = yield* realPathOrNull(existingAncestor); + } + if (isOutsideRoot(canonicalRoot, canonicalAncestor)) { + return yield* fail("savePath must stay inside the workspace root"); + } + + yield* fileSystem + .makeDirectory(lexicalParent, { recursive: true }) + .pipe(Effect.mapError((cause) => fail("failed to create the save directory", cause))); + const canonicalParent = yield* fileSystem + .realPath(lexicalParent) + .pipe(Effect.mapError((cause) => fail("failed to resolve the save directory", cause))); + if (isOutsideRoot(canonicalRoot, canonicalParent)) { + return yield* fail("savePath must stay inside the workspace root"); + } + + const destination = path.join(canonicalParent, path.basename(resolved.absolutePath)); + const destinationIsSymlink = yield* fileSystem.readLink(destination).pipe( + Effect.as(true), + Effect.orElseSucceed(() => false), + ); + if (destinationIsSymlink) { + return yield* fail("savePath must not be an existing symlink"); + } + + yield* writeScreenshotFile({ + absolutePath: destination, + screenshotBase64: input.screenshotBase64, + }).pipe(Effect.mapError((cause) => fail("failed to write the screenshot file", cause))); + return resolved.relativePath; + }, +); + const handlers = { preview_status: (input) => invokeTargeted("status", input ?? {}), preview_open: (input) => @@ -70,7 +237,29 @@ const handlers = { invokeTargeted("resize", input, input.timeoutMs), preview_set_appearance: (input) => invokeTargeted("setColorScheme", input), - preview_snapshot: (input) => invokeTargeted("snapshot", input ?? {}), + preview_snapshot: (input) => + Effect.gen(function* () { + const { save, savePath, ...target } = input ?? {}; + const snapshot = yield* invokeTargeted("snapshot", target); + if (savePath !== undefined) { + const scope = yield* McpInvocationContext.McpInvocationContext; + const savedScreenshotPath = yield* saveSnapshotScreenshot({ + scope, + savePath, + screenshotBase64: snapshot.screenshot.data, + }); + return { ...snapshot, savedScreenshotPath }; + } + if (save === true) { + const scope = yield* McpInvocationContext.McpInvocationContext; + const savedScreenshotPath = yield* saveSnapshotScreenshotArtifact({ + scope, + screenshotBase64: snapshot.screenshot.data, + }); + return { ...snapshot, savedScreenshotPath }; + } + return snapshot; + }), preview_click: (input) => invokeTargeted("click", input, input.timeoutMs).pipe(Effect.as(null)), preview_type: (input) => diff --git a/apps/server/src/mcp/toolkits/preview/tools.ts b/apps/server/src/mcp/toolkits/preview/tools.ts index a94d2b056f7..d8ca5cab4f0 100644 --- a/apps/server/src/mcp/toolkits/preview/tools.ts +++ b/apps/server/src/mcp/toolkits/preview/tools.ts @@ -13,14 +13,20 @@ import { PreviewAutomationSetColorSchemeInput, PreviewAutomationSetColorSchemeResult, PreviewAutomationSnapshot, + PreviewAutomationSnapshotInput, PreviewAutomationStatus, PreviewAutomationTabTargetInput, PreviewAutomationTypeInput, PreviewAutomationWaitForInput, } from "@t3tools/contracts"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; import * as Schema from "effect/Schema"; import { Tool, Toolkit } from "effect/unstable/ai"; +import * as ServerConfig from "../../../config.ts"; +import * as ProjectionSnapshotQuery from "../../../orchestration/Services/ProjectionSnapshotQuery.ts"; +import * as WorkspacePaths from "../../../workspace/WorkspacePaths.ts"; import * as McpInvocationContext from "../../McpInvocationContext.ts"; import * as PreviewAutomationBroker from "../../PreviewAutomationBroker.ts"; @@ -29,6 +35,15 @@ const dependencies = [ PreviewAutomationBroker.PreviewAutomationBroker, ]; +const snapshotDependencies = [ + ...dependencies, + ServerConfig.ServerConfig, + ProjectionSnapshotQuery.ProjectionSnapshotQuery, + WorkspacePaths.WorkspacePaths, + FileSystem.FileSystem, + Path.Path, +]; + const browserTool = (tool: T): T => tool.annotate(Tool.OpenWorld, true).annotate(Tool.Destructive, true) as T; @@ -104,11 +119,11 @@ export const PreviewSetAppearanceTool = safeBrowserTool( export const PreviewSnapshotTool = readonlyBrowserTool( Tool.make("preview_snapshot", { description: - "Inspect a page before interacting. Pass tabId to inspect a specific tab; omit it to use this agent session's current tab. Returns page state, semantic elements, diagnostics, action history, and a PNG screenshot.", - parameters: PreviewAutomationTabTargetInput, + "Inspect a page before interacting. Pass tabId to inspect a specific tab; omit it to use this agent session's current tab. Returns page state, semantic elements, diagnostics, action history, and a PNG screenshot. To keep visual evidence for the human, pass save:true; the result then includes savedScreenshotPath, which can be embedded with markdown image syntax. Use savePath only when the screenshot should live inside the repo.", + parameters: PreviewAutomationSnapshotInput, success: PreviewAutomationSnapshot, failure: PreviewAutomationError, - dependencies, + dependencies: snapshotDependencies, }).annotate(Tool.Title, "Inspect browser page"), ); @@ -192,7 +207,7 @@ export const PreviewRecordingStartTool = safeBrowserTool( export const PreviewRecordingStopTool = safeBrowserTool( Tool.make("preview_recording_stop", { description: - "Stop recording the collaborative browser tab selected by tabId, or this agent session's current tab when omitted, and save it as a local evidence artifact.", + "Stop recording the collaborative browser tab selected by tabId, or this agent session's current tab when omitted, and save it as a local evidence artifact (.webm). To show it to the human, embed the returned path with markdown image syntax; chat renders supported recordings as playable video. If the desktop app and server run on different machines, copy the file into the workspace and embed its workspace-relative path instead.", parameters: PreviewAutomationTabTargetInput, success: PreviewAutomationRecordingArtifact, failure: PreviewAutomationError, diff --git a/apps/server/src/mcp/toolkits/worktree/registration.test.ts b/apps/server/src/mcp/toolkits/worktree/registration.test.ts index 773f4edc323..22b11d2910f 100644 --- a/apps/server/src/mcp/toolkits/worktree/registration.test.ts +++ b/apps/server/src/mcp/toolkits/worktree/registration.test.ts @@ -7,19 +7,43 @@ import * as Layer from "effect/Layer"; import * as Schema from "effect/Schema"; import { HttpBody, HttpClient, HttpRouter } from "effect/unstable/http"; +import * as ServerConfig from "../../../config.ts"; import * as ServerEnvironment from "../../../environment/ServerEnvironment.ts"; import * as GitWorkflowService from "../../../git/GitWorkflowService.ts"; import { ThreadManagementService } from "../../../orchestration-v2/ThreadManagementService.ts"; +import * as ProjectionSnapshotQuery from "../../../orchestration/Services/ProjectionSnapshotQuery.ts"; import * as ProjectService from "../../../project/ProjectService.ts"; import * as ProjectSetupScriptRunner from "../../../project/ProjectSetupScriptRunner.ts"; import { ProviderRegistry } from "../../../provider/Services/ProviderRegistry.ts"; import { ScheduledTaskService } from "../../../scheduledTasks/ScheduledTaskService.ts"; import * as ServerSettings from "../../../serverSettings.ts"; import { VcsStatusBroadcaster } from "../../../vcs/VcsStatusBroadcaster.ts"; +import * as WorkspacePaths from "../../../workspace/WorkspacePaths.ts"; import * as McpHttpServer from "../../McpHttpServer.ts"; import * as McpSessionRegistry from "../../McpSessionRegistry.ts"; import * as PreviewAutomationBroker from "../../PreviewAutomationBroker.ts"; +const ToolsListPayload = Schema.fromJsonString( + Schema.Struct({ + result: Schema.Struct({ + tools: Schema.Array( + Schema.Struct({ + name: Schema.String, + inputSchema: Schema.Struct({ type: Schema.optional(Schema.String) }), + annotations: Schema.optional( + Schema.Struct({ + readOnlyHint: Schema.optional(Schema.Boolean), + destructiveHint: Schema.optional(Schema.Boolean), + openWorldHint: Schema.optional(Schema.Boolean), + }), + ), + }), + ), + }), + }), +); +const decodeToolsListPayload = Schema.decodeUnknownEffect(ToolsListPayload); + const StubServicesLive = Layer.mergeAll( Layer.mock(ThreadManagementService)({}), Layer.mock(ProviderRegistry)({}), @@ -29,6 +53,9 @@ const StubServicesLive = Layer.mergeAll( Layer.mock(GitWorkflowService.GitWorkflowService)({}), Layer.mock(ProjectSetupScriptRunner.ProjectSetupScriptRunner)({}), Layer.mock(VcsStatusBroadcaster)({}), + Layer.mock(ProjectionSnapshotQuery.ProjectionSnapshotQuery)({}), + WorkspacePaths.layer, + ServerConfig.layerTest(process.cwd(), { prefix: "mcp-registration-test-" }), ); it.effect("production mcp layer lists worktree tools over http", () => @@ -52,6 +79,7 @@ it.effect("production mcp layer lists worktree tools over http", () => const registry = McpSessionRegistry.issueActiveMcpCredential({ threadId: ThreadId.make("thread-scratch"), providerInstanceId: ProviderInstanceId.make("claudeAgent"), + capabilities: new Set(["worktree"]), }); const credential = yield* registry; expect(credential).toBeDefined(); @@ -83,28 +111,7 @@ it.effect("production mcp layer lists worktree tools over http", () => ), }); const bodyText = yield* listResponse.text; - const ToolsListPayload = Schema.fromJsonString( - Schema.Struct({ - result: Schema.Struct({ - tools: Schema.Array( - Schema.Struct({ - name: Schema.String, - inputSchema: Schema.Struct({ type: Schema.optional(Schema.String) }), - annotations: Schema.optional( - Schema.Struct({ - readOnlyHint: Schema.optional(Schema.Boolean), - destructiveHint: Schema.optional(Schema.Boolean), - openWorldHint: Schema.optional(Schema.Boolean), - }), - ), - }), - ), - }), - }), - ); - const payload = yield* Schema.decodeUnknownEffect(ToolsListPayload)( - bodyText.match(/\{.*\}/s)![0], - ); + const payload = yield* decodeToolsListPayload(bodyText.match(/\{.*\}/s)![0]); const tools = payload.result.tools; const toolNames = tools.map((tool) => tool.name); expect(toolNames).toContain("t3_worktree_handoff"); diff --git a/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.ts index aa3f8bb3364..5fe86e971fa 100644 --- a/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.ts @@ -474,7 +474,14 @@ function negotiatedCapabilities( const hasModelConfig = setup.configOptions?.some((option) => option.category === "model") === true; const hasModeConfig = setup.configOptions?.some((option) => option.category === "mode") === true; - const supportsMcp = agent.mcpCapabilities?.http === true || agent.mcpCapabilities?.sse === true; + // Some provider flavors implement ACP session MCP registration before they + // advertise the optional initialize capability. A flavor may explicitly + // opt in through its base capabilities; all other ACP providers remain + // gated by the negotiated inventory. + const supportsMcp = + base.tools.supportsMcpTools || + agent.mcpCapabilities?.http === true || + agent.mcpCapabilities?.sse === true; const canLoad = agent.loadSession === true; const canFork = session?.fork != null; return { diff --git a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.testkit.ts b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.testkit.ts index 0b55234cb3a..0b814947028 100644 --- a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.testkit.ts +++ b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.testkit.ts @@ -73,6 +73,7 @@ export function makeReplayServerConfig( const providerLogsDir = path.join(logsDir, "provider"); const terminalLogsDir = path.join(logsDir, "terminals"); const attachmentsDir = path.join(stateDir, "attachments"); + const browserArtifactsDir = path.join(stateDir, "browser-artifacts"); const worktreesDir = path.join(baseDir, "worktrees"); const providerStatusCacheDir = path.join(baseDir, "caches"); @@ -82,6 +83,7 @@ export function makeReplayServerConfig( providerLogsDir, terminalLogsDir, attachmentsDir, + browserArtifactsDir, worktreesDir, providerStatusCacheDir, ]) { @@ -121,6 +123,7 @@ export function makeReplayServerConfig( providerStatusCacheDir, worktreesDir, attachmentsDir, + browserArtifactsDir, logsDir, serverLogPath: path.join(logsDir, "server.log"), serverTracePath: path.join(logsDir, "server.trace.ndjson"), @@ -186,13 +189,17 @@ export function makeCodexProviderAdapterRegistryReplayLayer(input: { return registryLayer; } +const decodeCodexReplayTranscript = Schema.decodeUnknownEffect( + CodexReplay.CodexAppServerReplayTranscript, +); + export const CodexOrchestratorReplayHarness: OrchestratorV2ProviderReplayHarness< CodexReplay.CodexAppServerReplayTranscript, CodexOrchestratorReplayHarnessError > = { driver: CODEX_DRIVER_KIND, decodeTranscript: (transcript) => - Schema.decodeUnknownEffect(CodexReplay.CodexAppServerReplayTranscript)(transcript).pipe( + decodeCodexReplayTranscript(transcript).pipe( Effect.mapError( (cause) => new CodexReplayTranscriptDecodeError({ diff --git a/apps/server/src/orchestration-v2/Adapters/CursorAdapterV2.testkit.ts b/apps/server/src/orchestration-v2/Adapters/CursorAdapterV2.testkit.ts index 2632dd97d97..03ea0bd6888 100644 --- a/apps/server/src/orchestration-v2/Adapters/CursorAdapterV2.testkit.ts +++ b/apps/server/src/orchestration-v2/Adapters/CursorAdapterV2.testkit.ts @@ -523,6 +523,7 @@ function makeReplayServerConfig( const providerLogsDir = path.join(logsDir, "provider"); const terminalLogsDir = path.join(logsDir, "terminals"); const attachmentsDir = path.join(stateDir, "attachments"); + const browserArtifactsDir = path.join(stateDir, "browser-artifacts"); const worktreesDir = path.join(baseDir, "worktrees"); const providerStatusCacheDir = path.join(baseDir, "caches"); for (const directory of [ @@ -531,6 +532,7 @@ function makeReplayServerConfig( providerLogsDir, terminalLogsDir, attachmentsDir, + browserArtifactsDir, worktreesDir, providerStatusCacheDir, ]) { @@ -569,6 +571,7 @@ function makeReplayServerConfig( providerStatusCacheDir, worktreesDir, attachmentsDir, + browserArtifactsDir, logsDir, serverLogPath: path.join(logsDir, "server.log"), serverTracePath: path.join(logsDir, "server.trace.ndjson"), diff --git a/apps/server/src/orchestration-v2/Adapters/HermesAcpAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/HermesAcpAdapterV2.test.ts new file mode 100644 index 00000000000..3a593237e5b --- /dev/null +++ b/apps/server/src/orchestration-v2/Adapters/HermesAcpAdapterV2.test.ts @@ -0,0 +1,109 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { ProviderInstanceId, ProviderSessionId, ThreadId } from "@t3tools/contracts"; +import { assert, describe, it } from "@effect/vitest"; +import * as Crypto from "effect/Crypto"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; +import { ChildProcessSpawner } from "effect/unstable/process"; + +import { ServerConfig } from "../../config.ts"; +import { layer as idAllocatorLayer, IdAllocatorV2 } from "../IdAllocator.ts"; +import { ProviderAdapterV2RuntimePolicy } from "../ProviderAdapter.ts"; +import { BUILT_IN_PROVIDER_ADAPTER_DRIVER_KINDS_V2 } from "../builtInProviderAdapterDrivers.ts"; +import { + HERMES_ACP_DRIVER_KIND, + HermesAcpAdapterV2Driver, + HermesAcpProviderCapabilitiesV2, + makeHermesAcpAdapterV2, +} from "./HermesAcpAdapterV2.ts"; + +const decodeSettings = Schema.decodeUnknownEffect(HermesAcpAdapterV2Driver.configSchema); +const testLayer = Layer.mergeAll( + NodeServices.layer, + idAllocatorLayer, + ServerConfig.layerTest(process.cwd(), { + prefix: "t3-hermes-acp-adapter-", + }).pipe(Layer.provide(NodeServices.layer)), +); + +const shellQuote = (value: string): string => `'${value.replaceAll("'", "'\\''")}'`; + +describe("HermesAcpAdapterV2", () => { + it("registers a session family distinct from the Hermes Work gateway", () => { + assert.isTrue(BUILT_IN_PROVIDER_ADAPTER_DRIVER_KINDS_V2.has(HERMES_ACP_DRIVER_KIND)); + assert.equal(HermesAcpAdapterV2Driver.driverKind, "hermesAcp"); + assert.isTrue(HermesAcpProviderCapabilitiesV2.tools.supportsMcpTools); + assert.deepEqual(HermesAcpAdapterV2Driver.defaultConfig(), { + enabled: true, + binaryPath: "hermes", + customModels: [], + }); + }); + + it.effect("opens a standard ACP child process and negotiates Hermes capabilities", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const mockAgentPath = yield* path.fromFileUrl( + new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), + ); + const directory = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-hermes-acp-command-", + }); + const hermesPath = path.join(directory, "hermes"); + yield* fileSystem.writeFileString( + hermesPath, + [ + "#!/bin/sh", + 'if [ "$1" != "acp" ]; then exit 9; fi', + `exec ${shellQuote(process.execPath)} ${shellQuote(mockAgentPath)}`, + "", + ].join("\n"), + ); + yield* fileSystem.chmod(hermesPath, 0o755); + const instanceId = ProviderInstanceId.make("hermes-code-test"); + const settings = yield* decodeSettings({ + binaryPath: hermesPath, + }); + const adapter = makeHermesAcpAdapterV2({ + instanceId, + settings, + environment: { + T3_ACP_SESSION_LIFECYCLE: "1", + }, + childProcessSpawner: yield* ChildProcessSpawner.ChildProcessSpawner, + crypto: yield* Crypto.Crypto, + fileSystem, + idAllocator: yield* IdAllocatorV2, + serverConfig: yield* ServerConfig, + }); + const threadId = ThreadId.make("thread-hermes-acp"); + const runtimePolicy = ProviderAdapterV2RuntimePolicy.make({ + runtimeMode: "full-access", + interactionMode: "default", + cwd: process.cwd(), + }); + const modelSelection = { instanceId, model: "default" } as const; + const runtime = yield* adapter.openSession({ + threadId, + providerSessionId: ProviderSessionId.make("provider-session-hermes-acp"), + modelSelection, + runtimePolicy, + }); + const providerThread = yield* runtime.ensureThread({ + threadId, + modelSelection, + runtimePolicy, + }); + + assert.equal(runtime.providerSession.driver, "hermesAcp"); + assert.equal(providerThread.nativeThreadRef?.nativeId, "mock-session-1"); + assert.isTrue(runtime.providerSession.capabilities.threads.canReadThreadSnapshot); + assert.isTrue(runtime.providerSession.capabilities.threads.canForkThread); + assert.isTrue(runtime.providerSession.capabilities.tools.supportsMcpTools); + }).pipe(Effect.provide(testLayer), Effect.scoped), + ); +}); diff --git a/apps/server/src/orchestration-v2/Adapters/HermesAcpAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/HermesAcpAdapterV2.ts new file mode 100644 index 00000000000..0c81173f147 --- /dev/null +++ b/apps/server/src/orchestration-v2/Adapters/HermesAcpAdapterV2.ts @@ -0,0 +1,155 @@ +import { + defaultInstanceIdForDriver, + HermesAcpSettings, + type OrchestrationV2ProviderCapabilities, + ProviderDriverKind, +} from "@t3tools/contracts"; +import { HostProcessEnvironment } from "@t3tools/shared/hostProcess"; +import * as Crypto from "effect/Crypto"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Schema from "effect/Schema"; +import type * as Scope from "effect/Scope"; +import { ChildProcessSpawner } from "effect/unstable/process"; +import type * as EffectAcpErrors from "effect-acp/errors"; + +import { ServerConfig } from "../../config.ts"; +import { makeHermesAcpRuntime } from "../../provider/acp/HermesAcpSupport.ts"; +import { makeAcpNativeLoggerFactory } from "../../provider/acp/AcpNativeLogging.ts"; +import * as AcpSessionRuntime from "../../provider/acp/AcpSessionRuntime.ts"; +import { ProviderEventLoggers } from "../../provider/Layers/ProviderEventLoggers.ts"; +import { mergeProviderInstanceEnvironment } from "../../provider/ProviderInstanceEnvironment.ts"; +import { IdAllocatorV2 } from "../IdAllocator.ts"; +import { + ProviderAdapterDriverCreateError, + type ProviderAdapterDriver, + type ProviderAdapterDriverCreateInput, +} from "../ProviderAdapterDriver.ts"; +import { + AcpProviderCapabilitiesV2, + makeAcpAdapterV2, + type AcpAdapterV2RuntimeInput, +} from "./AcpAdapterV2.ts"; + +export const HERMES_ACP_PROVIDER = ProviderDriverKind.make("hermesAcp"); +export const HERMES_ACP_DRIVER_KIND = HERMES_ACP_PROVIDER; +export const HERMES_ACP_DEFAULT_INSTANCE_ID = defaultInstanceIdForDriver(HERMES_ACP_DRIVER_KIND); + +const DEFAULT_HERMES_ACP_SETTINGS = Schema.decodeSync(HermesAcpSettings)({}); + +/** + * Hermes 0.19 accepts HTTP MCP servers (including headers) in session/new, + * session/load, session/resume, and session/fork, but its initialize response + * omits ACP mcpCapabilities. Keep this compatibility assertion isolated to + * Hermes instead of weakening negotiation for every ACP provider. + */ +export const HermesAcpProviderCapabilitiesV2 = { + ...AcpProviderCapabilitiesV2, + tools: { + ...AcpProviderCapabilitiesV2.tools, + supportsMcpTools: true, + }, +} satisfies OrchestrationV2ProviderCapabilities; + +export interface HermesAcpAdapterV2Options { + readonly instanceId: Parameters[0]["instanceId"]; + readonly settings: HermesAcpSettings; + readonly environment: NodeJS.ProcessEnv; + readonly childProcessSpawner: ChildProcessSpawner.ChildProcessSpawner["Service"]; + readonly crypto: Crypto.Crypto; + readonly fileSystem: FileSystem.FileSystem; + readonly idAllocator: IdAllocatorV2["Service"]; + readonly serverConfig: ServerConfig["Service"]; + readonly nativeLogging?: Parameters[0]["nativeLogging"]; + readonly makeRuntime?: ( + input: AcpAdapterV2RuntimeInput, + ) => Effect.Effect< + AcpSessionRuntime.AcpSessionRuntime["Service"], + EffectAcpErrors.AcpError, + Crypto.Crypto | Scope.Scope + >; + readonly assertComplete?: Effect.Effect; +} + +export function makeHermesAcpAdapterV2(options: HermesAcpAdapterV2Options) { + return makeAcpAdapterV2({ + instanceId: options.instanceId, + flavor: { + driver: HERMES_ACP_PROVIDER, + capabilities: HermesAcpProviderCapabilitiesV2, + supportsImagePrompts: true, + makeRuntime: + options.makeRuntime ?? + ((input) => + makeHermesAcpRuntime({ + ...input, + hermesSettings: options.settings, + environment: options.environment, + childProcessSpawner: options.childProcessSpawner, + })), + ...(options.assertComplete === undefined ? {} : { assertComplete: options.assertComplete }), + }, + crypto: options.crypto, + fileSystem: options.fileSystem, + idAllocator: options.idAllocator, + serverConfig: options.serverConfig, + ...(options.nativeLogging === undefined ? {} : { nativeLogging: options.nativeLogging }), + }); +} + +export type HermesAcpAdapterV2DriverEnv = + | ChildProcessSpawner.ChildProcessSpawner + | Crypto.Crypto + | FileSystem.FileSystem + | IdAllocatorV2 + | ProviderEventLoggers + | ServerConfig; + +export const HermesAcpAdapterV2Driver: ProviderAdapterDriver< + HermesAcpSettings, + HermesAcpAdapterV2DriverEnv +> = { + driverKind: HERMES_ACP_DRIVER_KIND, + configSchema: HermesAcpSettings, + defaultConfig: (): HermesAcpSettings => DEFAULT_HERMES_ACP_SETTINGS, + create: Effect.fn("HermesAcpAdapterV2Driver.create")( + function* (input: ProviderAdapterDriverCreateInput) { + const hostEnvironment = yield* HostProcessEnvironment; + const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const crypto = yield* Crypto.Crypto; + const fileSystem = yield* FileSystem.FileSystem; + const idAllocator = yield* IdAllocatorV2; + const providerEventLoggers = yield* ProviderEventLoggers; + const serverConfig = yield* ServerConfig; + const makeNativeLogger = yield* makeAcpNativeLoggerFactory(); + return makeHermesAcpAdapterV2({ + instanceId: input.instanceId, + settings: { ...input.config, enabled: input.enabled }, + environment: mergeProviderInstanceEnvironment(input.environment, hostEnvironment), + childProcessSpawner, + crypto, + fileSystem, + idAllocator, + serverConfig, + nativeLogging: (threadId) => + makeNativeLogger({ + nativeEventLogger: providerEventLoggers.native, + provider: HERMES_ACP_PROVIDER, + threadId, + }), + }); + }, + (effect, input) => + effect.pipe( + Effect.mapError( + (cause) => + new ProviderAdapterDriverCreateError({ + driver: HERMES_ACP_DRIVER_KIND, + instanceId: input.instanceId, + detail: "Failed to create Hermes in Code ACP adapter.", + cause, + }), + ), + ), + ), +}; diff --git a/apps/server/src/orchestration-v2/Adapters/HermesServeAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/HermesServeAdapterV2.test.ts new file mode 100644 index 00000000000..b1769a0bfe5 --- /dev/null +++ b/apps/server/src/orchestration-v2/Adapters/HermesServeAdapterV2.test.ts @@ -0,0 +1,2481 @@ +import { assert, describe, it } from "@effect/vitest"; +import { + ChatAttachmentId, + EnvironmentId, + MessageId, + NodeId, + ProjectId, + ProviderInstanceId, + ProviderSessionId, + ProviderThreadId, + RunAttemptId, + RunId, + ThreadId, + type HermesGatewayCompatibility, + type HermesGatewayInterruptResult, + type HermesGatewayMutationStatusResult, + type HermesGatewayPromptSubmitParams, + type HermesGatewayPromptSubmitResult, + type HermesGatewaySessionCreateParams, + type HermesGatewaySessionCreateResult, + type HermesGatewaySessionHistoryResult, + type HermesGatewaySessionMcpParams, + type HermesGatewaySessionResumeParams, + type HermesGatewaySessionResumeResult, + type HermesGatewaySessionStatusResult, + type OrchestrationV2AppThread, + type OrchestrationV2ProviderThread, + type OrchestrationV2TurnItem, +} from "@t3tools/contracts"; +import * as DateTime from "effect/DateTime"; +import * as Exit from "effect/Exit"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Stream from "effect/Stream"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +import { + HermesGatewayMutationIndeterminateError, + HermesGatewayRpcError, + type HermesGatewayMutationOptions, + type HermesGatewayOrderedEvent, +} from "../../hermes/HermesGatewayClient.ts"; +import { + HermesSessionBindingRepository, + layer as HermesSessionBindingRepositoryLayer, +} from "../../hermes/HermesSessionBindingRepository.ts"; +import * as McpProviderSession from "../../mcp/McpProviderSession.ts"; +import { SqlitePersistenceMemory } from "../../persistence/Layers/Sqlite.ts"; +import { IdAllocatorV2, layer as IdAllocatorV2Layer } from "../IdAllocator.ts"; +import type { ProviderAdapterV2TurnInput } from "../ProviderAdapter.ts"; +import { + diagnoseHermesMcpIntegration, + HermesImportedSessionUnavailableError, + HermesProviderCapabilitiesV2, + hermesWireMutationId, + makeHermesServeAdapterV2, + sanitizeHermesToolValue, + type HermesGatewayClientLike, +} from "./HermesServeAdapterV2.ts"; + +it("does not advertise Git checkpointing for DM-like Hermes sessions", () => { + assert.isFalse(HermesProviderCapabilitiesV2.checkpointing.appCanCheckpointFilesystem); + assert.isFalse(HermesProviderCapabilitiesV2.checkpointing.supportsNestedCheckpointScopes); +}); + +const compatibility: HermesGatewayCompatibility = { + status: "supported", + protocol: { + major: 1, + minor: 0, + capabilities: ["mutation.stable_ids"], + }, + capabilities: [ + "session.lifecycle", + "session.history", + "turn.prompt", + "turn.interrupt", + "attachments.image", + "attachments.file", + "attachments.pdf", + "mutation.stable_ids", + ], + inventory: ["mutation.stable_ids"], + reason: "test", +}; + +class FakeHermesGatewayClient implements HermesGatewayClientLike { + compatibility: HermesGatewayCompatibility | undefined = compatibility; + readonly creates: Array<{ + readonly params: HermesGatewaySessionCreateParams; + readonly options: Omit; + }> = []; + readonly resumes: Array<{ + readonly params: HermesGatewaySessionResumeParams & { readonly model?: string }; + readonly options: Omit; + }> = []; + readonly prompts: Array<{ + readonly params: HermesGatewayPromptSubmitParams; + readonly options: Omit; + }> = []; + readonly mcpReplacements: Array = []; + readonly mcpRevocations: Array = []; + readonly titleReads: Array = []; + readonly titleUpdates: Array = []; + mcpReplacementResult: { + readonly scopeSessionId?: string; + readonly serverName?: string; + readonly toolNames?: Array; + } = {}; + readonly imageAttachments: Array<{ + readonly params: { + readonly session_id: string; + readonly content_base64: string; + readonly filename?: string; + }; + readonly options: Omit; + }> = []; + readonly fileAttachments: Array<{ + readonly params: { + readonly session_id: string; + readonly name: string; + readonly data_url: string; + }; + readonly options: Omit; + }> = []; + readonly pdfAttachments: Array<{ + readonly params: { + readonly session_id: string; + readonly filename: string; + readonly content_base64: string; + }; + readonly options: Omit; + }> = []; + readonly interrupts: Array> = []; + readonly listeners = new Set<(event: HermesGatewayOrderedEvent) => void | Promise>(); + history: HermesGatewaySessionHistoryResult = { count: 0, messages: [] }; + statusOutput = "idle"; + resumeRunning = false; + resumeInflight: unknown = undefined; + resumeQueued: unknown = undefined; + resumeStatus = "idle"; + mutationStatus: HermesGatewayMutationStatusResult = { mutation_status: "completed" }; + readonly reconciliations: Array = []; + readonly reconciliationMutationIds: Array = []; + createError: Error | null = null; + resumeError: Error | null = null; + titleError: Error | null = null; + promptError: Error | null = null; + promptResult: HermesGatewayPromptSubmitResult | null = null; + closeCount = 0; + connectError: Error | null = null; + private transportSequence = 0; + + async connect(): Promise { + if (this.connectError) throw this.connectError; + return this.compatibility ?? compatibility; + } + + hasCapability(capability: string): boolean { + return (this.compatibility ?? compatibility).capabilities.includes(capability); + } + + onEvent(listener: (event: HermesGatewayOrderedEvent) => void | Promise): () => void { + this.listeners.add(listener); + return () => this.listeners.delete(listener); + } + + async createSession( + params: HermesGatewaySessionCreateParams, + options: Omit, + ): Promise { + this.creates.push({ params, options }); + if (this.createError) throw this.createError; + const createOrdinal = this.creates.length; + return { + session_id: `live-create-${createOrdinal}`, + stored_session_id: `stored-session-${createOrdinal}`, + message_count: 0, + messages: [], + info: {}, + }; + } + + async resumeSession( + params: HermesGatewaySessionResumeParams, + options: Omit, + ): Promise { + this.resumes.push({ params, options }); + if (this.resumeError) throw this.resumeError; + return { + session_id: "live-resume-1", + resumed: params.session_id, + message_count: this.history.count, + messages: this.history.messages, + info: {}, + ...(this.resumeInflight === undefined ? {} : { inflight: this.resumeInflight }), + ...(this.resumeQueued === undefined ? {} : { queued: this.resumeQueued }), + running: this.resumeRunning, + session_key: params.session_id, + started_at: 1, + status: this.resumeStatus, + }; + } + + async replaceSessionMcp(params: HermesGatewaySessionMcpParams) { + this.mcpReplacements.push(params); + return { + lease_id: `lease-${this.mcpReplacements.length}`, + generation: this.mcpReplacements.length, + servers: [ + { + name: this.mcpReplacementResult.serverName ?? "t3-code", + runtime_name: "tui_session_fixture_t3_code", + }, + ], + tool_names: this.mcpReplacementResult.toolNames ?? [ + "mcp__tui_session_fixture_t3_code__delegate_task", + ], + scope: { + session_id: this.mcpReplacementResult.scopeSessionId ?? params.session_id, + session_key: "stored-session-1", + }, + persisted: false as const, + history_recorded: false as const, + }; + } + + async revokeSessionMcp(sessionId: string) { + this.mcpRevocations.push(sessionId); + return { + revoked: true, + lease_id: "lease-1", + persisted: false as const, + }; + } + + async readSessionStatus(): Promise { + return { output: this.statusOutput }; + } + + async readSessionHistory(): Promise { + return this.history; + } + + async readSessionTitle(params: { readonly session_id: string }) { + this.titleReads.push(params.session_id); + return { + session_key: "stored-session-1", + title: "Hermes session", + revision: 1, + origin: "gateway", + }; + } + + async updateSessionTitle(params: { readonly title: string }) { + this.titleUpdates.push(params.title); + if (this.titleError) throw this.titleError; + return { + session_key: "stored-session-1", + title: params.title, + revision: 2, + origin: "t3", + }; + } + + async branchSession(params: { readonly session_id: string }) { + return { + session_id: "live-branch-1", + stored_session_id: "stored-branch-1", + title: "Hermes branch", + parent: params.session_id, + boundary: { + mode: "latest_only" as const, + exact: false as const, + message_id: "message-head", + message_count: this.history.count, + }, + }; + } + + async reconcileMutation( + operationId: string, + mutationId: string = operationId, + ): Promise { + this.reconciliations.push(operationId); + this.reconciliationMutationIds.push(mutationId); + return this.mutationStatus; + } + + async submitPrompt( + params: HermesGatewayPromptSubmitParams, + options: Omit, + ): Promise { + this.prompts.push({ params, options }); + if (this.promptError) throw this.promptError; + if (this.promptResult) return this.promptResult; + return { + status: "streaming", + run_id: "hermes-run-1", + user_message_id: "hermes-user-1", + assistant_message_id: "hermes-assistant-1", + mutation_id: options.mutationId, + }; + } + + async attachImageBytes( + params: { + readonly session_id: string; + readonly content_base64: string; + readonly filename?: string; + }, + options: Omit, + ): Promise { + this.imageAttachments.push({ params, options }); + return { attached: true }; + } + + async respondToApproval( + _params: { readonly session_id: string; readonly choice: "once" | "session" | "deny" }, + _options: Omit, + ) { + return { resolved: true }; + } + + async respondToClarification( + _params: { readonly request_id: string; readonly answer: string }, + _options: Omit, + ) { + return { status: "ok" as const }; + } + + async attachFile( + params: { readonly session_id: string; readonly name: string; readonly data_url: string }, + options: Omit, + ): Promise { + this.fileAttachments.push({ params, options }); + return this.fileAttachOmitsRef + ? { attached: true } + : { attached: true, ref_text: `@file:${params.name}` }; + } + + fileAttachOmitsRef = false; + + async attachPdf( + params: { + readonly session_id: string; + readonly filename: string; + readonly content_base64: string; + }, + options: Omit, + ): Promise { + this.pdfAttachments.push({ params, options }); + return { attached: true }; + } + + async interruptSession( + _params: { readonly session_id: string }, + options: Omit, + ): Promise { + this.interrupts.push(options); + queueMicrotask(() => void this.emit("message.complete", { text: "partial" })); + return { status: "interrupted" }; + } + + close(): void { + this.closeCount += 1; + } + + async emit( + type: string, + payload?: unknown, + overrides: Partial = {}, + ): Promise { + const event: HermesGatewayOrderedEvent = { + transportSequence: ++this.transportSequence, + sessionSequence: this.transportSequence, + sessionId: "live-create-1", + eventId: `event-${this.transportSequence}`, + eventSequence: this.transportSequence, + emittedAt: undefined, + sessionKey: "stored-session-1", + runId: "hermes-run-1", + messageId: "hermes-assistant-1", + cursor: this.transportSequence, + mutationId: undefined, + frame: { + jsonrpc: "2.0", + method: "event", + params: { + type, + session_id: "live-create-1", + payload, + event_id: `event-${this.transportSequence}`, + event_sequence: this.transportSequence, + session_key: "stored-session-1", + run_id: "hermes-run-1", + message_id: "hermes-assistant-1", + }, + }, + ...overrides, + }; + for (const listener of this.listeners) { + await listener(event); + } + } +} + +const TestLayer = Layer.mergeAll( + IdAllocatorV2Layer, + HermesSessionBindingRepositoryLayer.pipe(Layer.provideMerge(SqlitePersistenceMemory)), +); + +const instanceId = ProviderInstanceId.make("hermes_test"); +const threadId = ThreadId.make("thread:hermes-test"); +const projectId = ProjectId.make("project:hermes-test"); +const providerSessionId = ProviderSessionId.make("provider-session:hermes-test"); +const modelSelection = { instanceId, model: "default" } as const; +const runtimePolicy = { + runtimeMode: "full-access", + interactionMode: "default", + cwd: "/tmp/hermes-project", +} as const; + +function appThread(): OrchestrationV2AppThread { + const now = DateTime.nowUnsafe(); + return { + createdBy: "user", + creationSource: "web", + id: threadId, + projectId, + title: "Hermes test", + providerInstanceId: instanceId, + modelSelection, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + activeProviderThreadId: null, + lineage: { + parentThreadId: null, + relationshipToParent: null, + rootThreadId: threadId, + }, + forkedFrom: null, + createdAt: now, + updatedAt: now, + archivedAt: null, + settledOverride: null, + settledAt: null, + deletedAt: null, + }; +} + +function turnInput(providerThread: OrchestrationV2ProviderThread): ProviderAdapterV2TurnInput { + return { + appThread: appThread(), + threadId, + runId: RunId.make("run:hermes-test"), + runOrdinal: 1, + providerTurnOrdinal: 1, + attemptId: RunAttemptId.make("attempt:hermes-test"), + rootNodeId: NodeId.make("node:hermes-root"), + providerThread, + message: { + messageId: MessageId.make("message:hermes-user"), + text: "hello Hermes", + attachments: [], + createdBy: "user", + creationSource: "web", + }, + modelSelection, + runtimePolicy, + }; +} + +const makeRuntime = Effect.fnUntraced(function* ( + fake: FakeHermesGatewayClient, + enabled = true, + readAttachment?: (attachment: { readonly id: string }) => Effect.Effect, + mcpEnabled = false, + resolveHistoryMedia?: Parameters[0]["resolveHistoryMedia"], +) { + const idAllocator = yield* IdAllocatorV2; + const repository = yield* HermesSessionBindingRepository; + const adapter = makeHermesServeAdapterV2({ + instanceId, + settings: { + enabled: true, + endpoint: "ws://127.0.0.1:9119/api/ws", + remoteAccessEnabled: false, + profileKey: "real-profile", + managedServerEnabled: true, + customModels: [], + importEnabled: false, + mcpEnabled, + attachmentsEnabled: readAttachment !== undefined, + proactiveEnabled: false, + voiceEnabled: false, + }, + enabled, + authToken: "test-token", + idAllocator, + repository, + ...(readAttachment === undefined ? {} : { readAttachment }), + ...(resolveHistoryMedia === undefined ? {} : { resolveHistoryMedia }), + clientFactory: () => fake, + }); + return yield* adapter.openSession({ + threadId, + providerSessionId, + modelSelection, + runtimePolicy, + }); +}); + +describe("HermesServeAdapterV2", () => { + it("requires the upstream ephemeral session MCP lease capability", () => { + assert.deepEqual(diagnoseHermesMcpIntegration(compatibility), { + status: "blocked_upstream", + missingCapabilities: ["session_mcp"], + reason: + "Hermes MCP exposure is blocked: the negotiated gateway protocol does not advertise ephemeral per-session MCP leases.", + }); + + assert.deepEqual( + diagnoseHermesMcpIntegration({ + ...compatibility, + capabilities: [...compatibility.capabilities, "session_mcp"], + }), + { + status: "ready", + missingCapabilities: [], + reason: + "Hermes advertises ephemeral per-session MCP leases with replace and revoke support.", + }, + ); + }); + + it.effect("creates a durable binding and omits the default model override", () => + Effect.scoped( + Effect.gen(function* () { + const fake = new FakeHermesGatewayClient(); + const runtime = yield* makeRuntime(fake); + const providerThread = yield* runtime.ensureThread({ + threadId, + modelSelection, + runtimePolicy, + }); + const repository = yield* HermesSessionBindingRepository; + const binding = yield* repository.getByThreadId(String(threadId)); + + assert.isTrue(Option.isSome(binding)); + assert.equal( + Option.getOrNull(Option.map(binding, (value) => value.storedSessionKey)), + "stored-session-1", + ); + assert.equal(providerThread.nativeThreadRef?.strength, "strong"); + assert.notProperty(fake.creates[0]!.params, "model"); + assert.isTrue(fake.creates[0]!.params.persist_immediately); + assert.equal( + fake.creates[0]!.options.mutationId, + hermesWireMutationId(fake.creates[0]!.options.operationId), + ); + }), + ).pipe(Effect.provide(TestLayer)), + ); + + it.effect( + "resumes an imported binding against its stored profile with historical timestamps", + () => + Effect.scoped( + Effect.gen(function* () { + const fake = new FakeHermesGatewayClient(); + fake.history = { count: 1, messages: [{ role: "user", text: "imported hello" }] }; + const repository = yield* HermesSessionBindingRepository; + yield* repository.createBinding({ + bindingId: "hermes-binding:test-imported", + providerInstanceId: String(instanceId), + profileKey: "imported-profile", + projectId: String(projectId), + storedSessionKey: "stored-imported-1", + threadId: String(threadId), + protocolClassification: "supported", + protocolMajor: 1, + protocolMinor: 0, + capabilities: [], + reconciliationCursor: null, + reconciliationFingerprint: null, + now: "2020-01-01T00:00:00.000Z", + }); + const runtime = yield* makeRuntime(fake); + const providerThread = yield* runtime.ensureThread({ + threadId, + modelSelection, + runtimePolicy, + }); + const snapshot = yield* runtime.readThreadSnapshot({ providerThread }); + + assert.lengthOf(fake.creates, 0); + assert.equal(fake.resumes[0]!.params.session_id, "stored-imported-1"); + assert.equal(fake.resumes[0]!.params.profile, "imported-profile"); + const message = snapshot.messages.find((entry) => entry.text === "imported hello"); + assert.isDefined(message); + assert.equal( + DateTime.toEpochMillis(message!.createdAt), + Date.parse("2020-01-01T00:00:00.000Z"), + ); + }), + ).pipe(Effect.provide(TestLayer)), + ); + + it.effect("degrades a vanished imported session into a typed read-only resume error", () => + Effect.scoped( + Effect.gen(function* () { + const fake = new FakeHermesGatewayClient(); + fake.resumeError = new HermesGatewayRpcError(4007, "session.resume", "fatal"); + const repository = yield* HermesSessionBindingRepository; + yield* repository.createBinding({ + bindingId: "hermes-binding:test-vanished", + providerInstanceId: String(instanceId), + profileKey: "imported-profile", + projectId: String(projectId), + storedSessionKey: "stored-vanished-1", + threadId: String(threadId), + protocolClassification: "supported", + protocolMajor: 1, + protocolMinor: 0, + capabilities: [], + reconciliationCursor: null, + reconciliationFingerprint: null, + now: "2020-01-01T00:00:00.000Z", + }); + const runtime = yield* makeRuntime(fake); + + const error = yield* Effect.flip( + runtime.ensureThread({ + threadId, + modelSelection, + runtimePolicy, + }), + ); + + assert.equal(error._tag, "ProviderAdapterEnsureThreadError"); + assert.instanceOf(error.cause, HermesImportedSessionUnavailableError); + const unavailable = error.cause as HermesImportedSessionUnavailableError; + assert.include(unavailable.message, "4007"); + assert.include(unavailable.message, "read-only"); + assert.include(unavailable.message, "stored-vanished-1"); + }), + ).pipe(Effect.provide(TestLayer)), + ); + + it.effect("replaces the live Hermes session MCP lease with the scoped T3 endpoint", () => + Effect.scoped( + Effect.gen(function* () { + const fake = new FakeHermesGatewayClient(); + fake.compatibility = { + ...compatibility, + capabilities: [...compatibility.capabilities, "session_mcp"], + }; + McpProviderSession.setMcpProviderSession({ + credentialId: "credential-hermes-test", + environmentId: EnvironmentId.make("environment-hermes-test"), + threadId, + providerSessionId: "mcp-provider-session-hermes-test", + providerInstanceId: instanceId, + endpoint: "http://127.0.0.1:43123/mcp", + authorizationHeader: "Bearer scoped-hermes-token", + capabilities: ["orchestration"], + }); + yield* Effect.addFinalizer(() => + Effect.sync(() => McpProviderSession.clearMcpProviderSession(threadId)), + ); + const runtime = yield* makeRuntime(fake, true, undefined, true); + yield* runtime.ensureThread({ + threadId, + modelSelection, + runtimePolicy, + }); + + assert.isTrue(runtime.providerSession.capabilities.tools.supportsMcpTools); + assert.deepEqual(fake.mcpReplacements, [ + { + session_id: "live-create-1", + servers: { + "t3-code": { + url: "http://127.0.0.1:43123/mcp", + headers: { Authorization: "Bearer scoped-hermes-token" }, + }, + }, + }, + ]); + }).pipe(Effect.provide(TestLayer)), + ), + ); + + it.effect("rejects an MCP lease that is not scoped to the live Hermes session", () => + Effect.scoped( + Effect.gen(function* () { + const fake = new FakeHermesGatewayClient(); + fake.compatibility = { + ...compatibility, + capabilities: [...compatibility.capabilities, "session_mcp"], + }; + fake.mcpReplacementResult = { scopeSessionId: "different-live-session" }; + McpProviderSession.setMcpProviderSession({ + credentialId: "credential-hermes-wrong-scope", + environmentId: EnvironmentId.make("environment-hermes-test"), + threadId, + providerSessionId: "mcp-provider-session-hermes-wrong-scope", + providerInstanceId: instanceId, + endpoint: "http://127.0.0.1:43123/mcp", + authorizationHeader: "Bearer scoped-hermes-token", + capabilities: ["orchestration"], + }); + yield* Effect.addFinalizer(() => + Effect.sync(() => McpProviderSession.clearMcpProviderSession(threadId)), + ); + const runtime = yield* makeRuntime(fake, true, undefined, true); + + const exit = yield* Effect.exit( + runtime.ensureThread({ + threadId, + modelSelection, + runtimePolicy, + }), + ); + + assert.equal(exit._tag, "Failure"); + assert.equal(fake.mcpReplacements.length, 1); + }).pipe(Effect.provide(TestLayer)), + ), + ); + + it.effect("creates and prompts without optional session.title and session_mcp capabilities", () => + Effect.scoped( + Effect.gen(function* () { + const fake = new FakeHermesGatewayClient(); + McpProviderSession.setMcpProviderSession({ + credentialId: "credential-hermes-degraded", + environmentId: EnvironmentId.make("environment-hermes-test"), + threadId, + providerSessionId: "mcp-provider-session-hermes-degraded", + providerInstanceId: instanceId, + endpoint: "http://127.0.0.1:43123/mcp", + authorizationHeader: "Bearer scoped-hermes-token", + capabilities: ["orchestration"], + }); + yield* Effect.addFinalizer(() => + Effect.sync(() => McpProviderSession.clearMcpProviderSession(threadId)), + ); + const runtime = yield* makeRuntime(fake, true, undefined, true); + const providerThread = yield* runtime.ensureThread({ + threadId, + modelSelection, + runtimePolicy, + }); + yield* runtime.startTurn(turnInput(providerThread)); + yield* Effect.promise(() => + fake.emit("message.complete", { text: "response", status: "complete" }), + ); + + assert.equal(fake.creates.length, 1); + assert.equal(fake.prompts.length, 1); + assert.deepEqual(fake.titleReads, []); + assert.deepEqual(fake.titleUpdates, []); + assert.deepEqual(fake.mcpReplacements, []); + assert.deepEqual(fake.mcpRevocations, []); + assert.isFalse(runtime.providerSession.capabilities.tools.supportsMcpTools); + }).pipe(Effect.provide(TestLayer)), + ), + ); + + it.effect("resumes and prompts without optional session.title and session_mcp capabilities", () => + Effect.gen(function* () { + const createFake = new FakeHermesGatewayClient(); + const createdThread = yield* Effect.scoped( + Effect.gen(function* () { + const runtime = yield* makeRuntime(createFake); + return yield* runtime.ensureThread({ threadId, modelSelection, runtimePolicy }); + }), + ); + const resumeFake = new FakeHermesGatewayClient(); + yield* Effect.scoped( + Effect.gen(function* () { + const runtime = yield* makeRuntime(resumeFake, true, undefined, true); + const resumed = yield* runtime.resumeThread({ + providerThread: createdThread, + threadId, + modelSelection, + runtimePolicy, + }); + yield* runtime.startTurn(turnInput(resumed)); + yield* Effect.promise(() => + resumeFake.emit( + "message.complete", + { text: "response", status: "complete" }, + { + sessionId: "live-resume-1", + }, + ), + ); + + assert.equal(resumeFake.resumes.length, 1); + assert.equal(resumeFake.prompts.length, 1); + assert.deepEqual(resumeFake.titleReads, []); + assert.deepEqual(resumeFake.titleUpdates, []); + assert.deepEqual(resumeFake.mcpReplacements, []); + }), + ); + }).pipe(Effect.provide(TestLayer)), + ); + + it.effect("does not duplicate live-streamed text when history lacks message ids", () => + Effect.scoped( + Effect.gen(function* () { + const fake = new FakeHermesGatewayClient(); + const runtime = yield* makeRuntime(fake); + const providerThread = yield* runtime.ensureThread({ + threadId, + modelSelection, + runtimePolicy, + }); + yield* runtime.startTurn(turnInput(providerThread)); + yield* Effect.promise(() => + fake.emit("message.complete", { text: "response", status: "complete" }), + ); + fake.history = { + count: 2, + messages: [ + { role: "user", text: "hello Hermes" }, + { role: "assistant", text: "response" }, + ], + }; + + const snapshot = yield* runtime.readThreadSnapshot({ providerThread }); + const assistantRows = snapshot.messages.filter( + (message) => message.role === "assistant" && message.text === "response", + ); + assert.equal(assistantRows.length, 1); + }).pipe(Effect.provide(TestLayer)), + ), + ); + + it.effect("fails a fork with a typed error when session.branch.latest is not advertised", () => + Effect.scoped( + Effect.gen(function* () { + const fake = new FakeHermesGatewayClient(); + const runtime = yield* makeRuntime(fake); + const providerThread = yield* runtime.ensureThread({ + threadId, + modelSelection, + runtimePolicy, + }); + + const exit = yield* Effect.exit( + runtime.forkThread({ + sourceProviderThread: providerThread, + targetThreadId: ThreadId.make("thread:hermes-test:fork"), + }), + ); + + assert.equal(exit._tag, "Failure"); + }).pipe(Effect.provide(TestLayer)), + ), + ); + + it.effect("creates a fresh Hermes context for every distinct T3 thread", () => + Effect.scoped( + Effect.gen(function* () { + const fake = new FakeHermesGatewayClient(); + const runtime = yield* makeRuntime(fake); + const secondThreadId = ThreadId.make("thread:hermes-test:2"); + const first = yield* runtime.ensureThread({ + threadId, + modelSelection, + runtimePolicy, + }); + const second = yield* runtime.ensureThread({ + threadId: secondThreadId, + modelSelection, + runtimePolicy, + }); + const repository = yield* HermesSessionBindingRepository; + const firstBinding = yield* repository.getByThreadId(String(threadId)); + const secondBinding = yield* repository.getByThreadId(String(secondThreadId)); + + assert.equal(fake.creates.length, 2); + assert.notEqual(first.id, second.id); + assert.equal( + Option.getOrNull(Option.map(firstBinding, (binding) => binding.storedSessionKey)), + "stored-session-1", + ); + assert.equal( + Option.getOrNull(Option.map(secondBinding, (binding) => binding.storedSessionKey)), + "stored-session-2", + ); + }), + ).pipe(Effect.provide(TestLayer)), + ); + + it.effect("resumes by durable stored key and hydrates history", () => + Effect.gen(function* () { + const createFake = new FakeHermesGatewayClient(); + const createdThread = yield* Effect.scoped( + Effect.gen(function* () { + const runtime = yield* makeRuntime(createFake); + return yield* runtime.ensureThread({ threadId, modelSelection, runtimePolicy }); + }), + ); + const resumeFake = new FakeHermesGatewayClient(); + const mediaPath = "/Users/maria/Downloads/history-render.png"; + resumeFake.history = { + count: 1, + messages: [ + { + message_id: "history-1", + role: "assistant", + text: `from history\nMEDIA:${mediaPath}`, + }, + ], + }; + yield* Effect.scoped( + Effect.gen(function* () { + const runtime = yield* makeRuntime(resumeFake, true, undefined, false, ({ sourcePath }) => + Effect.succeed( + sourcePath === mediaPath + ? { + type: "image" as const, + id: ChatAttachmentId.make("hermes-history-media"), + name: "history-render.png", + mimeType: "image/png", + sizeBytes: 128, + } + : null, + ), + ); + const resumed = yield* runtime.resumeThread({ + providerThread: createdThread, + threadId, + modelSelection, + runtimePolicy, + }); + const snapshot = yield* runtime.readThreadSnapshot({ providerThread: resumed }); + + assert.equal(resumeFake.resumes[0]!.params.session_id, "stored-session-1"); + assert.notProperty(resumeFake.resumes[0]!.params, "model"); + assert.deepEqual( + snapshot.messages.map((message) => message.text), + ["from history"], + ); + assert.deepEqual(snapshot.messages[0]?.attachments, [ + { + type: "image", + id: "hermes-history-media", + name: "history-render.png", + mimeType: "image/png", + sizeBytes: 128, + }, + ]); + }), + ); + }).pipe(Effect.provide(TestLayer)), + ); + + it.effect("normalizes imported transport history with stable ordering and attachments", () => + Effect.gen(function* () { + const createFake = new FakeHermesGatewayClient(); + const createdThread = yield* Effect.scoped( + Effect.gen(function* () { + const runtime = yield* makeRuntime(createFake); + return yield* runtime.ensureThread({ threadId, modelSelection, runtimePolicy }); + }), + ); + const repository = yield* HermesSessionBindingRepository; + const now = "2026-07-26T12:00:00.000Z"; + const imported = yield* repository.prepareSessionImport({ + importId: "hermes-import:test-history", + providerInstanceId: String(instanceId), + profileKey: "real-profile", + projectId: String(threadId), + importKind: "session", + storedSessionKey: "stored-session-1", + threadId: String(threadId), + now, + }); + yield* repository.transitionSessionImport({ + importId: imported.importId, + from: "prepared", + to: "thread_created", + now, + }); + yield* repository.transitionSessionImport({ + importId: imported.importId, + from: "thread_created", + to: "completed", + now, + }); + + const fake = new FakeHermesGatewayClient(); + fake.history = { + count: 2, + messages: [ + { + message_id: "history-user", + role: "user", + text: [ + "[maria] rewrite better", + "", + "[Image attached at: /Users/maria/.hermes/cache/images/img_fixture.webp]", + "[screenshot]", + ].join("\n"), + }, + { message_id: "history-assistant", role: "assistant", text: "Rewritten." }, + ], + }; + yield* Effect.scoped( + Effect.gen(function* () { + const runtime = yield* makeRuntime(fake, true, undefined, false, () => + Effect.succeed({ + type: "image", + id: "thread-hermes-test-00000000-0000-0000-0000-000000000001", + name: "img_fixture.webp", + mimeType: "image/webp", + sizeBytes: 12, + }), + ); + const resumed = yield* runtime.resumeThread({ + providerThread: createdThread, + threadId, + modelSelection, + runtimePolicy, + }); + const first = yield* runtime.readThreadSnapshot({ providerThread: resumed }); + const replay = yield* runtime.readThreadSnapshot({ providerThread: resumed }); + + assert.deepEqual( + first.messages.map((message) => [message.role, message.text]), + [ + ["user", "rewrite better"], + ["assistant", "Rewritten."], + ], + ); + assert.deepEqual(first.messages[0]?.attachments, [ + { + type: "image", + id: "thread-hermes-test-00000000-0000-0000-0000-000000000001", + name: "img_fixture.webp", + mimeType: "image/webp", + sizeBytes: 12, + }, + ]); + assert.isBelow( + DateTime.toEpochMillis(first.messages[0]!.createdAt), + DateTime.toEpochMillis(first.messages[1]!.createdAt), + ); + assert.deepEqual( + replay.messages.map((message) => ({ + id: message.id, + role: message.role, + text: message.text, + attachments: message.attachments, + createdAt: DateTime.formatIso(message.createdAt), + })), + first.messages.map((message) => ({ + id: message.id, + role: message.role, + text: message.text, + attachments: message.attachments, + createdAt: DateTime.formatIso(message.createdAt), + })), + ); + }), + ); + }).pipe(Effect.provide(TestLayer)), + ); + + it.effect("leaves the inherited boundary unrecorded while imported history reads empty", () => + Effect.gen(function* () { + const createFake = new FakeHermesGatewayClient(); + const createdThread = yield* Effect.scoped( + Effect.gen(function* () { + const runtime = yield* makeRuntime(createFake); + return yield* runtime.ensureThread({ threadId, modelSelection, runtimePolicy }); + }), + ); + const repository = yield* HermesSessionBindingRepository; + const now = "2026-07-26T12:00:00.000Z"; + const imported = yield* repository.prepareSessionImport({ + importId: "hermes-import:test-empty-boundary", + providerInstanceId: String(instanceId), + profileKey: "real-profile", + projectId: String(threadId), + importKind: "session", + storedSessionKey: "stored-session-1", + threadId: String(threadId), + now, + }); + yield* repository.transitionSessionImport({ + importId: imported.importId, + from: "prepared", + to: "thread_created", + now, + }); + yield* repository.transitionSessionImport({ + importId: imported.importId, + from: "thread_created", + to: "completed", + now, + }); + + const fake = new FakeHermesGatewayClient(); + fake.history = { count: 0, messages: [] }; + yield* Effect.scoped( + Effect.gen(function* () { + const runtime = yield* makeRuntime(fake); + const resumed = yield* runtime.resumeThread({ + providerThread: createdThread, + threadId, + modelSelection, + runtimePolicy, + }); + const empty = yield* runtime.readThreadSnapshot({ providerThread: resumed }); + assert.deepEqual(empty.messages, []); + + fake.history = { + count: 2, + messages: [ + { message_id: "late-user", role: "user", text: "[maria] late history" }, + { message_id: "late-assistant", role: "assistant", text: "Recovered." }, + ], + }; + const hydratedLater = yield* runtime.readThreadSnapshot({ providerThread: resumed }); + assert.deepEqual( + hydratedLater.messages.map((message) => [message.role, message.text]), + [ + ["user", "late history"], + ["assistant", "Recovered."], + ], + ); + }), + ); + }).pipe(Effect.provide(TestLayer)), + ); + + it.effect("resolves MEDIA output inside rehydrated imported tool activities", () => + Effect.gen(function* () { + const createFake = new FakeHermesGatewayClient(); + const createdThread = yield* Effect.scoped( + Effect.gen(function* () { + const runtime = yield* makeRuntime(createFake); + return yield* runtime.ensureThread({ threadId, modelSelection, runtimePolicy }); + }), + ); + const repository = yield* HermesSessionBindingRepository; + const now = "2026-07-26T12:00:00.000Z"; + const imported = yield* repository.prepareSessionImport({ + importId: "hermes-import:test-tool-media", + providerInstanceId: String(instanceId), + profileKey: "real-profile", + projectId: String(threadId), + importKind: "session", + storedSessionKey: "stored-session-1", + threadId: String(threadId), + now, + }); + yield* repository.transitionSessionImport({ + importId: imported.importId, + from: "prepared", + to: "thread_created", + now, + }); + yield* repository.transitionSessionImport({ + importId: imported.importId, + from: "thread_created", + to: "completed", + now, + }); + + const mediaPath = "/Users/maria/.hermes/cache/images/tool-render.png"; + const fake = new FakeHermesGatewayClient(); + fake.history = { + count: 3, + messages: [ + { message_id: "m-user", role: "user", text: "render it" }, + { + message_id: "m-assistant", + role: "assistant", + text: "", + tool_calls: [ + { + id: "call-render", + function: { name: "render_chart", arguments: "{}" }, + }, + ], + }, + { + message_id: "m-tool", + role: "tool", + tool_call_id: "call-render", + text: `rendered\nMEDIA:${mediaPath}`, + }, + ], + }; + yield* Effect.scoped( + Effect.gen(function* () { + const runtime = yield* makeRuntime(fake, true, undefined, false, ({ sourcePath }) => + Effect.succeed( + sourcePath === mediaPath + ? { + type: "image" as const, + id: ChatAttachmentId.make("hermes-tool-media"), + name: "tool-render.png", + mimeType: "image/png", + sizeBytes: 64, + } + : null, + ), + ); + const resumed = yield* runtime.resumeThread({ + providerThread: createdThread, + threadId, + modelSelection, + runtimePolicy, + }); + const snapshot = yield* runtime.readThreadSnapshot({ providerThread: resumed }); + const tool = snapshot.turnItems?.find((item) => item.type === "dynamic_tool"); + assert.isDefined(tool); + if (tool?.type === "dynamic_tool") { + assert.notInclude(tool.output ?? "", "MEDIA:"); + assert.include(tool.output ?? "", "rendered"); + assert.include(tool.output ?? "", "[Attachment: tool-render.png]"); + } + }), + ); + }).pipe(Effect.provide(TestLayer)), + ); + + it.effect("uploads image attachments to Hermes before submitting the prompt", () => + Effect.scoped( + Effect.gen(function* () { + const fake = new FakeHermesGatewayClient(); + const readAttachmentIds: string[] = []; + const runtime = yield* makeRuntime(fake, true, (attachment) => { + readAttachmentIds.push(attachment.id); + return Effect.succeed(Uint8Array.from([0x89, 0x50, 0x4e, 0x47])); + }); + const providerThread = yield* runtime.ensureThread({ + threadId, + modelSelection, + runtimePolicy, + }); + const input = turnInput(providerThread); + yield* runtime.startTurn({ + ...input, + message: { + ...input.message, + attachments: [ + { + type: "image", + id: "thread-hermes-test-00000000-0000-4000-8000-000000000000", + name: "image.png", + mimeType: "image/png", + sizeBytes: 4, + }, + ], + }, + }); + + assert.deepEqual(readAttachmentIds, [ + "thread-hermes-test-00000000-0000-4000-8000-000000000000", + ]); + assert.deepEqual(fake.imageAttachments, [ + { + params: { + session_id: "live-create-1", + content_base64: "iVBORw==", + filename: "image.png", + }, + options: { + operationId: "hermes:image:attempt:hermes-test:0", + }, + }, + ]); + assert.equal(fake.prompts.length, 1); + }), + ).pipe(Effect.provide(TestLayer)), + ); + + it.effect("maps files, PDFs, and videos to their protocol-supported Hermes methods", () => + Effect.scoped( + Effect.gen(function* () { + const fake = new FakeHermesGatewayClient(); + const runtime = yield* makeRuntime(fake, true, () => + Effect.succeed(Uint8Array.from([0x61, 0x62, 0x63])), + ); + const providerThread = yield* runtime.ensureThread({ + threadId, + modelSelection, + runtimePolicy, + }); + const input = turnInput(providerThread); + yield* runtime.startTurn({ + ...input, + message: { + ...input.message, + attachments: [ + { + type: "file", + id: "thread-hermes-test-00000000-0000-4000-8000-000000000001", + name: "notes.txt", + mimeType: "text/plain", + sizeBytes: 3, + }, + { + type: "pdf", + id: "thread-hermes-test-00000000-0000-4000-8000-000000000002", + name: "report.pdf", + mimeType: "application/pdf", + sizeBytes: 3, + }, + { + type: "video", + id: "thread-hermes-test-00000000-0000-4000-8000-000000000003", + name: "clip.webm", + mimeType: "video/webm", + sizeBytes: 3, + }, + ], + }, + }); + + assert.deepEqual(fake.fileAttachments, [ + { + params: { + session_id: "live-create-1", + name: "notes.txt", + data_url: "data:text/plain;base64,YWJj", + }, + options: { operationId: "hermes:file:attempt:hermes-test:0" }, + }, + { + params: { + session_id: "live-create-1", + name: "clip.webm", + data_url: "data:video/webm;base64,YWJj", + }, + options: { operationId: "hermes:file:attempt:hermes-test:2" }, + }, + ]); + assert.deepEqual(fake.pdfAttachments, [ + { + params: { + session_id: "live-create-1", + filename: "report.pdf", + content_base64: "YWJj", + }, + options: { operationId: "hermes:pdf:attempt:hermes-test:1" }, + }, + ]); + // Staged file/video references are what make the uploads visible to + // the model, so they must ride along in the submitted prompt text. + assert.equal(fake.prompts.length, 1); + assert.equal( + fake.prompts[0]?.params.text, + `${input.message.text}\n\n@file:notes.txt\n@file:clip.webm`, + ); + }), + ).pipe(Effect.provide(TestLayer)), + ); + + it.effect("fails the turn when a staged file attach returns no reference token", () => + Effect.scoped( + Effect.gen(function* () { + const fake = new FakeHermesGatewayClient(); + fake.fileAttachOmitsRef = true; + const runtime = yield* makeRuntime(fake, true, () => + Effect.succeed(Uint8Array.from([0x61, 0x62, 0x63])), + ); + const providerThread = yield* runtime.ensureThread({ + threadId, + modelSelection, + runtimePolicy, + }); + const input = turnInput(providerThread); + const result = yield* Effect.exit( + runtime.startTurn({ + ...input, + message: { + ...input.message, + attachments: [ + { + type: "file", + id: "thread-hermes-test-00000000-0000-4000-8000-000000000001", + name: "notes.txt", + mimeType: "text/plain", + sizeBytes: 3, + }, + ], + }, + }), + ); + assert.equal(Exit.isFailure(result), true); + assert.equal(fake.prompts.length, 0); + }), + ).pipe(Effect.provide(TestLayer)), + ); + + it.effect( + "keeps prompt admitted through streaming, confirms on terminal, and ignores duplicates/unknown events", + () => + Effect.scoped( + Effect.gen(function* () { + const fake = new FakeHermesGatewayClient(); + const runtime = yield* makeRuntime(fake); + const providerThread = yield* runtime.ensureThread({ + threadId, + modelSelection, + runtimePolicy, + }); + const input = turnInput(providerThread); + yield* runtime.startTurn(input); + const repository = yield* HermesSessionBindingRepository; + const operationId = `hermes:prompt:${input.attemptId}`; + const admitted = yield* repository.getMutationIntent(operationId); + assert.equal( + Option.getOrNull(Option.map(admitted, (intent) => intent.state)), + "admitted", + ); + + yield* Effect.promise(() => fake.emit("message.start", { text: "" })); + yield* Effect.promise(() => fake.emit("message.delta", { text: "hello " })); + yield* Effect.promise(() => fake.emit("future.event", { ignored: true })); + yield* Effect.promise(() => fake.emit("message.delta", { text: "world" })); + yield* Effect.promise(() => fake.emit("message.complete", { text: "hello world" })); + yield* Effect.promise(() => fake.emit("message.complete", { text: "duplicate" })); + + const projected = yield* runtime.events.pipe( + Stream.takeUntil((event) => event.type === "turn.terminal"), + Stream.runCollect, + ); + const terminalCount = [...projected].filter( + (event) => event.type === "turn.terminal", + ).length; + const messages = [...projected].filter((event) => event.type === "message.updated"); + const confirmed = yield* repository.getMutationIntent(operationId); + + assert.equal(terminalCount, 1); + assert.equal( + messages.at(-1)?.type === "message.updated" ? messages.at(-1)!.message.text : "", + "hello world", + ); + assert.equal( + Option.getOrNull(Option.map(confirmed, (intent) => intent.state)), + "confirmed", + ); + assert.equal(fake.prompts[0]!.options.mutationId, hermesWireMutationId(operationId)); + }), + ).pipe(Effect.provide(TestLayer)), + ); + + it.effect("does not duplicate repeated interim message snapshots", () => + Effect.scoped( + Effect.gen(function* () { + const fake = new FakeHermesGatewayClient(); + const runtime = yield* makeRuntime(fake); + const providerThread = yield* runtime.ensureThread({ + threadId, + modelSelection, + runtimePolicy, + }); + yield* runtime.startTurn(turnInput(providerThread)); + + const interim = "Spawned the subagent; I'll report its answer when it returns."; + yield* Effect.promise(() => fake.emit("message.interim", { text: interim })); + yield* Effect.promise(() => fake.emit("message.interim", { text: interim })); + // Deltas re-stream the message from the beginning after a snapshot. + yield* Effect.promise(() => fake.emit("message.delta", { text: interim })); + yield* Effect.promise(() => fake.emit("message.complete", { text: interim })); + + const projected = yield* runtime.events.pipe( + Stream.takeUntil((event) => event.type === "turn.terminal"), + Stream.runCollect, + ); + const messages = [...projected].filter((event) => event.type === "message.updated"); + assert.equal( + messages.at(-1)?.type === "message.updated" ? messages.at(-1)!.message.text : "", + interim, + ); + }), + ).pipe(Effect.provide(TestLayer)), + ); + + it.effect("turns completed assistant MEDIA output into attachments without exposing paths", () => + Effect.scoped( + Effect.gen(function* () { + const fake = new FakeHermesGatewayClient(); + const mediaPath = "/Users/maria/Downloads/rendered.jpg"; + const runtime = yield* makeRuntime(fake, true, undefined, false, ({ sourcePath }) => + Effect.succeed( + sourcePath === mediaPath + ? { + type: "image" as const, + id: ChatAttachmentId.make("hermes-media-output"), + name: "rendered.jpg", + mimeType: "image/jpeg", + sizeBytes: 128, + } + : null, + ), + ); + const providerThread = yield* runtime.ensureThread({ + threadId, + modelSelection, + runtimePolicy, + }); + yield* runtime.startTurn(turnInput(providerThread)); + yield* Effect.promise(() => + fake.emit("message.start", { + text: `Here is the result.\nMEDIA:${mediaPath}`, + }), + ); + yield* Effect.promise(() => + fake.emit("message.complete", { + text: `Here is the result.\nMEDIA:${mediaPath}`, + }), + ); + + const projected = yield* runtime.events.pipe( + Stream.takeUntil((event) => event.type === "turn.terminal"), + Stream.runCollect, + ); + const messages = [...projected].filter((event) => event.type === "message.updated"); + const finalMessage = messages.at(-1); + assert.equal( + finalMessage?.type === "message.updated" ? finalMessage.message.text : "", + "Here is the result.", + ); + assert.deepEqual( + finalMessage?.type === "message.updated" ? finalMessage.message.attachments : [], + [ + { + type: "image", + id: "hermes-media-output", + name: "rendered.jpg", + mimeType: "image/jpeg", + sizeBytes: 128, + }, + ], + ); + assert.isFalse( + messages.some( + (event) => event.type === "message.updated" && event.message.text.includes(mediaPath), + ), + ); + }), + ).pipe(Effect.provide(TestLayer)), + ); + + it.effect( + "projects every Hermes tool lifecycle with stable identities, ordering, and sanitized data", + () => + Effect.scoped( + Effect.gen(function* () { + const fake = new FakeHermesGatewayClient(); + const runtime = yield* makeRuntime(fake); + const providerThread = yield* runtime.ensureThread({ + threadId, + modelSelection, + runtimePolicy, + }); + yield* runtime.startTurn(turnInput(providerThread)); + + yield* Effect.promise(() => fake.emit("tool.generating", { name: "terminal" })); + yield* Effect.promise(() => fake.emit("tool.generating", { name: "terminal" })); + yield* Effect.promise(() => + fake.emit("tool.progress", { name: "terminal", preview: "preparing first command" }), + ); + yield* Effect.promise(() => + fake.emit("tool.start", { + tool_id: "tool-1", + name: "terminal", + context: "first command", + args_text: '{ "command": "echo one" }', + }), + ); + yield* Effect.promise(() => + fake.emit("tool.start", { + tool_id: "tool-2", + name: "terminal", + context: "second command", + args_text: '{ "command": "echo two" }', + }), + ); + yield* Effect.promise(() => + fake.emit("tool.complete", { + tool_id: "tool-2", + name: "terminal", + args: { command: "echo two", api_key: "secret-two" }, + result: { output: "two", exit_code: 0 }, + duration_s: 0.2, + summary: "Ran second command", + }), + ); + yield* Effect.promise(() => + fake.emit("tool.complete", { + tool_id: "tool-1", + name: "terminal", + args: { + command: "curl https://example.test/?token=secret-one", + password: "secret-password", + }, + result: { + output: "Authorization: Bearer secret-result", + exit_code: 0, + }, + result_text: "command completed", + duration_s: 0.1, + summary: "Ran first command", + }), + ); + yield* Effect.promise(() => fake.emit("message.complete", { text: "done" })); + + const projected = yield* runtime.events.pipe( + Stream.takeUntil((event) => event.type === "turn.terminal"), + Stream.runCollect, + ); + const toolItems = [...projected].flatMap((event) => + event.type === "turn_item.updated" && event.turnItem.type === "dynamic_tool" + ? [event.turnItem] + : [], + ); + const toolNodes = [...projected].flatMap((event) => + event.type === "node.updated" && event.node.kind === "tool_call" ? [event.node] : [], + ); + const firstCompleted = toolItems.find( + (item) => item.nativeItemRef?.nativeId === "tool-1" && item.status === "completed", + ); + const secondCompleted = toolItems.find( + (item) => item.nativeItemRef?.nativeId === "tool-2" && item.status === "completed", + ); + const first = toolItems.filter((item) => item.id === firstCompleted?.id); + const second = toolItems.filter((item) => item.id === secondCompleted?.id); + const assistant = [...projected].find( + (event) => + event.type === "turn_item.updated" && event.turnItem.type === "assistant_message", + ); + + assert.deepEqual( + first.map((item) => item.status), + ["pending", "running", "running", "completed"], + ); + assert.deepEqual( + second.map((item) => item.status), + ["pending", "running", "completed"], + ); + assert.equal(new Set(first.map((item) => item.id)).size, 1); + assert.equal(new Set(second.map((item) => item.id)).size, 1); + assert.deepEqual( + toolNodes + .filter((node) => node.id === firstCompleted?.nodeId) + .map((node) => node.status), + ["pending", "running", "running", "completed"], + ); + assert.equal( + new Set( + toolNodes + .filter((node) => node.id === secondCompleted?.nodeId) + .map((node) => node.id), + ).size, + 1, + ); + assert.equal(firstCompleted?.ordinal, 101); + assert.equal(secondCompleted?.ordinal, 102); + assert.equal( + assistant?.type === "turn_item.updated" ? assistant.turnItem.ordinal : null, + 103, + ); + assert.deepEqual(firstCompleted?.input, { + command: "curl https://example.test/?token=[REDACTED]", + password: "[REDACTED]", + }); + assert.deepEqual(firstCompleted?.output, { + output: "Authorization: Bearer [REDACTED]", + exit_code: 0, + }); + assert.deepEqual(secondCompleted?.input, { + command: "echo two", + api_key: "[REDACTED]", + }); + }), + ).pipe(Effect.provide(TestLayer)), + ); + + it.effect("projects start-less completions and failed tool results", () => + Effect.scoped( + Effect.gen(function* () { + const fake = new FakeHermesGatewayClient(); + const runtime = yield* makeRuntime(fake); + const providerThread = yield* runtime.ensureThread({ + threadId, + modelSelection, + runtimePolicy, + }); + yield* runtime.startTurn(turnInput(providerThread)); + yield* Effect.promise(() => + fake.emit("tool.start", { + tool_id: "failed-tool", + name: "terminal", + arguments: { command: "false" }, + }), + ); + yield* Effect.promise(() => + fake.emit("tool.complete", { + tool_id: "failed-tool", + name: "terminal", + result: "Error executing tool 'terminal': permission denied", + }), + ); + yield* Effect.promise(() => + fake.emit("tool.complete", { + tool_id: "completion-only", + name: "web_search", + args: { query: "Hermes" }, + result: { results: ["one"] }, + }), + ); + yield* Effect.promise(() => fake.emit("message.complete", { text: "done" })); + + const items = yield* runtime.events.pipe( + Stream.takeUntil((event) => event.type === "turn.terminal"), + Stream.runCollect, + Effect.map((events) => + [...events].flatMap((event) => + event.type === "turn_item.updated" && event.turnItem.type === "dynamic_tool" + ? [event.turnItem] + : [], + ), + ), + ); + const byNativeId = (nativeId: string): OrchestrationV2TurnItem | undefined => + items.findLast((item) => item.nativeItemRef?.nativeId === nativeId); + const completionOnly = byNativeId("completion-only"); + + assert.equal(byNativeId("failed-tool")?.status, "failed"); + assert.equal(completionOnly?.status, "completed"); + assert.deepEqual( + completionOnly?.type === "dynamic_tool" ? completionOnly.output : undefined, + { results: ["one"] }, + ); + }), + ).pipe(Effect.provide(TestLayer)), + ); + + it.effect("ignores stale and duplicate events while enriching tool risk output", () => + Effect.scoped( + Effect.gen(function* () { + const fake = new FakeHermesGatewayClient(); + const runtime = yield* makeRuntime(fake); + const providerThread = yield* runtime.ensureThread({ + threadId, + modelSelection, + runtimePolicy, + }); + yield* runtime.startTurn(turnInput(providerThread)); + + yield* Effect.promise(() => + fake.emit( + "tool.start", + { tool_id: "stale-tool", name: "terminal", args: { command: "stale" } }, + { runId: "prior-hermes-run" }, + ), + ); + yield* Effect.promise(() => + fake.emit( + "tool.start", + { tool_id: "risk-tool", name: "web_extract", args: { url: "https://example.test" } }, + { eventId: "dedupe-tool-start" }, + ), + ); + yield* Effect.promise(() => + fake.emit( + "tool.start", + { tool_id: "risk-tool", name: "web_extract", args: { url: "https://example.test" } }, + { eventId: "dedupe-tool-start" }, + ), + ); + yield* Effect.promise(() => + fake.emit("tool.complete", { + tool_id: "risk-tool", + name: "web_extract", + result: "untrusted page contents", + }), + ); + yield* Effect.promise(() => + fake.emit("tool.output_risk", { + tool_id: "risk-tool", + name: "web_extract", + risk: "high", + findings: ["prompt injection"], + redacted: true, + }), + ); + yield* Effect.promise(() => fake.emit("message.complete", { text: "done" })); + + const items = yield* runtime.events.pipe( + Stream.takeUntil((event) => event.type === "turn.terminal"), + Stream.runCollect, + Effect.map((events) => + [...events].flatMap((event) => + event.type === "turn_item.updated" && event.turnItem.type === "dynamic_tool" + ? [event.turnItem] + : [], + ), + ), + ); + const staleItems = items.filter((item) => item.nativeItemRef?.nativeId === "stale-tool"); + const riskItems = items.filter((item) => item.nativeItemRef?.nativeId === "risk-tool"); + const runningRiskItems = riskItems.filter((item) => item.status === "running"); + const finalRiskItem = riskItems.at(-1); + + assert.equal(staleItems.length, 0); + assert.equal(runningRiskItems.length, 1); + assert.deepEqual( + finalRiskItem?.type === "dynamic_tool" ? finalRiskItem.output : undefined, + { + result: "[REDACTED BY HERMES]", + outputRisk: { + risk: "high", + findings: ["prompt injection"], + redacted: true, + }, + }, + ); + }), + ).pipe(Effect.provide(TestLayer)), + ); + + it.effect("closes a tool without a completion event conservatively", () => + Effect.scoped( + Effect.gen(function* () { + const fake = new FakeHermesGatewayClient(); + const runtime = yield* makeRuntime(fake); + const providerThread = yield* runtime.ensureThread({ + threadId, + modelSelection, + runtimePolicy, + }); + yield* runtime.startTurn(turnInput(providerThread)); + yield* Effect.promise(() => + fake.emit("tool.start", { + tool_id: "incomplete-tool", + name: "terminal", + args: { command: "sleep 1" }, + }), + ); + yield* Effect.promise(() => fake.emit("message.complete", { text: "done" })); + + const finalToolItem = yield* runtime.events.pipe( + Stream.takeUntil((event) => event.type === "turn.terminal"), + Stream.runCollect, + Effect.map((events) => + [...events] + .flatMap((event) => + event.type === "turn_item.updated" && event.turnItem.type === "dynamic_tool" + ? [event.turnItem] + : [], + ) + .at(-1), + ), + ); + + assert.equal(finalToolItem?.status, "cancelled"); + }), + ).pipe(Effect.provide(TestLayer)), + ); + + it.effect( + "routes repeated turns through the projected thread id while retaining native identity", + () => + Effect.scoped( + Effect.gen(function* () { + const fake = new FakeHermesGatewayClient(); + const runtime = yield* makeRuntime(fake); + const providerThread = yield* runtime.ensureThread({ + threadId, + modelSelection, + runtimePolicy, + }); + const projectedThread = { + ...providerThread, + id: ProviderThreadId.make("provider-thread:hermes-placeholder"), + }; + + yield* runtime.startTurn(turnInput(projectedThread)); + assert.equal(fake.prompts.length, 1); + + yield* Effect.promise(() => fake.emit("message.complete", { text: "routed" })); + const firstEvents = yield* runtime.events.pipe( + Stream.takeUntil((event) => event.type === "turn.terminal"), + Stream.runCollect, + ); + const firstProviderTurn = [...firstEvents].find( + (event) => event.type === "provider_turn.updated", + ); + assert.equal( + firstProviderTurn?.type === "provider_turn.updated" + ? firstProviderTurn.providerTurn.providerThreadId + : null, + projectedThread.id, + ); + + fake.promptResult = { + status: "streaming", + run_id: "hermes-run-2", + user_message_id: "hermes-user-2", + assistant_message_id: "hermes-assistant-2", + mutation_id: "mutation-2", + }; + yield* runtime.startTurn({ + ...turnInput(projectedThread), + runId: RunId.make("run:hermes-test:2"), + runOrdinal: 2, + providerTurnOrdinal: 2, + attemptId: RunAttemptId.make("attempt:hermes-test:2"), + }); + yield* Effect.promise(() => + fake.emit("message.complete", { text: "again" }, { runId: "hermes-run-2" }), + ); + const secondEvents = yield* runtime.events.pipe( + Stream.takeUntil((event) => event.type === "turn.terminal"), + Stream.runCollect, + ); + const secondProviderTurn = [...secondEvents].find( + (event) => event.type === "provider_turn.updated", + ); + assert.equal( + secondProviderTurn?.type === "provider_turn.updated" + ? secondProviderTurn.providerTurn.providerThreadId + : null, + projectedThread.id, + ); + assert.equal( + secondProviderTurn?.type === "provider_turn.updated" + ? secondProviderTurn.providerTurn.ordinal + : null, + 2, + ); + assert.equal(fake.prompts.length, 2); + }), + ).pipe(Effect.provide(TestLayer)), + ); + + it.effect("continues the default model after Hermes reports its resolved concrete model", () => + Effect.scoped( + Effect.gen(function* () { + const fake = new FakeHermesGatewayClient(); + const runtime = yield* makeRuntime(fake); + const providerThread = yield* runtime.ensureThread({ + threadId, + modelSelection, + runtimePolicy, + }); + + yield* runtime.startTurn(turnInput(providerThread)); + yield* Effect.promise(() => fake.emit("session.info", { model: "openai/gpt-5.6-sol" })); + assert.equal(runtime.providerSession.model, "openai/gpt-5.6-sol"); + yield* Effect.promise(() => + fake.emit("message.complete", { text: "first response", status: "complete" }), + ); + + fake.promptResult = { + status: "streaming", + run_id: "hermes-run-2", + user_message_id: "hermes-user-2", + assistant_message_id: "hermes-assistant-2", + mutation_id: "mutation-2", + }; + yield* runtime.startTurn({ + ...turnInput(providerThread), + runId: RunId.make("run:hermes-test:2"), + runOrdinal: 2, + providerTurnOrdinal: 2, + attemptId: RunAttemptId.make("attempt:hermes-test:2"), + }); + + assert.equal(fake.prompts.length, 2); + }), + ).pipe(Effect.provide(TestLayer)), + ); + + it.effect("continues the first prompt when Hermes rejects a duplicate session title", () => + Effect.scoped( + Effect.gen(function* () { + const fake = new FakeHermesGatewayClient(); + fake.compatibility = { + ...compatibility, + capabilities: [...compatibility.capabilities, "session.title"], + }; + fake.titleError = new HermesGatewayRpcError(4022, "session.title", "fatal"); + const runtime = yield* makeRuntime(fake); + const providerThread = yield* runtime.ensureThread({ + threadId, + modelSelection, + runtimePolicy, + }); + + yield* runtime.startTurn(turnInput(providerThread)); + yield* Effect.promise(() => + fake.emit("message.complete", { text: "first response", status: "complete" }), + ); + yield* runtime.startTurn({ + ...turnInput(providerThread), + runId: RunId.make("run:hermes-title-conflict:2"), + runOrdinal: 2, + providerTurnOrdinal: 2, + attemptId: RunAttemptId.make("attempt:hermes-title-conflict:2"), + }); + + assert.equal(fake.prompts.length, 2); + }), + ).pipe(Effect.provide(TestLayer)), + ); + + it.effect("reacquires an expired same-generation lease before terminal settlement", () => + Effect.scoped( + Effect.gen(function* () { + const fake = new FakeHermesGatewayClient(); + const runtime = yield* makeRuntime(fake); + const providerThread = yield* runtime.ensureThread({ + threadId, + modelSelection, + runtimePolicy, + }); + const input = turnInput(providerThread); + yield* runtime.startTurn(input); + const sql = yield* SqlClient.SqlClient; + yield* sql` + UPDATE hermes_session_bindings + SET lease_expires_at = '2000-01-01T00:00:00.000Z' + WHERE thread_id = ${String(threadId)} + `; + yield* Effect.promise(() => fake.emit("message.complete", { text: "late result" })); + const terminal = yield* runtime.events.pipe( + Stream.filter((event) => event.type === "turn.terminal"), + Stream.runHead, + ); + const repository = yield* HermesSessionBindingRepository; + const intent = yield* repository.getMutationIntent(`hermes:prompt:${input.attemptId}`); + + assert.isTrue(Option.isSome(terminal)); + assert.equal(Option.getOrNull(Option.map(intent, (value) => value.state)), "confirmed"); + }), + ).pipe(Effect.provide(TestLayer)), + ); + + it.effect("waits for the terminal event when interrupting", () => + Effect.scoped( + Effect.gen(function* () { + const fake = new FakeHermesGatewayClient(); + const runtime = yield* makeRuntime(fake); + const providerThread = yield* runtime.ensureThread({ + threadId, + modelSelection, + runtimePolicy, + }); + const input = turnInput(providerThread); + yield* runtime.startTurn(input); + const providerTurn = yield* runtime.events.pipe( + Stream.filter((event) => event.type === "provider_turn.updated"), + Stream.runHead, + ); + assert.isTrue(Option.isSome(providerTurn)); + if (Option.isNone(providerTurn) || providerTurn.value.type !== "provider_turn.updated") + return; + + yield* runtime.interruptTurn({ + providerThread, + providerTurnId: providerTurn.value.providerTurn.id, + }); + assert.equal(fake.interrupts.length, 1); + }), + ).pipe(Effect.provide(TestLayer)), + ); + + it.effect("does not resubmit an indeterminate prompt mutation", () => + Effect.scoped( + Effect.gen(function* () { + const fake = new FakeHermesGatewayClient(); + fake.promptError = new HermesGatewayMutationIndeterminateError( + "hermes:prompt:attempt:hermes-test", + "prompt.submit", + ); + fake.mutationStatus = { + mutation_id: "hermes:prompt:attempt:hermes-test", + mutation_status: "indeterminate", + run_id: "hermes-run-1", + replayed: true, + }; + const runtime = yield* makeRuntime(fake); + const providerThread = yield* runtime.ensureThread({ + threadId, + modelSelection, + runtimePolicy, + }); + const input = turnInput(providerThread); + yield* Effect.result(runtime.startTurn(input)); + yield* Effect.result(runtime.startTurn(input)); + const repository = yield* HermesSessionBindingRepository; + const intent = yield* repository.getMutationIntent(`hermes:prompt:${input.attemptId}`); + + assert.equal(fake.prompts.length, 1); + assert.equal(Option.getOrNull(Option.map(intent, (value) => value.state)), "indeterminate"); + }), + ).pipe(Effect.provide(TestLayer)), + ); + + it.effect("recovers a completed prompt replay without waiting for message events", () => + Effect.scoped( + Effect.gen(function* () { + const fake = new FakeHermesGatewayClient(); + fake.promptError = new HermesGatewayMutationIndeterminateError( + "hermes:prompt:attempt:hermes-test", + "prompt.submit", + ); + const runtime = yield* makeRuntime(fake); + const providerThread = yield* runtime.ensureThread({ + threadId, + modelSelection, + runtimePolicy, + }); + const input = turnInput(providerThread); + yield* Effect.result(runtime.startTurn(input)); + + fake.promptError = null; + fake.mutationStatus = { mutation_status: "completed" }; + fake.promptResult = { + status: "complete", + mutation_id: `hermes:prompt:${input.attemptId}`, + mutation_status: "completed", + run_id: "hermes-run-1", + message_id: "hermes-assistant-1", + replayed: true, + }; + yield* runtime.startTurn(input); + const terminal = yield* runtime.events.pipe( + Stream.filter((event) => event.type === "turn.terminal"), + Stream.runHead, + ); + const repository = yield* HermesSessionBindingRepository; + const intent = yield* repository.getMutationIntent(`hermes:prompt:${input.attemptId}`); + + assert.isTrue(Option.isSome(terminal)); + assert.equal(Option.getOrNull(Option.map(intent, (value) => value.state)), "reconciled"); + assert.equal(fake.prompts.length, 2); + assert.deepEqual(fake.reconciliations, [`hermes:prompt:${input.attemptId}`]); + assert.deepEqual(fake.reconciliationMutationIds, [ + hermesWireMutationId(`hermes:prompt:${input.attemptId}`), + ]); + }), + ).pipe(Effect.provide(TestLayer)), + ); + + it.effect("blocks an indeterminate replay and any different prompt behind it", () => + Effect.scoped( + Effect.gen(function* () { + const fake = new FakeHermesGatewayClient(); + fake.promptError = new HermesGatewayMutationIndeterminateError( + "hermes:prompt:attempt:hermes-test", + "prompt.submit", + ); + fake.mutationStatus = { + mutation_id: "hermes:prompt:attempt:hermes-test", + mutation_status: "indeterminate", + run_id: "hermes-run-1", + replayed: true, + }; + const runtime = yield* makeRuntime(fake); + const providerThread = yield* runtime.ensureThread({ + threadId, + modelSelection, + runtimePolicy, + }); + const input = turnInput(providerThread); + yield* Effect.result(runtime.startTurn(input)); + yield* Effect.result(runtime.startTurn(input)); + yield* Effect.result( + runtime.startTurn({ + ...input, + attemptId: RunAttemptId.make("attempt:hermes-other"), + }), + ); + + assert.equal(fake.prompts.length, 1); + assert.deepEqual(fake.reconciliations, [`hermes:prompt:${input.attemptId}`]); + }), + ).pipe(Effect.provide(TestLayer)), + ); + + it.effect("recovers an indeterminate session create through completed stable replay", () => + Effect.gen(function* () { + const first = new FakeHermesGatewayClient(); + first.createError = new HermesGatewayMutationIndeterminateError( + `hermes:create:${instanceId}:${threadId}`, + "session.create", + ); + yield* Effect.scoped( + Effect.gen(function* () { + const runtime = yield* makeRuntime(first); + yield* Effect.result(runtime.ensureThread({ threadId, modelSelection, runtimePolicy })); + }), + ); + + const recovered = new FakeHermesGatewayClient(); + recovered.mutationStatus = { mutation_status: "completed" }; + const providerThread = yield* Effect.scoped( + Effect.gen(function* () { + const runtime = yield* makeRuntime(recovered); + return yield* runtime.ensureThread({ threadId, modelSelection, runtimePolicy }); + }), + ); + + assert.equal(providerThread.status, "idle"); + assert.equal(recovered.creates.length, 1); + assert.deepEqual(recovered.reconciliations, [`hermes:create:${instanceId}:${threadId}`]); + }).pipe(Effect.provide(TestLayer)), + ); + + it.effect("fences a recovered external run until an authoritative terminal event", () => + Effect.gen(function* () { + const createdThread = yield* Effect.scoped( + Effect.gen(function* () { + const runtime = yield* makeRuntime(new FakeHermesGatewayClient()); + return yield* runtime.ensureThread({ threadId, modelSelection, runtimePolicy }); + }), + ); + const recovered = new FakeHermesGatewayClient(); + recovered.resumeRunning = true; + recovered.resumeInflight = { run_id: "external-run" }; + recovered.resumeStatus = "running"; + recovered.statusOutput = "Hermes TUI Status\n\nAgent Running: Yes"; + yield* Effect.scoped( + Effect.gen(function* () { + const runtime = yield* makeRuntime(recovered); + const providerThread = yield* runtime.resumeThread({ + providerThread: createdThread, + threadId, + modelSelection, + runtimePolicy, + }); + + assert.equal(providerThread.status, "active"); + assert.equal(runtime.providerSession.status, "running"); + yield* Effect.result(runtime.startTurn(turnInput(providerThread))); + assert.equal(recovered.prompts.length, 0); + + yield* Effect.promise(() => recovered.emit("message.complete", { status: "complete" })); + assert.equal(runtime.providerSession.status, "ready"); + recovered.statusOutput = "Hermes TUI Status\n\nAgent Running: No"; + yield* runtime.startTurn(turnInput(providerThread)); + assert.equal(recovered.prompts.length, 1); + }), + ); + }).pipe(Effect.provide(TestLayer)), + ); + + it.effect("projects interrupted and error message.complete statuses", () => + Effect.scoped( + Effect.gen(function* () { + const fake = new FakeHermesGatewayClient(); + const runtime = yield* makeRuntime(fake); + const providerThread = yield* runtime.ensureThread({ + threadId, + modelSelection, + runtimePolicy, + }); + const firstInput = turnInput(providerThread); + yield* runtime.startTurn(firstInput); + yield* Effect.promise(() => + fake.emit("message.complete", { text: "interrupted", status: "interrupted" }), + ); + const interrupted = yield* runtime.events.pipe( + Stream.filter((event) => event.type === "turn.terminal"), + Stream.runHead, + ); + assert.isTrue(Option.isSome(interrupted)); + if (Option.isSome(interrupted) && interrupted.value.type === "turn.terminal") { + assert.equal(interrupted.value.status, "interrupted"); + } + + yield* runtime.startTurn({ + ...firstInput, + attemptId: RunAttemptId.make("attempt:hermes-error"), + providerTurnOrdinal: 2, + }); + yield* Effect.promise(() => + fake.emit("message.complete", { text: "failed", status: "error" }), + ); + const failed = yield* runtime.events.pipe( + Stream.filter((event) => event.type === "turn.terminal"), + Stream.runHead, + ); + assert.isTrue(Option.isSome(failed)); + if (Option.isSome(failed) && failed.value.type === "turn.terminal") { + assert.equal(failed.value.status, "failed"); + assert.isNotNull(failed.value.failure); + assert.equal(failed.value.threadDisposition, "broken"); + } + }), + ).pipe(Effect.provide(TestLayer)), + ); + + it.effect("publishes a broken failure and poisons the stale owner after generation loss", () => + Effect.scoped( + Effect.gen(function* () { + const fake = new FakeHermesGatewayClient(); + const runtime = yield* makeRuntime(fake); + const providerThread = yield* runtime.ensureThread({ + threadId, + modelSelection, + runtimePolicy, + }); + const input = turnInput(providerThread); + yield* runtime.startTurn(input); + const sql = yield* SqlClient.SqlClient; + yield* sql` + UPDATE hermes_session_bindings + SET + lease_owner_key = 'competing-owner', + lease_generation = lease_generation + 1, + lease_expires_at = '2999-01-01T00:00:00.000Z' + WHERE thread_id = ${String(threadId)} + `; + yield* Effect.promise(() => + fake.emit("message.complete", { text: "stale success", status: "complete" }), + ); + const terminal = yield* runtime.events.pipe( + Stream.filter((event) => event.type === "turn.terminal"), + Stream.runHead, + ); + yield* Effect.result( + runtime.startTurn({ + ...input, + attemptId: RunAttemptId.make("attempt:after-generation-loss"), + }), + ); + + assert.isTrue(Option.isSome(terminal)); + if (Option.isSome(terminal) && terminal.value.type === "turn.terminal") { + assert.equal(terminal.value.status, "failed"); + assert.equal(terminal.value.threadDisposition, "broken"); + } + assert.equal(fake.prompts.length, 1); + }), + ).pipe(Effect.provide(TestLayer)), + ); + + it.effect("rejects opening a disabled instance without connecting", () => + Effect.scoped( + Effect.gen(function* () { + const fake = new FakeHermesGatewayClient(); + const exit = yield* Effect.exit(makeRuntime(fake, false)); + assert.equal(exit._tag, "Failure"); + assert.equal(fake.creates.length, 0); + }), + ).pipe(Effect.provide(TestLayer)), + ); + + it.effect("closes the Hermes client when provider open cannot connect", () => + Effect.scoped( + Effect.gen(function* () { + const fake = new FakeHermesGatewayClient(); + fake.connectError = new Error("gateway unavailable"); + + const exit = yield* Effect.exit(makeRuntime(fake)); + + assert.equal(exit._tag, "Failure"); + assert.equal(fake.closeCount, 1); + }), + ).pipe(Effect.provide(TestLayer)), + ); + + it.effect("rejects a fully configured remote gateway before constructing a client", () => + Effect.scoped( + Effect.gen(function* () { + const idAllocator = yield* IdAllocatorV2; + const repository = yield* HermesSessionBindingRepository; + let clientCreations = 0; + const adapter = makeHermesServeAdapterV2({ + instanceId, + settings: { + enabled: true, + endpoint: "wss://gateway.example.com/api/ws", + remoteAccessEnabled: true, + profileKey: "remote-profile", + managedServerEnabled: false, + customModels: [], + importEnabled: false, + mcpEnabled: false, + attachmentsEnabled: false, + proactiveEnabled: false, + voiceEnabled: false, + }, + enabled: true, + authToken: "local-token", + remotePairingToken: "dedicated-pairing-token", + remoteTlsCertificateSha256: "ab".repeat(32), + idAllocator, + repository, + clientFactory: () => { + clientCreations += 1; + return new FakeHermesGatewayClient(); + }, + }); + + const exit = yield* Effect.exit( + adapter.openSession({ + threadId, + providerSessionId, + modelSelection, + runtimePolicy, + }), + ); + assert.equal(exit._tag, "Failure"); + assert.equal(clientCreations, 0); + }), + ).pipe(Effect.provide(TestLayer)), + ); +}); + +describe("sanitizeHermesToolValue", () => { + it("redacts Basic credentials and OAuth parameters", () => { + assert.deepEqual( + sanitizeHermesToolValue({ + header: "Authorization: Basic dXNlcjpwYXNz", + digest: "Digest username=abc", + url: "https://example.test/cb?client_secret=abc&refresh_token=def&id_token=ghi", + body: "client_secret: s3cret refresh_token=r3fresh", + }), + { + header: "Authorization: Basic [REDACTED]", + digest: "Digest [REDACTED]", + url: "https://example.test/cb?client_secret=[REDACTED]", + body: "client_secret: [REDACTED] refresh_token=[REDACTED]", + }, + ); + }); + + it("bounds oversized property names against the sanitized size budget", () => { + const longKey = "k".repeat(100_000); + const sanitized = sanitizeHermesToolValue({ [longKey]: "value", after: "kept" }) as Record< + string, + unknown + >; + const keys = Object.keys(sanitized); + assert.isBelow(keys[0]?.length ?? 0, 300); + assert.isTrue(keys[0]?.endsWith("[TRUNCATED 99744 CHARS]")); + assert.equal(sanitized["after"], "kept"); + assert.isBelow(JSON.stringify(sanitized).length, 40_000); + }); + + it("stops emitting object entries once the size budget is exhausted", () => { + const value = Object.fromEntries( + Array.from({ length: 100 }, (_, index) => [`key-${index}-${"x".repeat(200)}`, "v"]), + ); + const sanitized = sanitizeHermesToolValue(value, { maxChars: 1_000 }) as Record< + string, + unknown + >; + assert.isBelow(JSON.stringify(sanitized).length, 2_000); + assert.isAbove(Number(sanitized["[TRUNCATED_KEYS]"]), 0); + }); + + it("stops emitting array entries once the size budget is exhausted", () => { + const value = Array.from({ length: 100 }, () => "y".repeat(200)); + const sanitized = sanitizeHermesToolValue(value, { maxChars: 1_000 }) as Array; + assert.isBelow(JSON.stringify(sanitized).length, 2_000); + assert.include(sanitized, "[TRUNCATED]"); + }); +}); diff --git a/apps/server/src/orchestration-v2/Adapters/HermesServeAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/HermesServeAdapterV2.ts new file mode 100644 index 00000000000..32154b04b2a --- /dev/null +++ b/apps/server/src/orchestration-v2/Adapters/HermesServeAdapterV2.ts @@ -0,0 +1,3681 @@ +import { + type ChatAttachment, + type HermesGatewayCompatibility, + type HermesGatewayApprovalRespondResult, + type HermesGatewayClarificationRespondResult, + type HermesGatewayHistoryMessage, + type HermesGatewayInterruptResult, + type HermesGatewayMutationStatusResult, + type HermesGatewayPromptSubmitParams, + type HermesGatewayPromptSubmitResult, + type HermesGatewaySessionCreateParams, + type HermesGatewaySessionCreateResult, + type HermesGatewaySessionBranchParams, + type HermesGatewaySessionBranchResult, + type HermesGatewaySessionHistoryResult, + type HermesGatewaySessionMcpLeaseResult, + type HermesGatewaySessionMcpParams, + type HermesGatewaySessionMcpRevokeResult, + type HermesGatewaySessionResumeParams, + type HermesGatewaySessionResumeResult, + type HermesGatewaySessionStatusResult, + type HermesGatewaySessionTitleParams, + type HermesGatewaySessionTitleResult, + HermesGatewayTitleChangedEventPayload, + type HermesGatewayToolEventPayload, + HermesSettings, + type OrchestrationV2ConversationMessage, + type OrchestrationV2ExecutionNode, + type OrchestrationV2ProviderCapabilities, + type OrchestrationV2ProviderRef, + type OrchestrationV2ProviderSession, + type OrchestrationV2ProviderThread, + type OrchestrationV2ProviderTurn, + type OrchestrationV2TurnItem, + type OrchestrationV2RuntimeRequest, + type ProviderApprovalDecision, + type ProviderUserInputAnswers, + ProviderDriverKind, + type ProviderInstanceEnvironment, + type ProviderInstanceId, +} from "@t3tools/contracts"; +import * as NodeCrypto from "node:crypto"; +import * as DateTime from "effect/DateTime"; +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Option from "effect/Option"; +import * as Queue from "effect/Queue"; +import * as Schema from "effect/Schema"; +import * as Stream from "effect/Stream"; +import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import { getModelSelectionStringOptionValue } from "@t3tools/shared/model"; + +import { resolveAttachmentPath } from "../../attachmentStore.ts"; +import { ServerConfig } from "../../config.ts"; +import * as McpProviderSession from "../../mcp/McpProviderSession.ts"; +import { + assessHermesConnectionSecurity, + HERMES_REMOTE_PAIRING_TOKEN_ENV, + HERMES_REMOTE_TLS_CERT_SHA256_ENV, +} from "../../hermes/HermesConnectionSecurity.ts"; +import { + HermesGatewayClient, + HermesGatewayMutationIndeterminateError, + HermesGatewayRpcError, + type HermesGatewayMutationOptions, + type HermesGatewayOrderedEvent, +} from "../../hermes/HermesGatewayClient.ts"; +import { + hydrateImportedHermesActivities, + normalizeImportedHermesUserText, + type HermesImportedActivity, +} from "../../hermes/HermesImportHydration.ts"; +import { + hermesHistoryMediaRoots, + normalizeHermesHistoryMessage, + parseHermesHistoryText, + persistHermesHistoryMedia, + type HermesHistoryMediaKind, +} from "../../hermes/HermesHistoryNormalization.ts"; +import { + resolveHermesServeEndpoint, + type HermesServeRuntimeShape, +} from "../../hermes/HermesServeRuntime.ts"; +import { + HermesSessionBindingRepository, + type HermesMutationIntent, + type HermesMutationIntentState, + type HermesOwnerLease, + type HermesSessionBinding, + type HermesSessionBindingRepositoryShape, +} from "../../hermes/HermesSessionBindingRepository.ts"; +import { IdAllocatorV2, type IdAllocatorV2Shape } from "../IdAllocator.ts"; +import { makeProviderFailure } from "../ProviderFailure.ts"; +import { + ProviderAdapterEnsureThreadError, + ProviderAdapterForkThreadError, + ProviderAdapterInterruptError, + ProviderAdapterOpenSessionError, + ProviderAdapterProtocolError, + ProviderAdapterReadThreadSnapshotError, + ProviderAdapterResumeThreadError, + ProviderAdapterRollbackThreadError, + ProviderAdapterRuntimeRequestResponseError, + ProviderAdapterSteerRunUnsupportedError, + ProviderAdapterTurnStartError, + type ProviderAdapterV2EnsureThreadInput, + type ProviderAdapterV2Event, + type ProviderAdapterV2OpenSessionInput, + type ProviderAdapterV2SessionRuntime, + type ProviderAdapterV2Shape, + type ProviderAdapterV2TurnInput, +} from "../ProviderAdapter.ts"; +import { + ProviderAdapterDriverCreateError, + type ProviderAdapterDriver, + type ProviderAdapterDriverCreateInput, +} from "../ProviderAdapterDriver.ts"; + +export const HERMES_PROVIDER = ProviderDriverKind.make("hermes"); +export const HERMES_DRIVER_KIND = HERMES_PROVIDER; + +const DEFAULT_HERMES_SETTINGS = Schema.decodeSync(HermesSettings)({}); + +const LEASE_MINUTES = 30; +const INTERRUPT_TERMINAL_TIMEOUT = "15 seconds"; + +/** + * Hermes may receive T3's provider-session credential only when the negotiated + * protocol advertises its ephemeral live-session MCP lease contract. This + * capability is intentionally absent from the legacy fallback inventory. + */ +export const HERMES_SESSION_MCP_REQUIRED_CAPABILITIES = ["session_mcp"] as const; + +export interface HermesMcpIntegrationDiagnostic { + readonly status: "ready" | "blocked_upstream"; + readonly missingCapabilities: ReadonlyArray; + readonly reason: string; +} + +export function diagnoseHermesMcpIntegration( + compatibility: HermesGatewayCompatibility, +): HermesMcpIntegrationDiagnostic { + const missingCapabilities = HERMES_SESSION_MCP_REQUIRED_CAPABILITIES.filter( + (capability) => !compatibility.capabilities.includes(capability), + ); + if (compatibility.status !== "supported" || missingCapabilities.length > 0) { + return { + status: "blocked_upstream", + missingCapabilities, + reason: + "Hermes MCP exposure is blocked: the negotiated gateway protocol does not advertise ephemeral per-session MCP leases.", + }; + } + return { + status: "ready", + missingCapabilities: [], + reason: "Hermes advertises ephemeral per-session MCP leases with replace and revoke support.", + }; +} + +export const HermesProviderCapabilitiesV2 = { + sessions: { + supportsMultipleProviderThreadsPerSession: false, + supportsModelSwitchInSession: false, + supportsProviderSwitchingViaHandoff: false, + supportsRuntimeModeSwitchInSession: false, + pendingRequestsSurviveRestart: false, + }, + threads: { + canCreateEmptyThread: true, + canReadThreadSnapshot: true, + canRollbackThread: false, + canForkThread: true, + canForkFromTurn: false, + canForkFromSubagentThread: false, + exposesNativeThreadId: true, + }, + turns: { + exposesNativeTurnId: false, + emitsTurnStarted: true, + emitsTurnCompleted: true, + supportsInterrupt: true, + supportsActiveSteering: false, + supportsSteeringByInterruptRestart: false, + supportsQueuedMessages: false, + terminalStatusQuality: "strong", + }, + streaming: { + streamsAssistantText: true, + streamsReasoning: true, + streamsToolOutput: false, + streamsPlanText: false, + emitsMessageCompleted: true, + }, + tools: { + exposesToolItemIds: true, + emitsToolStarted: true, + emitsToolCompleted: true, + emitsToolOutput: true, + supportsMcpTools: true, + supportsDynamicToolCallbacks: false, + }, + approvals: { + supportsCommandApproval: true, + supportsFileReadApproval: false, + supportsFileChangeApproval: false, + supportsApplyPatchApproval: false, + approvalsHaveNativeRequestIds: false, + approvalCallbacksAreLiveOnly: true, + approvalsCanOriginateFromSubagents: false, + }, + planning: { + emitsPlanUpdated: false, + emitsTodoList: false, + emitsProposedPlan: false, + supportsStructuredQuestions: true, + planDeltasHaveItemIds: false, + }, + subagents: { + supportsSubagents: false, + exposesSubagentThreadIds: false, + emitsSubagentLifecycle: false, + canWaitForSubagents: false, + canCloseSubagents: false, + canForkSubagentThread: false, + }, + context: { + acceptsSystemContext: false, + acceptsDeveloperContext: false, + acceptsSyntheticUserContext: false, + canGenerateSummaries: false, + canConsumeHandoffSummaries: false, + supportsDeltaHandoff: false, + supportsFullThreadHandoff: false, + maxRecommendedHandoffChars: null, + }, + checkpointing: { + appCanCheckpointFilesystem: false, + supportsNestedCheckpointScopes: false, + providerCanRollbackConversation: false, + providerRollbackReturnsSnapshot: false, + providerCanReadConversationSnapshot: true, + }, + identity: { + nativeThreadIds: "strong", + nativeTurnIds: "none", + nativeItemIds: "weak", + nativeRequestIds: "none", + }, +} satisfies OrchestrationV2ProviderCapabilities; + +export interface HermesGatewayClientLike { + readonly compatibility: HermesGatewayCompatibility | undefined; + connect(): Promise; + hasCapability(capability: string): boolean; + onEvent(listener: (event: HermesGatewayOrderedEvent) => void | Promise): () => void; + createSession( + params: HermesGatewaySessionCreateParams, + options: Omit, + ): Promise; + resumeSession( + params: HermesGatewaySessionResumeParams, + options: Omit, + ): Promise; + replaceSessionMcp( + params: HermesGatewaySessionMcpParams, + options: Omit, + ): Promise; + revokeSessionMcp( + sessionId: string, + options: Omit, + ): Promise; + readSessionStatus(params: { + readonly session_id: string; + readonly profile?: string; + }): Promise; + readSessionHistory(params: { + readonly session_id: string; + readonly profile?: string; + }): Promise; + readSessionTitle( + params: Pick, + ): Promise; + updateSessionTitle( + params: HermesGatewaySessionTitleParams & { readonly title: string }, + options: Omit, + ): Promise; + branchSession( + params: HermesGatewaySessionBranchParams, + options: Omit, + ): Promise; + reconcileMutation( + operationId: string, + mutationId?: string, + ): Promise; + submitPrompt( + params: HermesGatewayPromptSubmitParams, + options: Omit, + ): Promise; + attachImageBytes( + params: { + readonly session_id: string; + readonly content_base64: string; + readonly filename?: string; + }, + options: Omit, + ): Promise; + respondToApproval( + params: { readonly session_id: string; readonly choice: "once" | "session" | "deny" }, + options: Omit, + ): Promise; + respondToClarification( + params: { readonly request_id: string; readonly answer: string }, + options: Omit, + ): Promise; + attachFile( + params: { + readonly session_id: string; + readonly name: string; + readonly data_url: string; + }, + options: Omit, + ): Promise; + attachPdf( + params: { + readonly session_id: string; + readonly filename: string; + readonly content_base64: string; + }, + options: Omit, + ): Promise; + interruptSession( + params: { readonly session_id: string }, + options: Omit, + ): Promise; + close(): void; +} + +export interface HermesServeAdapterV2Options { + readonly instanceId: ProviderInstanceId; + readonly settings: HermesSettings; + readonly enabled: boolean; + readonly authToken: string | undefined; + readonly remotePairingToken?: string | undefined; + readonly remoteTlsCertificateSha256?: string | undefined; + readonly connectionRuntime?: HermesServeRuntimeShape | undefined; + readonly idAllocator: IdAllocatorV2Shape; + readonly repository: HermesSessionBindingRepositoryShape; + readonly readAttachment?: ( + attachment: ChatAttachment, + ) => Effect.Effect; + readonly resolveHistoryMedia?: (input: { + readonly sourcePath: string; + readonly expectedKind: HermesHistoryMediaKind; + readonly threadId: string; + readonly stableKey: string; + }) => Effect.Effect; + readonly clientFactory?: (input: { + readonly endpoint: string; + readonly authToken: string; + }) => HermesGatewayClientLike; +} + +export function resolveHermesGatewayToken( + environment: ProviderInstanceEnvironment, +): string | undefined { + return environment.find( + (variable) => + variable.name === "HERMES_GATEWAY_TOKEN" && + variable.sensitive && + variable.value.trim().length > 0, + )?.value; +} + +export function resolveHermesRemotePairingToken( + environment: ProviderInstanceEnvironment, +): string | undefined { + return resolveSensitiveHermesEnvironment(environment, HERMES_REMOTE_PAIRING_TOKEN_ENV); +} + +export function resolveHermesRemoteTlsCertificateSha256( + environment: ProviderInstanceEnvironment, +): string | undefined { + return resolveSensitiveHermesEnvironment(environment, HERMES_REMOTE_TLS_CERT_SHA256_ENV); +} + +function resolveSensitiveHermesEnvironment( + environment: ProviderInstanceEnvironment, + name: string, +): string | undefined { + return environment.find( + (variable) => variable.name === name && variable.sensitive && variable.value.trim().length > 0, + )?.value; +} + +interface ActiveHermesTurn { + readonly input: ProviderAdapterV2TurnInput; + readonly operationId: string; + readonly completion: Deferred.Deferred; + readonly bufferedEvents: Array; + providerTurn: OrchestrationV2ProviderTurn | null; + assistantNativeId: string | null; + assistantText: string; + // Set when assistantText came from a full-message snapshot (start/interim) + // so a subsequent delta stream, which re-streams the message from its + // beginning, replaces the snapshot instead of appending to it. + assistantSnapshotPending: boolean; + assistantStartedAt: DateTime.Utc | null; + reasoningText: string; + reasoningStartedAt: DateTime.Utc | null; + reasoningHasStreamedDelta: boolean; + readonly itemOrdinals: Map; + nextItemOrdinal: number; + readonly toolsByIdentity: Map; + readonly toolsByNativeId: Map; + readonly generatingToolsByName: Map>; + readonly seenEventIds: Set; + gatewayRunId: string | null; + interrupted: boolean; + finalized: boolean; + intentState: HermesMutationIntentState; +} + +interface ActiveHermesTool { + readonly identity: string; + nativeToolId: string | null; + name: string | null; + input: unknown; + output: unknown | undefined; + title: string | null; + status: OrchestrationV2TurnItem["status"]; + startedAt: DateTime.Utc | null; + completedAt: DateTime.Utc | null; +} + +interface HermesThreadState { + readonly binding: HermesSessionBinding; + readonly liveSessionId: string; + lease: HermesOwnerLease; + titleRevision: number; + title: string | null; + providerThread: OrchestrationV2ProviderThread; + activeTurn: ActiveHermesTurn | null; + externalRunActive: boolean; + ownershipLost: boolean; + readonly turns: Map; + readonly messages: Map; + readonly importedTurnItems: Map; + readonly runtimeRequests: Map; +} + +interface PendingHermesRuntimeRequest { + readonly request: OrchestrationV2RuntimeRequest; + readonly state: HermesThreadState; + readonly active: ActiveHermesTurn; + readonly nodeId: OrchestrationV2ExecutionNode["id"]; + readonly turnItemId: OrchestrationV2TurnItem["id"]; + readonly nativeIdentity: string; + readonly kind: "approval" | "clarification"; + readonly prompt: string; + readonly questionId?: string; + readonly choices?: ReadonlyArray; + readonly nativeRequestId?: string; +} + +type HermesMutationFence = Pick; + +const sha256 = (value: string): string => + NodeCrypto.createHash("sha256").update(value).digest("hex"); +const stableDigest = (...parts: ReadonlyArray): string => sha256(JSON.stringify(parts)); +export const hermesWireMutationId = (operationId: string): string => `t3_${sha256(operationId)}`; + +const providerRef = ( + nativeId: string, + strength: OrchestrationV2ProviderRef["strength"] = "strong", +): OrchestrationV2ProviderRef => ({ + driver: HERMES_PROVIDER, + nativeId, + strength, +}); + +class HermesGatewayCallError extends Schema.TaggedErrorClass()( + "HermesGatewayCallError", + { + cause: Schema.Defect(), + }, +) {} +const isHermesGatewayCallError = Schema.is(HermesGatewayCallError); + +/** + * The Hermes gateway no longer stores the durable session behind an imported + * binding (session.resume error 4007). The imported transcript that T3 + * projected at import time remains readable; only live resumption is gone. + */ +export class HermesImportedSessionUnavailableError extends Schema.TaggedErrorClass()( + "HermesImportedSessionUnavailableError", + { + storedSessionKey: Schema.String, + profileKey: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Hermes no longer stores session ${this.storedSessionKey} in profile ${this.profileKey} (session.resume reported 4007: session not found). The imported conversation stays available read-only in T3.`; + } +} + +const HERMES_SESSION_NOT_FOUND_CODE = 4007; + +const isStoredSessionNotFound = (cause: unknown): boolean => + isHermesGatewayCallError(cause) && + cause.cause instanceof HermesGatewayRpcError && + cause.cause.method === "session.resume" && + cause.cause.code === HERMES_SESSION_NOT_FOUND_CODE; +const isProviderAdapterRuntimeRequestResponseError = Schema.is( + ProviderAdapterRuntimeRequestResponseError, +); + +const gatewayEffect = (operation: () => Promise) => + Effect.tryPromise({ + try: operation, + catch: (cause) => new HermesGatewayCallError({ cause }), + }); + +const isIndeterminateGatewayCall = (cause: unknown): boolean => + isHermesGatewayCallError(cause) && cause.cause instanceof HermesGatewayMutationIndeterminateError; + +const decodeTitleChanged = Schema.decodeUnknownSync(HermesGatewayTitleChangedEventPayload); + +export function hermesModelOverride(model: string): { readonly model?: string } { + return model === "default" ? {} : { model }; +} + +export function hermesReasoningOverride( + modelSelection: ProviderAdapterV2EnsureThreadInput["modelSelection"], +): { readonly reasoning_effort?: string } { + const effort = getModelSelectionStringOptionValue(modelSelection, "reasoningEffort"); + return effort === undefined ? {} : { reasoning_effort: effort }; +} + +export function hermesFastOverride( + modelSelection: ProviderAdapterV2EnsureThreadInput["modelSelection"], +): { readonly fast?: boolean } { + const fast = getModelSelectionStringOptionValue(modelSelection, "fast"); + return fast === undefined ? {} : { fast: fast === "fast" }; +} + +export function hermesApprovalChoice( + decision: ProviderApprovalDecision, +): "once" | "session" | "deny" { + if (decision === "accept") return "once"; + if (decision === "acceptForSession") return "session"; + return "deny"; +} + +export function hermesClarificationAnswer( + answers: ProviderUserInputAnswers, + questionId: string, +): string | undefined { + const value = answers[questionId] ?? Object.values(answers)[0]; + if (typeof value === "string") return value.trim() || undefined; + if (Array.isArray(value)) { + const values = value.filter((entry): entry is string => typeof entry === "string"); + return values.length === 0 ? undefined : values.join(", "); + } + return undefined; +} + +function compatibilityFields(compatibility: HermesGatewayCompatibility) { + return { + protocolClassification: compatibility.status, + protocolMajor: compatibility.protocol?.major ?? null, + protocolMinor: compatibility.protocol?.minor ?? null, + capabilities: compatibility.capabilities, + } as const; +} + +function leaseTimes() { + const nowValue = DateTime.nowUnsafe(); + return { + now: DateTime.formatIso(nowValue), + expiresAt: DateTime.formatIso(DateTime.add(nowValue, { minutes: LEASE_MINUTES })), + }; +} + +function eventText(event: HermesGatewayOrderedEvent): string { + const payload = event.frame.params.payload; + if (typeof payload !== "object" || payload === null) return ""; + const text = (payload as { readonly text?: unknown }).text; + return typeof text === "string" ? text : ""; +} + +function eventStatus(event: HermesGatewayOrderedEvent): string | undefined { + const payload = event.frame.params.payload; + if (typeof payload !== "object" || payload === null) return undefined; + const status = (payload as { readonly status?: unknown }).status; + return typeof status === "string" ? status.toLowerCase() : undefined; +} + +function eventMessageId(event: HermesGatewayOrderedEvent): string | undefined { + if (event.messageId) return event.messageId; + const payload = event.frame.params.payload; + if (typeof payload !== "object" || payload === null) return undefined; + const messageId = (payload as { readonly message_id?: unknown }).message_id; + return typeof messageId === "string" && messageId.length > 0 ? messageId : undefined; +} + +function eventToolPayload(event: HermesGatewayOrderedEvent): HermesGatewayToolEventPayload { + const payload = event.frame.params.payload; + return typeof payload === "object" && payload !== null + ? (payload as HermesGatewayToolEventPayload) + : {}; +} + +function nonEmptyString(value: unknown): string | undefined { + return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined; +} + +export function hermesAttachmentRefText(result: unknown): string | null { + if (result === null || typeof result !== "object" || Array.isArray(result)) return null; + const row = result as Readonly>; + return nonEmptyString(row.ref_text) ?? nonEmptyString(row.refText) ?? null; +} + +function sensitiveToolKey(key: string): boolean { + const normalized = key.replace(/[^a-z0-9]/giu, "").toLowerCase(); + return ( + normalized.endsWith("authorization") || + normalized.endsWith("apikey") || + normalized.endsWith("accesstoken") || + normalized.endsWith("refreshtoken") || + normalized.endsWith("password") || + normalized.endsWith("passwd") || + normalized.endsWith("secret") || + normalized.endsWith("credential") || + normalized.endsWith("privatekey") || + normalized.endsWith("accesskey") || + normalized === "cookie" || + normalized === "setcookie" || + normalized === "token" + ); +} + +function redactToolString(value: string): string { + return value + .replace( + /\b(Bearer|Basic|Digest|Negotiate|NTLM|AWS4-HMAC-SHA256)\s+[A-Za-z0-9._~+/=,-]+/giu, + "$1 [REDACTED]", + ) + .replace( + /([?&](?:access_token|refresh_token|id_token|client_secret|client_assertion|api_key|apikey|password|secret|token)=)[^&#\s]*/giu, + "$1[REDACTED]", + ) + .replace( + /\b((?:api[_-]?key|access[_-]?token|refresh[_-]?token|id[_-]?token|client[_-]?secret|client[_-]?assertion|password|secret|token)\s*[=:]\s*)([^\s,;]+)/giu, + "$1[REDACTED]", + ); +} + +/** + * Hermes complete events contain the raw tool arguments/result even when the + * optional verbose text is already redacted. Bound and redact those values + * before they enter durable orchestration projections. + */ +export function sanitizeHermesToolValue( + value: unknown, + options: { readonly maxChars?: number } = {}, +): unknown { + const maxChars = options.maxChars ?? 40_000; + let remaining = maxChars; + const seen = new WeakSet(); + const visit = (current: unknown, depth: number): unknown => { + if (remaining <= 0) return "[TRUNCATED]"; + if (typeof current === "string") { + const redacted = redactToolString(current); + const limit = Math.min(remaining, 16_000); + remaining -= Math.min(redacted.length, limit); + return redacted.length <= limit ? redacted : `${redacted.slice(0, limit)}… [TRUNCATED]`; + } + if (current === null || typeof current === "boolean" || typeof current === "number") { + remaining -= 8; + return current; + } + if (current === undefined) return undefined; + if (depth >= 8) return "[MAX_DEPTH]"; + if (typeof current !== "object") return String(current); + if (seen.has(current)) return "[CIRCULAR]"; + seen.add(current); + if (Array.isArray(current)) { + const values: Array = []; + for (const entry of current.slice(0, 100)) { + if (remaining <= 0) { + values.push("[TRUNCATED]"); + break; + } + values.push(visit(entry, depth + 1)); + } + if (current.length > values.length) values.push(`[${current.length - values.length} MORE]`); + return values; + } + const entries = Object.entries(current).slice(0, 100); + const result: Record = {}; + let visited = 0; + for (const [key, nested] of entries) { + if (remaining <= 0) break; + const boundedKey = + key.length <= 256 ? key : `${key.slice(0, 256)}… [TRUNCATED ${key.length - 256} CHARS]`; + remaining -= boundedKey.length; + result[boundedKey] = sensitiveToolKey(key) ? "[REDACTED]" : visit(nested, depth + 1); + visited += 1; + } + const omittedKeys = Object.keys(current).length - visited; + if (omittedKeys > 0) { + result["[TRUNCATED_KEYS]"] = omittedKeys; + } + return result; + }; + return visit(value, 0); +} + +function toolTerminalStatus( + payload: HermesGatewayToolEventPayload, +): OrchestrationV2TurnItem["status"] { + const explicit = nonEmptyString(payload.status)?.toLowerCase(); + if (explicit?.includes("interrupt")) return "interrupted"; + if (explicit?.includes("cancel")) return "cancelled"; + if (explicit?.includes("fail") || explicit?.includes("error")) return "failed"; + const result = + typeof payload.result === "object" && payload.result !== null + ? (payload.result as Record) + : undefined; + if ( + result?.success === false || + result?.ok === false || + (typeof result?.exit_code === "number" && result.exit_code !== 0) || + (typeof result?.returncode === "number" && result.returncode !== 0) + ) { + return "failed"; + } + if (typeof payload.result === "string") { + const normalized = payload.result.trim(); + if ( + /^Error executing tool\b/iu.test(normalized) || + /^Tool execution failed\b/iu.test(normalized) || + /\[Command timed out after \d+s\]\s*$/iu.test(normalized) + ) { + return "failed"; + } + } + return "completed"; +} + +function hasRecoveredWork(value: unknown): boolean { + if (value === undefined || value === null || value === false) return false; + if (Array.isArray(value)) return value.length > 0; + if (typeof value === "object") return Object.keys(value).length > 0; + if (typeof value === "string") return value.trim().length > 0; + return true; +} + +function isActiveHermesStatus(status: string): boolean { + const normalized = status.trim().toLowerCase(); + return ["active", "running", "streaming", "queued", "inflight", "busy"].some((value) => + normalized.includes(value), + ); +} + +function isTerminalHermesStatus(status: string): boolean { + const normalized = status.trim().toLowerCase(); + return ["idle", "complete", "completed", "interrupted", "error", "failed"].some((value) => + normalized.includes(value), + ); +} + +export function hermesSessionRuntimeStatus(status: HermesGatewaySessionStatusResult): string { + const runningLine = /^Agent Running:\s*(Yes|No)\s*$/imu.exec(status.output); + if (runningLine?.[1]?.toLowerCase() === "yes") return "running"; + if (runningLine?.[1]?.toLowerCase() === "no") return "idle"; + return status.output; +} + +function historyRole( + role: HermesGatewayHistoryMessage["role"], +): OrchestrationV2ConversationMessage["role"] { + if (role === "user" || role === "assistant" || role === "system") return role; + return "system"; +} + +export function makeHermesServeAdapterV2( + options: HermesServeAdapterV2Options, +): ProviderAdapterV2Shape { + const configuredCapabilities: OrchestrationV2ProviderCapabilities = { + ...HermesProviderCapabilitiesV2, + tools: { + ...HermesProviderCapabilitiesV2.tools, + supportsMcpTools: options.settings.mcpEnabled, + }, + }; + const makeClient = + options.clientFactory ?? + ((input) => + new HermesGatewayClient({ + endpoint: input.endpoint, + authToken: input.authToken, + criticalCapabilities: [ + "session.lifecycle", + "session.history", + "turn.prompt", + "turn.interrupt", + "events.tools", + ], + })); + + return { + instanceId: options.instanceId, + driver: HERMES_PROVIDER, + getCapabilities: () => Effect.succeed(configuredCapabilities), + planSelectionTransition: (input) => + Effect.succeed( + input.current.model === input.target.model + ? ({ type: "apply_on_next_turn" } as const) + : ({ + type: "reject", + reason: "Hermes model switching requires a new provider thread.", + } as const), + ), + openSession: Effect.fn("HermesServeAdapterV2.openSession")(function* ( + input: ProviderAdapterV2OpenSessionInput, + ) { + if (!options.enabled) { + return yield* new ProviderAdapterOpenSessionError({ + driver: HERMES_PROVIDER, + providerSessionId: input.providerSessionId, + cause: new Error("Hermes requires both enableHermes and an enabled provider instance."), + }); + } + if (!options.settings.profileKey.trim()) { + return yield* new ProviderAdapterOpenSessionError({ + driver: HERMES_PROVIDER, + providerSessionId: input.providerSessionId, + cause: new Error("Hermes profileKey must be configured."), + }); + } + let endpoint = resolveHermesServeEndpoint(options.settings.endpoint); + let authToken = options.authToken; + if (options.connectionRuntime !== undefined) { + const resolved = yield* options.connectionRuntime.ensureReady.pipe( + Effect.mapError( + (cause) => + new ProviderAdapterOpenSessionError({ + driver: HERMES_PROVIDER, + providerSessionId: input.providerSessionId, + cause, + }), + ), + ); + endpoint = resolved.endpoint; + authToken = resolved.authToken; + } + const connectionSecurity = assessHermesConnectionSecurity({ + endpoint, + gatewayToken: authToken, + remoteGloballyEnabled: options.enabled, + remoteInstanceEnabled: options.settings.remoteAccessEnabled, + remotePairingToken: options.remotePairingToken, + remoteTlsCertificateSha256: options.remoteTlsCertificateSha256, + }); + if (connectionSecurity.status !== "ready") { + return yield* new ProviderAdapterOpenSessionError({ + driver: HERMES_PROVIDER, + providerSessionId: input.providerSessionId, + cause: new Error(connectionSecurity.message), + }); + } + + const client = makeClient({ + endpoint: connectionSecurity.endpoint, + authToken: connectionSecurity.authToken, + }); + const compatibility = yield* gatewayEffect(() => client.connect()).pipe( + Effect.tapError(() => Effect.sync(() => client.close())), + Effect.mapError( + (cause) => + new ProviderAdapterOpenSessionError({ + driver: HERMES_PROVIDER, + providerSessionId: input.providerSessionId, + cause, + }), + ), + ); + const mcpDiagnostic = diagnoseHermesMcpIntegration(compatibility); + const mcpAvailable = options.settings.mcpEnabled && mcpDiagnostic.status === "ready"; + if (options.settings.mcpEnabled && !mcpAvailable) { + yield* Effect.logWarning("hermes.mcp-integration-blocked", { + providerSessionId: input.providerSessionId, + providerInstanceId: options.instanceId, + status: mcpDiagnostic.status, + missingCapabilities: mcpDiagnostic.missingCapabilities, + reason: mcpDiagnostic.reason, + }); + } + const events = yield* Queue.unbounded(); + const runtimeContext = yield* Effect.context(); + const runPromise = Effect.runPromiseWith(runtimeContext); + const statesByProviderThread = new Map(); + const statesByLiveSession = new Map(); + const mcpCredentialByLiveSession = new Map(); + const pendingRuntimeRequests = new Map(); + const stateForProviderThread = ( + providerThread: OrchestrationV2ProviderThread, + ): HermesThreadState | undefined => { + const exact = statesByProviderThread.get(String(providerThread.id)); + if (exact !== undefined) return exact; + const nativeThreadId = providerThread.nativeThreadRef?.nativeId; + if (nativeThreadId === undefined) return undefined; + return [...statesByProviderThread.values()].find( + (candidate) => candidate.providerThread.nativeThreadRef?.nativeId === nativeThreadId, + ); + }; + const alignStateProviderThread = ( + state: HermesThreadState, + projectedThread: OrchestrationV2ProviderThread, + ): OrchestrationV2ProviderThread => { + const previousId = String(state.providerThread.id); + state.providerThread = { + ...state.providerThread, + id: projectedThread.id, + providerSessionId: projectedThread.providerSessionId, + appThreadId: projectedThread.appThreadId, + ownerNodeId: projectedThread.ownerNodeId, + firstRunOrdinal: projectedThread.firstRunOrdinal ?? state.providerThread.firstRunOrdinal, + lastRunOrdinal: projectedThread.lastRunOrdinal ?? state.providerThread.lastRunOrdinal, + handoffIds: projectedThread.handoffIds, + forkedFrom: projectedThread.forkedFrom, + }; + if (previousId !== String(projectedThread.id)) { + statesByProviderThread.delete(previousId); + } + statesByProviderThread.set(String(projectedThread.id), state); + return state.providerThread; + }; + const providerCapabilities: OrchestrationV2ProviderCapabilities = { + ...HermesProviderCapabilitiesV2, + tools: { + ...HermesProviderCapabilitiesV2.tools, + supportsMcpTools: mcpAvailable, + }, + }; + let providerSession: OrchestrationV2ProviderSession = { + id: input.providerSessionId, + driver: HERMES_PROVIDER, + providerInstanceId: options.instanceId, + status: "ready" as const, + cwd: input.runtimePolicy.cwd ?? process.cwd(), + model: input.modelSelection.model, + capabilities: providerCapabilities, + createdAt: yield* DateTime.now, + updatedAt: yield* DateTime.now, + lastError: null, + }; + + const emit = (event: ProviderAdapterV2Event) => + Queue.offer(events, event).pipe(Effect.asVoid); + const reconcileTitle = Effect.fnUntraced(function* ( + state: HermesThreadState, + titleState: HermesGatewaySessionTitleResult, + ) { + const title = titleState.title?.trim(); + const origin = titleState.origin.trim(); + if (!title || !origin) return; + if (titleState.revision <= state.titleRevision) { + if (titleState.revision === state.titleRevision) state.title = title; + return; + } + const { now } = leaseTimes(); + const updated = yield* options.repository.updateTitleState({ + bindingId: state.binding.bindingId, + ownerKey: state.lease.ownerKey, + generation: state.lease.generation, + now, + revision: titleState.revision, + origin, + }); + if (!updated) return; + state.titleRevision = titleState.revision; + state.title = title; + yield* emit({ + type: "app_thread.title_reconciled", + driver: HERMES_PROVIDER, + threadId: state.providerThread.appThreadId ?? input.threadId, + title, + revision: titleState.revision, + origin, + }); + }); + const updateSession = ( + status: OrchestrationV2ProviderSession["status"], + lastError: string | null = providerSession.lastError, + ) => + Effect.gen(function* () { + providerSession = { + ...providerSession, + status, + lastError, + updatedAt: yield* DateTime.now, + }; + yield* emit({ + type: "provider_session.updated", + driver: HERMES_PROVIDER, + providerSession, + }); + }); + + const updateThread = ( + state: HermesThreadState, + patch: Partial, + ) => + Effect.gen(function* () { + state.providerThread = { + ...state.providerThread, + ...patch, + updatedAt: yield* DateTime.now, + }; + yield* emit({ + type: "provider_thread.updated", + driver: HERMES_PROVIDER, + providerThread: state.providerThread, + }); + }); + + const transitionIntent = ( + state: HermesMutationFence, + operationId: string, + from: HermesMutationIntentState, + to: HermesMutationIntentState, + ) => { + const { now } = leaseTimes(); + return options.repository + .transitionMutationIntent({ + bindingId: state.binding.bindingId, + ownerKey: state.lease.ownerKey, + generation: state.lease.generation, + now, + operationId, + from, + to, + }) + .pipe( + Effect.flatMap((changed) => + changed + ? Effect.void + : new ProviderAdapterProtocolError({ + driver: HERMES_PROVIDER, + detail: `Hermes mutation intent ${operationId} lost its lease fence`, + }), + ), + ); + }; + + const reconcileIntent = Effect.fnUntraced(function* ( + intent: HermesMutationIntent, + expected: { + readonly bindingId: string | null; + readonly mutationKind: string; + readonly method: string; + readonly payloadDigest: string; + }, + ) { + if ( + intent.bindingId !== expected.bindingId || + intent.mutationKind !== expected.mutationKind || + intent.method !== expected.method || + intent.payloadDigest !== expected.payloadDigest + ) { + return yield* new ProviderAdapterProtocolError({ + driver: HERMES_PROVIDER, + detail: `Hermes mutation ${intent.operationId} conflicts with its durable intent`, + }); + } + if ( + intent.state === "confirmed" || + intent.state === "reconciled" || + intent.state === "rejected" + ) { + return yield* new ProviderAdapterProtocolError({ + driver: HERMES_PROVIDER, + detail: `Hermes mutation ${intent.operationId} is already terminal (${intent.state})`, + }); + } + if (!client.hasCapability("mutation.stable_ids")) { + return { mutation_status: "admitted" } as const; + } + return yield* gatewayEffect(() => + client.reconcileMutation(intent.operationId, hermesWireMutationId(intent.operationId)), + ); + }); + + const prepareBoundMutation = Effect.fnUntraced(function* ( + state: HermesMutationFence, + input: { + readonly operationId: string; + readonly mutationKind: string; + readonly method: string; + readonly payloadDigest: string; + }, + ) { + const { now, expiresAt } = leaseTimes(); + const renewed = yield* options.repository.renewOwnerLease({ + bindingId: state.binding.bindingId, + ownerKey: state.lease.ownerKey, + generation: state.lease.generation, + now, + expiresAt, + }); + if (!renewed) { + return yield* new ProviderAdapterProtocolError({ + driver: HERMES_PROVIDER, + detail: "Hermes owner lease is no longer held", + }); + } + const result = yield* options.repository.prepareMutationIntent({ + ...input, + bindingId: state.binding.bindingId, + ownerKey: state.lease.ownerKey, + generation: state.lease.generation, + now, + }); + if (result.status === "prepared") { + yield* transitionIntent(state, input.operationId, "prepared", "admitted"); + return { + replay: false, + intentState: "admitted" as HermesMutationIntentState, + }; + } + if (result.status === "operation_exists") { + const outcome = yield* reconcileIntent(result.intent, { + bindingId: state.binding.bindingId, + mutationKind: input.mutationKind, + method: input.method, + payloadDigest: input.payloadDigest, + }); + if (outcome.mutation_status === "indeterminate") { + if (result.intent.state === "prepared" || result.intent.state === "admitted") { + yield* transitionIntent( + state, + input.operationId, + result.intent.state, + "indeterminate", + ); + } + return yield* new ProviderAdapterProtocolError({ + driver: HERMES_PROVIDER, + detail: `Hermes mutation ${input.operationId} remains indeterminate`, + }); + } + return { + replay: true, + intentState: result.intent.state, + }; + } + { + return yield* new ProviderAdapterProtocolError({ + driver: HERMES_PROVIDER, + detail: + result.status === "unsettled_prompt" + ? `Hermes prompt ${result.operationId} is still unsettled` + : `Hermes mutation ${input.operationId} cannot be safely resubmitted (${result.status})`, + }); + } + }); + + const mutationOptions = (operationId: string) => ({ + operationId, + ...(client.hasCapability("mutation.stable_ids") + ? { mutationId: hermesWireMutationId(operationId) } + : {}), + }); + + const settleMutation = ( + state: HermesMutationFence, + operationId: string, + operation: () => Promise, + ) => + gatewayEffect(operation).pipe( + Effect.tap(() => transitionIntent(state, operationId, "admitted", "confirmed")), + Effect.tapError((cause) => + transitionIntent( + state, + operationId, + "admitted", + isIndeterminateGatewayCall(cause) ? "indeterminate" : "rejected", + ).pipe(Effect.ignore), + ), + ); + + const resolveItemOrdinal = (active: ActiveHermesTurn, identity: string): number => { + const existing = active.itemOrdinals.get(identity); + if (existing !== undefined) return existing; + const ordinal = active.nextItemOrdinal++; + active.itemOrdinals.set(identity, ordinal); + return ordinal; + }; + + const toolArtifacts = Effect.fnUntraced(function* ( + state: HermesThreadState, + active: ActiveHermesTurn, + tool: ActiveHermesTool, + ) { + const turn = active.providerTurn; + if (turn === null) return; + const now = yield* DateTime.now; + const nativeItemRef = + tool.nativeToolId === null + ? providerRef(tool.identity, "weak") + : providerRef(tool.nativeToolId); + const nodeId = options.idAllocator.derive.nodeFromProviderItem({ + driver: HERMES_PROVIDER, + nativeItemId: tool.identity, + }); + const turnItemId = options.idAllocator.derive.turnItemFromProviderItem({ + driver: HERMES_PROVIDER, + nativeItemId: tool.identity, + }); + const nodeStatus: OrchestrationV2ExecutionNode["status"] = + tool.status === "pending" || + tool.status === "running" || + tool.status === "waiting" || + tool.status === "completed" || + tool.status === "interrupted" || + tool.status === "failed" || + tool.status === "cancelled" + ? tool.status + : "running"; + yield* emit({ + type: "node.updated", + driver: HERMES_PROVIDER, + node: { + id: nodeId, + threadId: active.input.threadId, + runId: active.input.runId, + parentNodeId: active.input.rootNodeId, + rootNodeId: active.input.rootNodeId, + kind: "tool_call", + status: nodeStatus, + countsForRun: false, + providerThreadId: state.providerThread.id, + providerTurnId: turn.id, + nativeItemRef, + runtimeRequestId: null, + checkpointScopeId: null, + startedAt: tool.startedAt, + completedAt: tool.completedAt, + }, + }); + const turnItem: OrchestrationV2TurnItem = { + id: turnItemId, + threadId: active.input.threadId, + runId: active.input.runId, + nodeId, + providerThreadId: state.providerThread.id, + providerTurnId: turn.id, + nativeItemRef, + parentItemId: null, + ordinal: resolveItemOrdinal(active, tool.identity), + status: tool.status, + title: tool.title, + startedAt: tool.startedAt, + completedAt: tool.completedAt, + updatedAt: now, + type: "dynamic_tool", + toolName: tool.name, + input: tool.input, + ...(tool.output === undefined ? {} : { output: tool.output }), + }; + yield* emit({ type: "turn_item.updated", driver: HERMES_PROVIDER, turnItem }); + }); + + const messageArtifacts = Effect.fnUntraced(function* ( + state: HermesThreadState, + active: ActiveHermesTurn, + completed: boolean, + ) { + const turn = active.providerTurn; + if (turn === null) return; + const now = yield* DateTime.now; + const nativeMessageId = + active.assistantNativeId ?? + stableDigest(state.binding.storedSessionKey, active.operationId, "assistant"); + const messageId = options.idAllocator.derive.messageFromProviderItem({ + driver: HERMES_PROVIDER, + nativeItemId: nativeMessageId, + }); + const nodeId = options.idAllocator.derive.nodeFromProviderItem({ + driver: HERMES_PROVIDER, + nativeItemId: nativeMessageId, + }); + const turnItemId = options.idAllocator.derive.turnItemFromProviderItem({ + driver: HERMES_PROVIDER, + nativeItemId: nativeMessageId, + }); + const startedAt = active.assistantStartedAt ?? now; + active.assistantStartedAt = startedAt; + const normalizedAssistant = completed + ? yield* normalizeHermesHistoryMessage({ + role: "assistant", + text: active.assistantText, + resolveMedia: (media, index) => + options.resolveHistoryMedia?.({ + sourcePath: media.path, + expectedKind: media.kind, + threadId: String(active.input.threadId), + stableKey: `${state.binding.providerInstanceId}:${state.binding.profileKey}:${state.binding.storedSessionKey}:${nativeMessageId}:${index}`, + }) ?? Effect.succeed(null), + }) + : { + text: parseHermesHistoryText({ + role: "assistant", + text: active.assistantText, + }).text, + attachments: [], + }; + const message: OrchestrationV2ConversationMessage = { + createdBy: "agent", + creationSource: "provider", + id: messageId, + threadId: active.input.threadId, + runId: active.input.runId, + nodeId, + role: "assistant", + text: normalizedAssistant.text, + attachments: normalizedAssistant.attachments, + streaming: !completed, + createdAt: startedAt, + updatedAt: now, + }; + state.messages.set(String(messageId), message); + yield* emit({ + type: "node.updated", + driver: HERMES_PROVIDER, + node: { + id: nodeId, + threadId: active.input.threadId, + runId: active.input.runId, + parentNodeId: active.input.rootNodeId, + rootNodeId: active.input.rootNodeId, + kind: "assistant_message", + status: completed ? "completed" : "running", + countsForRun: false, + providerThreadId: state.providerThread.id, + providerTurnId: turn.id, + nativeItemRef: providerRef(nativeMessageId), + runtimeRequestId: null, + checkpointScopeId: null, + startedAt, + completedAt: completed ? now : null, + }, + }); + yield* emit({ type: "message.updated", driver: HERMES_PROVIDER, message }); + const turnItem: OrchestrationV2TurnItem = { + id: turnItemId, + threadId: active.input.threadId, + runId: active.input.runId, + nodeId, + providerThreadId: state.providerThread.id, + providerTurnId: turn.id, + nativeItemRef: providerRef(nativeMessageId), + parentItemId: null, + ordinal: resolveItemOrdinal(active, `assistant:${nativeMessageId}`), + status: completed ? "completed" : "running", + title: null, + startedAt, + completedAt: completed ? now : null, + updatedAt: now, + type: "assistant_message", + messageId, + text: normalizedAssistant.text, + streaming: !completed, + }; + yield* emit({ type: "turn_item.updated", driver: HERMES_PROVIDER, turnItem }); + }); + + const reasoningArtifacts = Effect.fnUntraced(function* ( + state: HermesThreadState, + active: ActiveHermesTurn, + completed: boolean, + ) { + const turn = active.providerTurn; + if (turn === null || active.reasoningText.length === 0) return; + const now = yield* DateTime.now; + active.reasoningStartedAt ??= now; + const nativeIdentity = `hermes-reasoning:${active.gatewayRunId ?? active.operationId}`; + const nativeRef = providerRef(nativeIdentity, "weak"); + const nodeId = options.idAllocator.derive.nodeFromProviderItem({ + driver: HERMES_PROVIDER, + nativeItemId: nativeIdentity, + }); + yield* emit({ + type: "node.updated", + driver: HERMES_PROVIDER, + node: { + id: nodeId, + threadId: active.input.threadId, + runId: active.input.runId, + parentNodeId: active.input.rootNodeId, + rootNodeId: active.input.rootNodeId, + kind: "reasoning", + status: completed ? "completed" : "running", + countsForRun: false, + providerThreadId: state.providerThread.id, + providerTurnId: turn.id, + nativeItemRef: nativeRef, + runtimeRequestId: null, + checkpointScopeId: null, + startedAt: active.reasoningStartedAt, + completedAt: completed ? now : null, + }, + }); + yield* emit({ + type: "turn_item.updated", + driver: HERMES_PROVIDER, + turnItem: { + id: options.idAllocator.derive.turnItemFromProviderItem({ + driver: HERMES_PROVIDER, + nativeItemId: nativeIdentity, + }), + threadId: active.input.threadId, + runId: active.input.runId, + nodeId, + providerThreadId: state.providerThread.id, + providerTurnId: turn.id, + nativeItemRef: nativeRef, + parentItemId: null, + ordinal: resolveItemOrdinal(active, nativeIdentity), + status: completed ? "completed" : "running", + title: "Reasoning", + startedAt: active.reasoningStartedAt, + completedAt: completed ? now : null, + updatedAt: now, + type: "reasoning", + text: active.reasoningText, + streaming: !completed, + }, + }); + }); + + const runtimeRequestTurnItem = ( + pending: PendingHermesRuntimeRequest, + status: OrchestrationV2TurnItem["status"], + completedAt: DateTime.Utc | null, + updatedAt: DateTime.Utc, + ): OrchestrationV2TurnItem => { + const base = { + id: pending.turnItemId, + threadId: pending.active.input.threadId, + runId: pending.active.input.runId, + nodeId: pending.nodeId, + providerThreadId: pending.state.providerThread.id, + providerTurnId: pending.active.providerTurn?.id ?? null, + nativeItemRef: providerRef( + pending.nativeIdentity, + pending.nativeRequestId === undefined ? "weak" : "strong", + ), + parentItemId: null, + ordinal: resolveItemOrdinal(pending.active, pending.nativeIdentity), + status, + title: pending.kind === "approval" ? "Approval required" : "User input", + startedAt: pending.request.createdAt, + completedAt, + updatedAt, + } as const; + if (pending.kind === "approval") { + return { + ...base, + type: "approval_request", + requestId: pending.request.id, + requestKind: "command", + prompt: pending.prompt, + }; + } + return { + ...base, + type: "user_input_request", + requestId: pending.request.id, + questions: [ + { + id: pending.questionId ?? "question", + header: "Question", + question: pending.prompt, + options: (pending.choices ?? []).map((choice) => ({ + label: choice, + description: choice, + })), + }, + ], + }; + }; + + const emitRuntimeRequest = Effect.fnUntraced(function* ( + state: HermesThreadState, + active: ActiveHermesTurn, + event: HermesGatewayOrderedEvent, + kind: PendingHermesRuntimeRequest["kind"], + ) { + if (active.providerTurn === null) return; + const payload = + typeof event.frame.params.payload === "object" && event.frame.params.payload !== null + ? (event.frame.params.payload as Record) + : {}; + const nativeRequestId = + kind === "clarification" ? nonEmptyString(payload.request_id) : undefined; + if (kind === "clarification" && nativeRequestId === undefined) return; + const nativeIdentity = + nativeRequestId ?? + `approval:${event.eventId ?? event.eventSequence ?? event.transportSequence}`; + if ( + [...pendingRuntimeRequests.values()].some( + (pending) => + pending.state === state && + pending.kind === kind && + pending.nativeIdentity === nativeIdentity, + ) + ) { + return; + } + const prompt = + kind === "approval" + ? (nonEmptyString(payload.command) ?? + nonEmptyString(payload.description) ?? + "Hermes requests permission to run a command.") + : (nonEmptyString(payload.question) ?? "Hermes requests more information."); + const requestId = yield* options.idAllocator.allocate.runtimeRequest({ + driver: HERMES_PROVIDER, + providerTurnId: active.providerTurn.id, + nativeRequestId: nativeIdentity, + }); + const now = yield* DateTime.now; + const nodeId = options.idAllocator.derive.approvalNode({ requestId }); + const request: OrchestrationV2RuntimeRequest = { + id: requestId, + nodeId, + providerTurnId: active.providerTurn.id, + nativeRequestRef: + nativeRequestId === undefined ? null : providerRef(nativeRequestId, "strong"), + kind: kind === "approval" ? "command" : "user_input", + status: "pending", + responseCapability: { + type: "live", + providerSessionId: input.providerSessionId, + }, + createdAt: now, + resolvedAt: null, + }; + const pending: PendingHermesRuntimeRequest = { + request, + state, + active, + nodeId, + turnItemId: options.idAllocator.derive.approvalTurnItem({ requestId }), + nativeIdentity, + kind, + prompt, + ...(kind === "clarification" && nativeRequestId !== undefined + ? { questionId: nativeRequestId } + : {}), + ...(Array.isArray(payload.choices) + ? { + choices: payload.choices.filter( + (choice): choice is string => typeof choice === "string", + ), + } + : {}), + ...(nativeRequestId === undefined ? {} : { nativeRequestId }), + }; + pendingRuntimeRequests.set(String(requestId), pending); + state.runtimeRequests.set(String(requestId), request); + const nativeRef = providerRef( + nativeIdentity, + nativeRequestId === undefined ? "weak" : "strong", + ); + yield* emit({ + type: "node.updated", + driver: HERMES_PROVIDER, + node: { + id: nodeId, + threadId: active.input.threadId, + runId: active.input.runId, + parentNodeId: active.input.rootNodeId, + rootNodeId: active.input.rootNodeId, + kind: kind === "approval" ? "approval_request" : "user_input_request", + status: "waiting", + countsForRun: false, + providerThreadId: state.providerThread.id, + providerTurnId: active.providerTurn.id, + nativeItemRef: nativeRef, + runtimeRequestId: requestId, + checkpointScopeId: null, + startedAt: now, + completedAt: null, + }, + }); + yield* emit({ + type: "runtime_request.updated", + driver: HERMES_PROVIDER, + threadId: active.input.threadId, + runtimeRequest: request, + }); + yield* emit({ + type: "turn_item.updated", + driver: HERMES_PROVIDER, + turnItem: runtimeRequestTurnItem(pending, "waiting", null, now), + }); + yield* updateSession("waiting", null); + }); + + const settleRuntimeRequest = Effect.fnUntraced(function* ( + pending: PendingHermesRuntimeRequest, + status: "resolved" | "cancelled", + ) { + const now = yield* DateTime.now; + const resolved: OrchestrationV2RuntimeRequest = { + ...pending.request, + status, + resolvedAt: now, + }; + pending.state.runtimeRequests.set(String(resolved.id), resolved); + pendingRuntimeRequests.delete(String(resolved.id)); + yield* emit({ + type: "runtime_request.updated", + driver: HERMES_PROVIDER, + threadId: pending.active.input.threadId, + runtimeRequest: resolved, + }); + yield* emit({ + type: "node.updated", + driver: HERMES_PROVIDER, + node: { + id: pending.nodeId, + threadId: pending.active.input.threadId, + runId: pending.active.input.runId, + parentNodeId: pending.active.input.rootNodeId, + rootNodeId: pending.active.input.rootNodeId, + kind: pending.kind === "approval" ? "approval_request" : "user_input_request", + status: status === "resolved" ? "completed" : "cancelled", + countsForRun: false, + providerThreadId: pending.state.providerThread.id, + providerTurnId: pending.active.providerTurn?.id ?? null, + nativeItemRef: providerRef( + pending.nativeIdentity, + pending.nativeRequestId === undefined ? "weak" : "strong", + ), + runtimeRequestId: pending.request.id, + checkpointScopeId: null, + startedAt: pending.request.createdAt, + completedAt: now, + }, + }); + yield* emit({ + type: "turn_item.updated", + driver: HERMES_PROVIDER, + turnItem: runtimeRequestTurnItem( + pending, + status === "resolved" ? "completed" : "cancelled", + now, + now, + ), + }); + }); + + const removeGeneratingTool = (active: ActiveHermesTurn, tool: ActiveHermesTool): void => { + const key = tool.name ?? ""; + const queued = active.generatingToolsByName.get(key); + if (queued === undefined) return; + const index = queued.indexOf(tool); + if (index >= 0) queued.splice(index, 1); + if (queued.length === 0) active.generatingToolsByName.delete(key); + }; + + const handleToolEvent = Effect.fnUntraced(function* ( + state: HermesThreadState, + active: ActiveHermesTurn, + event: HermesGatewayOrderedEvent, + ) { + const payload = eventToolPayload(event); + const eventType = event.frame.params.type; + const name = nonEmptyString(payload.name) ?? null; + const toolId = nonEmptyString(payload.tool_id); + const runIdentity = event.runId ?? active.gatewayRunId ?? active.operationId; + const generatingIdentity = `hermes-tool:${event.runId ?? active.operationId}:${ + event.eventId ?? event.eventSequence ?? event.transportSequence + }`; + let tool: ActiveHermesTool | undefined = + toolId === undefined ? undefined : active.toolsByNativeId.get(toolId); + + if (tool === undefined && eventType !== "tool.generating") { + const queued = active.generatingToolsByName.get(name ?? "")?.[0]; + if (queued !== undefined) { + tool = queued; + if (eventType !== "tool.progress") removeGeneratingTool(active, queued); + } + } + if (tool === undefined && eventType === "tool.complete" && toolId === undefined) { + tool = [...active.toolsByIdentity.values()] + .toReversed() + .find( + (candidate) => + candidate.name === name && + (candidate.status === "pending" || + candidate.status === "running" || + candidate.status === "waiting"), + ); + } + if (tool === undefined) { + const identity = + toolId === undefined ? generatingIdentity : `hermes-tool:${runIdentity}:${toolId}`; + tool = { + identity, + nativeToolId: toolId ?? null, + name, + input: {}, + output: undefined, + title: name, + status: eventType === "tool.generating" ? "pending" : "running", + startedAt: null, + completedAt: null, + }; + active.toolsByIdentity.set(identity, tool); + if ( + eventType === "tool.generating" || + (eventType === "tool.progress" && toolId === undefined) + ) { + const queued = active.generatingToolsByName.get(name ?? "") ?? []; + queued.push(tool); + active.generatingToolsByName.set(name ?? "", queued); + } + } + if (toolId !== undefined) { + tool.nativeToolId = toolId; + active.toolsByNativeId.set(toolId, tool); + } + if (name !== null) tool.name = name; + + const now = yield* DateTime.now; + if (eventType === "tool.generating") { + tool.status = "pending"; + } else if (eventType === "tool.start" || eventType === "tool.progress") { + if (eventType === "tool.start") removeGeneratingTool(active, tool); + tool.status = "running"; + tool.startedAt ??= now; + const startInput = + payload.args !== undefined + ? payload.args + : payload.arguments !== undefined + ? payload.arguments + : (nonEmptyString(payload.args_text) ?? + nonEmptyString(payload.context) ?? + nonEmptyString(payload.preview) ?? + {}); + tool.input = sanitizeHermesToolValue(startInput, { maxChars: 20_000 }); + tool.title = + nonEmptyString(payload.context) ?? nonEmptyString(payload.preview) ?? tool.name; + } else { + removeGeneratingTool(active, tool); + tool.status = toolTerminalStatus(payload); + tool.startedAt ??= now; + tool.completedAt ??= now; + const completedInput = + payload.args !== undefined + ? payload.args + : payload.arguments !== undefined + ? payload.arguments + : undefined; + if (completedInput !== undefined) { + tool.input = sanitizeHermesToolValue(completedInput, { maxChars: 20_000 }); + } + const completedOutput = + payload.result !== undefined + ? payload.result + : (nonEmptyString(payload.result_text) ?? + nonEmptyString(payload.summary) ?? + nonEmptyString(payload.inline_diff)); + if (completedOutput !== undefined) { + tool.output = sanitizeHermesToolValue(completedOutput); + } + tool.title = + nonEmptyString(payload.summary) ?? + nonEmptyString(payload.context) ?? + nonEmptyString(payload.preview) ?? + tool.name; + } + yield* toolArtifacts(state, active, tool); + }); + + const handleToolOutputRisk = Effect.fnUntraced(function* ( + state: HermesThreadState, + active: ActiveHermesTurn, + event: HermesGatewayOrderedEvent, + ) { + const payload = eventToolPayload(event); + const toolId = nonEmptyString(payload.tool_id); + if (toolId === undefined) return; + const tool = active.toolsByNativeId.get(toolId); + if (tool === undefined) return; + const risk = nonEmptyString(payload.risk); + const findings = payload.findings; + const riskDetails = sanitizeHermesToolValue( + { + ...(risk === undefined ? {} : { risk }), + ...(findings === undefined ? {} : { findings }), + ...(payload.redacted === undefined ? {} : { redacted: payload.redacted }), + }, + { maxChars: 10_000 }, + ); + tool.output = + payload.redacted === true + ? { result: "[REDACTED BY HERMES]", outputRisk: riskDetails } + : { result: tool.output, outputRisk: riskDetails }; + yield* toolArtifacts(state, active, tool); + }); + + const finalizeUnsettledTools = Effect.fnUntraced(function* ( + state: HermesThreadState, + active: ActiveHermesTurn, + turnStatus: "completed" | "interrupted" | "failed", + ) { + const now = yield* DateTime.now; + for (const tool of active.toolsByIdentity.values()) { + if (tool.status !== "pending" && tool.status !== "running" && tool.status !== "waiting") { + continue; + } + tool.status = + turnStatus === "failed" + ? "failed" + : turnStatus === "interrupted" + ? "interrupted" + : "cancelled"; + tool.startedAt ??= tool.status === "cancelled" ? null : now; + tool.completedAt = now; + yield* toolArtifacts(state, active, tool); + } + }); + + const finalizeTurn = Effect.fnUntraced(function* ( + state: HermesThreadState, + status: "completed" | "interrupted" | "failed", + failureMessage?: string, + ) { + const active = state.activeTurn; + if (active === null || active.providerTurn === null || active.finalized) return; + const terminalIntentSettlement = yield* Effect.gen(function* () { + const { now, expiresAt } = leaseTimes(); + const renewed = yield* options.repository.renewOwnerLease({ + bindingId: state.binding.bindingId, + ownerKey: state.lease.ownerKey, + generation: state.lease.generation, + now, + expiresAt, + }); + if (!renewed) { + const reacquired = yield* options.repository.acquireOwnerLease({ + bindingId: state.binding.bindingId, + ownerKey: state.lease.ownerKey, + expectedGeneration: state.lease.generation, + now, + expiresAt, + }); + if (Option.isNone(reacquired)) { + return yield* new ProviderAdapterProtocolError({ + driver: HERMES_PROVIDER, + detail: "Hermes terminal mutation settlement lost its generation fence", + }); + } + state.lease = reacquired.value; + } + yield* transitionIntent( + state, + active.operationId, + active.intentState, + active.intentState === "admitted" ? "confirmed" : "reconciled", + ); + }).pipe(Effect.result); + const settlementFailed = terminalIntentSettlement._tag === "Failure"; + if (terminalIntentSettlement._tag === "Failure") { + state.ownershipLost = true; + yield* Effect.logError("Hermes terminal event could not settle its durable intent", { + operationId: active.operationId, + cause: terminalIntentSettlement.failure, + }); + } + const projectedStatus = settlementFailed ? "failed" : status; + const projectedFailureMessage = settlementFailed + ? "Hermes ownership was lost before terminal settlement." + : failureMessage; + active.finalized = true; + const now = yield* DateTime.now; + yield* finalizeUnsettledTools(state, active, projectedStatus); + yield* reasoningArtifacts(state, active, true); + yield* messageArtifacts(state, active, true); + for (const pending of pendingRuntimeRequests.values()) { + if (pending.active === active) { + yield* settleRuntimeRequest(pending, "cancelled"); + } + } + const providerTurn: OrchestrationV2ProviderTurn = { + ...active.providerTurn, + status: projectedStatus, + completedAt: now, + }; + active.providerTurn = providerTurn; + state.turns.set(String(providerTurn.id), providerTurn); + yield* emit({ + type: "provider_turn.updated", + driver: HERMES_PROVIDER, + threadId: active.input.threadId, + providerTurn, + }); + yield* updateThread(state, { + status: projectedStatus === "failed" ? "error" : "idle", + }); + yield* updateSession( + projectedStatus === "failed" ? "error" : "ready", + projectedFailureMessage ?? null, + ); + yield* emit( + projectedStatus === "failed" + ? { + type: "turn.terminal", + driver: HERMES_PROVIDER, + providerThreadId: state.providerThread.id, + providerTurnId: providerTurn.id, + runOrdinal: active.input.runOrdinal, + failureItemOrdinal: active.nextItemOrdinal, + status: "failed", + failure: makeProviderFailure({ + class: "provider_error", + message: projectedFailureMessage ?? "Hermes turn failed.", + }), + threadDisposition: "broken", + } + : { + type: "turn.terminal", + driver: HERMES_PROVIDER, + providerThreadId: state.providerThread.id, + providerTurnId: providerTurn.id, + runOrdinal: active.input.runOrdinal, + status: projectedStatus, + failure: null, + threadDisposition: "reusable", + }, + ); + yield* Deferred.succeed(active.completion, undefined); + state.activeTurn = null; + }); + + const settleExternalRun = Effect.fnUntraced(function* ( + state: HermesThreadState, + status: string | undefined, + ) { + if (!state.externalRunActive || status === undefined || !isTerminalHermesStatus(status)) { + return; + } + state.externalRunActive = false; + const failed = status.includes("error") || status.includes("failed"); + yield* updateThread(state, { status: failed ? "error" : "idle" }); + yield* updateSession( + failed ? "error" : "ready", + failed ? "Hermes recovered external run failed." : null, + ); + }); + + const handleGatewayEvent = Effect.fnUntraced(function* (event: HermesGatewayOrderedEvent) { + const state = + (event.sessionId === undefined ? undefined : statesByLiveSession.get(event.sessionId)) ?? + [...statesByProviderThread.values()].find( + (candidate) => candidate.binding.storedSessionKey === event.sessionKey, + ); + if (state === undefined) return; + if (event.frame.params.type === "title.changed") { + let titleState: HermesGatewaySessionTitleResult; + try { + titleState = decodeTitleChanged(event.frame.params.payload); + } catch { + return; + } + yield* reconcileTitle(state, titleState); + return; + } + if (event.frame.params.type === "session.info") { + const payload = + typeof event.frame.params.payload === "object" && event.frame.params.payload !== null + ? (event.frame.params.payload as Record) + : {}; + const model = nonEmptyString(payload.model); + if (model !== undefined && model !== providerSession.model) { + providerSession = { + ...providerSession, + model, + updatedAt: yield* DateTime.now, + }; + yield* emit({ + type: "provider_session.updated", + driver: HERMES_PROVIDER, + providerSession, + }); + } + return; + } + const active = state.activeTurn; + if (active === null) { + const externalStatus = + event.frame.params.type === "message.complete" + ? (eventStatus(event) ?? "complete") + : event.frame.params.type === "error" + ? "error" + : eventStatus(event); + yield* settleExternalRun(state, externalStatus); + return; + } + if (active.providerTurn === null) { + active.bufferedEvents.push(event); + return; + } + if ( + active.gatewayRunId !== null && + event.runId !== undefined && + event.runId !== active.gatewayRunId + ) { + return; + } + if (event.eventId !== undefined) { + if (active.seenEventIds.has(event.eventId)) return; + active.seenEventIds.add(event.eventId); + } + + switch (event.frame.params.type) { + case "thinking.delta": { + const text = eventText(event); + if (text.length > 0 && !active.reasoningHasStreamedDelta) { + active.reasoningText += text; + yield* reasoningArtifacts(state, active, false); + } + return; + } + case "reasoning.delta": { + const text = eventText(event); + if (text.length > 0) { + if (!active.reasoningHasStreamedDelta) active.reasoningText = ""; + active.reasoningHasStreamedDelta = true; + active.reasoningText += text; + yield* reasoningArtifacts(state, active, false); + } + return; + } + case "reasoning.available": { + const text = eventText(event); + if (text.length > 0 && !active.reasoningHasStreamedDelta) { + active.reasoningText = text; + yield* reasoningArtifacts(state, active, false); + } + return; + } + case "approval.request": + yield* emitRuntimeRequest(state, active, event, "approval"); + return; + case "clarify.request": + yield* emitRuntimeRequest(state, active, event, "clarification"); + return; + case "tool.generating": + case "tool.progress": + case "tool.start": + case "tool.complete": { + yield* handleToolEvent(state, active, event); + return; + } + case "tool.output_risk": + yield* handleToolOutputRisk(state, active, event); + return; + case "message.start": { + active.assistantNativeId = + eventMessageId(event) ?? active.assistantNativeId ?? active.operationId; + const text = eventText(event); + if (text) { + active.assistantText = text; + active.assistantSnapshotPending = true; + } + yield* messageArtifacts(state, active, false); + return; + } + case "message.delta": { + active.assistantNativeId = + eventMessageId(event) ?? active.assistantNativeId ?? active.operationId; + if (active.assistantSnapshotPending) { + active.assistantText = ""; + active.assistantSnapshotPending = false; + } + active.assistantText += eventText(event); + yield* messageArtifacts(state, active, false); + return; + } + case "message.interim": { + active.assistantNativeId = + eventMessageId(event) ?? active.assistantNativeId ?? active.operationId; + // Interim events carry a full message snapshot, not a delta: + // merge instead of appending so a repeated snapshot (e.g. the + // delegation acknowledgement) is not duplicated. + const text = eventText(event); + if (text && text !== active.assistantText) { + active.assistantText = text.startsWith(active.assistantText) + ? text + : `${active.assistantText}${text}`; + } + if (text) active.assistantSnapshotPending = true; + yield* messageArtifacts(state, active, false); + return; + } + case "message.complete": { + if (active.finalized) return; + active.assistantNativeId = + eventMessageId(event) ?? active.assistantNativeId ?? active.operationId; + const text = eventText(event); + if (text && text !== active.assistantText && !active.assistantText.endsWith(text)) { + active.assistantText = text.startsWith(active.assistantText) + ? text + : `${active.assistantText}${text}`; + } + const status = eventStatus(event); + if (status === "error" || status === "failed") { + yield* finalizeTurn(state, "failed", "Hermes reported a turn error."); + } else if (status === "interrupted") { + yield* finalizeTurn(state, "interrupted"); + } else { + yield* finalizeTurn(state, active.interrupted ? "interrupted" : "completed"); + } + return; + } + case "status.update": + case "session.status": { + const status = eventStatus(event); + if (status === "error" || status === "failed") { + yield* finalizeTurn(state, "failed", "Hermes reported a turn error."); + } else if (status === "interrupted") { + yield* finalizeTurn(state, "interrupted"); + } else if (status === "idle" || status === "complete" || status === "completed") { + yield* finalizeTurn(state, active.interrupted ? "interrupted" : "completed"); + } + return; + } + case "error": + yield* finalizeTurn(state, "failed", "Hermes reported a turn error."); + return; + default: + // Unsupported and future event families are isolated from text streaming. + return; + } + }); + + const unsubscribe = client.onEvent((event) => + runPromise( + handleGatewayEvent(event).pipe( + Effect.catchCause((cause) => + Effect.logWarning("Ignored malformed Hermes gateway event", { + eventType: event.frame.params.type, + cause, + }), + ), + ), + ), + ); + + const acquireLease = Effect.fnUntraced(function* (binding: HermesSessionBinding) { + const { now, expiresAt } = leaseTimes(); + const acquired = yield* options.repository.acquireOwnerLease({ + bindingId: binding.bindingId, + ownerKey: String(input.providerSessionId), + expectedGeneration: binding.leaseGeneration, + now, + expiresAt, + }); + if (Option.isNone(acquired)) { + return yield* new ProviderAdapterProtocolError({ + driver: HERMES_PROVIDER, + detail: `Hermes binding ${binding.bindingId} is owned by another generation`, + }); + } + return acquired.value; + }); + + const readTitleState = Effect.fnUntraced(function* (liveSessionId: string) { + if (!client.hasCapability("session.title")) return undefined; + return yield* gatewayEffect(() => client.readSessionTitle({ session_id: liveSessionId })); + }); + + const ensureSessionMcp = Effect.fnUntraced(function* ( + liveSessionId: string, + threadId: ProviderAdapterV2EnsureThreadInput["threadId"], + ) { + if (!mcpAvailable) return; + const mcpSession = McpProviderSession.readMcpProviderSession(threadId); + if (mcpSession === undefined) { + return yield* new ProviderAdapterProtocolError({ + driver: HERMES_PROVIDER, + detail: `T3 MCP credential was not prepared for Hermes thread ${threadId}`, + }); + } + const credentialIdentity = mcpSession.credentialId ?? mcpSession.providerSessionId; + if (mcpCredentialByLiveSession.get(liveSessionId) === credentialIdentity) { + return; + } + const operationId = `hermes:mcp:replace:${stableDigest( + options.instanceId, + liveSessionId, + credentialIdentity, + )}`; + const lease = yield* gatewayEffect(() => + client.replaceSessionMcp( + { + session_id: liveSessionId, + servers: { + "t3-code": { + url: mcpSession.endpoint, + headers: { + Authorization: mcpSession.authorizationHeader, + }, + }, + }, + }, + mutationOptions(operationId), + ), + ); + if ( + lease.scope.session_id !== liveSessionId || + !lease.servers.some((server) => server.name === "t3-code") || + !lease.tool_names.some((toolName) => toolName.endsWith("__delegate_task")) + ) { + return yield* new ProviderAdapterProtocolError({ + driver: HERMES_PROVIDER, + detail: `Hermes did not scope the T3 orchestration MCP lease to live session ${liveSessionId}`, + }); + } + mcpCredentialByLiveSession.set(liveSessionId, credentialIdentity); + }); + + const importedActivityTurnItems = ( + threadId: ProviderAdapterV2EnsureThreadInput["threadId"], + binding: HermesSessionBinding, + activities: ReadonlyArray, + ): ReadonlyArray => { + const createdAt = DateTime.makeUnsafe(binding.createdAt); + return activities.map((activity, index): OrchestrationV2TurnItem => { + const nativeItemId = `hermes-import:${binding.providerInstanceId}:${binding.profileKey}:${binding.storedSessionKey}:${activity.key}`; + // Imported history carries no timestamps, so activities share the + // ordinal-offset clock used for hydrated messages: they interleave + // deterministically with the surrounding transcript. + const at = DateTime.add(createdAt, { milliseconds: activity.ordinal }); + const base = { + id: options.idAllocator.derive.turnItemFromProviderItem({ + driver: HERMES_PROVIDER, + nativeItemId, + }), + threadId, + runId: null, + nodeId: null, + providerThreadId: null, + providerTurnId: null, + nativeItemRef: providerRef(nativeItemId, "weak"), + parentItemId: null, + ordinal: index, + startedAt: at, + completedAt: at, + updatedAt: at, + }; + switch (activity.kind) { + case "reasoning": + return { + ...base, + status: "completed", + title: null, + type: "reasoning", + text: activity.text, + streaming: false, + }; + case "command_execution": + return { + ...base, + status: activity.status, + title: activity.title, + type: "command_execution", + input: activity.input, + ...(activity.output === undefined ? {} : { output: activity.output }), + }; + case "file_change": + return { + ...base, + status: activity.status, + title: activity.title, + type: "file_change", + fileName: activity.fileName, + }; + case "web_search": + return { + ...base, + status: activity.status, + title: activity.title, + type: "web_search", + patterns: activity.patterns, + }; + case "dynamic_tool": + return { + ...base, + status: activity.status, + title: activity.title, + type: "dynamic_tool", + toolName: activity.toolName, + input: activity.input, + ...(activity.output === undefined ? {} : { output: activity.output }), + }; + } + }); + }; + + const historyMessages = Effect.fnUntraced(function* ( + threadId: ProviderAdapterV2EnsureThreadInput["threadId"], + binding: HermesSessionBinding, + history: HermesGatewaySessionHistoryResult, + ) { + const createdAt = DateTime.makeUnsafe(binding.createdAt); + const imported = yield* options.repository.getSessionImportByStoredIdentity({ + providerInstanceId: binding.providerInstanceId, + profileKey: binding.profileKey, + projectId: binding.projectId, + storedSessionKey: binding.storedSessionKey, + }); + // The inherited boundary is recorded once, on the first hydration + // that actually observes history, so imported-transcript + // normalization and activity rehydration only ever apply to the + // history that existed at import time. An empty read leaves the + // boundary unrecorded — history may simply not be readable yet — so + // a later full history still hydrates. Native T3 messages appended + // after the boundary are left untouched. + const inheritedCount = Option.isSome(imported) + ? (imported.value.inheritedMessageCount ?? + (history.messages.length === 0 + ? 0 + : yield* options.repository.setSessionImportInheritedCount({ + importId: imported.value.importId, + inheritedMessageCount: history.messages.length, + now: DateTime.formatIso(yield* DateTime.now), + }))) + : 0; + const inherited = history.messages.slice(0, inheritedCount); + const hydration = hydrateImportedHermesActivities(inherited); + // Rehydrated tool outputs come from raw history rows, so they may + // still carry Hermes' MEDIA: output protocol. Resolve it to durable + // attachment markers the same way visible transcript rows do. + const normalizedActivities = yield* Effect.forEach( + hydration.activities, + Effect.fnUntraced(function* (activity) { + if (!("output" in activity) || activity.output === undefined) return activity; + const normalized = yield* normalizeHermesHistoryMessage({ + role: "tool", + text: activity.output, + resolveMedia: (media, index) => + options.resolveHistoryMedia?.({ + sourcePath: media.path, + expectedKind: media.kind, + threadId: String(threadId), + stableKey: `${binding.providerInstanceId}:${binding.profileKey}:${binding.storedSessionKey}:${activity.key}:${index}`, + }) ?? Effect.succeed(null), + }); + const markers = normalized.attachments.map( + (attachment) => `[Attachment: ${attachment.name}]`, + ); + const output = [normalized.text, ...markers].filter(Boolean).join("\n\n"); + return { ...activity, output }; + }), + { concurrency: 1 }, + ); + const turnItems = importedActivityTurnItems(threadId, binding, normalizedActivities); + const messages = yield* Effect.forEach( + history.messages, + Effect.fnUntraced(function* (message, ordinal) { + if (ordinal < inheritedCount && hydration.hiddenOrdinals.has(ordinal)) return []; + const rawText = message.text ?? ""; + const text = + ordinal < inheritedCount && message.role === "user" + ? normalizeImportedHermesUserText(rawText) + : rawText; + if (!text) return []; + const nativeId = + message.message_id ?? + stableDigest( + options.instanceId, + options.settings.profileKey, + binding.storedSessionKey, + ordinal, + message.role, + text, + ); + // Imported transport messages need sender/envelope cleanup, while + // every assistant/tool history message may contain Hermes' native + // MEDIA: output protocol (including sessions created in T3 Work). + const normalized = + ordinal < inheritedCount || message.role === "assistant" || message.role === "tool" + ? yield* normalizeHermesHistoryMessage({ + role: message.role, + text, + resolveMedia: (media, index) => + options.resolveHistoryMedia?.({ + sourcePath: media.path, + expectedKind: media.kind, + threadId: String(threadId), + stableKey: `${binding.providerInstanceId}:${binding.profileKey}:${binding.storedSessionKey}:${nativeId}:${index}`, + }) ?? Effect.succeed(null), + }) + : { text, attachments: [] }; + if (!normalized.text && normalized.attachments.length === 0) return []; + return [ + { + createdBy: message.role === "user" ? "user" : "agent", + creationSource: "provider", + id: options.idAllocator.derive.messageFromProviderItem({ + driver: HERMES_PROVIDER, + nativeItemId: nativeId, + }), + threadId, + runId: null, + nodeId: null, + role: historyRole(message.role), + text: normalized.text, + attachments: normalized.attachments, + streaming: false, + // The pinned history payload has no timestamps. Preserve its + // authoritative array order in projection queries, which sort + // messages by createdAt before id. + createdAt: DateTime.add(createdAt, { milliseconds: ordinal }), + updatedAt: DateTime.add(createdAt, { milliseconds: ordinal }), + } satisfies OrchestrationV2ConversationMessage, + ]; + }), + { concurrency: 1 }, + ).pipe(Effect.map((rows) => rows.flat())); + return { messages, turnItems }; + }); + + const registerState = Effect.fnUntraced(function* ( + binding: HermesSessionBinding, + liveSessionId: string, + lease: HermesOwnerLease, + appThreadId: ProviderAdapterV2EnsureThreadInput["threadId"], + history: HermesGatewaySessionHistoryResult, + externalRunActive: boolean, + titleState?: HermesGatewaySessionTitleResult, + ) { + const now = yield* DateTime.now; + const nativeThreadId = `${options.instanceId}:${options.settings.profileKey}:${binding.storedSessionKey}`; + const providerThread: OrchestrationV2ProviderThread = { + id: options.idAllocator.derive.providerThread({ + driver: HERMES_PROVIDER, + nativeThreadId, + }), + driver: HERMES_PROVIDER, + providerInstanceId: options.instanceId, + providerSessionId: input.providerSessionId, + appThreadId, + ownerNodeId: null, + nativeThreadRef: providerRef(nativeThreadId), + nativeConversationHeadRef: null, + status: externalRunActive ? "active" : "idle", + firstRunOrdinal: null, + lastRunOrdinal: null, + handoffIds: [], + forkedFrom: null, + createdAt: now, + updatedAt: now, + }; + const hydrated = yield* historyMessages(appThreadId, binding, history); + const state: HermesThreadState = { + binding, + liveSessionId, + lease, + titleRevision: binding.titleRevision, + title: null, + providerThread, + activeTurn: null, + externalRunActive, + ownershipLost: false, + turns: new Map(), + messages: new Map(hydrated.messages.map((message) => [String(message.id), message])), + importedTurnItems: new Map(hydrated.turnItems.map((item) => [String(item.id), item])), + runtimeRequests: new Map(), + }; + statesByProviderThread.set(String(providerThread.id), state); + statesByLiveSession.set(liveSessionId, state); + if (externalRunActive) { + yield* updateSession("running", null); + } + if (titleState !== undefined) { + yield* reconcileTitle(state, titleState); + } + return providerThread; + }); + + const resumeBinding = Effect.fnUntraced(function* ( + binding: HermesSessionBinding, + threadInput: ProviderAdapterV2EnsureThreadInput, + ) { + const lease = yield* acquireLease(binding); + const operationId = `hermes:resume:${binding.bindingId}:g${lease.generation}`; + const temporaryState = { + binding, + lease, + } satisfies HermesMutationFence; + const prepared = yield* prepareBoundMutation(temporaryState, { + operationId, + mutationKind: "session_resume", + method: "session.resume", + payloadDigest: stableDigest(binding.storedSessionKey, binding.profileKey), + }); + // The durable session key lives in the profile database the binding + // was discovered under, so resume targets the binding's profile. + const resumeParams = { + session_id: binding.storedSessionKey, + profile: binding.profileKey, + source: "t3-code", + close_on_disconnect: false, + } satisfies HermesGatewaySessionResumeParams; + const resumed = yield* ( + prepared.replay + ? gatewayEffect(() => + client.resumeSession(resumeParams, mutationOptions(operationId)), + ).pipe( + Effect.tap(() => + transitionIntent( + temporaryState, + operationId, + prepared.intentState, + prepared.intentState === "admitted" ? "confirmed" : "reconciled", + ), + ), + ) + : settleMutation(temporaryState, operationId, () => + client.resumeSession(resumeParams, mutationOptions(operationId)), + ) + ).pipe( + Effect.mapError((cause) => + isStoredSessionNotFound(cause) + ? new HermesImportedSessionUnavailableError({ + storedSessionKey: binding.storedSessionKey, + profileKey: binding.profileKey, + cause, + }) + : cause, + ), + ); + // Hermes revokes an ephemeral MCP lease as part of session.resume. + mcpCredentialByLiveSession.delete(resumed.session_id); + yield* ensureSessionMcp(resumed.session_id, threadInput.threadId); + const authoritativeStatus = yield* gatewayEffect(() => + client.readSessionStatus({ + session_id: resumed.session_id, + profile: binding.profileKey, + }), + ); + const history = yield* gatewayEffect(() => + client.readSessionHistory({ + session_id: resumed.session_id, + profile: binding.profileKey, + }), + ); + const titleState = yield* readTitleState(resumed.session_id); + const recoveredWork = + resumed.running || + hasRecoveredWork(resumed.inflight) || + hasRecoveredWork(resumed.queued) || + isActiveHermesStatus(resumed.status); + const authoritativeRuntimeStatus = hermesSessionRuntimeStatus(authoritativeStatus); + const externalRunActive = isTerminalHermesStatus(authoritativeRuntimeStatus) + ? false + : recoveredWork || isActiveHermesStatus(authoritativeRuntimeStatus); + return yield* registerState( + binding, + resumed.session_id, + lease, + threadInput.threadId, + history, + externalRunActive, + titleState, + ); + }); + + const createBinding = Effect.fnUntraced(function* ( + threadInput: ProviderAdapterV2EnsureThreadInput, + ) { + const operationId = `hermes:create:${options.instanceId}:${threadInput.threadId}`; + const now = DateTime.formatIso(yield* DateTime.now); + const payloadDigest = stableDigest( + options.instanceId, + options.settings.profileKey, + threadInput.threadId, + threadInput.runtimePolicy.cwd, + threadInput.modelSelection.model, + ); + const prepared = yield* options.repository.prepareSessionCreateIntent({ + operationId, + providerInstanceId: options.instanceId, + profileKey: options.settings.profileKey, + projectId: String(threadInput.threadId), + threadId: String(threadInput.threadId), + method: "session.create", + payloadDigest, + now, + }); + let replay = false; + if (prepared.status === "prepared") { + const admitted = yield* options.repository.transitionSessionCreateIntent({ + operationId, + from: "prepared", + to: "admitted", + now, + }); + if (!admitted) { + return yield* new ProviderAdapterProtocolError({ + driver: HERMES_PROVIDER, + detail: "Hermes session.create intent could not be admitted", + }); + } + } else if (prepared.status === "operation_exists") { + const outcome = yield* reconcileIntent(prepared.intent, { + bindingId: null, + mutationKind: "session_create", + method: "session.create", + payloadDigest, + }); + if (outcome.mutation_status === "indeterminate") { + if (prepared.intent.state === "prepared" || prepared.intent.state === "admitted") { + yield* options.repository.transitionSessionCreateIntent({ + operationId, + from: prepared.intent.state, + to: "indeterminate", + now, + }); + } + return yield* new ProviderAdapterProtocolError({ + driver: HERMES_PROVIDER, + detail: `Hermes session.create remains indeterminate (${operationId})`, + }); + } + replay = true; + } else { + return yield* new ProviderAdapterProtocolError({ + driver: HERMES_PROVIDER, + detail: `Hermes session.create cannot be safely resubmitted (${prepared.status})`, + }); + } + const createParams = { + profile: options.settings.profileKey, + source: "t3-code", + cwd: threadInput.runtimePolicy.cwd ?? providerSession.cwd, + close_on_disconnect: false, + // T3 persists this stored identity before the first prompt, so Hermes + // must make the otherwise-lazy empty session resumable as well. + persist_immediately: true, + ...hermesModelOverride(threadInput.modelSelection.model), + ...hermesReasoningOverride(threadInput.modelSelection), + ...hermesFastOverride(threadInput.modelSelection), + } satisfies HermesGatewaySessionCreateParams; + const created = yield* gatewayEffect(() => + client.createSession(createParams, mutationOptions(operationId)), + ).pipe( + Effect.tapError((cause) => + replay + ? Effect.void + : options.repository + .transitionSessionCreateIntent({ + operationId, + from: "admitted", + to: isIndeterminateGatewayCall(cause) ? "indeterminate" : "rejected", + now: DateTime.formatIso(DateTime.nowUnsafe()), + }) + .pipe(Effect.ignore), + ), + ); + const existingIdentity = yield* options.repository.getByStoredIdentity({ + providerInstanceId: options.instanceId, + profileKey: options.settings.profileKey, + storedSessionKey: created.stored_session_id, + }); + if ( + Option.isSome(existingIdentity) && + existingIdentity.value.threadId !== String(threadInput.threadId) + ) { + return yield* new ProviderAdapterProtocolError({ + driver: HERMES_PROVIDER, + detail: "Hermes stored session is already bound to another T3 thread", + }); + } + const bindingId = `hermes-binding:${stableDigest( + options.instanceId, + options.settings.profileKey, + created.stored_session_id, + )}`; + const bindingCreatedAt = DateTime.formatIso(yield* DateTime.now); + const inserted = yield* options.repository.createBinding({ + bindingId, + providerInstanceId: options.instanceId, + profileKey: options.settings.profileKey, + projectId: String(threadInput.threadId), + storedSessionKey: created.stored_session_id, + threadId: String(threadInput.threadId), + ...compatibilityFields(compatibility), + reconciliationCursor: null, + reconciliationFingerprint: null, + now: bindingCreatedAt, + createOperationId: operationId, + }); + if (!inserted) { + return yield* new ProviderAdapterProtocolError({ + driver: HERMES_PROVIDER, + detail: "Hermes binding creation conflicted with existing durable identity", + }); + } + const loaded = yield* options.repository.getByThreadId(String(threadInput.threadId)); + if (Option.isNone(loaded)) { + return yield* new ProviderAdapterProtocolError({ + driver: HERMES_PROVIDER, + detail: "Hermes binding was not readable after creation", + }); + } + const lease = yield* acquireLease(loaded.value); + yield* ensureSessionMcp(created.session_id, threadInput.threadId); + yield* gatewayEffect(() => + client.readSessionStatus({ + session_id: created.session_id, + profile: options.settings.profileKey, + }), + ); + const history = yield* gatewayEffect(() => + client.readSessionHistory({ + session_id: created.session_id, + profile: options.settings.profileKey, + }), + ); + const titleState = yield* readTitleState(created.session_id); + return yield* registerState( + loaded.value, + created.session_id, + lease, + threadInput.threadId, + history, + false, + titleState, + ); + }); + + yield* Effect.addFinalizer(() => + Effect.gen(function* () { + unsubscribe(); + for (const [liveSessionId, credentialIdentity] of mcpCredentialByLiveSession) { + const operationId = `hermes:mcp:revoke:${stableDigest( + options.instanceId, + liveSessionId, + credentialIdentity, + )}`; + yield* gatewayEffect(() => + client.revokeSessionMcp(liveSessionId, mutationOptions(operationId)), + ).pipe(Effect.ignore); + } + mcpCredentialByLiveSession.clear(); + for (const state of statesByProviderThread.values()) { + const { now } = leaseTimes(); + yield* options.repository + .releaseOwnerLease({ + bindingId: state.binding.bindingId, + ownerKey: state.lease.ownerKey, + generation: state.lease.generation, + now, + }) + .pipe(Effect.ignore); + } + client.close(); + yield* Queue.shutdown(events); + }), + ); + + const runtime: ProviderAdapterV2SessionRuntime = { + instanceId: options.instanceId, + driver: HERMES_PROVIDER, + providerSessionId: input.providerSessionId, + get providerSession() { + return providerSession; + }, + events: Stream.fromEffectRepeat(Queue.take(events)), + ensureThread: (threadInput) => + Effect.gen(function* () { + if (threadInput.existingProviderThread !== undefined) { + return yield* runtime.resumeThread({ + providerThread: threadInput.existingProviderThread, + threadId: threadInput.threadId, + modelSelection: threadInput.modelSelection, + runtimePolicy: threadInput.runtimePolicy, + }); + } + const existing = yield* options.repository.getByThreadId(String(threadInput.threadId)); + return Option.isSome(existing) + ? yield* resumeBinding(existing.value, threadInput) + : yield* createBinding(threadInput); + }).pipe( + Effect.mapError( + (cause) => + new ProviderAdapterEnsureThreadError({ + driver: HERMES_PROVIDER, + threadId: threadInput.threadId, + cause, + }), + ), + ), + resumeThread: (threadInput) => + Effect.gen(function* () { + const existingState = stateForProviderThread(threadInput.providerThread); + if (existingState !== undefined) { + const appThreadId = + threadInput.threadId ?? threadInput.providerThread.appThreadId ?? input.threadId; + yield* ensureSessionMcp(existingState.liveSessionId, appThreadId); + return alignStateProviderThread(existingState, threadInput.providerThread); + } + const appThreadId = + threadInput.threadId ?? threadInput.providerThread.appThreadId ?? input.threadId; + const binding = yield* options.repository.getByThreadId(String(appThreadId)); + if (Option.isNone(binding)) { + return yield* new ProviderAdapterProtocolError({ + driver: HERMES_PROVIDER, + detail: `No Hermes binding exists for thread ${appThreadId}`, + }); + } + const resumedThread = yield* resumeBinding(binding.value, { + threadId: appThreadId, + modelSelection: threadInput.modelSelection ?? input.modelSelection, + runtimePolicy: threadInput.runtimePolicy ?? input.runtimePolicy, + existingProviderThread: threadInput.providerThread, + }); + const resumedState = stateForProviderThread(resumedThread); + return resumedState === undefined + ? resumedThread + : alignStateProviderThread(resumedState, threadInput.providerThread); + }).pipe( + Effect.mapError( + (cause) => + new ProviderAdapterResumeThreadError({ + driver: HERMES_PROVIDER, + providerSessionId: input.providerSessionId, + providerThreadId: threadInput.providerThread.id, + cause, + }), + ), + ), + startTurn: (turnInput) => + Effect.gen(function* () { + const requestedModel = hermesModelOverride(turnInput.modelSelection.model).model; + if (requestedModel !== undefined && requestedModel !== providerSession.model) { + return yield* new ProviderAdapterProtocolError({ + driver: HERMES_PROVIDER, + detail: "Hermes model switching is not supported in an open session", + }); + } + const state = stateForProviderThread(turnInput.providerThread); + if (state === undefined) { + return yield* new ProviderAdapterProtocolError({ + driver: HERMES_PROVIDER, + detail: `Hermes provider thread ${turnInput.providerThread.id} is not resumed`, + }); + } + if (state.activeTurn !== null) { + return yield* new ProviderAdapterProtocolError({ + driver: HERMES_PROVIDER, + detail: "Hermes does not support queued or steered prompts", + }); + } + if (state.ownershipLost) { + return yield* new ProviderAdapterProtocolError({ + driver: HERMES_PROVIDER, + detail: "Hermes thread ownership was lost and cannot accept another prompt", + }); + } + if (state.externalRunActive) { + const authoritative = yield* gatewayEffect(() => + client.readSessionStatus({ + session_id: state.liveSessionId, + profile: options.settings.profileKey, + }), + ); + yield* settleExternalRun(state, hermesSessionRuntimeStatus(authoritative)); + if (state.externalRunActive) { + return yield* new ProviderAdapterProtocolError({ + driver: HERMES_PROVIDER, + detail: "Hermes has a recovered external run in progress", + }); + } + } + alignStateProviderThread(state, turnInput.providerThread); + const projectedTitle = turnInput.appThread.title.trim(); + if ( + client.hasCapability("session.title") && + projectedTitle && + projectedTitle !== state.title && + (turnInput.appThread.titleRevision ?? state.titleRevision) === state.titleRevision + ) { + const titleOperationId = `hermes:title:${state.binding.bindingId}:r${state.titleRevision + 1}:${stableDigest(projectedTitle).slice(0, 12)}`; + const titleSyncResult = yield* Effect.gen(function* () { + const preparedTitle = yield* prepareBoundMutation(state, { + operationId: titleOperationId, + mutationKind: "session_title", + method: "session.title", + payloadDigest: stableDigest( + state.binding.storedSessionKey, + projectedTitle, + "client:t3-code", + ), + }); + const titleState = yield* preparedTitle.replay + ? gatewayEffect(() => + client.updateSessionTitle( + { + session_id: state.liveSessionId, + title: projectedTitle, + origin: "client:t3-code", + }, + mutationOptions(titleOperationId), + ), + ).pipe( + Effect.tap(() => + transitionIntent( + state, + titleOperationId, + preparedTitle.intentState, + preparedTitle.intentState === "admitted" ? "confirmed" : "reconciled", + ), + ), + ) + : settleMutation(state, titleOperationId, () => + client.updateSessionTitle( + { + session_id: state.liveSessionId, + title: projectedTitle, + origin: "client:t3-code", + }, + mutationOptions(titleOperationId), + ), + ); + yield* reconcileTitle(state, titleState); + }).pipe(Effect.result); + if (titleSyncResult._tag === "Failure") { + yield* Effect.logWarning("Hermes title sync failed; continuing prompt", { + providerThreadId: state.providerThread.id, + operationId: titleOperationId, + cause: titleSyncResult.failure, + }); + } + } + if (turnInput.message.attachments.length > 0 && !options.settings.attachmentsEnabled) { + return yield* new ProviderAdapterProtocolError({ + driver: HERMES_PROVIDER, + detail: "Attachments are disabled for this Hermes instance", + }); + } + if (turnInput.message.attachments.length > 0 && options.readAttachment === undefined) { + return yield* new ProviderAdapterProtocolError({ + driver: HERMES_PROVIDER, + detail: "Hermes attachment storage is unavailable", + }); + } + const attachmentRefs = yield* Effect.forEach( + turnInput.message.attachments, + (attachment, attachmentOrdinal) => + Effect.gen(function* () { + const bytes = yield* options.readAttachment!(attachment); + const contentBase64 = Buffer.from(bytes).toString("base64"); + if (attachment.type === "image") { + if (!client.hasCapability("attachments.image")) { + return yield* new ProviderAdapterProtocolError({ + driver: HERMES_PROVIDER, + detail: "This Hermes gateway does not support image attachments", + }); + } + yield* gatewayEffect(() => + client.attachImageBytes( + { + session_id: state.liveSessionId, + content_base64: contentBase64, + filename: attachment.name, + }, + { + operationId: `hermes:image:${turnInput.attemptId}:${attachmentOrdinal}`, + }, + ), + ); + return null; + } + if (attachment.type === "pdf") { + if (!client.hasCapability("attachments.pdf")) { + return yield* new ProviderAdapterProtocolError({ + driver: HERMES_PROVIDER, + detail: "This Hermes gateway does not support PDF attachments", + }); + } + yield* gatewayEffect(() => + client.attachPdf( + { + session_id: state.liveSessionId, + content_base64: contentBase64, + filename: attachment.name, + }, + { + operationId: `hermes:pdf:${turnInput.attemptId}:${attachmentOrdinal}`, + }, + ), + ); + return null; + } + if (!client.hasCapability("attachments.file")) { + return yield* new ProviderAdapterProtocolError({ + driver: HERMES_PROVIDER, + detail: `This Hermes gateway does not support ${attachment.type === "video" ? "video" : "file"} attachments`, + }); + } + const attachResult = yield* gatewayEffect(() => + client.attachFile( + { + session_id: state.liveSessionId, + name: attachment.name, + data_url: `data:${attachment.mimeType};base64,${contentBase64}`, + }, + { + operationId: `hermes:file:${turnInput.attemptId}:${attachmentOrdinal}`, + }, + ), + ); + // The gateway stages the file and hands back a reference token + // (e.g. "@file:..."); the model only sees the file when that + // token is included in the prompt text. + const refText = hermesAttachmentRefText(attachResult); + if (refText === null) { + return yield* new ProviderAdapterProtocolError({ + driver: HERMES_PROVIDER, + detail: `Hermes gateway staged "${attachment.name}" without returning a file reference`, + }); + } + return refText; + }), + { concurrency: 1 }, + ); + const promptRefs = attachmentRefs.filter((ref): ref is string => ref !== null); + const promptText = + promptRefs.length > 0 + ? `${turnInput.message.text}\n\n${promptRefs.join("\n")}` + : turnInput.message.text; + const operationId = `hermes:prompt:${turnInput.attemptId}`; + const prepared = yield* prepareBoundMutation(state, { + operationId, + mutationKind: "prompt", + method: "prompt.submit", + payloadDigest: stableDigest( + state.binding.storedSessionKey, + turnInput.message.text, + turnInput.attemptId, + ), + }); + const completion = yield* Deferred.make(); + const active: ActiveHermesTurn = { + input: turnInput, + operationId, + completion, + bufferedEvents: [], + providerTurn: null, + assistantNativeId: null, + assistantText: "", + assistantSnapshotPending: false, + assistantStartedAt: null, + reasoningText: "", + reasoningStartedAt: null, + reasoningHasStreamedDelta: false, + itemOrdinals: new Map(), + nextItemOrdinal: turnInput.providerTurnOrdinal * 100 + 1, + toolsByIdentity: new Map(), + toolsByNativeId: new Map(), + generatingToolsByName: new Map(), + seenEventIds: new Set(), + gatewayRunId: null, + interrupted: false, + finalized: false, + intentState: prepared.intentState, + }; + state.activeTurn = active; + const submitted = yield* gatewayEffect(() => + client.submitPrompt( + { + session_id: state.liveSessionId, + text: promptText, + }, + mutationOptions(operationId), + ), + ).pipe( + Effect.tapError((cause) => + prepared.replay + ? Effect.void + : transitionIntent( + state, + operationId, + "admitted", + isIndeterminateGatewayCall(cause) ? "indeterminate" : "rejected", + ).pipe(Effect.ignore), + ), + ); + const terminalReplay = submitted.mutation_status === "completed"; + if (prepared.replay && prepared.intentState === "prepared" && !terminalReplay) { + yield* transitionIntent(state, operationId, "prepared", "admitted"); + active.intentState = "admitted"; + } + const nativeRunId = + submitted.run_id ?? stableDigest(state.binding.storedSessionKey, operationId, "run"); + active.gatewayRunId = submitted.run_id ?? null; + const startedAt = yield* DateTime.now; + const providerTurn: OrchestrationV2ProviderTurn = { + id: options.idAllocator.derive.providerTurn({ + driver: HERMES_PROVIDER, + nativeTurnId: nativeRunId, + }), + providerThreadId: state.providerThread.id, + nodeId: turnInput.rootNodeId, + runAttemptId: turnInput.attemptId, + nativeTurnRef: providerRef( + nativeRunId, + submitted.run_id === undefined ? "weak" : "strong", + ), + ordinal: turnInput.providerTurnOrdinal, + status: "running", + startedAt, + completedAt: null, + }; + active.providerTurn = providerTurn; + active.assistantNativeId = submitted.assistant_message_id ?? null; + state.turns.set(String(providerTurn.id), providerTurn); + yield* emit({ + type: "provider_turn.updated", + driver: HERMES_PROVIDER, + threadId: turnInput.threadId, + providerTurn, + }); + yield* updateThread(state, { + status: "active", + firstRunOrdinal: state.providerThread.firstRunOrdinal ?? turnInput.runOrdinal, + lastRunOrdinal: turnInput.runOrdinal, + }); + yield* updateSession("running", null); + for (const buffered of active.bufferedEvents.splice(0)) { + yield* handleGatewayEvent(buffered); + } + if (terminalReplay && !active.finalized) { + if (submitted.status === "error") { + yield* finalizeTurn(state, "failed", "Hermes reported a turn error."); + } else { + yield* finalizeTurn( + state, + submitted.status === "interrupted" ? "interrupted" : "completed", + ); + } + } + }).pipe( + Effect.tapError(() => + Effect.sync(() => { + const state = stateForProviderThread(turnInput.providerThread); + if (state?.activeTurn?.providerTurn === null) state.activeTurn = null; + }), + ), + Effect.mapError( + (cause) => + new ProviderAdapterTurnStartError({ + driver: HERMES_PROVIDER, + threadId: turnInput.threadId, + providerThreadId: turnInput.providerThread.id, + runId: turnInput.runId, + cause, + }), + ), + ), + steerTurn: (steerInput) => + new ProviderAdapterSteerRunUnsupportedError({ + driver: HERMES_PROVIDER, + providerThreadId: steerInput.providerThread.id, + }), + interruptTurn: (interruptInput) => + Effect.gen(function* () { + const state = stateForProviderThread(interruptInput.providerThread); + const active = state?.activeTurn; + if (state === undefined || active == null || active.providerTurn === null) { + return yield* new ProviderAdapterProtocolError({ + driver: HERMES_PROVIDER, + detail: "Hermes provider turn is not active", + }); + } + const operationId = `hermes:interrupt:${interruptInput.providerTurnId}`; + const prepared = yield* prepareBoundMutation(state, { + operationId, + mutationKind: "interrupt", + method: "session.interrupt", + payloadDigest: stableDigest( + state.binding.storedSessionKey, + interruptInput.providerTurnId, + ), + }); + active.interrupted = true; + if (prepared.replay) { + yield* gatewayEffect(() => + client.interruptSession( + { session_id: state.liveSessionId }, + mutationOptions(operationId), + ), + ).pipe( + Effect.tap(() => + transitionIntent( + state, + operationId, + prepared.intentState, + prepared.intentState === "admitted" ? "confirmed" : "reconciled", + ), + ), + ); + } else { + yield* settleMutation(state, operationId, () => + client.interruptSession( + { session_id: state.liveSessionId }, + mutationOptions(operationId), + ), + ); + } + const terminal = yield* Deferred.await(active.completion).pipe( + Effect.timeoutOption(INTERRUPT_TERMINAL_TIMEOUT), + ); + if (Option.isNone(terminal)) { + return yield* new ProviderAdapterProtocolError({ + driver: HERMES_PROVIDER, + detail: "Hermes interrupt did not produce a terminal event", + }); + } + }).pipe( + Effect.mapError( + (cause) => + new ProviderAdapterInterruptError({ + driver: HERMES_PROVIDER, + providerThreadId: interruptInput.providerThread.id, + providerTurnId: interruptInput.providerTurnId, + cause, + }), + ), + ), + respondToRuntimeRequest: (requestInput) => + Effect.gen(function* () { + const pending = pendingRuntimeRequests.get(String(requestInput.requestId)); + if (pending === undefined) { + return yield* new ProviderAdapterProtocolError({ + driver: HERMES_PROVIDER, + detail: `No pending Hermes runtime request ${requestInput.requestId}.`, + }); + } + const operationId = `hermes:runtime-response:${requestInput.requestId}`; + if (pending.kind === "approval") { + if (requestInput.decision === undefined) { + return yield* new ProviderAdapterProtocolError({ + driver: HERMES_PROVIDER, + detail: `Hermes approval ${requestInput.requestId} requires a decision.`, + }); + } + const result = yield* gatewayEffect(() => + client.respondToApproval( + { + session_id: pending.state.liveSessionId, + choice: hermesApprovalChoice(requestInput.decision!), + }, + mutationOptions(operationId), + ), + ); + if (!result.resolved) { + return yield* new ProviderAdapterProtocolError({ + driver: HERMES_PROVIDER, + detail: + "Hermes no longer has a live approval for this session; the response was not applied.", + }); + } + } else { + if (requestInput.answers === undefined || pending.nativeRequestId === undefined) { + return yield* new ProviderAdapterProtocolError({ + driver: HERMES_PROVIDER, + detail: `Hermes clarification ${requestInput.requestId} requires an answer.`, + }); + } + const answer = hermesClarificationAnswer( + requestInput.answers, + pending.questionId ?? pending.nativeRequestId, + ); + if (answer === undefined) { + return yield* new ProviderAdapterProtocolError({ + driver: HERMES_PROVIDER, + detail: `Hermes clarification ${requestInput.requestId} received no usable answer.`, + }); + } + const result = yield* gatewayEffect(() => + client.respondToClarification( + { request_id: pending.nativeRequestId!, answer }, + mutationOptions(operationId), + ), + ); + if (result.status === "expired") { + yield* settleRuntimeRequest(pending, "cancelled"); + return; + } + } + yield* settleRuntimeRequest(pending, "resolved"); + yield* updateSession("running", null); + }).pipe( + Effect.mapError((cause) => + isProviderAdapterRuntimeRequestResponseError(cause) + ? cause + : new ProviderAdapterRuntimeRequestResponseError({ + driver: HERMES_PROVIDER, + requestId: requestInput.requestId, + cause, + }), + ), + ), + readThreadSnapshot: (snapshotInput) => + Effect.gen(function* () { + const state = stateForProviderThread(snapshotInput.providerThread); + if (state === undefined) { + return yield* new ProviderAdapterProtocolError({ + driver: HERMES_PROVIDER, + detail: "Hermes thread must be resumed before reading its snapshot", + }); + } + yield* gatewayEffect(() => + client.readSessionStatus({ + session_id: state.liveSessionId, + profile: state.binding.profileKey, + }), + ); + const history = yield* gatewayEffect(() => + client.readSessionHistory({ + session_id: state.liveSessionId, + profile: state.binding.profileKey, + }), + ); + const hydrated = yield* historyMessages( + state.providerThread.appThreadId ?? input.threadId, + state.binding, + history, + ); + // History rows lacking a message_id get digest-derived ids that + // never match the ids of live-streamed T3 messages, so merging + // them blindly duplicates every turn's text. Skip history rows + // whose content is already represented by a live message, + // consuming one live occurrence per skipped row so repeated + // identical messages still hydrate proportionally. + const hydratedIds = new Set(hydrated.messages.map((message) => String(message.id))); + const liveTextBudget = new Map(); + for (const message of state.messages.values()) { + if (hydratedIds.has(String(message.id))) continue; + const key = `${message.role}\n${message.text}`; + liveTextBudget.set(key, (liveTextBudget.get(key) ?? 0) + 1); + } + for (const message of hydrated.messages) { + if (!state.messages.has(String(message.id))) { + const key = `${message.role}\n${message.text}`; + const budget = liveTextBudget.get(key) ?? 0; + if (budget > 0) { + liveTextBudget.set(key, budget - 1); + continue; + } + } + state.messages.set(String(message.id), message); + } + for (const item of hydrated.turnItems) { + state.importedTurnItems.set(String(item.id), item); + } + return { + providerThread: state.providerThread, + providerTurns: [...state.turns.values()], + messages: [...state.messages.values()], + runtimeRequests: [...state.runtimeRequests.values()], + turnItems: [...state.importedTurnItems.values()], + }; + }).pipe( + Effect.mapError( + (cause) => + new ProviderAdapterReadThreadSnapshotError({ + driver: HERMES_PROVIDER, + providerThreadId: snapshotInput.providerThread.id, + cause, + }), + ), + ), + rollbackThread: (rollbackInput) => + new ProviderAdapterRollbackThreadError({ + driver: HERMES_PROVIDER, + providerThreadId: rollbackInput.providerThread.id, + checkpointId: rollbackInput.target.checkpointId, + cause: new Error("Hermes rollback is not supported."), + }), + forkThread: (forkInput) => + Effect.gen(function* () { + if (!client.hasCapability("session.branch.latest")) { + return yield* new ProviderAdapterProtocolError({ + driver: HERMES_PROVIDER, + detail: "This Hermes gateway does not support latest-head native branches", + }); + } + const source = stateForProviderThread(forkInput.sourceProviderThread); + if (source === undefined) { + return yield* new ProviderAdapterProtocolError({ + driver: HERMES_PROVIDER, + detail: "Hermes source thread must be resumed before branching", + }); + } + if (source.activeTurn !== null || source.externalRunActive) { + return yield* new ProviderAdapterProtocolError({ + driver: HERMES_PROVIDER, + detail: "Hermes can branch only from an idle latest transcript head", + }); + } + const existing = yield* options.repository.getByThreadId( + String(forkInput.targetThreadId), + ); + if (Option.isSome(existing)) { + const resumed = yield* resumeBinding(existing.value, { + threadId: forkInput.targetThreadId, + modelSelection: forkInput.modelSelection ?? input.modelSelection, + runtimePolicy: forkInput.runtimePolicy ?? input.runtimePolicy, + }); + const resumedState = stateForProviderThread(resumed); + if (resumedState === undefined) return resumed; + yield* updateThread(resumedState, { + ownerNodeId: forkInput.ownerNodeId ?? null, + forkedFrom: { + providerThreadId: forkInput.sourceProviderThread.id, + ...(forkInput.providerTurnId === undefined + ? {} + : { providerTurnId: forkInput.providerTurnId }), + }, + }); + return resumedState.providerThread; + } + + const operationId = `hermes:branch:${source.binding.bindingId}:${forkInput.targetThreadId}`; + const prepared = yield* prepareBoundMutation(source, { + operationId, + mutationKind: "session_branch", + method: "session.branch", + payloadDigest: stableDigest( + source.binding.storedSessionKey, + forkInput.targetThreadId, + "latest_only", + ), + }); + const branched = prepared.replay + ? yield* gatewayEffect(() => + client.branchSession( + { session_id: source.liveSessionId }, + mutationOptions(operationId), + ), + ).pipe( + Effect.tap(() => + transitionIntent( + source, + operationId, + prepared.intentState, + prepared.intentState === "admitted" ? "confirmed" : "reconciled", + ), + ), + ) + : yield* settleMutation(source, operationId, () => + client.branchSession( + { session_id: source.liveSessionId }, + mutationOptions(operationId), + ), + ); + if ( + branched.parent !== source.binding.storedSessionKey || + branched.boundary.mode !== "latest_only" || + branched.boundary.exact !== false + ) { + return yield* new ProviderAdapterProtocolError({ + driver: HERMES_PROVIDER, + detail: "Hermes returned an unsupported branch boundary", + }); + } + const bindingId = `hermes-binding:${stableDigest( + options.instanceId, + options.settings.profileKey, + branched.stored_session_id, + )}`; + const now = DateTime.formatIso(yield* DateTime.now); + const inserted = yield* options.repository.createBinding({ + bindingId, + providerInstanceId: options.instanceId, + profileKey: options.settings.profileKey, + projectId: String(forkInput.targetThreadId), + storedSessionKey: branched.stored_session_id, + threadId: String(forkInput.targetThreadId), + ...compatibilityFields(compatibility), + reconciliationCursor: null, + reconciliationFingerprint: null, + parentBindingId: source.binding.bindingId, + branchBoundaryMode: branched.boundary.mode, + branchBoundaryMessageId: branched.boundary.message_id, + branchBoundaryMessageCount: branched.boundary.message_count, + now, + }); + if (!inserted) { + return yield* new ProviderAdapterProtocolError({ + driver: HERMES_PROVIDER, + detail: "Hermes child binding conflicted with an existing durable identity", + }); + } + const childBinding = yield* options.repository.getByThreadId( + String(forkInput.targetThreadId), + ); + if (Option.isNone(childBinding)) { + return yield* new ProviderAdapterProtocolError({ + driver: HERMES_PROVIDER, + detail: "Hermes child binding was not readable after native branching", + }); + } + const lease = yield* acquireLease(childBinding.value); + yield* ensureSessionMcp(branched.session_id, forkInput.targetThreadId); + const history = yield* gatewayEffect(() => + client.readSessionHistory({ + session_id: branched.session_id, + profile: options.settings.profileKey, + }), + ); + const titleState = yield* readTitleState(branched.session_id); + const child = yield* registerState( + childBinding.value, + branched.session_id, + lease, + forkInput.targetThreadId, + history, + false, + titleState, + ); + const childState = stateForProviderThread(child); + if (childState === undefined) return child; + yield* updateThread(childState, { + ownerNodeId: forkInput.ownerNodeId ?? null, + forkedFrom: { + providerThreadId: forkInput.sourceProviderThread.id, + ...(forkInput.providerTurnId === undefined + ? {} + : { providerTurnId: forkInput.providerTurnId }), + }, + }); + return childState.providerThread; + }).pipe( + Effect.mapError( + (cause) => + new ProviderAdapterForkThreadError({ + driver: HERMES_PROVIDER, + providerThreadId: forkInput.sourceProviderThread.id, + cause, + }), + ), + ), + }; + return runtime; + }), + }; +} + +export type HermesServeAdapterV2DriverEnv = + | FileSystem.FileSystem + | IdAllocatorV2 + | HermesSessionBindingRepository + | ServerConfig; + +export const makeHermesServeAdapterV2Driver = Effect.fn("makeHermesServeAdapterV2Driver")( + function* ( + input: ProviderAdapterDriverCreateInput, + options: { readonly connectionRuntime?: HermesServeRuntimeShape } = {}, + ) { + const idAllocator = yield* IdAllocatorV2; + const repository = yield* HermesSessionBindingRepository; + const fileSystem = yield* FileSystem.FileSystem; + const serverConfig = yield* ServerConfig; + const hostPlatform = yield* HostProcessPlatform; + const configuredHermesHome = input.environment.find( + (variable) => variable.name === "HERMES_HOME" && variable.value.trim().length > 0, + )?.value; + const configuredMediaRoots = input.environment + .find( + (variable) => + variable.name === "HERMES_MEDIA_ALLOW_DIRS" && variable.value.trim().length > 0, + ) + ?.value.split(hostPlatform === "win32" ? ";" : ":") + .flatMap((chunk) => chunk.split(",")) + .map((root) => root.trim()) + .filter(Boolean); + const approvedHistoryMediaRoots = hermesHistoryMediaRoots({ + hermesHome: configuredHermesHome ?? process.env.HERMES_HOME, + profileKey: input.config.profileKey, + extraRoots: configuredMediaRoots, + }); + const token = resolveHermesGatewayToken(input.environment); + return makeHermesServeAdapterV2({ + instanceId: input.instanceId, + settings: input.config, + enabled: input.enabled, + authToken: token, + remotePairingToken: resolveHermesRemotePairingToken(input.environment), + remoteTlsCertificateSha256: resolveHermesRemoteTlsCertificateSha256(input.environment), + ...(options.connectionRuntime === undefined + ? {} + : { connectionRuntime: options.connectionRuntime }), + idAllocator, + repository, + readAttachment: (attachment) => + Effect.gen(function* () { + const attachmentPath = resolveAttachmentPath({ + attachmentsDir: serverConfig.attachmentsDir, + attachment, + }); + if (attachmentPath === null) { + return yield* new ProviderAdapterProtocolError({ + driver: HERMES_PROVIDER, + detail: `Invalid Hermes attachment id '${attachment.id}'`, + }); + } + return yield* fileSystem.readFile(attachmentPath).pipe( + Effect.mapError( + (cause) => + new ProviderAdapterProtocolError({ + driver: HERMES_PROVIDER, + detail: `Failed to read Hermes attachment '${attachment.id}'`, + payload: cause, + }), + ), + ); + }), + resolveHistoryMedia: ({ sourcePath, expectedKind, threadId, stableKey }) => + persistHermesHistoryMedia({ + sourcePath, + expectedKind, + approvedRoots: approvedHistoryMediaRoots, + attachmentsDir: serverConfig.attachmentsDir, + threadId, + stableKey, + }), + }); + }, +); + +export const HermesServeAdapterV2Driver: ProviderAdapterDriver< + HermesSettings, + HermesServeAdapterV2DriverEnv +> = { + driverKind: HERMES_DRIVER_KIND, + configSchema: HermesSettings, + defaultConfig: () => DEFAULT_HERMES_SETTINGS, + create: Effect.fn("HermesServeAdapterV2Driver.create")( + (input: ProviderAdapterDriverCreateInput) => + makeHermesServeAdapterV2Driver(input), + (effect, input) => + effect.pipe( + Effect.mapError( + (cause) => + new ProviderAdapterDriverCreateError({ + driver: HERMES_DRIVER_KIND, + instanceId: input.instanceId, + detail: "Failed to create Hermes adapter.", + cause, + }), + ), + ), + ), +}; diff --git a/apps/server/src/orchestration-v2/Adapters/OpenClawAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/OpenClawAdapterV2.test.ts new file mode 100644 index 00000000000..fb65489924d --- /dev/null +++ b/apps/server/src/orchestration-v2/Adapters/OpenClawAdapterV2.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { BUILT_IN_PROVIDER_ADAPTER_DRIVER_KINDS_V2 } from "../builtInProviderAdapterDrivers.ts"; +import { OPENCLAW_DRIVER_KIND, OpenClawAdapterV2Driver } from "./OpenClawAdapterV2.ts"; + +describe("OpenClawAdapterV2", () => { + it("registers the standard ACP adapter flavor", () => { + expect(BUILT_IN_PROVIDER_ADAPTER_DRIVER_KINDS_V2.has(OPENCLAW_DRIVER_KIND)).toBe(true); + expect(OpenClawAdapterV2Driver.driverKind).toBe("openclaw"); + expect(OpenClawAdapterV2Driver.defaultConfig()).toEqual({ + enabled: true, + binaryPath: "openclaw", + url: "", + tokenFile: "", + passwordFile: "", + session: "", + resetSession: false, + customModels: [], + }); + }); +}); diff --git a/apps/server/src/orchestration-v2/Adapters/OpenClawAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/OpenClawAdapterV2.ts new file mode 100644 index 00000000000..b8be514d176 --- /dev/null +++ b/apps/server/src/orchestration-v2/Adapters/OpenClawAdapterV2.ts @@ -0,0 +1,139 @@ +import { + defaultInstanceIdForDriver, + OpenClawSettings, + ProviderDriverKind, +} from "@t3tools/contracts"; +import { HostProcessEnvironment } from "@t3tools/shared/hostProcess"; +import * as Crypto from "effect/Crypto"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Schema from "effect/Schema"; +import type * as Scope from "effect/Scope"; +import { ChildProcessSpawner } from "effect/unstable/process"; +import type * as EffectAcpErrors from "effect-acp/errors"; + +import { ServerConfig } from "../../config.ts"; +import { makeAcpNativeLoggerFactory } from "../../provider/acp/AcpNativeLogging.ts"; +import * as AcpSessionRuntime from "../../provider/acp/AcpSessionRuntime.ts"; +import { makeOpenClawRuntime } from "../../provider/acp/OpenClawSupport.ts"; +import { ProviderEventLoggers } from "../../provider/Layers/ProviderEventLoggers.ts"; +import { mergeProviderInstanceEnvironment } from "../../provider/ProviderInstanceEnvironment.ts"; +import { IdAllocatorV2 } from "../IdAllocator.ts"; +import { + ProviderAdapterDriverCreateError, + type ProviderAdapterDriver, + type ProviderAdapterDriverCreateInput, +} from "../ProviderAdapterDriver.ts"; +import { + AcpProviderCapabilitiesV2, + makeAcpAdapterV2, + type AcpAdapterV2RuntimeInput, +} from "./AcpAdapterV2.ts"; + +export const OPENCLAW_PROVIDER = ProviderDriverKind.make("openclaw"); +export const OPENCLAW_DRIVER_KIND = OPENCLAW_PROVIDER; +export const OPENCLAW_DEFAULT_INSTANCE_ID = defaultInstanceIdForDriver(OPENCLAW_DRIVER_KIND); + +const DEFAULT_OPENCLAW_SETTINGS = Schema.decodeSync(OpenClawSettings)({}); + +export interface OpenClawAdapterV2Options { + readonly instanceId: Parameters[0]["instanceId"]; + readonly settings: OpenClawSettings; + readonly environment: NodeJS.ProcessEnv; + readonly childProcessSpawner: ChildProcessSpawner.ChildProcessSpawner["Service"]; + readonly crypto: Crypto.Crypto; + readonly fileSystem: FileSystem.FileSystem; + readonly idAllocator: IdAllocatorV2["Service"]; + readonly serverConfig: ServerConfig["Service"]; + readonly nativeLogging?: Parameters[0]["nativeLogging"]; + readonly makeRuntime?: ( + input: AcpAdapterV2RuntimeInput, + ) => Effect.Effect< + AcpSessionRuntime.AcpSessionRuntime["Service"], + EffectAcpErrors.AcpError, + Crypto.Crypto | Scope.Scope + >; + readonly assertComplete?: Effect.Effect; +} + +export function makeOpenClawAdapterV2(options: OpenClawAdapterV2Options) { + return makeAcpAdapterV2({ + instanceId: options.instanceId, + flavor: { + driver: OPENCLAW_PROVIDER, + capabilities: AcpProviderCapabilitiesV2, + makeRuntime: + options.makeRuntime ?? + ((input) => + makeOpenClawRuntime({ + ...input, + openClawSettings: options.settings, + environment: options.environment, + childProcessSpawner: options.childProcessSpawner, + })), + ...(options.assertComplete === undefined ? {} : { assertComplete: options.assertComplete }), + }, + crypto: options.crypto, + fileSystem: options.fileSystem, + idAllocator: options.idAllocator, + serverConfig: options.serverConfig, + ...(options.nativeLogging === undefined ? {} : { nativeLogging: options.nativeLogging }), + }); +} + +export type OpenClawAdapterV2DriverEnv = + | ChildProcessSpawner.ChildProcessSpawner + | Crypto.Crypto + | FileSystem.FileSystem + | IdAllocatorV2 + | ProviderEventLoggers + | ServerConfig; + +export const OpenClawAdapterV2Driver: ProviderAdapterDriver< + OpenClawSettings, + OpenClawAdapterV2DriverEnv +> = { + driverKind: OPENCLAW_DRIVER_KIND, + configSchema: OpenClawSettings, + defaultConfig: (): OpenClawSettings => DEFAULT_OPENCLAW_SETTINGS, + create: Effect.fn("OpenClawAdapterV2Driver.create")( + function* (input: ProviderAdapterDriverCreateInput) { + const hostEnvironment = yield* HostProcessEnvironment; + const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const crypto = yield* Crypto.Crypto; + const fileSystem = yield* FileSystem.FileSystem; + const idAllocator = yield* IdAllocatorV2; + const providerEventLoggers = yield* ProviderEventLoggers; + const serverConfig = yield* ServerConfig; + const makeNativeLogger = yield* makeAcpNativeLoggerFactory(); + return makeOpenClawAdapterV2({ + instanceId: input.instanceId, + settings: { ...input.config, enabled: input.enabled }, + environment: mergeProviderInstanceEnvironment(input.environment, hostEnvironment), + childProcessSpawner, + crypto, + fileSystem, + idAllocator, + serverConfig, + nativeLogging: (threadId) => + makeNativeLogger({ + nativeEventLogger: providerEventLoggers.native, + provider: OPENCLAW_PROVIDER, + threadId, + }), + }); + }, + (effect, input) => + effect.pipe( + Effect.mapError( + (cause) => + new ProviderAdapterDriverCreateError({ + driver: OPENCLAW_DRIVER_KIND, + instanceId: input.instanceId, + detail: "Failed to create OpenClaw ACP adapter.", + cause, + }), + ), + ), + ), +}; diff --git a/apps/server/src/orchestration-v2/EffectOutbox.ts b/apps/server/src/orchestration-v2/EffectOutbox.ts index f7f49826fd8..b403d159993 100644 --- a/apps/server/src/orchestration-v2/EffectOutbox.ts +++ b/apps/server/src/orchestration-v2/EffectOutbox.ts @@ -109,6 +109,14 @@ export const PROCESS_BOUND_EFFECT_TYPES = [ "runtime-request.respond", ] as const satisfies ReadonlyArray; +/** + * Prefix persisted in `last_error` when an effect has permanently failed but + * its terminal projection could not be committed. A reclaim that sees this + * marker must only retry the terminal projection and never re-run the + * side-effecting provider execution. + */ +export const PENDING_TERMINALIZATION_MARKER = "[pending-terminalization] "; + export const OrchestrationEffectStatusV2 = Schema.Literals([ "pending", "running", @@ -434,7 +442,10 @@ export const layer: Layer.Layer = La lease_owner = ${workerId}, lease_expires_at = ${leaseExpiresAt}, updated_at = ${nowIso}, - last_error = NULL + last_error = CASE + WHEN last_error LIKE ${`${PENDING_TERMINALIZATION_MARKER}%`} THEN last_error + ELSE NULL + END WHERE effect_id = ( SELECT candidate.effect_id FROM orchestration_v2_effect_outbox AS candidate diff --git a/apps/server/src/orchestration-v2/EffectWorker.test.ts b/apps/server/src/orchestration-v2/EffectWorker.test.ts index 583e16e2ec7..82952da1cff 100644 --- a/apps/server/src/orchestration-v2/EffectWorker.test.ts +++ b/apps/server/src/orchestration-v2/EffectWorker.test.ts @@ -16,16 +16,28 @@ import * as Option from "effect/Option"; import * as Ref from "effect/Ref"; import { CheckpointRollbackServiceV2 } from "./CheckpointRollbackService.ts"; -import type { OrchestrationEffectV2 } from "./EffectOutbox.ts"; +import { + EffectOutboxV2, + PENDING_TERMINALIZATION_MARKER, + type OrchestrationEffectV2, +} from "./EffectOutbox.ts"; import { executorLayer, isNonRetryableProviderTurnControlFailure, + isNonRetryableProviderTurnStartPrerequisiteFailure, + layerWithOptions as effectWorkerLayerWithOptions, + OrchestrationEffectExecutionError, OrchestrationEffectExecutorV2, + OrchestrationEffectWorkerV2, } from "./EffectWorker.ts"; import { RunFinalizationService } from "./RunFinalizationService.ts"; import { ProviderSessionManagerV2 } from "./ProviderSessionManager.ts"; import { ProviderTurnControlServiceV2 } from "./ProviderTurnControlService.ts"; -import { ProviderTurnStartError, ProviderTurnStartServiceV2 } from "./ProviderTurnStartService.ts"; +import { + canTerminalizeProviderTurnStartFailure, + ProviderTurnStartError, + ProviderTurnStartServiceV2, +} from "./ProviderTurnStartService.ts"; import { RuntimeRequestServiceV2 } from "./RuntimeRequestService.ts"; const threadId = ThreadId.make("thread:effect-worker-restart"); @@ -119,6 +131,7 @@ function makeExecutorLayer(input: { }); } }), + failPermanently: () => record("fail-permanently"), }), ), Layer.succeed( @@ -171,6 +184,24 @@ it("does not retry pure interrupt races where the turn is already gone", () => { ); }); +it("classifies checkpoint baseline prerequisites as non-retryable start failures", () => { + assert.isTrue( + isNonRetryableProviderTurnStartPrerequisiteFailure( + "provider-turn.start", + "CheckpointBaselineCaptureError: Failed to capture checkpoint baseline 0", + ), + ); + assert.isFalse( + isNonRetryableProviderTurnStartPrerequisiteFailure( + "provider-turn.interrupt", + "CheckpointBaselineCaptureError: Failed to capture checkpoint baseline 0", + ), + ); + assert.isTrue(canTerminalizeProviderTurnStartFailure("starting")); + assert.isTrue(canTerminalizeProviderTurnStartFailure("running")); + assert.isFalse(canTerminalizeProviderTurnStartFailure("completed")); +}); + it.effect("detaches a handed-off session only after the old turn terminalizes", () => Effect.gen(function* () { const now = yield* DateTime.now; @@ -217,3 +248,268 @@ it.effect("safely retries after replacement cleanup succeeds and start fails", ( ]); }), ); + +it.effect("routes exhausted restart effects to permanent start failure handling", () => + Effect.gen(function* () { + const now = yield* DateTime.now; + const events = yield* Ref.make>([]); + + yield* Effect.gen(function* () { + const executor = yield* OrchestrationEffectExecutorV2; + assert.isDefined(executor.handlePermanentFailure); + yield* executor.handlePermanentFailure?.(restartEffect(now, { type: "detach" })); + }).pipe(Effect.provide(makeExecutorLayer({ events }))); + + assert.deepEqual(yield* Ref.get(events), ["fail-permanently"]); + }), +); + +it.effect("projects permanent failure before failing an exhausted outbox effect", () => + Effect.gen(function* () { + const now = yield* DateTime.now; + const events = yield* Ref.make>([]); + const record = (event: string) => Ref.update(events, (current) => [...current, event]); + const effect = { + ...restartEffect(now, { type: "detach" }), + attemptCount: 5, + leaseOwner: "permanent-failure-worker", + }; + const outboxLayer = Layer.mock(EffectOutboxV2)({ + claimNext: () => Effect.succeed(Option.some(effect)), + get: () => Effect.succeed(Option.some(effect)), + awaitCancellation: () => Effect.never, + clearCancellation: () => Effect.void, + fail: () => record("outbox-fail").pipe(Effect.as(true)), + }); + const executorLayer = Layer.succeed( + OrchestrationEffectExecutorV2, + OrchestrationEffectExecutorV2.of({ + execute: () => + record("execute").pipe( + Effect.andThen( + Effect.fail( + new OrchestrationEffectExecutionError({ + effectId: effect.id, + effectType: effect.request.type, + cause: "simulated transport failure", + }), + ), + ), + ), + handlePermanentFailure: () => record("terminalize"), + }), + ); + const workerLayer = effectWorkerLayerWithOptions({ + workerId: "permanent-failure-worker", + maxAttempts: 5, + }).pipe(Layer.provide(Layer.merge(outboxLayer, executorLayer))); + + assert.isTrue( + yield* OrchestrationEffectWorkerV2.pipe( + Effect.flatMap((worker) => worker.runOnce), + Effect.provide(workerLayer), + ), + ); + assert.deepEqual(yield* Ref.get(events), ["execute", "terminalize", "outbox-fail"]); + }), +); + +it.effect("terminalizes a non-retryable checkpoint prerequisite on its first attempt", () => + Effect.gen(function* () { + const now = yield* DateTime.now; + const events = yield* Ref.make>([]); + const record = (event: string) => Ref.update(events, (current) => [...current, event]); + const effect = { + ...restartEffect(now, { type: "detach" }), + leaseOwner: "checkpoint-prerequisite-worker", + }; + const outboxLayer = Layer.mock(EffectOutboxV2)({ + claimNext: () => Effect.succeed(Option.some(effect)), + get: () => Effect.succeed(Option.some(effect)), + awaitCancellation: () => Effect.never, + clearCancellation: () => Effect.void, + fail: () => record("outbox-fail").pipe(Effect.as(true)), + }); + const executorLayer = Layer.succeed( + OrchestrationEffectExecutorV2, + OrchestrationEffectExecutorV2.of({ + execute: () => + Effect.fail( + new OrchestrationEffectExecutionError({ + effectId: effect.id, + effectType: effect.request.type, + cause: "CheckpointBaselineCaptureError: Failed to capture checkpoint baseline 0", + }), + ), + handlePermanentFailure: () => record("terminalize"), + }), + ); + const workerLayer = effectWorkerLayerWithOptions({ + workerId: "checkpoint-prerequisite-worker", + maxAttempts: 5, + }).pipe(Layer.provide(Layer.merge(outboxLayer, executorLayer))); + + assert.isTrue( + yield* OrchestrationEffectWorkerV2.pipe( + Effect.flatMap((worker) => worker.runOnce), + Effect.provide(workerLayer), + ), + ); + assert.deepEqual(yield* Ref.get(events), ["terminalize", "outbox-fail"]); + }), +); + +it.effect("retries instead of failing the outbox effect when terminal projection fails", () => + Effect.gen(function* () { + const now = yield* DateTime.now; + const events = yield* Ref.make>([]); + const record = (event: string) => Ref.update(events, (current) => [...current, event]); + const effect = { + ...restartEffect(now, { type: "detach" }), + attemptCount: 5, + leaseOwner: "projection-retry-worker", + }; + const retryErrors: Array = []; + const outboxLayer = Layer.mock(EffectOutboxV2)({ + claimNext: () => Effect.succeed(Option.some(effect)), + get: () => Effect.succeed(Option.some(effect)), + awaitCancellation: () => Effect.never, + clearCancellation: () => Effect.void, + fail: () => record("outbox-fail").pipe(Effect.as(true)), + retry: ({ error }) => + Effect.sync(() => { + retryErrors.push(error); + }).pipe(Effect.andThen(record("outbox-retry")), Effect.as(true)), + }); + const executorLayer = Layer.succeed( + OrchestrationEffectExecutorV2, + OrchestrationEffectExecutorV2.of({ + execute: () => + Effect.fail( + new OrchestrationEffectExecutionError({ + effectId: effect.id, + effectType: effect.request.type, + cause: "simulated transport failure", + }), + ), + handlePermanentFailure: () => + record("terminalize").pipe( + Effect.andThen( + Effect.fail( + new OrchestrationEffectExecutionError({ + effectId: effect.id, + effectType: effect.request.type, + cause: "simulated projection store failure", + }), + ), + ), + ), + }), + ); + const workerLayer = effectWorkerLayerWithOptions({ + workerId: "projection-retry-worker", + maxAttempts: 5, + }).pipe(Layer.provide(Layer.merge(outboxLayer, executorLayer))); + + assert.isTrue( + yield* OrchestrationEffectWorkerV2.pipe( + Effect.flatMap((worker) => worker.runOnce), + Effect.provide(workerLayer), + ), + ); + assert.deepEqual(yield* Ref.get(events), ["terminalize", "outbox-retry"]); + assert.equal(retryErrors.length, 1); + assert.isTrue(retryErrors[0]?.startsWith(PENDING_TERMINALIZATION_MARKER)); + }), +); + +it.effect("only retries the terminal projection when reclaiming a pending terminalization", () => + Effect.gen(function* () { + const now = yield* DateTime.now; + const events = yield* Ref.make>([]); + const record = (event: string) => Ref.update(events, (current) => [...current, event]); + const effect = { + ...restartEffect(now, { type: "detach" }), + attemptCount: 2, + leaseOwner: "pending-terminalization-worker", + lastError: `${PENDING_TERMINALIZATION_MARKER}simulated transport failure`, + }; + const outboxLayer = Layer.mock(EffectOutboxV2)({ + claimNext: () => Effect.succeed(Option.some(effect)), + get: () => Effect.succeed(Option.some(effect)), + awaitCancellation: () => Effect.never, + clearCancellation: () => Effect.void, + fail: ({ error }) => record(`outbox-fail:${error}`).pipe(Effect.as(true)), + }); + const executorLayer = Layer.succeed( + OrchestrationEffectExecutorV2, + OrchestrationEffectExecutorV2.of({ + execute: () => record("execute").pipe(Effect.asVoid), + handlePermanentFailure: () => record("terminalize"), + }), + ); + const workerLayer = effectWorkerLayerWithOptions({ + workerId: "pending-terminalization-worker", + maxAttempts: 5, + }).pipe(Layer.provide(Layer.merge(outboxLayer, executorLayer))); + + assert.isTrue( + yield* OrchestrationEffectWorkerV2.pipe( + Effect.flatMap((worker) => worker.runOnce), + Effect.provide(workerLayer), + ), + ); + // The provider execution never re-runs; only the projection is retried. + assert.deepEqual(yield* Ref.get(events), [ + "terminalize", + "outbox-fail:simulated transport failure", + ]); + }), +); + +it.effect("does not project a permanent failure after losing the effect lease", () => + Effect.gen(function* () { + const now = yield* DateTime.now; + const events = yield* Ref.make>([]); + const record = (event: string) => Ref.update(events, (current) => [...current, event]); + const claimed = { + ...restartEffect(now, { type: "detach" }), + attemptCount: 5, + leaseOwner: "stale-worker", + }; + const reclaimed = { ...claimed, leaseOwner: "other-worker" }; + const outboxLayer = Layer.mock(EffectOutboxV2)({ + claimNext: () => Effect.succeed(Option.some(claimed)), + get: () => Effect.succeed(Option.some(reclaimed)), + awaitCancellation: () => Effect.never, + clearCancellation: () => Effect.void, + fail: () => record("outbox-fail").pipe(Effect.as(false)), + }); + const executorLayer = Layer.succeed( + OrchestrationEffectExecutorV2, + OrchestrationEffectExecutorV2.of({ + execute: () => + Effect.fail( + new OrchestrationEffectExecutionError({ + effectId: claimed.id, + effectType: claimed.request.type, + cause: "simulated transport failure", + }), + ), + handlePermanentFailure: () => record("terminalize"), + }), + ); + const workerLayer = effectWorkerLayerWithOptions({ + workerId: "stale-worker", + maxAttempts: 5, + }).pipe(Layer.provide(Layer.merge(outboxLayer, executorLayer))); + + const result = yield* OrchestrationEffectWorkerV2.pipe( + Effect.flatMap((worker) => worker.runOnce), + Effect.provide(workerLayer), + Effect.exit, + ); + assert.isTrue(Exit.isFailure(result)); + assert.deepEqual(yield* Ref.get(events), ["outbox-fail"]); + }), +); diff --git a/apps/server/src/orchestration-v2/EffectWorker.ts b/apps/server/src/orchestration-v2/EffectWorker.ts index 85c915af3f1..d1aef10a292 100644 --- a/apps/server/src/orchestration-v2/EffectWorker.ts +++ b/apps/server/src/orchestration-v2/EffectWorker.ts @@ -9,7 +9,11 @@ import * as Schema from "effect/Schema"; import { RunFinalizationService } from "./RunFinalizationService.ts"; import { ResourceCleanupService } from "./ResourceCleanupService.ts"; -import { EffectOutboxV2, type OrchestrationEffectV2 } from "./EffectOutbox.ts"; +import { + EffectOutboxV2, + PENDING_TERMINALIZATION_MARKER, + type OrchestrationEffectV2, +} from "./EffectOutbox.ts"; import { CheckpointRollbackServiceV2 } from "./CheckpointRollbackService.ts"; import { ProviderSessionManagerV2 } from "./ProviderSessionManager.ts"; import { ProviderTurnControlServiceV2 } from "./ProviderTurnControlService.ts"; @@ -49,10 +53,24 @@ export function isNonRetryableProviderTurnControlFailure( ); } +export function isNonRetryableProviderTurnStartPrerequisiteFailure( + effectType: string, + errorText: string, +): boolean { + return ( + (effectType === "provider-turn.start" || effectType === "provider-turn.restart") && + (/CheckpointBaselineCaptureError/iu.test(errorText) || + /Failed to capture checkpoint baseline/iu.test(errorText)) + ); +} + export interface OrchestrationEffectExecutorV2Shape { readonly execute: ( effect: OrchestrationEffectV2, ) => Effect.Effect; + readonly handlePermanentFailure?: ( + effect: OrchestrationEffectV2, + ) => Effect.Effect; } export class OrchestrationEffectExecutorV2 extends Context.Service< @@ -280,6 +298,29 @@ export const executorLayer: Layer.Layer< ); } }, + handlePermanentFailure: (effect) => { + switch (effect.request.type) { + case "provider-turn.start": + case "provider-turn.restart": + return providerTurnStart + .failPermanently({ + threadId: effect.threadId, + runId: effect.request.runId, + }) + .pipe( + Effect.mapError( + (cause) => + new OrchestrationEffectExecutionError({ + effectId: effect.id, + effectType: effect.request.type, + cause, + }), + ), + ); + default: + return Effect.void; + } + }, }); }), ); @@ -351,48 +392,104 @@ export const layerWithOptions = ( yield* outbox.clearCancellation(effect.id); return true; } - const execution = executor.execute(effect).pipe(Effect.as("executed" as const)); - const cancellation = outbox - .awaitCancellation(effect.id) - .pipe(Effect.as("cancelled" as const)); - const exit = yield* Effect.exit(Effect.raceFirst(execution, cancellation)).pipe( - Effect.ensuring(outbox.clearCancellation(effect.id)), - ); - if (Exit.isSuccess(exit) && exit.value === "cancelled") { - return true; + // A pending-terminalization marker means a previous attempt already + // failed permanently but its terminal projection did not commit. + // Only the projection may be retried; the side-effecting provider + // execution must never run again. + const pendingTerminalizationError = + effect.lastError !== null && effect.lastError.startsWith(PENDING_TERMINALIZATION_MARKER) + ? effect.lastError.slice(PENDING_TERMINALIZATION_MARKER.length) + : null; + let error: string; + let ignorableControlFailure = false; + let nonRetryableStartFailure = false; + if (pendingTerminalizationError === null) { + const execution = executor.execute(effect).pipe(Effect.as("executed" as const)); + const cancellation = outbox + .awaitCancellation(effect.id) + .pipe(Effect.as("cancelled" as const)); + const exit = yield* Effect.exit(Effect.raceFirst(execution, cancellation)).pipe( + Effect.ensuring(outbox.clearCancellation(effect.id)), + ); + if (Exit.isSuccess(exit) && exit.value === "cancelled") { + return true; + } + if (Exit.isSuccess(exit)) { + const completed = yield* outbox.succeed({ effectId: effect.id, workerId }); + if (!completed) { + if (yield* wasCancelled(effect.id)) return true; + return yield* new OrchestrationEffectWorkerError({ + operation: "complete", + effectId: effect.id, + cause: "The worker no longer owns the effect lease.", + }); + } + return true; + } + + error = Cause.pretty(exit.cause); + ignorableControlFailure = isNonRetryableProviderTurnControlFailure( + effect.request.type, + error, + ); + nonRetryableStartFailure = isNonRetryableProviderTurnStartPrerequisiteFailure( + effect.request.type, + error, + ); + yield* Effect.logWarning("Orchestration effect execution failed", { + effectId: effect.id, + effectType: effect.request.type, + attemptCount: effect.attemptCount, + nonRetryable: ignorableControlFailure || nonRetryableStartFailure, + error, + }); + } else { + yield* outbox.clearCancellation(effect.id); + error = pendingTerminalizationError; } - if (Exit.isSuccess(exit)) { - const completed = yield* outbox.succeed({ effectId: effect.id, workerId }); - if (!completed) { - if (yield* wasCancelled(effect.id)) return true; - return yield* new OrchestrationEffectWorkerError({ - operation: "complete", - effectId: effect.id, - cause: "The worker no longer owns the effect lease.", - }); + const exhausted = !ignorableControlFailure && effect.attemptCount >= maxAttempts; + const permanentFailure = + pendingTerminalizationError !== null || nonRetryableStartFailure || exhausted; + // Only the worker that still owns the lease may project the terminal + // failure; a stale worker must not override another owner's attempt. + let permanentProjectionFailed = false; + if (permanentFailure && executor.handlePermanentFailure !== undefined) { + const ownsLease = yield* outbox.get(effect.id).pipe( + Effect.map( + Option.match({ + onNone: () => false, + onSome: (current) => + current.status === "running" && current.leaseOwner === workerId, + }), + ), + ); + if (ownsLease) { + const projection = yield* Effect.exit(executor.handlePermanentFailure(effect)); + if (Exit.isFailure(projection)) { + permanentProjectionFailed = true; + yield* Effect.logError("Failed to project permanent orchestration effect failure", { + effectId: effect.id, + effectType: effect.request.type, + cause: projection.cause, + }); + } } - return true; } - - const error = Cause.pretty(exit.cause); - const nonRetryable = isNonRetryableProviderTurnControlFailure(effect.request.type, error); - yield* Effect.logWarning("Orchestration effect execution failed", { - effectId: effect.id, - effectType: effect.request.type, - attemptCount: effect.attemptCount, - nonRetryable, - error, - }); // Prefer succeed for terminal interrupt races so the outbox does not // keep a failed interrupt around; fail only when we must not retry. - const updated = nonRetryable + // If the terminal projection itself failed, keep the effect retryable + // so a later attempt can still terminalize the run. + const updated = ignorableControlFailure ? yield* outbox.succeed({ effectId: effect.id, workerId }) - : effect.attemptCount >= maxAttempts + : permanentFailure && !permanentProjectionFailed ? yield* outbox.fail({ effectId: effect.id, workerId, error }) : yield* outbox.retry({ effectId: effect.id, workerId, - error, + error: + permanentFailure && permanentProjectionFailed + ? `${PENDING_TERMINALIZATION_MARKER}${error}` + : error, delayMs: Math.min(30_000, 100 * 2 ** Math.max(0, effect.attemptCount - 1)), }); if (!updated) { diff --git a/apps/server/src/orchestration-v2/FoundationPersistence.test.ts b/apps/server/src/orchestration-v2/FoundationPersistence.test.ts index d218701a4a3..fcf90c17634 100644 --- a/apps/server/src/orchestration-v2/FoundationPersistence.test.ts +++ b/apps/server/src/orchestration-v2/FoundationPersistence.test.ts @@ -35,7 +35,11 @@ import * as SqlClient from "effect/unstable/sql/SqlClient"; import { SqlitePersistenceMemory } from "../persistence/Layers/Sqlite.ts"; import { CodexProviderCapabilitiesV2 } from "./Adapters/CodexAdapterV2.ts"; import { CommandReceiptStoreV2, layer as commandReceiptStoreLayer } from "./CommandReceiptStore.ts"; -import { EffectOutboxV2, layer as effectOutboxLayer } from "./EffectOutbox.ts"; +import { + EffectOutboxV2, + layer as effectOutboxLayer, + PENDING_TERMINALIZATION_MARKER, +} from "./EffectOutbox.ts"; import { layerWithOptions as effectWorkerLayerWithOptions, OrchestrationEffectExecutorV2, @@ -889,6 +893,51 @@ it.layer(TestLayer)("orchestration V2 foundation persistence", (it) => { }), ); + it.effect("preserves the pending-terminalization marker across a reclaim", () => + Effect.gen(function* () { + const outbox = yield* EffectOutboxV2; + const commandId = CommandId.make("command:foundation-pending-terminalization"); + const effectId = "effect:foundation-pending-terminalization"; + yield* outbox.enqueue([ + { + id: effectId, + commandId, + threadId: ThreadId.make("thread:foundation-pending-terminalization"), + request: { + type: "provider-turn.start", + runId: RunId.make("run:foundation-pending-terminalization"), + }, + }, + ]); + + const claimed = yield* outbox.claimNext({ workerId: "worker-a", leaseDurationMs: 30_000 }); + assert.isTrue(Option.isSome(claimed)); + yield* outbox.retry({ + effectId, + workerId: "worker-a", + error: `${PENDING_TERMINALIZATION_MARKER}projection failed`, + delayMs: 0, + }); + const reclaimed = yield* outbox.claimNext({ workerId: "worker-b", leaseDurationMs: 30_000 }); + assert.isTrue(Option.isSome(reclaimed)); + if (Option.isSome(reclaimed)) { + assert.equal( + reclaimed.value.lastError, + `${PENDING_TERMINALIZATION_MARKER}projection failed`, + ); + } + + // Ordinary retry errors are still cleared on the next claim. + yield* outbox.retry({ effectId, workerId: "worker-b", error: "transient", delayMs: 0 }); + const thirdClaim = yield* outbox.claimNext({ workerId: "worker-c", leaseDurationMs: 30_000 }); + assert.isTrue(Option.isSome(thirdClaim)); + if (Option.isSome(thirdClaim)) { + assert.isNull(thirdClaim.value.lastError); + yield* outbox.succeed({ effectId, workerId: "worker-c" }); + } + }), + ); + it.effect("runs distinct threads concurrently while serializing effects within a thread", () => Effect.gen(function* () { const outbox = yield* EffectOutboxV2; diff --git a/apps/server/src/orchestration-v2/Orchestrator.ts b/apps/server/src/orchestration-v2/Orchestrator.ts index ea49891f410..f6313d12702 100644 --- a/apps/server/src/orchestration-v2/Orchestrator.ts +++ b/apps/server/src/orchestration-v2/Orchestrator.ts @@ -142,6 +142,15 @@ export interface OrchestratorV2DispatchResult { export interface OrchestratorV2Shape { readonly resumeQueuedRuns: Effect.Effect; + /** + * Lazily materializes a provider-owned conversation snapshot into the durable + * T3 projection. Snapshot entities have provider-stable ids, so retries only + * append entities that are not already projected. + */ + readonly hydrateProviderThreadSnapshot: (input: { + readonly threadId: ThreadId; + readonly providerInstanceId: ProviderInstanceId; + }) => Effect.Effect; readonly dispatch: ( command: OrchestrationV2Command, ) => Effect.Effect; @@ -831,7 +840,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio "orchestration_v2.driver": command.modelSelection.instanceId, }); - const now = yield* DateTime.now; + const now = command.createdAt ?? (yield* DateTime.now); const emitEvent = emit(events, command); const thread: OrchestrationV2AppThread = { createdBy: command.createdBy, @@ -857,6 +866,8 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio archivedAt: null, settledOverride: null, settledAt: null, + pinnedAt: null, + workInboxRole: null, snoozedUntil: null, snoozedAt: null, deletedAt: null, @@ -937,6 +948,27 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio cause: `Thread ${command.threadId} is archived.`, }); } + if ( + thread.workInboxRole === "main" && + (command.type === "thread.settle" || command.type === "thread.snooze") + ) { + return yield* new OrchestratorDispatchError({ + commandId: command.commandId, + commandType: command.type, + cause: `Thread ${command.threadId} is the Work inbox main thread and cannot be settled or snoozed.`, + }); + } + if ( + command.type === "thread.metadata.update" && + command.pinned === true && + (thread.settledOverride === "settled" || thread.snoozedUntil != null) + ) { + return yield* new OrchestratorDispatchError({ + commandId: command.commandId, + commandType: command.type, + cause: `Thread ${command.threadId} must be active before it can be pinned.`, + }); + } if ( command.type === "thread.settle" && (projection.runs.some((run) => @@ -1013,11 +1045,18 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio return { ...thread, deletedAt: thread.deletedAt ?? now, updatedAt: now }; case "thread.settle": { const alreadySettled = thread.settledOverride === "settled" && thread.settledAt !== null; + // Historical settles (provider imports) supply their own settledAt + // so imported threads sort and age by when the work actually ended. + const settledAt = + command.settledAt !== undefined + ? Option.getOrElse(DateTime.make(command.settledAt), () => now) + : now; return { ...thread, settledOverride: "settled", - settledAt: alreadySettled ? thread.settledAt : now, - updatedAt: alreadySettled ? thread.updatedAt : now, + settledAt: alreadySettled ? thread.settledAt : settledAt, + pinnedAt: null, + updatedAt: alreadySettled ? thread.updatedAt : settledAt, }; } case "thread.unsettle": { @@ -1039,6 +1078,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio ...thread, snoozedUntil, snoozedAt: existingSnoozedAt ?? now, + pinnedAt: null, updatedAt: existingSnoozedAt === null ? now : thread.updatedAt, }; } @@ -1057,6 +1097,24 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio ...(command.title === undefined ? {} : { title: command.title }), ...(command.branch === undefined ? {} : { branch: command.branch }), ...(command.worktreePath === undefined ? {} : { worktreePath: command.worktreePath }), + ...(command.pinned === undefined + ? {} + : { pinnedAt: command.pinned || thread.workInboxRole === "main" ? now : null }), + ...(command.workInboxRole === undefined + ? {} + : { + workInboxRole: command.workInboxRole, + ...(command.workInboxRole === "main" + ? { + pinnedAt: thread.pinnedAt ?? now, + settledOverride: null, + settledAt: null, + snoozedUntil: null, + snoozedAt: null, + } + : {}), + }), + ...(command.clearTimeline === true ? { timelineClearedAt: now } : {}), updatedAt: now, }; case "thread.runtime-mode.set": @@ -5632,6 +5690,134 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio const dispatchWithReceipt = (command: OrchestrationV2Command) => threadDispatch.withLock(commandThreadId(command), dispatchWithReceiptEffect(command)); + const hydrateProviderThreadSnapshot = (input: { + readonly threadId: ThreadId; + readonly providerInstanceId: ProviderInstanceId; + }) => + threadDispatch.withLock( + input.threadId, + Effect.gen(function* () { + const threadId = input.threadId; + const projection = yield* projectionStore.getThreadProjection(threadId); + const modelSelection = projection.thread.modelSelection; + if (modelSelection.instanceId !== input.providerInstanceId) { + return; + } + const adapter = yield* providerAdapters.get(modelSelection.instanceId); + const existingProviderThread = rootProviderThreadsForProvider( + projection, + modelSelection.instanceId, + )[0]; + const providerSessionId = + existingProviderThread?.providerSessionId ?? + (yield* providerSessionIdFor({ + adapter, + providerInstanceId: modelSelection.instanceId, + threadId, + })); + const resolvedRuntimePolicy = yield* runtimePolicy.resolve({ + thread: projection.thread, + modelSelection, + }); + const session = yield* providerSessions.open({ + threadId, + providerSessionId, + modelSelection, + runtimePolicy: resolvedRuntimePolicy, + }); + const providerThread = yield* session.ensureThread({ + threadId, + modelSelection, + runtimePolicy: resolvedRuntimePolicy, + ...(existingProviderThread === undefined ? {} : { existingProviderThread }), + }); + const snapshot = yield* session.readThreadSnapshot({ providerThread }); + + // Opening the runtime persists its provider-session attachment, so + // compare against a fresh projection before appending snapshot rows. + const current = yield* projectionStore.getThreadProjection(threadId); + const projectedMessageIds = new Set(current.messages.map((message) => String(message.id))); + const missingMessages = snapshot.messages.filter( + (message) => !projectedMessageIds.has(String(message.id)), + ); + const projectedTurnItemIds = new Set(current.turnItems.map((item) => String(item.id))); + const missingTurnItems = (snapshot.turnItems ?? []).filter( + (item) => !projectedTurnItemIds.has(String(item.id)), + ); + const projectedProviderThread = current.providerThreads.find( + (candidate) => candidate.id === snapshot.providerThread.id, + ); + if ( + projectedProviderThread !== undefined && + missingMessages.length === 0 && + missingTurnItems.length === 0 + ) { + return; + } + + // Hydrated snapshots carry the provider's historical message + // timestamps; stamping hydration time here would mark imported + // threads as active "now". + const latestSnapshotAt = [...missingMessages, ...missingTurnItems].reduce< + DateTime.Utc | undefined + >( + (latest, entity) => + latest === undefined ? entity.updatedAt : DateTime.max(latest, entity.updatedAt), + undefined, + ); + const occurredAt = latestSnapshotAt ?? (yield* DateTime.now); + const events: Array = []; + if (projectedProviderThread === undefined) { + events.push({ + id: yield* idAllocator.allocate.event({ threadId, providerSessionId }), + type: "provider-thread.updated", + threadId, + providerInstanceId: modelSelection.instanceId, + driver: adapter.driver, + occurredAt, + payload: snapshot.providerThread, + }); + } + for (const message of missingMessages) { + events.push({ + id: yield* idAllocator.allocate.event({ threadId, providerSessionId }), + type: "message.updated", + threadId, + ...(message.runId === null ? {} : { runId: message.runId }), + ...(message.nodeId === null ? {} : { nodeId: message.nodeId }), + providerInstanceId: modelSelection.instanceId, + driver: adapter.driver, + occurredAt, + payload: message, + }); + } + for (const turnItem of missingTurnItems) { + events.push({ + id: yield* idAllocator.allocate.event({ threadId, providerSessionId }), + type: "turn-item.updated", + threadId, + ...(turnItem.runId === null ? {} : { runId: turnItem.runId }), + ...(turnItem.nodeId === null ? {} : { nodeId: turnItem.nodeId }), + providerInstanceId: modelSelection.instanceId, + driver: adapter.driver, + occurredAt, + payload: turnItem, + }); + } + if (events.length > 0) { + yield* eventSink.write({ events }); + } + }).pipe( + Effect.mapError( + (cause) => + new OrchestratorProjectionError({ + threadId: input.threadId, + cause, + }), + ), + ), + ); + yield* eventSink.stream().pipe( Stream.filter( (stored) => @@ -5664,6 +5850,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio return OrchestratorV2.of({ resumeQueuedRuns, + hydrateProviderThreadSnapshot, dispatch: dispatchWithReceipt, getThreadProjection: (threadId) => projectionStore @@ -5743,6 +5930,13 @@ export const layerUnavailable: Layer.Layer = Layer.succeed( cause: "Orchestration V2 live runtime is not configured.", }), ), + hydrateProviderThreadSnapshot: (input) => + Effect.fail( + new OrchestratorProjectionError({ + threadId: input.threadId, + cause: "Orchestration V2 live runtime is not configured.", + }), + ), dispatch: (command) => Effect.fail( new OrchestratorDispatchError({ diff --git a/apps/server/src/orchestration-v2/ProjectionStore.ts b/apps/server/src/orchestration-v2/ProjectionStore.ts index 63cc7837cc4..6681cd23f9a 100644 --- a/apps/server/src/orchestration-v2/ProjectionStore.ts +++ b/apps/server/src/orchestration-v2/ProjectionStore.ts @@ -190,6 +190,21 @@ export function applyToProjection( ...base, thread: event.payload, }; + case "thread.title-reconciled": { + const currentRevision = projection.thread.titleRevision ?? 0; + if (event.payload.revision <= currentRevision) { + return projection; + } + return { + ...base, + thread: { + ...base.thread, + title: event.payload.title, + titleRevision: event.payload.revision, + titleOrigin: event.payload.origin, + }, + }; + } case "run.created": case "run.updated": return withLocalVisibleTurnItems({ @@ -456,45 +471,61 @@ const encodeContextTransferPayload = Schema.encodeEffect( Schema.fromJsonString(OrchestrationV2ContextTransferJsonSchema), ); -const decodeThreadPayload = (json: string) => - Schema.decodeUnknownEffect(Schema.fromJsonString(OrchestrationV2AppThreadJsonSchema))(json); -const decodeRunPayload = (json: string) => - Schema.decodeUnknownEffect(Schema.fromJsonString(OrchestrationV2RunJsonSchema))(json); -const decodeRunAttemptPayload = (json: string) => - Schema.decodeUnknownEffect(Schema.fromJsonString(OrchestrationV2RunAttemptJsonSchema))(json); -const decodeNodePayload = (json: string) => - Schema.decodeUnknownEffect(Schema.fromJsonString(OrchestrationV2ExecutionNodeJsonSchema))(json); -const decodeSubagentPayload = (json: string) => - Schema.decodeUnknownEffect(Schema.fromJsonString(OrchestrationV2SubagentJsonSchema))(json); -const decodeProviderSessionPayload = (json: string) => - Schema.decodeUnknownEffect(Schema.fromJsonString(OrchestrationV2ProviderSessionJsonSchema))(json); -const decodeProviderThreadPayload = (json: string) => - Schema.decodeUnknownEffect(Schema.fromJsonString(OrchestrationV2ProviderThreadJsonSchema))(json); -const decodeProviderTurnPayload = (json: string) => - Schema.decodeUnknownEffect(Schema.fromJsonString(OrchestrationV2ProviderTurnJsonSchema))(json); -const decodeRuntimeRequestPayload = (json: string) => - Schema.decodeUnknownEffect(Schema.fromJsonString(OrchestrationV2RuntimeRequestJsonSchema))(json); -const decodeMessagePayload = (json: string) => - Schema.decodeUnknownEffect(Schema.fromJsonString(OrchestrationV2ConversationMessageJsonSchema))( - json, - ); -const decodePlanPayload = (json: string) => - Schema.decodeUnknownEffect(OrchestrationV2PlanArtifactSchema)(parseEncodedPayload(json)); -const decodeTurnItemPayload = (json: string) => - Schema.decodeUnknownEffect(Schema.fromJsonString(OrchestrationV2TurnItemJsonSchema))(json); -const decodeCheckpointScopePayload = (json: string) => - Schema.decodeUnknownEffect(Schema.fromJsonString(OrchestrationV2CheckpointScopeJsonSchema))(json); -const decodeCheckpointPayload = (json: string) => - Schema.decodeUnknownEffect(Schema.fromJsonString(OrchestrationV2CheckpointJsonSchema))(json); -const decodeContextHandoffPayload = (json: string) => - Schema.decodeUnknownEffect(Schema.fromJsonString(OrchestrationV2ContextHandoffJsonSchema))(json); -const decodeContextTransferPayload = (json: string) => - Schema.decodeUnknownEffect(Schema.fromJsonString(OrchestrationV2ContextTransferJsonSchema))(json); +const decodeThreadPayload = Schema.decodeUnknownEffect( + Schema.fromJsonString(OrchestrationV2AppThreadJsonSchema), +); +const decodeRunPayload = Schema.decodeUnknownEffect( + Schema.fromJsonString(OrchestrationV2RunJsonSchema), +); +const decodeRunAttemptPayload = Schema.decodeUnknownEffect( + Schema.fromJsonString(OrchestrationV2RunAttemptJsonSchema), +); +const decodeNodePayload = Schema.decodeUnknownEffect( + Schema.fromJsonString(OrchestrationV2ExecutionNodeJsonSchema), +); +const decodeSubagentPayload = Schema.decodeUnknownEffect( + Schema.fromJsonString(OrchestrationV2SubagentJsonSchema), +); +const decodeProviderSessionPayload = Schema.decodeUnknownEffect( + Schema.fromJsonString(OrchestrationV2ProviderSessionJsonSchema), +); +const decodeProviderThreadPayload = Schema.decodeUnknownEffect( + Schema.fromJsonString(OrchestrationV2ProviderThreadJsonSchema), +); +const decodeProviderTurnPayload = Schema.decodeUnknownEffect( + Schema.fromJsonString(OrchestrationV2ProviderTurnJsonSchema), +); +const decodeRuntimeRequestPayload = Schema.decodeUnknownEffect( + Schema.fromJsonString(OrchestrationV2RuntimeRequestJsonSchema), +); +const decodeMessagePayload = Schema.decodeUnknownEffect( + Schema.fromJsonString(OrchestrationV2ConversationMessageJsonSchema), +); +const decodePlanArtifact = Schema.decodeUnknownEffect(OrchestrationV2PlanArtifactSchema); +const decodePlanPayload = (json: string) => decodePlanArtifact(parseEncodedPayload(json)); +const decodeTurnItemPayload = Schema.decodeUnknownEffect( + Schema.fromJsonString(OrchestrationV2TurnItemJsonSchema), +); +const decodeCheckpointScopePayload = Schema.decodeUnknownEffect( + Schema.fromJsonString(OrchestrationV2CheckpointScopeJsonSchema), +); +const decodeCheckpointPayload = Schema.decodeUnknownEffect( + Schema.fromJsonString(OrchestrationV2CheckpointJsonSchema), +); +const decodeContextHandoffPayload = Schema.decodeUnknownEffect( + Schema.fromJsonString(OrchestrationV2ContextHandoffJsonSchema), +); +const decodeContextTransferPayload = Schema.decodeUnknownEffect( + Schema.fromJsonString(OrchestrationV2ContextTransferJsonSchema), +); function parseEncodedPayload(json: string): Record { return JSON.parse(json) as Record; } +const isProjectionStoreThreadNotFoundError = Schema.is(ProjectionStoreThreadNotFoundError); +const isProjectionStoreReadError = Schema.is(ProjectionStoreReadError); + function stringField(payload: Record, field: string): string { const value = payload[field]; return typeof value === "string" ? value : String(value); @@ -938,6 +969,8 @@ function shellFromState(input: { id: input.state.thread.id, projectId: input.state.thread.projectId, title: input.state.thread.title, + titleRevision: input.state.thread.titleRevision, + titleOrigin: input.state.thread.titleOrigin, providerInstanceId: input.state.thread.providerInstanceId, modelSelection: input.state.thread.modelSelection, runtimeMode: input.state.thread.runtimeMode, @@ -979,6 +1012,9 @@ function shellFromState(input: { archivedAt: input.state.thread.archivedAt, settledOverride: input.state.thread.settledOverride, settledAt: input.state.thread.settledAt, + pinnedAt: input.state.thread.pinnedAt ?? null, + workInboxRole: input.state.thread.workInboxRole ?? null, + timelineClearedAt: input.state.thread.timelineClearedAt ?? null, snoozedUntil: input.state.thread.snoozedUntil ?? null, snoozedAt: input.state.thread.snoozedAt ?? null, deletedAt: input.state.thread.deletedAt, @@ -1056,6 +1092,35 @@ export const layer: Layer.Layer = `; break; } + case "thread.title-reconciled": { + const rows = yield* sql` + SELECT payload_json + FROM orchestration_v2_projection_threads + WHERE thread_id = ${event.threadId} + LIMIT 1 + `; + const row = rows[0]; + if (row === undefined) break; + const thread = yield* decodeThreadPayload(row.payload_json); + if (event.payload.revision <= (thread.titleRevision ?? 0)) break; + const updatedThread = { + ...thread, + title: event.payload.title, + titleRevision: event.payload.revision, + titleOrigin: event.payload.origin, + updatedAt: event.occurredAt, + }; + const payloadJson = yield* encodeThreadPayload(updatedThread); + yield* sql` + UPDATE orchestration_v2_projection_threads + SET + title = ${event.payload.title}, + updated_at = ${stringField(parseEncodedPayload(payloadJson), "updatedAt")}, + payload_json = ${payloadJson} + WHERE thread_id = ${event.threadId} + `; + break; + } case "run.created": case "run.updated": { const payloadJson = yield* encodeRunPayload(event.payload); @@ -1778,7 +1843,8 @@ export const layer: Layer.Layer = event.type !== "thread.runtime-mode-updated" && event.type !== "thread.interaction-mode-updated" && event.type !== "thread.model-selection-updated" && - event.type !== "thread.provider-switched" + event.type !== "thread.provider-switched" && + event.type !== "thread.title-reconciled" ) { const rows = yield* sql` SELECT payload_json @@ -2004,7 +2070,7 @@ export const layer: Layer.Layer = return withLocalVisibleTurnItems(projection); }).pipe( Effect.mapError((cause) => - Schema.is(ProjectionStoreThreadNotFoundError)(cause) + isProjectionStoreThreadNotFoundError(cause) ? cause : new ProjectionStoreReadError({ threadId, @@ -2061,8 +2127,7 @@ export const layer: Layer.Layer = ) .pipe( Effect.mapError((cause) => - Schema.is(ProjectionStoreThreadNotFoundError)(cause) || - Schema.is(ProjectionStoreReadError)(cause) + isProjectionStoreThreadNotFoundError(cause) || isProjectionStoreReadError(cause) ? cause : new ProjectionStoreReadError({ threadId, cause }), ), diff --git a/apps/server/src/orchestration-v2/ProviderAdapter.ts b/apps/server/src/orchestration-v2/ProviderAdapter.ts index d8f3595bcf3..194d89e3ffc 100644 --- a/apps/server/src/orchestration-v2/ProviderAdapter.ts +++ b/apps/server/src/orchestration-v2/ProviderAdapter.ts @@ -77,6 +77,14 @@ export const ProviderAdapterV2Event = Schema.Union([ driver: ProviderDriverKind, appThread: OrchestrationV2AppThread, }), + Schema.Struct({ + type: Schema.Literal("app_thread.title_reconciled"), + driver: ProviderDriverKind, + threadId: ThreadId, + title: Schema.String, + revision: Schema.Number, + origin: Schema.String, + }), Schema.Struct({ type: Schema.Literal("provider_session.updated"), driver: ProviderDriverKind, @@ -423,6 +431,12 @@ export interface ProviderAdapterV2ThreadSnapshot { readonly providerTurns: ReadonlyArray; readonly messages: ReadonlyArray; readonly runtimeRequests: ReadonlyArray; + /** + * Durable activities rehydrated from provider history (imported tool + * calls/results, historical reasoning). Hydration appends any items missing + * from the app projection. + */ + readonly turnItems?: ReadonlyArray; readonly providerPayload?: unknown; } diff --git a/apps/server/src/orchestration-v2/ProviderEventIngestor.ts b/apps/server/src/orchestration-v2/ProviderEventIngestor.ts index 91e4a974390..012a9fbca66 100644 --- a/apps/server/src/orchestration-v2/ProviderEventIngestor.ts +++ b/apps/server/src/orchestration-v2/ProviderEventIngestor.ts @@ -147,6 +147,18 @@ export const layer: Layer.Layer + Effect.gen(function* () { + const state = yield* Ref.make(emptyState); + const mcpConfigs = yield* Ref.make< + ReadonlyArray + >([]); + const effect = Effect.gen(function* () { + const eventSink = yield* EventSinkV2; + const idAllocator = yield* IdAllocatorV2; + const manager = yield* ProviderSessionManagerV2; + const now = yield* DateTime.now; + const threadId = ThreadId.make("thread-provider-session-manager-no-mcp"); + const providerSessionId = yield* idAllocator.allocate.providerSession({ + providerInstanceId: modelSelection.instanceId, + threadId, + }); + yield* eventSink.write({ + events: [yield* makeThreadCreatedEvent({ idAllocator, threadId, now })], + }); + + yield* manager.open({ + threadId, + providerSessionId, + modelSelection, + runtimePolicy, + }); + + assert.deepEqual(yield* Ref.get(mcpConfigs), [undefined]); + assert.isUndefined(McpProviderSession.readMcpProviderSession(threadId)); + yield* manager.close(providerSessionId); + }); + + yield* effect.pipe( + Effect.provide( + makeTestLayer({ + state, + idleTimeoutMs: 1_000, + capabilities: NoMcpCapabilities, + mcpConfigs, + }), + ), + ); + }), +); + +it.effect( + "ProviderSessionManagerV2 scopes non-full-access credentials away from worktree mutation", + () => + Effect.gen(function* () { + const state = yield* Ref.make(emptyState); + const mcpConfigs = yield* Ref.make< + ReadonlyArray + >([]); + const effect = Effect.gen(function* () { + const eventSink = yield* EventSinkV2; + const idAllocator = yield* IdAllocatorV2; + const manager = yield* ProviderSessionManagerV2; + const registry = yield* McpSessionRegistry.McpSessionRegistry; + const now = yield* DateTime.now; + const threadId = ThreadId.make("thread-provider-session-manager-scoped-mcp"); + const providerSessionId = yield* idAllocator.allocate.providerSession({ + providerInstanceId: modelSelection.instanceId, + threadId, + }); + yield* eventSink.write({ + events: [yield* makeThreadCreatedEvent({ idAllocator, threadId, now })], + }); + + yield* manager.open({ + threadId, + providerSessionId, + modelSelection, + runtimePolicy: { ...runtimePolicy, runtimeMode: "approval-required" }, + }); + + const config = (yield* Ref.get(mcpConfigs))[0]; + assert.deepEqual(config?.capabilities, ["orchestration", "preview"]); + const token = config?.authorizationHeader.replace(/^Bearer\s+/, ""); + assert.isDefined(token); + assert.deepEqual( + (yield* registry.resolve(token!, registry.audience))?.capabilities, + new Set(["preview", "orchestration"]), + ); + yield* manager.close(providerSessionId); + }); + + yield* effect.pipe( + Effect.provide(makeTestLayer({ state, idleTimeoutMs: 1_000, mcpConfigs })), + ); + }), +); + it.effect("ProviderSessionManagerV2 revokes MCP credentials when release persistence fails", () => Effect.gen(function* () { const state = yield* Ref.make(emptyState); @@ -814,12 +916,12 @@ it.effect("ProviderSessionManagerV2 revokes MCP credentials when release persist const captured = (yield* Ref.get(mcpConfigs))[0]; const token = captured?.authorizationHeader.replace(/^Bearer\s+/, ""); assert.isDefined(token); - assert.isDefined(yield* registry.resolve(token!)); + assert.isDefined(yield* registry.resolve(token!, registry.audience)); const closeError = yield* manager.close(providerSessionId).pipe(Effect.flip); assert.equal(closeError._tag, "ProviderSessionCloseError"); assert.isUndefined(McpProviderSession.readMcpProviderSession(threadId)); - assert.isUndefined(yield* registry.resolve(token!)); + assert.isUndefined(yield* registry.resolve(token!, registry.audience)); }); yield* effect.pipe( @@ -889,7 +991,10 @@ it.effect("ProviderSessionManagerV2 duplicate detach preserves replacement MCP c McpProviderSession.readMcpProviderSession(threadId)?.providerSessionId, replacement?.providerSessionId, ); - assert.equal((yield* registry.resolve(replacementToken!))?.threadId, threadId); + assert.equal( + (yield* registry.resolve(replacementToken!, registry.audience))?.threadId, + threadId, + ); }); yield* effect.pipe( @@ -965,7 +1070,10 @@ it.effect( McpProviderSession.readMcpProviderSession(threadId)?.providerSessionId, replacement?.providerSessionId, ); - assert.equal((yield* registry.resolve(replacementToken!))?.threadId, threadId); + assert.equal( + (yield* registry.resolve(replacementToken!, registry.audience))?.threadId, + threadId, + ); }); yield* effect.pipe( @@ -1021,7 +1129,7 @@ it.effect( // process's MCP client keeps using the credential it was started with. yield* manager.detach({ providerSessionId, threadId, detail: "Workspace changed." }); assert.equal( - (yield* registry.resolve(originalToken!))?.threadId, + (yield* registry.resolve(originalToken!, registry.audience))?.threadId, threadId, "detach must not revoke the credential the live provider process still holds", ); @@ -1040,11 +1148,14 @@ it.effect( original?.providerSessionId, "re-attach must reuse the existing credential, not rotate it", ); - assert.equal((yield* registry.resolve(originalToken!))?.threadId, threadId); + assert.equal( + (yield* registry.resolve(originalToken!, registry.audience))?.threadId, + threadId, + ); // Releasing the session (provider process gone) still revokes. yield* manager.close(providerSessionId); - assert.isUndefined(yield* registry.resolve(originalToken!)); + assert.isUndefined(yield* registry.resolve(originalToken!, registry.audience)); }); yield* effect.pipe( @@ -1097,13 +1208,13 @@ it.effect( const rotated = McpProviderSession.readMcpProviderSession(threadId); assert.isDefined(rotated); const rotatedToken = rotated?.authorizationHeader.replace(/^Bearer\s+/, ""); - assert.isDefined(yield* registry.resolve(rotatedToken!)); + assert.isDefined(yield* registry.resolve(rotatedToken!, registry.audience)); // Releasing S2 must revoke C2 even though S1 still carries a stale // record (of dead C1) for the same thread. yield* manager.close(s2); assert.isUndefined( - yield* registry.resolve(rotatedToken!), + yield* registry.resolve(rotatedToken!, registry.audience), "stale record on S1 must not veto revoking S2's rotated credential", ); yield* manager.close(s1); @@ -1164,7 +1275,7 @@ it.effect( "the credential the adapter was configured with must remain current", ); assert.equal( - (yield* registry.resolve(originalToken!))?.threadId, + (yield* registry.resolve(originalToken!, registry.audience))?.threadId, threadId, "the predecessor release must not revoke a credential reserved by an in-flight open", ); @@ -1214,7 +1325,7 @@ it.effect("ProviderSessionManagerV2 terminal detach revokes the thread's MCP cre yield* manager.open({ threadId, providerSessionId, modelSelection, runtimePolicy }); const issued = (yield* Ref.get(mcpConfigs)).at(-1); const token = issued?.authorizationHeader.replace(/^Bearer\s+/, ""); - assert.isDefined(yield* registry.resolve(token!)); + assert.isDefined(yield* registry.resolve(token!, registry.audience)); // Archive/delete detaches carry revokeMcpCredential: the token must die // with the thread even though the shared provider process lives on. @@ -1224,7 +1335,7 @@ it.effect("ProviderSessionManagerV2 terminal detach revokes the thread's MCP cre detail: "Thread deleted.", revokeMcpCredential: true, }); - assert.isUndefined(yield* registry.resolve(token!)); + assert.isUndefined(yield* registry.resolve(token!, registry.audience)); assert.isUndefined(McpProviderSession.readMcpProviderSession(threadId)); }); diff --git a/apps/server/src/orchestration-v2/ProviderSessionManager.ts b/apps/server/src/orchestration-v2/ProviderSessionManager.ts index 8eeacd7f685..e36e271acb8 100644 --- a/apps/server/src/orchestration-v2/ProviderSessionManager.ts +++ b/apps/server/src/orchestration-v2/ProviderSessionManager.ts @@ -24,6 +24,7 @@ import * as Scope from "effect/Scope"; import * as Stream from "effect/Stream"; import * as McpProviderSession from "../mcp/McpProviderSession.ts"; +import * as McpInvocationContext from "../mcp/McpInvocationContext.ts"; import * as McpSessionRegistry from "../mcp/McpSessionRegistry.ts"; import { EventSinkV2 } from "./EventSink.ts"; import { IdAllocatorV2 } from "./IdAllocator.ts"; @@ -315,15 +316,21 @@ export const layerWithOptions = ( const prepareMcpSession = ( threadId: ThreadId, providerInstanceId: ProviderInstanceId, + supportsMcpTools: boolean, + runtimePolicy: ProviderAdapterV2RuntimePolicy, ): Effect.Effect => - options.configureMcp === false + options.configureMcp === false || !supportsMcpTools ? Effect.sync((): PreparedMcpCredential => { - McpProviderSession.clearMcpProviderSession(threadId); return { mcpCredentialId: undefined, issued: false }; }) : mcpPrepareLock.withLock( threadId, Effect.gen(function* () { + const capabilities = new Set([ + "preview", + "orchestration", + ...(runtimePolicy.runtimeMode === "full-access" ? (["worktree"] as const) : []), + ]); // Reuse a still-valid credential for this thread instead of // rotating: long-lived provider processes (codex app-server) // build their MCP client once per conversation and keep using @@ -336,20 +343,25 @@ export const layerWithOptions = ( // revoke the credential between validation and reservation. reserveMcpCredential(threadId, existing.providerSessionId); const rawToken = existing.authorizationHeader.replace(/^Bearer\s+/, ""); - const resolved = yield* mcpSessionRegistry.resolve(rawToken); + const resolved = yield* mcpSessionRegistry.resolve( + rawToken, + mcpSessionRegistry.audience, + ); if ( resolved !== undefined && resolved.threadId === threadId && - resolved.providerInstanceId === providerInstanceId + resolved.providerInstanceId === providerInstanceId && + resolved.capabilities.size === capabilities.size && + [...capabilities].every((capability) => resolved.capabilities.has(capability)) ) { return { mcpCredentialId: existing.providerSessionId, issued: false }; } dropMcpCredentialReservation(threadId, existing.providerSessionId); } - yield* mcpSessionRegistry.revokeThread(threadId); - const credential = yield* mcpSessionRegistry.issue({ + const credential = yield* mcpSessionRegistry.rotate({ threadId, providerInstanceId, + capabilities, }); McpProviderSession.setMcpProviderSession(credential.config); reserveMcpCredential(threadId, credential.config.providerSessionId); @@ -961,6 +973,8 @@ export const layerWithOptions = ( readonly providerSessionId: ProviderSessionId; readonly threadId: ThreadId; readonly providerInstanceId: ProviderInstanceId; + readonly supportsMcpTools: boolean; + readonly runtimePolicy: ProviderAdapterV2RuntimePolicy; }) => Effect.suspend(() => { let preparedForCleanup: PreparedMcpCredential | undefined; @@ -974,7 +988,12 @@ export const layerWithOptions = ( return Effect.gen(function* () { const attached = yield* attachThread(input); if (attached) { - const prepared = yield* prepareMcpSession(input.threadId, input.providerInstanceId); + const prepared = yield* prepareMcpSession( + input.threadId, + input.providerInstanceId, + input.supportsMcpTools, + input.runtimePolicy, + ); preparedForCleanup = prepared; if (prepared.mcpCredentialId !== undefined) { const mcpCredentialId = prepared.mcpCredentialId; @@ -1146,6 +1165,8 @@ export const layerWithOptions = ( providerSessionId, threadId: input.threadId, providerInstanceId: runtime.instanceId, + supportsMcpTools: runtime.providerSession.capabilities.tools.supportsMcpTools, + runtimePolicy: input.runtimePolicy, }), ).pipe( Effect.andThen(runtime.ensureThread(input)), @@ -1179,6 +1200,12 @@ export const layerWithOptions = ( providerSessionId, threadId, providerInstanceId: runtime.instanceId, + supportsMcpTools: runtime.providerSession.capabilities.tools.supportsMcpTools, + runtimePolicy: input.runtimePolicy ?? { + runtimeMode: "approval-required", + interactionMode: "default", + cwd: runtime.providerSession.cwd, + }, }), ).pipe( Effect.andThen( @@ -1211,6 +1238,12 @@ export const layerWithOptions = ( providerSessionId, threadId: input.targetThreadId, providerInstanceId: runtime.instanceId, + supportsMcpTools: runtime.providerSession.capabilities.tools.supportsMcpTools, + runtimePolicy: input.runtimePolicy ?? { + runtimeMode: "approval-required", + interactionMode: "default", + cwd: runtime.providerSession.cwd, + }, }), ).pipe( Effect.andThen(runtime.forkThread(input)), @@ -1237,6 +1270,8 @@ export const layerWithOptions = ( providerSessionId, threadId: input.threadId, providerInstanceId: runtime.instanceId, + supportsMcpTools: runtime.providerSession.capabilities.tools.supportsMcpTools, + runtimePolicy: input.runtimePolicy, }), ).pipe( Effect.andThen(observeActivity(providerSessionId, markBusy(providerSessionId))), @@ -1384,6 +1419,9 @@ export const layerWithOptions = ( providerSessionId: input.providerSessionId, threadId: input.threadId, providerInstanceId: existing.runtime.instanceId, + supportsMcpTools: + existing.runtime.providerSession.capabilities.tools.supportsMcpTools, + runtimePolicy: input.runtimePolicy, }); yield* touchActivity(input.providerSessionId); return existing.exposedRuntime; @@ -1399,9 +1437,21 @@ export const layerWithOptions = ( }), ), ); + const adapterCapabilities = yield* adapter.getCapabilities().pipe( + Effect.mapError( + (cause) => + new ProviderSessionOpenError({ + instanceId: input.modelSelection.instanceId, + providerSessionId: input.providerSessionId, + cause, + }), + ), + ); const prepared = yield* prepareMcpSession( input.threadId, input.modelSelection.instanceId, + adapterCapabilities.tools.supportsMcpTools, + input.runtimePolicy, ); const mcpCredentialId = prepared.mcpCredentialId; // The reservation from prepare protects the credential (which diff --git a/apps/server/src/orchestration-v2/ProviderTurnStartService.ts b/apps/server/src/orchestration-v2/ProviderTurnStartService.ts index 3b6f74bf5f7..5511026ab93 100644 --- a/apps/server/src/orchestration-v2/ProviderTurnStartService.ts +++ b/apps/server/src/orchestration-v2/ProviderTurnStartService.ts @@ -21,6 +21,7 @@ import { } from "./ContextHandoffService.ts"; import { IdAllocatorV2 } from "./IdAllocator.ts"; import { ProjectionStoreV2 } from "./ProjectionStore.ts"; +import { makeProviderFailure } from "./ProviderFailure.ts"; import { ProviderSessionManagerV2 } from "./ProviderSessionManager.ts"; import { canRouteRelatedSubagent, RunExecutionServiceV2 } from "./RunExecutionService.ts"; import { RuntimePolicyV2 } from "./RuntimePolicy.ts"; @@ -35,11 +36,21 @@ export class ProviderTurnStartError extends Schema.TaggedErrorClass Effect.Effect; + readonly failPermanently: (input: { + readonly threadId: ThreadId; + readonly runId: RunId; + }) => Effect.Effect; } export class ProviderTurnStartServiceV2 extends Context.Service< @@ -459,6 +470,8 @@ export const layer: Layer.Layer< }), Effect.catchCause(() => Effect.succeed(false)), ), + captureFilesystemCheckpoint: + session.providerSession.capabilities.checkpointing.appCanCheckpointFilesystem, message: { messageId: message.id, text: @@ -477,6 +490,123 @@ export const layer: Layer.Layer< }); }); + const failPermanently = Effect.fn("orchestrationV2.providerTurnStart.failPermanently")( + function* (input: { readonly threadId: ThreadId; readonly runId: RunId }) { + const projection = yield* projectionStore.getThreadProjection(input.threadId); + const run = projection.runs.find((candidate) => candidate.id === input.runId); + if (run === undefined) { + return yield* new ProviderTurnStartError({ + runId: input.runId, + cause: `Run ${input.runId} was not found.`, + }); + } + if (!canTerminalizeProviderTurnStartFailure(run.status)) { + return; + } + const rootNode = projection.nodes.find((candidate) => candidate.id === run.rootNodeId); + const attempt = projection.attempts.find( + (candidate) => candidate.id === run.activeAttemptId, + ); + const providerThread = projection.providerThreads.find( + (candidate) => candidate.id === run.providerThreadId, + ); + if (rootNode === undefined || attempt === undefined || providerThread === undefined) { + return yield* new ProviderTurnStartError({ + runId: input.runId, + cause: `Run ${input.runId} is missing its execution projection state.`, + }); + } + + const now = yield* DateTime.now; + const failure = makeProviderFailure({ + message: "Could not connect to the provider after repeated attempts.", + code: "provider_turn_start_failed", + class: "transport_error", + retryable: false, + }); + const errorItemId = idAllocator.derive.turnItemFromProviderItem({ + driver: providerThread.driver, + nativeItemId: `provider-turn-start-failure:${run.id}`, + }); + const errorItemOrdinal = + Math.max( + run.ordinal * 1_000_000, + ...projection.turnItems + .filter((item) => item.runId === run.id) + .map((item) => item.ordinal), + ) + 1; + yield* eventSink.writeIfRunCurrent({ + threadId: projection.thread.id, + runId: run.id, + activeAttemptId: attempt.id, + expectedStatus: run.status, + events: [ + { + id: yield* idAllocator.allocate.event({ threadId: projection.thread.id }), + type: "run-attempt.updated", + threadId: projection.thread.id, + runId: run.id, + nodeId: rootNode.id, + providerInstanceId: run.providerInstanceId, + occurredAt: now, + payload: { ...attempt, status: "failed", completedAt: now }, + }, + { + id: yield* idAllocator.allocate.event({ threadId: projection.thread.id }), + type: "node.updated", + threadId: projection.thread.id, + runId: run.id, + nodeId: rootNode.id, + providerInstanceId: run.providerInstanceId, + occurredAt: now, + payload: { ...rootNode, status: "failed", completedAt: now }, + }, + { + id: yield* idAllocator.allocate.event({ threadId: projection.thread.id }), + type: "turn-item.updated", + threadId: projection.thread.id, + runId: run.id, + nodeId: rootNode.id, + providerInstanceId: run.providerInstanceId, + occurredAt: now, + payload: { + id: errorItemId, + threadId: projection.thread.id, + runId: run.id, + nodeId: rootNode.id, + providerThreadId: providerThread.id, + providerTurnId: null, + nativeItemRef: null, + parentItemId: null, + ordinal: errorItemOrdinal, + status: "failed", + title: "Provider failed to start", + startedAt: now, + completedAt: now, + updatedAt: now, + type: "error", + failure, + }, + }, + { + id: yield* idAllocator.allocate.event({ threadId: projection.thread.id }), + type: "run.updated", + threadId: projection.thread.id, + runId: run.id, + nodeId: rootNode.id, + providerInstanceId: run.providerInstanceId, + occurredAt: now, + payload: { ...run, status: "failed", completedAt: now }, + }, + ], + }); + yield* Effect.logWarning("Provider turn start permanently failed", { + threadId: input.threadId, + runId: input.runId, + }); + }, + ); + return ProviderTurnStartServiceV2.of({ start: (input) => start(input).pipe( @@ -486,6 +616,14 @@ export const layer: Layer.Layer< : new ProviderTurnStartError({ runId: input.runId, cause }), ), ), + failPermanently: (input) => + failPermanently(input).pipe( + Effect.mapError((cause) => + isProviderTurnStartError(cause) + ? cause + : new ProviderTurnStartError({ runId: input.runId, cause }), + ), + ), }); }), ); diff --git a/apps/server/src/orchestration-v2/RunExecutionService.test.ts b/apps/server/src/orchestration-v2/RunExecutionService.test.ts index 28f2120e599..f8d51f8dfad 100644 --- a/apps/server/src/orchestration-v2/RunExecutionService.test.ts +++ b/apps/server/src/orchestration-v2/RunExecutionService.test.ts @@ -307,6 +307,73 @@ it.effect("rechecks run ownership immediately before calling the provider", () = }).pipe(Effect.provide(RunExecutionTestLayer)), ); +it.effect("skips Git baseline capture for projectless Hermes runs", () => + Effect.gen(function* () { + const captures = yield* Ref.make(0); + const testLayer = runExecutionServiceLayer.pipe( + Layer.provide( + Layer.mergeAll( + Layer.mock(CheckpointServiceV2)({ + captureBaseline: () => Ref.update(captures, (count) => count + 1), + }), + Layer.mock(EventSinkV2)({}), + idAllocatorLayer, + Layer.mock(ProviderEventIngestorV2)({ ingestNormalized: () => Effect.succeed([]) }), + ServerSettingsService.layerTest(), + ), + ), + ); + const threadId = ThreadId.make("thread:hermes-no-project"); + const runId = RunId.make("run:hermes-no-project"); + const attemptId = RunAttemptId.make("attempt:hermes-no-project"); + const providerInstanceId = ProviderInstanceId.make("hermes"); + const providerThreadId = ProviderThreadId.make("provider-thread:hermes-no-project"); + const providerSessionId = ProviderSessionId.make("session:hermes-no-project"); + + yield* RunExecutionServiceV2.pipe( + Effect.flatMap((runExecution) => + runExecution.startRootRun({ + commandId: CommandId.make("command:hermes-no-project"), + appThread: { id: threadId, worktreePath: null } as OrchestrationV2AppThread, + providerSessionId, + session: { events: Stream.never } as unknown as ProviderAdapterV2SessionRuntime, + run: { id: runId, threadId, ordinal: 1, providerInstanceId } as OrchestrationV2Run, + rootNode: { id: NodeId.make("node:hermes-no-project") } as OrchestrationV2ExecutionNode, + checkpointScope: { + id: CheckpointScopeId.make("checkpoint-scope:hermes-no-project"), + cwd: "/tmp/t3-work-no-git", + } as OrchestrationV2CheckpointScope, + providerThread: { + id: providerThreadId, + driver: ProviderDriverKind.make("hermes"), + } as OrchestrationV2ProviderThread, + attempt: { id: attemptId, providerTurnId: null } as OrchestrationV2RunAttempt, + attemptId, + providerTurnOrdinal: 1, + shouldStartProviderTurn: () => Effect.succeed(false), + captureFilesystemCheckpoint: false, + message: { + messageId: MessageId.make("message:hermes-no-project"), + text: "Hello Hermes", + attachments: [], + createdBy: "user", + creationSource: "web", + }, + modelSelection: { instanceId: providerInstanceId, model: "hermes" }, + runtimePolicy: { + runtimeMode: "full-access", + interactionMode: "default", + cwd: "/tmp/t3-work-no-git", + }, + }), + ), + Effect.provide(testLayer), + ); + + assert.equal(yield* Ref.get(captures), 0); + }), +); + it.effect("keeps ingesting owned child events after the root turn terminalizes", () => Effect.gen(function* () { const threadId = ThreadId.make("thread:run-execution-late-child"); diff --git a/apps/server/src/orchestration-v2/RunExecutionService.ts b/apps/server/src/orchestration-v2/RunExecutionService.ts index a3d72b065b7..3b7f298ea67 100644 --- a/apps/server/src/orchestration-v2/RunExecutionService.ts +++ b/apps/server/src/orchestration-v2/RunExecutionService.ts @@ -335,6 +335,8 @@ export function routeProviderEvent( }, ]; } + case "app_thread.title_reconciled": + return [ownsThread(event.threadId), state]; case "provider_thread.updated": { const belongs = state.ownedProviderThreadIds.has(event.providerThread.id) || @@ -432,6 +434,7 @@ export interface RunExecutionServiceV2StartRootRunInput { readonly shouldStartProviderTurn?: () => Effect.Effect; readonly shouldFinalizeRun?: () => Effect.Effect; readonly hasUnpairedRunInterruptRequest?: () => Effect.Effect; + readonly captureFilesystemCheckpoint?: boolean; readonly message: ProviderAdapterV2TurnMessage; readonly modelSelection: ModelSelection; readonly runtimePolicy: ProviderAdapterV2RuntimePolicy; @@ -710,21 +713,23 @@ export const layer: Layer.Layer< }), ), ); - yield* checkpointService - .captureBaseline({ - scope: input.checkpointScope, - ordinalWithinScope: Math.max(0, input.run.ordinal - 1), - }) - .pipe( - Effect.mapError( - (cause) => - new RunExecutionStartError({ - commandId: input.commandId, - runId: input.run.id, - cause, - }), - ), - ); + if (input.captureFilesystemCheckpoint !== false) { + yield* checkpointService + .captureBaseline({ + scope: input.checkpointScope, + ordinalWithinScope: Math.max(0, input.run.ordinal - 1), + }) + .pipe( + Effect.mapError( + (cause) => + new RunExecutionStartError({ + commandId: input.commandId, + runId: input.run.id, + cause, + }), + ), + ); + } if ( input.shouldStartProviderTurn !== undefined && !(yield* input.shouldStartProviderTurn()) diff --git a/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts b/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts index de45bac5ae1..78ab952b876 100644 --- a/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts +++ b/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts @@ -125,6 +125,7 @@ function launchInput(input: { readonly thread: string; readonly message?: string; readonly workspace?: ThreadLaunch.ThreadLaunchWorkspaceStrategy; + readonly prepareWorkspace?: boolean; }) { return { commandId: CommandId.make(input.command), @@ -135,6 +136,7 @@ function launchInput(input: { runtimeMode: "full-access" as const, interactionMode: "default" as const, workspaceStrategy: input.workspace ?? { type: "root" as const }, + ...(input.prepareWorkspace === undefined ? {} : { prepareWorkspace: input.prepareWorkspace }), ...(input.message === undefined ? {} : { @@ -149,6 +151,36 @@ function launchInput(input: { }; } +it.effect("starts projectless launches without workspace preparation", () => + Effect.gen(function* () { + const harness = makeHarness(); + yield* Effect.gen(function* () { + const launches = yield* ThreadLaunch.ThreadLaunchService; + const outbox = yield* EffectOutbox.EffectOutboxV2; + const launched = yield* launches.launch( + launchInput({ + command: "command:launch:projectless", + thread: "thread:launch:projectless", + message: "Hello Hermes", + prepareWorkspace: false, + }), + ); + + assert.equal(launched.projection.runs[0]?.status, "starting"); + assert.isUndefined( + launched.projection.turnItems.find( + (item) => item.type === "command_execution" && item.input === "Preparing workspace", + ), + ); + assert.equal(harness.runSetup.mock.calls.length, 0); + assert.lengthOf( + yield* outbox.listByCommandId(CommandId.make("command:launch:projectless:initial-message")), + 1, + ); + }).pipe(Effect.provide(harness.layer)); + }), +); + function waitUntil(predicate: () => Effect.Effect): Effect.Effect { return Effect.gen(function* () { for (let attempt = 0; attempt < 200; attempt += 1) { @@ -443,6 +475,30 @@ it.effect("does not put optional title generation on the provisioning critical p }), ); +it.effect("still generates a title for launches that skip workspace preparation", () => + Effect.gen(function* () { + const harness = makeHarness(); + yield* Effect.gen(function* () { + const launches = yield* ThreadLaunch.ThreadLaunchService; + const threads = yield* ThreadManagement.ThreadManagementService; + const launched = yield* launches.launch( + launchInput({ + command: "command:launch:no-workspace-title", + thread: "thread:launch:no-workspace-title", + message: "Name this thread", + prepareWorkspace: false, + }), + ); + assert.equal(harness.runSetup.mock.calls.length, 0); + yield* waitUntil(() => + threads + .getThreadProjection(launched.threadId) + .pipe(Effect.map((projection) => projection.thread.title === "Generated title")), + ); + }).pipe(Effect.provide(harness.layer)); + }), +); + for (const failurePoint of ["worktree", "setup"] as const) { it.effect( `${failurePoint} failure keeps the thread and message visible and emits failure items`, diff --git a/apps/server/src/orchestration-v2/ThreadLaunchService.ts b/apps/server/src/orchestration-v2/ThreadLaunchService.ts index 69e3fc83d86..77d4ccaf4d6 100644 --- a/apps/server/src/orchestration-v2/ThreadLaunchService.ts +++ b/apps/server/src/orchestration-v2/ThreadLaunchService.ts @@ -61,6 +61,7 @@ export interface ThreadLaunchInput { readonly runtimeMode: RuntimeMode; readonly interactionMode: ProviderInteractionMode; readonly workspaceStrategy: ThreadLaunchWorkspaceStrategy; + readonly prepareWorkspace?: boolean; readonly initialMessage?: ThreadLaunchInitialMessage; readonly createdBy: OrchestrationV2Actor; readonly creationSource: OrchestrationV2CreationSource; @@ -178,26 +179,12 @@ export const make = Effect.gen(function* () { } }); - const prepareInBackground = Effect.fn("ThreadLaunchService.prepareInBackground")(function* ( - input: ThreadLaunchInput, - threadId: ThreadId, - runId: RunId | null, - ) { - const project = yield* projects.getById(input.projectId).pipe( - Effect.mapError(mapError(input, "resolve-project", threadId)), - Effect.flatMap( - Option.match({ - onNone: () => - Effect.fail(mapError(input, "resolve-project", threadId)("Project no longer exists.")), - onSome: Effect.succeed, - }), - ), - ); - - if (input.title === "New thread" && input.initialMessage !== undefined) { + const scheduleTitleGeneration = Effect.fn("ThreadLaunchService.scheduleTitleGeneration")( + function* (input: ThreadLaunchInput, threadId: ThreadId, workspaceRoot: string) { + if (input.title !== "New thread" || input.initialMessage === undefined) return; yield* textGeneration .generateThreadTitle({ - cwd: project.workspaceRoot, + cwd: workspaceRoot, message: input.initialMessage.text, attachments: input.initialMessage.attachments, modelSelection: input.modelSelection, @@ -220,7 +207,26 @@ export const make = Effect.gen(function* () { ), Effect.forkIn(preparationScope), ); - } + }, + ); + + const prepareInBackground = Effect.fn("ThreadLaunchService.prepareInBackground")(function* ( + input: ThreadLaunchInput, + threadId: ThreadId, + runId: RunId | null, + ) { + const project = yield* projects.getById(input.projectId).pipe( + Effect.mapError(mapError(input, "resolve-project", threadId)), + Effect.flatMap( + Option.match({ + onNone: () => + Effect.fail(mapError(input, "resolve-project", threadId)("Project no longer exists.")), + onSome: Effect.succeed, + }), + ), + ); + + yield* scheduleTitleGeneration(input, threadId, project.workspaceRoot); let branch = input.workspaceStrategy.type === "worktree" && @@ -491,7 +497,10 @@ export const make = Effect.gen(function* () { text: input.initialMessage.text, attachments: input.initialMessage.attachments, modelSelection: input.modelSelection, - dispatchMode: { type: "defer_start" }, + dispatchMode: + input.prepareWorkspace === false + ? { type: "start_immediately" } + : { type: "defer_start" }, createdBy: input.createdBy, creationSource: input.creationSource, }) @@ -515,7 +524,16 @@ export const make = Effect.gen(function* () { const runIsPreparing = runId !== null && projection.runs.some((run) => run.id === runId && run.status === "preparing"); - const shouldSchedule = runId === null ? Option.isNone(launchReceipt) : runIsPreparing; + const shouldSchedule = + input.prepareWorkspace !== false && + (runId === null ? Option.isNone(launchReceipt) : runIsPreparing); + if ( + input.prepareWorkspace === false && + Option.isNone(launchReceipt) && + !messageWasAlreadyAccepted + ) { + yield* scheduleTitleGeneration(input, threadId, project.workspaceRoot); + } if (shouldSchedule) { const ownsPreparation = yield* reservePreparation(input.commandId); if (ownsPreparation) { diff --git a/apps/server/src/orchestration-v2/builtInProviderAdapterDrivers.ts b/apps/server/src/orchestration-v2/builtInProviderAdapterDrivers.ts index 1ef37a2bfa0..a688c2ed5fd 100644 --- a/apps/server/src/orchestration-v2/builtInProviderAdapterDrivers.ts +++ b/apps/server/src/orchestration-v2/builtInProviderAdapterDrivers.ts @@ -14,6 +14,18 @@ import { type CursorAdapterV2DriverEnv, } from "./Adapters/CursorAdapterV2.ts"; import { GrokAdapterV2Driver, type GrokAdapterV2DriverEnv } from "./Adapters/GrokAdapterV2.ts"; +import { + HermesAcpAdapterV2Driver, + type HermesAcpAdapterV2DriverEnv, +} from "./Adapters/HermesAcpAdapterV2.ts"; +import { + HermesServeAdapterV2Driver, + type HermesServeAdapterV2DriverEnv, +} from "./Adapters/HermesServeAdapterV2.ts"; +import { + OpenClawAdapterV2Driver, + type OpenClawAdapterV2DriverEnv, +} from "./Adapters/OpenClawAdapterV2.ts"; import { OpenCodeAdapterV2Driver, type OpenCodeAdapterV2DriverEnv, @@ -26,6 +38,9 @@ export type BuiltInProviderAdapterDriversV2Env = | CodexAdapterV2DriverEnv | CursorAdapterV2DriverEnv | GrokAdapterV2DriverEnv + | HermesAcpAdapterV2DriverEnv + | HermesServeAdapterV2DriverEnv + | OpenClawAdapterV2DriverEnv | OpenCodeAdapterV2DriverEnv; export const BUILT_IN_PROVIDER_ADAPTER_DRIVERS_V2: ReadonlyArray< @@ -37,6 +52,9 @@ export const BUILT_IN_PROVIDER_ADAPTER_DRIVERS_V2: ReadonlyArray< OpenCodeAdapterV2Driver, GrokAdapterV2Driver, AcpRegistryAdapterV2Driver, + HermesAcpAdapterV2Driver, + OpenClawAdapterV2Driver, + HermesServeAdapterV2Driver, ]; export const BUILT_IN_PROVIDER_ADAPTER_DRIVER_KINDS_V2: ReadonlySet = new Set( diff --git a/apps/server/src/orchestration-v2/runtimeLayer.test.ts b/apps/server/src/orchestration-v2/runtimeLayer.test.ts index dd8084ea82e..a7e5aff93fe 100644 --- a/apps/server/src/orchestration-v2/runtimeLayer.test.ts +++ b/apps/server/src/orchestration-v2/runtimeLayer.test.ts @@ -5,16 +5,20 @@ import { CommandId, MessageId, type ModelSelection, + type OrchestrationV2ProviderThread, ProjectId, ProviderDriverKind, ProviderInstanceId, + ProviderThreadId, ThreadId, + TurnItemId, } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; import * as DateTime from "effect/DateTime"; import * as Layer from "effect/Layer"; import * as Queue from "effect/Queue"; import * as Stream from "effect/Stream"; +import * as TestClock from "effect/testing/TestClock"; import * as SqlClient from "effect/unstable/sql/SqlClient"; import * as CheckpointStore from "../checkpointing/CheckpointStore.ts"; @@ -39,7 +43,8 @@ import { } from "./Orchestrator.ts"; import { OrchestrationEffectWorkerV2 } from "./EffectWorker.ts"; import { ProjectionMaintenanceV2 } from "./ProjectionMaintenance.ts"; -import type { ProviderAdapterV2Shape } from "./ProviderAdapter.ts"; +import type { ProviderAdapterV2SessionRuntime, ProviderAdapterV2Shape } from "./ProviderAdapter.ts"; +import { ProviderSessionManagerV2 } from "./ProviderSessionManager.ts"; import { OrchestrationV2LayerLive } from "./runtimeLayer.ts"; import { shellStreamItemFromSnapshot } from "./ShellStream.ts"; import { CodexProviderCapabilitiesV2 } from "./Adapters/CodexAdapterV2.ts"; @@ -65,12 +70,124 @@ const CheckpointStoreTestLayer = CheckpointStore.layer.pipe( ); const driver = ProviderDriverKind.make("codex"); +const historyHydrationThreadId = ThreadId.make("runtime-layer-history-hydration-thread"); +let hydrationSnapshotReads = 0; const orchestrationAdapter = { instanceId: modelSelection.instanceId, driver, getCapabilities: () => Effect.succeed(CodexProviderCapabilitiesV2), planSelectionTransition: () => Effect.succeed({ type: "apply_on_next_turn" }), - openSession: () => Effect.die("sessions are not used by lifecycle tests"), + openSession: (input) => { + if (input.threadId !== historyHydrationThreadId) { + return Effect.die("sessions are not used by lifecycle tests"); + } + const now = DateTime.nowUnsafe(); + const makeProviderThread = (threadId: ThreadId): OrchestrationV2ProviderThread => ({ + id: ProviderThreadId.make(`provider-thread:hydration:${threadId}`), + driver, + providerInstanceId: modelSelection.instanceId, + providerSessionId: input.providerSessionId, + appThreadId: threadId, + ownerNodeId: null, + nativeThreadRef: null, + nativeConversationHeadRef: null, + status: "idle", + firstRunOrdinal: null, + lastRunOrdinal: null, + handoffIds: [], + forkedFrom: null, + createdAt: now, + updatedAt: now, + }); + const runtime: ProviderAdapterV2SessionRuntime = { + instanceId: modelSelection.instanceId, + driver, + providerSessionId: input.providerSessionId, + providerSession: { + id: input.providerSessionId, + driver, + providerInstanceId: modelSelection.instanceId, + status: "ready", + cwd: input.runtimePolicy.cwd ?? process.cwd(), + model: input.modelSelection.model, + capabilities: CodexProviderCapabilitiesV2, + createdAt: now, + updatedAt: now, + lastError: null, + }, + events: Stream.never, + ensureThread: (threadInput) => Effect.succeed(makeProviderThread(threadInput.threadId)), + resumeThread: (threadInput) => Effect.succeed(threadInput.providerThread), + startTurn: () => Effect.die("unused startTurn"), + steerTurn: () => Effect.die("unused steerTurn"), + interruptTurn: () => Effect.die("unused interruptTurn"), + respondToRuntimeRequest: () => Effect.die("unused respondToRuntimeRequest"), + readThreadSnapshot: ({ providerThread }) => + Effect.sync(() => { + hydrationSnapshotReads += 1; + const threadId = providerThread.appThreadId!; + return { + providerThread, + providerTurns: [], + messages: [ + { + createdBy: "user", + creationSource: "provider", + id: MessageId.make("message:hydrated:user"), + threadId, + runId: null, + nodeId: null, + role: "user", + text: "Existing Hermes question", + attachments: [], + streaming: false, + createdAt: now, + updatedAt: now, + }, + { + createdBy: "agent", + creationSource: "provider", + id: MessageId.make("message:hydrated:assistant"), + threadId, + runId: null, + nodeId: null, + role: "assistant", + text: "Existing Hermes answer", + attachments: [], + streaming: false, + createdAt: DateTime.add(now, { milliseconds: 1 }), + updatedAt: DateTime.add(now, { milliseconds: 1 }), + }, + ], + runtimeRequests: [], + turnItems: [ + { + id: TurnItemId.make("turn-item:hydrated:command"), + threadId, + runId: null, + nodeId: null, + providerThreadId: null, + providerTurnId: null, + nativeItemRef: null, + parentItemId: null, + ordinal: 0, + status: "completed", + title: "git status", + startedAt: now, + completedAt: now, + updatedAt: DateTime.add(now, { milliseconds: 1 }), + type: "command_execution", + input: "git status", + output: "clean", + }, + ], + }; + }), + rollbackThread: () => Effect.die("unused rollbackThread"), + forkThread: () => Effect.die("unused forkThread"), + }; + return Effect.succeed(runtime); + }, } as ProviderAdapterV2Shape; const providerInstance = { instanceId: modelSelection.instanceId, @@ -382,6 +499,104 @@ it.layer(LegacyImportTestLayer)("OrchestrationV2 legacy import", (it) => { }); it.layer(TestLayer)("OrchestrationV2LayerLive lifecycle", (it) => { + it.effect("honors an explicit historical createdAt on thread.create for imported threads", () => + Effect.gen(function* () { + const orchestrator = yield* OrchestratorV2; + const threadId = ThreadId.make("runtime-layer-imported-created-at-thread"); + const importedAt = DateTime.makeUnsafe("2020-06-01T12:00:00.000Z"); + + yield* orchestrator.dispatch({ + type: "thread.create", + createdBy: "system", + creationSource: "provider", + commandId: CommandId.make("runtime-layer-imported-created-at"), + threadId, + projectId: ProjectId.make("runtime-layer-imported-created-at-project"), + title: "Imported thread with historical time", + modelSelection, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + createdAt: importedAt, + }); + + const projection = yield* orchestrator.getThreadProjection(threadId); + assert.equal( + DateTime.toEpochMillis(projection.thread.createdAt), + DateTime.toEpochMillis(importedAt), + ); + assert.equal( + DateTime.toEpochMillis(projection.thread.updatedAt), + DateTime.toEpochMillis(importedAt), + ); + }), + ); + + it.effect("hydrates provider history once and remains idempotent across retries and reopen", () => + Effect.gen(function* () { + hydrationSnapshotReads = 0; + const orchestrator = yield* OrchestratorV2; + const providerSessions = yield* ProviderSessionManagerV2; + const threadId = historyHydrationThreadId; + + yield* orchestrator.dispatch({ + type: "thread.create", + createdBy: "system", + creationSource: "provider", + commandId: CommandId.make("runtime-layer-history-hydration-create"), + threadId, + projectId: ProjectId.make("runtime-layer-history-hydration-project"), + title: "Imported Hermes thread", + modelSelection, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: "/tmp/runtime-layer-history-hydration", + }); + + const hydrationInput = { + threadId, + providerInstanceId: modelSelection.instanceId, + }; + yield* orchestrator.hydrateProviderThreadSnapshot(hydrationInput); + const first = yield* orchestrator.getThreadSnapshot(threadId); + yield* orchestrator.hydrateProviderThreadSnapshot(hydrationInput); + const retry = yield* orchestrator.getThreadSnapshot(threadId); + + assert.deepEqual( + retry.projection.messages.map((message) => message.text), + ["Existing Hermes question", "Existing Hermes answer"], + ); + assert.equal(retry.projection.messages.length, 2); + assert.equal( + DateTime.toEpochMillis(retry.projection.thread.updatedAt), + Math.max( + ...retry.projection.messages.map((message) => DateTime.toEpochMillis(message.updatedAt)), + ), + ); + assert.deepEqual( + retry.projection.turnItems.map((item) => String(item.id)), + ["turn-item:hydrated:command"], + ); + assert.equal(retry.snapshotSequence, first.snapshotSequence); + + const providerSessionId = retry.projection.providerThreads[0]?.providerSessionId; + assert.isNotNull(providerSessionId); + yield* providerSessions.close(providerSessionId!); + yield* orchestrator.hydrateProviderThreadSnapshot(hydrationInput); + const reopened = yield* orchestrator.getThreadProjection(threadId); + + assert.equal(hydrationSnapshotReads, 3); + assert.equal(reopened.messages.length, 2); + assert.deepEqual( + reopened.messages.map((message) => message.id), + [MessageId.make("message:hydrated:user"), MessageId.make("message:hydrated:assistant")], + ); + assert.equal(reopened.turnItems.length, 1); + }), + ); + it.effect("applies lifecycle commands idempotently and emits archive/removal shell deltas", () => Effect.gen(function* () { const orchestrator = yield* OrchestratorV2; @@ -442,6 +657,24 @@ it.layer(TestLayer)("OrchestrationV2LayerLive lifecycle", (it) => { assert.equal(settledProjection.thread.settledOverride, "settled"); assert.isNotNull(settledProjection.thread.settledAt); + yield* orchestrator.dispatch({ + type: "thread.unsettle", + commandId: CommandId.make("runtime-layer-lifecycle-unsettle-historical"), + threadId, + reason: "user", + }); + const historicalSettledAt = "2025-11-02T03:04:05.000Z"; + yield* orchestrator.dispatch({ + type: "thread.settle", + commandId: CommandId.make("runtime-layer-lifecycle-settle-historical"), + threadId, + settledAt: historicalSettledAt, + }); + const historicalProjection = yield* orchestrator.getThreadProjection(threadId); + assert.isNotNull(historicalProjection.thread.settledAt); + assert.equal(DateTime.formatIso(historicalProjection.thread.settledAt!), historicalSettledAt); + assert.equal(DateTime.formatIso(historicalProjection.thread.updatedAt), historicalSettledAt); + yield* orchestrator.dispatch({ type: "thread.unsettle", commandId: CommandId.make("runtime-layer-lifecycle-unsettle"), @@ -532,6 +765,86 @@ it.layer(TestLayer)("OrchestrationV2LayerLive lifecycle", (it) => { }), ); + it.effect("persists Work inbox pin metadata and protects the main thread lifecycle", () => + Effect.gen(function* () { + const orchestrator = yield* OrchestratorV2; + const threadId = ThreadId.make("runtime-layer-work-main-thread"); + yield* orchestrator.dispatch({ + type: "thread.create", + createdBy: "user", + creationSource: "web", + commandId: CommandId.make("runtime-layer-work-main-create"), + threadId, + projectId: ProjectId.make("runtime-layer-work-main-project"), + title: "Main", + modelSelection, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + }); + yield* orchestrator.dispatch({ + type: "thread.metadata.update", + commandId: CommandId.make("runtime-layer-work-main-metadata"), + threadId, + pinned: true, + workInboxRole: "main", + }); + + const projection = yield* orchestrator.getThreadProjection(threadId); + const shell = (yield* orchestrator.getShellSnapshot()).threads.find( + (candidate) => candidate.id === threadId, + ); + assert.equal(projection.thread.workInboxRole, "main"); + assert.isNotNull(projection.thread.pinnedAt); + assert.equal(shell?.workInboxRole, "main"); + assert.deepEqual(shell?.pinnedAt, projection.thread.pinnedAt); + + const settleError = yield* orchestrator + .dispatch({ + type: "thread.settle", + commandId: CommandId.make("runtime-layer-work-main-settle"), + threadId, + }) + .pipe(Effect.flip); + assert.equal(settleError._tag, "OrchestratorDispatchError"); + }), + ); + + it.effect("persists an in-place timeline clear boundary on the thread and shell", () => + Effect.gen(function* () { + const orchestrator = yield* OrchestratorV2; + const threadId = ThreadId.make("runtime-layer-cleared-thread"); + yield* orchestrator.dispatch({ + type: "thread.create", + createdBy: "user", + creationSource: "web", + commandId: CommandId.make("runtime-layer-cleared-create"), + threadId, + projectId: ProjectId.make("runtime-layer-cleared-project"), + title: "Cleared chat", + modelSelection, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + }); + yield* orchestrator.dispatch({ + type: "thread.metadata.update", + commandId: CommandId.make("runtime-layer-clear-timeline"), + threadId, + clearTimeline: true, + }); + + const projection = yield* orchestrator.getThreadProjection(threadId); + const shell = (yield* orchestrator.getShellSnapshot()).threads.find( + (candidate) => candidate.id === threadId, + ); + assert.isNotNull(projection.thread.timelineClearedAt); + assert.deepEqual(shell?.timelineClearedAt, projection.thread.timelineClearedAt); + }), + ); + it.effect("rejects settling a thread while a run is active", () => Effect.gen(function* () { const orchestrator = yield* OrchestratorV2; @@ -743,6 +1056,84 @@ it.layer(SharedApplicationDataPlaneTestLayer)("snooze projection", (it) => { ); }); +it.layer(SharedApplicationDataPlaneTestLayer)("permanent provider start failure", (it) => { + it.effect("terminalizes a provider turn that permanently fails to start", () => + Effect.gen(function* () { + const applicationEngine = yield* OrchestrationEngineService; + const orchestrator = yield* OrchestratorV2; + const effectWorker = yield* OrchestrationEffectWorkerV2; + const projectId = ProjectId.make("runtime-layer-permanent-start-failure-project"); + const threadId = ThreadId.make("runtime-layer-permanent-start-failure-thread"); + + yield* applicationEngine.dispatch({ + type: "project.create", + commandId: CommandId.make("runtime-layer-permanent-start-failure-project-create"), + projectId, + title: "Permanent start failure", + workspaceRoot: "/tmp/runtime-layer-permanent-start-failure-project", + defaultModelSelection: modelSelection, + scripts: [], + createdAt: "2026-07-25T00:00:00.000Z", + }); + yield* orchestrator.dispatch({ + type: "thread.create", + createdBy: "user", + creationSource: "web", + commandId: CommandId.make("runtime-layer-permanent-start-failure-create"), + threadId, + projectId, + title: "Permanent start failure", + modelSelection, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + }); + yield* orchestrator.dispatch({ + type: "message.dispatch", + createdBy: "user", + creationSource: "web", + commandId: CommandId.make("runtime-layer-permanent-start-failure-message"), + threadId, + messageId: MessageId.make("runtime-layer-permanent-start-failure-message"), + text: "Hello", + attachments: [], + modelSelection, + dispatchMode: { type: "start_immediately" }, + }); + + const before = yield* orchestrator.getThreadProjection(threadId); + const run = before.runs[0]; + assert.isDefined(run); + if (run === undefined) return; + + for (let attempt = 0; attempt < 5; attempt += 1) { + assert.isTrue(yield* effectWorker.runOnce); + yield* TestClock.adjust("2 seconds"); + } + + const after = yield* orchestrator.getThreadProjection(threadId); + const failedRun = after.runs.find((candidate) => candidate.id === run.id); + const failedAttempt = after.attempts.find( + (candidate) => candidate.id === run.activeAttemptId, + ); + const failedRootNode = after.nodes.find((candidate) => candidate.id === run.rootNodeId); + const errorItem = after.turnItems.find( + (candidate) => candidate.runId === run.id && candidate.type === "error", + ); + + assert.equal(failedRun?.status, "failed"); + assert.isNotNull(failedRun?.completedAt); + assert.equal(failedAttempt?.status, "failed"); + assert.equal(failedRootNode?.status, "failed"); + assert.equal(errorItem?.status, "failed"); + if (errorItem?.type === "error") { + assert.equal(errorItem.failure.code, "provider_turn_start_failed"); + } + }).pipe(Effect.provide(TestClock.layer())), + ); +}); + it.layer(SharedApplicationDataPlaneTestLayer)("shared application data plane", (it) => { it.effect("orders retained project transactions and V2 thread transactions in one source", () => Effect.gen(function* () { diff --git a/apps/server/src/orchestration-v2/testkit/ProviderReplayHarness.ts b/apps/server/src/orchestration-v2/testkit/ProviderReplayHarness.ts index 332db73af3a..c7a86cd1244 100644 --- a/apps/server/src/orchestration-v2/testkit/ProviderReplayHarness.ts +++ b/apps/server/src/orchestration-v2/testkit/ProviderReplayHarness.ts @@ -76,6 +76,7 @@ export function makeReplayServerConfig( const providerLogsDir = path.join(logsDir, "provider"); const terminalLogsDir = path.join(logsDir, "terminals"); const attachmentsDir = path.join(stateDir, "attachments"); + const browserArtifactsDir = path.join(stateDir, "browser-artifacts"); const worktreesDir = path.join(baseDir, "worktrees"); const providerStatusCacheDir = path.join(baseDir, "caches"); @@ -85,6 +86,7 @@ export function makeReplayServerConfig( providerLogsDir, terminalLogsDir, attachmentsDir, + browserArtifactsDir, worktreesDir, providerStatusCacheDir, ]) { @@ -124,6 +126,7 @@ export function makeReplayServerConfig( providerStatusCacheDir, worktreesDir, attachmentsDir, + browserArtifactsDir, logsDir, serverLogPath: path.join(logsDir, "server.log"), serverTracePath: path.join(logsDir, "server.trace.ndjson"), diff --git a/apps/server/src/orchestration/decider.settled.test.ts b/apps/server/src/orchestration/decider.settled.test.ts index 73f1cbf9127..cf906c1aa2b 100644 --- a/apps/server/src/orchestration/decider.settled.test.ts +++ b/apps/server/src/orchestration/decider.settled.test.ts @@ -108,6 +108,28 @@ it.layer(NodeServices.layer)("settled thread decider", (it) => { }), ); + it.effect("stamps a historical settledAt and updatedAt when the command supplies one", () => + Effect.gen(function* () { + const historical = "2025-11-01T00:00:00.000Z"; + const event = yield* decideOrchestrationCommand({ + command: { + type: "thread.settle", + commandId: CommandId.make("cmd-settle-historical"), + threadId: ThreadId.make("thread-1"), + settledAt: historical, + }, + readModel: makeReadModel(null), + }); + const events = Array.isArray(event) ? event : [event]; + expect(events).toHaveLength(1); + expect(events[0]?.type).toBe("thread.settled"); + if (events[0]?.type === "thread.settled") { + expect(events[0].payload.settledAt).toBe(historical); + expect(events[0].payload.updatedAt).toBe(historical); + } + }), + ); + it.effect("rejects settling a thread with a live session", () => Effect.gen(function* () { for (const status of ["starting", "running"] as const) { diff --git a/apps/server/src/orchestration/decider.ts b/apps/server/src/orchestration/decider.ts index cf19820a193..ad7c5d37ac0 100644 --- a/apps/server/src/orchestration/decider.ts +++ b/apps/server/src/orchestration/decider.ts @@ -497,11 +497,12 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" type: "thread.settled", payload: { threadId: command.threadId, - settledAt: alreadySettled ? thread.settledAt : occurredAt, + settledAt: alreadySettled ? thread.settledAt : (command.settledAt ?? occurredAt), // A re-emission is a projected no-op: keep the existing updatedAt // so duplicate settles neither rewind nor churn ordering. A fresh - // settle stamps the command time. - updatedAt: alreadySettled ? thread.updatedAt : occurredAt, + // settle stamps its settle time, so historical settles (provider + // imports) keep sorting by when the work actually ended. + updatedAt: alreadySettled ? thread.updatedAt : (command.settledAt ?? occurredAt), }, }; } diff --git a/apps/server/src/persistence/Migrations.ts b/apps/server/src/persistence/Migrations.ts index 05ff25d81a2..8b81f33a3e0 100644 --- a/apps/server/src/persistence/Migrations.ts +++ b/apps/server/src/persistence/Migrations.ts @@ -56,6 +56,12 @@ import Migration0040 from "./Migrations/040_ApplicationEventSource.ts"; import Migration0041 from "./Migrations/041_OrchestrationV2EffectCancellation.ts"; import Migration0042 from "./Migrations/042_ScheduledTasks.ts"; import Migration0043 from "./Migrations/043_LegacyV1ImportState.ts"; +import Migration0044 from "./Migrations/044_HermesSessionBindings.ts"; +import Migration0045 from "./Migrations/045_HermesProactiveEvents.ts"; +import Migration0046 from "./Migrations/046_HermesTitleBranchLineage.ts"; +import Migration0047 from "./Migrations/047_HermesSessionImports.ts"; +import Migration0048 from "./Migrations/048_HermesImportProjectScope.ts"; +import Migration0049 from "./Migrations/049_HermesImportInheritedBoundary.ts"; /** * Migration loader with all migrations defined inline. @@ -111,6 +117,12 @@ export const migrationEntries = [ [41, "OrchestrationV2EffectCancellation", Migration0041], [42, "ScheduledTasks", Migration0042], [43, "LegacyV1ImportState", Migration0043], + [44, "HermesSessionBindings", Migration0044], + [45, "HermesProactiveEvents", Migration0045], + [46, "HermesTitleBranchLineage", Migration0046], + [47, "HermesSessionImports", Migration0047], + [48, "HermesImportProjectScope", Migration0048], + [49, "HermesImportInheritedBoundary", Migration0049], ] as const; export const makeMigrationLoader = (throughId?: number) => diff --git a/apps/server/src/persistence/Migrations/035_036_OrchestrationV2.test.ts b/apps/server/src/persistence/Migrations/035_036_OrchestrationV2.test.ts index 5b217fb9c70..c4248ca8d52 100644 --- a/apps/server/src/persistence/Migrations/035_036_OrchestrationV2.test.ts +++ b/apps/server/src/persistence/Migrations/035_036_OrchestrationV2.test.ts @@ -13,7 +13,7 @@ layer("035_036_OrchestrationV2", (it) => { Effect.sync(() => { assert.deepStrictEqual( migrationEntries.map(([id]) => id), - Array.from({ length: 43 }, (_, index) => index + 1), + Array.from({ length: 49 }, (_, index) => index + 1), ); }), ); @@ -122,7 +122,7 @@ it.effect("upgrades a database already at released main migration 034", () => assert.ok(snoozeColumns.some((column) => column.name === "snoozed_until")); assert.ok(snoozeColumns.some((column) => column.name === "snoozed_at")); - yield* runMigrations({ toMigrationInclusive: 43 }); + yield* runMigrations({ toMigrationInclusive: 44 }); const migrations = yield* sql<{ readonly migration_id: number; @@ -130,7 +130,7 @@ it.effect("upgrades a database already at released main migration 034", () => }>` SELECT migration_id, name FROM effect_sql_migrations - WHERE migration_id BETWEEN 34 AND 43 + WHERE migration_id BETWEEN 34 AND 44 ORDER BY migration_id `; assert.deepStrictEqual( @@ -146,6 +146,7 @@ it.effect("upgrades a database already at released main migration 034", () => [41, "OrchestrationV2EffectCancellation"], [42, "ScheduledTasks"], [43, "LegacyV1ImportState"], + [44, "HermesSessionBindings"], ], ); @@ -162,5 +163,12 @@ it.effect("upgrades a database already at released main migration 034", () => WHERE type = 'table' AND name = 'orchestration_v2_legacy_imports' `; assert.strictEqual(legacyImportTables.length, 1); + + const hermesBindingTables = yield* sql<{ readonly name: string }>` + SELECT name + FROM sqlite_master + WHERE type = 'table' AND name = 'hermes_session_bindings' + `; + assert.strictEqual(hermesBindingTables.length, 1); }).pipe(Effect.provide(NodeSqliteClient.layerMemory())), ); diff --git a/apps/server/src/persistence/Migrations/044_HermesSessionBindings.test.ts b/apps/server/src/persistence/Migrations/044_HermesSessionBindings.test.ts new file mode 100644 index 00000000000..a47b47d35a8 --- /dev/null +++ b/apps/server/src/persistence/Migrations/044_HermesSessionBindings.test.ts @@ -0,0 +1,149 @@ +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +import { runMigrations } from "../Migrations.ts"; +import * as NodeSqliteClient from "../NodeSqliteClient.ts"; + +const layer = it.layer(Layer.mergeAll(NodeSqliteClient.layerMemory())); + +layer("044_HermesSessionBindings", (it) => { + it.effect("registers a privacy-minimized binding and mutation-intent schema", () => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + yield* runMigrations({ toMigrationInclusive: 44 }); + + const migrations = yield* sql<{ + readonly migration_id: number; + readonly name: string; + }>` + SELECT migration_id, name + FROM effect_sql_migrations + WHERE migration_id = 44 + `; + assert.deepStrictEqual(migrations, [ + { + migration_id: 44, + name: "HermesSessionBindings", + }, + ]); + + const bindingColumns = yield* sql<{ readonly name: string }>` + PRAGMA table_info(hermes_session_bindings) + `; + const intentColumns = yield* sql<{ readonly name: string }>` + PRAGMA table_info(hermes_mutation_intents) + `; + const importColumns = yield* sql<{ readonly name: string }>` + PRAGMA table_info(hermes_session_imports) + `; + assert.deepStrictEqual( + bindingColumns.map(({ name }) => name), + [ + "binding_id", + "provider_instance_id", + "profile_key", + "project_id", + "stored_session_key", + "thread_id", + "protocol_classification", + "protocol_major", + "protocol_minor", + "capabilities_json", + "reconciliation_cursor", + "reconciliation_fingerprint", + "lease_owner_key", + "lease_generation", + "lease_expires_at", + "created_at", + "updated_at", + ], + ); + assert.deepStrictEqual( + intentColumns.map(({ name }) => name), + [ + "operation_id", + "binding_id", + "provider_instance_id", + "profile_key", + "project_id", + "thread_id", + "run_id", + "attempt_id", + "message_id", + "mutation_kind", + "method", + "payload_digest", + "owner_generation", + "state", + "prepared_at", + "admitted_at", + "settled_at", + "updated_at", + ], + ); + assert.deepStrictEqual( + importColumns.map(({ name }) => name), + [ + "import_id", + "provider_instance_id", + "profile_key", + "project_id", + "import_kind", + "stored_session_key", + "thread_id", + "state", + "created_at", + "updated_at", + ], + ); + + const forbiddenColumns = new Set([ + "session_id", + "token", + "prompt", + "transcript", + "payload", + "payload_json", + ]); + assert.ok( + [...bindingColumns, ...intentColumns, ...importColumns].every( + ({ name }) => !forbiddenColumns.has(name), + ), + ); + + const unsettledPromptIndex = yield* sql<{ readonly sql: string }>` + SELECT sql + FROM sqlite_master + WHERE type = 'index' + AND name = 'hermes_mutation_intents_one_unsettled_prompt_idx' + `; + assert.match(unsettledPromptIndex[0]!.sql, /CREATE UNIQUE INDEX/); + assert.match( + unsettledPromptIndex[0]!.sql, + /state IN \('prepared', 'admitted', 'indeterminate'\)/, + ); + + const unsettledCreateIndex = yield* sql<{ readonly sql: string }>` + SELECT sql + FROM sqlite_master + WHERE type = 'index' + AND name = 'hermes_mutation_intents_one_unsettled_create_idx' + `; + assert.match( + unsettledCreateIndex[0]!.sql, + /provider_instance_id, profile_key, project_id, thread_id/, + ); + + const oneMainIndex = yield* sql<{ readonly sql: string }>` + SELECT sql + FROM sqlite_master + WHERE type = 'index' + AND name = 'hermes_session_imports_one_main_idx' + `; + assert.match(oneMainIndex[0]!.sql, /CREATE UNIQUE INDEX/); + assert.match(oneMainIndex[0]!.sql, /WHERE import_kind = 'main'/); + }), + ); +}); diff --git a/apps/server/src/persistence/Migrations/044_HermesSessionBindings.ts b/apps/server/src/persistence/Migrations/044_HermesSessionBindings.ts new file mode 100644 index 00000000000..c54867e975f --- /dev/null +++ b/apps/server/src/persistence/Migrations/044_HermesSessionBindings.ts @@ -0,0 +1,149 @@ +import * as Effect from "effect/Effect"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +/** + * Durable Hermes identity and write-safety state. + * + * The live gateway session id is intentionally absent: Hermes only guarantees + * the profile-scoped stored session key across reconnects. Mutation payloads + * are represented by a caller-computed digest and are never stored here. + */ +export default Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + yield* sql` + CREATE TABLE hermes_session_bindings ( + binding_id TEXT PRIMARY KEY, + provider_instance_id TEXT NOT NULL, + profile_key TEXT NOT NULL, + project_id TEXT NOT NULL, + stored_session_key TEXT NOT NULL, + thread_id TEXT NOT NULL UNIQUE, + protocol_classification TEXT NOT NULL + CHECK (protocol_classification IN ('legacy', 'supported', 'unsupported')), + protocol_major INTEGER, + protocol_minor INTEGER, + capabilities_json TEXT NOT NULL, + reconciliation_cursor TEXT, + reconciliation_fingerprint TEXT, + lease_owner_key TEXT, + lease_generation INTEGER NOT NULL DEFAULT 0 CHECK (lease_generation >= 0), + lease_expires_at TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + UNIQUE (provider_instance_id, profile_key, stored_session_key), + CHECK ( + (protocol_major IS NULL AND protocol_minor IS NULL) + OR (protocol_major IS NOT NULL AND protocol_minor IS NOT NULL) + ), + CHECK ( + (lease_owner_key IS NULL AND lease_expires_at IS NULL) + OR (lease_owner_key IS NOT NULL AND lease_expires_at IS NOT NULL) + ) + ) + `; + + yield* sql` + CREATE TABLE hermes_mutation_intents ( + operation_id TEXT PRIMARY KEY, + binding_id TEXT REFERENCES hermes_session_bindings(binding_id) ON DELETE CASCADE, + provider_instance_id TEXT NOT NULL, + profile_key TEXT NOT NULL, + project_id TEXT NOT NULL, + thread_id TEXT NOT NULL, + run_id TEXT, + attempt_id TEXT, + message_id TEXT, + mutation_kind TEXT NOT NULL, + method TEXT NOT NULL, + payload_digest TEXT NOT NULL + CHECK ( + length(payload_digest) = 64 + AND payload_digest NOT GLOB '*[^0-9a-f]*' + ), + owner_generation INTEGER NOT NULL CHECK (owner_generation >= 0), + state TEXT NOT NULL + CHECK ( + state IN ( + 'prepared', + 'admitted', + 'confirmed', + 'indeterminate', + 'reconciled', + 'rejected' + ) + ), + prepared_at TEXT NOT NULL, + admitted_at TEXT, + settled_at TEXT, + updated_at TEXT NOT NULL, + CHECK ( + binding_id IS NOT NULL + OR (mutation_kind = 'session_create' AND owner_generation = 0) + ) + ) + `; + + yield* sql` + CREATE INDEX hermes_mutation_intents_binding_state_idx + ON hermes_mutation_intents(binding_id, state, prepared_at, operation_id) + `; + + yield* sql` + CREATE INDEX hermes_mutation_intents_thread_state_idx + ON hermes_mutation_intents(thread_id, state, prepared_at, operation_id) + `; + + yield* sql` + CREATE UNIQUE INDEX hermes_mutation_intents_one_unsettled_prompt_idx + ON hermes_mutation_intents(binding_id) + WHERE mutation_kind = 'prompt' + AND state IN ('prepared', 'admitted', 'indeterminate') + `; + + yield* sql` + CREATE UNIQUE INDEX hermes_mutation_intents_one_unsettled_create_idx + ON hermes_mutation_intents(provider_instance_id, profile_key, project_id, thread_id) + WHERE mutation_kind = 'session_create' + AND state IN ('prepared', 'admitted', 'indeterminate') + `; + + /** + * Durable, replayable bridge from Hermes profile sessions to T3 shells. + * A row is written before the orchestration event. Deterministic thread and + * command IDs plus the phase column make retry after any crash idempotent. + * + * `main` has no stored session identity until it is first opened; the normal + * Hermes binding path then creates and attaches its durable Hermes session. + */ + yield* sql` + CREATE TABLE hermes_session_imports ( + import_id TEXT PRIMARY KEY, + provider_instance_id TEXT NOT NULL, + profile_key TEXT NOT NULL, + project_id TEXT NOT NULL, + import_kind TEXT NOT NULL CHECK (import_kind IN ('session', 'main')), + stored_session_key TEXT, + thread_id TEXT NOT NULL UNIQUE, + state TEXT NOT NULL CHECK (state IN ('prepared', 'thread_created', 'completed')), + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + CHECK ( + (import_kind = 'session' AND stored_session_key IS NOT NULL) + OR (import_kind = 'main' AND stored_session_key IS NULL) + ) + ) + `; + + yield* sql` + CREATE UNIQUE INDEX hermes_session_imports_stored_identity_idx + ON hermes_session_imports(provider_instance_id, profile_key, project_id, stored_session_key) + WHERE import_kind = 'session' + `; + + yield* sql` + CREATE UNIQUE INDEX hermes_session_imports_one_main_idx + ON hermes_session_imports(provider_instance_id, profile_key, project_id) + WHERE import_kind = 'main' + `; +}); diff --git a/apps/server/src/persistence/Migrations/045_HermesProactiveEvents.ts b/apps/server/src/persistence/Migrations/045_HermesProactiveEvents.ts new file mode 100644 index 00000000000..6e0ca0beb30 --- /dev/null +++ b/apps/server/src/persistence/Migrations/045_HermesProactiveEvents.ts @@ -0,0 +1,131 @@ +import * as Effect from "effect/Effect"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +/** + * Durable, capability-gated storage for Hermes events that happen without an + * attached T3 turn. The source checkpoint and event page commit together. + */ +export default Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + yield* sql` + CREATE TABLE hermes_proactive_sources ( + source_id TEXT PRIMARY KEY, + provider_instance_id TEXT NOT NULL, + profile_key TEXT NOT NULL, + capability_state TEXT NOT NULL + CHECK (capability_state IN ('ready', 'degraded')), + diagnostic_code TEXT NOT NULL + CHECK ( + diagnostic_code IN ( + 'ready', + 'missing_capability_inventory', + 'missing_durable_global_cursor', + 'missing_stable_event_ids' + ) + ), + missing_capabilities_json TEXT NOT NULL, + checkpoint_cursor TEXT, + checkpoint_sequence INTEGER NOT NULL DEFAULT 0 CHECK (checkpoint_sequence >= 0), + last_checked_at TEXT NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + UNIQUE (provider_instance_id, profile_key) + ) + `; + + yield* sql` + CREATE TABLE hermes_proactive_events ( + event_id TEXT PRIMARY KEY, + source_id TEXT NOT NULL + REFERENCES hermes_proactive_sources(source_id) ON DELETE CASCADE, + external_event_id TEXT NOT NULL, + external_cursor TEXT NOT NULL, + event_kind TEXT NOT NULL, + title TEXT NOT NULL, + body TEXT NOT NULL, + project_id TEXT, + thread_id TEXT, + occurred_at TEXT NOT NULL, + received_at TEXT NOT NULL, + provenance_json TEXT NOT NULL, + UNIQUE (source_id, external_event_id) + ) + `; + + yield* sql` + CREATE INDEX hermes_proactive_events_source_cursor_idx + ON hermes_proactive_events(source_id, external_cursor, event_id) + `; + + yield* sql` + CREATE TABLE hermes_notification_outbox ( + outbox_id TEXT PRIMARY KEY, + event_id TEXT NOT NULL UNIQUE + REFERENCES hermes_proactive_events(event_id) ON DELETE CASCADE, + state TEXT NOT NULL + CHECK (state IN ('pending', 'processing', 'retry', 'delivered', 'dead_letter')), + attempt_count INTEGER NOT NULL DEFAULT 0 CHECK (attempt_count >= 0), + available_at TEXT NOT NULL, + lease_owner TEXT, + lease_expires_at TEXT, + last_error_code TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + delivered_at TEXT, + CHECK ( + (state = 'processing' AND lease_owner IS NOT NULL AND lease_expires_at IS NOT NULL) + OR (state <> 'processing' AND lease_owner IS NULL AND lease_expires_at IS NULL) + ) + ) + `; + + yield* sql` + CREATE INDEX hermes_notification_outbox_claim_idx + ON hermes_notification_outbox(state, available_at, created_at, outbox_id) + WHERE state IN ('pending', 'retry', 'processing') + `; + + yield* sql` + CREATE TABLE hermes_proactive_work_items ( + work_item_id TEXT PRIMARY KEY, + event_id TEXT NOT NULL UNIQUE + REFERENCES hermes_proactive_events(event_id) ON DELETE CASCADE, + project_id TEXT, + thread_id TEXT, + title TEXT NOT NULL, + summary TEXT NOT NULL, + status TEXT NOT NULL CHECK (status IN ('unread', 'read', 'dismissed')), + occurred_at TEXT NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + ) + `; + + yield* sql` + CREATE INDEX hermes_proactive_work_items_status_idx + ON hermes_proactive_work_items(status, occurred_at DESC, work_item_id) + `; + + yield* sql` + CREATE TABLE hermes_in_app_notifications ( + notification_id TEXT PRIMARY KEY, + event_id TEXT NOT NULL UNIQUE + REFERENCES hermes_proactive_events(event_id) ON DELETE CASCADE, + work_item_id TEXT NOT NULL UNIQUE + REFERENCES hermes_proactive_work_items(work_item_id) ON DELETE CASCADE, + project_id TEXT, + thread_id TEXT, + title TEXT NOT NULL, + body TEXT NOT NULL, + status TEXT NOT NULL CHECK (status IN ('unread', 'read', 'dismissed')), + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + ) + `; + + yield* sql` + CREATE INDEX hermes_in_app_notifications_status_idx + ON hermes_in_app_notifications(status, created_at DESC, notification_id) + `; +}); diff --git a/apps/server/src/persistence/Migrations/046_HermesTitleBranchLineage.ts b/apps/server/src/persistence/Migrations/046_HermesTitleBranchLineage.ts new file mode 100644 index 00000000000..4a776f6fd1d --- /dev/null +++ b/apps/server/src/persistence/Migrations/046_HermesTitleBranchLineage.ts @@ -0,0 +1,44 @@ +import * as Effect from "effect/Effect"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +/** + * Provider-authoritative title cursor and latest-head native branch provenance. + * + * Titles themselves remain in the orchestration projection; the binding stores + * only the monotonic upstream cursor/origin needed to suppress stale replay. + */ +export default Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + yield* sql` + ALTER TABLE hermes_session_bindings + ADD COLUMN title_revision INTEGER NOT NULL DEFAULT 0 CHECK (title_revision >= 0) + `; + yield* sql` + ALTER TABLE hermes_session_bindings + ADD COLUMN title_origin TEXT + `; + yield* sql` + ALTER TABLE hermes_session_bindings + ADD COLUMN parent_binding_id TEXT REFERENCES hermes_session_bindings(binding_id) + `; + yield* sql` + ALTER TABLE hermes_session_bindings + ADD COLUMN branch_boundary_mode TEXT + CHECK (branch_boundary_mode IS NULL OR branch_boundary_mode = 'latest_only') + `; + yield* sql` + ALTER TABLE hermes_session_bindings + ADD COLUMN branch_boundary_message_id TEXT + `; + yield* sql` + ALTER TABLE hermes_session_bindings + ADD COLUMN branch_boundary_message_count INTEGER + CHECK (branch_boundary_message_count IS NULL OR branch_boundary_message_count >= 0) + `; + + yield* sql` + CREATE INDEX hermes_session_bindings_parent_idx + ON hermes_session_bindings(parent_binding_id, created_at, binding_id) + `; +}); diff --git a/apps/server/src/persistence/Migrations/047_HermesSessionImports.test.ts b/apps/server/src/persistence/Migrations/047_HermesSessionImports.test.ts new file mode 100644 index 00000000000..100e79b73c6 --- /dev/null +++ b/apps/server/src/persistence/Migrations/047_HermesSessionImports.test.ts @@ -0,0 +1,70 @@ +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +import { runMigrations } from "../Migrations.ts"; +import * as NodeSqliteClient from "../NodeSqliteClient.ts"; + +const layer = it.layer(Layer.mergeAll(NodeSqliteClient.layerMemory())); + +layer("047_HermesSessionImports", (it) => { + it.effect("repairs databases whose recorded migration 044 lacks the import ledger", () => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + yield* runMigrations({ toMigrationInclusive: 46 }); + yield* sql`DROP TABLE hermes_session_imports`; + + yield* runMigrations({ toMigrationInclusive: 47 }); + + const migrations = yield* sql<{ + readonly migration_id: number; + readonly name: string; + }>` + SELECT migration_id, name + FROM effect_sql_migrations + WHERE migration_id = 47 + `; + assert.deepStrictEqual(migrations, [ + { + migration_id: 47, + name: "HermesSessionImports", + }, + ]); + + const importColumns = yield* sql<{ readonly name: string }>` + PRAGMA table_info(hermes_session_imports) + `; + assert.deepStrictEqual( + importColumns.map(({ name }) => name), + [ + "import_id", + "provider_instance_id", + "profile_key", + "project_id", + "import_kind", + "stored_session_key", + "thread_id", + "state", + "created_at", + "updated_at", + ], + ); + + const indexes = yield* sql<{ readonly name: string }>` + SELECT name + FROM sqlite_master + WHERE type = 'index' + AND name IN ( + 'hermes_session_imports_stored_identity_idx', + 'hermes_session_imports_one_main_idx' + ) + ORDER BY name + `; + assert.deepStrictEqual( + indexes.map(({ name }) => name), + ["hermes_session_imports_one_main_idx", "hermes_session_imports_stored_identity_idx"], + ); + }), + ); +}); diff --git a/apps/server/src/persistence/Migrations/047_HermesSessionImports.ts b/apps/server/src/persistence/Migrations/047_HermesSessionImports.ts new file mode 100644 index 00000000000..ac18c3e78f3 --- /dev/null +++ b/apps/server/src/persistence/Migrations/047_HermesSessionImports.ts @@ -0,0 +1,43 @@ +import * as Effect from "effect/Effect"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +/** + * Repair databases that ran the original Hermes bindings migration before the + * session-import ledger was added to it. Released migrations are immutable, so + * existing databases need a new forward migration while fresh databases keep + * receiving the same schema from migration 044. + */ +export default Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + yield* sql` + CREATE TABLE IF NOT EXISTS hermes_session_imports ( + import_id TEXT PRIMARY KEY, + provider_instance_id TEXT NOT NULL, + profile_key TEXT NOT NULL, + project_id TEXT NOT NULL, + import_kind TEXT NOT NULL CHECK (import_kind IN ('session', 'main')), + stored_session_key TEXT, + thread_id TEXT NOT NULL UNIQUE, + state TEXT NOT NULL CHECK (state IN ('prepared', 'thread_created', 'completed')), + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + CHECK ( + (import_kind = 'session' AND stored_session_key IS NOT NULL) + OR (import_kind = 'main' AND stored_session_key IS NULL) + ) + ) + `; + + yield* sql` + CREATE UNIQUE INDEX IF NOT EXISTS hermes_session_imports_stored_identity_idx + ON hermes_session_imports(provider_instance_id, profile_key, project_id, stored_session_key) + WHERE import_kind = 'session' + `; + + yield* sql` + CREATE UNIQUE INDEX IF NOT EXISTS hermes_session_imports_one_main_idx + ON hermes_session_imports(provider_instance_id, profile_key, project_id) + WHERE import_kind = 'main' + `; +}); diff --git a/apps/server/src/persistence/Migrations/048_HermesImportProjectScope.test.ts b/apps/server/src/persistence/Migrations/048_HermesImportProjectScope.test.ts new file mode 100644 index 00000000000..4ae0c03f4a8 --- /dev/null +++ b/apps/server/src/persistence/Migrations/048_HermesImportProjectScope.test.ts @@ -0,0 +1,109 @@ +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +import { runMigrations } from "../Migrations.ts"; +import * as NodeSqliteClient from "../NodeSqliteClient.ts"; + +const layer = it.layer(Layer.mergeAll(NodeSqliteClient.layerMemory())); + +layer("048_HermesImportProjectScope", (it) => { + it.effect("backfills legacy import rows from their thread and drops unrecoverable rows", () => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + yield* runMigrations({ toMigrationInclusive: 47 }); + yield* sql`DROP INDEX hermes_session_imports_stored_identity_idx`; + yield* sql`DROP INDEX hermes_session_imports_one_main_idx`; + yield* sql`DROP TABLE hermes_session_imports`; + yield* sql` + CREATE TABLE hermes_session_imports ( + import_id TEXT PRIMARY KEY, + provider_instance_id TEXT NOT NULL, + profile_key TEXT NOT NULL, + import_kind TEXT NOT NULL, + stored_session_key TEXT, + thread_id TEXT NOT NULL UNIQUE, + state TEXT NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + ) + `; + yield* sql` + INSERT INTO orchestration_v2_projection_threads ( + thread_id, + project_id, + title, + default_provider, + runtime_mode, + interaction_mode, + active_provider_thread_id, + created_at, + updated_at, + archived_at, + deleted_at, + payload_json + ) VALUES ( + 'thread:imported', + 'project:t3-work', + 'Imported', + 'hermes', + 'full-access', + 'default', + NULL, + '2026-07-26T12:00:00.000Z', + '2026-07-26T12:00:00.000Z', + NULL, + NULL, + '{}' + ) + `; + yield* sql` + INSERT INTO hermes_session_imports VALUES + ( + 'import:resolved', + 'hermes-local', + 'default', + 'session', + 'stored:resolved', + 'thread:imported', + 'completed', + '2026-07-26T12:00:00.000Z', + '2026-07-26T12:00:00.000Z' + ), + ( + 'import:unresolved', + 'hermes-local', + 'default', + 'session', + 'stored:unresolved', + 'thread:missing', + 'prepared', + '2026-07-26T12:00:00.000Z', + '2026-07-26T12:00:00.000Z' + ) + `; + + yield* runMigrations({ toMigrationInclusive: 48 }); + + const columns = yield* sql<{ readonly name: string }>` + PRAGMA table_info(hermes_session_imports) + `; + assert.include( + columns.map(({ name }) => name), + "project_id", + ); + const rows = yield* sql<{ + readonly import_id: string; + readonly project_id: string; + }>` + SELECT import_id, project_id + FROM hermes_session_imports + ORDER BY import_id + `; + assert.deepStrictEqual(rows, [ + { import_id: "import:resolved", project_id: "project:t3-work" }, + ]); + }), + ); +}); diff --git a/apps/server/src/persistence/Migrations/048_HermesImportProjectScope.ts b/apps/server/src/persistence/Migrations/048_HermesImportProjectScope.ts new file mode 100644 index 00000000000..c225112161b --- /dev/null +++ b/apps/server/src/persistence/Migrations/048_HermesImportProjectScope.ts @@ -0,0 +1,87 @@ +import * as Effect from "effect/Effect"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +/** + * Repair development databases that ran the pre-project-scoping version of + * the Hermes import ledger. Resolvable rows inherit their project from the + * durable binding or orchestration thread projection. Unresolvable prepared + * rows are safe to discard and will be recreated by the idempotent importer. + */ +export default Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const columns = yield* sql<{ readonly name: string }>` + PRAGMA table_info(hermes_session_imports) + `; + if (columns.some(({ name }) => name === "project_id")) return; + + yield* sql.withTransaction( + Effect.gen(function* () { + yield* sql`DROP INDEX IF EXISTS hermes_session_imports_stored_identity_idx`; + yield* sql`DROP INDEX IF EXISTS hermes_session_imports_one_main_idx`; + yield* sql` + ALTER TABLE hermes_session_imports + RENAME TO hermes_session_imports_legacy_048 + `; + yield* sql` + CREATE TABLE hermes_session_imports ( + import_id TEXT PRIMARY KEY, + provider_instance_id TEXT NOT NULL, + profile_key TEXT NOT NULL, + project_id TEXT NOT NULL, + import_kind TEXT NOT NULL CHECK (import_kind IN ('session', 'main')), + stored_session_key TEXT, + thread_id TEXT NOT NULL UNIQUE, + state TEXT NOT NULL CHECK (state IN ('prepared', 'thread_created', 'completed')), + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + CHECK ( + (import_kind = 'session' AND stored_session_key IS NOT NULL) + OR (import_kind = 'main' AND stored_session_key IS NULL) + ) + ) + `; + yield* sql` + INSERT INTO hermes_session_imports ( + import_id, + provider_instance_id, + profile_key, + project_id, + import_kind, + stored_session_key, + thread_id, + state, + created_at, + updated_at + ) + SELECT + legacy.import_id, + legacy.provider_instance_id, + legacy.profile_key, + COALESCE(binding.project_id, thread.project_id), + legacy.import_kind, + legacy.stored_session_key, + legacy.thread_id, + legacy.state, + legacy.created_at, + legacy.updated_at + FROM hermes_session_imports_legacy_048 AS legacy + LEFT JOIN hermes_session_bindings AS binding + ON binding.thread_id = legacy.thread_id + LEFT JOIN orchestration_v2_projection_threads AS thread + ON thread.thread_id = legacy.thread_id + WHERE COALESCE(binding.project_id, thread.project_id) IS NOT NULL + `; + yield* sql`DROP TABLE hermes_session_imports_legacy_048`; + yield* sql` + CREATE UNIQUE INDEX hermes_session_imports_stored_identity_idx + ON hermes_session_imports(provider_instance_id, profile_key, project_id, stored_session_key) + WHERE import_kind = 'session' + `; + yield* sql` + CREATE UNIQUE INDEX hermes_session_imports_one_main_idx + ON hermes_session_imports(provider_instance_id, profile_key, project_id) + WHERE import_kind = 'main' + `; + }), + ); +}); diff --git a/apps/server/src/persistence/Migrations/049_HermesImportInheritedBoundary.ts b/apps/server/src/persistence/Migrations/049_HermesImportInheritedBoundary.ts new file mode 100644 index 00000000000..798e1a8e01e --- /dev/null +++ b/apps/server/src/persistence/Migrations/049_HermesImportInheritedBoundary.ts @@ -0,0 +1,21 @@ +import * as Effect from "effect/Effect"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +/** + * Records the imported (inherited) Hermes history length on the import + * ledger. Messages before this boundary receive imported-transcript + * normalization and activity rehydration; native T3 messages appended after + * the import remain untouched. + */ +export default Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const columns = yield* sql<{ readonly name: string }>` + PRAGMA table_info(hermes_session_imports) + `; + if (columns.some(({ name }) => name === "inherited_message_count")) return; + + yield* sql` + ALTER TABLE hermes_session_imports + ADD COLUMN inherited_message_count INTEGER + `; +}); diff --git a/apps/server/src/provider/Drivers/HermesAcpDriver.test.ts b/apps/server/src/provider/Drivers/HermesAcpDriver.test.ts new file mode 100644 index 00000000000..fbd27bb6211 --- /dev/null +++ b/apps/server/src/provider/Drivers/HermesAcpDriver.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { BUILT_IN_DRIVERS } from "../builtInDrivers.ts"; +import { HermesAcpDriver } from "./HermesAcpDriver.ts"; + +describe("HermesAcpDriver", () => { + it("is a first-class instance-only driver separate from Hermes Work", () => { + expect(BUILT_IN_DRIVERS).toContain(HermesAcpDriver); + expect(HermesAcpDriver.driverKind).toBe("hermesAcp"); + expect(HermesAcpDriver.metadata.displayName).toBe("Hermes in Code"); + expect(HermesAcpDriver.defaultConfig()).toEqual({ + enabled: true, + binaryPath: "hermes", + customModels: [], + }); + }); +}); diff --git a/apps/server/src/provider/Drivers/HermesAcpDriver.ts b/apps/server/src/provider/Drivers/HermesAcpDriver.ts new file mode 100644 index 00000000000..0b071a7e6a0 --- /dev/null +++ b/apps/server/src/provider/Drivers/HermesAcpDriver.ts @@ -0,0 +1,139 @@ +import { + HermesAcpSettings, + ProviderDriverKind, + TextGenerationError, + type ServerProvider, +} from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; +import * as Stream from "effect/Stream"; +import { ChildProcessSpawner } from "effect/unstable/process"; + +import { + HermesAcpAdapterV2Driver, + type HermesAcpAdapterV2DriverEnv, +} from "../../orchestration-v2/Adapters/HermesAcpAdapterV2.ts"; +import type { TextGenerationShape } from "../../textGeneration/TextGeneration.ts"; +import { ProviderDriverError } from "../Errors.ts"; +import { + buildInitialHermesAcpProviderSnapshot, + checkHermesAcpProviderStatus, +} from "../Layers/HermesAcpProvider.ts"; +import { makeManagedServerProvider } from "../makeManagedServerProvider.ts"; +import { makeManualOnlyProviderMaintenanceCapabilities } from "../providerMaintenance.ts"; +import { + defaultProviderContinuationIdentity, + type ProviderDriver, + type ProviderInstance, +} from "../ProviderDriver.ts"; +import type { ServerProviderDraft } from "../providerSnapshot.ts"; +import { mergeProviderInstanceEnvironment } from "../ProviderInstanceEnvironment.ts"; + +const DRIVER_KIND = ProviderDriverKind.make("hermesAcp"); +const decodeSettings = Schema.decodeSync(HermesAcpSettings); + +const makeUnsupportedTextGeneration = (): TextGenerationShape => { + const unsupported = (operation: string) => + Effect.fail( + new TextGenerationError({ + operation, + detail: "Hermes in Code sessions do not provide application text generation.", + }), + ); + return { + generateCommitMessage: () => unsupported("generateCommitMessage"), + generatePrContent: () => unsupported("generatePrContent"), + generateBranchName: () => unsupported("generateBranchName"), + generateThreadTitle: () => unsupported("generateThreadTitle"), + }; +}; + +export type HermesAcpDriverEnv = + | HermesAcpAdapterV2DriverEnv + | ChildProcessSpawner.ChildProcessSpawner; + +export const HermesAcpDriver: ProviderDriver = { + driverKind: DRIVER_KIND, + metadata: { + displayName: "Hermes in Code", + supportsMultipleInstances: true, + }, + configSchema: HermesAcpSettings, + defaultConfig: () => decodeSettings({}), + create: ({ instanceId, displayName, accentColor, environment, enabled, config }) => + Effect.gen(function* () { + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const continuationIdentity = defaultProviderContinuationIdentity({ + driverKind: DRIVER_KIND, + instanceId, + }); + const processEnvironment = mergeProviderInstanceEnvironment(environment); + const effectiveConfig = { ...config, enabled } satisfies HermesAcpSettings; + const stampIdentity = (snapshot: ServerProviderDraft): ServerProvider => ({ + ...snapshot, + instanceId, + driver: DRIVER_KIND, + ...(displayName ? { displayName } : {}), + ...(accentColor ? { accentColor } : {}), + continuation: { groupKey: continuationIdentity.continuationKey }, + }); + + const orchestrationAdapter = yield* HermesAcpAdapterV2Driver.create({ + instanceId, + displayName, + accentColor, + environment, + enabled, + config, + }).pipe( + Effect.mapError( + (cause) => + new ProviderDriverError({ + driver: DRIVER_KIND, + instanceId, + detail: "Failed to build Hermes in Code ACP orchestration adapter.", + cause, + }), + ), + ); + + const snapshot = yield* makeManagedServerProvider({ + maintenanceCapabilities: makeManualOnlyProviderMaintenanceCapabilities({ + provider: DRIVER_KIND, + packageName: null, + }), + getSettings: Effect.succeed(effectiveConfig), + streamSettings: Stream.empty, + haveSettingsChanged: () => false, + initialSnapshot: (settings) => + buildInitialHermesAcpProviderSnapshot(settings).pipe(Effect.map(stampIdentity)), + checkProvider: checkHermesAcpProviderStatus(effectiveConfig, processEnvironment).pipe( + Effect.map(stampIdentity), + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + ), + refreshInterval: "5 minutes", + }).pipe( + Effect.mapError( + (cause) => + new ProviderDriverError({ + driver: DRIVER_KIND, + instanceId, + detail: "Failed to build Hermes in Code provider diagnostics.", + cause, + }), + ), + ); + + return { + instanceId, + driverKind: DRIVER_KIND, + continuationIdentity, + displayName, + accentColor, + enabled, + snapshot, + orchestrationAdapter, + textGeneration: makeUnsupportedTextGeneration(), + } satisfies ProviderInstance; + }), +}; diff --git a/apps/server/src/provider/Drivers/HermesDriver.test.ts b/apps/server/src/provider/Drivers/HermesDriver.test.ts new file mode 100644 index 00000000000..b842dd6aab7 --- /dev/null +++ b/apps/server/src/provider/Drivers/HermesDriver.test.ts @@ -0,0 +1,364 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, describe, it } from "@effect/vitest"; +import { HermesSettings, ProviderInstanceId } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Schema from "effect/Schema"; + +import * as ServerConfig from "../../config.ts"; +import { layer as HermesSessionBindingRepositoryLayer } from "../../hermes/HermesSessionBindingRepository.ts"; +import { BUILT_IN_PROVIDER_ADAPTER_DRIVERS_V2 } from "../../orchestration-v2/builtInProviderAdapterDrivers.ts"; +import { layer as IdAllocatorV2Layer } from "../../orchestration-v2/IdAllocator.ts"; +import { + hermesModelOverride, + hermesFastOverride, + resolveHermesGatewayToken, + resolveHermesRemotePairingToken, + resolveHermesRemoteTlsCertificateSha256, +} from "../../orchestration-v2/Adapters/HermesServeAdapterV2.ts"; +import { SqlitePersistenceMemory } from "../../persistence/Layers/Sqlite.ts"; +import { BUILT_IN_DRIVERS } from "../builtInDrivers.ts"; +import { HermesDriver, hermesProviderModels, hermesSlashCommands } from "./HermesDriver.ts"; + +const ServerConfigTestLayer = ServerConfig.ServerConfig.layerTest(process.cwd(), { + prefix: "t3-hermes-driver-test-", +}); +const TestLayer = Layer.mergeAll( + IdAllocatorV2Layer, + HermesSessionBindingRepositoryLayer.pipe(Layer.provideMerge(SqlitePersistenceMemory)), + ServerConfigTestLayer, +).pipe(Layer.provideMerge(NodeServices.layer)); +const decodeHermesSettingsEffect = Schema.decodeUnknownEffect(HermesSettings); + +describe("HermesDriver", () => { + it("presents the default sentinel as the effective Hermes model with live options", () => { + const models = hermesProviderModels( + { + model: "gpt-5.6-sol", + provider: "openai", + providers: [ + { + slug: "openai", + name: "OpenAI", + models: ["gpt-5.6-sol", "gpt-5.4"], + capabilities: { + "gpt-5.6-sol": { fast: true, reasoning: true }, + "gpt-5.4": { fast: false, reasoning: true }, + }, + }, + ], + }, + { value: "high", display: "show" }, + { value: "fast" }, + ["default"], + ); + + assert.deepInclude(models[0], { + slug: "default", + name: "GPT-5.6 Sol", + isDefault: true, + }); + assert.deepEqual( + models[0]!.capabilities?.optionDescriptors?.map((option) => [option.id, option.currentValue]), + [ + ["reasoningEffort", "high"], + ["fast", "fast"], + ], + ); + }); + + it("deduplicates model slugs exposed by multiple Hermes upstream providers", () => { + const models = hermesProviderModels( + { + model: "grok-4.5", + provider: "xai", + providers: [ + { + slug: "xai", + name: "xAI", + models: ["grok-4.5"], + capabilities: { "grok-4.5": { reasoning: true } }, + }, + { + slug: "opencode", + name: "OpenCode", + models: ["grok-4.5"], + capabilities: { "grok-4.5": { reasoning: true } }, + }, + ], + }, + { value: "medium", display: "show" }, + { value: "normal" }, + ["default"], + ); + + assert.strictEqual(models.filter((model) => model.slug === "grok-4.5").length, 1); + assert.strictEqual(models.find((model) => model.slug === "grok-4.5")?.subProvider, "xAI"); + }); + + it("resolves default-model capabilities from the active provider on duplicate slugs", () => { + const models = hermesProviderModels( + { + model: "grok-4.5", + provider: "xai", + providers: [ + { + slug: "opencode", + name: "OpenCode", + models: ["grok-4.5"], + capabilities: { "grok-4.5": { fast: true, reasoning: false } }, + }, + { + slug: "xai", + name: "xAI", + is_current: true, + models: ["grok-4.5"], + capabilities: { "grok-4.5": { fast: false, reasoning: true } }, + }, + ], + }, + { value: "medium", display: "show" }, + { value: "normal" }, + ["default"], + ); + + assert.deepEqual( + models + .find((model) => model.slug === "default") + ?.capabilities?.optionDescriptors?.map((option) => option.id), + ["reasoningEffort"], + ); + }); + + it("surfaces catalog aliases and falls back to official gateway commands", () => { + const live = hermesSlashCommands({ + pairs: [["/background", "Run in background (usage: /background )"]], + canon: { "/background": "/background", "/bg": "/background" }, + sub: { "/background": [""] }, + }); + assert.deepInclude(live, { + name: "bg", + description: "Alias for /background", + input: { hint: "" }, + }); + + const fallback = hermesSlashCommands(undefined); + for (const gatewayCommand of [ + "new", + "reset", + "topic", + "approve", + "deny", + "reasoning", + "skills", + "commands", + "restart", + "platform", + ]) { + assert.isTrue( + fallback.some((command) => command.name === gatewayCommand), + `expected fallback /${gatewayCommand}`, + ); + } + assert.deepInclude( + fallback.find((command) => command.name === "model"), + { + name: "model", + input: { hint: "[model] [--provider name] [--global|--session] [--refresh]" }, + }, + ); + assert.deepInclude( + fallback.find((command) => command.name === "clear"), + { + name: "clear", + description: "Clear the visible T3 Work timeline without resetting Hermes context", + }, + ); + }); + + it("decodes a safe default config and accepts explicit loopback/profile settings", () => { + const decode = Schema.decodeSync(HermesSettings); + assert.deepEqual(decode({}), { + enabled: false, + endpoint: "", + remoteAccessEnabled: false, + profileKey: "default", + managedServerEnabled: true, + customModels: [], + importEnabled: false, + mcpEnabled: true, + attachmentsEnabled: true, + proactiveEnabled: false, + voiceEnabled: false, + }); + assert.deepEqual( + decode({ + endpoint: "ws://127.0.0.1:9119/api/ws", + profileKey: "real-profile", + customModels: ["custom/model"], + }), + { + enabled: false, + endpoint: "ws://127.0.0.1:9119/api/ws", + remoteAccessEnabled: false, + profileKey: "real-profile", + managedServerEnabled: true, + customModels: ["custom/model"], + importEnabled: false, + mcpEnabled: true, + attachmentsEnabled: true, + proactiveEnabled: false, + voiceEnabled: false, + }, + ); + }); + + it("only consumes a non-empty sensitive instance token", () => { + assert.equal( + resolveHermesGatewayToken([ + { name: "HERMES_GATEWAY_TOKEN", value: "secret", sensitive: true }, + ]), + "secret", + ); + assert.isUndefined( + resolveHermesGatewayToken([ + { name: "HERMES_GATEWAY_TOKEN", value: "plain", sensitive: false }, + ]), + ); + assert.isUndefined( + resolveHermesGatewayToken([{ name: "HERMES_GATEWAY_TOKEN", value: "", sensitive: true }]), + ); + }); + + it("only consumes dedicated sensitive remote trust and pairing variables", () => { + const environment = [ + { name: "HERMES_GATEWAY_TOKEN", value: "broad-token", sensitive: true }, + { name: "HERMES_REMOTE_PAIRING_TOKEN", value: "pairing-token", sensitive: true }, + { + name: "HERMES_REMOTE_TLS_CERT_SHA256", + value: "ab".repeat(32), + sensitive: true, + }, + { name: "MCP_API_TOKEN", value: "must-not-be-used", sensitive: true }, + ]; + + assert.equal(resolveHermesRemotePairingToken(environment), "pairing-token"); + assert.equal(resolveHermesRemoteTlsCertificateSha256(environment), "ab".repeat(32)); + assert.isUndefined( + resolveHermesRemotePairingToken([ + { name: "HERMES_REMOTE_PAIRING_TOKEN", value: "plain", sensitive: false }, + ]), + ); + }); + + it("omits the profile default model and forwards only explicit custom slugs", () => { + assert.deepEqual(hermesModelOverride("default"), {}); + assert.deepEqual(hermesModelOverride("custom/model"), { model: "custom/model" }); + }); + + it("maps the effective fast option onto fresh Hermes sessions", () => { + const selection = { + instanceId: ProviderInstanceId.make("hermes"), + model: "default", + options: [{ id: "fast", value: "fast" }], + }; + assert.deepEqual(hermesFastOverride(selection), { fast: true }); + assert.deepEqual( + hermesFastOverride({ ...selection, options: [{ id: "fast", value: "normal" }] }), + { fast: false }, + ); + }); + + it("is registered in both built-in driver catalogs", () => { + assert.isTrue(BUILT_IN_DRIVERS.includes(HermesDriver)); + assert.isTrue( + BUILT_IN_PROVIDER_ADAPTER_DRIVERS_V2.some((driver) => driver.driverKind === "hermes"), + ); + }); + + it.effect( + "advertises default/custom models while disabled and keeps text generation unsupported", + () => + Effect.scoped( + Effect.gen(function* () { + const config = yield* decodeHermesSettingsEffect({ + endpoint: "ws://127.0.0.1:9119/api/ws", + profileKey: "real-profile", + customModels: ["custom/model"], + }); + const instance = yield* HermesDriver.create({ + instanceId: ProviderInstanceId.make("hermes_disabled"), + displayName: "Hermes local", + accentColor: undefined, + environment: [{ name: "HERMES_GATEWAY_TOKEN", value: "secret", sensitive: true }], + enabled: false, + config, + }); + const snapshot = yield* instance.snapshot.getSnapshot; + const textGeneration = yield* Effect.result( + instance.textGeneration.generateThreadTitle({ + cwd: "/tmp/hermes-project", + message: "ignored", + modelSelection: { + instanceId: ProviderInstanceId.make("hermes_disabled"), + model: "default", + }, + }), + ); + + assert.equal(snapshot.status, "disabled"); + assert.deepEqual( + snapshot.models.map((model) => model.slug), + ["default", "custom/model"], + ); + assert.equal(textGeneration._tag, "Failure"); + assert.isNull(instance.snapshot.maintenanceCapabilities.update); + assert.isUndefined(instance.hermesSessionCatalog); + }), + ).pipe(Effect.provide(TestLayer)), + ); + + it.effect("only reports an enabled Hermes instance ready when setup is complete", () => + Effect.scoped( + Effect.gen(function* () { + const incompleteConfig = yield* decodeHermesSettingsEffect({ + profileKey: "default", + }); + const incomplete = yield* HermesDriver.create({ + instanceId: ProviderInstanceId.make("hermes_incomplete"), + displayName: "Hermes incomplete", + accentColor: undefined, + environment: [], + enabled: true, + config: incompleteConfig, + }); + const incompleteSnapshot = yield* incomplete.snapshot.getSnapshot; + + assert.equal(incompleteSnapshot.status, "warning"); + assert.equal(incompleteSnapshot.auth.status, "unauthenticated"); + assert.include(incompleteSnapshot.message, "HERMES_GATEWAY_TOKEN"); + assert.include(incompleteSnapshot.message, "attach to an existing Hermes Serve"); + + const configuredConfig = yield* decodeHermesSettingsEffect({ + endpoint: "ws://127.0.0.1:49119/api/ws", + profileKey: "default", + managedServerEnabled: false, + }); + const configured = yield* HermesDriver.create({ + instanceId: ProviderInstanceId.make("hermes_configured"), + displayName: "Hermes configured", + accentColor: undefined, + environment: [{ name: "HERMES_GATEWAY_TOKEN", value: "secret", sensitive: true }], + enabled: true, + config: configuredConfig, + }); + const configuredSnapshot = yield* configured.snapshot.getSnapshot; + + assert.equal(configuredSnapshot.status, "warning"); + assert.equal(configuredSnapshot.auth.status, "unknown"); + assert.include(configuredSnapshot.message, "automatic startup is disabled"); + assert.isDefined(configured.hermesSessionCatalog); + }), + ).pipe(Effect.provide(TestLayer)), + ); +}); diff --git a/apps/server/src/provider/Drivers/HermesDriver.ts b/apps/server/src/provider/Drivers/HermesDriver.ts new file mode 100644 index 00000000000..fe3fd41f4be --- /dev/null +++ b/apps/server/src/provider/Drivers/HermesDriver.ts @@ -0,0 +1,677 @@ +import { + HermesSettings, + ProviderDriverKind, + TextGenerationError, + type HermesGatewayCommandsCatalogResult, + type HermesGatewayFastConfigResult, + type HermesGatewayModelOptionsResult, + type HermesGatewayReasoningConfigResult, + type ServerProvider, + type ServerProviderModel, + type ServerProviderSlashCommand, +} from "@t3tools/contracts"; +import { createModelCapabilities } from "@t3tools/shared/model"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; +import * as Stream from "effect/Stream"; +import { ChildProcessSpawner } from "effect/unstable/process"; + +import { assessHermesConnectionSecurity } from "../../hermes/HermesConnectionSecurity.ts"; +import { HermesGatewayClient } from "../../hermes/HermesGatewayClient.ts"; +import { + makeHermesServeRuntime, + type HermesServeOwnership, +} from "../../hermes/HermesServeRuntime.ts"; +import { + makeHermesServeAdapterV2Driver, + resolveHermesGatewayToken, + resolveHermesRemotePairingToken, + resolveHermesRemoteTlsCertificateSha256, + type HermesServeAdapterV2DriverEnv, +} from "../../orchestration-v2/Adapters/HermesServeAdapterV2.ts"; +import type { TextGenerationShape } from "../../textGeneration/TextGeneration.ts"; +import { makeHermesSessionCatalog } from "../../hermes/HermesSessionCatalog.ts"; +import { ProviderDriverError } from "../Errors.ts"; +import { mergeProviderInstanceEnvironment } from "../ProviderInstanceEnvironment.ts"; +import { makeManualOnlyProviderMaintenanceCapabilities } from "../providerMaintenance.ts"; +import { + defaultProviderContinuationIdentity, + type ProviderDriver, + type ProviderInstance, +} from "../ProviderDriver.ts"; + +const DRIVER_KIND = ProviderDriverKind.make("hermes"); +const decodeSettings = Schema.decodeSync(HermesSettings); + +const unsupportedTextGeneration = (): TextGenerationShape => { + const unsupported = (operation: string) => + Effect.fail( + new TextGenerationError({ + operation, + detail: "Hermes instances do not provide application text generation.", + }), + ); + return { + generateCommitMessage: () => unsupported("generateCommitMessage"), + generatePrContent: () => unsupported("generatePrContent"), + generateBranchName: () => unsupported("generateBranchName"), + generateThreadTitle: () => unsupported("generateThreadTitle"), + }; +}; + +const HERMES_REASONING_EFFORTS = [ + "none", + "minimal", + "low", + "medium", + "high", + "xhigh", + "max", + "ultra", +] as const; + +const reasoningLabel = (effort: string): string => + effort === "xhigh" ? "Extra High" : effort.charAt(0).toUpperCase() + effort.slice(1); + +const HERMES_FALLBACK_COMMANDS = [ + ["start", "Acknowledge platform start pings without a reply"], + ["new", "Start a new chat (usage: /new [name])"], + ["clear", "Clear the visible T3 Work timeline without resetting Hermes context"], + ["topic", "Enable or inspect Telegram DM topic sessions (usage: /topic [off|help|session-id])"], + ["retry", "Retry the last message (resend to agent)"], + ["undo", "Back up N user turns and re-prompt (default 1) (usage: /undo [N])"], + ["title", "Set a title for the current session (usage: /title [name])"], + ["branch", "Branch the current session (explore a different path) (usage: /branch [name])"], + [ + "compress", + "Compress conversation context (usage: /compress [here [N] | focus topic | --preview|--dry-run])", + ], + ["rollback", "List or restore filesystem checkpoints (usage: /rollback [number])"], + ["stop", "Kill all running background processes"], + ["approve", "Approve a pending dangerous command (usage: /approve [session|always])"], + ["deny", "Deny a pending dangerous command (usage: /deny [all] [reason])"], + ["background", "Run a prompt in the background (usage: /background )"], + ["agents", "Show active agents and running tasks"], + ["queue", "Queue a prompt for the next turn (usage: /queue )"], + ["steer", "Inject a message after the next tool call (usage: /steer )"], + [ + "goal", + "Set or manage a standing goal (usage: /goal [text | draft | show | pause | resume | clear | status | wait | unwait])", + ], + ["moa", "Run one prompt through the default Mixture of Agents preset (usage: /moa )"], + [ + "subgoal", + "Add or manage criteria on the active goal (usage: /subgoal [text | remove N | clear])", + ], + ["status", "Show session, model, token, and context info"], + ["egress", "Show Docker egress proxy status (usage: /egress [status])"], + ["whoami", "Show your slash command access"], + ["profile", "Show active profile name and home directory"], + ["sethome", "Set this chat as the home channel"], + ["resume", "Resume a previously-named session (usage: /resume [name])"], + ["sessions", "Browse and resume previous sessions"], + [ + "model", + "Switch model (usage: /model [model] [--provider name] [--global|--session] [--refresh])", + ], + [ + "codex-runtime", + "Toggle codex app-server runtime for OpenAI/Codex models (usage: /codex-runtime [auto|codex_app_server])", + ], + ["personality", "Set a predefined personality (usage: /personality [name])"], + ["verbose", "Cycle tool progress display: off → new → all → verbose → log"], + ["footer", "Toggle gateway runtime-metadata footer (usage: /footer [on|off|status])"], + ["yolo", "Toggle YOLO mode"], + ["reasoning", "Manage reasoning effort and display"], + ["fast", "Toggle fast processing mode (usage: /fast [normal|fast|status] [--global])"], + ["voice", "Toggle voice mode (usage: /voice [on|off|tts|status])"], + ["skills", "Search, install, inspect, or manage skills"], + ["memory", "Review pending memory writes or toggle the approval gate"], + ["bundles", "List skill bundles"], + ["learn", "Learn a reusable skill (usage: /learn )"], + ["suggestions", "Review suggested automations"], + ["blueprint", "Set up an automation from a blueprint template"], + ["curator", "Manage background skill maintenance"], + ["kanban", "Manage the multi-profile collaboration board"], + ["reload-mcp", "Reload MCP servers from config"], + ["reload-skills", "Re-scan installed skills"], + ["commands", "Browse all commands and skills (usage: /commands [page])"], + ["help", "Show available commands"], + ["restart", "Gracefully restart the gateway after draining active runs"], + ["usage", "Show token usage and rate limits"], + ["topup", "Show Nous balance and billing options"], + ["insights", "Show usage insights and analytics (usage: /insights [days])"], + [ + "platform", + "Pause, resume, or list a failing gateway platform (usage: /platform [name])", + ], + ["update", "Update Hermes Agent to the latest version"], + ["version", "Show Hermes Agent version"], + ["debug", "Upload a debug report (usage: /debug [nous|local])"], +] as const; + +export const HERMES_FALLBACK_COMMAND_CATALOG: HermesGatewayCommandsCatalogResult = { + pairs: HERMES_FALLBACK_COMMANDS.map(([name, description]) => [`/${name}`, description]), + canon: { + "/reset": "/new", + "/fork": "/branch", + "/compact": "/compress", + "/bg": "/background", + "/btw": "/background", + "/tasks": "/agents", + "/q": "/queue", + "/set-home": "/sethome", + "/codex_runtime": "/codex-runtime", + "/suggest": "/suggestions", + "/bp": "/blueprint", + "/reload_mcp": "/reload-mcp", + "/reload_skills": "/reload-skills", + "/v": "/version", + }, + sub: { + "/topic": ["off", "help", "session-id"], + "/approve": ["session", "always"], + "/deny": ["all"], + "/goal": ["draft", "show", "pause", "resume", "clear", "status", "wait", "unwait"], + "/subgoal": ["remove", "clear"], + "/egress": ["status"], + "/codex-runtime": ["auto", "codex_app_server"], + "/verbose": ["off", "new", "all", "verbose", "log"], + "/footer": ["on", "off", "status"], + "/reasoning": [...HERMES_REASONING_EFFORTS, "show", "hide", "full", "clamp", "--global"], + "/fast": ["normal", "fast", "status", "on", "off", "--global"], + "/voice": ["on", "off", "tts", "status"], + "/skills": [ + "search", + "browse", + "inspect", + "install", + "audit", + "pending", + "approve", + "reject", + "diff", + "approval", + ], + "/memory": ["pending", "approve", "reject", "approval"], + "/suggestions": ["accept", "dismiss", "catalog", "clear"], + "/curator": ["status", "run", "pause", "resume", "pin", "unpin", "restore", "list-archived"], + "/platform": ["pause", "resume", "list"], + }, + warning: "Using the built-in Hermes command catalog because gateway probing is unavailable.", +}; + +const displayModelName = (model: string): string => { + const parts = model.replace(/^.*[/:]/u, "").split("-"); + if (/^gpt$/iu.test(parts[0] ?? "") && /^\d/u.test(parts[1] ?? "")) { + return [ + `GPT-${parts[1]}`, + ...parts.slice(2).map((part) => part.charAt(0).toUpperCase() + part.slice(1)), + ].join(" "); + } + return parts.map((part) => part.charAt(0).toUpperCase() + part.slice(1)).join(" "); +}; + +export function hermesProviderModels( + inventory: HermesGatewayModelOptionsResult | undefined, + reasoning: HermesGatewayReasoningConfigResult | undefined, + fast: HermesGatewayFastConfigResult | undefined, + fallbackModels: ReadonlyArray, +): ReadonlyArray { + const effectiveModel = inventory?.model?.trim(); + const inventoryModels = (() => { + const bySlug = new Map< + string, + ServerProviderModel & { + readonly providerCapabilities?: { + readonly fast?: boolean | undefined; + readonly reasoning?: boolean | undefined; + }; + } + >(); + for (const provider of inventory?.providers ?? []) { + for (const model of provider.models ?? []) { + if (bySlug.has(model)) continue; + const providerCapabilities = provider.capabilities?.[model]; + bySlug.set(model, { + slug: model, + name: displayModelName(model), + subProvider: provider.name, + isCustom: false, + capabilities: null, + ...(providerCapabilities === undefined ? {} : { providerCapabilities }), + }); + } + } + return [...bySlug.values()]; + })(); + const models: Array< + ServerProviderModel & { + readonly providerCapabilities?: { + readonly fast?: boolean | undefined; + readonly reasoning?: boolean | undefined; + }; + } + > = + inventoryModels.length > 0 && effectiveModel + ? [ + { + slug: "default", + name: displayModelName(effectiveModel), + isCustom: false, + isDefault: true, + capabilities: null, + ...(() => { + const providers = inventory?.providers ?? []; + const preferred = providers.filter( + (provider) => provider.is_current === true || provider.slug === inventory?.provider, + ); + const owner = [...preferred, ...providers].find((provider) => + provider.models?.includes(effectiveModel), + ); + const providerCapabilities = owner?.capabilities?.[effectiveModel]; + return providerCapabilities === undefined ? {} : { providerCapabilities }; + })(), + }, + ...inventoryModels.filter((model) => model.slug !== "default"), + ] + : Array.from(new Set(fallbackModels)).map((model) => ({ + slug: model, + name: model === "default" ? "Hermes configured model" : displayModelName(model), + isCustom: model !== "default", + ...(model === "default" ? { isDefault: true } : {}), + capabilities: null, + })); + const effectiveEffort = reasoning?.value || "medium"; + return models.map(({ providerCapabilities, ...model }) => { + const optionDescriptors = []; + if (providerCapabilities?.reasoning !== false) { + optionDescriptors.push({ + id: "reasoningEffort", + label: "Reasoning", + type: "select" as const, + options: HERMES_REASONING_EFFORTS.map((effort) => ({ + id: effort, + label: reasoningLabel(effort), + ...(effort === effectiveEffort ? { isDefault: true } : {}), + })), + currentValue: effectiveEffort, + }); + } + if (providerCapabilities?.fast === true) { + const effectiveFast = fast?.value ?? "normal"; + optionDescriptors.push({ + id: "fast", + label: "Processing", + type: "select" as const, + options: [ + { + id: "normal", + label: "Normal", + ...(effectiveFast === "normal" ? { isDefault: true } : {}), + }, + { id: "fast", label: "Fast", ...(effectiveFast === "fast" ? { isDefault: true } : {}) }, + ], + currentValue: effectiveFast, + }); + } + return { + ...model, + capabilities: + optionDescriptors.length === 0 ? null : createModelCapabilities({ optionDescriptors }), + }; + }); +} + +export function hermesSlashCommands( + catalog: HermesGatewayCommandsCatalogResult | undefined, +): ReadonlyArray { + const effectiveCatalog = catalog?.pairs === undefined ? HERMES_FALLBACK_COMMAND_CATALOG : catalog; + const catalogPairs = effectiveCatalog.pairs ?? HERMES_FALLBACK_COMMAND_CATALOG.pairs ?? []; + const seen = new Set(); + const commands = catalogPairs.flatMap(([wireName, rawDescription]) => { + const name = wireName.replace(/^\/+/u, "").trim(); + if (!name || seen.has(name)) return []; + seen.add(name); + const description = rawDescription.trim(); + const subcommands = effectiveCatalog.sub?.[name] ?? effectiveCatalog.sub?.[wireName] ?? []; + const usageHint = description.match(/\(usage:\s*\/\S+\s+(.+)\)$/iu)?.[1]?.trim(); + const inputHint = subcommands.length > 0 ? subcommands.join(" | ") : usageHint; + return [ + { + name, + ...(description ? { description } : {}), + ...(inputHint ? { input: { hint: inputHint } } : {}), + }, + ]; + }); + for (const [aliasWireName, canonicalWireName] of Object.entries(effectiveCatalog.canon ?? {})) { + const alias = aliasWireName.replace(/^\/+/u, "").trim(); + const canonical = canonicalWireName.replace(/^\/+/u, "").trim(); + if (!alias || alias === canonical || seen.has(alias)) continue; + seen.add(alias); + const canonicalInput = commands.find((command) => command.name === canonical)?.input; + commands.push({ + name: alias, + description: `Alias for /${canonical}`, + ...(canonicalInput ? { input: canonicalInput } : {}), + }); + } + for (const native of [ + { name: "new", description: "Start a new chat" }, + { name: "reset", description: "Start a new chat" }, + { + name: "clear", + description: "Clear the visible T3 Work timeline without resetting Hermes context", + }, + ] as const) { + if (seen.has(native.name)) continue; + seen.add(native.name); + commands.push(native); + } + return commands; +} + +function snapshot(input: { + readonly instanceId: ProviderInstance["instanceId"]; + readonly displayName: string | undefined; + readonly accentColor: string | undefined; + readonly enabled: boolean; + readonly settings: HermesSettings; + readonly gatewayToken: string | undefined; + readonly remotePairingToken: string | undefined; + readonly remoteTlsCertificateSha256: string | undefined; + readonly continuationKey: string; + readonly checkedAt: string; + readonly inventory?: { + readonly commands?: HermesGatewayCommandsCatalogResult; + readonly models?: HermesGatewayModelOptionsResult; + readonly reasoning?: HermesGatewayReasoningConfigResult; + readonly fast?: HermesGatewayFastConfigResult; + }; + readonly inventoryWarning?: string; + readonly effectiveEndpoint: string; + readonly connectionOwnership?: HermesServeOwnership; +}): ServerProvider { + const models = hermesProviderModels( + input.inventory?.models, + input.inventory?.reasoning, + input.inventory?.fast, + ["default", ...input.settings.customModels], + ); + const hasProfileKey = input.settings.profileKey.trim().length > 0; + const connectionSecurity = input.effectiveEndpoint + ? assessHermesConnectionSecurity({ + endpoint: input.effectiveEndpoint, + gatewayToken: input.gatewayToken, + remoteGloballyEnabled: input.enabled, + remoteInstanceEnabled: input.settings.remoteAccessEnabled, + remotePairingToken: input.remotePairingToken, + remoteTlsCertificateSha256: input.remoteTlsCertificateSha256, + }) + : undefined; + const hasGatewayToken = input.gatewayToken !== undefined; + const isConfigured = hasProfileKey && connectionSecurity?.status === "ready"; + const isUnauthenticated = + !input.enabled || + !hasGatewayToken || + (connectionSecurity?.status !== "ready" && + connectionSecurity?.code === "remote_pairing_required") || + (!hasGatewayToken && connectionSecurity?.scope === "loopback"); + const isAuthenticated = + hasGatewayToken && + input.inventory !== undefined && + Object.values(input.inventory).some((value) => value !== undefined); + const configurationMessage = !input.enabled + ? undefined + : !hasProfileKey + ? "Configure a Hermes profile key before starting a thread." + : !hasGatewayToken + ? "Add a sensitive HERMES_GATEWAY_TOKEN. T3 will use it to attach to an existing Hermes Serve instance or securely launch its own." + : connectionSecurity?.status === "ready" + ? (input.inventoryWarning ?? + (input.connectionOwnership === "t3_owned" + ? "Running a private Hermes Serve process managed by T3 Work." + : input.connectionOwnership === "external" + ? "Attached to an existing Hermes Serve instance." + : undefined)) + : connectionSecurity?.message; + return { + instanceId: input.instanceId, + driver: DRIVER_KIND, + ...(input.displayName ? { displayName: input.displayName } : {}), + ...(input.accentColor ? { accentColor: input.accentColor } : {}), + continuation: { groupKey: input.continuationKey }, + enabled: input.enabled, + installed: true, + version: null, + status: !input.enabled + ? "disabled" + : isConfigured && input.inventoryWarning === undefined + ? "ready" + : "warning", + auth: { + status: isUnauthenticated ? "unauthenticated" : isAuthenticated ? "authenticated" : "unknown", + }, + checkedAt: input.checkedAt, + ...(configurationMessage === undefined ? {} : { message: configurationMessage }), + models, + slashCommands: hermesSlashCommands(input.inventory?.commands), + skills: [], + }; +} + +export type HermesDriverEnv = + | HermesServeAdapterV2DriverEnv + | ChildProcessSpawner.ChildProcessSpawner; + +export const HermesDriver: ProviderDriver = { + driverKind: DRIVER_KIND, + metadata: { + displayName: "Hermes", + supportsMultipleInstances: true, + }, + configSchema: HermesSettings, + defaultConfig: () => decodeSettings({}), + create: ({ instanceId, displayName, accentColor, environment, enabled, config }) => + Effect.gen(function* () { + const continuationIdentity = defaultProviderContinuationIdentity({ + driverKind: DRIVER_KIND, + instanceId, + }); + const checkedAt = DateTime.formatIso(yield* DateTime.now); + const gatewayToken = resolveHermesGatewayToken(environment); + const connectionRuntime = yield* makeHermesServeRuntime({ + endpoint: config.endpoint, + authToken: gatewayToken, + managedServerEnabled: config.managedServerEnabled, + processEnvironment: mergeProviderInstanceEnvironment(environment), + }); + const orchestrationAdapter = yield* makeHermesServeAdapterV2Driver( + { + instanceId, + displayName, + accentColor, + environment, + enabled, + config, + }, + { connectionRuntime }, + ).pipe( + Effect.mapError( + (cause) => + new ProviderDriverError({ + driver: DRIVER_KIND, + instanceId, + detail: "Failed to build Hermes orchestration adapter.", + cause, + }), + ), + ); + const remotePairingToken = resolveHermesRemotePairingToken(environment); + const remoteTlsCertificateSha256 = resolveHermesRemoteTlsCertificateSha256(environment); + let inventory: + | { + readonly commands?: HermesGatewayCommandsCatalogResult; + readonly models?: HermesGatewayModelOptionsResult; + readonly reasoning?: HermesGatewayReasoningConfigResult; + readonly fast?: HermesGatewayFastConfigResult; + } + | undefined; + let inventoryWarning: string | undefined; + let connectionOwnership: HermesServeOwnership | undefined; + const currentSnapshot = () => + snapshot({ + instanceId, + displayName, + accentColor, + enabled, + settings: config, + gatewayToken, + remotePairingToken, + remoteTlsCertificateSha256, + continuationKey: continuationIdentity.continuationKey, + checkedAt, + effectiveEndpoint: connectionRuntime.effectiveEndpoint, + ...(inventory === undefined ? {} : { inventory }), + ...(inventoryWarning === undefined ? {} : { inventoryWarning }), + ...(connectionOwnership === undefined ? {} : { connectionOwnership }), + }); + const refreshSnapshot = Effect.gen(function* () { + if (!enabled || !config.profileKey.trim()) { + return currentSnapshot(); + } + inventory = undefined; + inventoryWarning = undefined; + connectionOwnership = undefined; + const resolvedConnection = yield* Effect.result(connectionRuntime.ensureReady); + if (resolvedConnection._tag === "Failure") { + inventoryWarning = resolvedConnection.failure.message; + return currentSnapshot(); + } + connectionOwnership = resolvedConnection.success.ownership; + const security = assessHermesConnectionSecurity({ + endpoint: resolvedConnection.success.endpoint, + gatewayToken: resolvedConnection.success.authToken, + remoteGloballyEnabled: enabled, + remoteInstanceEnabled: config.remoteAccessEnabled, + remotePairingToken, + remoteTlsCertificateSha256, + }); + if (security.status !== "ready") return currentSnapshot(); + if (security.scope !== "loopback") { + inventoryWarning = + "Hermes command/model inventory probing is unavailable for remote gateways in this build."; + return currentSnapshot(); + } + const client = new HermesGatewayClient({ + endpoint: security.endpoint, + authToken: security.authToken, + reconnect: { maxAttempts: 0 }, + }); + const probeResult = yield* Effect.tryPromise({ + try: async () => { + await client.connect(); + const [commandsResult, modelsResult, reasoningResult, fastResult] = + await Promise.allSettled([ + client.readCommandsCatalog(), + client.readModelOptions({ + explicit_only: true, + include_unconfigured: false, + }), + client.readReasoningConfig(), + client.readFastConfig(), + ]); + return { commandsResult, modelsResult, reasoningResult, fastResult }; + }, + catch: (cause) => + new ProviderDriverError({ + driver: DRIVER_KIND, + instanceId, + detail: "Hermes command and model inventory probe failed.", + cause, + }), + }).pipe( + Effect.ensuring( + Effect.sync(() => { + client.close(); + }), + ), + Effect.result, + ); + if (probeResult._tag === "Failure") { + inventoryWarning = + "Hermes Serve connected, but its command and model inventory could not be read."; + return currentSnapshot(); + } + const { commandsResult, modelsResult, reasoningResult, fastResult } = probeResult.success; + inventory = { + ...(commandsResult.status === "fulfilled" ? { commands: commandsResult.value } : {}), + ...(modelsResult.status === "fulfilled" ? { models: modelsResult.value } : {}), + ...(reasoningResult.status === "fulfilled" ? { reasoning: reasoningResult.value } : {}), + ...(fastResult.status === "fulfilled" ? { fast: fastResult.value } : {}), + }; + const probeWarnings: Array = []; + if (commandsResult.status === "fulfilled") { + const warning = commandsResult.value.warning?.trim(); + if (warning) probeWarnings.push(warning); + } else { + probeWarnings.push( + "Hermes command probing is unavailable; using the built-in command catalog.", + ); + } + if (modelsResult.status === "rejected") { + probeWarnings.push( + "Hermes model options probing is unavailable; using the built-in model catalog.", + ); + } + if (reasoningResult.status === "rejected") { + probeWarnings.push("Hermes reasoning configuration could not be read."); + } + if (fastResult.status === "rejected") { + probeWarnings.push("Hermes fast-mode configuration could not be read."); + } + inventoryWarning = probeWarnings.length > 0 ? probeWarnings.join(" ") : undefined; + return currentSnapshot(); + }); + + return { + instanceId, + driverKind: DRIVER_KIND, + continuationIdentity, + displayName, + accentColor, + enabled, + snapshot: { + maintenanceCapabilities: makeManualOnlyProviderMaintenanceCapabilities({ + provider: DRIVER_KIND, + packageName: null, + }), + getSnapshot: refreshSnapshot, + refresh: refreshSnapshot, + streamChanges: Stream.empty, + }, + orchestrationAdapter, + // A disabled instance must not expose session discovery or import; + // calling the catalog could otherwise start a managed Hermes process. + ...(enabled + ? { + hermesSessionCatalog: makeHermesSessionCatalog({ + providerInstanceId: instanceId, + endpoint: connectionRuntime.effectiveEndpoint, + authToken: gatewayToken, + remoteGloballyEnabled: enabled, + remoteInstanceEnabled: config.remoteAccessEnabled, + remotePairingToken, + remoteTlsCertificateSha256, + profileKey: config.profileKey, + importEnabled: config.importEnabled, + ensureReady: connectionRuntime.ensureReady, + }), + } + : {}), + textGeneration: unsupportedTextGeneration(), + } satisfies ProviderInstance; + }), +}; diff --git a/apps/server/src/provider/Drivers/OpenClawDriver.test.ts b/apps/server/src/provider/Drivers/OpenClawDriver.test.ts new file mode 100644 index 00000000000..668552e14c3 --- /dev/null +++ b/apps/server/src/provider/Drivers/OpenClawDriver.test.ts @@ -0,0 +1,15 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { BUILT_IN_DRIVERS } from "../builtInDrivers.ts"; +import { OpenClawDriver } from "./OpenClawDriver.ts"; + +describe("OpenClawDriver", () => { + it("is a discoverable built-in ACP provider", () => { + expect(BUILT_IN_DRIVERS).toContain(OpenClawDriver); + expect(OpenClawDriver.driverKind).toBe("openclaw"); + expect(OpenClawDriver.metadata).toEqual({ + displayName: "OpenClaw", + supportsMultipleInstances: true, + }); + }); +}); diff --git a/apps/server/src/provider/Drivers/OpenClawDriver.ts b/apps/server/src/provider/Drivers/OpenClawDriver.ts new file mode 100644 index 00000000000..3017f30f148 --- /dev/null +++ b/apps/server/src/provider/Drivers/OpenClawDriver.ts @@ -0,0 +1,139 @@ +import { + OpenClawSettings, + ProviderDriverKind, + TextGenerationError, + type ServerProvider, +} from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; +import * as Stream from "effect/Stream"; +import { ChildProcessSpawner } from "effect/unstable/process"; + +import { + OpenClawAdapterV2Driver, + type OpenClawAdapterV2DriverEnv, +} from "../../orchestration-v2/Adapters/OpenClawAdapterV2.ts"; +import type { TextGenerationShape } from "../../textGeneration/TextGeneration.ts"; +import { ProviderDriverError } from "../Errors.ts"; +import { + buildInitialOpenClawProviderSnapshot, + checkOpenClawProviderStatus, +} from "../Layers/OpenClawProvider.ts"; +import { makeManagedServerProvider } from "../makeManagedServerProvider.ts"; +import { makeManualOnlyProviderMaintenanceCapabilities } from "../providerMaintenance.ts"; +import { + defaultProviderContinuationIdentity, + type ProviderDriver, + type ProviderInstance, +} from "../ProviderDriver.ts"; +import type { ServerProviderDraft } from "../providerSnapshot.ts"; +import { mergeProviderInstanceEnvironment } from "../ProviderInstanceEnvironment.ts"; + +const DRIVER_KIND = ProviderDriverKind.make("openclaw"); +const decodeSettings = Schema.decodeSync(OpenClawSettings); + +const makeUnsupportedTextGeneration = (): TextGenerationShape => { + const unsupported = (operation: string) => + Effect.fail( + new TextGenerationError({ + operation, + detail: "OpenClaw sessions do not provide application text generation.", + }), + ); + return { + generateCommitMessage: () => unsupported("generateCommitMessage"), + generatePrContent: () => unsupported("generatePrContent"), + generateBranchName: () => unsupported("generateBranchName"), + generateThreadTitle: () => unsupported("generateThreadTitle"), + }; +}; + +export type OpenClawDriverEnv = + | OpenClawAdapterV2DriverEnv + | ChildProcessSpawner.ChildProcessSpawner; + +export const OpenClawDriver: ProviderDriver = { + driverKind: DRIVER_KIND, + metadata: { + displayName: "OpenClaw", + supportsMultipleInstances: true, + }, + configSchema: OpenClawSettings, + defaultConfig: () => decodeSettings({}), + create: ({ instanceId, displayName, accentColor, environment, enabled, config }) => + Effect.gen(function* () { + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const continuationIdentity = defaultProviderContinuationIdentity({ + driverKind: DRIVER_KIND, + instanceId, + }); + const processEnvironment = mergeProviderInstanceEnvironment(environment); + const effectiveConfig = { ...config, enabled } satisfies OpenClawSettings; + const stampIdentity = (snapshot: ServerProviderDraft): ServerProvider => ({ + ...snapshot, + instanceId, + driver: DRIVER_KIND, + ...(displayName ? { displayName } : {}), + ...(accentColor ? { accentColor } : {}), + continuation: { groupKey: continuationIdentity.continuationKey }, + }); + + const orchestrationAdapter = yield* OpenClawAdapterV2Driver.create({ + instanceId, + displayName, + accentColor, + environment, + enabled, + config, + }).pipe( + Effect.mapError( + (cause) => + new ProviderDriverError({ + driver: DRIVER_KIND, + instanceId, + detail: "Failed to build OpenClaw ACP orchestration adapter.", + cause, + }), + ), + ); + + const snapshot = yield* makeManagedServerProvider({ + maintenanceCapabilities: makeManualOnlyProviderMaintenanceCapabilities({ + provider: DRIVER_KIND, + packageName: null, + }), + getSettings: Effect.succeed(effectiveConfig), + streamSettings: Stream.empty, + haveSettingsChanged: () => false, + initialSnapshot: (settings) => + buildInitialOpenClawProviderSnapshot(settings).pipe(Effect.map(stampIdentity)), + checkProvider: checkOpenClawProviderStatus(effectiveConfig, processEnvironment).pipe( + Effect.map(stampIdentity), + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + ), + refreshInterval: "5 minutes", + }).pipe( + Effect.mapError( + (cause) => + new ProviderDriverError({ + driver: DRIVER_KIND, + instanceId, + detail: "Failed to build OpenClaw provider diagnostics.", + cause, + }), + ), + ); + + return { + instanceId, + driverKind: DRIVER_KIND, + continuationIdentity, + displayName, + accentColor, + enabled, + snapshot, + orchestrationAdapter, + textGeneration: makeUnsupportedTextGeneration(), + } satisfies ProviderInstance; + }), +}; diff --git a/apps/server/src/provider/Layers/HermesAcpProvider.test.ts b/apps/server/src/provider/Layers/HermesAcpProvider.test.ts new file mode 100644 index 00000000000..8fb39665d4b --- /dev/null +++ b/apps/server/src/provider/Layers/HermesAcpProvider.test.ts @@ -0,0 +1,81 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { HermesAcpSettings } from "@t3tools/contracts"; +import { describe, expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; + +import { + buildInitialHermesAcpProviderSnapshot, + checkHermesAcpProviderStatus, +} from "./HermesAcpProvider.ts"; + +const decodeSettings = Schema.decodeSync(HermesAcpSettings); + +describe("buildInitialHermesAcpProviderSnapshot", () => { + it.effect("keeps Hermes in Code distinct and pending until its ACP probe completes", () => + Effect.gen(function* () { + const snapshot = yield* buildInitialHermesAcpProviderSnapshot(decodeSettings({})); + expect(snapshot.displayName).toBe("Hermes in Code"); + expect(snapshot.badgeLabel).toBe("ACP"); + expect(snapshot.message).toContain("Hermes ACP"); + expect(snapshot.models.map((model) => model.slug)).toEqual(["default"]); + }), + ); +}); + +it.layer(NodeServices.layer)("checkHermesAcpProviderStatus", (it) => { + it.effect("rejects a Hermes executable without the ACP command", () => + Effect.scoped( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const directory = yield* fs.makeTempDirectoryScoped({ prefix: "t3-hermes-no-acp-" }); + const executable = path.join(directory, "hermes"); + yield* fs.writeFileString(executable, "#!/bin/sh\nexit 2\n"); + yield* fs.chmod(executable, 0o755); + + const snapshot = yield* checkHermesAcpProviderStatus( + decodeSettings({ binaryPath: executable }), + ); + expect(snapshot.installed).toBe(true); + expect(snapshot.status).toBe("error"); + expect(snapshot.message).toContain("does not expose"); + }), + ), + ); + + it.effect("reports ready only when both ACP version and dependency checks pass", () => + Effect.scoped( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const directory = yield* fs.makeTempDirectoryScoped({ prefix: "t3-hermes-acp-" }); + const executable = path.join(directory, "hermes"); + yield* fs.writeFileString( + executable, + [ + "#!/bin/sh", + 'if [ "$1" != "acp" ]; then exit 9; fi', + 'if [ "$2" = "--version" ]; then printf "0.19.0\\n"; exit 0; fi', + 'if [ "$2" = "--check" ]; then printf "Hermes ACP check OK\\n"; exit 0; fi', + "exit 8", + "", + ].join("\n"), + ); + yield* fs.chmod(executable, 0o755); + + const snapshot = yield* checkHermesAcpProviderStatus( + decodeSettings({ + binaryPath: executable, + customModels: ["custom/model"], + }), + ); + expect(snapshot.status).toBe("ready"); + expect(snapshot.version).toBe("0.19.0"); + expect(snapshot.models.map((model) => model.slug)).toEqual(["default", "custom/model"]); + }), + ), + ); +}); diff --git a/apps/server/src/provider/Layers/HermesAcpProvider.ts b/apps/server/src/provider/Layers/HermesAcpProvider.ts new file mode 100644 index 00000000000..c99126c6356 --- /dev/null +++ b/apps/server/src/provider/Layers/HermesAcpProvider.ts @@ -0,0 +1,196 @@ +import { + type HermesAcpSettings, + type ModelCapabilities, + type ServerProviderModel, +} from "@t3tools/contracts"; +import { createModelCapabilities } from "@t3tools/shared/model"; +import { resolveSpawnCommand } from "@t3tools/shared/shell"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import * as Result from "effect/Result"; +import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; + +import { + buildServerProvider, + isCommandMissingCause, + parseGenericCliVersion, + providerModelsFromSettings, + spawnAndCollect, + type ServerProviderDraft, +} from "../providerSnapshot.ts"; + +const HERMES_ACP_PRESENTATION = { + displayName: "Hermes in Code", + badgeLabel: "ACP", + showInteractionModeToggle: true, + requiresNewThreadForModelChange: false, +} as const; + +const EMPTY_CAPABILITIES: ModelCapabilities = createModelCapabilities({ + optionDescriptors: [], +}); +const PROBE_TIMEOUT_MS = 8_000; +const DEFAULT_MODEL: ServerProviderModel = { + slug: "default", + name: "Hermes default", + isCustom: false, + capabilities: EMPTY_CAPABILITIES, +}; + +function hermesAcpModels(settings: HermesAcpSettings): ReadonlyArray { + return providerModelsFromSettings( + [DEFAULT_MODEL], + settings.customModels ?? [], + EMPTY_CAPABILITIES, + ); +} + +const runHermesCommand = ( + settings: HermesAcpSettings, + args: ReadonlyArray, + environment: NodeJS.ProcessEnv, +) => + Effect.gen(function* () { + const command = settings.binaryPath || "hermes"; + const spawn = yield* resolveSpawnCommand(command, args, { env: environment }); + return yield* spawnAndCollect( + command, + ChildProcess.make(spawn.command, spawn.args, { + env: environment, + shell: spawn.shell, + }), + ); + }); + +export function buildInitialHermesAcpProviderSnapshot( + settings: HermesAcpSettings, +): Effect.Effect { + return Effect.gen(function* () { + const checkedAt = DateTime.formatIso(yield* DateTime.now); + return buildServerProvider({ + presentation: HERMES_ACP_PRESENTATION, + enabled: settings.enabled, + checkedAt, + models: hermesAcpModels(settings), + probe: settings.enabled + ? { + installed: true, + version: null, + status: "warning", + auth: { status: "unknown" }, + message: "Checking Hermes ACP availability...", + } + : { + installed: false, + version: null, + status: "warning", + auth: { status: "unknown" }, + message: "Hermes in Code is disabled in T3 Code settings.", + }, + }); + }); +} + +export const checkHermesAcpProviderStatus = Effect.fn("checkHermesAcpProviderStatus")(function* ( + settings: HermesAcpSettings, + environment: NodeJS.ProcessEnv = process.env, +): Effect.fn.Return { + const checkedAt = DateTime.formatIso(yield* DateTime.now); + const models = hermesAcpModels(settings); + const build = (probe: Parameters[0]["probe"]): ServerProviderDraft => + buildServerProvider({ + presentation: HERMES_ACP_PRESENTATION, + enabled: settings.enabled, + checkedAt, + models, + probe, + }); + + if (!settings.enabled) { + return build({ + installed: false, + version: null, + status: "warning", + auth: { status: "unknown" }, + message: "Hermes in Code is disabled in T3 Code settings.", + }); + } + + const versionResult = yield* runHermesCommand(settings, ["acp", "--version"], environment).pipe( + Effect.timeoutOption(PROBE_TIMEOUT_MS), + Effect.result, + ); + if (Result.isFailure(versionResult)) { + return build({ + installed: !isCommandMissingCause(versionResult.failure), + version: null, + status: "error", + auth: { status: "unknown" }, + message: isCommandMissingCause(versionResult.failure) + ? "Hermes Agent CLI (`hermes`) is not installed or not on PATH." + : "Failed to execute the Hermes ACP version probe.", + }); + } + if (Option.isNone(versionResult.success)) { + return build({ + installed: true, + version: null, + status: "error", + auth: { status: "unknown" }, + message: "Hermes Agent CLI timed out while checking `hermes acp --version`.", + }); + } + + const versionOutput = versionResult.success.value; + const version = parseGenericCliVersion(`${versionOutput.stdout}\n${versionOutput.stderr}`); + if (versionOutput.code !== 0) { + return build({ + installed: true, + version, + status: "error", + auth: { status: "unknown" }, + message: "This Hermes Agent CLI does not expose a working `hermes acp` stdio command.", + }); + } + + const checkResult = yield* runHermesCommand(settings, ["acp", "--check"], environment).pipe( + Effect.timeoutOption(PROBE_TIMEOUT_MS), + Effect.result, + ); + if (Result.isFailure(checkResult)) { + return build({ + installed: !isCommandMissingCause(checkResult.failure), + version, + status: "error", + auth: { status: "unknown" }, + message: "Hermes ACP dependency check could not be executed.", + }); + } + if (Option.isNone(checkResult.success)) { + return build({ + installed: true, + version, + status: "error", + auth: { status: "unknown" }, + message: "Hermes ACP dependency check timed out.", + }); + } + if (checkResult.success.value.code !== 0) { + return build({ + installed: true, + version, + status: "error", + auth: { status: "unknown" }, + message: + "Hermes Agent is installed, but its ACP adapter or protocol dependencies are unavailable.", + }); + } + + return build({ + installed: true, + version, + status: "ready", + auth: { status: "unknown" }, + }); +}); diff --git a/apps/server/src/provider/Layers/OpenClawProvider.test.ts b/apps/server/src/provider/Layers/OpenClawProvider.test.ts new file mode 100644 index 00000000000..e386f72e3e1 --- /dev/null +++ b/apps/server/src/provider/Layers/OpenClawProvider.test.ts @@ -0,0 +1,110 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { OpenClawSettings } from "@t3tools/contracts"; +import { describe, expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; + +import { + buildInitialOpenClawProviderSnapshot, + checkOpenClawProviderStatus, +} from "./OpenClawProvider.ts"; + +const decodeSettings = Schema.decodeSync(OpenClawSettings); + +describe("buildInitialOpenClawProviderSnapshot", () => { + it.effect("is pending until the OpenClaw ACP probe completes", () => + Effect.gen(function* () { + const snapshot = yield* buildInitialOpenClawProviderSnapshot(decodeSettings({})); + expect(snapshot.displayName).toBe("OpenClaw"); + expect(snapshot.badgeLabel).toBe("ACP"); + expect(snapshot.message).toContain("OpenClaw ACP"); + expect(snapshot.models.map((model) => model.slug)).toEqual(["default"]); + }), + ); +}); + +it.layer(NodeServices.layer)("checkOpenClawProviderStatus", (it) => { + it.effect("gracefully reports an unavailable OpenClaw executable", () => + Effect.scoped( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const directory = yield* fs.makeTempDirectoryScoped({ + prefix: "t3-openclaw-missing-", + }); + + const snapshot = yield* checkOpenClawProviderStatus( + decodeSettings({ binaryPath: path.join(directory, "openclaw") }), + ); + expect(snapshot.installed).toBe(false); + expect(snapshot.status).toBe("error"); + expect(snapshot.message).toContain("not installed"); + }), + ), + ); + + it.effect("rejects an OpenClaw executable without the ACP command", () => + Effect.scoped( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const directory = yield* fs.makeTempDirectoryScoped({ + prefix: "t3-openclaw-no-acp-", + }); + const executable = path.join(directory, "openclaw"); + yield* fs.writeFileString( + executable, + [ + "#!/bin/sh", + 'if [ "$1" = "--version" ]; then printf "OpenClaw 1.2.3\\n"; exit 0; fi', + "exit 2", + "", + ].join("\n"), + ); + yield* fs.chmod(executable, 0o755); + + const snapshot = yield* checkOpenClawProviderStatus( + decodeSettings({ binaryPath: executable }), + ); + expect(snapshot.installed).toBe(true); + expect(snapshot.version).toBe("1.2.3"); + expect(snapshot.status).toBe("error"); + expect(snapshot.message).toContain("does not expose"); + }), + ), + ); + + it.effect("reports ready after safe version and ACP help probes", () => + Effect.scoped( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const directory = yield* fs.makeTempDirectoryScoped({ prefix: "t3-openclaw-acp-" }); + const executable = path.join(directory, "openclaw"); + yield* fs.writeFileString( + executable, + [ + "#!/bin/sh", + 'if [ "$1" = "--version" ]; then printf "OpenClaw 1.2.3\\n"; exit 0; fi', + 'if [ "$1" = "acp" ] && [ "$2" = "--help" ]; then printf "ACP over stdio\\n"; exit 0; fi', + "exit 8", + "", + ].join("\n"), + ); + yield* fs.chmod(executable, 0o755); + + const snapshot = yield* checkOpenClawProviderStatus( + decodeSettings({ + binaryPath: executable, + customModels: ["custom/model"], + }), + ); + expect(snapshot.status).toBe("ready"); + expect(snapshot.version).toBe("1.2.3"); + expect(snapshot.models.map((model) => model.slug)).toEqual(["default", "custom/model"]); + }), + ), + ); +}); diff --git a/apps/server/src/provider/Layers/OpenClawProvider.ts b/apps/server/src/provider/Layers/OpenClawProvider.ts new file mode 100644 index 00000000000..6b9872dcfb1 --- /dev/null +++ b/apps/server/src/provider/Layers/OpenClawProvider.ts @@ -0,0 +1,195 @@ +import { + type ModelCapabilities, + type OpenClawSettings, + type ServerProviderModel, +} from "@t3tools/contracts"; +import { createModelCapabilities } from "@t3tools/shared/model"; +import { resolveSpawnCommand } from "@t3tools/shared/shell"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import * as Result from "effect/Result"; +import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; + +import { + buildServerProvider, + isCommandMissingCause, + parseGenericCliVersion, + providerModelsFromSettings, + spawnAndCollect, + type ServerProviderDraft, +} from "../providerSnapshot.ts"; + +const OPENCLAW_PRESENTATION = { + displayName: "OpenClaw", + badgeLabel: "ACP", + showInteractionModeToggle: true, + requiresNewThreadForModelChange: false, +} as const; + +const EMPTY_CAPABILITIES: ModelCapabilities = createModelCapabilities({ + optionDescriptors: [], +}); +const PROBE_TIMEOUT_MS = 8_000; +const DEFAULT_MODEL: ServerProviderModel = { + slug: "default", + name: "OpenClaw default", + isCustom: false, + capabilities: EMPTY_CAPABILITIES, +}; + +function openClawModels(settings: OpenClawSettings): ReadonlyArray { + return providerModelsFromSettings( + [DEFAULT_MODEL], + settings.customModels ?? [], + EMPTY_CAPABILITIES, + ); +} + +const runOpenClawCommand = ( + settings: OpenClawSettings, + args: ReadonlyArray, + environment: NodeJS.ProcessEnv, +) => + Effect.gen(function* () { + const command = settings.binaryPath || "openclaw"; + const spawn = yield* resolveSpawnCommand(command, args, { env: environment }); + return yield* spawnAndCollect( + command, + ChildProcess.make(spawn.command, spawn.args, { + env: environment, + shell: spawn.shell, + }), + ); + }); + +export function buildInitialOpenClawProviderSnapshot( + settings: OpenClawSettings, +): Effect.Effect { + return Effect.gen(function* () { + const checkedAt = DateTime.formatIso(yield* DateTime.now); + return buildServerProvider({ + presentation: OPENCLAW_PRESENTATION, + enabled: settings.enabled, + checkedAt, + models: openClawModels(settings), + probe: settings.enabled + ? { + installed: true, + version: null, + status: "warning", + auth: { status: "unknown" }, + message: "Checking OpenClaw ACP availability...", + } + : { + installed: false, + version: null, + status: "warning", + auth: { status: "unknown" }, + message: "OpenClaw is disabled in T3 Code settings.", + }, + }); + }); +} + +export const checkOpenClawProviderStatus = Effect.fn("checkOpenClawProviderStatus")(function* ( + settings: OpenClawSettings, + environment: NodeJS.ProcessEnv = process.env, +): Effect.fn.Return { + const checkedAt = DateTime.formatIso(yield* DateTime.now); + const models = openClawModels(settings); + const build = (probe: Parameters[0]["probe"]): ServerProviderDraft => + buildServerProvider({ + presentation: OPENCLAW_PRESENTATION, + enabled: settings.enabled, + checkedAt, + models, + probe, + }); + + if (!settings.enabled) { + return build({ + installed: false, + version: null, + status: "warning", + auth: { status: "unknown" }, + message: "OpenClaw is disabled in T3 Code settings.", + }); + } + + const versionResult = yield* runOpenClawCommand(settings, ["--version"], environment).pipe( + Effect.timeoutOption(PROBE_TIMEOUT_MS), + Effect.result, + ); + if (Result.isFailure(versionResult)) { + return build({ + installed: !isCommandMissingCause(versionResult.failure), + version: null, + status: "error", + auth: { status: "unknown" }, + message: isCommandMissingCause(versionResult.failure) + ? "OpenClaw CLI (`openclaw`) is not installed or not on PATH." + : "Failed to execute the OpenClaw version probe.", + }); + } + if (Option.isNone(versionResult.success)) { + return build({ + installed: true, + version: null, + status: "error", + auth: { status: "unknown" }, + message: "OpenClaw CLI timed out while checking `openclaw --version`.", + }); + } + + const versionOutput = versionResult.success.value; + const version = parseGenericCliVersion(`${versionOutput.stdout}\n${versionOutput.stderr}`); + if (versionOutput.code !== 0) { + return build({ + installed: true, + version, + status: "error", + auth: { status: "unknown" }, + message: "The OpenClaw version probe exited unsuccessfully.", + }); + } + + const helpResult = yield* runOpenClawCommand(settings, ["acp", "--help"], environment).pipe( + Effect.timeoutOption(PROBE_TIMEOUT_MS), + Effect.result, + ); + if (Result.isFailure(helpResult)) { + return build({ + installed: !isCommandMissingCause(helpResult.failure), + version, + status: "error", + auth: { status: "unknown" }, + message: "OpenClaw ACP help could not be executed.", + }); + } + if (Option.isNone(helpResult.success)) { + return build({ + installed: true, + version, + status: "error", + auth: { status: "unknown" }, + message: "OpenClaw ACP help timed out.", + }); + } + if (helpResult.success.value.code !== 0) { + return build({ + installed: true, + version, + status: "error", + auth: { status: "unknown" }, + message: "This OpenClaw CLI does not expose a working `openclaw acp` command.", + }); + } + + return build({ + installed: true, + version, + status: "ready", + auth: { status: "unknown" }, + }); +}); diff --git a/apps/server/src/provider/Layers/ProviderInstanceRegistryHydration.test.ts b/apps/server/src/provider/Layers/ProviderInstanceRegistryHydration.test.ts new file mode 100644 index 00000000000..0eb40d71000 --- /dev/null +++ b/apps/server/src/provider/Layers/ProviderInstanceRegistryHydration.test.ts @@ -0,0 +1,106 @@ +import { describe, expect, it } from "vite-plus/test"; +import { + DEFAULT_SERVER_SETTINGS, + ProviderDriverKind, + ProviderInstanceId, + type ServerSettings, +} from "@t3tools/contracts"; + +import { deriveProviderInstanceConfigMap } from "./ProviderInstanceRegistryHydration.ts"; + +const hermesId = ProviderInstanceId.make("hermes_local"); + +function settings(enableHermes: boolean, instanceEnabled: boolean | undefined): ServerSettings { + return { + ...DEFAULT_SERVER_SETTINGS, + enableHermes, + providerInstances: { + [hermesId]: { + driver: ProviderDriverKind.make("hermes"), + ...(instanceEnabled === undefined ? {} : { enabled: instanceEnabled }), + config: { + endpoint: "ws://127.0.0.1:9119/api/ws", + profileKey: "real-profile", + }, + }, + }, + }; +} + +describe("Hermes provider-instance hydration gate", () => { + it.each([ + { global: false, instance: true, expected: false }, + { global: true, instance: false, expected: false }, + { global: true, instance: undefined, expected: false }, + { global: true, instance: true, expected: true }, + ])( + "requires global=$global and explicit instance=$instance", + ({ global, instance, expected }) => { + expect(deriveProviderInstanceConfigMap(settings(global, instance))[hermesId]?.enabled).toBe( + expected, + ); + }, + ); + + it("applies both gates to the built-in Hermes instance", () => { + const enabled = { + ...DEFAULT_SERVER_SETTINGS, + enableHermes: true, + providers: { + ...DEFAULT_SERVER_SETTINGS.providers, + hermes: { + ...DEFAULT_SERVER_SETTINGS.providers.hermes, + enabled: true, + endpoint: "ws://127.0.0.1:9119/api/ws", + }, + }, + }; + const disabled = { + ...enabled, + enableHermes: false, + }; + const defaultId = ProviderInstanceId.make("hermes"); + + expect(deriveProviderInstanceConfigMap(enabled)[defaultId]?.enabled).toBe(true); + expect(deriveProviderInstanceConfigMap(disabled)[defaultId]?.enabled).toBe(false); + }); + + it("requires an independent global and per-instance opt-in for remote Hermes", () => { + const base: ServerSettings = { + ...DEFAULT_SERVER_SETTINGS, + enableHermes: true, + providerInstances: { + [hermesId]: { + driver: ProviderDriverKind.make("hermes"), + enabled: true, + config: { + endpoint: "wss://gateway.example.com/api/ws", + profileKey: "remote-profile", + remoteAccessEnabled: true, + }, + }, + }, + }; + const remoteInstance = base.providerInstances[hermesId]!; + + expect(deriveProviderInstanceConfigMap(base)[hermesId]?.enabled).toBe(false); + expect( + deriveProviderInstanceConfigMap({ ...base, enableRemoteHermes: true })[hermesId]?.enabled, + ).toBe(true); + expect( + deriveProviderInstanceConfigMap({ + ...base, + enableRemoteHermes: true, + providerInstances: { + [hermesId]: { + ...remoteInstance, + config: { + ...(remoteInstance.config as Record), + remoteAccessEnabled: false, + }, + }, + }, + })[hermesId]?.enabled, + ).toBe(false); + }); +}); diff --git a/apps/server/src/provider/Layers/ProviderInstanceRegistryHydration.ts b/apps/server/src/provider/Layers/ProviderInstanceRegistryHydration.ts index 0b28cd1bbe3..4a068468590 100644 --- a/apps/server/src/provider/Layers/ProviderInstanceRegistryHydration.ts +++ b/apps/server/src/provider/Layers/ProviderInstanceRegistryHydration.ts @@ -49,8 +49,13 @@ import { } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; import * as Stream from "effect/Stream"; +import { + HermesSessionBindingRepository, + HermesSessionBindingRepositoryError, +} from "../../hermes/HermesSessionBindingRepository.ts"; import { ServerSettingsService } from "../../serverSettings.ts"; import { BUILT_IN_DRIVERS, type BuiltInDriversEnv } from "../builtInDrivers.ts"; import { ProviderInstanceRegistry } from "../Services/ProviderInstanceRegistry.ts"; @@ -61,10 +66,72 @@ import { ProviderOrchestrationAdapterInfrastructureLive, } from "./ProviderOrchestrationAdapterInfrastructure.ts"; +// Hermes persistence is late-bound by the production server wrapper. Keeping +// it out of this base layer's public environment lets non-Hermes test/build +// compositions hydrate the other built-in drivers without constructing a +// database-backed Hermes repository. type ProviderInstanceRegistryHydrationEnv = - | Exclude + | Exclude< + BuiltInDriversEnv, + ProviderOrchestrationAdapterInfrastructure | HermesSessionBindingRepository + > | ServerSettingsService; +const unavailableHermesRepositoryOperation = ( + operation: string, +): Effect.Effect => + Effect.fail( + new HermesSessionBindingRepositoryError({ + operation, + detail: "Hermes persistence was not supplied to this runtime composition.", + }), + ); + +const UnavailableHermesSessionBindingRepository = HermesSessionBindingRepository.of({ + createBinding: () => unavailableHermesRepositoryOperation("createBinding"), + getByThreadId: () => unavailableHermesRepositoryOperation("getByThreadId"), + getByStoredIdentity: () => unavailableHermesRepositoryOperation("getByStoredIdentity"), + updateNegotiation: () => unavailableHermesRepositoryOperation("updateNegotiation"), + updateReconciliation: () => unavailableHermesRepositoryOperation("updateReconciliation"), + updateTitleState: () => unavailableHermesRepositoryOperation("updateTitleState"), + acquireOwnerLease: () => unavailableHermesRepositoryOperation("acquireOwnerLease"), + renewOwnerLease: () => unavailableHermesRepositoryOperation("renewOwnerLease"), + releaseOwnerLease: () => unavailableHermesRepositoryOperation("releaseOwnerLease"), + prepareMutationIntent: () => unavailableHermesRepositoryOperation("prepareMutationIntent"), + setSessionImportInheritedCount: () => + unavailableHermesRepositoryOperation("setSessionImportInheritedCount"), + prepareSessionCreateIntent: () => + unavailableHermesRepositoryOperation("prepareSessionCreateIntent"), + transitionSessionCreateIntent: () => + unavailableHermesRepositoryOperation("transitionSessionCreateIntent"), + transitionMutationIntent: () => unavailableHermesRepositoryOperation("transitionMutationIntent"), + getMutationIntent: () => unavailableHermesRepositoryOperation("getMutationIntent"), + listUnsettledMutationIntents: () => + unavailableHermesRepositoryOperation("listUnsettledMutationIntents"), + prepareSessionImport: () => unavailableHermesRepositoryOperation("prepareSessionImport"), + getSessionImportByStoredIdentity: () => + unavailableHermesRepositoryOperation("getSessionImportByStoredIdentity"), + getMainSessionImport: () => unavailableHermesRepositoryOperation("getMainSessionImport"), + transitionSessionImport: () => unavailableHermesRepositoryOperation("transitionSessionImport"), + listHistoryThreadIds: () => unavailableHermesRepositoryOperation("listHistoryThreadIds"), + clearHistoryRecords: () => unavailableHermesRepositoryOperation("clearHistoryRecords"), +}); + +const remoteHermesEnabled = (settings: ServerSettings, config: unknown): boolean => { + if (typeof config !== "object" || config === null) return true; + const endpoint = Reflect.get(config, "endpoint"); + if (typeof endpoint !== "string" || endpoint.trim() === "") return true; + let remote = true; + try { + remote = !["127.0.0.1", "localhost", "::1"].includes(new URL(endpoint).hostname); + } catch { + // Invalid endpoints remain visible to the provider's configuration diagnostics. + return true; + } + if (!remote) return true; + return settings.enableRemoteHermes && Reflect.get(config, "remoteAccessEnabled") === true; +}; + /** * Synthesize a `ProviderInstanceConfigMap` from a `ServerSettings` snapshot. * @@ -83,6 +150,20 @@ export const deriveProviderInstanceConfigMap = ( ): ProviderInstanceConfigMap => { const merged: Record = { ...settings.providerInstances }; + for (const [instanceId, entry] of Object.entries(merged)) { + if (entry.driver !== "hermes") continue; + // Hermes is intentionally gated twice: the global rollout flag must be + // enabled and the explicit instance must opt in. Missing `enabled` is + // therefore false for Hermes even though other providers default it true. + merged[instanceId] = { + ...entry, + enabled: + settings.enableHermes && + entry.enabled === true && + remoteHermesEnabled(settings, entry.config), + }; + } + for (const driver of BUILT_IN_DRIVERS) { const instanceId = defaultInstanceIdForDriver(driver.driverKind); if (instanceId in merged) { @@ -104,6 +185,14 @@ export const deriveProviderInstanceConfigMap = ( merged[instanceId] = { driver: driver.driverKind, + ...(driver.driverKind === "hermes" + ? { + enabled: + settings.enableHermes && + legacyConfig.enabled === true && + remoteHermesEnabled(settings, legacyConfig), + } + : {}), config: legacyConfig, }; } @@ -164,6 +253,7 @@ export const ProviderInstanceRegistryHydrationLive: Layer.Layer< > = Layer.unwrap( Effect.gen(function* () { const serverSettings = yield* ServerSettingsService; + const hermesRepository = yield* Effect.serviceOption(HermesSessionBindingRepository); const initialSettings: ServerSettings | undefined = yield* serverSettings.getSettings.pipe( Effect.orElseSucceed(() => undefined), ); @@ -175,8 +265,18 @@ export const ProviderInstanceRegistryHydrationLive: Layer.Layer< const mutableLayer = ProviderInstanceRegistryMutableLayer({ drivers: BUILT_IN_DRIVERS, configMap: initialConfigMap, - }).pipe(Layer.provide(ProviderOrchestrationAdapterInfrastructureLive)); + }).pipe( + Layer.provide( + Layer.mergeAll( + ProviderOrchestrationAdapterInfrastructureLive, + Layer.succeed( + HermesSessionBindingRepository, + Option.getOrElse(hermesRepository, () => UnavailableHermesSessionBindingRepository), + ), + ), + ), + ); return SettingsWatcherLive.pipe(Layer.provideMerge(mutableLayer)); }), -) as Layer.Layer; +); diff --git a/apps/server/src/provider/Layers/ProviderInstanceRegistryLive.test.ts b/apps/server/src/provider/Layers/ProviderInstanceRegistryLive.test.ts index dd5155f7786..c83a66530a1 100644 --- a/apps/server/src/provider/Layers/ProviderInstanceRegistryLive.test.ts +++ b/apps/server/src/provider/Layers/ProviderInstanceRegistryLive.test.ts @@ -39,6 +39,7 @@ import * as Layer from "effect/Layer"; import { HttpClient, HttpClientResponse } from "effect/unstable/http"; import { ServerConfig } from "../../config.ts"; +import type { HermesSessionBindingRepository } from "../../hermes/HermesSessionBindingRepository.ts"; import { ServerSettingsService } from "../../serverSettings.ts"; import type { BuiltInDriversEnv } from "../builtInDrivers.ts"; import { ClaudeDriver } from "../Drivers/ClaudeDriver.ts"; @@ -310,7 +311,9 @@ describe("ProviderInstanceRegistryLive — all drivers slice", () => { }, }; - const { registry } = yield* makeProviderInstanceRegistry({ + const { registry } = yield* makeProviderInstanceRegistry< + Exclude + >({ drivers: [CodexDriver, ClaudeDriver, CursorDriver, GrokDriver, OpenCodeDriver], configMap, }); diff --git a/apps/server/src/provider/Layers/ProviderRegistry.test.ts b/apps/server/src/provider/Layers/ProviderRegistry.test.ts index 8058fb8f1c0..ee429f2acad 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.test.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.test.ts @@ -1763,6 +1763,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te "codex", "cursor", "grok", + "hermes", "opencode", ]); assert.strictEqual(cursorProvider?.enabled, false); diff --git a/apps/server/src/provider/ProviderDriver.ts b/apps/server/src/provider/ProviderDriver.ts index 50205103e3b..6b3339abfec 100644 --- a/apps/server/src/provider/ProviderDriver.ts +++ b/apps/server/src/provider/ProviderDriver.ts @@ -32,6 +32,7 @@ import type * as Scope from "effect/Scope"; import type { TextGenerationShape } from "../textGeneration/TextGeneration.ts"; import type { ProviderAdapterV2Shape } from "../orchestration-v2/ProviderAdapter.ts"; +import type { HermesSessionCatalogShape } from "../hermes/HermesSessionCatalog.ts"; import type { ProviderDriverError } from "./Errors.ts"; import type { ServerProviderShape } from "./Services/ServerProvider.ts"; @@ -71,6 +72,11 @@ export interface ProviderInstance { readonly snapshot: ServerProviderShape; readonly orchestrationAdapter: ProviderAdapterV2Shape; readonly textGeneration: TextGenerationShape; + /** + * Provider-owned, credential-encapsulated durable session inventory. + * Present only for Hermes instances. + */ + readonly hermesSessionCatalog?: HermesSessionCatalogShape; } export interface ProviderContinuationIdentity { diff --git a/apps/server/src/provider/acp/HermesAcpSupport.test.ts b/apps/server/src/provider/acp/HermesAcpSupport.test.ts new file mode 100644 index 00000000000..dd9f6602564 --- /dev/null +++ b/apps/server/src/provider/acp/HermesAcpSupport.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { buildHermesAcpSpawnInput } from "./HermesAcpSupport.ts"; + +describe("HermesAcpSupport", () => { + it("launches the genuine ACP stdio command instead of the Work gateway", () => { + const spawn = buildHermesAcpSpawnInput( + { binaryPath: "/opt/hermes/bin/hermes" }, + "/workspace/project", + { + EXISTING: "kept", + HERMES_ACP_SKIP_CONFIGURED_MCP: "0", + }, + ); + + expect(spawn).toEqual({ + command: "/opt/hermes/bin/hermes", + args: ["acp"], + cwd: "/workspace/project", + env: { + EXISTING: "kept", + HERMES_ACP_SKIP_CONFIGURED_MCP: "1", + }, + }); + expect(spawn.args).not.toContain("serve"); + }); +}); diff --git a/apps/server/src/provider/acp/HermesAcpSupport.ts b/apps/server/src/provider/acp/HermesAcpSupport.ts new file mode 100644 index 00000000000..1dd21e1be2c --- /dev/null +++ b/apps/server/src/provider/acp/HermesAcpSupport.ts @@ -0,0 +1,73 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { type HermesAcpSettings } from "@t3tools/contracts"; +import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import * as Crypto from "effect/Crypto"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Scope from "effect/Scope"; +import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; +import * as EffectAcpErrors from "effect-acp/errors"; + +import * as AcpSessionRuntime from "./AcpSessionRuntime.ts"; + +const HERMES_SKIP_CONFIGURED_MCP_ENV = "HERMES_ACP_SKIP_CONFIGURED_MCP"; + +type HermesAcpRuntimeSettings = Pick; + +interface HermesAcpRuntimeInput extends Omit { + readonly childProcessSpawner: ChildProcessSpawner.ChildProcessSpawner["Service"]; + readonly hermesSettings: HermesAcpRuntimeSettings; + readonly environment?: NodeJS.ProcessEnv; +} + +/** + * Hermes Agent's ACP entrypoint is a JSON-RPC stdio server. It is deliberately + * separate from `hermes serve`, whose websocket protocol powers Hermes Work. + */ +export function buildHermesAcpSpawnInput( + settings: HermesAcpRuntimeSettings, + cwd: string, + environment?: NodeJS.ProcessEnv, +): AcpSessionRuntime.AcpSpawnInput { + return { + command: settings.binaryPath || "hermes", + args: ["acp"], + cwd, + env: { + ...environment, + // T3 supplies its scoped MCP endpoint in session/new and session/load. + // Avoid also importing the user's global Hermes MCP configuration into + // this separately supervised provider process. + [HERMES_SKIP_CONFIGURED_MCP_ENV]: "1", + }, + }; +} + +export const makeHermesAcpRuntime = ( + input: HermesAcpRuntimeInput, +): Effect.Effect< + AcpSessionRuntime.AcpSessionRuntime["Service"], + EffectAcpErrors.AcpError, + Crypto.Crypto | Scope.Scope +> => + Effect.gen(function* () { + const processGroupPlatform = yield* HostProcessPlatform.pipe( + Effect.provide(NodeServices.layer), + ); + const context = yield* Layer.build( + AcpSessionRuntime.layer({ + ...input, + spawn: buildHermesAcpSpawnInput(input.hermesSettings, input.cwd, input.environment), + // Hermes tools may create child processes. Keep the ACP process and its + // process tree inside the runtime's owned teardown boundary. + ownDetachedProcessGroup: true, + ownDescendantProcessGroups: processGroupPlatform === "linux", + processGroupPlatform, + }).pipe( + Layer.provide( + Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, input.childProcessSpawner), + ), + ), + ); + return yield* Effect.service(AcpSessionRuntime.AcpSessionRuntime).pipe(Effect.provide(context)); + }); diff --git a/apps/server/src/provider/acp/OpenClawSupport.test.ts b/apps/server/src/provider/acp/OpenClawSupport.test.ts new file mode 100644 index 00000000000..774cf199b61 --- /dev/null +++ b/apps/server/src/provider/acp/OpenClawSupport.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { buildOpenClawSpawnInput } from "./OpenClawSupport.ts"; + +describe("OpenClawSupport", () => { + it("uses config and environment by default", () => { + expect( + buildOpenClawSpawnInput( + { + binaryPath: "openclaw", + url: "", + tokenFile: "", + passwordFile: "", + session: "", + resetSession: false, + }, + "/workspace/project", + { OPENCLAW_CONFIG_DIR: "/config/openclaw" }, + ), + ).toEqual({ + command: "openclaw", + args: ["acp"], + cwd: "/workspace/project", + env: { OPENCLAW_CONFIG_DIR: "/config/openclaw" }, + }); + }); + + it("passes only documented non-secret and credential-file overrides", () => { + const spawn = buildOpenClawSpawnInput( + { + binaryPath: "/opt/openclaw/bin/openclaw", + url: "wss://gateway.example.com", + tokenFile: "/run/secrets/openclaw-token", + passwordFile: "/run/secrets/openclaw-password", + session: "agent:main:main", + resetSession: true, + }, + "/workspace/project", + ); + + expect(spawn.args).toEqual([ + "acp", + "--url", + "wss://gateway.example.com", + "--token-file", + "/run/secrets/openclaw-token", + "--password-file", + "/run/secrets/openclaw-password", + "--session", + "agent:main:main", + "--reset-session", + ]); + expect(spawn.args).not.toContain("--token"); + expect(spawn.args).not.toContain("--password"); + }); +}); diff --git a/apps/server/src/provider/acp/OpenClawSupport.ts b/apps/server/src/provider/acp/OpenClawSupport.ts new file mode 100644 index 00000000000..523ceead501 --- /dev/null +++ b/apps/server/src/provider/acp/OpenClawSupport.ts @@ -0,0 +1,74 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { type OpenClawSettings } from "@t3tools/contracts"; +import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import * as Crypto from "effect/Crypto"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Scope from "effect/Scope"; +import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; +import * as EffectAcpErrors from "effect-acp/errors"; + +import * as AcpSessionRuntime from "./AcpSessionRuntime.ts"; + +type OpenClawRuntimeSettings = Pick< + OpenClawSettings, + "binaryPath" | "passwordFile" | "resetSession" | "session" | "tokenFile" | "url" +>; + +interface OpenClawRuntimeInput extends Omit { + readonly childProcessSpawner: ChildProcessSpawner.ChildProcessSpawner["Service"]; + readonly openClawSettings: OpenClawRuntimeSettings; + readonly environment?: NodeJS.ProcessEnv; +} + +/** + * Builds the official OpenClaw ACP stdio entrypoint. Authentication remains in + * OpenClaw config/environment unless the user supplies a credential file path; + * credential values are never copied into argv. + */ +export function buildOpenClawSpawnInput( + settings: OpenClawRuntimeSettings, + cwd: string, + environment?: NodeJS.ProcessEnv, +): AcpSessionRuntime.AcpSpawnInput { + const args = ["acp"]; + if (settings.url) args.push("--url", settings.url); + if (settings.tokenFile) args.push("--token-file", settings.tokenFile); + if (settings.passwordFile) args.push("--password-file", settings.passwordFile); + if (settings.session) args.push("--session", settings.session); + if (settings.resetSession) args.push("--reset-session"); + + return { + command: settings.binaryPath || "openclaw", + args, + cwd, + ...(environment === undefined ? {} : { env: environment }), + }; +} + +export const makeOpenClawRuntime = ( + input: OpenClawRuntimeInput, +): Effect.Effect< + AcpSessionRuntime.AcpSessionRuntime["Service"], + EffectAcpErrors.AcpError, + Crypto.Crypto | Scope.Scope +> => + Effect.gen(function* () { + const processGroupPlatform = yield* HostProcessPlatform.pipe( + Effect.provide(NodeServices.layer), + ); + const context = yield* Layer.build( + AcpSessionRuntime.layer({ + ...input, + spawn: buildOpenClawSpawnInput(input.openClawSettings, input.cwd, input.environment), + ownDetachedProcessGroup: true, + ownDescendantProcessGroups: processGroupPlatform === "linux", + processGroupPlatform, + }).pipe( + Layer.provide( + Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, input.childProcessSpawner), + ), + ), + ); + return yield* Effect.service(AcpSessionRuntime.AcpSessionRuntime).pipe(Effect.provide(context)); + }); diff --git a/apps/server/src/provider/builtInDrivers.ts b/apps/server/src/provider/builtInDrivers.ts index bbff99705d2..3eef461b8bd 100644 --- a/apps/server/src/provider/builtInDrivers.ts +++ b/apps/server/src/provider/builtInDrivers.ts @@ -25,6 +25,9 @@ import { ClaudeDriver, type ClaudeDriverEnv } from "./Drivers/ClaudeDriver.ts"; import { CodexDriver, type CodexDriverEnv } from "./Drivers/CodexDriver.ts"; import { CursorDriver, type CursorDriverEnv } from "./Drivers/CursorDriver.ts"; import { GrokDriver, type GrokDriverEnv } from "./Drivers/GrokDriver.ts"; +import { HermesAcpDriver, type HermesAcpDriverEnv } from "./Drivers/HermesAcpDriver.ts"; +import { HermesDriver, type HermesDriverEnv } from "./Drivers/HermesDriver.ts"; +import { OpenClawDriver, type OpenClawDriverEnv } from "./Drivers/OpenClawDriver.ts"; import { OpenCodeDriver, type OpenCodeDriverEnv } from "./Drivers/OpenCodeDriver.ts"; import type { AnyProviderDriver } from "./ProviderDriver.ts"; @@ -39,6 +42,9 @@ export type BuiltInDriversEnv = | CodexDriverEnv | CursorDriverEnv | GrokDriverEnv + | HermesAcpDriverEnv + | HermesDriverEnv + | OpenClawDriverEnv | OpenCodeDriverEnv; /** @@ -53,4 +59,7 @@ export const BUILT_IN_DRIVERS: ReadonlyArray; +const ProviderInstanceRegistryHydrationWithHermesLive = ProviderInstanceRegistryHydrationLive.pipe( + Layer.provide(HermesPersistenceLayerLive), +); const VcsDriverRegistryLayerLive = VcsDriverRegistry.layer.pipe( Layer.provide(VcsProjectConfig.layer), @@ -273,7 +292,8 @@ const RuntimeCoreDependenciesBaseLive = AgentAwarenessRelay.layer.pipe( Layer.provideMerge(GitLayerLive), Layer.provideMerge(VcsLayerLive), Layer.provideMerge(Layer.mergeAll(TerminalLayerLive, PreviewLayerLive)), - Layer.provideMerge(PersistenceLayerLive), + // The repository and every other persistence consumer share one SqlClient. + Layer.provideMerge(HermesPersistenceLayerLive), Layer.provideMerge(Keybindings.layer), Layer.provideMerge(ProviderRegistryLive), // The instance registry is the new routing keystone — text generation, @@ -281,7 +301,15 @@ const RuntimeCoreDependenciesBaseLive = AgentAwarenessRelay.layer.pipe( // through this layer. Built-in drivers come from `BUILT_IN_DRIVERS`; // `providerInstances` hydration merges `settings.providers.` // with explicit `providerInstances` entries on boot. - Layer.provideMerge(ProviderInstanceRegistryHydrationLive), + Layer.provideMerge(ProviderInstanceRegistryHydrationWithHermesLive), +); + +const HermesCronWithServerSettingsLayerLive = HermesCron.layer.pipe( + Layer.provideMerge(ServerSettings.layer.pipe(Layer.provide(ServerSecretStore.layer))), +); + +const HermesSkillsWithServerSettingsLayerLive = HermesSkills.layer.pipe( + Layer.provideMerge(ServerSettings.layer.pipe(Layer.provide(ServerSecretStore.layer))), ); const RuntimeCoreDependenciesLive = RuntimeCoreDependenciesBaseLive.pipe( @@ -292,7 +320,8 @@ const RuntimeCoreDependenciesLive = RuntimeCoreDependenciesBaseLive.pipe( // no longer transitively provides it. Exposing it at the runtime level // keeps a single Live for all opencode consumers. Layer.provideMerge(OpenCodeRuntime.OpenCodeRuntimeLive), - Layer.provideMerge(ServerSettings.layer.pipe(Layer.provide(ServerSecretStore.layer))), + Layer.provideMerge(HermesCronWithServerSettingsLayerLive), + Layer.provideMerge(HermesSkillsWithServerSettingsLayerLive), Layer.provideMerge(WorkspaceLayerLive), Layer.provideMerge(ProjectEnrichmentService.layer), Layer.provideMerge(ProjectFaviconResolverLayerLive), diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 9d85c1a3f37..37c9c03fae7 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -74,6 +74,9 @@ import * as ExternalLauncher from "./process/externalLauncher.ts"; import * as ThreadManagementService from "./orchestration-v2/ThreadManagementService.ts"; import * as ThreadLaunchService from "./orchestration-v2/ThreadLaunchService.ts"; import * as ScheduledTasks from "./scheduledTasks/ScheduledTaskService.ts"; +import * as HermesCron from "./hermes/HermesCron.ts"; +import * as HermesSkills from "./hermes/HermesSkills.ts"; +import * as HermesSessionImport from "./hermes/HermesSessionImportService.ts"; import { archivedShellStreamItemFromSnapshot, coalesceShellApplicationEvents, @@ -141,7 +144,7 @@ const persistChatAttachments = Effect.fn("ws.assets.persistChatAttachments")(fun readonly threadId: ThreadId; readonly messageId: MessageId; readonly attachments: ReadonlyArray<{ - readonly type: "image"; + readonly type: "image" | "file" | "pdf" | "video"; readonly name: string; readonly mimeType: string; readonly sizeBytes: number; @@ -157,7 +160,7 @@ const persistChatAttachments = Effect.fn("ws.assets.persistChatAttachments")(fun const parsed = parseBase64DataUrl(attachment.dataUrl); if (parsed === null || parsed.mimeType !== attachment.mimeType.toLowerCase()) { return yield* new PersistChatAttachmentsError({ - message: `Attachment ${attachment.name} has an invalid image payload.`, + message: `Attachment ${attachment.name} has an invalid payload.`, }); } const bytes = yield* Effect.fromResult(Encoding.decodeBase64(parsed.base64)).pipe( @@ -181,7 +184,7 @@ const persistChatAttachments = Effect.fn("ws.assets.persistChatAttachments")(fun }); } const persisted = { - type: "image" as const, + type: attachment.type, id: ChatAttachmentId.make(rawId), name: attachment.name, mimeType: attachment.mimeType, @@ -340,6 +343,15 @@ const RPC_REQUIRED_SCOPE = new Map([ [WS_METHODS.scheduledTasksSetEnabled, AuthOrchestrationOperateScope], [WS_METHODS.scheduledTasksDelete, AuthOrchestrationOperateScope], [WS_METHODS.scheduledTasksRunNow, AuthOrchestrationOperateScope], + [WS_METHODS.hermesCronList, AuthOrchestrationReadScope], + [WS_METHODS.hermesCronMutate, AuthOrchestrationOperateScope], + [WS_METHODS.hermesSkillsList, AuthOrchestrationReadScope], + [WS_METHODS.hermesSkillsSearch, AuthOrchestrationReadScope], + [WS_METHODS.hermesSkillsInspect, AuthOrchestrationReadScope], + [WS_METHODS.hermesSkillsReload, AuthOrchestrationOperateScope], + [WS_METHODS.hermesSessionsDiscover, AuthOrchestrationReadScope], + [WS_METHODS.hermesSessionsImport, AuthOrchestrationOperateScope], + [WS_METHODS.hermesHistoryReset, AuthOrchestrationOperateScope], [WS_METHODS.cloudGetRelayClientStatus, AuthRelayWriteScope], [WS_METHODS.cloudInstallRelayClient, AuthRelayWriteScope], [WS_METHODS.sourceControlLookupRepository, AuthOrchestrationReadScope], @@ -478,6 +490,9 @@ const makeWsRpcLayer = ( ); const threadLaunch = yield* ThreadLaunchService.ThreadLaunchService; const scheduledTasks = yield* ScheduledTasks.ScheduledTaskService; + const hermesCron = yield* HermesCron.HermesCron; + const hermesSkills = yield* HermesSkills.HermesSkills; + const hermesSessions = yield* HermesSessionImport.make; const projectService = yield* ProjectService.ProjectService; const checkpointDiffQuery = yield* CheckpointDiffQuery.CheckpointDiffQuery; const keybindings = yield* Keybindings.Keybindings; @@ -493,6 +508,7 @@ const makeWsRpcLayer = ( const providerMaintenanceRunner = yield* ProviderMaintenanceRunner.ProviderMaintenanceRunner; const serverSelfUpdate = yield* ServerSelfUpdate.ServerSelfUpdate; const config = yield* ServerConfig.ServerConfig; + const path = yield* Path.Path; const lifecycleEvents = yield* ServerLifecycleEvents.ServerLifecycleEvents; const serverSettings = yield* ServerSettings.ServerSettingsService; const startup = yield* ServerRuntimeStartup.ServerRuntimeStartup; @@ -602,6 +618,7 @@ const makeWsRpcLayer = ( environment, auth, cwd: config.cwd, + t3WorkDirectory: config.t3WorkDir ?? path.join(config.baseDir, "t3-work"), keybindingsConfigPath: config.keybindingsConfigPath, keybindings: keybindingsConfig.keybindings, issues: keybindingsConfig.issues, @@ -647,6 +664,7 @@ const makeWsRpcLayer = ( }), ), ); + yield* hermesSessions.hydrateThread(input.threadId); const eventStreamFrom = (afterSequence: number) => threadManagement @@ -1170,6 +1188,9 @@ const makeWsRpcLayer = ( runtimeMode: input.runtimeMode, interactionMode: input.interactionMode, workspaceStrategy: input.workspaceStrategy, + ...(input.prepareWorkspace === undefined + ? {} + : { prepareWorkspace: input.prepareWorkspace }), ...(input.initialMessage === undefined ? {} : { @@ -1252,6 +1273,42 @@ const makeWsRpcLayer = ( "rpc.aggregate": "scheduledTasks", "scheduled_task.id": input.id, }), + [WS_METHODS.hermesCronList]: (_input) => + observeRpcEffect(WS_METHODS.hermesCronList, hermesCron.list(), { + "rpc.aggregate": "hermesCron", + }), + [WS_METHODS.hermesCronMutate]: (input) => + observeRpcEffect(WS_METHODS.hermesCronMutate, hermesCron.mutate(input), { + "rpc.aggregate": "hermesCron", + }), + [WS_METHODS.hermesSkillsList]: (_input) => + observeRpcEffect(WS_METHODS.hermesSkillsList, hermesSkills.list(), { + "rpc.aggregate": "hermesSkills", + }), + [WS_METHODS.hermesSkillsSearch]: (input) => + observeRpcEffect(WS_METHODS.hermesSkillsSearch, hermesSkills.search(input), { + "rpc.aggregate": "hermesSkills", + }), + [WS_METHODS.hermesSkillsInspect]: (input) => + observeRpcEffect(WS_METHODS.hermesSkillsInspect, hermesSkills.inspect(input), { + "rpc.aggregate": "hermesSkills", + }), + [WS_METHODS.hermesSkillsReload]: (input) => + observeRpcEffect(WS_METHODS.hermesSkillsReload, hermesSkills.reload(input), { + "rpc.aggregate": "hermesSkills", + }), + [WS_METHODS.hermesSessionsDiscover]: (input) => + observeRpcEffect(WS_METHODS.hermesSessionsDiscover, hermesSessions.discover(input), { + "rpc.aggregate": "hermesSessions", + }), + [WS_METHODS.hermesSessionsImport]: (input) => + observeRpcEffect(WS_METHODS.hermesSessionsImport, hermesSessions.importSessions(input), { + "rpc.aggregate": "hermesSessions", + }), + [WS_METHODS.hermesHistoryReset]: (input) => + observeRpcEffect(WS_METHODS.hermesHistoryReset, hermesSessions.resetHistory(input), { + "rpc.aggregate": "hermesSessions", + }), [WS_METHODS.serverProbe]: (_input) => observeRpcEffect(WS_METHODS.serverProbe, Effect.succeed({}), { "rpc.aggregate": "server", diff --git a/apps/web/public/hermes-agent-logo.png b/apps/web/public/hermes-agent-logo.png new file mode 100644 index 00000000000..961df2cbeb3 Binary files /dev/null and b/apps/web/public/hermes-agent-logo.png differ diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index fac3bf7d245..ad17649a8bb 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -39,6 +39,7 @@ import rehypeRaw from "rehype-raw"; import rehypeSanitize, { defaultSchema } from "rehype-sanitize"; import remarkBreaks from "remark-breaks"; import remarkGfm from "remark-gfm"; +import { MarkdownMedia } from "./chat/MarkdownMedia"; import { renderSkillInlineMarkdownChildren } from "./chat/SkillInlineText"; import { CHAT_FILE_TAG_CHIP_CLASS_NAME, FileTagChipContent } from "./chat/FileTagChip"; import { PierreEntryIcon } from "./chat/PierreEntryIcon"; @@ -159,10 +160,12 @@ function findTaskListMarkerOffset(markdown: string, listItemStart: number): numb } const CHAT_MARKDOWN_SANITIZE_SCHEMA = { ...defaultSchema, + tagNames: [...(defaultSchema.tagNames ?? []), "video"], attributes: { ...defaultSchema.attributes, "*": (defaultSchema.attributes?.["*"] ?? []).filter((attribute) => attribute !== "title"), code: [...(defaultSchema.attributes?.code ?? []), "dataCodeMeta"], + video: ["src", "controls", "muted", "loop", "playsInline", "poster", "preload"], }, protocols: { ...defaultSchema.protocols, @@ -183,8 +186,36 @@ const CHAT_MARKDOWN_REMARK_PLUGINS_WITH_BREAKS = [ remarkPreserveCodeMeta, ] satisfies NonNullable; +interface HastNodeLike { + readonly type: string; + readonly tagName?: string; + properties?: Record; + children?: HastNodeLike[]; +} + +const WINDOWS_DRIVE_SRC_PATTERN = /^[A-Za-z]:[\\/]/; + +function rehypeEscapeWindowsDriveMediaSrc() { + const escapeNode = (node: HastNodeLike): void => { + if ( + node.type === "element" && + (node.tagName === "img" || node.tagName === "video") && + node.properties && + typeof node.properties.src === "string" && + WINDOWS_DRIVE_SRC_PATTERN.test(node.properties.src) + ) { + node.properties.src = `/${node.properties.src}`; + } + for (const child of node.children ?? []) { + escapeNode(child); + } + }; + return escapeNode; +} + const CHAT_MARKDOWN_REHYPE_PLUGINS = [ rehypeRaw, + rehypeEscapeWindowsDriveMediaSrc, [rehypeSanitize, CHAT_MARKDOWN_SANITIZE_SCHEMA], ] satisfies NonNullable; @@ -1241,6 +1272,246 @@ function areMarkdownFileLinkPropsEqual( ); } +interface ChatMarkdownComponentsContext { + readonly text: string; + readonly skills: ReadonlyArray>; + readonly threadRef: ScopedThreadRef | undefined; + readonly onTaskListChange: ChatMarkdownProps["onTaskListChange"]; + readonly isStreaming: boolean; + readonly resolvedTheme: "light" | "dark"; + readonly diffThemeName: DiffThemeName; + readonly markdownFileLinkMetaByHref: ReadonlyMap< + string, + NonNullable> + >; + readonly fileLinkParentSuffixByPath: ReadonlyMap; + readonly openInPreferredEditor: ( + targetPath: string, + ) => Promise>; + readonly openExternalLinkInPreview: (url: string) => Promise>; + readonly openMarkdownFileInPreview: (path: string) => Promise>; +} + +function createChatMarkdownComponents(context: ChatMarkdownComponentsContext): Components { + const { + text, + skills, + threadRef, + onTaskListChange, + isStreaming, + resolvedTheme, + diffThemeName, + markdownFileLinkMetaByHref, + fileLinkParentSuffixByPath, + openInPreferredEditor, + openExternalLinkInPreview, + openMarkdownFileInPreview, + } = context; + return { + p({ node: _node, children, ...props }) { + return

{renderSkillInlineMarkdownChildren(children, skills)}

; + }, + li({ node, children, ...props }) { + const listItemStart = node?.position?.start.offset; + const markerOffset = + typeof listItemStart === "number" ? findTaskListMarkerOffset(text, listItemStart) : null; + return ( +
  • + {renderSkillInlineMarkdownChildren(children, skills)} +
  • + ); + }, + input({ node: _node, type, checked, disabled: _disabled, ...props }) { + if (type !== "checkbox" || !onTaskListChange) { + return ( + + ); + } + return ( + { + const markerOffset = Number( + event.currentTarget.closest("li")?.dataset.taskMarkerOffset, + ); + if (!Number.isSafeInteger(markerOffset)) return; + onTaskListChange({ markerOffset, checked: event.currentTarget.checked }); + }} + /> + ); + }, + a({ node, href, children, ...props }) { + const normalizedHref = href ? normalizeMarkdownLinkHrefKey(href) : ""; + const fileLinkMeta = normalizedHref ? markdownFileLinkMetaByHref.get(normalizedHref) : null; + if (!fileLinkMeta) { + const faviconHost = resolveExternalWebLinkHost(href); + const isSameDocumentLink = href?.startsWith("#") ?? false; + const onClick = props.onClick; + const canOpenInPreview = Boolean(threadRef) && isPreviewSupportedInRuntime(); + const link = ( +
    { + onClick?.(event); + if (isSameDocumentLink && href) { + handleMarkdownFragmentClick(event, href); + } + }} + onContextMenu={(event) => { + if (!canOpenInPreview || !href || !faviconHost) return; + event.preventDefault(); + event.stopPropagation(); + const api = readLocalApi(); + if (!api) return; + void showExternalLinkContextMenu({ + href, + position: { x: event.clientX, y: event.clientY }, + showContextMenu: (items, position) => api.contextMenu.show(items, position), + openInPreview: async (target) => { + const result = await openExternalLinkInPreview(target); + if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { + reportMarkdownActionFailure( + { operation: "open-link-in-preview", target }, + result.cause, + ); + } + }, + openExternal: (target) => api.shell.openExternal(target), + copyLink: (target) => writeTextToClipboard(target, "link"), + reportFailure: (operation, cause) => { + reportMarkdownActionFailure({ operation, target: href }, cause); + }, + }); + }} + > + {faviconHost ? ( + + {children} + + ) : ( + children + )} + + ); + if (!faviconHost || !href) { + return link; + } + return ( + + + + {href} + + + ); + } + + const parentSuffix = fileLinkParentSuffixByPath.get(fileLinkMeta.filePath); + const labelParts = [fileLinkMeta.basename]; + if (typeof parentSuffix === "string" && parentSuffix.length > 0) { + labelParts.push(parentSuffix); + } + if (fileLinkMeta.line) { + labelParts.push( + `L${fileLinkMeta.line}${fileLinkMeta.column ? `:C${fileLinkMeta.column}` : ""}`, + ); + } + + return ( + openMarkdownFileInPreview(fileLinkMeta.filePath) + : undefined + } + className={props.className} + /> + ); + }, + img({ node: _node, src, alt }) { + return ( + + ); + }, + video({ node: _node, src }) { + return ( + + ); + }, + table({ node: _node, ...props }) { + return ; + }, + details({ node: _node, children, open: detailsOpen }) { + return {children}; + }, + pre({ node, children, ...props }) { + const codeBlock = extractCodeBlock(children); + if (!codeBlock) { + return
    {children}
    ; + } + + const language = extractFenceLanguage(codeBlock.className); + const fenceTitle = extractFenceTitle(extractPreCodeMeta(node)); + return ( + + {children}}> + {children}}> + + + + + ); + }, + }; +} + function ChatMarkdown({ text, cwd, @@ -1340,191 +1611,21 @@ function ChatMarkdown({ [createAssetUrl, openPreview, preparedConnection, threadRef], ); const markdownComponents = useMemo( - () => ({ - p({ node: _node, children, ...props }) { - return

    {renderSkillInlineMarkdownChildren(children, skills)}

    ; - }, - li({ node, children, ...props }) { - const listItemStart = node?.position?.start.offset; - const markerOffset = - typeof listItemStart === "number" ? findTaskListMarkerOffset(text, listItemStart) : null; - return ( -
  • - {renderSkillInlineMarkdownChildren(children, skills)} -
  • - ); - }, - input({ node: _node, type, checked, disabled: _disabled, ...props }) { - if (type !== "checkbox" || !onTaskListChange) { - return ( - - ); - } - return ( - { - const markerOffset = Number( - event.currentTarget.closest("li")?.dataset.taskMarkerOffset, - ); - if (!Number.isSafeInteger(markerOffset)) return; - onTaskListChange({ markerOffset, checked: event.currentTarget.checked }); - }} - /> - ); - }, - a({ node, href, children, ...props }) { - const normalizedHref = href ? normalizeMarkdownLinkHrefKey(href) : ""; - const fileLinkMeta = normalizedHref ? markdownFileLinkMetaByHref.get(normalizedHref) : null; - if (!fileLinkMeta) { - const faviconHost = resolveExternalWebLinkHost(href); - const isSameDocumentLink = href?.startsWith("#") ?? false; - const onClick = props.onClick; - const canOpenInPreview = Boolean(threadRef) && isPreviewSupportedInRuntime(); - const link = ( - { - onClick?.(event); - if (isSameDocumentLink && href) { - handleMarkdownFragmentClick(event, href); - } - }} - onContextMenu={(event) => { - if (!canOpenInPreview || !href || !faviconHost) return; - event.preventDefault(); - event.stopPropagation(); - const api = readLocalApi(); - if (!api) return; - void showExternalLinkContextMenu({ - href, - position: { x: event.clientX, y: event.clientY }, - showContextMenu: (items, position) => api.contextMenu.show(items, position), - openInPreview: async (target) => { - const result = await openExternalLinkInPreview(target); - if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { - reportMarkdownActionFailure( - { operation: "open-link-in-preview", target }, - result.cause, - ); - } - }, - openExternal: (target) => api.shell.openExternal(target), - copyLink: (target) => writeTextToClipboard(target, "link"), - reportFailure: (operation, cause) => { - reportMarkdownActionFailure({ operation, target: href }, cause); - }, - }); - }} - > - {faviconHost ? ( - - {children} - - ) : ( - children - )} - - ); - if (!faviconHost || !href) { - return link; - } - return ( - - - - {href} - - - ); - } - - const parentSuffix = fileLinkParentSuffixByPath.get(fileLinkMeta.filePath); - const labelParts = [fileLinkMeta.basename]; - if (typeof parentSuffix === "string" && parentSuffix.length > 0) { - labelParts.push(parentSuffix); - } - if (fileLinkMeta.line) { - labelParts.push( - `L${fileLinkMeta.line}${fileLinkMeta.column ? `:C${fileLinkMeta.column}` : ""}`, - ); - } - - return ( - openMarkdownFileInPreview(fileLinkMeta.filePath) - : undefined - } - className={props.className} - /> - ); - }, - table({ node: _node, ...props }) { - return ; - }, - details({ node: _node, children, open: detailsOpen }) { - return {children}; - }, - pre({ node, children, ...props }) { - const codeBlock = extractCodeBlock(children); - if (!codeBlock) { - return
    {children}
    ; - } - - const language = extractFenceLanguage(codeBlock.className); - const fenceTitle = extractFenceTitle(extractPreCodeMeta(node)); - return ( - - {children}}> - {children}}> - - - - - ); - }, - }), + () => + createChatMarkdownComponents({ + text, + skills, + threadRef, + onTaskListChange, + isStreaming, + resolvedTheme, + diffThemeName, + markdownFileLinkMetaByHref, + fileLinkParentSuffixByPath, + openInPreferredEditor, + openExternalLinkInPreview, + openMarkdownFileInPreview, + }), [ diffThemeName, fileLinkParentSuffixByPath, diff --git a/apps/web/src/components/ChatView.logic.test.ts b/apps/web/src/components/ChatView.logic.test.ts index 279b1381863..fcb78f52789 100644 --- a/apps/web/src/components/ChatView.logic.test.ts +++ b/apps/web/src/components/ChatView.logic.test.ts @@ -2,6 +2,7 @@ import { EnvironmentId, MessageId, ProjectId, + ProviderDriverKind, ProviderInstanceId, ThreadId, RunId, @@ -21,18 +22,24 @@ import { createLocalDispatchSnapshot, deriveCommittedServerUserMessageIds, deriveComposerSendState, + deriveLockedProvider, dismissBranchMismatchForSession, getStartedThreadModelChangeBlockReason, hasServerAcknowledgedLocalDispatch, + isHermesClearChatCommand, + isHermesFreshChatCommand, isBranchMismatchDismissedForSession, + isWorkspacePreparationTurnItem, reconcileMountedTerminalThreadIds, reconcileRetainedMountedThreadIds, resolveThreadMetadataUpdateForNextTurn, resolveSendEnvMode, - startNewThreadForProject, + shouldExposeWorkspaceArtifacts, shouldShowBranchMismatchBanner, shouldShowComposerContextStrip, + shouldShowWorkingTimeline, shouldWriteThreadErrorToCurrentServerThread, + startNewThreadForProject, } from "./ChatView.logic"; const environmentId = EnvironmentId.make("environment-local"); @@ -40,6 +47,52 @@ const projectId = ProjectId.make("project-1"); const threadId = ThreadId.make("thread-1"); const now = "2026-03-29T00:00:00.000Z"; +describe("Hermes native fresh-chat commands", () => { + it.each(["/new", "/reset Work"])("recognizes %s only for Hermes", (text) => { + expect(isHermesFreshChatCommand({ text, isHermesConversation: true })).toBe(true); + expect(isHermesFreshChatCommand({ text, isHermesConversation: false })).toBe(false); + }); + + it.each(["/newer", "/clear", "/retry"])("does not intercept %s", (text) => { + expect(isHermesFreshChatCommand({ text, isHermesConversation: true })).toBe(false); + }); + + it("recognizes exact /clear only for Hermes", () => { + expect(isHermesClearChatCommand({ text: " /clear ", isHermesConversation: true })).toBe(true); + expect(isHermesClearChatCommand({ text: "/clear", isHermesConversation: false })).toBe(false); + expect(isHermesClearChatCommand({ text: "/clear all", isHermesConversation: true })).toBe( + false, + ); + expect(isHermesClearChatCommand({ text: "please /clear", isHermesConversation: true })).toBe( + false, + ); + }); +}); + +describe("working timeline visibility", () => { + it.each(["connecting", "running"] as const)("stays visible while a run is %s", (phase) => { + expect( + shouldShowWorkingTimeline({ + phase, + isSendBusy: false, + isConnecting: false, + isRevertingCheckpoint: false, + }), + ).toBe(true); + }); + + it("hides for an idle ready thread", () => { + expect( + shouldShowWorkingTimeline({ + phase: "ready", + isSendBusy: false, + isConnecting: false, + isRevertingCheckpoint: false, + }), + ).toBe(false); + }); +}); + function makeThread(overrides: Partial = {}): Thread { return makeThreadFixture({ id: threadId, @@ -86,6 +139,54 @@ const readySession = { updatedAt: "2026-03-29T00:00:10.000Z", }; +describe("deriveLockedProvider", () => { + it("resolves a custom provider instance id to its owning driver", () => { + const hermesInstanceId = ProviderInstanceId.make("hermes_local"); + expect( + deriveLockedProvider({ + thread: makeThread({ + modelSelection: { + instanceId: hermesInstanceId, + model: "default", + }, + latestRun: completedTurn, + runtime: { + ...readySession, + providerName: hermesInstanceId, + providerInstanceId: hermesInstanceId, + }, + }), + selectedProvider: null, + threadProvider: hermesInstanceId, + providerInstances: [ + { + instanceId: hermesInstanceId, + driver: ProviderDriverKind.make("hermes"), + }, + ], + }), + ).toBe("hermes"); + }); + + it("preserves an unknown open driver kind when no instance metadata exists", () => { + expect( + deriveLockedProvider({ + thread: makeThread({ + latestRun: completedTurn, + runtime: { + ...readySession, + providerName: "forkDriver", + providerInstanceId: ProviderInstanceId.make("forkDriver"), + }, + }), + selectedProvider: null, + threadProvider: "forkDriver", + providerInstances: [], + }), + ).toBe("forkDriver"); + }); +}); + describe("resolveThreadMetadataUpdateForNextTurn", () => { const modelSelection = { instanceId: ProviderInstanceId.make("codex"), @@ -121,16 +222,29 @@ describe("shouldShowComposerContextStrip", () => { routeKind: "draft", isGitRepo: true, hasActiveProject: true, + isProjectlessConversation: false, }), ).toBe(true); }); + it("hides git context for a projectless conversation backed by an internal project", () => { + expect( + shouldShowComposerContextStrip({ + routeKind: "draft", + isGitRepo: true, + hasActiveProject: true, + isProjectlessConversation: true, + }), + ).toBe(false); + }); + it("hides git context after the draft becomes a thread", () => { expect( shouldShowComposerContextStrip({ routeKind: "server", isGitRepo: true, hasActiveProject: true, + isProjectlessConversation: false, }), ).toBe(false); }); @@ -141,6 +255,7 @@ describe("shouldShowComposerContextStrip", () => { routeKind: "draft", isGitRepo: false, hasActiveProject: true, + isProjectlessConversation: false, }), ).toBe(false); expect( @@ -148,10 +263,51 @@ describe("shouldShowComposerContextStrip", () => { routeKind: "draft", isGitRepo: true, hasActiveProject: false, + isProjectlessConversation: false, }), ).toBe(false); }); }); + +describe("shouldExposeWorkspaceArtifacts", () => { + it("hides git and workspace artifacts for projectless conversations", () => { + expect( + shouldExposeWorkspaceArtifacts({ + isProjectlessConversation: true, + }), + ).toBe(false); + }); + + it("keeps git and workspace artifacts for project conversations", () => { + expect( + shouldExposeWorkspaceArtifacts({ + isProjectlessConversation: false, + }), + ).toBe(true); + }); +}); + +describe("isWorkspacePreparationTurnItem", () => { + it("matches only T3's synthetic workspace item, not provider commands", () => { + expect( + isWorkspacePreparationTurnItem({ + type: "command_execution", + input: "Preparing workspace", + providerTurnId: null, + nativeItemRef: null, + }), + ).toBe(true); + expect( + isWorkspacePreparationTurnItem({ + type: "command_execution", + input: "Preparing workspace", + providerTurnId: "provider-turn-1", + nativeItemRef: { id: "tool-1" }, + }), + ).toBe(false); + }); +}); + describe("deriveComposerSendState", () => { it("treats expired terminal pills as non-sendable content", () => { const state = deriveComposerSendState({ diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts index 3264951bedd..4aed64c9971 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -14,7 +14,7 @@ import { import * as DateTime from "effect/DateTime"; import { presentThreadShell } from "@t3tools/client-runtime/state/shell"; import { type ChatMessage, type SessionPhase, type Thread } from "../types"; -import { type ComposerImageAttachment, type DraftThreadState } from "../composerDraftStore"; +import { type ComposerAttachment, type DraftThreadState } from "../composerDraftStore"; import * as Schema from "effect/Schema"; import { appAtomRegistry } from "../rpc/atomRegistry"; import { environmentThreadShells } from "../state/threads"; @@ -186,10 +186,9 @@ export function revokeUserMessagePreviewUrls(message: ChatMessage): void { return; } for (const attachment of message.attachments) { - if (attachment.type !== "image") { - continue; + if ("previewUrl" in attachment) { + revokeBlobPreviewUrl(attachment.previewUrl); } - revokeBlobPreviewUrl(attachment.previewUrl); } } @@ -199,9 +198,9 @@ export function collectUserMessageBlobPreviewUrls(message: ChatMessage): string[ } const previewUrls: string[] = []; for (const attachment of message.attachments) { - if (attachment.type !== "image") continue; - if (!attachment.previewUrl || !attachment.previewUrl.startsWith("blob:")) continue; - previewUrls.push(attachment.previewUrl); + if ("previewUrl" in attachment && attachment.previewUrl?.startsWith("blob:")) { + previewUrls.push(attachment.previewUrl); + } } return previewUrls; } @@ -239,13 +238,68 @@ export function shouldShowComposerContextStrip(input: { routeKind: "draft" | "server"; isGitRepo: boolean; hasActiveProject: boolean; + isProjectlessConversation: boolean; +}): boolean { + return ( + !input.isProjectlessConversation && + input.routeKind === "draft" && + input.isGitRepo && + input.hasActiveProject + ); +} + +export function shouldExposeWorkspaceArtifacts(input: { + readonly isProjectlessConversation: boolean; +}): boolean { + return !input.isProjectlessConversation; +} + +export function isHermesFreshChatCommand(input: { + readonly text: string; + readonly isHermesConversation: boolean; +}): boolean { + if (!input.isHermesConversation) return false; + return /^\/(?:new|reset)(?:\s+.*)?$/iu.test(input.text.trim()); +} + +export function isHermesClearChatCommand(input: { + readonly text: string; + readonly isHermesConversation: boolean; +}): boolean { + if (!input.isHermesConversation) return false; + return /^\/clear$/iu.test(input.text.trim()); +} + +export function shouldShowWorkingTimeline(input: { + readonly phase: SessionPhase; + readonly isSendBusy: boolean; + readonly isConnecting: boolean; + readonly isRevertingCheckpoint: boolean; }): boolean { - return input.routeKind === "draft" && input.isGitRepo && input.hasActiveProject; + return ( + input.phase === "connecting" || + input.phase === "running" || + input.isSendBusy || + input.isConnecting || + input.isRevertingCheckpoint + ); } -export function cloneComposerImageForRetry( - image: ComposerImageAttachment, -): ComposerImageAttachment { +export function isWorkspacePreparationTurnItem(item: { + readonly type: string; + readonly input?: unknown; + readonly providerTurnId?: string | null | undefined; + readonly nativeItemRef?: unknown; +}): boolean { + return ( + item.type === "command_execution" && + item.input === "Preparing workspace" && + item.providerTurnId === null && + item.nativeItemRef === null + ); +} + +export function cloneComposerImageForRetry(image: ComposerAttachment): ComposerAttachment { if (typeof URL === "undefined" || !image.previewUrl.startsWith("blob:")) { return image; } @@ -354,39 +408,31 @@ export function threadHasStarted(thread: Thread | null | undefined): boolean { return Boolean(thread && (thread.latestRun !== null || thread.itemCount > 0 || thread.runtime)); } -// `threadProvider` is the open branded driver kind carried by the session. -// Unknown driver kinds degrade to `null` (i.e. "unlocked"), which is the safe -// rollback / fork behavior — the routing layer is the right place to surface -// "driver not installed" errors, not the lock state. -// -// `selectedProvider` takes the same open-string shape because the composer -// now tracks the picker selection as a `ProviderInstanceId` (e.g. -// `codex_personal`). Custom instance ids that don't directly match a -// registered driver resolve to `null` here, which matches the existing -// "unknown driver -> unlocked" semantics. Callers that want the lock to track -// a custom instance's underlying driver kind should resolve the instance id -// upstream and pass the correlated kind. +// Session/model routing values are provider instance ids. Resolve them through +// the environment's provider inventory before falling back to the open driver +// slug shape used by rollback/fork builds. This keeps a custom instance such +// as `hermes_local` locked to its actual `hermes` driver after the first turn. export function deriveLockedProvider(input: { thread: Thread | null | undefined; selectedProvider: string | null; threadProvider: string | null; + providerInstances: ReadonlyArray>; }): ProviderDriverKind | null { if (!threadHasStarted(input.thread)) { return null; } + const resolveDriverKind = (selection: string | null): ProviderDriverKind | null => { + if (!selection) return null; + const instance = input.providerInstances.find((entry) => entry.instanceId === selection); + if (instance) return instance.driver; + return isProviderDriverKind(selection) ? selection : null; + }; const sessionProvider = input.thread?.runtime?.providerName ?? null; - if (sessionProvider && isProviderDriverKind(sessionProvider)) { - return sessionProvider; - } - const narrowedThreadProvider = - input.threadProvider && isProviderDriverKind(input.threadProvider) - ? input.threadProvider - : null; - const narrowedSelectedProvider = - input.selectedProvider && isProviderDriverKind(input.selectedProvider) - ? input.selectedProvider - : null; - return narrowedThreadProvider ?? narrowedSelectedProvider ?? null; + return ( + resolveDriverKind(sessionProvider) ?? + resolveDriverKind(input.threadProvider) ?? + resolveDriverKind(input.selectedProvider) + ); } export function getStartedThreadModelChangeBlockReason(input: { diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 68dca3198b7..a3b5ea63346 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -190,7 +190,7 @@ import { } from "../logicalProject"; import { buildDraftThreadRouteParams, buildThreadRouteParams } from "../threadRoutes"; import { - type ComposerImageAttachment, + type ComposerAttachment, type DraftThreadEnvMode, useComposerDraftStore, type DraftId, @@ -280,6 +280,9 @@ import { dismissBranchMismatchForSession, hasServerAcknowledgedLocalDispatch, isBranchMismatchDismissedForSession, + isHermesClearChatCommand, + isHermesFreshChatCommand, + isWorkspacePreparationTurnItem, shouldShowBranchMismatchBanner, getStartedThreadModelChangeBlockReason, LAST_INVOKED_SCRIPT_BY_PROJECT_KEY, @@ -292,6 +295,8 @@ import { reconcileMountedTerminalThreadIds, resolveThreadMetadataUpdateForNextTurn, resolveSendEnvMode, + shouldExposeWorkspaceArtifacts, + shouldShowWorkingTimeline, revokeBlobPreviewUrl, revokeUserMessagePreviewUrls, shouldShowComposerContextStrip, @@ -326,12 +331,13 @@ import { } from "../versionSkew"; import { useAssetUrls } from "../assets/assetUrls"; -const IMAGE_ONLY_BOOTSTRAP_PROMPT = - "[User attached one or more images without additional text. Respond using the conversation context and the attached image(s).]"; +const ATTACHMENT_ONLY_BOOTSTRAP_PROMPT = + "[User attached one or more files without additional text. Respond using the conversation context and the attachment(s).]"; const EMPTY_PROVIDERS: ServerProvider[] = []; const EMPTY_PROVIDER_SKILLS: ServerProvider["skills"] = []; const EMPTY_ATTACHMENT_IDS: string[] = []; const EMPTY_PENDING_USER_INPUT_ANSWERS: Record = {}; +const EMPTY_TURN_DIFF_SUMMARIES: ReadonlyArray = []; function useDraftHeroLayoutTransition(isDraftHeroState: boolean) { const transitionGroupRef = useRef(null); @@ -1282,7 +1288,7 @@ function ChatViewContent(props: ChatViewProps) { : null, ); const promptRef = useRef(""); - const composerImagesRef = useRef([]); + const composerImagesRef = useRef([]); const composerTerminalContextsRef = useRef([]); const composerElementContextsRef = useRef([]); const localComposerRef = useRef(null); @@ -1925,17 +1931,19 @@ function ChatViewContent(props: ChatViewProps) { activeThread?.modelSelection.instanceId ?? activeProject?.defaultModelSelection?.instanceId ?? null; + // Once a thread selects an environment, never substitute the primary + // environment's config while the selected environment is still loading. + const serverConfig = activeThread + ? (activeEnvironment?.serverConfig ?? null) + : (primaryEnvironment?.serverConfig ?? null); + const providerStatuses = serverConfig?.providers ?? EMPTY_PROVIDERS; const lockedProvider = deriveLockedProvider({ thread: activeThread, selectedProvider: selectedProviderByThreadId, threadProvider, + providerInstances: providerStatuses, }); const modelPickerLockedProvider = supportsProviderSwitchingViaHandoff ? null : lockedProvider; - // Once a thread selects an environment, never substitute the primary - // environment's config while the selected environment is still loading. - const serverConfig = activeThread - ? (activeEnvironment?.serverConfig ?? null) - : (primaryEnvironment?.serverConfig ?? null); const versionMismatch = resolveServerConfigVersionMismatch(serverConfig); const versionMismatchDismissKey = versionMismatch && activeThread @@ -2056,13 +2064,13 @@ function ChatViewContent(props: ChatViewProps) { versionMismatchSelfUpdate, versionMismatchServerLabel, ]); - const providerStatuses = serverConfig?.providers ?? EMPTY_PROVIDERS; const unlockedSelectedProvider = resolveSelectableProvider( providerStatuses, selectedProviderByThreadId ?? threadProvider, ); const selectedProvider: ProviderDriverKind = modelPickerLockedProvider ?? unlockedSelectedProvider; + const isHermesConversation = selectedProvider === ProviderDriverKind.make("hermes"); const phase = derivePhase(activeRuntime); const pendingRequests = useMemo( () => @@ -2156,7 +2164,12 @@ function ChatViewContent(props: ChatViewProps) { activePendingUserInput: activePendingUserInput?.requestId ?? null, threadError, }); - const isWorking = phase === "running" || isSendBusy || isConnecting || isRevertingCheckpoint; + const isWorking = shouldShowWorkingTimeline({ + phase, + isSendBusy, + isConnecting, + isRevertingCheckpoint, + }); const activeWorkStartedAt = deriveActiveWorkStartedAt( activeLatestRun, activeRuntime, @@ -2230,8 +2243,13 @@ function ChatViewContent(props: ChatViewProps) { attachmentIds.add(attachment.id); } } + for (const message of serverProjection?.messages ?? []) { + for (const attachment of message.attachments) { + attachmentIds.add(attachment.id); + } + } return [...attachmentIds]; - }, [serverVisibleTurnItems]); + }, [serverProjection?.messages, serverVisibleTurnItems]); const serverAttachmentIds = isServerThread ? committedServerAttachmentIds : EMPTY_ATTACHMENT_IDS; const serverAttachmentResources = useMemo( () => @@ -2277,11 +2295,9 @@ function ChatViewContent(props: ChatViewProps) { } const serverPreviewUrls = serverMessage.attachments.flatMap((attachment) => - attachment.type === "image" - ? [serverAttachmentUrlById.get(attachment.id)].filter( - (previewUrl): previewUrl is string => previewUrl !== undefined, - ) - : [], + [serverAttachmentUrlById.get(attachment.id)].filter( + (previewUrl): previewUrl is string => previewUrl !== undefined, + ), ); if ( serverPreviewUrls.length === 0 || @@ -2296,8 +2312,15 @@ function ChatViewContent(props: ChatViewProps) { let cancelled = false; const imageInstances: HTMLImageElement[] = []; + const imagePreviewUrls = serverMessage.attachments.flatMap((attachment) => + attachment.type === "image" + ? [serverAttachmentUrlById.get(attachment.id)].filter( + (previewUrl): previewUrl is string => previewUrl !== undefined, + ) + : [], + ); const preloadServerPreviews = Promise.all( - serverPreviewUrls.map( + imagePreviewUrls.map( (previewUrl) => new Promise((resolve, reject) => { const image = new Image(); @@ -2351,20 +2374,26 @@ function ChatViewContent(props: ChatViewProps) { if (row.item.type !== "user_message") continue; const handoffUrls = attachmentPreviewHandoffByMessageId[row.item.messageId]; if (handoffUrls === undefined) continue; - let imageIndex = 0; - for (const attachment of row.item.attachments) { - if (attachment.type !== "image") continue; - const handoffUrl = handoffUrls[imageIndex]; - imageIndex += 1; + for (const [attachmentIndex, attachment] of row.item.attachments.entries()) { + const handoffUrl = handoffUrls[attachmentIndex]; if (handoffUrl !== undefined) urls.set(attachment.id, handoffUrl); } } return urls; }, [attachmentPreviewHandoffByMessageId, serverAttachmentUrlById, serverVisibleTurnItems]); + const displayedServerTurnItems = useMemo( + () => + isHermesConversation + ? serverVisibleTurnItems.filter((row) => !isWorkspacePreparationTurnItem(row.item)) + : serverVisibleTurnItems, + [isHermesConversation, serverVisibleTurnItems], + ); const serverTimelineEntries = useMemo( () => deriveTimelineEntriesFromVisibleTurnItems({ - visibleTurnItems: serverVisibleTurnItems, + visibleTurnItems: displayedServerTurnItems, + projectionMessages: + isHermesConversation && serverProjection !== null ? serverProjection.messages : [], optimisticMessages: optimisticUserMessages, attachmentUrlById: timelineAttachmentUrlById, ...(serverProjection === null @@ -2374,7 +2403,13 @@ function ChatViewContent(props: ChatViewProps) { nodes: serverProjection.nodes, }), }), - [optimisticUserMessages, serverVisibleTurnItems, serverProjection, timelineAttachmentUrlById], + [ + displayedServerTurnItems, + isHermesConversation, + optimisticUserMessages, + serverProjection, + timelineAttachmentUrlById, + ], ); const draftTimelineEntries = useMemo( () => @@ -2398,30 +2433,37 @@ function ChatViewContent(props: ChatViewProps) { const draftHeroTransition = useDraftHeroLayoutTransition(isDraftHeroState); const captureDraftHeroComposerRect = draftHeroTransition.captureComposerRect; const { turnDiffSummaries } = useTurnDiffSummaries(serverProjection); + const exposeWorkspaceArtifacts = shouldExposeWorkspaceArtifacts({ + isProjectlessConversation: isHermesConversation, + }); + const visibleTurnDiffSummaries = exposeWorkspaceArtifacts + ? turnDiffSummaries + : EMPTY_TURN_DIFF_SUMMARIES; const turnDiffSummaryByAssistantMessageId = useMemo(() => { const byMessageId = new Map(); - for (const summary of turnDiffSummaries) { + for (const summary of visibleTurnDiffSummaries) { if (!summary.assistantMessageId) continue; byMessageId.set(summary.assistantMessageId, summary); } return byMessageId; - }, [turnDiffSummaries]); + }, [visibleTurnDiffSummaries]); const revertTurnCountByUserMessageId = useMemo( () => deriveRevertTurnCountByUserMessageId({ timelineEntries, - checkpoints: turnDiffSummaries, + checkpoints: visibleTurnDiffSummaries, }), - [timelineEntries, turnDiffSummaries], + [timelineEntries, visibleTurnDiffSummaries], ); - const gitCwd = activeProject - ? projectScriptCwd({ - project: { cwd: activeProject.workspaceRoot }, - worktreePath: activeThread?.worktreePath ?? null, - }) - : null; - const gitStatusCwd = activeThread?.worktreePath ?? gitCwd; + const gitCwd = + exposeWorkspaceArtifacts && activeProject + ? projectScriptCwd({ + project: { cwd: activeProject.workspaceRoot }, + worktreePath: activeThread?.worktreePath ?? null, + }) + : null; + const gitStatusCwd = exposeWorkspaceArtifacts ? (activeThread?.worktreePath ?? gitCwd) : null; const gitStatusQuery = useEnvironmentQuery( gitStatusCwd === null ? null @@ -2484,7 +2526,9 @@ function ChatViewContent(props: ChatViewProps) { const hasTimelineTopBanner = Boolean(threadError) || visibleProviderStatus !== null; const activeProjectCwd = activeProject?.workspaceRoot ?? null; const activeThreadWorktreePath = activeThread?.worktreePath ?? null; - const activeWorkspaceRoot = activeThreadWorktreePath ?? activeProjectCwd ?? undefined; + const activeWorkspaceRoot = exposeWorkspaceArtifacts + ? (activeThreadWorktreePath ?? activeProjectCwd ?? undefined) + : undefined; const activeTerminalLaunchContext = terminalUiLaunchContext?.threadId === activeThreadId ? terminalUiLaunchContext : null; // Default true while loading to avoid toolbar flicker. @@ -2493,6 +2537,7 @@ function ChatViewContent(props: ChatViewProps) { routeKind, isGitRepo, hasActiveProject: activeProject !== null, + isProjectlessConversation: isHermesConversation, }); const initialDiffPanelGitScope = gitStatusQuery.data?.hasWorkingTreeChanges === true ? "unstaged" : "branch"; @@ -4638,7 +4683,11 @@ function ChatViewContent(props: ChatViewProps) { ); const onForkFromRun = useCallback( - async (input: { readonly sourceThreadId: ThreadId; readonly runId: RunId }) => { + async (input: { + readonly sourceThreadId: ThreadId; + readonly runId: RunId; + readonly latestOnly?: boolean; + }) => { if (!activeThread || activeEnvironmentUnavailable) return; const targetThreadId = newThreadId(); const targetThreadRef = scopeThreadRef(environmentId, targetThreadId); @@ -4648,6 +4697,7 @@ function ChatViewContent(props: ChatViewProps) { sourceThreadId: input.sourceThreadId, targetThreadId, runId: input.runId, + ...(input.latestOnly ? { latestOnly: true } : {}), title: `${activeThread.title} fork`, }, }); @@ -4684,6 +4734,46 @@ function ChatViewContent(props: ChatViewProps) { ], ); + const clearCurrentHermesTimeline = useCallback(async () => { + if (!activeThread || !isHermesConversation) { + return false; + } + if (!isServerThread) { + scheduleComposerFocus(); + return true; + } + const result = await updateThreadMetadata({ + environmentId, + input: { + threadId: activeThread.id, + clearTimeline: true, + }, + }); + if (result._tag === "Failure") { + if (!isAtomCommandInterrupted(result)) { + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Failed to clear chat", + description: chatActionErrorMessage(squashAtomCommandFailure(result)), + }), + ); + } + return false; + } + scrollToEnd(); + scheduleComposerFocus(); + return true; + }, [ + activeThread, + environmentId, + isHermesConversation, + isServerThread, + scheduleComposerFocus, + scrollToEnd, + updateThreadMetadata, + ]); + const onSend = async ( e?: { preventDefault: () => void }, dispatchMode: ComposerDispatchMode = "auto", @@ -4752,6 +4842,44 @@ function ChatViewContent(props: ChatViewProps) { composerReviewComments.length === 0 ? parseStandaloneComposerSlashCommand(trimmed) : null; + const isStandaloneHermesCommand = + composerImages.length === 0 && + sendableComposerTerminalContexts.length === 0 && + composerElementContexts.length === 0 && + composerPreviewAnnotations.length === 0 && + composerReviewComments.length === 0; + if ( + isStandaloneHermesCommand && + isHermesClearChatCommand({ + text: trimmed, + isHermesConversation, + }) + ) { + const cleared = await clearCurrentHermesTimeline(); + if (cleared) { + promptRef.current = ""; + clearComposerDraftContent(composerDraftTarget); + composerRef.current?.resetCursorState(); + } + return; + } + if ( + isStandaloneHermesCommand && + isHermesFreshChatCommand({ + text: trimmed, + isHermesConversation, + }) && + activeProject + ) { + promptRef.current = ""; + clearComposerDraftContent(composerDraftTarget); + composerRef.current?.resetCursorState(); + await handleNewThread(scopeProjectRef(activeProject.environmentId, activeProject.id), { + fresh: true, + modelSelection: ctxSelectedModelSelection, + }); + return; + } if (standaloneSlashCommand) { handleInteractionModeChange(standaloneSlashCommand); promptRef.current = ""; @@ -4843,19 +4971,49 @@ function ChatViewContent(props: ChatViewProps) { model: ctxSelectedModel, models: ctxSelectedProviderModels, effort: ctxSelectedPromptEffort, - text: messageTextForSend || IMAGE_ONLY_BOOTSTRAP_PROMPT, + text: messageTextForSend || ATTACHMENT_ONLY_BOOTSTRAP_PROMPT, }); const turnAttachmentsPromise = Promise.all( - composerImagesSnapshot.map(async (image) => ({ - type: "image" as const, - name: image.name, - mimeType: image.mimeType, - sizeBytes: image.sizeBytes, - dataUrl: await readFileAsDataUrl(image.file), - })), + composerImagesSnapshot.map(async (image) => { + const dataUrl = await readFileAsDataUrl(image.file); + switch (image.type) { + case "image": + return { + type: "image" as const, + name: image.name, + mimeType: image.mimeType, + sizeBytes: image.sizeBytes, + dataUrl, + }; + case "file": + return { + type: "file" as const, + name: image.name, + mimeType: image.mimeType, + sizeBytes: image.sizeBytes, + dataUrl, + }; + case "pdf": + return { + type: "pdf" as const, + name: image.name, + mimeType: image.mimeType, + sizeBytes: image.sizeBytes, + dataUrl, + }; + case "video": + return { + type: "video" as const, + name: image.name, + mimeType: image.mimeType, + sizeBytes: image.sizeBytes, + dataUrl, + }; + } + }), ); const optimisticAttachments = composerImagesSnapshot.map((image) => ({ - type: "image" as const, + type: image.type, id: image.id, name: image.name, mimeType: image.mimeType, @@ -4912,17 +5070,17 @@ function ChatViewContent(props: ChatViewProps) { clearComposerDraftContent(composerDraftTarget); composerRef.current?.resetCursorState(); - let firstComposerImageName: string | null = null; + let firstComposerAttachmentName: string | null = null; if (composerImagesSnapshot.length > 0) { - const firstComposerImage = composerImagesSnapshot[0]; - if (firstComposerImage) { - firstComposerImageName = firstComposerImage.name; + const firstComposerAttachment = composerImagesSnapshot[0]; + if (firstComposerAttachment) { + firstComposerAttachmentName = firstComposerAttachment.name; } } let titleSeed = trimmed; if (!titleSeed) { - if (firstComposerImageName) { - titleSeed = `Image: ${firstComposerImageName}`; + if (firstComposerAttachmentName) { + titleSeed = `Attachment: ${firstComposerAttachmentName}`; } else if (composerTerminalContextsSnapshot.length > 0) { titleSeed = formatTerminalContextLabel(composerTerminalContextsSnapshot[0]!); } else if (composerElementContextsSnapshot.length > 0) { @@ -4977,7 +5135,7 @@ function ChatViewContent(props: ChatViewProps) { let turnStartSucceeded = false; if (failure === null && turnAttachmentsResult._tag === "Success") { const bootstrap = - isLocalDraftThread || baseBranchForWorktree + isLocalDraftThread || (baseBranchForWorktree && !isHermesConversation) ? { ...(isLocalDraftThread ? { @@ -4987,13 +5145,14 @@ function ChatViewContent(props: ChatViewProps) { modelSelection: threadCreateModelSelection, runtimeMode, interactionMode, - branch: activeThreadBranch, - worktreePath: activeThread.worktreePath, + branch: isHermesConversation ? null : activeThreadBranch, + worktreePath: isHermesConversation ? null : activeThread.worktreePath, createdAt: activeThread.createdAt, }, + ...(isHermesConversation ? { prepareWorkspace: false } : {}), } : {}), - ...(baseBranchForWorktree + ...(baseBranchForWorktree && !isHermesConversation ? { prepareWorktree: { projectCwd: activeProject.workspaceRoot, @@ -5843,6 +6002,7 @@ function ChatViewContent(props: ChatViewProps) { environmentConnection: activeEnvironment?.connection ?? null, threadId: activeThread.id, ...(draftId ? { draftId } : {}), + isProjectlessConversation: isHermesConversation, activeProjectName: activeProject?.title, activeProjectScripts: activeProject?.scripts, preferredScriptId: activeProject @@ -5888,7 +6048,7 @@ function ChatViewContent(props: ChatViewProps) { onDeleteProjectScript: deleteProjectScript, }; const panelToggleControlProps = { - terminalAvailable: activeProject !== null, + terminalAvailable: activeProject !== null && !isHermesConversation, terminalOpen: terminalUiState.terminalOpen, terminalShortcutLabel: shortcutLabelForCommand(keybindings, "terminal.toggle"), threadPanelOpen, @@ -5908,7 +6068,7 @@ function ChatViewContent(props: ChatViewProps) { threadPanelShortcutLabel: shortcutLabelForCommand(keybindings, "threadPanel.toggle"), threadPanelHasAttention: activeEnvironmentUnavailableState !== null || showVersionMismatchBanner, - rightPanelAvailable: activeProject !== null, + rightPanelAvailable: activeProject !== null && !isHermesConversation, rightPanelOpen, rightPanelShortcutLabel: shortcutLabelForCommand(keybindings, "rightPanel.toggle"), onToggleTerminal: toggleTerminalVisibility, @@ -5919,10 +6079,12 @@ function ChatViewContent(props: ChatViewProps) { ); const threadPanelHeaderControl = ( -
    +
    ); const panelLayoutControls = ( -
    +
    {rightPanelOpen && !shouldUsePlanSidebarSheet ? (
    @@ -6133,6 +6299,7 @@ function ChatViewContent(props: ChatViewProps) { activeThread={activeThread} isServerThread={isServerThread} isLocalDraftThread={isLocalDraftThread} + isProjectlessConversation={isHermesConversation} forceExpandedOnMobile={forceExpandedMobileComposer && isDraftHeroState} projectSelectionRequired={isLocalDraftThread && activeProject === null} phase={phase} @@ -6176,6 +6343,29 @@ function ChatViewContent(props: ChatViewProps) { shouldAutoScrollRef={isAtEndRef} scheduleStickToBottom={scrollToEnd} onSend={onSend} + onStartFreshChat={() => { + const sendContext = composerRef.current?.getSendContext(); + if (!activeProject || !sendContext) return; + promptRef.current = ""; + clearComposerDraftContent(composerDraftTarget); + composerRef.current?.resetCursorState(); + void handleNewThread( + scopeProjectRef(activeProject.environmentId, activeProject.id), + { + fresh: true, + modelSelection: sendContext.selectedModelSelection, + }, + ); + }} + onClearChat={() => { + void (async () => { + const cleared = await clearCurrentHermesTimeline(); + if (!cleared) return; + promptRef.current = ""; + clearComposerDraftContent(composerDraftTarget); + composerRef.current?.resetCursorState(); + })(); + }} onInterrupt={onInterrupt} onImplementPlanInNewThread={onImplementPlanInNewThread} onRespondToApproval={onRespondToApproval} diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index aa7547c8ba6..81fb3f04021 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -27,6 +27,7 @@ import { FolderPlusIcon, LinkIcon, MessageSquareIcon, + MessagesSquareIcon, SettingsIcon, SquarePenIcon, } from "lucide-react"; @@ -104,14 +105,28 @@ import { RECENT_THREAD_LIMIT, } from "./CommandPalette.logic"; import { orderItemsByPreferredIds, sortLogicalProjectsForSidebar } from "./Sidebar.logic"; +import type { Project } from "../types"; import { resolveEnvironmentOptionLabel } from "./BranchToolbar.logic"; import { CommandPaletteResults } from "./CommandPaletteResults"; import { AzureDevOpsIcon, BitbucketIcon, GitHubIcon, GitLabIcon } from "./Icons"; import { ProjectFavicon } from "./ProjectFavicon"; import { ThreadRowLeadingStatus, ThreadRowTrailingStatus } from "./ThreadStatusIndicators"; -import { primaryServerKeybindingsAtom, primaryServerProvidersAtom } from "../state/server"; -import { resolveDefaultProviderModelSelection } from "../providerInstances"; +import { + environmentServerConfigsAtom, + primaryServerKeybindingsAtom, + primaryServerProvidersAtom, +} from "../state/server"; +import { + deriveProviderInstanceEntries, + resolveDefaultProviderModelSelection, +} from "../providerInstances"; import { resolveShortcutCommand, threadJumpIndexFromCommand } from "../keybindings"; +import { + isT3WorkBackingProject, + T3_WORK_BACKING_PROJECT_ID, + T3_WORK_BACKING_PROJECT_TITLE, + t3WorkDirectoryForEnvironment, +} from "../t3WorkProject"; import { Command, CommandDialog, @@ -250,6 +265,16 @@ function remoteProjectSourceIcon(source: AddProjectRemoteSource, className: stri } } +function projectActionItemIcon(project: Project): ReactNode { + return ( + + ); +} + function remoteProjectInputPlaceholder(flow: AddProjectCloneFlow | null): string | null { if (!flow) return null; if (flow.step === "confirm") return null; @@ -499,6 +524,18 @@ function OpenCommandPaletteDialog(props: { const threads = useThreadShells(); const keybindings = useAtomValue(primaryServerKeybindingsAtom); const providers = useAtomValue(primaryServerProvidersAtom); + const serverConfigs = useAtomValue(environmentServerConfigsAtom); + const hermesProviderEntry = useMemo( + () => + deriveProviderInstanceEntries(providers).find( + (entry) => + entry.driverKind === "hermes" && + entry.enabled && + entry.isAvailable && + entry.status === "ready", + ) ?? null, + [providers], + ); const [viewStack, setViewStack] = useState([]); const currentView = viewStack.at(-1) ?? null; const [browseGeneration, setBrowseGeneration] = useState(0); @@ -521,10 +558,14 @@ function OpenCommandPaletteDialog(props: { ), [environments], ); + const visibleProjects = useMemo( + () => projects.filter((project) => !isT3WorkBackingProject(project, serverConfigs)), + [projects, serverConfigs], + ); const orderedProjects = useMemo( () => orderItemsByPreferredIds({ - items: projects, + items: visibleProjects, preferredIds: projectOrder, getId: getProjectOrderKey, getPreferenceIds: (project) => [ @@ -532,12 +573,13 @@ function OpenCommandPaletteDialog(props: { legacyProjectCwdPreferenceKey(project.workspaceRoot), ], }), - [projectOrder, projects], + [projectOrder, visibleProjects], ); const unsortedProjectGroups = useMemo( () => buildSidebarProjectSnapshots({ - projects: clientSettings.sidebarProjectSortOrder === "manual" ? orderedProjects : projects, + projects: + clientSettings.sidebarProjectSortOrder === "manual" ? orderedProjects : visibleProjects, settings: projectGroupingSettings, primaryEnvironmentId, resolveEnvironmentLabel: (environmentId) => environmentLabelById.get(environmentId) ?? null, @@ -548,7 +590,7 @@ function OpenCommandPaletteDialog(props: { orderedProjects, primaryEnvironmentId, projectGroupingSettings, - projects, + visibleProjects, ], ); const projectGroups = useMemo( @@ -793,18 +835,103 @@ function OpenCommandPaletteDialog(props: { group?.memberProjects.flatMap((member) => [member.title, member.workspaceRoot]) ?? [] ); }, - icon: (project) => ( - - ), + icon: projectActionItemIcon, runProject: openProjectFromSearch, }), [openProjectFromSearch, pickerProjects, projectGroupByTargetKey], ); + const startFreshHermesChat = useCallback(async () => { + const t3WorkDirectory = t3WorkDirectoryForEnvironment(serverConfigs, primaryEnvironmentId); + const existingBackingProject = + projects.find( + (project) => + project.environmentId === primaryEnvironmentId && + t3WorkDirectory !== null && + project.workspaceRoot === t3WorkDirectory, + ) ?? null; + const hermesModel = + hermesProviderEntry?.models.find((model) => model.slug === "default") ?? + hermesProviderEntry?.models[0] ?? + null; + if ( + primaryEnvironmentId === null || + t3WorkDirectory === null || + !hermesProviderEntry || + !hermesModel + ) { + toastManager.add({ + type: "warning", + title: "Hermes is not ready", + description: "Enable and configure Hermes before starting a new chat.", + }); + return; + } + if (existingBackingProject === null) { + const createResult = await createProject({ + environmentId: primaryEnvironmentId, + input: { + projectId: T3_WORK_BACKING_PROJECT_ID, + title: T3_WORK_BACKING_PROJECT_TITLE, + workspaceRoot: t3WorkDirectory, + createWorkspaceRootIfMissing: true, + defaultModelSelection: { + instanceId: hermesProviderEntry.instanceId, + model: hermesModel.slug, + }, + }, + }); + if (createResult._tag === "Failure") { + if (!isAtomCommandInterrupted(createResult)) { + const error = squashAtomCommandFailure(createResult); + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Could not prepare T3 Work", + description: + error instanceof Error + ? error.message + : "The private T3 Work conversation directory could not be created.", + }), + ); + } + return; + } + } + await handleNewThread( + scopeProjectRef( + primaryEnvironmentId, + existingBackingProject?.id ?? T3_WORK_BACKING_PROJECT_ID, + ), + { + fresh: true, + modelSelection: { + instanceId: hermesProviderEntry.instanceId, + model: hermesModel.slug, + }, + }, + ); + }, [ + createProject, + handleNewThread, + hermesProviderEntry, + primaryEnvironmentId, + projects, + serverConfigs, + ]); + + const newChatItem = useMemo( + () => ({ + kind: "action", + value: "new-thread:new-chat", + searchTerms: ["new chat", "hermes", "conversation", "fresh"], + title: "New chat", + icon: , + run: startFreshHermesChat, + }), + [startFreshHermesChat], + ); + const projectThreadItems = useMemo( () => enumerateCommandPaletteItems( @@ -817,13 +944,7 @@ function OpenCommandPaletteDialog(props: { group?.memberProjects.flatMap((member) => [member.title, member.workspaceRoot]) ?? [] ); }, - icon: (project) => ( - - ), + icon: projectActionItemIcon, runProject: async (project) => { const group = projectGroupByTargetKey.get(`${project.environmentId}:${project.id}`); const contextualRefBelongsToGroup = @@ -1133,7 +1254,7 @@ function OpenCommandPaletteDialog(props: { { value: "projects", label: "Projects", - items: enumerateCommandPaletteItems(prioritized), + items: [newChatItem, ...enumerateCommandPaletteItems(prioritized)], }, ], }); @@ -1141,6 +1262,7 @@ function OpenCommandPaletteDialog(props: { clearOpenIntent, currentProjectEnvironmentId, currentProjectId, + newChatItem, openIntent, projectThreadItems, ]); @@ -1182,7 +1304,13 @@ function OpenCommandPaletteDialog(props: { title: "New thread in...", icon: , addonIcon: , - groups: [{ value: "projects", label: "Projects", items: projectThreadItems }], + groups: [ + { + value: "projects", + label: "Projects", + items: [newChatItem, ...projectThreadItems], + }, + ], }); } diff --git a/apps/web/src/components/HermesImportOnboarding.logic.test.ts b/apps/web/src/components/HermesImportOnboarding.logic.test.ts new file mode 100644 index 00000000000..ccde2756f52 --- /dev/null +++ b/apps/web/src/components/HermesImportOnboarding.logic.test.ts @@ -0,0 +1,120 @@ +import { describe, expect, it } from "vite-plus/test"; +import { ThreadId } from "@t3tools/contracts"; + +import { + describeHermesImportStatus, + hermesTransportLabel, + summarizeHermesImportSessions, +} from "./HermesImportOnboarding.logic.ts"; + +describe("Hermes import onboarding presentation", () => { + it("summarizes transport and 72-hour settlement classifications", () => { + expect( + summarizeHermesImportSessions( + [ + { + storedSessionId: "discord-new", + title: "New", + preview: "", + startedAt: 1, + settlement: "unsettled", + messageCount: 1, + source: "discord", + importedThreadId: null, + }, + { + storedSessionId: "telegram-old", + title: "Old", + preview: "", + startedAt: -2 * 24 * 60 * 60, + settlement: "settled", + messageCount: 1, + source: "telegram", + importedThreadId: null, + }, + { + storedSessionId: "discord-old", + title: "Older", + preview: "", + startedAt: 1, + settlement: "settled", + messageCount: 1, + source: "discord", + importedThreadId: ThreadId.make("thread:existing"), + }, + ], + 1, + 0, + ), + ).toEqual({ + total: 2, + ready: 1, + alreadyImported: 1, + unsettled: 1, + settled: 0, + transports: [{ source: "discord", count: 2 }], + }); + }); + + it("uses friendly built-in labels and a readable forward-compatible fallback", () => { + expect(hermesTransportLabel("telegram")).toBe("Telegram"); + expect(hermesTransportLabel("whatsapp_cloud")).toBe("WhatsApp Cloud"); + expect(hermesTransportLabel("future_transport")).toBe("future transport"); + }); + + it("describes ready conversations and their settlement behavior", () => { + expect( + describeHermesImportStatus( + { + total: 9, + ready: 7, + alreadyImported: 2, + unsettled: 3, + settled: 4, + transports: [], + }, + "30 days", + ), + ).toEqual({ + title: "7 conversations ready to import", + description: + "3 started in the last 72 hours will remain unsettled; 4 older will start settled.", + }); + }); + + it("describes empty and already-imported selections", () => { + expect( + describeHermesImportStatus( + { + total: 0, + ready: 0, + alreadyImported: 0, + unsettled: 0, + settled: 0, + transports: [], + }, + "14 days", + ), + ).toEqual({ + title: "No matching conversations", + description: "No conversations started within the last 14 days.", + }); + + expect( + describeHermesImportStatus( + { + total: 1, + ready: 0, + alreadyImported: 1, + unsettled: 0, + settled: 0, + transports: [{ source: "telegram", count: 1 }], + }, + "14 days", + ), + ).toEqual({ + title: "Hermes is up to date", + description: "All 1 matching conversation is already in T3 Work.", + }); + }); +}); diff --git a/apps/web/src/components/HermesImportOnboarding.logic.ts b/apps/web/src/components/HermesImportOnboarding.logic.ts new file mode 100644 index 00000000000..4262a28263b --- /dev/null +++ b/apps/web/src/components/HermesImportOnboarding.logic.ts @@ -0,0 +1,96 @@ +import type { HermesDiscoveredSession } from "@t3tools/contracts"; + +const DAY_MS = 24 * 60 * 60 * 1_000; + +export interface HermesImportSummary { + readonly total: number; + readonly ready: number; + readonly alreadyImported: number; + readonly unsettled: number; + readonly settled: number; + readonly transports: ReadonlyArray<{ readonly source: string; readonly count: number }>; +} + +export interface HermesImportStatus { + readonly title: string; + readonly description: string; +} + +export function describeHermesImportStatus( + summary: HermesImportSummary, + ageLabel: string, +): HermesImportStatus { + if (summary.total === 0) { + return { + title: "No matching conversations", + description: `No conversations started within the last ${ageLabel}.`, + }; + } + if (summary.ready === 0) { + return { + title: "Hermes is up to date", + description: `All ${summary.total} matching conversation${summary.total === 1 ? " is" : "s are"} already in T3 Work.`, + }; + } + return { + title: `${summary.ready} conversation${summary.ready === 1 ? "" : "s"} ready to import`, + description: `${summary.unsettled} started in the last 72 hours will remain unsettled; ${summary.settled} older will start settled.`, + }; +} + +export function summarizeHermesImportSessions( + sessions: ReadonlyArray, + activeWithinDays: number, + nowMillis: number, +): HermesImportSummary { + const counts = new Map(); + let ready = 0; + let unsettled = 0; + const matchingSessions = sessions.filter( + (session) => session.startedAt * 1_000 >= nowMillis - activeWithinDays * DAY_MS, + ); + for (const session of matchingSessions) { + counts.set(session.source, (counts.get(session.source) ?? 0) + 1); + if (session.importedThreadId !== null) continue; + ready += 1; + if (session.settlement === "unsettled") unsettled += 1; + } + return { + total: matchingSessions.length, + ready, + alreadyImported: matchingSessions.length - ready, + unsettled, + settled: ready - unsettled, + transports: [...counts] + .map(([source, count]) => ({ source, count })) + .sort((left, right) => left.source.localeCompare(right.source)), + }; +} + +export function hermesTransportLabel(source: string): string { + const known: Readonly> = { + api_server: "API server", + bluebubbles: "BlueBubbles", + dingtalk: "DingTalk", + discord: "Discord", + email: "Email", + feishu: "Feishu", + homeassistant: "Home Assistant", + matrix: "Matrix", + mattermost: "Mattermost", + msgraph_webhook: "Microsoft Graph", + qqbot: "QQ Bot", + signal: "Signal", + slack: "Slack", + sms: "SMS", + telegram: "Telegram", + wecom: "WeCom", + wecom_callback: "WeCom callback", + webhook: "Webhook", + weixin: "Weixin", + whatsapp: "WhatsApp", + whatsapp_cloud: "WhatsApp Cloud", + yuanbao: "Yuanbao", + }; + return known[source] ?? source.replaceAll("_", " "); +} diff --git a/apps/web/src/components/HermesImportOnboarding.tsx b/apps/web/src/components/HermesImportOnboarding.tsx new file mode 100644 index 00000000000..217316d8f15 --- /dev/null +++ b/apps/web/src/components/HermesImportOnboarding.tsx @@ -0,0 +1,295 @@ +import type { + EnvironmentId, + HermesSessionDiscoveryResult, + ProjectId, + ProviderInstanceId, +} from "@t3tools/contracts"; +import { + DEFAULT_HERMES_SESSION_IMPORT_AGE_DAYS, + MAX_HERMES_SESSION_IMPORT_AGE_DAYS, + MIN_HERMES_SESSION_IMPORT_AGE_DAYS, +} from "@t3tools/contracts"; +import { LoaderCircleIcon } from "lucide-react"; +import { useCallback, useEffect, useId, useMemo, useRef, useState } from "react"; + +import { hermesEnvironment } from "../state/hermes"; +import { useAtomCommand } from "../state/use-atom-command"; +import { + isAtomCommandInterrupted, + squashAtomCommandFailure, +} from "@t3tools/client-runtime/state/runtime"; +import { Button } from "./ui/button"; +import { + Dialog, + DialogDescription, + DialogFooter, + DialogHeader, + DialogPanel, + DialogPopup, + DialogTitle, +} from "./ui/dialog"; +import { stackedThreadToast, toastManager } from "./ui/toast"; +import { + describeHermesImportStatus, + hermesTransportLabel, + summarizeHermesImportSessions, +} from "./HermesImportOnboarding.logic"; + +interface HermesImportOnboardingProps { + readonly environmentId: EnvironmentId | null; + readonly providerInstanceId: ProviderInstanceId | null; + readonly backingProjectId: ProjectId | null; + readonly autoOpenEnabled: boolean; + readonly open: boolean; + readonly onOpenChange: (open: boolean) => void; +} + +function failureDescription(result: { readonly cause: unknown }): string { + const error = squashAtomCommandFailure(result as Parameters[0]); + return error instanceof Error ? error.message : "Hermes conversations could not be loaded."; +} + +export function HermesImportOnboarding(props: HermesImportOnboardingProps) { + const discover = useAtomCommand(hermesEnvironment.discoverSessions, { + reportFailure: false, + }); + const importSessions = useAtomCommand(hermesEnvironment.importSessions, { + reportFailure: false, + }); + const [discovery, setDiscovery] = useState(null); + const [loading, setLoading] = useState(false); + const [importing, setImporting] = useState(false); + const [activeWithinDays, setActiveWithinDays] = useState(DEFAULT_HERMES_SESSION_IMPORT_AGE_DAYS); + const ageSliderId = useId(); + const autoCheckedKeyRef = useRef(null); + const discoveryRequestIdRef = useRef(0); + + const loadDiscovery = useCallback( + async (autoOpen: boolean) => { + if (props.environmentId === null || props.providerInstanceId === null) return; + const requestId = ++discoveryRequestIdRef.current; + setDiscovery(null); + setLoading(true); + const result = await discover({ + environmentId: props.environmentId, + input: { providerInstanceId: props.providerInstanceId, limit: 10_000 }, + }); + if (requestId !== discoveryRequestIdRef.current) return; + setLoading(false); + if (result._tag === "Success") { + setDiscovery(result.value); + if (autoOpen && result.value.mainThreadId === null) props.onOpenChange(true); + return; + } + if (!isAtomCommandInterrupted(result) && !autoOpen) { + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Could not load Hermes conversations", + description: failureDescription(result), + }), + ); + } + }, + [discover, props.environmentId, props.onOpenChange, props.providerInstanceId], + ); + + useEffect( + () => () => { + discoveryRequestIdRef.current += 1; + }, + [], + ); + + useEffect(() => { + if ( + !props.autoOpenEnabled || + props.environmentId === null || + props.providerInstanceId === null || + props.backingProjectId === null + ) { + return; + } + const key = `${props.environmentId}:${props.providerInstanceId}`; + if (autoCheckedKeyRef.current === key) return; + autoCheckedKeyRef.current = key; + void loadDiscovery(true); + }, [ + loadDiscovery, + props.autoOpenEnabled, + props.backingProjectId, + props.environmentId, + props.providerInstanceId, + ]); + + useEffect(() => { + if (props.open) void loadDiscovery(false); + }, [loadDiscovery, props.open]); + + const summary = useMemo( + () => summarizeHermesImportSessions(discovery?.sessions ?? [], activeWithinDays, Date.now()), + [activeWithinDays, discovery], + ); + const ageLabel = `${activeWithinDays} ${activeWithinDays === 1 ? "day" : "days"}`; + const importStatus = describeHermesImportStatus(summary, ageLabel); + + const runImport = useCallback(async () => { + if ( + props.environmentId === null || + props.providerInstanceId === null || + props.backingProjectId === null + ) { + return; + } + setImporting(true); + const result = await importSessions({ + environmentId: props.environmentId, + input: { + providerInstanceId: props.providerInstanceId, + backingProjectId: props.backingProjectId, + selection: { type: "all" }, + activeWithinDays, + ensureMain: true, + }, + }); + setImporting(false); + if (result._tag === "Success") { + const newlyImported = result.value.imported.filter( + (item) => item.status === "imported", + ).length; + props.onOpenChange(false); + toastManager.add({ + type: "success", + title: newlyImported === 0 ? "Hermes is up to date" : "Hermes conversations imported", + description: + newlyImported === 0 + ? "No new transport conversations were found for this Hermes profile." + : `${newlyImported} conversation${newlyImported === 1 ? "" : "s"} added to T3 Work.`, + }); + return; + } + if (!isAtomCommandInterrupted(result)) { + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Hermes import failed", + description: failureDescription(result), + }), + ); + } + }, [ + importSessions, + activeWithinDays, + props.backingProjectId, + props.environmentId, + props.onOpenChange, + props.providerInstanceId, + ]); + + return ( + + + + Import Hermes conversations + + Choose how far back to bring this profile’s transport history into T3 Work. + + + + {loading && discovery === null ? ( +
    + + Looking for conversations… +
    + ) : ( + <> +
    +
    + + + {ageLabel} + +
    + setActiveWithinDays(Number(event.currentTarget.value))} + step={1} + type="range" + value={activeWithinDays} + /> + +
    + +
    +

    {importStatus.title}

    +

    + {importStatus.description} +

    + {summary.total === 0 ? ( +

    + Messaging transports appear after this profile has sessions. +

    + ) : null} +
    + + {summary.transports.length > 0 ? ( +
    + + Transports + +
      + {summary.transports.map((transport) => ( +
    • + {hermesTransportLabel(transport.source)}{" "} + + {transport.count} + +
    • + ))} +
    +
    + ) : null} + {discovery?.capabilities.activityTimestamp.limitation ? ( +

    + {discovery.capabilities.activityTimestamp.limitation} +

    + ) : null} + + )} +
    + + + + +
    +
    + ); +} diff --git a/apps/web/src/components/Icons.tsx b/apps/web/src/components/Icons.tsx index 8ea38c51958..d39ad02fe3c 100644 --- a/apps/web/src/components/Icons.tsx +++ b/apps/web/src/components/Icons.tsx @@ -663,6 +663,66 @@ export const OpenCodeIcon: Icon = (props) => ( ); +/** OpenClaw logo from https://svgl.app/library/openclaw.svg. */ +export const OpenClawIcon: Icon = ({ className, ...props }) => ( + + + + + + + + + + + + + + + + + +); + +/** + * Hermes Agent logo from the user-selected, commit-pinned upstream asset: + * https://github.com/NousResearch/hermes-agent/blob/62e07223d630c122317d5bed3102d24bd1144976/website/static/img/logo.png + * + * The checked-in public asset is a size-optimized derivative of that exact + * PNG (matching source SHA-256: + * 2eaff911b9da9b1f1fcc81adb02f4992bb9ea6b781f4dd048cd79349927ddb7a). + */ +export const HermesIcon: Icon = ({ className, ...props }) => ( + + + +); + export const GithubCopilotIcon: Icon = ({ className, ...props }) => ( { + const hermesEnvironmentId = EnvironmentId.make("environment-hermes"); + const hermesInstanceId = ProviderInstanceId.make("hermes-primary"); + const driverKinds = new Map([ + [`${hermesEnvironmentId}\u0000${hermesInstanceId}`, ProviderDriverKind.make("hermes")] as const, + ]); + + it("projects durable main and needs-you roles into structured sections", () => { + expect(workInboxActiveSection(makeThreadFixture({ workInboxRole: "main" }))).toBe("main"); + expect(workInboxActiveSection(makeThreadFixture({ hasPendingApprovals: true }))).toBe( + "needs-you", + ); + expect(workInboxActiveSection(makeThreadFixture({ hasPendingUserInput: true }))).toBe( + "needs-you", + ); + expect(workInboxActiveSection(makeThreadFixture())).toBe("active"); + }); + + it("only permits unsettled ordinary Hermes sidebar threads to be pinned", () => { + const ordinaryHermes = makeThreadFixture({ + environmentId: hermesEnvironmentId, + providerInstanceId: hermesInstanceId, + }); + const canPin = (overrides: Partial[0]>) => + canPinWorkInboxThread({ + thread: ordinaryHermes, + providerDriverKindByInstance: driverKinds, + isSnoozed: false, + isSettled: false, + ...overrides, + }); + + expect(canPin({})).toBe(true); + expect(canPin({ isSettled: true })).toBe(false); + expect(canPin({ isSnoozed: true })).toBe(false); + expect(canPin({ thread: { ...ordinaryHermes, workInboxRole: "main" } })).toBe(false); + expect( + canPin({ + thread: { + ...ordinaryHermes, + lineage: { + ...ordinaryHermes.lineage, + relationshipToParent: "subagent", + }, + }, + }), + ).toBe(false); + expect( + canPin({ + thread: { + ...ordinaryHermes, + providerInstanceId: ProviderInstanceId.make("codex"), + }, + }), + ).toBe(false); + }); +}); + describe("shouldNavigateAfterProjectRemoval", () => { const projectThreads = [{ environmentId: "environment-local", id: "thread-1" }]; @@ -263,6 +335,207 @@ describe("sidebar thread lineage helpers", () => { expect(isSidebarSubagentThread(makeThreadFixture())).toBe(false); }); + it("keeps subagent threads out of every sidebar lifecycle bucket", () => { + const parentId = ThreadId.make("thread-parent"); + const subagentLineage = { + rootThreadId: parentId, + parentThreadId: parentId, + relationshipToParent: "subagent" as const, + }; + + const activeSubagent = makeThreadFixture({ lineage: subagentLineage }); + const snoozedSubagent = makeThreadFixture({ + lineage: subagentLineage, + snoozedUntil: "2026-03-10T12:00:00.000Z", + }); + const settledSubagent = makeThreadFixture({ + lineage: subagentLineage, + settledOverride: "settled", + settledAt: "2026-03-09T12:00:00.000Z", + }); + + expect( + [activeSubagent, snoozedSubagent, settledSubagent].map(isSidebarLifecycleThread), + ).toEqual([false, false, false]); + expect(isSidebarLifecycleThread(makeThreadFixture())).toBe(true); + expect( + isSidebarLifecycleThread(makeThreadFixture({ archivedAt: "2026-03-09T12:00:00.000Z" })), + ).toBe(false); + }); + + it("filters lifecycle threads by workspace using provider driver metadata", () => { + const environmentId = EnvironmentId.make("environment-workspaces"); + const codexInstanceId = ProviderInstanceId.make("codex_personal"); + const defaultHermesInstanceId = ProviderInstanceId.make("hermes"); + const customHermesInstanceId = ProviderInstanceId.make("research_assistant"); + const parentId = ThreadId.make("thread-parent"); + const providerDriverKindByInstance = new Map([ + [ + sidebarProviderInstanceKey(environmentId, codexInstanceId), + ProviderDriverKind.make("codex"), + ], + [ + sidebarProviderInstanceKey(environmentId, defaultHermesInstanceId), + ProviderDriverKind.make("hermes"), + ], + [ + sidebarProviderInstanceKey(environmentId, customHermesInstanceId), + ProviderDriverKind.make("hermes"), + ], + ]); + const ordinaryThread = makeThreadFixture({ + environmentId, + id: ThreadId.make("thread-code"), + providerInstanceId: codexInstanceId, + }); + const hermesThread = makeThreadFixture({ + environmentId, + id: ThreadId.make("thread-hermes"), + providerInstanceId: defaultHermesInstanceId, + }); + const customHermesThread = makeThreadFixture({ + environmentId, + id: ThreadId.make("thread-custom-hermes"), + providerInstanceId: customHermesInstanceId, + }); + const subagentThread = makeThreadFixture({ + environmentId, + id: ThreadId.make("thread-hermes-subagent"), + providerInstanceId: customHermesInstanceId, + lineage: { + rootThreadId: parentId, + parentThreadId: parentId, + relationshipToParent: "subagent", + }, + }); + const threads = [ordinaryThread, hermesThread, customHermesThread, subagentThread]; + + expect( + threads.filter((thread) => + isThreadVisibleInSidebarWorkspace(thread, "code", providerDriverKindByInstance), + ), + ).toEqual([ordinaryThread]); + expect( + threads.filter((thread) => + isThreadVisibleInSidebarWorkspace(thread, "work", providerDriverKindByInstance), + ), + ).toEqual([hermesThread, customHermesThread]); + }); + + it("keeps Hermes threads on known Code projects in the code workspace", () => { + const environmentId = EnvironmentId.make("environment-partition"); + const hermesInstanceId = ProviderInstanceId.make("hermes-main"); + const providerDriverKindByInstance = new Map([ + [ + sidebarProviderInstanceKey(environmentId, hermesInstanceId), + ProviderDriverKind.make("hermes"), + ], + ]); + const workProjectId = ProjectId.make("project:t3-work"); + const codeProjectId = ProjectId.make("project-code"); + const hermesWorkThread = makeThreadFixture({ + environmentId, + id: ThreadId.make("thread-hermes-work"), + providerInstanceId: hermesInstanceId, + projectId: workProjectId, + }); + const hermesCodeThread = makeThreadFixture({ + environmentId, + id: ThreadId.make("thread-hermes-code"), + providerInstanceId: hermesInstanceId, + projectId: codeProjectId, + }); + const codeProjectKeys = new Set([sidebarProjectKey(environmentId, codeProjectId)]); + + expect( + isThreadVisibleInSidebarWorkspace( + hermesWorkThread, + "work", + providerDriverKindByInstance, + codeProjectKeys, + ), + ).toBe(true); + expect( + isThreadVisibleInSidebarWorkspace( + hermesCodeThread, + "code", + providerDriverKindByInstance, + codeProjectKeys, + ), + ).toBe(true); + expect( + isThreadVisibleInSidebarWorkspace( + hermesCodeThread, + "work", + providerDriverKindByInstance, + codeProjectKeys, + ), + ).toBe(false); + // While projects/configs are still loading the set is absent, so Hermes + // threads conservatively stay in the work workspace. + expect( + isThreadVisibleInSidebarWorkspace(hermesCodeThread, "work", providerDriverKindByInstance), + ).toBe(true); + }); + + it("uses the literal Hermes instance only as a missing-metadata fallback", () => { + const environmentId = EnvironmentId.make("environment-history"); + const historicalHermesThread = makeThreadFixture({ + environmentId, + providerInstanceId: ProviderInstanceId.make("hermes"), + }); + const misleadingCustomThread = makeThreadFixture({ + environmentId, + providerInstanceId: ProviderInstanceId.make("hermes_personal"), + }); + + expect(isThreadVisibleInSidebarWorkspace(historicalHermesThread, "work", new Map())).toBe(true); + expect(isThreadVisibleInSidebarWorkspace(misleadingCustomThread, "work", new Map())).toBe( + false, + ); + expect(isThreadVisibleInSidebarWorkspace(historicalHermesThread, "code", new Map())).toBe( + false, + ); + expect(isThreadVisibleInSidebarWorkspace(misleadingCustomThread, "code", new Map())).toBe(true); + }); + + it("scopes Hermes membership to the thread's own environment", () => { + const homeEnvironmentId = EnvironmentId.make("environment-home"); + const otherEnvironmentId = EnvironmentId.make("environment-other"); + const sharedInstanceId = ProviderInstanceId.make("assistant"); + const providerDriverKindByInstance = new Map([ + [ + sidebarProviderInstanceKey(homeEnvironmentId, sharedInstanceId), + ProviderDriverKind.make("hermes"), + ], + [ + sidebarProviderInstanceKey(otherEnvironmentId, sharedInstanceId), + ProviderDriverKind.make("codex"), + ], + ]); + const homeThread = makeThreadFixture({ + environmentId: homeEnvironmentId, + providerInstanceId: sharedInstanceId, + }); + const otherThread = makeThreadFixture({ + environmentId: otherEnvironmentId, + providerInstanceId: sharedInstanceId, + }); + + expect( + isThreadVisibleInSidebarWorkspace(homeThread, "work", providerDriverKindByInstance), + ).toBe(true); + expect( + isThreadVisibleInSidebarWorkspace(homeThread, "code", providerDriverKindByInstance), + ).toBe(false); + expect( + isThreadVisibleInSidebarWorkspace(otherThread, "work", providerDriverKindByInstance), + ).toBe(false); + expect( + isThreadVisibleInSidebarWorkspace(otherThread, "code", providerDriverKindByInstance), + ).toBe(true); + }); + it("resolves the parent thread for fork sidebar affordances", () => { const parentId = ThreadId.make("thread-parent"); const fallbackParentId = ThreadId.make("thread-fallback-parent"); @@ -288,6 +561,127 @@ describe("sidebar thread lineage helpers", () => { }); }); +describe("resolveWorkspaceSwitchNavigation", () => { + const environmentId = EnvironmentId.make("environment-switch"); + const hermesInstanceId = ProviderInstanceId.make("hermes-main"); + const codexInstanceId = ProviderInstanceId.make("codex-main"); + const providerDriverKindByInstance = new Map([ + [ + sidebarProviderInstanceKey(environmentId, hermesInstanceId), + ProviderDriverKind.make("hermes"), + ], + [sidebarProviderInstanceKey(environmentId, codexInstanceId), ProviderDriverKind.make("codex")], + ]); + const codeThread = { + ...makeThreadFixture({ environmentId, providerInstanceId: codexInstanceId }), + threadKey: "code-thread", + }; + const workThread = { + ...makeThreadFixture({ environmentId, providerInstanceId: hermesInstanceId }), + threadKey: "work-thread", + }; + const threads = [codeThread, workThread]; + + it("navigates to the remembered thread when it is still visible in the target workspace", () => { + expect( + resolveWorkspaceSwitchNavigation({ + nextWorkspace: "work", + rememberedThreadKey: "work-thread", + routeThreadKey: "code-thread", + threads, + providerDriverKindByInstance, + }), + ).toEqual({ kind: "remembered-thread", threadKey: "work-thread" }); + }); + + it("opens the target-mode composer when the open thread belongs to the other workspace", () => { + expect( + resolveWorkspaceSwitchNavigation({ + nextWorkspace: "work", + rememberedThreadKey: undefined, + routeThreadKey: "code-thread", + threads, + providerDriverKindByInstance, + }), + ).toEqual({ kind: "new-chat" }); + expect( + resolveWorkspaceSwitchNavigation({ + nextWorkspace: "code", + rememberedThreadKey: undefined, + routeThreadKey: "work-thread", + threads, + providerDriverKindByInstance, + }), + ).toEqual({ kind: "new-chat" }); + }); + + it("falls back to the composer when the remembered thread is no longer visible", () => { + expect( + resolveWorkspaceSwitchNavigation({ + nextWorkspace: "work", + rememberedThreadKey: "archived-work-thread", + routeThreadKey: "code-thread", + threads, + providerDriverKindByInstance, + }), + ).toEqual({ kind: "new-chat" }); + }); + + it("stays put on workspace-neutral routes and threads already valid in the target workspace", () => { + expect( + resolveWorkspaceSwitchNavigation({ + nextWorkspace: "work", + rememberedThreadKey: undefined, + routeThreadKey: null, + threads, + providerDriverKindByInstance, + }), + ).toEqual({ kind: "stay" }); + expect( + resolveWorkspaceSwitchNavigation({ + nextWorkspace: "work", + rememberedThreadKey: undefined, + routeThreadKey: "work-thread", + threads, + providerDriverKindByInstance, + }), + ).toEqual({ kind: "stay" }); + }); + + it("routes to the target composer when the open draft belongs to the other workspace", () => { + expect( + resolveWorkspaceSwitchNavigation({ + nextWorkspace: "code", + rememberedThreadKey: undefined, + routeThreadKey: null, + routeDraftWorkspace: "work", + threads, + providerDriverKindByInstance, + }), + ).toEqual({ kind: "new-chat" }); + expect( + resolveWorkspaceSwitchNavigation({ + nextWorkspace: "work", + rememberedThreadKey: undefined, + routeThreadKey: null, + routeDraftWorkspace: "code", + threads, + providerDriverKindByInstance, + }), + ).toEqual({ kind: "new-chat" }); + expect( + resolveWorkspaceSwitchNavigation({ + nextWorkspace: "code", + rememberedThreadKey: undefined, + routeThreadKey: null, + routeDraftWorkspace: "code", + threads, + providerDriverKindByInstance, + }), + ).toEqual({ kind: "stay" }); + }); +}); + function makeLatestRun(overrides?: { completedAt?: string | null; startedAt?: string | null; @@ -763,6 +1157,26 @@ describe("sortThreadsForSidebarV2", () => { expect(sorted.map((thread) => thread.id)).toEqual(["a", "b"]); }); + + it("keeps pinned unsettled threads first while preserving creation order within each group", () => { + const pinnedIds = new Set(["pinned-old", "pinned-new"]); + const sorted = sortThreadsForSidebarV2( + [ + sortable({ id: "regular-new", createdAt: "2026-03-09T13:00:00.000Z" }), + sortable({ id: "pinned-old", createdAt: "2026-03-09T08:00:00.000Z" }), + sortable({ id: "regular-old", createdAt: "2026-03-09T09:00:00.000Z" }), + sortable({ id: "pinned-new", createdAt: "2026-03-09T12:00:00.000Z" }), + ], + (thread) => pinnedIds.has(thread.id), + ); + + expect(sorted.map((thread) => thread.id)).toEqual([ + "pinned-new", + "pinned-old", + "regular-new", + "regular-old", + ]); + }); }); describe("sortSettledThreadsForSidebarV2", () => { diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index 4dcd8906a20..04571aef894 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -1,5 +1,10 @@ import * as React from "react"; -import type { ContextMenuItem } from "@t3tools/contracts"; +import type { + ContextMenuItem, + EnvironmentId, + ProviderDriverKind, + ProviderInstanceId, +} from "@t3tools/contracts"; import type { SidebarProjectSortOrder, SidebarThreadSortOrder } from "@t3tools/contracts/settings"; import { getThreadSortTimestamp, @@ -111,6 +116,165 @@ export function filterSidebarV2VisibleThreads< ); } +/** + * Threads eligible for the left sidebar's lifecycle buckets. + * + * Subagent child threads are backing storage for delegated work, not + * top-level conversations. They remain discoverable through the parent + * thread's relationship/details panel, but never enter active, snoozed, or + * settled sidebar lists. + */ +export function isSidebarLifecycleThread( + thread: Pick, +): boolean { + return thread.archivedAt === null && !isSidebarSubagentThread(thread); +} + +export type SidebarWorkspace = "work" | "code"; + +export function sidebarProviderInstanceKey( + environmentId: EnvironmentId, + providerInstanceId: ProviderInstanceId, +): string { + return `${environmentId}\u0000${providerInstanceId}`; +} + +export function isHermesSidebarThread( + thread: Pick, + providerDriverKindByInstance: ReadonlyMap, +): boolean { + const driverKind = providerDriverKindByInstance.get( + sidebarProviderInstanceKey(thread.environmentId, thread.providerInstanceId), + ); + return ( + driverKind === "hermes" || (driverKind === undefined && thread.providerInstanceId === "hermes") + ); +} + +export function sidebarProjectKey( + environmentId: EnvironmentId, + projectId: SidebarThreadSummary["projectId"], +): string { + return `${environmentId}:${projectId}`; +} + +/** + * Applies the selected workspace to the sidebar lifecycle lists. + * + * The work and code workspaces partition lifecycle threads on Hermes + * membership: Hermes conversations belong to work, everything else to code. + * Provider instance ids are user-configurable, so Hermes membership comes + * from the environment's provider metadata. The literal `hermes` fallback is + * only for cached historical shells whose server config has not loaded yet. + * + * A Hermes-driven thread on a regular Code project (Hermes as a coding + * provider) belongs to code, not work; `hermesCodeProjectKeys` holds the + * project keys known NOT to be the T3 Work backing project. While projects + * or server configs are still loading the set is conservative, so Hermes + * threads default to work rather than flickering into code. + */ +export function isThreadVisibleInSidebarWorkspace( + thread: Pick< + SidebarThreadSummary, + "archivedAt" | "environmentId" | "lineage" | "projectId" | "providerInstanceId" + >, + workspace: SidebarWorkspace, + providerDriverKindByInstance: ReadonlyMap, + hermesCodeProjectKeys?: ReadonlySet, +): boolean { + if (!isSidebarLifecycleThread(thread)) return false; + const isWork = + isHermesSidebarThread(thread, providerDriverKindByInstance) && + !hermesCodeProjectKeys?.has(sidebarProjectKey(thread.environmentId, thread.projectId)); + return workspace === "work" ? isWork : !isWork; +} + +/** + * Where the sidebar should route after a workspace switch. + * + * The remembered thread for the target workspace wins when it is still + * visible there. Otherwise, leaving a thread that belongs to the other + * workspace open would strand the user on stale content, so the switch + * falls back to the target workspace's new-chat composer. Routes that are + * already workspace-neutral (the index composer) stay put, but a draft + * composer pinned to the other workspace's project (e.g. a Hermes Work + * draft) is not neutral and also routes to the target composer. + */ +export type WorkspaceSwitchNavigation = + | { kind: "remembered-thread"; threadKey: string } + | { kind: "new-chat" } + | { kind: "stay" }; + +export function resolveWorkspaceSwitchNavigation(input: { + nextWorkspace: SidebarWorkspace; + rememberedThreadKey: string | undefined; + routeThreadKey: string | null; + routeDraftWorkspace?: SidebarWorkspace | null; + threads: readonly (Pick< + SidebarThreadSummary, + "archivedAt" | "environmentId" | "lineage" | "projectId" | "providerInstanceId" + > & { readonly threadKey: string })[]; + providerDriverKindByInstance: ReadonlyMap; + hermesCodeProjectKeys?: ReadonlySet; +}): WorkspaceSwitchNavigation { + const isVisible = (threadKey: string | null): boolean => { + if (threadKey === null) return false; + const thread = input.threads.find((candidate) => candidate.threadKey === threadKey); + return ( + thread !== undefined && + isThreadVisibleInSidebarWorkspace( + thread, + input.nextWorkspace, + input.providerDriverKindByInstance, + input.hermesCodeProjectKeys, + ) + ); + }; + if (input.rememberedThreadKey !== undefined && isVisible(input.rememberedThreadKey)) { + return { kind: "remembered-thread", threadKey: input.rememberedThreadKey }; + } + if (input.routeThreadKey === null) { + return input.routeDraftWorkspace != null && input.routeDraftWorkspace !== input.nextWorkspace + ? { kind: "new-chat" } + : { kind: "stay" }; + } + if (isVisible(input.routeThreadKey)) { + return { kind: "stay" }; + } + return { kind: "new-chat" }; +} + +export type WorkInboxActiveSection = "main" | "needs-you" | "active"; + +export function workInboxActiveSection( + thread: Pick< + SidebarThreadSummary, + "hasPendingApprovals" | "hasPendingUserInput" | "workInboxRole" + >, +): WorkInboxActiveSection { + if (thread.workInboxRole === "main") return "main"; + if (thread.hasPendingApprovals || thread.hasPendingUserInput) return "needs-you"; + return "active"; +} + +export function canPinWorkInboxThread(input: { + thread: Pick< + SidebarThreadSummary, + "archivedAt" | "environmentId" | "lineage" | "providerInstanceId" | "workInboxRole" + >; + providerDriverKindByInstance: ReadonlyMap; + isSnoozed: boolean; + isSettled: boolean; +}): boolean { + return ( + input.thread.workInboxRole !== "main" && + isSidebarLifecycleThread(input.thread) && + isHermesSidebarThread(input.thread, input.providerDriverKindByInstance) && + !input.isSnoozed && + !input.isSettled + ); +} + export function getSidebarForkParentThreadId( thread: Pick, ) { @@ -494,12 +658,15 @@ export function firstValidTimestamp( // approval) is carried by each card's edge strip, not by position. export function sortThreadsForSidebarV2< T extends { readonly id: string; readonly createdAt: string }, ->(threads: readonly T[]): T[] { - return [...threads].toSorted( - (left, right) => +>(threads: readonly T[], isPinned: (thread: T) => boolean = () => false): T[] { + return [...threads].toSorted((left, right) => { + const pinOrder = Number(isPinned(right)) - Number(isPinned(left)); + return ( + pinOrder || parseTimestampMs(right.createdAt) - parseTimestampMs(left.createdAt) || - left.id.localeCompare(right.id), - ); + left.id.localeCompare(right.id) + ); + }); } type SettledTimestampInput = Pick< diff --git a/apps/web/src/components/SidebarV2.tsx b/apps/web/src/components/SidebarV2.tsx index 3239d3a92fb..e2e760852e2 100644 --- a/apps/web/src/components/SidebarV2.tsx +++ b/apps/web/src/components/SidebarV2.tsx @@ -1,5 +1,6 @@ import { autoAnimate } from "@formkit/auto-animate"; import { useAtomValue } from "@effect/atom-react"; +import * as Schema from "effect/Schema"; import { canSnooze, effectiveSettled, @@ -12,7 +13,12 @@ import { scopeThreadRef, scopedThreadKey, } from "@t3tools/client-runtime/environment"; -import type { ScopedThreadRef, SidebarProjectGroupingMode } from "@t3tools/contracts"; +import { + ProviderDriverKind, + type EnvironmentId, + type ScopedThreadRef, + type SidebarProjectGroupingMode, +} from "@t3tools/contracts"; import { AlarmClockIcon, AlarmClockOffIcon, @@ -23,11 +29,14 @@ import { CircleDashedIcon, ClockIcon, CopyIcon, + DownloadIcon, FolderIcon, FolderPlusIcon, GitBranchIcon, EllipsisIcon, MessageSquareIcon, + PinIcon, + PinOffIcon, PlusIcon, SearchIcon, ServerIcon, @@ -98,23 +107,35 @@ import { useEnvironmentQuery } from "../state/query"; import { useAtomCommand } from "../state/use-atom-command"; import { buildThreadRouteParams, resolveThreadRouteTarget } from "../threadRoutes"; import { formatRelativeTimeLabel, parseTimestampDate } from "../timestampFormat"; +import { + isT3WorkBackingProject, + T3_WORK_BACKING_PROJECT_ID, + T3_WORK_BACKING_PROJECT_TITLE, + t3WorkDirectoryForEnvironment, +} from "../t3WorkProject"; import type { SidebarThreadSummary } from "../types"; import { cn } from "~/lib/utils"; import { formatWorkingDurationLabel, - filterSidebarV2VisibleThreads, firstValidTimestampMs, hasUnseenCompletion, + canPinWorkInboxThread, + isThreadVisibleInSidebarWorkspace, isTrailingDoubleClick, orderItemsByPreferredIds, resolveAdjacentThreadId, + resolveWorkspaceSwitchNavigation, resolveSettledTimestamp, resolveSidebarV2Status, resolveWorkingStartedAt, + sidebarProjectKey, + sidebarProviderInstanceKey, shouldNavigateAfterProjectRemoval, sortSidebarV2ProjectGroups, sortSettledThreadsForSidebarV2, sortThreadsForSidebarV2, + workInboxActiveSection, + type SidebarWorkspace, } from "./Sidebar.logic"; import { resolveLocalCheckoutBranchMismatch } from "./BranchToolbar.logic"; import { @@ -154,11 +175,37 @@ import { SidebarChromeFooter, SidebarChromeHeader } from "./sidebar/SidebarChrom import { Popover, PopoverPopup, PopoverTrigger } from "./ui/popover"; import { Tooltip, TooltipPopup, TooltipProvider, TooltipTrigger } from "./ui/tooltip"; import { useComposerDraftStore } from "../composerDraftStore"; +import { useLocalStorage } from "../hooks/useLocalStorage"; +import { T3Wordmark } from "./sidebar/SidebarChrome"; +import { HermesImportOnboarding } from "./HermesImportOnboarding"; // Settled-tail paging: recent history is the common lookup; the deep tail // stays behind an explicit Show more. const SETTLED_TAIL_INITIAL_COUNT = 10; const SETTLED_TAIL_PAGE_COUNT = 25; +const SIDEBAR_WORKSPACE_STORAGE_KEY = "t3code:sidebar-workspace"; +const SIDEBAR_WORKSPACE_SCHEMA = Schema.Literals(["work", "code"]); +const SIDEBAR_WORKSPACE_ROUTES_STORAGE_KEY = "t3code:sidebar-workspace-routes:v1"; +const SIDEBAR_WORKSPACE_ROUTES_SCHEMA = Schema.Struct({ + work: Schema.optional(Schema.String), + code: Schema.optional(Schema.String), +}); +const SIDEBAR_WORKSPACE_OPTIONS: ReadonlyArray<{ + value: SidebarWorkspace; + label: string; + description: string; +}> = [ + { + value: "work", + label: "T3 Work", + description: "Create, learn, and explore", + }, + { + value: "code", + label: "T3 Code", + description: "Build, debug, and ship", + }, +]; const PROJECT_GROUPING_MODE_LABELS: Record = { repository: "Group by repository", repository_path: "Group by repository path", @@ -175,6 +222,62 @@ function threadTimeLabel(thread: SidebarThreadSummary): string { return compactSidebarTimeLabel(formatRelativeTimeLabel(timestamp)); } +function SidebarWorkspaceSelector(props: { + workspace: SidebarWorkspace; + onWorkspaceChange: (workspace: SidebarWorkspace) => void; +}) { + const selected = SIDEBAR_WORKSPACE_OPTIONS.find((option) => option.value === props.workspace)!; + + return ( + + + + + {selected.label.slice(3)} + + + + + { + if (value === "work" || value === "code") { + props.onWorkspaceChange(value); + } + }} + > + {SIDEBAR_WORKSPACE_OPTIONS.map((option) => ( + + + + {option.label} + + {option.description} + + + {props.workspace === option.value ? ( + + ) : null} + + + ))} + + + + ); +} + // Settled rows read "how long ago did this wrap up", matching their sort // key: both go through resolveSettledTimestamp so label and order can't // disagree. @@ -222,6 +325,7 @@ function SidebarV2ThreadTooltip({ modelInstanceId, modelLabel, branchMismatch, + showProjectContext, }: { thread: SidebarThreadSummary; projectTitle: string | null; @@ -234,6 +338,7 @@ function SidebarV2ThreadTooltip({ threadBranch: string; currentBranch: string; } | null; + showProjectContext: boolean; }) { return (
    {thread.title}
    - {projectTitle ? ( + {showProjectContext && projectTitle ? (
    {environmentLabel}
    ) : null} - {thread.branch ? ( + {showProjectContext && thread.branch ? (
    {thread.branch}
    ) : null} - {branchMismatch ? ( + {showProjectContext && branchMismatch ? (
    @@ -366,6 +471,10 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { snoozeSupported: boolean; // Compact wake countdown ("2h") for rows in the snoozed shelf. snoozeWakeLabelText: string | null; + // Pins exist only in the unsettled card list. A lifecycle transition out + // of that list removes the affordance and the pin no longer affects order. + isPinned: boolean; + canPin: boolean; // When a snooze ended (timer or early wake); drives the Woke pill until // the user visits the thread. wokeAt: string | null; @@ -389,6 +498,7 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { onUnsettle: (threadRef: ScopedThreadRef) => void; onSnooze: (threadRef: ScopedThreadRef, preset: SnoozePreset) => void; onUnsnooze: (threadRef: ScopedThreadRef) => void; + onTogglePin: (threadRef: ScopedThreadRef) => void; onChangeRequestState: (threadKey: string, state: "open" | "closed" | "merged" | null) => void; }) { const { @@ -403,6 +513,7 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { onStartRename, onThreadActivate, onThreadClick, + onTogglePin, onUnsettle, onUnsnooze, renamingTitle, @@ -485,9 +596,20 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { } : null; + const modelInstanceId = thread.runtime?.providerInstanceId ?? thread.modelSelection.instanceId; + const providerEntry = props.providerEntryByInstanceId.get(modelInstanceId) ?? null; + const driverKind = providerEntry?.driverKind ?? null; + const isHermes = driverKind === "hermes" || (driverKind === null && modelInstanceId === "hermes"); + const selectedModel = providerEntry?.models.find( + (model) => model.slug === thread.modelSelection.model, + ); + const modelLabel = selectedModel + ? getTriggerDisplayModelLabel(selectedModel) + : thread.modelSelection.model; + const gitCwd = thread.worktreePath ?? props.projectCwd; const gitStatus = useEnvironmentQuery( - (thread.branch != null || thread.worktreePath !== null) && gitCwd !== null + !isHermes && (thread.branch != null || thread.worktreePath !== null) && gitCwd !== null ? vcsEnvironment.status({ environmentId: thread.environmentId, input: { cwd: gitCwd }, @@ -513,29 +635,20 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { onChangeRequestState(threadKey, prState); }, [onChangeRequestState, prState, threadKey]); - const modelInstanceId = thread.runtime?.providerInstanceId ?? thread.modelSelection.instanceId; - const providerEntry = props.providerEntryByInstanceId.get(modelInstanceId) ?? null; - const driverKind = providerEntry?.driverKind ?? null; - const selectedModel = providerEntry?.models.find( - (model) => model.slug === thread.modelSelection.model, - ); - const modelLabel = selectedModel - ? getTriggerDisplayModelLabel(selectedModel) - : thread.modelSelection.model; - const isRemote = props.currentEnvironmentId !== null && thread.environmentId !== props.currentEnvironmentId; const detailsTooltip = ( ); @@ -626,6 +739,19 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { }, [onSnooze, threadRef], ); + const handleTogglePinClick = useCallback( + (event: ReactMouseEvent) => { + event.preventDefault(); + event.stopPropagation(); + // A clicked button keeps focus, and the hover-action cluster stays + // visible via focus-within; drop focus so it fades with the pointer. + // Keyboard activation (detail === 0) keeps focus so the cluster stays + // reachable for Enter/Space users. + if (event.detail > 0) event.currentTarget.blur(); + onTogglePin(threadRef); + }, + [onTogglePin, threadRef], + ); // While the snooze popover is open the pointer leaves the row, which // would fade the hover actions out from under the open menu; pin them. const [snoozeMenuOpenRaw, setSnoozeMenuOpen] = useState(false); @@ -760,12 +886,20 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { "opacity-40 grayscale group-hover/v2-row:opacity-100 group-hover/v2-row:grayscale-0", )} > - + {isHermes ? ( + + ) : ( + + )} {title} {/* The PR badge stays outside the hover-fading slot: it must @@ -862,12 +996,29 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { >
    - - {props.projectTitle ? ( + {isHermes ? ( + + ) : ( + + )} + {isHermes ? ( + + Hermes + + ) : props.projectTitle ? ( - {props.settlementSupported || showSnoozeButton ? ( + {props.settlementSupported || showSnoozeButton || variant === "card" ? ( + {variant === "card" && thread.workInboxRole === "main" ? ( + + + + ) : variant === "card" && props.canPin ? ( + + ) : null} {showSnoozeButton ? (
    {title}
    - {thread.branch ? ( + {!isHermes && thread.branch ? ( {thread.branch} ) : ( )} {prBadge} - {diff ? ( + {!isHermes && diff ? ( +{diff.insertions}{" "} −{diff.deletions} @@ -966,7 +1139,14 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { ) : null} - {driverKind ? ( + {props.isPinned ? ( + + + + ) : driverKind && !isHermes ? ( ( + SIDEBAR_WORKSPACE_STORAGE_KEY, + "code", + SIDEBAR_WORKSPACE_SCHEMA, + ); + const [workspaceRoutes, setWorkspaceRoutes] = useLocalStorage( + SIDEBAR_WORKSPACE_ROUTES_STORAGE_KEY, + {}, + SIDEBAR_WORKSPACE_ROUTES_SCHEMA, + ); const projects = useProjects(); const projectOrder = useUiStateStore((store) => store.projectOrder); const threads = useThreadShells(); + const pinnedThreadKeySet = useMemo( + () => + new Set( + threads + .filter((thread) => thread.pinnedAt !== null || thread.workInboxRole === "main") + .map((thread) => scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id))), + ), + [threads], + ); const router = useRouter(); const { isMobile, setOpenMobile } = useSidebar(); const keybindings = useAtomValue(primaryServerKeybindingsAtom); @@ -1011,9 +1210,37 @@ export default function SidebarV2() { const updateThreadMetadata = useAtomCommand(threadEnvironment.updateMetadata, { reportFailure: false, }); + const toggleThreadPin = useCallback( + (threadRef: ScopedThreadRef) => { + void (async () => { + const threadKey = scopedThreadKey(threadRef); + const result = await updateThreadMetadata({ + environmentId: threadRef.environmentId, + input: { + threadId: threadRef.threadId, + pinned: !pinnedThreadKeySet.has(threadKey), + }, + }); + if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { + const error = squashAtomCommandFailure(result); + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Failed to update pin", + description: error instanceof Error ? error.message : "An error occurred.", + }), + ); + } + })(); + }, + [pinnedThreadKeySet, updateThreadMetadata], + ); const deleteProject = useAtomCommand(projectEnvironment.delete, { reportFailure: false, }); + const createProject = useAtomCommand(projectEnvironment.create, { + reportFailure: false, + }); const updateProject = useAtomCommand(projectEnvironment.update, { reportFailure: false, }); @@ -1065,6 +1292,10 @@ export default function SidebarV2() { // the command was in flight, completing it must not yank them away. const routeThreadKeyRef = useRef(routeThreadKey); routeThreadKeyRef.current = routeThreadKey; + useEffect(() => { + if (routeThreadKey === null || workspaceRoutes[workspace] === routeThreadKey) return; + setWorkspaceRoutes((current) => ({ ...current, [workspace]: routeThreadKey })); + }, [routeThreadKey, setWorkspaceRoutes, workspace, workspaceRoutes]); const environmentLabelById = useMemo( () => @@ -1073,10 +1304,31 @@ export default function SidebarV2() { ), [environments], ); + const serverConfigs = useAtomValue(environmentServerConfigsAtom); + const visibleProjects = useMemo( + () => projects.filter((project) => !isT3WorkBackingProject(project, serverConfigs)), + [projects, serverConfigs], + ); + // Hermes threads on these projects are Hermes-as-coding-provider Code + // threads, not T3 Work conversations; only classify projects whose + // environment config has loaded so the Work partition stays conservative. + const hermesCodeProjectKeys = useMemo( + () => + new Set( + projects + .filter( + (project) => + serverConfigs.has(project.environmentId) && + !isT3WorkBackingProject(project, serverConfigs), + ) + .map((project) => sidebarProjectKey(project.environmentId, project.id)), + ), + [projects, serverConfigs], + ); const orderedProjects = useMemo( () => orderItemsByPreferredIds({ - items: projects, + items: visibleProjects, preferredIds: projectOrder, getId: getProjectOrderKey, getPreferenceIds: (project) => [ @@ -1084,12 +1336,12 @@ export default function SidebarV2() { legacyProjectCwdPreferenceKey(project.workspaceRoot), ], }), - [projectOrder, projects], + [projectOrder, visibleProjects], ); const unsortedProjectGroups = useMemo( () => buildSidebarProjectSnapshots({ - projects: sidebarProjectSortOrder === "manual" ? orderedProjects : projects, + projects: sidebarProjectSortOrder === "manual" ? orderedProjects : visibleProjects, settings: projectGroupingSettings, primaryEnvironmentId, resolveEnvironmentLabel: (environmentId) => environmentLabelById.get(environmentId) ?? null, @@ -1099,7 +1351,7 @@ export default function SidebarV2() { orderedProjects, primaryEnvironmentId, projectGroupingSettings, - projects, + visibleProjects, sidebarProjectSortOrder, ], ); @@ -1117,6 +1369,111 @@ export default function SidebarV2() { ), [serverProviders], ); + // Work-mode environment scope: one menu above the list, mirroring the + // code-mode project scope. Scoping filters the visible work threads and + // retargets the New-thread composer to the selected environment. + const [workEnvironmentScopeId, setWorkEnvironmentScopeId] = useState(null); + useEffect(() => { + if ( + workEnvironmentScopeId !== null && + !environments.some((environment) => environment.environmentId === workEnvironmentScopeId) + ) { + setWorkEnvironmentScopeId(null); + } + }, [environments, workEnvironmentScopeId]); + const workTargetEnvironmentId = workEnvironmentScopeId ?? primaryEnvironmentId; + const hermesProviderEntry = useMemo(() => { + const isReadyHermesEntry = (entry: ProviderInstanceEntry) => + entry.driverKind === "hermes" && + entry.enabled && + entry.isAvailable && + entry.status === "ready"; + const scopedProviders = + workTargetEnvironmentId === null + ? undefined + : serverConfigs.get(workTargetEnvironmentId)?.providers; + if (scopedProviders !== undefined) { + return deriveProviderInstanceEntries(scopedProviders).find(isReadyHermesEntry) ?? null; + } + if (workTargetEnvironmentId !== primaryEnvironmentId) return null; + return [...providerEntryByInstanceId.values()].find(isReadyHermesEntry) ?? null; + }, [primaryEnvironmentId, providerEntryByInstanceId, serverConfigs, workTargetEnvironmentId]); + const t3WorkDirectory = t3WorkDirectoryForEnvironment(serverConfigs, workTargetEnvironmentId); + const hermesBackingProject = + projects.find( + (project) => + project.environmentId === workTargetEnvironmentId && + t3WorkDirectory !== null && + project.workspaceRoot === t3WorkDirectory, + ) ?? null; + const t3WorkProjectCreateRef = useRef(null); + useEffect(() => { + if ( + workspace !== "work" || + workTargetEnvironmentId === null || + t3WorkDirectory === null || + hermesProviderEntry === null || + hermesBackingProject !== null + ) { + return; + } + const createKey = `${workTargetEnvironmentId}:${t3WorkDirectory}`; + if (t3WorkProjectCreateRef.current === createKey) return; + t3WorkProjectCreateRef.current = createKey; + const hermesModel = + hermesProviderEntry.models.find((model) => model.slug === "default") ?? + hermesProviderEntry.models[0] ?? + null; + void (async () => { + const result = await createProject({ + environmentId: workTargetEnvironmentId, + input: { + projectId: T3_WORK_BACKING_PROJECT_ID, + title: T3_WORK_BACKING_PROJECT_TITLE, + workspaceRoot: t3WorkDirectory, + createWorkspaceRootIfMissing: true, + defaultModelSelection: + hermesModel === null + ? null + : { + instanceId: hermesProviderEntry.instanceId, + model: hermesModel.slug, + }, + }, + }); + t3WorkProjectCreateRef.current = null; + if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { + const error = squashAtomCommandFailure(result); + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Could not prepare T3 Work", + description: + error instanceof Error + ? error.message + : "The private T3 Work conversation directory could not be created.", + }), + ); + } + })(); + }, [ + createProject, + hermesBackingProject, + hermesProviderEntry, + t3WorkDirectory, + workTargetEnvironmentId, + workspace, + ]); + const [hermesImportOpen, setHermesImportOpen] = useState(false); + const providerDriverKindByInstance = useMemo(() => { + const result = new Map(); + for (const [environmentId, serverConfig] of serverConfigs) { + for (const provider of serverConfig.providers) { + result.set(sidebarProviderInstanceKey(environmentId, provider.instanceId), provider.driver); + } + } + return result; + }, [serverConfigs]); const projectCwdByKey = useMemo( () => new Map( @@ -1200,7 +1557,7 @@ export default function SidebarV2() { // hidden now, and bulk actions must never count or touch invisible rows. useEffect(() => { clearSelection(); - }, [clearSelection, projectScopeKey]); + }, [clearSelection, projectScopeKey, workEnvironmentScopeId]); const handleRemoveProjectMembers = useCallback( async (projectGroup: SidebarProjectSnapshot, members: readonly SidebarProjectGroupMember[]) => { @@ -1358,7 +1715,6 @@ export default function SidebarV2() { // the partition works directly off live shells: no archived-snapshot // merging, no optimistic holds. Archived threads remain hidden here — // archive keeps its original "remove from sidebar" meaning. - const serverConfigs = useAtomValue(environmentServerConfigsAtom); const { activeThreads, snoozedThreads, settledThreads, snoozeNow } = useMemo(() => { const now = `${nowMinute}:00.000Z`; // Snooze classification uses a REAL clock, not the quantized minute: @@ -1367,7 +1723,19 @@ export default function SidebarV2() { // memo exactly at the next wake boundary. void snoozeWakeTick; const preciseNow = new Date().toISOString(); - const visible = filterSidebarV2VisibleThreads(threads, scopedProjectKeys); + const visible = threads.filter( + (thread) => + isThreadVisibleInSidebarWorkspace( + thread, + workspace, + providerDriverKindByInstance, + hermesCodeProjectKeys, + ) && + (workspace === "work" + ? workEnvironmentScopeId === null || thread.environmentId === workEnvironmentScopeId + : scopedProjectKeys === null || + scopedProjectKeys.has(`${thread.environmentId}:${thread.projectId}`)), + ); const active: EnvironmentThreadShell[] = []; const snoozed: EnvironmentThreadShell[] = []; const settled: EnvironmentThreadShell[] = []; @@ -1385,9 +1753,13 @@ export default function SidebarV2() { // Snooze outranks settled classification: an explicitly snoozed thread // belongs to the shelf even if it would also auto-settle (the shelf's // wake time is a stronger statement about when it matters again). - if (supportsSnooze && effectiveSnoozed(thread, { now: preciseNow })) { + const fixedInMain = thread.workInboxRole === "main"; + const durablyPinned = thread.pinnedAt !== null; + if (!fixedInMain && supportsSnooze && effectiveSnoozed(thread, { now: preciseNow })) { snoozed.push(thread); } else if ( + !fixedInMain && + !durablyPinned && supportsSettlement && effectiveSettled(thread, { now, autoSettleAfterDays, changeRequestState }) ) { @@ -1397,7 +1769,9 @@ export default function SidebarV2() { } } return { - activeThreads: sortThreadsForSidebarV2(active), + activeThreads: sortThreadsForSidebarV2(active, (thread) => + pinnedThreadKeySet.has(scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id))), + ), // Soonest wake first: "what comes back next" is the shelf's question. snoozedThreads: snoozed.toSorted( (left, right) => @@ -1410,12 +1784,40 @@ export default function SidebarV2() { }, [ autoSettleAfterDays, changeRequestStateByKey, + hermesCodeProjectKeys, nowMinute, + pinnedThreadKeySet, + providerDriverKindByInstance, scopedProjectKeys, serverConfigs, snoozeWakeTick, threads, + workEnvironmentScopeId, + workspace, ]); + const { mainThreads, needsYouThreads, ordinaryActiveThreads } = useMemo(() => { + const main: EnvironmentThreadShell[] = []; + const needsYou: EnvironmentThreadShell[] = []; + const active: EnvironmentThreadShell[] = []; + for (const thread of activeThreads) { + switch (workInboxActiveSection(thread)) { + case "main": + main.push(thread); + break; + case "needs-you": + needsYou.push(thread); + break; + case "active": + active.push(thread); + break; + } + } + return { + mainThreads: main, + needsYouThreads: needsYou, + ordinaryActiveThreads: active, + }; + }, [activeThreads]); // Arm a timeout for the earliest upcoming wake so the shelf empties the // moment a snooze expires instead of on the next minute tick. Sorted @@ -1440,7 +1842,10 @@ export default function SidebarV2() { // filter context changes so a scope/search flip never inherits a deep // page state. const [settledVisibleCount, setSettledVisibleCount] = useState(SETTLED_TAIL_INITIAL_COUNT); - const settledResetKey = projectScopeKey ?? "all"; + const settledResetKey = + workspace === "work" + ? `work:${workEnvironmentScopeId ?? "all"}` + : `code:${projectScopeKey ?? "all"}`; const lastSettledResetKeyRef = useRef(settledResetKey); if (lastSettledResetKeyRef.current !== settledResetKey) { lastSettledResetKeyRef.current = settledResetKey; @@ -1589,6 +1994,98 @@ export default function SidebarV2() { }, [clearSelection, isMobile, router, setOpenMobile, setSelectionAnchor], ); + // The work-mode composer target: a fresh draft on the Hermes backing + // project. Returns false when Hermes is not ready so callers can fall back. + const openWorkComposer = useCallback((): boolean => { + const hermesModel = + hermesProviderEntry?.models.find((model) => model.slug === "default") ?? + hermesProviderEntry?.models[0] ?? + null; + if (!hermesBackingProject || !hermesProviderEntry || !hermesModel) return false; + if (isMobile) setOpenMobile(false); + void newThreadContext.handleNewThread( + scopeProjectRef(hermesBackingProject.environmentId, hermesBackingProject.id), + { + fresh: true, + modelSelection: { + instanceId: hermesProviderEntry.instanceId, + model: hermesModel.slug, + }, + }, + ); + return true; + }, [hermesBackingProject, hermesProviderEntry, isMobile, newThreadContext, setOpenMobile]); + const handleWorkspaceChange = useCallback( + (nextWorkspace: SidebarWorkspace) => { + if (nextWorkspace === workspace) return; + setWorkspace(nextWorkspace); + // A draft composer is only workspace-neutral when its project does not + // pin it to one side: a draft on the Hermes backing project is a Work + // composer and must not stay open when switching to Code. + const routeDraftWorkspace = (() => { + const target = routeTargetRef.current; + if (target?.kind !== "draft") return null; + const draft = useComposerDraftStore.getState().getDraftSession(target.draftId); + if (draft === null) return null; + const draftProject = + projects.find( + (project) => + project.environmentId === draft.environmentId && project.id === draft.projectId, + ) ?? null; + // Unknown project (still loading) stays neutral rather than guessing. + if (draftProject === null) return null; + return isT3WorkBackingProject(draftProject, serverConfigs) + ? ("work" as const) + : ("code" as const); + })(); + const navigation = resolveWorkspaceSwitchNavigation({ + nextWorkspace, + rememberedThreadKey: workspaceRoutes[nextWorkspace], + routeThreadKey, + routeDraftWorkspace, + threads: threads.map((thread) => ({ + ...thread, + threadKey: scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), + })), + providerDriverKindByInstance, + hermesCodeProjectKeys, + }); + if (navigation.kind === "remembered-thread") { + const rememberedThread = threads.find( + (thread) => + scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)) === + navigation.threadKey, + ); + if (rememberedThread) { + navigateToThread(scopeThreadRef(rememberedThread.environmentId, rememberedThread.id)); + } + return; + } + if (navigation.kind === "new-chat") { + if (nextWorkspace === "work") { + // The index route starts a Code draft; when the Work composer + // cannot open, staying put beats landing on a Code composer. + openWorkComposer(); + return; + } + void router.navigate({ to: "/" }); + } + }, + [ + hermesCodeProjectKeys, + navigateToThread, + openWorkComposer, + projects, + providerDriverKindByInstance, + routeThreadKey, + serverConfigs, + router, + setWorkspace, + threads, + workspace, + workspaceRoutes, + ], + ); const [renamingThreadKey, setRenamingThreadKey] = useState(null); const [renamingTitle, setRenamingTitle] = useState(""); @@ -1968,13 +2465,23 @@ export default function SidebarV2() { // clears the override, for auto-settled rows it pins the thread // active until real activity clears the pin. Environments without // the settlement capability get no lifecycle items at all. + const isWorkMain = thread.workInboxRole === "main"; const supportsSettlement = + !isWorkMain && serverConfigs.get(thread.environmentId)?.environment.capabilities.threadSettlement === - true; + true; const supportsSnooze = + !isWorkMain && serverConfigs.get(thread.environmentId)?.environment.capabilities.threadSnooze === true; const isSettled = settledThreadKeysRef.current.has(threadKey); const isSnoozed = snoozedThreadKeysRef.current.has(threadKey); + const isPinned = pinnedThreadKeySet.has(threadKey); + const canPin = canPinWorkInboxThread({ + thread, + providerDriverKindByInstance, + isSnoozed, + isSettled, + }); // Presets resolve at menu-open time (same as the popover). const snoozePresets = resolveSnoozePresets(new Date()); const clicked = await settlePromise(() => @@ -2010,6 +2517,14 @@ export default function SidebarV2() { }, ] : []), + ...(canPin + ? [ + { + id: isPinned ? "unpin" : "pin", + label: isPinned ? "Unpin thread" : "Pin thread", + }, + ] + : []), { id: "rename", label: "Rename thread" }, { id: "mark-unread", label: "Mark unread" }, { id: "delete", label: "Delete", destructive: true, icon: "trash" }, @@ -2058,6 +2573,10 @@ export default function SidebarV2() { case "unsnooze": attemptUnsnooze(threadRef); return; + case "pin": + case "unpin": + toggleThreadPin(threadRef); + return; case "rename": startThreadRename(threadRef, thread.title); return; @@ -2077,15 +2596,17 @@ export default function SidebarV2() { if (confirmed._tag === "Failure" || !confirmed.value) return; } const result = await deleteThread(threadRef); - if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { - const error = squashAtomCommandFailure(result); - toastManager.add( - stackedThreadToast({ - type: "error", - title: "Failed to delete thread", - description: error instanceof Error ? error.message : "An error occurred.", - }), - ); + if (result._tag === "Failure") { + if (!isAtomCommandInterrupted(result)) { + const error = squashAtomCommandFailure(result); + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Failed to delete thread", + description: error instanceof Error ? error.message : "An error occurred.", + }), + ); + } return; } return; @@ -2104,8 +2625,11 @@ export default function SidebarV2() { deleteThread, handleMultiSelectContextMenu, markThreadUnread, + pinnedThreadKeySet, + providerDriverKindByInstance, serverConfigs, startThreadRename, + toggleThreadPin, ], ); @@ -2185,6 +2709,17 @@ export default function SidebarV2() { // uses. The command palette already offers a "New thread in..." submenu // for multi-project setups. const handleNewThreadClick = useCallback(() => { + if (workspace === "work") { + if (!openWorkComposer()) { + toastManager.add({ + type: "warning", + title: "Hermes is not ready", + description: + "Enable and configure Hermes, then wait for T3 Work to finish preparing its private conversation directory.", + }); + } + return; + } // One project: nothing to pick, create immediately. if (projectGroups.length <= 1) { if (isMobile) setOpenMobile(false); @@ -2198,7 +2733,14 @@ export default function SidebarV2() { } if (isMobile) setOpenMobile(false); openCommandPalette({ open: "new-thread-in" }); - }, [isMobile, newThreadContext, projectGroups.length, setOpenMobile]); + }, [ + isMobile, + newThreadContext, + openWorkComposer, + projectGroups.length, + setOpenMobile, + workspace, + ]); const commandPaletteShortcutLabel = shortcutLabelForCommand(keybindings, "commandPalette.toggle"); // Same resolution as v1: prefer the local-thread binding, fall back to @@ -2208,7 +2750,15 @@ export default function SidebarV2() { shortcutLabelForCommand(keybindings, "chat.new"); return ( <> - + + } + />
    @@ -2233,6 +2783,27 @@ export default function SidebarV2() { ) : null}
    +
    + {workspace === "work" ? ( + + setHermesImportOpen(true)} + disabled={hermesProviderEntry === null || hermesBackingProject === null} + aria-label="Import Hermes conversations" + /> + } + > + + + Import Hermes conversations + + ) : null} +
    } @@ -2260,7 +2835,7 @@ export default function SidebarV2() {
    - {projectGroups.length > 0 ? ( + {workspace === "code" && projectGroups.length > 0 ? (
    @@ -2353,6 +2928,52 @@ export default function SidebarV2() {
    ) : null} + {workspace === "work" && environments.length > 1 ? ( + + + + + + {(workEnvironmentScopeId !== null + ? environmentLabelById.get(workEnvironmentScopeId) + : null) ?? "All environments"} + + + + + + setWorkEnvironmentScopeId(value === "all" ? null : (value as EnvironmentId)) + } + > + + + All environments + + {environments.map((environment) => ( + + + {environment.label} + + ))} + + + + + ) : null} ); }; - const items: ReactNode[] = activeThreads.map((thread) => - renderThreadRow(thread, "active"), - ); + const items: ReactNode[] = []; + const addWorkSection = ( + key: string, + label: string, + sectionThreads: readonly EnvironmentThreadShell[], + tone: "default" | "attention" = "default", + ) => { + if (sectionThreads.length === 0) return; + items.push( +
  • +
    + + {label} + + +
    +
  • , + ); + for (const thread of sectionThreads) { + items.push(renderThreadRow(thread, "active")); + } + }; + if (workspace === "work") { + addWorkSection("main", "Main", mainThreads); + addWorkSection("needs-you", "Needs you", needsYouThreads, "attention"); + addWorkSection("active", "Active", ordinaryActiveThreads); + } else { + for (const thread of activeThreads) { + items.push(renderThreadRow(thread, "active")); + } + } // Snoozed shelf: between the inbox and Settled — out of the // way, never gone. The header always renders while anything // is snoozed (the count is the whole footprint when @@ -2721,6 +3397,14 @@ export default function SidebarV2() { + ); diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 596848561ea..aebeff8f244 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -17,7 +17,6 @@ import { ProviderDriverKind, ProviderInstanceId, PROVIDER_SEND_TURN_MAX_ATTACHMENTS, - PROVIDER_SEND_TURN_MAX_IMAGE_BYTES, } from "@t3tools/contracts"; import type { EnvironmentConnectionPresentation } from "@t3tools/client-runtime/connection"; import { serializeComposerFileLink } from "@t3tools/shared/composerTrigger"; @@ -49,6 +48,7 @@ import { makeComposerMentionDragHandlers, } from "./composerMentionDrag"; import { + type ComposerAttachment, type ComposerImageAttachment, type DraftId, type PersistedComposerImageAttachment, @@ -174,12 +174,18 @@ import { toastManager } from "../ui/toast"; import { BotIcon, CircleAlertIcon, + EraserIcon, + FileIcon, + FileTextIcon, ListTodoIcon, PencilRulerIcon, type LucideIcon, LockIcon, LockOpenIcon, PenLineIcon, + PlusIcon, + PaperclipIcon, + SquarePenIcon, SparklesIcon, XIcon, } from "lucide-react"; @@ -212,8 +218,11 @@ import { formatProviderSkillDisplayName } from "../../providerSkillPresentation" import { searchProviderSkills } from "../../providerSkillSearch"; import { useMediaQuery } from "../../hooks/useMediaQuery"; import type { ReviewCommentContext } from "../../reviewCommentContext"; - -const IMAGE_SIZE_LIMIT_LABEL = `${Math.round(PROVIDER_SEND_TURN_MAX_IMAGE_BYTES / (1024 * 1024))}MB`; +import { + composerAttachmentAccept, + validateComposerAttachment, +} from "./composerAttachmentValidation"; +import { Menu, MenuItem, MenuPopup, MenuTrigger } from "../ui/menu"; const runtimeModeConfig: Record< RuntimeMode, @@ -510,7 +519,7 @@ export interface ChatComposerHandle { /** Get the current prompt/effort/model state for use in send. */ getSendContext: () => { prompt: string; - images: ComposerImageAttachment[]; + images: ComposerAttachment[]; terminalContexts: TerminalContextDraft[]; elementContexts: ElementContextDraft[]; previewAnnotations: PreviewAnnotationPayload[]; @@ -542,6 +551,7 @@ export interface ChatComposerProps { activeThread: Thread | undefined; isServerThread: boolean; isLocalDraftThread: boolean; + isProjectlessConversation: boolean; forceExpandedOnMobile: boolean; projectSelectionRequired: boolean; @@ -602,7 +612,7 @@ export interface ChatComposerProps { // Refs the parent needs kept in sync promptRef: React.RefObject; - composerImagesRef: React.RefObject; + composerImagesRef: React.RefObject; composerTerminalContextsRef: React.RefObject; composerElementContextsRef: React.RefObject; composerRef: React.RefObject; @@ -613,6 +623,8 @@ export interface ChatComposerProps { // Callbacks onSend: (e?: { preventDefault: () => void }, dispatchMode?: ComposerDispatchMode) => void; + onStartFreshChat: () => void; + onClearChat: () => void; onInterrupt: () => void; onImplementPlanInNewThread: () => void; onRespondToApproval: ( @@ -659,6 +671,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) activeThread, isServerThread: _isServerThread, isLocalDraftThread: _isLocalDraftThread, + isProjectlessConversation, forceExpandedOnMobile, projectSelectionRequired, phase, @@ -701,6 +714,8 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) shouldAutoScrollRef, scheduleStickToBottom, onSend, + onStartFreshChat, + onClearChat, onInterrupt, onImplementPlanInNewThread, onRespondToApproval, @@ -1032,6 +1047,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) * thread) can still be stashed while an earlier encode is running. */ const stashInFlightRef = useRef>(new Set()); + const attachmentInputRef = useRef(null); // ------------------------------------------------------------------ // Derived: composer send state @@ -1290,14 +1306,14 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) ); const addComposerImage = useCallback( - (image: ComposerImageAttachment) => { + (image: ComposerAttachment) => { addComposerDraftImage(composerDraftTarget, image); }, [composerDraftTarget, addComposerDraftImage], ); const addComposerImagesToDraft = useCallback( - (images: ComposerImageAttachment[]) => { + (images: ComposerAttachment[]) => { addComposerDraftImages(composerDraftTarget, images); }, [composerDraftTarget, addComposerDraftImages], @@ -1522,6 +1538,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) try { const dataUrl = await readFileAsDataUrl(image.file); stagedAttachmentById.set(image.id, { + type: image.type, id: image.id, name: image.name, mimeType: image.mimeType, @@ -2342,32 +2359,29 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) if (pendingUserInputs.length > 0) { toastManager.add({ type: "error", - title: "Attach images after answering plan questions.", + title: "Attach files after answering plan questions.", }); return; } - const nextImages: ComposerImageAttachment[] = []; + const nextImages: ComposerAttachment[] = []; let nextImageCount = composerImagesRef.current.length; let error: string | null = null; for (const file of files) { - if (!file.type.startsWith("image/")) { - error = `Unsupported file type for '${file.name}'. Please attach image files only.`; - continue; - } - if (file.size > PROVIDER_SEND_TURN_MAX_IMAGE_BYTES) { - error = `'${file.name}' exceeds the ${IMAGE_SIZE_LIMIT_LABEL} attachment limit.`; + const validation = validateComposerAttachment(file, selectedProvider); + if (!validation.accepted) { + error = validation.message; continue; } if (nextImageCount >= PROVIDER_SEND_TURN_MAX_ATTACHMENTS) { - error = `You can attach up to ${PROVIDER_SEND_TURN_MAX_ATTACHMENTS} images per message.`; + error = `You can attach up to ${PROVIDER_SEND_TURN_MAX_ATTACHMENTS} files per message.`; break; } const previewUrl = URL.createObjectURL(file); nextImages.push({ - type: "image", + type: validation.type, id: randomUUID(), name: file.name || "image", - mimeType: file.type, + mimeType: validation.mimeType, sizeBytes: file.size, previewUrl, file, @@ -2392,10 +2406,8 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) const onComposerPaste = (event: React.ClipboardEvent) => { const files = Array.from(event.clipboardData.files); if (files.length === 0) return; - const imageFiles = files.filter((file) => file.type.startsWith("image/")); - if (imageFiles.length === 0) return; event.preventDefault(); - addComposerImages(imageFiles); + addComposerImages(files); }; const onComposerDragEnter = (event: React.DragEvent) => { @@ -2922,7 +2934,10 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) composerPreviewAnnotations.length > 0 && ( + attachment.type === "image", + )} onRemove={(annotationId) => removeComposerDraftPreviewAnnotation(composerDraftTarget, annotationId) } @@ -2980,7 +2995,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) key={image.id} className="relative h-16 w-16 overflow-hidden rounded-lg border border-border/80 bg-background" > - {image.previewUrl ? ( + {image.type === "image" && image.previewUrl ? ( + ) : image.type === "video" && image.previewUrl ? ( +