diff --git a/apps/mobile/src/app/(app)/agent-chat/[session-id].mounted.test.tsx b/apps/mobile/src/app/(app)/agent-chat/[session-id].mounted.test.tsx index ca9fb663ca..a87d4886b2 100644 --- a/apps/mobile/src/app/(app)/agent-chat/[session-id].mounted.test.tsx +++ b/apps/mobile/src/app/(app)/agent-chat/[session-id].mounted.test.tsx @@ -1,14 +1,59 @@ /* eslint-disable typescript-eslint/no-deprecated -- react-test-renderer is the DOM-free renderer used to mount React/RN trees under vitest (node env, no jsdom). */ -import { createElement } from 'react'; +/* eslint-disable max-lines -- keep the real SDK lifecycle probes with the route's shared mounted fixture. */ +import { createElement, type ReactElement, useEffect } from 'react'; +import { useAtomValue } from 'jotai'; +import { useTranslation } from 'react-i18next'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import type * as ReactQuery from '@tanstack/react-query'; import TestRenderer, { act } from 'react-test-renderer'; -import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { beforeEach, describe, expect, it, onTestFinished, vi } from 'vitest'; +import { + createSessionManager, + type KiloSessionId, + type SessionManager, + type SessionManagerConfig, + type SessionSnapshotPageOutcome, +} from '@kilocode/cloud-agent-sdk'; +import { kiloId, stubTextPart, stubUserMessage } from '@kilocode/cloud-agent-sdk/test-helpers'; +import '@/i18n'; +import { useSessionManager } from '@/components/agents/session-provider'; +import { UserWebConnectionProvider } from '@/components/agents/user-web-connection-provider'; +import { useSessionDetailRename } from '@/components/agents/use-session-detail-rename'; +import { QueryError } from '@/components/query-error'; +import { ScreenHeader } from '@/components/screen-header'; +import { Button } from '@/components/ui/button'; +import { clearActiveToken, setActiveToken, setSignOutTeardownActive } from '@/lib/auth/token-owner'; +import { bumpAuthEpoch, currentAuthEpoch } from '@/lib/auth/auth-epoch'; +import { setSignOutActive } from '@/lib/auth/sign-out-state'; +import { + beginAuthenticatedOwner, + confirmAuthenticatedOwner, + getAuthenticatedOwner, +} from '@/lib/context-scope'; import SessionDetailScreen from './[session-id]'; const useLocalSearchParamsMock = vi.hoisted(() => vi.fn()); const useRouterMock = vi.hoisted(() => vi.fn()); const useQueryMock = vi.hoisted(() => vi.fn()); const queryOptionsMock = vi.hoisted(() => vi.fn()); +const createMobileManagerMock = vi.hoisted(() => vi.fn()); +const authState = vi.hoisted(() => ({ + token: 'account-a-token' as string | undefined, + authEpoch: 1, + isLoading: false, + isSigningOut: false, + sessionEnded: false, +})); + +const CHILD_ID = kiloId('ses_child_scope_probe'); +const childPageMock = vi.fn>(); +type ManagerProbe = { manager: SessionManager; store: SessionManagerConfig['store'] }; +const managers: ManagerProbe[] = []; +// Request credentials can change independently of the React token (request-time refresh). +let requestAccount: 'A' | 'B' = 'A'; +const rootRequests: { account: 'A' | 'B'; sessionId: KiloSessionId }[] = []; +let rootMetadataReady: Promise | null = null; const queryState = vi.hoisted(() => ({ isPending: false, @@ -19,8 +64,38 @@ const queryState = vi.hoisted(() => ({ refetch: vi.fn(), })); +const confirmationRequests = vi.hoisted(() => ({ + getMe: vi.fn<() => Promise<{ id: string }>>(), + ticket: vi.fn<() => Promise<{ token: string }>>(), +})); + +const navigationRoutes = ['session-detail']; vi.mock('react-native', () => ({ View: 'View', + Pressable: 'Pressable', + ActivityIndicator: 'ActivityIndicator', + I18nManager: { isRTL: false }, + Platform: { OS: 'android' }, +})); +vi.mock('react-native-safe-area-context', () => ({ + useSafeAreaInsets: () => ({ top: 0, bottom: 0 }), +})); +vi.mock('@/components/ui/directional-icons', () => ({ DirectionalChevronLeft: 'ChevronLeft' })); +vi.mock('@/components/ui/eyebrow', () => ({ Eyebrow: 'Eyebrow' })); +vi.mock('expo-secure-store', () => ({ getItemAsync: vi.fn() })); +vi.mock('@/lib/config', () => ({ SESSION_INGEST_WS_URL: 'wss://ingest.example.com' })); +vi.mock('@/lib/user-web-connection-lifecycle', () => ({ + createNativeUserWebConnectionLifecycleHooks: () => ({}), +})); +vi.mock('@/lib/a11y/announce', () => ({ announceForA11y: vi.fn() })); +vi.mock('@/lib/hooks/use-theme-colors', () => ({ useThemeColors: () => ({}) })); +vi.mock('@/components/ui/icons', () => ({ + AlertCircle: 'AlertCircle', + ChevronDown: 'ChevronDown', + Lock: 'Lock', + SearchX: 'SearchX', + ServerCrash: 'ServerCrash', + WifiOff: 'WifiOff', })); vi.mock('expo-router', () => ({ @@ -28,30 +103,81 @@ vi.mock('expo-router', () => ({ useRouter: useRouterMock, })); -vi.mock('@tanstack/react-query', () => ({ +vi.mock('@tanstack/react-query', async importOriginal => ({ + ...(await importOriginal()), useQuery: useQueryMock, })); -// This suite covers route-param parsing only; the foreground refresh hook -// needs a real QueryClient, which the react-query mock above does not provide. +// Foreground query refresh is separate from route parsing and provider lifetime. vi.mock('@/lib/hooks/use-route-foreground-refresh', () => ({ useRouteForegroundRefresh: vi.fn(), })); +vi.mock('@/lib/auth/auth-context', () => ({ + useAuth: () => authState, +})); + vi.mock('@/lib/trpc', () => ({ useTRPC: () => ({ cliSessionsV2: { - get: { queryOptions: queryOptionsMock }, + get: { + queryOptions: queryOptionsMock, + queryKey: () => [['cliSessionsV2', 'get']], + }, }, }), + trpcClient: { + user: { getMe: { query: confirmationRequests.getMe } }, + activeSessions: { createWebTicket: { mutate: confirmationRequests.ticket } }, + }, })); vi.mock('@/components/invalid-route-state', () => ({ InvalidRouteState: 'InvalidRouteState', })); +vi.mock('@/lib/hooks/use-session-mutations', () => ({ + useSessionMutations: () => ({ renameSessionAsync: vi.fn() }), +})); + vi.mock('@/components/agents/session-detail-content', () => ({ - SessionDetailContent: 'SessionDetailContent', + SessionDetailContent: function SessionDetailContent( + props: Readonly<{ sessionId: KiloSessionId; cachedTitle?: string }> + ) { + const manager = useSessionManager(); + const { t } = useTranslation(); + const { sessionId, cachedTitle } = props; + // Match the real detail lifecycle for the original manager and every successor. + useEffect(() => { + void manager.switchSession(sessionId); + }, [sessionId, manager]); + const rootMessages = useAtomValue(manager.atoms.messagesList); + const childMessages = useAtomValue(manager.atoms.childMessages)(CHILD_ID); + const fetchedData = useAtomValue(manager.atoms.fetchedSessionData); + const isSessionLoaded = fetchedData?.kiloSessionId === sessionId; + // Use the real title hook with metadata from the real manager, not a fixed mock title. + const rename = useSessionDetailRename({ + sessionId, + isLoaded: isSessionLoaded, + serverTitle: isSessionLoaded ? (fetchedData.title ?? undefined) : undefined, + fallbackTitle: cachedTitle ?? t('agentChat.session.title'), + }); + return createElement( + 'SessionDetailContent', + props, + createElement(ScreenHeader, { title: rename.title }), + rootMessages.flatMap(message => + message.parts.flatMap(part => + part.type === 'text' ? [createElement('RootText', { key: part.id }, part.text)] : [] + ) + ), + childMessages.flatMap(message => + message.parts.flatMap(part => + part.type === 'text' ? [createElement('Text', { key: part.id }, part.text)] : [] + ) + ) + ); + }, })); vi.mock('@/components/agents/session-detail-skeleton', () => ({ @@ -67,8 +193,8 @@ vi.mock('@/components/agents/session-context-metrics', () => ({ SessionContextMetrics: 'SessionContextMetrics', })); -vi.mock('@/components/agents/session-provider', () => ({ - AgentSessionProvider: 'AgentSessionProvider', +vi.mock('@/components/agents/mobile-session-manager', () => ({ + createMobileAgentSessionManager: createMobileManagerMock, })); vi.mock('@/components/agents/session-terminal-error', () => ({ @@ -79,21 +205,10 @@ vi.mock('@/components/agents/use-message-copy', () => ({ performCopy: vi.fn(), })); -vi.mock('@/components/query-error', () => ({ - QueryError: 'QueryError', -})); - -vi.mock('@/components/screen-header', () => ({ - ScreenHeader: 'ScreenHeader', -})); - -vi.mock('@/components/ui/button', () => ({ - Button: 'Button', -})); - -vi.mock('@/components/ui/text', () => ({ - Text: 'Text', -})); +vi.mock('@/components/ui/text', async () => { + const { createContext } = await import('react'); + return { Text: 'Text', TextClassContext: createContext(undefined) }; +}); vi.mock('@/lib/spawned-not-found-retry', () => ({ shouldRetryNotFoundOnSpawnedRoute: () => false, @@ -103,6 +218,15 @@ function findByType( root: TestRenderer.ReactTestInstance, type: string ): TestRenderer.ReactTestInstance[] { + if (type === 'QueryError') { + return root.findAllByType(QueryError); + } + if (type === 'Button') { + return root.findAllByType(Button); + } + if (type === 'ScreenHeader') { + return root.findAllByType(ScreenHeader); + } return root.findAll(node => typeof node.type === 'string' && (node.type as string) === type); } @@ -115,15 +239,33 @@ function propOf(instance: TestRenderer.ReactTestInstance | undefined, key: strin /* eslint-enable typescript-eslint/no-unsafe-member-access */ } -function mountRoute(): TestRenderer.ReactTestRenderer { +async function mountRoute( + element: ReactElement = createElement(SessionDetailScreen) +): Promise { const ref: { current: TestRenderer.ReactTestRenderer | undefined } = { current: undefined }; - act(() => { - ref.current = TestRenderer.create(createElement(SessionDetailScreen)); + await act(async () => { + ref.current = TestRenderer.create(createElement(UserWebConnectionProvider, null, element)); + await Promise.resolve(); }); if (!ref.current) { throw new Error('route did not render'); } - return ref.current; + const renderer = ref.current; + onTestFinished(() => { + act(() => { + renderer.unmount(); + }); + }); + return renderer; +} + +async function updateRoute(renderer: TestRenderer.ReactTestRenderer) { + await act(async () => { + renderer.update( + createElement(UserWebConnectionProvider, null, createElement(SessionDetailScreen)) + ); + await Promise.resolve(); + }); } function queryEnabled(): boolean | undefined { @@ -135,10 +277,118 @@ function queryInput(): { session_id?: string } | undefined { return queryOptionsMock.mock.calls[0]?.[0] as { session_id?: string } | undefined; } +function beginReplacement() { + setSignOutActive(true); + setSignOutTeardownActive(true); + authState.isSigningOut = true; + authState.token = undefined; + bumpAuthEpoch(); + authState.authEpoch = currentAuthEpoch(); + beginAuthenticatedOwner(); + clearActiveToken(); +} + +function commitCredentials(account: 'A' | 'B') { + requestAccount = account; + authState.token = account === 'A' ? 'account-a-token' : 'account-b-token'; + setActiveToken(authState.token, null); + authState.isSigningOut = false; + setSignOutTeardownActive(false); + setSignOutActive(false); +} + +function commitAccount(account: 'A' | 'B') { + commitCredentials(account); + confirmAuthenticatedOwner(getAuthenticatedOwner(), `user-${account}`); +} + beforeEach(() => { + beginReplacement(); + commitAccount('A'); + confirmationRequests.getMe + .mockReset() + .mockReturnValue(Promise.withResolvers<{ id: string }>().promise); + // Keep sockets deterministic; connection integration has its own real-SDK socket suite. + confirmationRequests.ticket + .mockReset() + .mockReturnValue(Promise.withResolvers<{ token: string }>().promise); + managers.length = 0; + requestAccount = 'A'; + rootRequests.length = 0; + rootMetadataReady = null; + childPageMock.mockReset(); + createMobileManagerMock.mockReset(); + createMobileManagerMock.mockImplementation( + ({ store, userWebConnection }: Pick) => { + const manager = createSessionManager({ + store, + userWebConnection, + resolveSession: async id => { + await Promise.resolve(); + return { type: 'read-only', kiloSessionId: id }; + }, + getTicket: vi.fn(), + fetchSnapshot: vi.fn().mockResolvedValue({ info: { id: 'sess-1' }, messages: [] }), + fetchSnapshotPage: async (id, options) => { + if (id === CHILD_ID) { + const page = await childPageMock(id, options); + return page; + } + return transcriptPage( + id, + `msg-root-${requestAccount}`, + `Account ${requestAccount} root row` + ); + }, + api: { + send: vi.fn(), + interrupt: vi.fn(), + answer: vi.fn(), + reject: vi.fn(), + respondToPermission: vi.fn(), + }, + prepare: vi.fn(), + initiate: vi.fn(), + fetchSession: async id => { + const account = requestAccount; + rootRequests.push({ account, sessionId: id }); + await rootMetadataReady; + return { + kiloSessionId: id, + cloudAgentSessionId: null, + title: `Account ${account} current title`, + organizationId: null, + gitUrl: null, + gitBranch: null, + mode: null, + model: null, + variant: null, + repository: null, + isInitiated: true, + needsLegacyPrepare: false, + isPreparingAsync: false, + prompt: null, + initialMessageId: null, + associatedPr: null, + }; + }, + }); + managers.push({ manager, store }); + return manager; + } + ); useLocalSearchParamsMock.mockReset(); useRouterMock.mockReset(); - useRouterMock.mockReturnValue({ replace: vi.fn() }); + navigationRoutes.splice(0, navigationRoutes.length, 'session-detail'); + useRouterMock.mockReturnValue({ + canGoBack: () => navigationRoutes.length > 1, + back: () => { + navigationRoutes.pop(); + }, + replace: (href: string) => { + navigationRoutes.splice(-1, 1, href); + }, + }); useQueryMock.mockReset(); useQueryMock.mockImplementation((options: { enabled?: boolean } | undefined) => { // A disabled TanStack query stays pending forever (`isPending: true` when @@ -161,41 +411,33 @@ beforeEach(() => { }); describe('SessionDetailScreen invalid session-id', () => { - it('renders InvalidRouteState with the app backTo when session-id is undefined', () => { + it('renders InvalidRouteState with the app backTo when session-id is undefined', async () => { useLocalSearchParamsMock.mockReturnValue({ 'session-id': undefined }); - const renderer = mountRoute(); + const renderer = await mountRoute(); const invalid = findByType(renderer.root, 'InvalidRouteState'); expect(invalid).toHaveLength(1); expect(propOf(invalid[0], 'backTo')).toBe('/(app)'); expect(findByType(renderer.root, 'SessionDetailContent')).toHaveLength(0); expect(queryEnabled()).toBe(false); - - act(() => { - renderer.unmount(); - }); }); - it('renders InvalidRouteState with the app backTo when session-id is an array', () => { + it('renders InvalidRouteState with the app backTo when session-id is an array', async () => { useLocalSearchParamsMock.mockReturnValue({ 'session-id': ['sess-1', 'sess-2'] }); - const renderer = mountRoute(); + const renderer = await mountRoute(); const invalid = findByType(renderer.root, 'InvalidRouteState'); expect(invalid).toHaveLength(1); expect(propOf(invalid[0], 'backTo')).toBe('/(app)'); expect(findByType(renderer.root, 'SessionDetailContent')).toHaveLength(0); expect(queryEnabled()).toBe(false); - - act(() => { - renderer.unmount(); - }); }); }); describe('SessionDetailScreen valid session-id', () => { - it('renders the session content with the parsed session-id and enables the query', () => { + it('renders the session content with the parsed session-id and enables the query', async () => { useLocalSearchParamsMock.mockReturnValue({ 'session-id': 'sess-1' }); - const renderer = mountRoute(); + const renderer = await mountRoute(); const content = findByType(renderer.root, 'SessionDetailContent'); expect(content).toHaveLength(1); @@ -203,9 +445,705 @@ describe('SessionDetailScreen valid session-id', () => { expect(findByType(renderer.root, 'InvalidRouteState')).toHaveLength(0); expect(queryEnabled()).toBe(true); expect(queryInput()).toEqual({ session_id: 'sess-1' }); + }); +}); + +function transcriptPage(sessionId: KiloSessionId, messageId: string, text: string) { + return { + kind: 'success', + info: { id: sessionId, ...(sessionId === CHILD_ID ? { parentID: 'sess-1' } : {}) }, + messages: [ + { + info: stubUserMessage({ id: messageId, sessionID: sessionId }), + parts: [ + stubTextPart({ + id: `part-${messageId}`, + sessionID: sessionId, + messageID: messageId, + text, + }), + ], + }, + ], + nextCursor: null, + omittedItemCount: 0, + } satisfies SessionSnapshotPageOutcome; +} + +function childPage(messageId: string, text: string, nextCursor: string | null = null) { + return { ...transcriptPage(CHILD_ID, messageId, text), nextCursor }; +} + +function transcriptText(renderer: TestRenderer.ReactTestRenderer, type = 'Text'): string { + if (renderer.toJSON() === null) { + return ''; + } + const headerText = new Set( + renderer.root.findAllByType(ScreenHeader).flatMap(header => findByType(header, type)) + ); + return findByType(renderer.root, type) + .filter(node => !headerText.has(node)) + .flatMap(node => node.children.filter(child => typeof child === 'string')) + .join('\n'); +} + +function childIds({ store, manager }: ManagerProbe): string[] { + return store + .get(manager.atoms.childMessages)(CHILD_ID) + .map(message => message.info.id); +} + +async function startChildPage(pageKind: 'first' | 'older') { + useLocalSearchParamsMock.mockReturnValue({ 'session-id': 'sess-1', organizationId: 'org-a' }); + const renderer = await mountRoute(); + const current = managers.at(-1); + if (!current) { + throw new Error('route did not create a manager'); + } + // The rendered detail effect, not this helper, must initialize the manager. + expect(transcriptText(renderer, 'RootText')).toBe('Account A root row'); + + if (pageKind === 'older') { + childPageMock.mockResolvedValueOnce( + childPage('msg-account-a-cached', 'Account A cached row', 'older-cursor') + ); + await act(async () => { + await current.manager.hydrateChildSession(CHILD_ID); + }); + expect(transcriptText(renderer)).toContain('Account A cached row'); + } + + const deferred = Promise.withResolvers(); + childPageMock.mockReturnValueOnce(deferred.promise); + const pending: { request?: Promise } = {}; + act(() => { + pending.request = + pageKind === 'first' + ? current.manager.hydrateChildSession(CHILD_ID) + : current.manager.loadOlderChildMessages(CHILD_ID); + }); + if (!pending.request) { + throw new Error('child request did not start'); + } + expect(childPageMock).toHaveBeenLastCalledWith( + CHILD_ID, + pageKind === 'first' ? {} : { cursor: 'older-cursor' } + ); + return { renderer, current, request: pending.request, resolvePage: deferred.resolve }; +} + +// Exercise the real provider, manager, child replay, and Jotai storage with +// controlled auth snapshots, network results, and a native transcript renderer stub. +describe.each(['first', 'older'] as const)('SessionDetailScreen %s child-page scope', pageKind => { + it.each([ + { + transition: 'root replacement', + change: () => { + useLocalSearchParamsMock.mockReturnValue({ + 'session-id': 'sess-2', + organizationId: 'org-a', + }); + }, + }, + { + transition: 'context replacement', + change: () => { + useLocalSearchParamsMock.mockReturnValue({ + 'session-id': 'sess-1', + organizationId: 'org-b', + }); + }, + }, + { + transition: 'account replacement before credential publication', + change: () => { + beginReplacement(); + }, + }, + { + transition: 'account replacement after credential publication', + change: () => { + beginReplacement(); + commitAccount('B'); + }, + }, + { + transition: 'logout before credential cleanup', + change: () => { + authState.isSigningOut = true; + setSignOutActive(true); + beginAuthenticatedOwner(); + }, + }, + ])('rejects deferred rows after $transition', async ({ change }) => { + const { renderer, current, request, resolvePage } = await startChildPage(pageKind); + act(change); + await updateRoute(renderer); + await act(async () => { + resolvePage(childPage('msg-account-a-late', 'Account A late row')); + await request; + }); + + // Keep both observations even when one fails: hidden content is not retired storage. + expect.soft(childIds(current)).not.toContain('msg-account-a-late'); + expect.soft(transcriptText(renderer)).not.toContain('Account A'); + }); + + it('keeps valid rows and accepts deferred rows during ordinary token refresh', async () => { + const { renderer, current, request, resolvePage } = await startChildPage(pageKind); + authState.token = 'account-a-refreshed-token'; + await updateRoute(renderer); + await act(async () => { + resolvePage(childPage('msg-account-a-late', 'Account A late row')); + await request; + }); + + expect(transcriptText(renderer)).toContain('Account A late row'); + expect(childIds(current)).toContain('msg-account-a-late'); + if (pageKind === 'older') { + expect(transcriptText(renderer)).toContain('Account A cached row'); + } + }); +}); + +describe.each(['first', 'older'] as const)( + 'SessionDetailScreen %s replacement sequence', + pageKind => { + it('retires the manager synchronously before React can unmount its route', async () => { + const { renderer, current, request, resolvePage } = await startChildPage(pageKind); + await act(async () => { + beginReplacement(); + // Root rows exist in both cases, so this fails if retirement waits for React cleanup. + expect(current.store.get(current.manager.atoms.messagesList)).toEqual([]); + expect(childIds(current)).toEqual([]); + resolvePage(childPage('msg-account-a-late', 'Account A late row')); + await request; + }); + + expect(childIds(current)).toEqual([]); + expect(transcriptText(renderer)).toBe(''); + }); + + it('retires the old owner while pending and initializes the committed successor', async () => { + const { renderer, current, request, resolvePage } = await startChildPage(pageKind); + const startedRequests = rootRequests.length; + + // Pending ownership publishes while credential persistence still holds account A. + act(beginReplacement); + await updateRoute(renderer); + expect.soft(transcriptText(renderer, 'RootText')).not.toContain('Account A'); + expect.soft(transcriptText(renderer)).not.toContain('Account A'); + expect.soft(rootRequests.slice(startedRequests)).toEqual([]); + + await act(async () => { + resolvePage(childPage('msg-account-a-late', 'Account A late row')); + await request; + }); + expect.soft(childIds(current)).toEqual([]); + expect.soft(transcriptText(renderer)).not.toContain('Account A'); + + // A current getMe response confirms the committed credentials. + act(() => { + commitAccount('B'); + }); + await updateRoute(renderer); + expect.soft(transcriptText(renderer, 'RootText')).toBe('Account B root row'); + + const successor = managers.at(-1); + if (successor && renderer.toJSON() !== null) { + childPageMock.mockResolvedValueOnce( + childPage('msg-account-b-current', 'Account B current row') + ); + await act(async () => { + await successor.manager.hydrateChildSession(CHILD_ID); + }); + } + expect.soft(childIds(current)).toEqual([]); + expect.soft(transcriptText(renderer)).toBe('Account B current row'); + + authState.token = 'account-b-refreshed-token'; + await updateRoute(renderer); + expect.soft(transcriptText(renderer, 'RootText')).toBe('Account B root row'); + expect.soft(transcriptText(renderer)).toBe('Account B current row'); + }); + } +); + +describe('SessionDetailScreen owner-scoped metadata and recovery', () => { + it('does not initialize a successor from the previous account metadata cache', async () => { + const actual = await vi.importActual('@tanstack/react-query'); + useQueryMock.mockImplementation(actual.useQuery); + const metadata = Promise.withResolvers<{ organization_id: string }>(); + queryOptionsMock.mockImplementation(() => ({ + queryKey: [['cliSessionsV2', 'get']], + queryFn: async () => { + const account = requestAccount; + await Promise.resolve(); + return account === 'A' ? { organization_id: 'org-a' } : metadata.promise; + }, + })); + const client = new QueryClient({ + defaultOptions: { queries: { retry: false, gcTime: Infinity } }, + }); + vi.useFakeTimers(); + onTestFinished(() => { + client.clear(); + vi.useRealTimers(); + }); + useLocalSearchParamsMock.mockReturnValue({ 'session-id': 'sess-1' }); + const tree = createElement(QueryClientProvider, { client }, createElement(SessionDetailScreen)); + const renderer = await mountRoute(tree); + await act(async () => { + await vi.advanceTimersByTimeAsync(0); + }); + expect(transcriptText(renderer, 'RootText')).toBe('Account A root row'); + const requestsBeforeReplacement = rootRequests.length; + + await act(async () => { + beginReplacement(); + commitAccount('B'); + await vi.advanceTimersByTimeAsync(0); + }); + expect(transcriptText(renderer, 'RootText')).toBe(''); + expect(rootRequests.slice(requestsBeforeReplacement)).toEqual([]); + + await act(async () => { + metadata.resolve({ organization_id: 'org-b' }); + await vi.advanceTimersByTimeAsync(0); + }); + expect(transcriptText(renderer, 'RootText')).toBe('Account B root row'); + }); + + it('waits for current identity after credentials commit on a fresh mount', async () => { + beginReplacement(); + requestAccount = 'B'; + authState.token = 'account-b-token'; + authState.isSigningOut = false; + setSignOutActive(false); + useLocalSearchParamsMock.mockReturnValue({ 'session-id': 'sess-1', organizationId: 'org-a' }); + const renderer = await mountRoute(); + expect(rootRequests).toEqual([]); + expect(transcriptText(renderer, 'RootText')).toBe(''); + act(() => { + commitAccount('B'); + }); + await updateRoute(renderer); + expect(transcriptText(renderer, 'RootText')).toBe('Account B root row'); + }); + + it('keeps the existing retry action usable for a temporary metadata failure', async () => { + useLocalSearchParamsMock.mockReturnValue({ 'session-id': 'sess-1' }); + queryState.isError = true; + queryState.error = { data: { code: 'INTERNAL_SERVER_ERROR' } }; + queryState.refetch.mockImplementation(async () => { + queryState.isError = false; + queryState.error = null; + await Promise.resolve(); + }); + const renderer = await mountRoute(); + const error = findByType(renderer.root, 'QueryError')[0]; + expect(propOf(error, 'variant')).toBe('server'); + const retry = propOf(error, 'onRetry') as (() => void) | undefined; + if (!retry) { + throw new Error('temporary error lost its retry action'); + } + + act(retry); + await updateRoute(renderer); + + expect(findByType(renderer.root, 'QueryError')).toHaveLength(0); + expect(transcriptText(renderer, 'RootText')).toBe('Account A root row'); + }); + + it.each([ + { code: 'NOT_FOUND', variant: 'not-found' }, + { code: 'UNAUTHORIZED', variant: 'permission' }, + ])('keeps $code terminal with no retry action', async ({ code, variant }) => { + useLocalSearchParamsMock.mockReturnValue({ 'session-id': 'sess-1' }); + queryState.isError = true; + queryState.error = { data: { code } }; + const renderer = await mountRoute(); + const error = findByType(renderer.root, 'QueryError')[0]; + + expect(propOf(error, 'variant')).toBe(variant); + expect(propOf(error, 'onRetry')).toBeUndefined(); + expect(findByType(renderer.root, 'SessionDetailContent')).toHaveLength(0); + expect(findByType(renderer.root, 'Button')).toHaveLength(2); + }); +}); + +describe('SessionDetailScreen fresh authentication scope', () => { + // Fresh mounts now consume the producer's pending/confirmed association, not token history. + it('starts no old-account work when first mounted during pending replacement', async () => { + beginReplacement(); + useLocalSearchParamsMock.mockReturnValue({ 'session-id': 'sess-1', organizationId: 'org-a' }); + const renderer = await mountRoute(); + + expect.soft(rootRequests).toEqual([]); + expect.soft(transcriptText(renderer, 'RootText')).toBe(''); + expect.soft(transcriptText(renderer)).toBe(''); + + act(() => { + commitAccount('B'); + }); + await updateRoute(renderer); + expect.soft(transcriptText(renderer, 'RootText')).toBe('Account B root row'); + }); + + it('initializes current-account rows on a fresh mount and route re-entry', async () => { + beginReplacement(); + commitAccount('A'); + useLocalSearchParamsMock.mockReturnValue({ 'session-id': 'sess-1', organizationId: 'org-a' }); + const renderer = await mountRoute(); + expect(transcriptText(renderer, 'RootText')).toBe('Account A root row'); + const previous = managers.at(-1); + if (!previous) { + throw new Error('route did not create a manager'); + } act(() => { renderer.unmount(); }); + expect(previous.store.get(previous.manager.atoms.messagesList)).toEqual([]); + + const reentered = await mountRoute(); + expect(transcriptText(reentered, 'RootText')).toBe('Account A root row'); }); }); + +function retryControl(renderer: TestRenderer.ReactTestRenderer) { + const retry = findByType(renderer.root, 'Pressable').find( + node => propOf(node, 'accessibilityLabel') === 'Retry' + ); + if (!retry) { + throw new Error('confirmation Retry is missing'); + } + return retry; +} + +function pressControl(control: TestRenderer.ReactTestInstance | undefined) { + const onPress = propOf(control, 'onPress') as (() => void) | undefined; + if (!onPress) { + throw new Error('route control is not operable'); + } + onPress(); +} + +describe.each([true, false])('SessionDetailScreen header return with history=%s', hasHistory => { + it.each([ + { state: 'pending identity', source: 'identity', code: undefined }, + { state: 'retryable identity failure', source: 'identity', code: 'INTERNAL_SERVER_ERROR' }, + { state: 'pending metadata', source: 'metadata', code: undefined }, + { state: 'retryable metadata failure', source: 'metadata', code: 'INTERNAL_SERVER_ERROR' }, + { state: 'terminal missing session', source: 'metadata', code: 'NOT_FOUND' }, + { state: 'terminal access denial', source: 'metadata', code: 'UNAUTHORIZED' }, + ] as const)('leaves $state without admitting session data', async ({ source, code }) => { + if (hasHistory) { + navigationRoutes.unshift('previous-screen'); + } + useLocalSearchParamsMock.mockReturnValue({ 'session-id': 'sess-1' }); + if (source === 'identity') { + beginReplacement(); + commitCredentials('B'); + if (code) { + confirmationRequests.getMe.mockRejectedValueOnce(new Error('offline')); + } + } else { + queryState.isPending = code === undefined; + queryState.isError = code !== undefined; + queryState.error = code ? { data: { code } } : null; + } + const renderer = await mountRoute(); + expect(findByType(renderer.root, code ? 'QueryError' : 'SessionSkeletonMessages')).toHaveLength( + 1 + ); + const back = findByType(renderer.root.findByType(ScreenHeader), 'Pressable').find( + node => propOf(node, 'accessibilityLabel') === 'Go back' + ); + act(() => { + pressControl(back); + }); + + expect(navigationRoutes).toEqual( + hasHistory ? ['previous-screen'] : ['/(app)/(tabs)/(2_agents)'] + ); + expect(rootRequests).toEqual([]); + }); +}); + +describe('SessionDetailScreen identity confirmation feedback', () => { + beforeEach(() => { + vi.useFakeTimers(); + onTestFinished(() => { + vi.useRealTimers(); + }); + beginReplacement(); + commitCredentials('B'); + useLocalSearchParamsMock.mockReturnValue({ 'session-id': 'sess-1' }); + }); + + it.each([undefined, 'org-a'])( + 'shows the header and existing skeletons while identity is pending with organization %s', + async organizationId => { + useLocalSearchParamsMock.mockReturnValue({ + 'session-id': 'sess-1', + organizationId, + title: 'Account A private title', + }); + const renderer = await mountRoute( + createElement( + 'RouteAndSibling', + null, + createElement(SessionDetailScreen), + createElement('UnrelatedScreen') + ) + ); + + const header = findByType(renderer.root, 'ScreenHeader')[0]; + expect(propOf(header, 'title')).toBeTruthy(); + expect(propOf(header, 'title')).not.toBe('Account A private title'); + expect(findByType(renderer.root, 'SessionSkeletonMessages')).toHaveLength(1); + expect(findByType(renderer.root, 'SessionComposerSkeleton')).toHaveLength(1); + expect(findByType(renderer.root, 'SessionDetailContent')).toHaveLength(0); + expect(findByType(renderer.root, 'QueryError')).toHaveLength(0); + expect( + findByType(renderer.root, 'Pressable').filter( + node => propOf(node, 'accessibilityLabel') === 'Retry' + ) + ).toHaveLength(0); + expect(findByType(renderer.root, 'UnrelatedScreen')).toHaveLength(1); + expect(transcriptText(renderer, 'RootText')).toBe(''); + expect(rootRequests).toEqual([]); + expect(queryEnabled()).toBe(false); + } + ); + + it('keeps repeated failures recoverable and opens current root and child rows after user Retry', async () => { + const repeatedFailure = Promise.withResolvers<{ id: string }>(); + const success = Promise.withResolvers<{ id: string }>(); + confirmationRequests.getMe + .mockRejectedValueOnce(new Error('offline')) + .mockReturnValueOnce(repeatedFailure.promise) + .mockReturnValueOnce(success.promise); + const renderer = await mountRoute(); + + expect(transcriptText(renderer)).toContain('Could not load your account'); + expect(transcriptText(renderer)).toContain('Check your connection and try again.'); + expect(propOf(retryControl(renderer), 'accessibilityState')).toMatchObject({ + disabled: false, + busy: false, + }); + act(() => { + pressControl(retryControl(renderer)); + }); + expect(findByType(renderer.root, 'QueryError')).toHaveLength(1); + expect(propOf(retryControl(renderer), 'disabled')).toBe(true); + expect(propOf(retryControl(renderer), 'accessibilityState')).toMatchObject({ + disabled: true, + busy: true, + }); + expect(findByType(renderer.root, 'ActivityIndicator')).toHaveLength(1); + expect(rootRequests).toEqual([]); + + await act(async () => { + repeatedFailure.reject(new Error('still offline')); + await Promise.resolve(); + }); + expect(transcriptText(renderer)).toContain('Could not load your account'); + expect(transcriptText(renderer)).toContain('Back to sessions'); + expect(propOf(retryControl(renderer), 'accessibilityState')).toMatchObject({ + disabled: false, + busy: false, + }); + expect(transcriptText(renderer, 'RootText')).toBe(''); + act(() => { + pressControl(retryControl(renderer)); + }); + expect(propOf(retryControl(renderer), 'accessibilityState')).toMatchObject({ + disabled: true, + busy: true, + }); + await act(async () => { + success.resolve({ id: 'user-B' }); + await success.promise; + }); + + expect(getAuthenticatedOwner().userId).toBe('user-B'); + expect(findByType(renderer.root, 'QueryError')).toHaveLength(0); + expect(transcriptText(renderer, 'RootText')).toBe('Account B root row'); + const current = managers.at(-1); + if (!current) { + throw new Error('confirmed route did not initialize its manager'); + } + childPageMock.mockResolvedValueOnce(childPage('msg-current-child', 'Account B child row')); + await act(async () => { + await current.manager.hydrateChildSession(CHILD_ID); + }); + expect(transcriptText(renderer)).toBe('Account B child row'); + }); + + it('leaves failed confirmation through Back to sessions', async () => { + confirmationRequests.getMe.mockRejectedValueOnce(new Error('offline')); + const renderer = await mountRoute(); + const back = findByType(renderer.root, 'Button').find(button => + findByType(button, 'Text').some(text => text.children.includes('Back to sessions')) + ); + act(() => { + pressControl(back); + }); + + expect(navigationRoutes).toEqual(['/(app)/(tabs)/(2_agents)']); + expect(rootRequests).toEqual([]); + }); + + it.each(['success', 'failure'] as const)( + 'keeps successor feedback and ownership unchanged after retired identity %s', + async outcome => { + beginReplacement(); + commitCredentials('A'); + const retired = Promise.withResolvers<{ id: string }>(); + const current = Promise.withResolvers<{ id: string }>(); + confirmationRequests.getMe + .mockReturnValueOnce(retired.promise) + .mockReturnValueOnce(current.promise); + const renderer = await mountRoute(); + act(beginReplacement); + commitCredentials('B'); + await updateRoute(renderer); + const owner = getAuthenticatedOwner(); + await act(async () => { + if (outcome === 'success') { + retired.resolve({ id: 'user-A' }); + } else { + retired.reject(new Error('retired account failure')); + } + await Promise.resolve(); + }); + + expect(getAuthenticatedOwner()).toBe(owner); + expect(owner.userId).toBeNull(); + expect(findByType(renderer.root, 'ScreenHeader')).toHaveLength(1); + expect(findByType(renderer.root, 'SessionSkeletonMessages')).toHaveLength(1); + expect(findByType(renderer.root, 'QueryError')).toHaveLength(0); + expect(transcriptText(renderer, 'RootText')).toBe(''); + expect(rootRequests).toEqual([]); + await act(async () => { + current.resolve({ id: 'user-B' }); + await current.promise; + }); + expect(transcriptText(renderer, 'RootText')).toBe('Account B root row'); + expect(findByType(renderer.root, 'QueryError')).toHaveLength(0); + } + ); +}); + +describe.each([ + { route: 'personal', organizationId: undefined }, + { route: 'explicit organization', organizationId: 'org-a' }, +])('SessionDetailScreen title isolation on $route routes', ({ organizationId }) => { + it.each(['retained replacement', 'fresh pending mount'] as const)( + 'does not restore an inherited title after %s confirms with delayed metadata', + async mountKind => { + const actual = await vi.importActual('@tanstack/react-query'); + useQueryMock.mockImplementation(actual.useQuery); + const routeMetadata = Promise.withResolvers<{ organization_id: string }>(); + const sessionMetadata = Promise.withResolvers(); + const identity = Promise.withResolvers<{ id: string }>(); + confirmationRequests.getMe.mockReturnValueOnce(identity.promise); + queryOptionsMock.mockImplementation(() => ({ + queryKey: [['cliSessionsV2', 'get']], + queryFn: async () => { + const account = requestAccount; + await Promise.resolve(); + return account === 'A' ? { organization_id: 'org-a' } : routeMetadata.promise; + }, + })); + const client = new QueryClient({ + defaultOptions: { queries: { retry: false, gcTime: Infinity } }, + }); + vi.useFakeTimers(); + onTestFinished(() => { + client.clear(); + vi.useRealTimers(); + }); + useLocalSearchParamsMock.mockReturnValue({ + 'session-id': 'sess-1', + organizationId, + title: + mountKind === 'fresh pending mount' + ? ['Account A private title'] + : 'Account A private title', + }); + const tree = createElement( + QueryClientProvider, + { client }, + createElement(SessionDetailScreen) + ); + if (mountKind === 'fresh pending mount') { + beginReplacement(); + commitCredentials('B'); + rootMetadataReady = sessionMetadata.promise; + } + const renderer = await mountRoute(tree); + await act(async () => { + await vi.advanceTimersByTimeAsync(0); + }); + + if (mountKind === 'retained replacement') { + expect(propOf(findByType(renderer.root, 'ScreenHeader')[0], 'title')).toBe( + 'Account A current title' + ); + expect(transcriptText(renderer, 'RootText')).toBe('Account A root row'); + await act(async () => { + rootMetadataReady = sessionMetadata.promise; + beginReplacement(); + commitCredentials('B'); + renderer.update(createElement(UserWebConnectionProvider, null, tree)); + await vi.advanceTimersByTimeAsync(0); + }); + } + + expect(propOf(findByType(renderer.root, 'ScreenHeader')[0], 'title')).toBe('Session'); + expect(findByType(renderer.root, 'SessionSkeletonMessages')).toHaveLength(1); + expect(findByType(renderer.root, 'SessionDetailContent')).toHaveLength(0); + expect(transcriptText(renderer, 'RootText')).toBe(''); + await act(async () => { + identity.resolve({ id: 'user-B' }); + await vi.advanceTimersByTimeAsync(0); + }); + + expect(getAuthenticatedOwner().userId).toBe('user-B'); + expect.soft(propOf(findByType(renderer.root, 'ScreenHeader')[0], 'title')).toBe('Session'); + if (organizationId === undefined) { + expect(findByType(renderer.root, 'SessionSkeletonMessages')).toHaveLength(1); + expect(findByType(renderer.root, 'SessionDetailContent')).toHaveLength(0); + await act(async () => { + routeMetadata.resolve({ organization_id: 'org-b' }); + await vi.advanceTimersByTimeAsync(0); + }); + } + + // Explicit organization routes reach content without resolving the route metadata query. + const content = findByType(renderer.root, 'SessionDetailContent'); + expect(content).toHaveLength(1); + expect.soft(propOf(content[0], 'cachedTitle')).toBeUndefined(); + expect.soft(propOf(findByType(renderer.root, 'ScreenHeader')[0], 'title')).toBe('Session'); + expect(transcriptText(renderer, 'RootText')).toBe(''); + await act(async () => { + sessionMetadata.resolve(undefined); + await vi.advanceTimersByTimeAsync(0); + }); + + expect(propOf(findByType(renderer.root, 'ScreenHeader')[0], 'title')).toBe( + 'Account B current title' + ); + expect(transcriptText(renderer, 'RootText')).toBe('Account B root row'); + act(() => { + authState.isSigningOut = true; + setSignOutActive(true); + beginAuthenticatedOwner(); + }); + expect(renderer.toJSON()).toBeNull(); + } + ); +}); diff --git a/apps/mobile/src/app/(app)/agent-chat/[session-id].tsx b/apps/mobile/src/app/(app)/agent-chat/[session-id].tsx index 1f05f083ed..bed3b37be3 100644 --- a/apps/mobile/src/app/(app)/agent-chat/[session-id].tsx +++ b/apps/mobile/src/app/(app)/agent-chat/[session-id].tsx @@ -1,8 +1,15 @@ import { type KiloSessionId } from '@kilocode/cloud-agent-sdk'; import { type Href, useLocalSearchParams, useRouter } from 'expo-router'; -import { useQuery } from '@tanstack/react-query'; +import { hashKey, useQuery } from '@tanstack/react-query'; import { View } from 'react-native'; import { useTranslation } from 'react-i18next'; +import { useSyncExternalStore } from 'react'; + +import { + getAuthenticatedOwner, + isAuthenticatedOwner, + subscribeAuthenticatedOwner, +} from '@/lib/context-scope'; import { SessionDetailContent } from '@/components/agents/session-detail-content'; import { @@ -12,6 +19,7 @@ import { import { SessionConnectionIndicator } from '@/components/agents/session-connection-indicator'; import { SessionContextMetrics } from '@/components/agents/session-context-metrics'; import { AgentSessionProvider } from '@/components/agents/session-provider'; +import { useIdentityConfirmation } from '@/components/agents/user-web-connection-provider'; import { buildTerminalErrorCopyText } from '@/components/agents/session-terminal-error'; import { performCopy } from '@/components/agents/use-message-copy'; import { InvalidRouteState } from '@/components/invalid-route-state'; @@ -25,6 +33,10 @@ import { shouldRetryNotFoundOnSpawnedRoute } from '@/lib/spawned-not-found-retry import { useTRPC } from '@/lib/trpc'; export default function SessionDetailScreen() { + const owner = useSyncExternalStore(subscribeAuthenticatedOwner, getAuthenticatedOwner); + const confirmation = useIdentityConfirmation(); + const identityPending = !isAuthenticatedOwner(owner); + const identityFailed = identityPending && confirmation.isError; const { 'session-id': rawSessionId, organizationId: routeOrganizationId, @@ -33,7 +45,6 @@ export default function SessionDetailScreen() { shareId: shareIdParam, autoSend: autoSendRaw, mode: modeParam, - title: titleParam, } = useLocalSearchParams<{ 'session-id': string; organizationId?: string; @@ -53,7 +64,7 @@ export default function SessionDetailScreen() { autoSend?: string; /** Agent mode the spawn was started with; seeds the composer before the CLI reports one. */ mode?: string; - /** Title the list row already showed; paints the header on the first frame. */ + /** Legacy title hints remain accepted but carry no account ownership, so ignore them. */ title?: string; }>(); // `session-id` is required: a malformed deep link can hand us `undefined` @@ -64,10 +75,6 @@ export default function SessionDetailScreen() { const shareId = Array.isArray(shareIdParam) ? shareIdParam[0] : shareIdParam; const autoSendParam = Array.isArray(autoSendRaw) ? autoSendRaw[0] : autoSendRaw; const spawnedMode = Array.isArray(modeParam) ? modeParam[0] : modeParam; - // The row the user tapped already showed this title, so the header opens - // with it instead of a generic label that swaps a beat later. Deep links - // and push opens carry no title and keep the fallback. - const cachedTitle = Array.isArray(titleParam) ? titleParam[0] : titleParam; const trpc = useTRPC(); const router = useRouter(); const { t } = useTranslation(); @@ -103,20 +110,31 @@ export default function SessionDetailScreen() { retryDelay: 1000, } ), - enabled: routeOrganizationId === undefined && sessionId !== null, + // Isolate account metadata while preserving the typed tRPC key and prefix invalidation. + queryHash: hashKey([ + ...trpc.cliSessionsV2.get.queryKey({ session_id: sessionId ?? '' }), + owner.authEpoch, + owner.generation, + owner.userId, + ]), + enabled: isAuthenticatedOwner(owner) && routeOrganizationId === undefined && sessionId !== null, }); if (sessionId === null) { return ; } - if (routeOrganizationId === undefined && sessionQuery.isPending) { + if ( + !identityFailed && + (identityPending || (routeOrganizationId === undefined && sessionQuery.isPending)) + ) { // The composer placeholder holds its own height: nothing may shift when - // the query resolves. + // the query resolves. Route title hints are not bound to an account. return ( void sessionQuery.refetch(); const copyText = buildTerminalErrorCopyText({ sessionId, title, message }); return ( - + void sessionQuery.refetch()} - isRetrying={sessionQuery.isFetching} + onRetry={notFound || unauthorized ? undefined : retry} + isRetrying={identityFailed ? confirmation.isPending : sessionQuery.isFetching} />