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
10 changes: 6 additions & 4 deletions apps/desktop/src/app/chat/session-view.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,13 @@ import {
$awaitingResponse,
$busy,
$currentCwd,
$currentFastMode,
$currentReasoningEffort,
$lastVisibleMessageIsUser,
$messages,
$messagesEmpty,
$primaryFastMode,
$primaryModel,
$primaryProvider,
$primaryReasoningEffort,
$selectedStoredSessionId
} from '@/store/session'

Expand Down Expand Up @@ -49,15 +49,17 @@ export const PRIMARY_SESSION_VIEW: SessionView = {
$awaitingResponse,
$busy,
$cwd: $currentCwd,
$fast: $currentFastMode,
// The OPEN session's fast mode, not the composer's persisted pick — those
// are the same value only on a fresh draft (see $primaryModel).
$fast: $primaryFastMode,
$lastVisibleIsUser: $lastVisibleMessageIsUser,
$messages,
$messagesEmpty,
// The OPEN session's model, not the composer's persisted pick — those are
// the same value only on a fresh draft (see $primaryModel).
$model: $primaryModel,
$provider: $primaryProvider,
$reasoningEffort: $currentReasoningEffort,
$reasoningEffort: $primaryReasoningEffort,
$runtimeId: $activeSessionId,
$storedId: $selectedStoredSessionId
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,15 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { ClientSessionState } from '@/app/types'
import { createClientSessionState } from '@/lib/chat-runtime'
import {
$currentFastMode,
$currentModel,
$currentProvider,
$currentReasoningEffort,
setCurrentFastMode,
setCurrentModel,
setCurrentModelSource,
setCurrentProvider
setCurrentProvider,
setCurrentReasoningEffort
} from '@/store/session'
import type { RpcEvent } from '@/types/hermes'

Expand Down Expand Up @@ -97,3 +101,53 @@ describe('session.info does not clobber composer model selection', () => {
expect($currentProvider.get()).toBe('deepseek')
})
})

