diff --git a/apps/desktop/electron/profile-delete-routing.test.ts b/apps/desktop/electron/profile-delete-routing.test.ts index b7f3b97e43..236d4b3fa1 100644 --- a/apps/desktop/electron/profile-delete-routing.test.ts +++ b/apps/desktop/electron/profile-delete-routing.test.ts @@ -156,6 +156,7 @@ test('resolveRouteProfile preserves a primary-backend route from another routing test('explicit registered local DELETE holds one gate through teardown and dispatch', async () => { const events: string[] = [] const gate = new ProfileDeletionGate() + const result = await dispatchConnectionScopedProfileDelete( { connectionId: 'local', method: 'DELETE', path: '/api/profiles/worker', profile: 'worker' }, { @@ -163,6 +164,7 @@ test('explicit registered local DELETE holds one gate through teardown and dispa acquire: profile => { events.push(`gate:${profile}`) const release = gate.acquire(profile) + return () => { events.push(`release:${profile}`) release() @@ -172,6 +174,7 @@ test('explicit registered local DELETE holds one gate through teardown and dispa dispatch: async routeProfile => { assert.equal(gate.blocks('worker'), true) events.push(`dispatch:${routeProfile ?? 'primary'}`) + return 'deleted' }, prepareLocal: async request => { @@ -193,17 +196,20 @@ test('explicit registered local DELETE holds one gate through teardown and dispa test('explicit registered SSH DELETE tears down its source backend before dispatch', async () => { const events: string[] = [] + const result = await dispatchConnectionScopedProfileDelete( { connectionId: 'build-host', method: 'DELETE', path: '/api/profiles/worker', profile: 'worker' }, { ...deps, acquire: profile => { events.push(`gate:${profile}`) + return () => events.push(`release:${profile}`) }, connectionKind: () => 'ssh', dispatch: async routeProfile => { events.push(`dispatch:${routeProfile ?? 'primary'}`) + return 'deleted' }, prepareLocal: async () => assert.fail('SSH deletion must not use local profile teardown'), @@ -224,6 +230,7 @@ test('explicit registered SSH DELETE tears down its source backend before dispat test('non-identity connection DELETE tears down the logical pool and deletes the backend target', async () => { const events: string[] = [] + const request = { connectionId: 'source-a', method: 'DELETE', @@ -235,11 +242,13 @@ test('non-identity connection DELETE tears down the logical pool and deletes the ...deps, acquire: profile => { events.push(`gate:${profile}`) + return () => events.push(`release:${profile}`) }, connectionKind: () => 'ssh', dispatch: async routeProfile => { events.push(`delete:${request.path}:${routeProfile ?? 'primary'}`) + return 'deleted' }, prepareLocal: async () => assert.fail('remote deletion must not use local profile teardown'), @@ -266,11 +275,13 @@ test('connection-scoped default DELETE rejects before gate, preparation, teardow { acquire: profile => { events.push(`gate:${profile}`) + return () => events.push(`release:${profile}`) }, connectionKind: () => 'ssh', dispatch: async () => { events.push('dispatch') + return 'deleted' }, isDefaultProfile: profile => profile === 'default', @@ -298,11 +309,13 @@ test('connection-scoped invalid-name DELETE rejects before gate, preparation, te { acquire: profile => { events.push(`gate:${profile}`) + return () => events.push(`release:${profile}`) }, connectionKind: () => 'local', dispatch: async () => { events.push('dispatch') + return 'deleted' }, isDefaultProfile: profile => profile === 'default', diff --git a/apps/desktop/src/app/chat/index.tsx b/apps/desktop/src/app/chat/index.tsx index 1a7043c3cd..b0965a6662 100644 --- a/apps/desktop/src/app/chat/index.tsx +++ b/apps/desktop/src/app/chat/index.tsx @@ -33,8 +33,8 @@ import { $petActive } from '@/store/pet' import { $petOverlayActive } from '@/store/pet-overlay' import { $activeGatewayProfile, $gatewaySwapTarget, $profiles } from '@/store/profile' import { - $contextSuggestions, $connection, + $contextSuggestions, $freshDraftReady, $gatewayState, $introPersonality, @@ -272,12 +272,15 @@ function ChatRuntimeBoundary({ // appears/retires as tail hydrations and backfill pages record their state. const transcriptTailStates = useStore($transcriptTailBySessionId) const connectionId = connection?.connectionId || (connection?.mode === 'local' ? 'local' : '') + const ownerRoute = storedId ? getSessionOwnerHint(storedId, connectionId ? { connectionId, profile: activeProfile } : undefined) : undefined + const tailProfile = ownerRoute ? { connectionId: ownerRoute.connectionId, profile: ownerRoute.targetProfile || ownerRoute.profile } : undefined + const tailState = storedId && transcriptTailStates ? transcriptTailState(storedId, tailProfile) : undefined const restBackfillAvailable = Boolean(tailState?.possiblyTruncated) diff --git a/apps/desktop/src/app/chat/transcript-backfill.test.ts b/apps/desktop/src/app/chat/transcript-backfill.test.ts index 5a020b51ef..733c114cc6 100644 --- a/apps/desktop/src/app/chat/transcript-backfill.test.ts +++ b/apps/desktop/src/app/chat/transcript-backfill.test.ts @@ -74,6 +74,7 @@ describe('transcript tail bookkeeping', () => { messages: [row(1, 'tail')], pagination: { limit: 120, offset: 0, order: 'latest' as const, returned: 1 } } + const sourceA = { connectionId: 'source-a', profile: 'backend' } const sourceB = { connectionId: 'source-b', profile: 'backend' } @@ -91,6 +92,7 @@ describe('transcript tail bookkeeping', () => { messages: [row(1, 'tail')], pagination: { limit: 120, offset: 0, order: 'latest' as const, returned: 1 } } + const scope = { connectionId: 'source-a', profile: 'backend' } for (let index = 0; index < 257; index += 1) { @@ -202,10 +204,12 @@ describe('backfillOlderTranscriptPage', () => { it('backfills the matching connection when two owners share one session id', async () => { const sourceA = { connectionId: 'source-a', profile: 'backend-a' } const sourceB = { connectionId: 'source-b', profile: 'backend-b' } + const page = { messages: Array.from({ length: 120 }, (_, index) => row(index, `tail${index}`)), pagination: { limit: 120, offset: 0, order: 'latest' as const, returned: 120 } } + recordTranscriptTail('same-session', page, sourceA) recordTranscriptTail('same-session', page, sourceB) vi.mocked(getOlderSessionMessages).mockResolvedValue({ diff --git a/apps/desktop/src/app/session/hooks/use-route-resume.test.tsx b/apps/desktop/src/app/session/hooks/use-route-resume.test.tsx index ca27979e95..c33eeb2cbb 100644 --- a/apps/desktop/src/app/session/hooks/use-route-resume.test.tsx +++ b/apps/desktop/src/app/session/hooks/use-route-resume.test.tsx @@ -352,13 +352,16 @@ describe('useRouteResume', () => { const creatingSessionRef = { current: false } const runtimeIdByStoredSessionIdRef = { current: new Map([['session-1', 'runtime-1']]) } const selectedStoredSessionIdRef: MutableRefObject = { current: 'session-1' } + const ownerRoute: SessionProfileRoute = { connectionId: 'source-a', mode: 'remote', profile: 'worker', targetProfile: 'backend-worker' } + const request = { ownerRoute, sequence: 1, sessionId: 'session-1' } + const props = { activeSessionId: 'runtime-1', activeSessionIdRef, diff --git a/apps/desktop/src/app/session/hooks/use-route-resume.ts b/apps/desktop/src/app/session/hooks/use-route-resume.ts index af51055dec..ee135d3303 100644 --- a/apps/desktop/src/app/session/hooks/use-route-resume.ts +++ b/apps/desktop/src/app/session/hooks/use-route-resume.ts @@ -178,8 +178,10 @@ export function useRouteResume({ } bootResumeRef.current = false + const ownerRoute = sessionResumeRequest?.sessionId === routedSessionId ? sessionResumeRequest.ownerRoute : undefined + if (ownerRoute) { void resumeSession(routedSessionId, true, ownerRoute) } else { diff --git a/apps/desktop/src/app/session/hooks/use-session-actions.test.tsx b/apps/desktop/src/app/session/hooks/use-session-actions.test.tsx index 7614a5c1f5..d21790c4b0 100644 --- a/apps/desktop/src/app/session/hooks/use-session-actions.test.tsx +++ b/apps/desktop/src/app/session/hooks/use-session-actions.test.tsx @@ -16,14 +16,13 @@ import { } from '@/hermes' import { createClientSessionState } from '@/lib/chat-runtime' import { clearSessionDraft, stashSessionDraft, takeSessionDraft } from '@/store/composer' +import { requestGatewayForAgent } from '@/store/gateway' import { $activeGatewayProfile, $newChatProfile, $newChatRoute, ensureGatewayProfile } from '@/store/profile' -import { requestGatewayForAgent } from '@/store/gateway' -import type { SessionProfileRoute } from '@/store/session-request-router' import { $projectScope, $projectTree, ALL_PROJECTS } from '@/store/projects' import { $activeSessionId, @@ -54,6 +53,7 @@ import { setSessions, setTurnStartedAt } from '@/store/session' +import type { SessionProfileRoute } from '@/store/session-request-router' import { $sessionTiles } from '@/store/session-states' import sessionResumeActiveTurn from '../../../../../../tests/fixtures/session-resume-active-turn.json' @@ -600,6 +600,7 @@ describe('createBackendSessionForSend profile routing', () => { profile: 'default', targetProfile: 'backend-default' } + const ambientRequest = vi.fn(async () => ({}) as never) vi.mocked(requestGatewayForAgent).mockResolvedValueOnce({ session_id: RUNTIME_SESSION_ID, @@ -1812,12 +1813,15 @@ describe('resumeSession warm-cache mapping integrity', () => { profile: 'default', targetProfile: 'backend-default' } + const runtimeIdByStoredSessionIdRef: MutableRefObject> = { current: new Map([['stored-warm', 'runtime-warm']]) } + const sessionStateByRuntimeIdRef: MutableRefObject> = { current: new Map([['runtime-warm', clientState('stored-warm')]]) } + // Same-name rows without a source tag are not authoritative for an // explicit owner. Metadata must be re-read from the captured connection. setSessions([storedSession({ id: 'stored-warm', profile: 'default' })]) @@ -1845,11 +1849,13 @@ describe('resumeSession warm-cache mapping integrity', () => { }) const ambientRequest = vi.fn(async () => ({}) as never) + let resume: null | (( storedSessionId: string, replaceRoute?: boolean, ownerRoute?: SessionProfileRoute ) => Promise) = null + render( (resume = ready)} diff --git a/apps/desktop/src/app/session/hooks/use-session-actions/index.ts b/apps/desktop/src/app/session/hooks/use-session-actions/index.ts index 001e82b656..7d2f11221e 100644 --- a/apps/desktop/src/app/session/hooks/use-session-actions/index.ts +++ b/apps/desktop/src/app/session/hooks/use-session-actions/index.ts @@ -46,6 +46,7 @@ import { $newChatWorkspaceTarget, $sessions, $yoloActive, + getSessionOwnerHint, type NewChatWorkspaceTarget, resolveComposerSessionKey, sessionPinId, @@ -67,7 +68,6 @@ import { setSelectedStoredSessionId, setSessions, setSessionStartedAt, - getSessionOwnerHint, setTurnStartedAt, setWorkspaceCwdOwner, setYoloActive @@ -473,6 +473,7 @@ export function useSessionActions({ const capturedRoute = $newChatRoute.get() const params = await desktopSessionCreateParams(cwd, capturedRoute) + const created = capturedRoute ? await requestGatewayForAgent( capturedRoute.connectionId, @@ -481,6 +482,7 @@ export function useSessionActions({ params ) : await requestGateway('session.create', params) + const stored = created.stored_session_id ?? null // Only a genuine move to a DIFFERENT chat mid-create should orphan the @@ -597,6 +599,7 @@ export function useSessionActions({ // (project scope → configured default). const capturedRoute = $newChatRoute.get() const params = await desktopSessionCreateParams((options?.cwd || resolveNewSessionCwd()).trim(), capturedRoute) + const created = capturedRoute ? await requestGatewayForAgent( capturedRoute.connectionId, @@ -605,6 +608,7 @@ export function useSessionActions({ params ) : await requestGateway('session.create', params) + const stored = created.stored_session_id if (!stored) { @@ -1785,12 +1789,14 @@ export function useSessionActions({ const closingRuntimeId = wasSelected ? activeSessionId : null const previousMessages = $messages.get() const previousPinned = $pinnedSessionIds.get() + const removedOwner: SessionOwnerScope = removed?.connection_id ? { connectionId: removed.connection_id, profile: removed.profile || 'default' } : removed?.profile + const previousArchived = $archivedSessions.get() // Pins are keyed on the durable lineage-root id; the stored id may be the // live tip after compression. Drop both so the pin can't linger. diff --git a/apps/desktop/src/app/session/hooks/use-session-actions/utils.ts b/apps/desktop/src/app/session/hooks/use-session-actions/utils.ts index d552c8bef7..f2e80ba758 100644 --- a/apps/desktop/src/app/session/hooks/use-session-actions/utils.ts +++ b/apps/desktop/src/app/session/hooks/use-session-actions/utils.ts @@ -1317,6 +1317,7 @@ export async function resolveStoredSession( connectionId: ownerRoute.connectionId, profile: ownerRoute.targetProfile || ownerRoute.profile } + const cachedOwnerMatches = cached && cached.connection_id === ownerRoute.connectionId && (!cached.profile || normalizeProfileKey(cached.profile) === normalizeProfileKey(ownerRoute.profile)) diff --git a/apps/desktop/src/plugins/hermes-bots/tests/remote-routing-races.test.mjs b/apps/desktop/src/plugins/hermes-bots/tests/remote-routing-races.test.mjs index f53786c4e8..4f6da3d2c0 100644 --- a/apps/desktop/src/plugins/hermes-bots/tests/remote-routing-races.test.mjs +++ b/apps/desktop/src/plugins/hermes-bots/tests/remote-routing-races.test.mjs @@ -16,7 +16,7 @@ function deferred() { return { promise, resolve, reject } } -function load({ requestProfile, agents, profileRoutes, storage } = {}) { +function load({ requestProfile, agents, profileRoutes } = {}) { const values = new Map() const atom = initial => { const slot = { diff --git a/apps/desktop/src/sdk/profile-routing.test.ts b/apps/desktop/src/sdk/profile-routing.test.ts index d6a041b965..6ab9fa07fc 100644 --- a/apps/desktop/src/sdk/profile-routing.test.ts +++ b/apps/desktop/src/sdk/profile-routing.test.ts @@ -274,6 +274,7 @@ describe('connection-aware plugin host APIs', () => { profile: 'worker', targetProfile: 'backend-worker' } + vi.mocked(deleteProfile).mockResolvedValueOnce({ ok: true, path: '/profiles/backend-worker' }) await host.deleteProfile(route) @@ -319,6 +320,7 @@ describe('connection-aware plugin host APIs', () => { profile: 'worker', targetProfile: 'backend-worker' } + vi.mocked(deleteProfile).mockResolvedValueOnce({ ok: true, path: '/profiles/backend-worker' }) await host.deleteProfile(route) diff --git a/apps/desktop/src/store/session-request-router.test.ts b/apps/desktop/src/store/session-request-router.test.ts index 5883c8f169..05b4dae4f0 100644 --- a/apps/desktop/src/store/session-request-router.test.ts +++ b/apps/desktop/src/store/session-request-router.test.ts @@ -149,15 +149,19 @@ describe('requestForSessionProfile', () => { setPrimaryGateway(primary as never, 'default') installDesktop() await ensureGatewayForProfile('other') + const desktop = (window as unknown as { hermesDesktop: { getConnectionFor: ReturnType } }).hermesDesktop + const ambient = vi.fn(async () => ({ ambient: true })) + const routeA = { connectionId: 'source-a', profile: 'default', targetProfile: 'backend-a' } + const routeB = { connectionId: 'source-b', profile: 'default', @@ -168,9 +172,11 @@ describe('requestForSessionProfile', () => { profile: 'default', session_id: 'stored-a' }) + routeA.connectionId = 'source-b' routeA.targetProfile = 'mutated-after-dispatch' await ensureGatewayForProfile('default') + const fromB = requestForSessionProfile(routeB, ambient as never, 'session.resume', { profile: 'default', session_id: 'stored-b' diff --git a/apps/desktop/src/store/session.test.ts b/apps/desktop/src/store/session.test.ts index a351878977..f4e0e7487b 100644 --- a/apps/desktop/src/store/session.test.ts +++ b/apps/desktop/src/store/session.test.ts @@ -39,8 +39,8 @@ import { setCurrentCwdTransient, setRememberedRoute, setRememberedSessionId, - setSessionOwnerHint, setSelectedStoredSessionId, + setSessionOwnerHint, setSessions, shouldMigrateComposerScope, touchSessionActivity,