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
100 changes: 100 additions & 0 deletions apps/desktop/src/app/contrib/hooks/use-session-tile-delegate.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import { renderHook } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'

import type * as HermesModule from '@/hermes'
import { setSessions } from '@/store/session'
import { sessionTileDelegate } from '@/store/session-states'
import type { SessionInfo } from '@/types/hermes'

import { useSessionTileDelegate } from './use-session-tile-delegate'

vi.mock('@/hermes', async importActual => ({
...(await importActual<typeof HermesModule>()),
getSessionMessages: vi.fn(async () => ({ messages: [], session_id: '' }))
}))

const { getSessionMessages } = await import('@/hermes')

const row = (over: Partial<SessionInfo>): SessionInfo =>
({
ended_at: null,
id: 'live',
input_tokens: 0,
is_active: false,
last_active: 0,
message_count: 1,
model: null,
output_tokens: 0,
preview: null,
profile: 'default',
source: null,
started_at: 0,
title: null,
...over
}) as SessionInfo

function renderTile(requestGateway: ReturnType<typeof vi.fn>) {
renderHook(() =>
useSessionTileDelegate({
archiveSession: vi.fn(async () => undefined),
branchStoredSession: vi.fn(async () => undefined),
executeSlashCommand: vi.fn(async () => undefined) as never,
removeSession: vi.fn(async () => undefined),
requestGateway: requestGateway as never,
runtimeIdByStoredSessionIdRef: { current: new Map() },
sessionStateByRuntimeIdRef: { current: new Map() },
updateSessionState: vi.fn()
})
)
}

describe('useSessionTileDelegate resumeTile', () => {
beforeEach(() => {
setSessions([])
vi.mocked(getSessionMessages).mockClear()
})

afterEach(() => {
setSessions([])
})

it('carries the owning profile into a cold tile resume so it cannot fork profiles', async () => {
// A tile opens a session owned by another profile. Resuming without the
// profile lets the gateway fall back to the launch-profile DB and clone the
// conversation into the wrong profile (#67603). The owning profile must ride
// both the transcript prefetch and the resume RPC.
setSessions([row({ id: 'stored-x', profile: 'ai-engineer' })])

const requestGateway = vi.fn(async (method: string) =>
method === 'session.resume' ? ({ session_id: 'runtime-1' } as never) : ({} as never)
)

renderTile(requestGateway)
const runtimeId = await sessionTileDelegate()!.resumeTile('stored-x')

expect(runtimeId).toBe('runtime-1')
expect(getSessionMessages).toHaveBeenCalledWith('stored-x', 'ai-engineer')
expect(requestGateway).toHaveBeenCalledWith('session.resume', {
session_id: 'stored-x',
cols: 96,
profile: 'ai-engineer'
})
})

it('resolves and carries a default-profile session explicitly', async () => {
setSessions([row({ id: 'stored-y', profile: 'default' })])

const requestGateway = vi.fn(async (method: string) =>
method === 'session.resume' ? ({ session_id: 'runtime-2' } as never) : ({} as never)
)

renderTile(requestGateway)
await sessionTileDelegate()!.resumeTile('stored-y')

expect(requestGateway).toHaveBeenCalledWith('session.resume', {
session_id: 'stored-y',
cols: 96,
profile: 'default'
})
})
})
16 changes: 14 additions & 2 deletions apps/desktop/src/app/contrib/hooks/use-session-tile-delegate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { publishSessionState, setSessionTileDelegate } from '@/store/session-sta
import type { SessionResumeResponse } from '@/types/hermes'

import type { usePromptActions } from '../../session/hooks/use-prompt-actions'
import { resolveSessionProfile } from '../../session/hooks/use-session-actions/utils'
import type { useSessionStateCache } from '../../session/hooks/use-session-state-cache'
import type { GatewayRequester } from '../types'

Expand Down Expand Up @@ -66,9 +67,20 @@ export function useSessionTileDelegate({
return existing
}

// Resolve the owning profile before binding a runtime. A tile can open a
// session from any profile, not just the active one; resuming (or
// reading messages) without a profile lets the gateway fall back to the
// launch-profile DB and fork the conversation into the wrong profile —
// the same cross-profile bleed the recovery resumes had (#67603).
const profile = await resolveSessionProfile(storedSessionId)

const [prefetch, resumed] = await Promise.all([
getSessionMessages(storedSessionId).catch(() => null),
requestGateway<SessionResumeResponse>('session.resume', { session_id: storedSessionId, cols: 96 })
getSessionMessages(storedSessionId, profile).catch(() => null),
requestGateway<SessionResumeResponse>('session.resume', {
session_id: storedSessionId,
cols: 96,
...(profile ? { profile } : {})
})
])

const runtimeId = resumed?.session_id
Expand Down
Loading