From c3602f7f05ae75ddaec04f51177c7e3a4c2237ca Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Thu, 23 Jul 2026 00:15:30 -0500 Subject: [PATCH] fix(desktop): route tiled session resumes to the owning profile MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `resumeTile` — the cold-resume path for a session opened in a tile / split pane — resumed with `{ session_id, cols }` and read messages with no profile, so a tile opening a session from another profile let 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), just a sibling call path. Resolve the owning profile via the shared `resolveSessionProfile` and carry it on both the transcript prefetch and the resume RPC. Co-authored-by: oliviaaaa7788 --- .../hooks/use-session-tile-delegate.test.ts | 100 ++++++++++++++++++ .../hooks/use-session-tile-delegate.ts | 16 ++- 2 files changed, 114 insertions(+), 2 deletions(-) create mode 100644 apps/desktop/src/app/contrib/hooks/use-session-tile-delegate.test.ts diff --git a/apps/desktop/src/app/contrib/hooks/use-session-tile-delegate.test.ts b/apps/desktop/src/app/contrib/hooks/use-session-tile-delegate.test.ts new file mode 100644 index 000000000000..f522e7103599 --- /dev/null +++ b/apps/desktop/src/app/contrib/hooks/use-session-tile-delegate.test.ts @@ -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()), + getSessionMessages: vi.fn(async () => ({ messages: [], session_id: '' })) +})) + +const { getSessionMessages } = await import('@/hermes') + +const row = (over: Partial): 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) { + 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' + }) + }) +}) diff --git a/apps/desktop/src/app/contrib/hooks/use-session-tile-delegate.ts b/apps/desktop/src/app/contrib/hooks/use-session-tile-delegate.ts index d7a7e0467272..ba1107e9e064 100644 --- a/apps/desktop/src/app/contrib/hooks/use-session-tile-delegate.ts +++ b/apps/desktop/src/app/contrib/hooks/use-session-tile-delegate.ts @@ -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' @@ -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('session.resume', { session_id: storedSessionId, cols: 96 }) + getSessionMessages(storedSessionId, profile).catch(() => null), + requestGateway('session.resume', { + session_id: storedSessionId, + cols: 96, + ...(profile ? { profile } : {}) + }) ]) const runtimeId = resumed?.session_id