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 new file mode 100644 index 00000000000..739e8b93756 --- /dev/null +++ b/apps/desktop/src/app/session/hooks/use-session-actions.test.tsx @@ -0,0 +1,119 @@ +import { cleanup, render, waitFor } from '@testing-library/react' +import type { MutableRefObject } from 'react' +import { useEffect } from 'react' +import { afterEach, describe, expect, it, vi } from 'vitest' + +import { $activeGatewayProfile, $newChatProfile } from '@/store/profile' +import { $currentCwd } from '@/store/session' + +import type { ClientSessionState } from '../../types' + +import { useSessionActions } from './use-session-actions' + +vi.mock('@/hermes', async importOriginal => ({ + ...(await importOriginal>()), + deleteSession: vi.fn(), + getSessionMessages: vi.fn(), + listAllProfileSessions: vi.fn(), + setApiRequestProfile: vi.fn(), + setSessionArchived: vi.fn() +})) + +const RUNTIME_SESSION_ID = 'rt-new-001' + +function Harness({ + onReady, + requestGateway +}: { + onReady: (create: (preview?: string | null) => Promise) => void + requestGateway: (method: string, params?: Record) => Promise +}) { + const ref = (value: T): MutableRefObject => ({ current: value }) + + const actions = useSessionActions({ + activeSessionId: null, + activeSessionIdRef: ref(null), + busyRef: ref(false), + creatingSessionRef: ref(false), + ensureSessionState: () => ({}) as ClientSessionState, + getRouteToken: () => 'token', + navigate: vi.fn() as never, + requestGateway, + runtimeIdByStoredSessionIdRef: ref(new Map()), + selectedStoredSessionId: null, + selectedStoredSessionIdRef: ref(null), + sessionStateByRuntimeIdRef: ref(new Map()), + syncSessionStateToView: vi.fn(), + updateSessionState: () => ({}) as ClientSessionState + }) + + useEffect(() => { + onReady(actions.createBackendSessionForSend) + }, [actions.createBackendSessionForSend, onReady]) + + return null +} + +async function createWith(profileSetup: () => void): Promise | undefined> { + let createParams: Record | undefined + + const requestGateway = vi.fn(async (method: string, params?: Record) => { + if (method === 'session.create') { + createParams = params + + return { session_id: RUNTIME_SESSION_ID, stored_session_id: null } as never + } + + return {} as never + }) + + $currentCwd.set('') + profileSetup() + + let create: ((preview?: string | null) => Promise) | null = null + render( (create = c)} requestGateway={requestGateway} />) + await waitFor(() => expect(create).not.toBeNull()) + await create!() + + return createParams +} + +describe('createBackendSessionForSend profile routing', () => { + afterEach(() => { + cleanup() + $newChatProfile.set(null) + $activeGatewayProfile.set('default') + vi.restoreAllMocks() + }) + + it('routes a plain new chat (no explicit profile) to the live gateway profile', async () => { + // The "rubberband to default" bug: the top New Session button clears + // $newChatProfile to null. In global-remote mode one backend serves every + // profile, so an omitted `profile` lands the chat on the launch (default) + // profile. The session must instead carry the active gateway profile. + const params = await createWith(() => { + $activeGatewayProfile.set('coder') + $newChatProfile.set(null) + }) + + expect(params).toMatchObject({ profile: 'coder' }) + }) + + it('honours an explicit per-profile "+" selection', async () => { + const params = await createWith(() => { + $activeGatewayProfile.set('coder') + $newChatProfile.set('analyst') + }) + + expect(params).toMatchObject({ profile: 'analyst' }) + }) + + it('passes the default profile for single-profile users (backend resolves it to launch)', async () => { + const params = await createWith(() => { + $activeGatewayProfile.set('default') + $newChatProfile.set(null) + }) + + expect(params).toMatchObject({ profile: 'default' }) + }) +}) diff --git a/apps/desktop/src/app/session/hooks/use-session-actions.ts b/apps/desktop/src/app/session/hooks/use-session-actions.ts index 00350538711..a4a2feaaacb 100644 --- a/apps/desktop/src/app/session/hooks/use-session-actions.ts +++ b/apps/desktop/src/app/session/hooks/use-session-actions.ts @@ -407,13 +407,17 @@ export function useSessionActions({ creatingSessionRef.current = true try { - // Route the new chat to the chosen profile's backend (null = primary, - // so single-profile users are unaffected). - await ensureGatewayProfile($newChatProfile.get()) + // A plain new session (top "New Session", /new, keybind) leaves + // $newChatProfile null to mean "use the live context"; the per-profile + // "+" sets it explicitly. Resolve null to the active gateway profile so + // session.create always carries it: in global-remote mode one backend + // serves every profile, so an omitted profile param silently lands the + // chat on the launch (default) profile — the "rubberbands back to + // default" bug. This is a no-op for single-profile/local-pooled users: + // a backend resolves its own launch profile to None (_profile_home). + const newChatProfile = $newChatProfile.get() ?? normalizeProfileKey($activeGatewayProfile.get()) + await ensureGatewayProfile(newChatProfile) const cwd = $currentCwd.get().trim() || workspaceCwdForNewSession() - // Pass the owning profile so a new chat under a non-launch profile (global - // remote mode) builds its agent + persists against THAT profile's home/db. - const newChatProfile = $newChatProfile.get() const created = await requestGateway('session.create', { cols: 96,