Skip to content
Closed
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
56 changes: 56 additions & 0 deletions apps/desktop/src/app/contrib/hooks/use-background-sync.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { renderHook } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'

import { $activeSessionId } from '@/store/session'

import { useBackgroundSync } from './use-background-sync'

vi.mock('@/store/profile', () => ({ refreshActiveProfile: vi.fn() }))

function setup(overrides: Partial<Parameters<typeof useBackgroundSync>[0]> = {}) {
const params: Parameters<typeof useBackgroundSync>[0] = {
activeIsMessaging: false,
activeSessionId: null,
freshDraftReady: false,
gatewayState: 'open',
refreshActiveMessagingTranscript: vi.fn(),
refreshCronJobs: vi.fn(),
refreshCurrentModel: vi.fn(),
refreshHermesConfig: vi.fn(),
refreshMessagingSessions: vi.fn(),
refreshSessions: vi.fn(),
requestGateway: vi.fn(),
...overrides
}

renderHook(() => useBackgroundSync(params))

return params
}

describe('useBackgroundSync model reseeding', () => {
beforeEach(() => {
$activeSessionId.set(null)
})

it('force-reseeds the composer when booting onto a fresh draft', () => {
const { refreshCurrentModel } = setup()

expect(refreshCurrentModel).toHaveBeenCalledWith(true)
})

it('does not force-reseed when booting with a live session', () => {
$activeSessionId.set('runtime-1')
const { refreshCurrentModel } = setup({ activeSessionId: 'runtime-1' })

expect(refreshCurrentModel).toHaveBeenCalledTimes(1)
expect(refreshCurrentModel).toHaveBeenCalledWith(false)
})

it('force-reseeds when a new-session draft becomes ready', () => {
const { refreshCurrentModel, refreshHermesConfig } = setup({ freshDraftReady: true })

expect(refreshCurrentModel).toHaveBeenCalledWith(true)
expect(refreshHermesConfig).toHaveBeenCalledOnce()
})
})
6 changes: 4 additions & 2 deletions apps/desktop/src/app/contrib/hooks/use-background-sync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,9 @@ export function useBackgroundSync({
return
}

void refreshCurrentModel()
// Opening onto a fresh draft must discard the previous chat's sticky
// composer pick. A live session keeps its session-scoped selection.
void refreshCurrentModel(!$activeSessionId.get())
void refreshActiveProfile()
void refreshSessions()

Expand Down Expand Up @@ -130,7 +132,7 @@ export function useBackgroundSync({
// model + config so the composer pill reflects the profile default.
useEffect(() => {
if (gatewayState === 'open' && !activeSessionId && freshDraftReady) {
void refreshCurrentModel()
void refreshCurrentModel(true)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

force bypasses both manual-selection checks in use-model-controls.ts:61,67; because the global-model lookup is async, a picker selection made after this call starts can be overwritten by its later result. Please tag this refresh with a draft/selection generation (and add a deferred-result test) so newer manual intent wins.

void refreshHermesConfig()
}
}, [activeSessionId, freshDraftReady, gatewayState, refreshCurrentModel, refreshHermesConfig])
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,7 @@ describe('useModelControls', () => {
expect(getCurrentModelSource()).toBe('default')
})

it('does not clobber the active session footer state with global model info', async () => {
it('does not clobber the active session footer state even when force-reseeded', async () => {
setCurrentModel('deepseek/deepseek-v4-pro')
setCurrentProvider('deepseek')
$activeSessionId.set('runtime-1')
Expand All @@ -109,7 +109,7 @@ describe('useModelControls', () => {
})
)

await result.current.refreshCurrentModel()
await result.current.refreshCurrentModel(true)

expect($currentModel.get()).toBe('deepseek/deepseek-v4-pro')
expect($currentProvider.get()).toBe('deepseek')
Expand Down Expand Up @@ -201,10 +201,13 @@ describe('useModelControls', () => {
setCurrentProvider('anthropic')
await result.current.refreshCurrentModel()
expect($currentModel.get()).toBe('anthropic/claude-sonnet-4.6')
expect($currentProvider.get()).toBe('anthropic')

// A profile swap forces a reseed to the new profile's default.
await result.current.refreshCurrentModel(true)
expect($currentModel.get()).toBe('openai/gpt-5.5')
expect($currentProvider.get()).toBe('openai-codex')
expect(getCurrentModelSource()).toBe('default')
})

it('refreshes legacy/default-derived composer state from the profile default', async () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -258,12 +258,9 @@ export function useSessionActions({
})
setSessionStartedAt(null)
setTurnStartedAt(null)
// The composer's model/effort/fast is sticky UI state (persisted in
// localStorage) — a new chat FOLLOWS your last pick instead of snapping
// back to the profile default, so we deliberately don't reset it here. The
// profile default still owns first-run seeding and profile switches (see
// refreshCurrentModel). Only $currentServiceTier (a live-session mirror)
// is cleared.
// Model/provider reseeding happens after this transition, once the fresh
// draft is ready (see useBackgroundSync). Effort/fast remain sticky UI
// state. Only $currentServiceTier (a live-session mirror) is cleared here.
setCurrentServiceTier('')
setYoloActive(false)
setNewChatWorkspaceTarget(hasWorkspaceTarget ? workspaceTarget : undefined)
Expand Down