Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
119 changes: 119 additions & 0 deletions apps/desktop/src/app/session/hooks/use-session-actions.test.tsx
Original file line number Diff line number Diff line change
@@ -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<Record<string, unknown>>()),
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<string | null>) => void
requestGateway: <T>(method: string, params?: Record<string, unknown>) => Promise<T>
}) {
const ref = <T,>(value: T): MutableRefObject<T> => ({ current: value })

const actions = useSessionActions({
activeSessionId: null,
activeSessionIdRef: ref<string | null>(null),
busyRef: ref(false),
creatingSessionRef: ref(false),
ensureSessionState: () => ({}) as ClientSessionState,
getRouteToken: () => 'token',
navigate: vi.fn() as never,
requestGateway,
runtimeIdByStoredSessionIdRef: ref(new Map<string, string>()),
selectedStoredSessionId: null,
selectedStoredSessionIdRef: ref<string | null>(null),
sessionStateByRuntimeIdRef: ref(new Map<string, ClientSessionState>()),
syncSessionStateToView: vi.fn(),
updateSessionState: () => ({}) as ClientSessionState
})

useEffect(() => {
onReady(actions.createBackendSessionForSend)
}, [actions.createBackendSessionForSend, onReady])

return null
}

async function createWith(profileSetup: () => void): Promise<Record<string, unknown> | undefined> {
let createParams: Record<string, unknown> | undefined

const requestGateway = vi.fn(async (method: string, params?: Record<string, unknown>) => {
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<string | null>) | null = null
render(<Harness onReady={c => (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' })
})
})
16 changes: 10 additions & 6 deletions apps/desktop/src/app/session/hooks/use-session-actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<SessionCreateResponse>('session.create', {
cols: 96,
Expand Down
Loading