// Same guarantee, for the other two sticky composer fields (#318 fixed only
// model/provider). This handler sits inside the `if (apply)` block, which is
// also true for a global broadcast with no active session — so an unscoped
// heartbeat carrying reasoning_effort/fast must not rewrite the stored keys
// out from under a fresh draft either.
describe('session.info does not clobber composer effort/fast selection', () => {
beforeEach(() => {
handleEvent = null
setCurrentReasoningEffort('high')
setCurrentFastMode(true)
})

afterEach(() => {
cleanup()
vi.restoreAllMocks()
setCurrentReasoningEffort('')
setCurrentFastMode(false)
})

it('keeps a sticky pick when a global session.info carries a different reasoning_effort/fast', async () => {
await mountStream(null)

act(() =>
handleEvent!({
payload: { fast: false, reasoning_effort: 'low' },
type: 'session.info'
})
)

expect($currentReasoningEffort.get()).toBe('high')
expect($currentFastMode.get()).toBe(true)
expect(window.localStorage.getItem('hermes.desktop.composer.reasoning-effort')).toBe('high')
expect(window.localStorage.getItem('hermes.desktop.composer.fast')).toBe('true')
})

it('keeps the composer pick when an unscoped session.info arrives with no live session', async () => {
await mountStream(null)

act(() =>
handleEvent!({
payload: { cwd: '/tmp/project', fast: false, reasoning_effort: 'low' },
type: 'session.info'
})
)

expect($currentReasoningEffort.get()).toBe('high')
expect($currentFastMode.get()).toBe(true)
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -37,12 +37,12 @@ import {
$currentProvider,
sessionMatchesStoredId,
$localDeviceName,
setActiveSessionFastMode,
setActiveSessionReasoningEffort,
setCurrentBranch,
setCurrentCwd,
setCurrentFallbackPolicy,
setCurrentFastMode,
setCurrentPersonality,
setCurrentReasoningEffort,
setCurrentServiceTier,
setCurrentUsage,
setLocalDeviceName,
Expand Down Expand Up @@ -257,6 +257,12 @@ export function useGatewayEventHandler(deps: GatewayEventDeps) {
// (or a stale session model) and would silently revert the dropdown.
// Active-session model/provider still flows through the session state
// cache via updateSessionState → syncRuntimeMetadataToView below.
// Same reasoning applies to reasoning_effort/fast just below: they
// paint the runtime mirror (setActiveSessionReasoningEffort/
// setActiveSessionFastMode), never the composer's persisted pick —
// `apply` is also true for a global broadcast with no active session,
// so a heartbeat here could otherwise rewrite the pick under a fresh
// draft's feet.

if (statePatch.fallbackPolicy) {
setCurrentFallbackPolicy(statePatch.fallbackPolicy)
Expand Down Expand Up @@ -288,15 +294,15 @@ export function useGatewayEventHandler(deps: GatewayEventDeps) {
}

if (typeof payload?.reasoning_effort === 'string') {
setCurrentReasoningEffort(payload.reasoning_effort)
setActiveSessionReasoningEffort(payload.reasoning_effort)
}

if (typeof payload?.service_tier === 'string') {
setCurrentServiceTier(payload.service_tier)
}

if (typeof payload?.fast === 'boolean') {
setCurrentFastMode(payload.fast)
setActiveSessionFastMode(payload.fast)
}

if (typeof payload?.yolo === 'boolean') {
Expand Down
55 changes: 55 additions & 0 deletions apps/desktop/src/app/session/hooks/use-session-actions.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1251,6 +1251,8 @@ describe('createBackendSessionForSend per-session overrides', () => {
$currentCwd.set('')
$currentModel.set('')
$currentProvider.set('')
$currentReasoningEffort.set('')
$currentFastMode.set(false)
vi.restoreAllMocks()
})

Expand Down Expand Up @@ -1464,4 +1466,57 @@ describe('createBackendSessionForSend per-session overrides', () => {
expect(createCalls[0]).toMatchObject({ model: 'deepseek-v4-flash-0731-ds4', provider: 'ai-router' })
expect(createCalls[1]).toMatchObject({ model: 'anthropic/claude-sonnet-4.6', provider: 'anthropic' })
})

// Same guarantee as above, for the other two sticky composer fields (#318
// fixed only model/provider): a session's reported effort/fast must not
// become the pick for the NEXT new chat either.
it('starts the NEXT chat on the picked effort/fast after a session reported different runtime values', async () => {
const createCalls: Record<string, unknown>[] = []

const requestGateway = vi.fn(async (method: string, params?: Record<string, unknown>) => {
if (method === 'session.create') {
createCalls.push(params ?? {})

// The backend can report a different EFFECTIVE effort/fast than what
// was requested (same info echo path as model/provider above).
// Stubbing a divergent value is what exercises the leak:
// applyRuntimeInfo must paint the mirror, never the composer's pick.
return {
info: {
fast: false,
reasoning_effort: 'low'
},
session_id: `${RUNTIME_SESSION_ID}-${createCalls.length}`,
stored_session_id: null
} as never
}

return {} as never
})

setCurrentCwd('')
setNewChatWorkspaceTarget(undefined)
setCurrentReasoningEffort('high')
setCurrentFastMode(true)

let handle: HarnessHandle | null = null
render(<Harness onReady={h => (handle = h)} requestGateway={requestGateway} />)
await waitFor(() => expect(handle).not.toBeNull())

await act(async () => {
await handle!.createBackendSessionForSend(null)
})

// The user hits Cmd+N and sends — a new draft, no override.
await act(async () => {
handle!.startFreshSessionDraft()
})
await act(async () => {
await handle!.createBackendSessionForSend(null)
})

expect(createCalls).toHaveLength(2)
expect(createCalls[0]).toMatchObject({ fast: true, reasoning_effort: 'high' })
expect(createCalls[1]).toMatchObject({ fast: true, reasoning_effort: 'high' })
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ import {
$sessions,
$sessionsTotal,
$yoloActive,
clearActiveSessionModel,
clearActiveSessionRuntime,
type NewChatWorkspaceTarget,
sessionAliasIds,
setActiveSessionId,
Expand Down Expand Up @@ -342,9 +342,9 @@ export function useSessionActions({
// 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 the live-session mirrors ($currentServiceTier
// and the model/provider pair) are cleared — with no session open, the
// composer's own pick is what the pill should show again.
clearActiveSessionModel()
// and the model/provider/effort/fast quartet) are cleared — with no
// session open, the composer's own pick is what the pill should show again.
clearActiveSessionRuntime()
setCurrentServiceTier('')
setCurrentFallbackPolicy('')
setYoloActive(false)
Expand Down
17 changes: 10 additions & 7 deletions apps/desktop/src/app/session/hooks/use-session-actions/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,14 +10,14 @@ import {
$currentCwd,
$sessions,
sessionMatchesStoredId,
setActiveSessionFastMode,
setActiveSessionModel,
setActiveSessionProvider,
setActiveSessionReasoningEffort,
setCurrentBranch,
setCurrentCwd,
setCurrentFallbackPolicy,
setCurrentFastMode,
setCurrentPersonality,
setCurrentReasoningEffort,
setCurrentServiceTier,
setCurrentUsage,
setSessions,
Expand Down Expand Up @@ -569,8 +569,10 @@ export function applyRuntimeInfo(
sessionState.personality = personality
}

// Same leak as model/provider above (#318 fixed only those two): a
// session's reported effort paints the mirror, never the composer's pick.
if (typeof info.reasoning_effort === 'string') {
setCurrentReasoningEffort(info.reasoning_effort)
setActiveSessionReasoningEffort(info.reasoning_effort)
sessionState.reasoningEffort = info.reasoning_effort
}

Expand All @@ -580,7 +582,7 @@ export function applyRuntimeInfo(
}

if (typeof info.fast === 'boolean') {
setCurrentFastMode(info.fast)
setActiveSessionFastMode(info.fast)
sessionState.fast = info.fast
}

Expand All @@ -604,13 +606,14 @@ export function applyRuntimeInfo(

export function applyStoredSessionPreviewRuntimeInfo(stored: { model?: null | string } | undefined) {
// A resume in flight has no runtime id yet, so the mirror (not $activeSessionId)
// is what keeps the stored row's model on screen until session.info lands.
// is what keeps the stored row's model (and effort/fast) on screen until
// session.info lands.
setActiveSessionModel(stored?.model || '')
setActiveSessionProvider('')
setCurrentFallbackPolicy('')
setCurrentReasoningEffort('')
setActiveSessionReasoningEffort('')
setCurrentServiceTier('')
setCurrentFastMode(false)
setActiveSessionFastMode(false)
setYoloActive(false)
setCurrentPersonality('')
}
Expand Down
Loading
Loading