From 841466b899dad98252cede1202d3eccd9161abb4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Fri, 28 Aug 2026 14:32:41 +0200 Subject: [PATCH 01/12] fix(mobile): bind child sessions to committed account ownership --- .../agent-chat/[session-id].mounted.test.tsx | 576 +++++++++++++++++- .../src/app/(app)/agent-chat/[session-id].tsx | 25 +- .../components/agents/session-provider.tsx | 16 +- ...r-web-connection-provider.mounted.test.tsx | 400 ++++++++++-- .../user-web-connection-provider.test.ts | 110 ++-- .../agents/user-web-connection-provider.tsx | 89 ++- .../mobile/src/lib/auth/auth-context.test.tsx | 232 ++++++- apps/mobile/src/lib/auth/auth-context.tsx | 9 + apps/mobile/src/lib/context-scope.test.ts | 70 +++ apps/mobile/src/lib/context-scope.ts | 64 ++ 10 files changed, 1435 insertions(+), 156 deletions(-) create mode 100644 apps/mobile/src/lib/context-scope.test.ts create mode 100644 apps/mobile/src/lib/context-scope.ts 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..dee1c1dbf9 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,50 @@ /* 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 { 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 { useSessionManager } from '@/components/agents/session-provider'; +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 }[] = []; const queryState = vi.hoisted(() => ({ isPending: false, @@ -28,20 +64,27 @@ 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']], + }, }, }), })); @@ -51,7 +94,32 @@ vi.mock('@/components/invalid-route-state', () => ({ })); vi.mock('@/components/agents/session-detail-content', () => ({ - SessionDetailContent: 'SessionDetailContent', + SessionDetailContent: function SessionDetailContent( + props: Readonly<{ sessionId: KiloSessionId }> + ) { + const manager = useSessionManager(); + const { sessionId } = 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); + return createElement( + 'SessionDetailContent', + props, + 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 +135,15 @@ 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/user-web-connection-provider', () => ({ + useUserWebConnection: () => ({ + subscribeToCliSession: vi.fn(() => vi.fn()), + onSystemEvent: vi.fn(() => vi.fn()), + }), })); vi.mock('@/components/agents/session-terminal-error', () => ({ @@ -115,15 +190,31 @@ 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(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(SessionDetailScreen)); + await Promise.resolve(); + }); } function queryEnabled(): boolean | undefined { @@ -135,7 +226,89 @@ function queryInput(): { session_id?: string } | undefined { return queryOptionsMock.mock.calls[0]?.[0] as { session_id?: string } | undefined; } +function beginReplacement() { + setSignOutActive(true); + authState.isSigningOut = true; + authState.token = undefined; + bumpAuthEpoch(); + authState.authEpoch = currentAuthEpoch(); + beginAuthenticatedOwner(); +} + +function commitAccount(account: 'A' | 'B') { + requestAccount = account; + authState.token = account === 'A' ? 'account-a-token' : 'account-b-token'; + authState.isSigningOut = false; + setSignOutActive(false); + confirmAuthenticatedOwner(getAuthenticatedOwner(), `user-${account}`); +} + beforeEach(() => { + beginReplacement(); + commitAccount('A'); + managers.length = 0; + requestAccount = 'A'; + rootRequests.length = 0; + 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 => { + rootRequests.push({ account: requestAccount, sessionId: id }); + await Promise.resolve(); + return { + kiloSessionId: id, + cloudAgentSessionId: null, + title: 'Scope probe root', + 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() }); @@ -161,41 +334,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 +368,364 @@ 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 ''; + } + return findByType(renderer.root, type) + .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'); }); }); 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..75bb4d39b0 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 { @@ -25,6 +32,7 @@ import { shouldRetryNotFoundOnSpawnedRoute } from '@/lib/spawned-not-found-retry import { useTRPC } from '@/lib/trpc'; export default function SessionDetailScreen() { + const owner = useSyncExternalStore(subscribeAuthenticatedOwner, getAuthenticatedOwner); const { 'session-id': rawSessionId, organizationId: routeOrganizationId, @@ -103,13 +111,24 @@ 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 (!isAuthenticatedOwner(owner)) { + return null; + } + if (routeOrganizationId === undefined && sessionQuery.isPending) { // The composer placeholder holds its own height: nothing may shift when // the query resolves. @@ -196,7 +215,7 @@ export default function SessionDetailScreen() { return ( (null); @@ -24,12 +29,21 @@ export function AgentSessionProvider({ organizationId, }); + const owner = useRef(getAuthenticatedOwner()).current; useEffect(() => { const manager = managerRef.current; + const retire = () => { + if (!isAuthenticatedOwner(owner)) { + manager?.destroy(); + } + }; + const unsubscribe = subscribeAuthenticatedOwner(retire); + retire(); return () => { + unsubscribe(); manager?.destroy(); }; - }, []); + }, [owner]); return ( diff --git a/apps/mobile/src/components/agents/user-web-connection-provider.mounted.test.tsx b/apps/mobile/src/components/agents/user-web-connection-provider.mounted.test.tsx index 8702427664..ccd5cf083f 100644 --- a/apps/mobile/src/components/agents/user-web-connection-provider.mounted.test.tsx +++ b/apps/mobile/src/components/agents/user-web-connection-provider.mounted.test.tsx @@ -1,93 +1,365 @@ -/* eslint-disable typescript-eslint/no-deprecated -- react-test-renderer is the DOM-free renderer used to mount React/RN trees under vitest (same pattern as src/test/render-with-providers.tsx) */ -import { createElement } from 'react'; +/* eslint-disable typescript-eslint/no-deprecated -- react-test-renderer mounts the native provider without a device. */ +/* eslint-disable max-lines -- real connection lifetime cases share the socket and credential fixture. */ +import { createElement, StrictMode } from 'react'; import TestRenderer, { act } from 'react-test-renderer'; -import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { type UserWebConnection } from '@kilocode/cloud-agent-sdk/user-web-connection'; -import { UserWebConnectionProvider } from './user-web-connection-provider'; - -type AuthConfig = { getAuthToken: () => Promise }; +import { bumpAuthEpoch } from '@/lib/auth/auth-epoch'; +import { setSignOutActive } from '@/lib/auth/sign-out-state'; +import { + clearActiveToken, + getActiveToken, + setActiveToken, + setSignOutTeardownActive, +} from '@/lib/auth/token-owner'; +import { + beginAuthenticatedOwner, + getAuthenticatedOwner, + isAuthenticatedOwner, +} from '@/lib/context-scope'; +import { UserWebConnectionProvider, useUserWebConnection } from './user-web-connection-provider'; const mocks = vi.hoisted(() => ({ - mutate: vi.fn(() => ({ token: 'ticket-1', expiresAt: 1_700_000_060 })), + getMe: vi.fn<() => Promise<{ id: string }>>(), + mutate: vi.fn<() => Promise<{ token: string }>>(), query: vi.fn(), - createUserWebConnection: vi.fn(), - capturedConfig: null as AuthConfig | null, -})); - -vi.mock('@kilocode/cloud-agent-sdk/user-web-connection', () => ({ - createUserWebConnection: (config: AuthConfig) => { - mocks.capturedConfig = config; - mocks.createUserWebConnection(config); - return { - retain: vi.fn(() => vi.fn()), - connect: vi.fn(), - disconnect: vi.fn(), - destroy: vi.fn(), - isConnected: vi.fn(() => false), - onConnectionChange: vi.fn(() => vi.fn()), - isReconnectExhausted: vi.fn(() => false), - onReconnectExhaustionChange: vi.fn(() => vi.fn()), - retryConnection: vi.fn(), - subscribeToCliSession: vi.fn(() => vi.fn()), - sendCommand: vi.fn(), - sendCommandToConnection: vi.fn(), - onCliEvent: vi.fn(() => vi.fn()), - onSystemEvent: vi.fn(() => vi.fn()), - onReconnect: vi.fn(() => vi.fn()), - onSessionEvent: vi.fn(() => vi.fn()), - }; + auth: { + token: 'account-a-token' as string | undefined, + isLoading: false, + isSigningOut: false, + sessionEnded: false, }, })); -vi.mock('@/lib/config', () => ({ - SESSION_INGEST_WS_URL: 'wss://ingest.example.com', -})); - +vi.mock('expo-secure-store', () => ({ getItemAsync: vi.fn() })); +vi.mock('@/lib/auth/auth-context', () => ({ useAuth: () => mocks.auth })); +vi.mock('@/lib/config', () => ({ SESSION_INGEST_WS_URL: 'wss://ingest.example.com' })); vi.mock('@/lib/user-web-connection-lifecycle', () => ({ createNativeUserWebConnectionLifecycleHooks: () => ({}), })); - vi.mock('@/lib/trpc', () => ({ trpcClient: { + user: { getMe: { query: mocks.getMe } }, activeSessions: { - createWebTicket: { - mutate: mocks.mutate, - }, - getToken: { - query: mocks.query, - }, + createWebTicket: { mutate: mocks.mutate }, + getToken: { query: mocks.query }, }, }, })); -describe('UserWebConnectionProvider', () => { - beforeEach(() => { - mocks.capturedConfig = null; - mocks.mutate.mockClear(); - mocks.query.mockClear(); - mocks.createUserWebConnection.mockClear(); +const sockets: TestSocket[] = []; +class TestSocket { + static readonly OPEN = 1; + readyState = 0; + onopen: (() => void) | null = null; + onmessage: ((event: { data: string }) => void) | null = null; + onclose: ((event: { code: number }) => void) | null = null; + onerror: (() => void) | null = null; + readonly frames: string[] = []; + + readonly url: string; + + constructor(url: string) { + this.url = url; + sockets.push(this); + } + + send(frame: string) { + this.frames.push(frame); + } + + close() { + this.readyState = 3; + } + + open() { + this.readyState = 1; + this.onopen?.(); + this.receive({ type: 'system', event: 'sessions.list', data: { sessions: [] } }); + } + + receive(message: unknown) { + this.onmessage?.({ data: JSON.stringify(message) }); + } +} + +const renderers: TestRenderer.ReactTestRenderer[] = []; +const connections: UserWebConnection[] = []; +const identityCredentials: (string | undefined)[] = []; +const ticketCredentials: (string | undefined)[] = []; + +function Consumer() { + connections.push(useUserWebConnection()); + return createElement('ConnectionConsumer'); +} + +function connectionTree() { + return createElement(UserWebConnectionProvider, null, createElement(Consumer)); +} + +async function mountConnection(strict = false) { + const holder: { renderer?: TestRenderer.ReactTestRenderer } = {}; + await act(async () => { + holder.renderer = TestRenderer.create( + strict ? createElement(StrictMode, null, connectionTree()) : connectionTree() + ); + await Promise.resolve(); }); + if (!holder.renderer) { + throw new Error('connection provider did not mount'); + } + renderers.push(holder.renderer); + return holder.renderer; +} - it('mints the ingest ticket via the createWebTicket mutation', async () => { - const holder: { renderer?: TestRenderer.ReactTestRenderer } = {}; - await act(() => { - holder.renderer = TestRenderer.create(createElement(UserWebConnectionProvider, null)); - }); +async function updateConnection(renderer: TestRenderer.ReactTestRenderer) { + await act(async () => { + renderer.update(connectionTree()); + await Promise.resolve(); + }); +} + +function currentConnection() { + const connection = connections.at(-1); + if (!connection) { + throw new Error('connection consumer did not mount'); + } + return connection; +} + +function currentSocket() { + const socket = sockets.at(-1); + if (!socket) { + throw new Error('connection did not open a socket'); + } + return socket; +} + +function beginReplacement() { + mocks.auth.token = undefined; + mocks.auth.isSigningOut = true; + setSignOutTeardownActive(true); + setSignOutActive(true); + bumpAuthEpoch(); + beginAuthenticatedOwner(); + clearActiveToken(); +} - expect(mocks.createUserWebConnection).toHaveBeenCalledTimes(1); - const config = mocks.capturedConfig; - if (!config) { - throw new Error('user web connection config not captured'); +function commitCredentials() { + setActiveToken('account-b-token', null); + setSignOutTeardownActive(false); + setSignOutActive(false); + mocks.auth.token = 'account-b-token'; + mocks.auth.isSigningOut = false; +} + +beforeEach(() => { + vi.useFakeTimers(); + vi.spyOn(Math, 'random').mockReturnValue(0.5); + vi.stubGlobal('WebSocket', TestSocket); + sockets.length = 0; + connections.length = 0; + identityCredentials.length = 0; + ticketCredentials.length = 0; + mocks.query.mockClear(); + mocks.auth.token = 'account-a-token'; + mocks.auth.isSigningOut = false; + setSignOutActive(false); + setSignOutTeardownActive(false); + bumpAuthEpoch(); + beginAuthenticatedOwner(); + setActiveToken('account-a-token', null); + mocks.getMe.mockReset().mockImplementation(async () => { + const token = getActiveToken()?.token; + identityCredentials.push(token); + await Promise.resolve(); + return { id: token === 'account-b-token' ? 'user-b' : 'user-a' }; + }); + mocks.mutate.mockReset().mockImplementation(async () => { + const token = getActiveToken()?.token; + ticketCredentials.push(token); + await Promise.resolve(); + return { token: token === 'account-b-token' ? 'ticket-b' : 'ticket-a' }; + }); +}); + +afterEach(() => { + act(() => { + for (const renderer of renderers.splice(0)) { + renderer.unmount(); } + }); + for (const connection of connections) { + connection.destroy(); + } + vi.unstubAllGlobals(); + vi.useRealTimers(); + vi.restoreAllMocks(); +}); - const token = await config.getAuthToken(); +describe('UserWebConnectionProvider ownership', () => { + it('mints the ingest ticket via the createWebTicket mutation under confirmed credentials', async () => { + await mountConnection(); + currentSocket().open(); - expect(token).toBe('ticket-1'); - expect(mocks.mutate).toHaveBeenCalledTimes(1); + expect(new URL(currentSocket().url).searchParams.get('ticket')).toBe('ticket-a'); + expect(identityCredentials).toEqual(['account-a-token']); + expect(ticketCredentials).toEqual(['account-a-token']); + expect(getAuthenticatedOwner().userId).toBe('user-a'); + expect(currentConnection().isConnected()).toBe(true); expect(mocks.query).not.toHaveBeenCalled(); + }); + + it('starts no old-account work on a fresh pending mount and opens a committed successor', async () => { + beginReplacement(); + const renderer = await mountConnection(); + expect(renderer.toJSON()).toBeNull(); + expect(identityCredentials).toEqual([]); + expect(ticketCredentials).toEqual([]); + expect(sockets).toHaveLength(0); + + commitCredentials(); + await updateConnection(renderer); + currentSocket().open(); + expect(getAuthenticatedOwner().userId).toBe('user-b'); + expect(identityCredentials).toEqual(['account-b-token']); + expect(ticketCredentials).toEqual(['account-b-token']); + expect(currentConnection().isConnected()).toBe(true); + }); + + it.each(['replacement', 'early logout'] as const)( + 'destroys the old connection during %s even with a retained session consumer', + async transition => { + const renderer = await mountConnection(); + const previous = currentConnection(); + const socket = currentSocket(); + socket.open(); + const releaseSession = previous.subscribeToCliSession('old-root'); + expect(socket.frames).toContain(JSON.stringify({ type: 'subscribe', sessionId: 'old-root' })); + + act(() => { + if (transition === 'replacement') { + beginReplacement(); + } else { + mocks.auth.isSigningOut = true; + setSignOutActive(true); + beginAuthenticatedOwner(); + } + // Observe retirement before React unmounts the consumer or releases its retain. + expect(socket.readyState).toBe(3); + expect(previous.isConnected()).toBe(false); + }); + await expect(previous.sendCommand('old-root', 'send_message', {})).rejects.toThrow( + 'Connection destroyed' + ); + releaseSession(); + + commitCredentials(); + await updateConnection(renderer); + currentSocket().open(); + expect(currentConnection()).not.toBe(previous); + expect(currentConnection().isConnected()).toBe(true); + expect(new URL(currentSocket().url).searchParams.get('ticket')).toBe('ticket-b'); + } + ); + + it('rejects a late getMe response after replacement and keeps the successor identity', async () => { + const identity = Promise.withResolvers<{ id: string }>(); + mocks.getMe.mockReturnValueOnce(identity.promise); + const renderer = await mountConnection(); + expect(getAuthenticatedOwner().userId).toBeNull(); + expect(sockets).toHaveLength(0); + + act(beginReplacement); + commitCredentials(); + await updateConnection(renderer); + await act(async () => { + identity.resolve({ id: 'user-a' }); + await identity.promise; + }); + + expect(getAuthenticatedOwner().userId).toBe('user-b'); + expect(ticketCredentials).toEqual(['account-b-token']); + expect(sockets).toHaveLength(1); + expect(new URL(currentSocket().url).searchParams.get('ticket')).toBe('ticket-b'); + }); + + it('rejects a pending old ticket after replacement while the successor connects', async () => { + const ticket = Promise.withResolvers<{ token: string }>(); + mocks.mutate.mockReturnValueOnce(ticket.promise); + const renderer = await mountConnection(); + const previous = currentConnection(); + const releaseSession = previous.subscribeToCliSession('old-root'); + expect(isAuthenticatedOwner(getAuthenticatedOwner())).toBe(true); + expect(sockets).toHaveLength(0); - await act(() => { - holder.renderer?.unmount(); + act(beginReplacement); + commitCredentials(); + await updateConnection(renderer); + await act(async () => { + ticket.resolve({ token: 'late-ticket-a' }); + await ticket.promise; }); + + expect(sockets).toHaveLength(1); + expect(new URL(currentSocket().url).searchParams.get('ticket')).toBe('ticket-b'); + expect(previous.isConnected()).toBe(false); + releaseSession(); + }); + + it('preserves the live connection, subscription, and owner during ordinary refresh', async () => { + const renderer = await mountConnection(); + const connection = currentConnection(); + const socket = currentSocket(); + const owner = getAuthenticatedOwner(); + socket.open(); + const releaseSession = connection.subscribeToCliSession('current-root'); + const events: string[] = []; + const unsubscribe = connection.onSystemEvent(event => { + events.push(event.event); + }); + + setActiveToken('account-a-refreshed-token', null); + mocks.auth.token = 'account-a-refreshed-token'; + await updateConnection(renderer); + socket.receive({ type: 'system', event: 'sessions.list', data: { sessions: [] } }); + + expect(getAuthenticatedOwner()).toBe(owner); + expect(currentConnection()).toBe(connection); + expect(connection.isConnected()).toBe(true); + expect(sockets).toHaveLength(1); + expect(socket.frames).toEqual([ + JSON.stringify({ type: 'subscribe', sessionId: 'current-root' }), + ]); + expect(events).toEqual(['sessions.list']); + expect(identityCredentials).toEqual(['account-a-token']); + expect(ticketCredentials).toEqual(['account-a-token']); + unsubscribe(); + releaseSession(); + }); + + it('recovers identity confirmation through the existing connection retry after a transient failure', async () => { + mocks.getMe.mockRejectedValueOnce(new Error('offline')); + await mountConnection(); + expect(getAuthenticatedOwner().userId).toBeNull(); + expect(sockets).toHaveLength(0); + + await act(async () => { + await vi.advanceTimersByTimeAsync(1000); + }); + + expect(getAuthenticatedOwner().userId).toBe('user-a'); + currentSocket().open(); + expect(currentConnection().isConnected()).toBe(true); + }); + + it('keeps a current connection usable after StrictMode effect replay', async () => { + await mountConnection(true); + currentSocket().open(); + + expect(sockets).toHaveLength(1); + expect(getAuthenticatedOwner().userId).toBe('user-a'); + expect(currentConnection().isConnected()).toBe(true); }); }); diff --git a/apps/mobile/src/components/agents/user-web-connection-provider.test.ts b/apps/mobile/src/components/agents/user-web-connection-provider.test.ts index 4beaf0b4da..1dd3107c9b 100644 --- a/apps/mobile/src/components/agents/user-web-connection-provider.test.ts +++ b/apps/mobile/src/components/agents/user-web-connection-provider.test.ts @@ -1,85 +1,111 @@ -/* eslint-disable typescript-eslint/no-deprecated, react/no-children-prop -- react-test-renderer is the DOM-free renderer used to mount React/RN trees under vitest (same pattern as user-web-connection-provider.mounted.test.tsx); `children` must be passed as a prop because this is a .ts file with no JSX */ +/* eslint-disable typescript-eslint/no-deprecated, react/no-children-prop -- the .ts fixture uses react-test-renderer without JSX. */ import { createElement } from 'react'; 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 { bumpAuthEpoch } from '@/lib/auth/auth-epoch'; +import { setSignOutActive } from '@/lib/auth/sign-out-state'; +import { setActiveToken } from '@/lib/auth/token-owner'; +import { + beginAuthenticatedOwner, + confirmAuthenticatedOwner, + getAuthenticatedOwner, +} from '@/lib/context-scope'; import { UserWebConnectionProvider } from './user-web-connection-provider'; type AuthConfig = { getAuthToken: () => Promise }; - const mocks = vi.hoisted(() => ({ - mutate: vi.fn(async () => { - await Promise.resolve(); - return { token: 'ticket-1', expiresAt: 1_700_000_060 }; - }), + mutate: vi.fn<() => Promise<{ token: string; expiresAt: number }>>(), query: vi.fn(), capturedConfig: null as AuthConfig | null, })); +vi.mock('expo-secure-store', () => ({ getItemAsync: vi.fn() })); +vi.mock('@/lib/auth/auth-context', () => ({ + useAuth: () => ({ + token: 'account-a-token', + isLoading: false, + isSigningOut: false, + sessionEnded: false, + }), +})); vi.mock('@kilocode/cloud-agent-sdk/user-web-connection', () => ({ createUserWebConnection: (config: AuthConfig) => { mocks.capturedConfig = config; - return { retain: () => vi.fn() }; + return { retain: () => vi.fn(), destroy: vi.fn() }; }, })); - -vi.mock('@/lib/config', () => ({ - SESSION_INGEST_WS_URL: 'wss://ingest.example.com', -})); - +vi.mock('@/lib/config', () => ({ SESSION_INGEST_WS_URL: 'wss://ingest.example.com' })); vi.mock('@/lib/user-web-connection-lifecycle', () => ({ createNativeUserWebConnectionLifecycleHooks: () => ({}), })); - vi.mock('@/lib/trpc', () => ({ trpcClient: { + user: { getMe: { query: vi.fn().mockResolvedValue({ id: 'user-a' }) } }, activeSessions: { - createWebTicket: { - mutate: mocks.mutate, - }, - getToken: { - query: mocks.query, - }, + createWebTicket: { mutate: mocks.mutate }, + getToken: { query: mocks.query }, }, }, })); -describe('UserWebConnectionProvider', () => { +async function mountTicketProducer() { + const holder: { renderer?: TestRenderer.ReactTestRenderer } = {}; + await act(async () => { + holder.renderer = TestRenderer.create( + createElement(UserWebConnectionProvider, { children: null }) + ); + await Promise.resolve(); + }); + onTestFinished(() => { + act(() => holder.renderer?.unmount()); + }); + if (!mocks.capturedConfig) { + throw new Error('createUserWebConnection was not called'); + } + return mocks.capturedConfig; +} + +describe('UserWebConnectionProvider ticket fencing', () => { beforeEach(() => { + setSignOutActive(false); + bumpAuthEpoch(); + beginAuthenticatedOwner(); + setActiveToken('account-a-token', null); mocks.capturedConfig = null; - mocks.mutate.mockClear(); + mocks.mutate.mockReset().mockResolvedValue({ token: 'ticket-1', expiresAt: 1_700_000_060 }); mocks.query.mockClear(); }); it('mints the ingest ticket via the createWebTicket mutation (not the getToken query)', async () => { - const rendererRef: { current: TestRenderer.ReactTestRenderer | undefined } = { - current: undefined, - }; - await act(async () => { - await Promise.resolve(); - rendererRef.current = TestRenderer.create( - createElement(UserWebConnectionProvider, { children: null }) - ); - }); - - const config = mocks.capturedConfig; - if (!config) { - throw new Error('createUserWebConnection was not called'); - } - + const config = await mountTicketProducer(); const token = await config.getAuthToken(); expect(token).toBe('ticket-1'); expect(mocks.mutate).toHaveBeenCalledTimes(1); expect(mocks.query).not.toHaveBeenCalled(); + }); - const renderer = rendererRef.current; - if (!renderer) { - throw new Error('renderer was not created'); - } + it('rejects rather than returns a ticket completed under a retired generation', async () => { + const ticket = Promise.withResolvers<{ token: string; expiresAt: number }>(); + mocks.mutate.mockReturnValueOnce(ticket.promise); + const config = await mountTicketProducer(); + const result = config.getAuthToken(); + const rejection = expect(result).rejects.toThrow('Authenticated owner changed'); await act(async () => { await Promise.resolve(); - renderer.unmount(); }); + act(() => { + setSignOutActive(true); + bumpAuthEpoch(); + beginAuthenticatedOwner(); + setActiveToken('account-b-token', null); + setSignOutActive(false); + confirmAuthenticatedOwner(getAuthenticatedOwner(), 'user-b'); + }); + ticket.resolve({ token: 'retired-ticket', expiresAt: 1_700_000_060 }); + + await rejection; + await expect(config.getAuthToken()).rejects.toThrow('Authenticated owner changed'); }); }); diff --git a/apps/mobile/src/components/agents/user-web-connection-provider.tsx b/apps/mobile/src/components/agents/user-web-connection-provider.tsx index e164dfc39d..c2beebde05 100644 --- a/apps/mobile/src/components/agents/user-web-connection-provider.tsx +++ b/apps/mobile/src/components/agents/user-web-connection-provider.tsx @@ -1,16 +1,25 @@ -import { createContext, type ReactNode, useContext, useEffect, useRef } from 'react'; +import { + createContext, + type ReactNode, + useContext, + useEffect, + useRef, + useSyncExternalStore, +} from 'react'; import { type UserWebConnection } from '@kilocode/cloud-agent-sdk'; -// kilocode_change - K1/C2: `createUserWebConnection` must come from its -// narrow subpath, not the `cloud-agent-sdk` barrel. The barrel's index.ts -// also re-exports web-only transport code that imports a web-app `@/...` -// alias unresolved under the mobile app's own `@` alias — this previously -// went unnoticed because nothing under `apps/mobile` had a test that -// actually imported this provider (and thus the barrel) at runtime until -// the `kilo remote` spawn hook test did. See the matching -// vitest.config.ts aliases for the full explanation. +// Use the narrow subpath: the barrel also loads web-only transport imports. import { createUserWebConnection } from '@kilocode/cloud-agent-sdk/user-web-connection'; +import { useAuth } from '@/lib/auth/auth-context'; +import { getActiveToken } from '@/lib/auth/token-owner'; import { SESSION_INGEST_WS_URL } from '@/lib/config'; +import { + type AuthenticatedOwner, + confirmAuthenticatedOwner, + getAuthenticatedOwner, + isCurrentOwner, + subscribeAuthenticatedOwner, +} from '@/lib/context-scope'; import { createNativeUserWebConnectionLifecycleHooks } from '@/lib/user-web-connection-lifecycle'; import { trpcClient } from '@/lib/trpc'; @@ -21,24 +30,74 @@ type UserWebConnectionProviderProps = { }; export function UserWebConnectionProvider({ children }: Readonly) { + const { token, isLoading, isSigningOut, sessionEnded } = useAuth(); + const owner = useSyncExternalStore(subscribeAuthenticatedOwner, getAuthenticatedOwner); + if (!token || isLoading || isSigningOut || sessionEnded || !isCurrentOwner(owner)) { + return null; + } + return ( + + {children} + + ); +} + +type OwnedUserWebConnectionProviderProps = UserWebConnectionProviderProps & { + owner: AuthenticatedOwner; +}; + +function OwnedUserWebConnectionProvider({ + children, + owner, +}: Readonly) { + const captured = useRef(owner).current; const connectionRef = useRef(null); connectionRef.current ??= createUserWebConnection({ websocketUrl: `${SESSION_INGEST_WS_URL}/api/user/web`, getAuthToken: async () => { + if (!isCurrentOwner(captured) || !getActiveToken()) { + throw new Error('Authenticated owner changed'); + } + if (getAuthenticatedOwner().userId === null) { + // Never confirm from a cached getMe result or a decoded bearer token. + const user = await trpcClient.user.getMe.query(); + if (!confirmAuthenticatedOwner(captured, user.id)) { + throw new Error('Authenticated owner changed'); + } + } + if (!isCurrentOwner(captured)) { + throw new Error('Authenticated owner changed'); + } const result = await trpcClient.activeSessions.createWebTicket.mutate(); + if (!isCurrentOwner(captured)) { + throw new Error('Authenticated owner changed'); + } return result.token; }, lifecycleHooks: createNativeUserWebConnectionLifecycleHooks(), }); + const connection = connectionRef.current; - // Retain the connection for the provider lifetime. The retain return value - // IS the release, so the effect cleanup releases it; zero retains stops the - // socket reversibly. An effect replay (StrictMode development double-mount) - // re-retains and reconnects instead of destroying the connection. - useEffect(() => connectionRef.current?.retain(), []); + useEffect(() => { + // Ownership revocation must win even while a session holds another retain. + const retire = () => { + if (!isCurrentOwner(captured)) { + connection.destroy(); + } + }; + const unsubscribe = subscribeAuthenticatedOwner(retire); + retire(); + const release = connection.retain(); + return () => { + unsubscribe(); + release(); + // Effect replay keeps a current connection reusable; account changes do not. + retire(); + }; + }, [captured, connection]); return ( - + {children} ); diff --git a/apps/mobile/src/lib/auth/auth-context.test.tsx b/apps/mobile/src/lib/auth/auth-context.test.tsx index 151891b4bf..123bc89458 100644 --- a/apps/mobile/src/lib/auth/auth-context.test.tsx +++ b/apps/mobile/src/lib/auth/auth-context.test.tsx @@ -1,10 +1,13 @@ +/// /* oxlint-disable typescript-eslint/no-deprecated -- react-test-renderer is the DOM-free renderer for RN trees under vitest (node env, no jsdom) */ /* oxlint-disable @typescript-eslint/no-unsafe-call @typescript-eslint/no-unsafe-member-access */ /* eslint-disable max-lines -- one cohesive auth-context suite: sign-out teardown ordering and stale sign-in fencing share the provider mount and the SecureStore mock */ import { createElement } from 'react'; 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 type * as AuthContextModule from './auth-context'; +import type * as ContextScopeModule from '../context-scope'; +import type * as TokenOwnerModule from './token-owner'; // ---- hoisted mocks ---- @@ -106,6 +109,38 @@ const consentMock = vi.hoisted(() => ({ clearPendingConsentOutcome: vi.fn(), })); +const ownerProducer = vi.hoisted(() => ({ + getMe: vi.fn<() => Promise<{ id: string }>>().mockResolvedValue({ id: 'user-a' }), + ticket: vi.fn().mockResolvedValue({ token: 'ingest-ticket' }), + getAuthToken: undefined as (() => Promise) | undefined, +})); + +vi.mock('@/lib/trpc', () => ({ + trpcClient: { + user: { getMe: { query: ownerProducer.getMe } }, + activeSessions: { createWebTicket: { mutate: ownerProducer.ticket } }, + }, +})); +vi.mock('@/lib/user-web-connection-lifecycle', () => ({ + createNativeUserWebConnectionLifecycleHooks: () => ({}), +})); +// Capture the real provider's credential callback. The mounted connection suite uses the real SDK. +vi.mock('@kilocode/cloud-agent-sdk/user-web-connection', () => ({ + createUserWebConnection: (config: { getAuthToken: () => Promise }) => { + ownerProducer.getAuthToken = config.getAuthToken; + return { retain: () => vi.fn(), destroy: vi.fn() }; + }, +})); + +async function requestOwnerTicket() { + const request = ownerProducer.getAuthToken; + if (!request) { + throw new Error('committed connection producer did not mount'); + } + const token = await request(); + return token; +} + // ---- all vi.mock calls ---- vi.mock('expo-secure-store', () => ({ @@ -246,6 +281,7 @@ vi.mock('@/lib/storage-keys', () => ({ vi.mock('@/lib/config', () => ({ API_BASE_URL: 'https://api.example.com', + SESSION_INGEST_WS_URL: 'wss://ingest.example.com', })); vi.mock('react-native', () => ({ @@ -1071,12 +1107,18 @@ describe('reactive auth epoch', () => { /** Mount the provider with a consumer that re-captures the context on every * render, so the test can read the epoch after sign-in or sign-out moved it. */ // oxlint-disable-next-line require-await -- dynamic import is awaited - async function mountEpochTest(): Promise<{ + async function mountEpochTest(withConnection = false): Promise<{ getCtx: () => AuthContextValue; unmount: () => void; }> { vi.resetModules(); + ownerProducer.getAuthToken = undefined; + ownerProducer.getMe.mockReset().mockResolvedValue({ id: 'user-a' }); const mod = await import('./auth-context'); + const connectionModule = withConnection + ? await import('../../components/agents/user-web-connection-provider') + : null; + const ConnectionProvider = connectionModule?.UserWebConnectionProvider; let capturedCtx: AuthContextValue | undefined = undefined; function Consumer(): null { @@ -1086,8 +1128,14 @@ describe('reactive auth epoch', () => { let renderer: TestRenderer.ReactTestRenderer | undefined = undefined; await act(async () => { + const consumer = createElement(Consumer); renderer = TestRenderer.create( - createElement(mod.AuthProvider, null, createElement(Consumer)) + createElement( + mod.AuthProvider, + null, + consumer, + ConnectionProvider ? createElement(ConnectionProvider, null, null) : null + ) ); await Promise.resolve(); }); @@ -1111,6 +1159,184 @@ describe('reactive auth epoch', () => { }; } + it('publishes pending ownership before credential writes and confirms only the committed successor', async () => { + const { getCtx, unmount } = await mountEpochTest(true); + onTestFinished(() => act(unmount)); + const scope: typeof ContextScopeModule = await import('../context-scope'); + const tokens: typeof TokenOwnerModule = await import('./token-owner'); + await act(async () => { + await getCtx().signIn('account-a-token'); + }); + await act(async () => { + await requestOwnerTicket(); + }); + const previous = scope.getAuthenticatedOwner(); + expect(previous.userId).toBe('user-a'); + // The old credentials remain readable on disk while the replacement write is held. + hoisted.secureStore.getItemAsync.mockResolvedValue('account-a-token'); + + const published: { userId: string | null; token: string | null }[] = []; + const unsubscribe = scope.subscribeAuthenticatedOwner(() => { + published.push({ + userId: scope.getAuthenticatedOwner().userId, + token: tokens.getActiveToken()?.token ?? null, + }); + }); + onTestFinished(unsubscribe); + const write = Promise.withResolvers(); + onTestFinished(() => { + write.resolve(undefined); + }); + hoisted.secureStore.setItemAsync.mockImplementationOnce(async () => { + await write.promise; + }); + const transition: { promise?: Promise } = {}; + await act(async () => { + transition.promise = getCtx().signIn('account-b-token'); + await Promise.resolve(); + }); + + expect(published).toEqual([{ userId: null, token: null }]); + expect(scope.isAuthenticatedOwner(previous)).toBe(false); + expect(getCtx().token).toBeUndefined(); + expect(getCtx().isSigningOut).toBe(true); + await expect(tokens.getAuthTokenForRequest()).resolves.toBeNull(); + await expect(requestOwnerTicket()).rejects.toThrow('Authenticated owner changed'); + + await act(async () => { + write.resolve(undefined); + await transition.promise; + }); + expect(scope.getAuthenticatedOwner().userId).toBeNull(); + const requestedTokens: (string | undefined)[] = []; + ownerProducer.getMe.mockImplementationOnce(async () => { + requestedTokens.push(tokens.getActiveToken()?.token); + await Promise.resolve(); + return { id: 'user-b' }; + }); + await act(async () => { + await requestOwnerTicket(); + }); + + expect(requestedTokens).toEqual(['account-b-token']); + expect(scope.getAuthenticatedOwner().userId).toBe('user-b'); + expect(scope.getAuthenticatedOwner().generation).toBeGreaterThan(previous.generation); + expect(getCtx().token).toBe('account-b-token'); + expect(getCtx().isSigningOut).toBe(false); + }); + + it('confirms a restored account from getMe rather than the decoded token hint', async () => { + const token = makeToken({ kiloUserId: 'unconfirmed-hint' }); + hoisted.secureStore.getItemAsync + .mockResolvedValueOnce(token) + .mockResolvedValueOnce('stored-refresh') + .mockResolvedValueOnce('9999999999999') + .mockResolvedValueOnce(token); + const { unmount } = await mountEpochTest(true); + onTestFinished(() => act(unmount)); + const scope: typeof ContextScopeModule = await import('../context-scope'); + expect(scope.getAuthenticatedOwner().userId).toBeNull(); + + await act(async () => { + await requestOwnerTicket(); + }); + + expect(scope.getAuthenticatedOwner().userId).toBe('user-a'); + expect(scope.isAuthenticatedOwner(scope.getAuthenticatedOwner())).toBe(true); + }); + + it('rejects a prior account getMe completion after the current account confirms', async () => { + const { getCtx, unmount } = await mountEpochTest(true); + onTestFinished(() => act(unmount)); + await act(async () => { + await getCtx().signIn('account-a-token'); + }); + const identity = Promise.withResolvers<{ id: string }>(); + ownerProducer.getMe.mockReturnValueOnce(identity.promise); + const stale = requestOwnerTicket(); + const rejection = expect(stale).rejects.toThrow('Authenticated owner changed'); + + await act(async () => { + await getCtx().signIn('account-b-token'); + }); + ownerProducer.getMe.mockResolvedValueOnce({ id: 'user-b' }); + await act(async () => { + await requestOwnerTicket(); + identity.resolve({ id: 'user-a' }); + await rejection; + }); + + const scope: typeof ContextScopeModule = await import('../context-scope'); + expect(scope.getAuthenticatedOwner().userId).toBe('user-b'); + expect(getCtx().token).toBe('account-b-token'); + }); + + it('revokes the confirmed owner before remote logout cleanup or the epoch bump', async () => { + const { getCtx, unmount } = await mountEpochTest(true); + onTestFinished(() => act(unmount)); + await act(async () => { + await getCtx().signIn('account-a-token'); + }); + await act(async () => { + await requestOwnerTicket(); + }); + const scope: typeof ContextScopeModule = await import('../context-scope'); + const previous = scope.getAuthenticatedOwner(); + const cleanup = Promise.withResolvers(); + onTestFinished(() => { + cleanup.resolve(undefined); + }); + logoutCleanupMock.runLogoutCleanup.mockReturnValueOnce(cleanup.promise); + const transition: { promise?: Promise } = {}; + await act(async () => { + transition.promise = getCtx().signOut(); + await Promise.resolve(); + }); + + expect(getCtx().authEpoch).toBe(previous.authEpoch); + expect(getCtx().isSigningOut).toBe(true); + expect(scope.getAuthenticatedOwner().userId).toBeNull(); + expect(scope.isAuthenticatedOwner(previous)).toBe(false); + await expect(requestOwnerTicket()).rejects.toThrow('Authenticated owner changed'); + + await act(async () => { + cleanup.resolve(undefined); + await transition.promise; + }); + expect(getCtx().token).toBeUndefined(); + }); + + it('keeps confirmed ownership stable during real request-time credential refresh', async () => { + const { getCtx, unmount } = await mountEpochTest(true); + onTestFinished(() => act(unmount)); + await act(async () => { + await getCtx().signIn('account-a-token'); + }); + await act(async () => { + await requestOwnerTicket(); + }); + const scope: typeof ContextScopeModule = await import('../context-scope'); + const owner = scope.getAuthenticatedOwner(); + const { performRefresh } = await import('@/lib/auth/credentials'); + const tokens: typeof TokenOwnerModule = await import('./token-owner'); + hoisted.secureStore.getItemAsync.mockResolvedValueOnce('refresh-a'); + const fetch = vi + .spyOn(globalThis, 'fetch') + .mockResolvedValueOnce( + Response.json({ token: 'refreshed-token', refreshToken: 'refreshed-pair', expiresIn: 3600 }) + ); + onTestFinished(() => { + fetch.mockRestore(); + }); + + const outcome = await performRefresh(); + + expect(outcome.ok).toBe(true); + expect(tokens.getActiveToken()?.token).toBe('refreshed-token'); + expect(scope.getAuthenticatedOwner()).toBe(owner); + expect(scope.isAuthenticatedOwner(owner)).toBe(true); + }); + it('exposes the current auth epoch in the context value', async () => { const { getCtx, unmount } = await mountEpochTest(); const { currentAuthEpoch } = await import('@/lib/auth/auth-epoch'); diff --git a/apps/mobile/src/lib/auth/auth-context.tsx b/apps/mobile/src/lib/auth/auth-context.tsx index 3e2cfb2797..d5783531a2 100644 --- a/apps/mobile/src/lib/auth/auth-context.tsx +++ b/apps/mobile/src/lib/auth/auth-context.tsx @@ -75,6 +75,7 @@ import { clearTelemetryDecision } from '@/lib/telemetry/controller'; import { clearSentryUser } from '@/lib/sentry-context'; import { purgePostHogPersistence } from '@/lib/telemetry/posthog-storage'; import { AppState } from 'react-native'; +import { beginAuthenticatedOwner } from '@/lib/context-scope'; // Pre-load tokens at module level so they're available before React mounts export const preloadedAuthToken = SecureStore.getItemAsync(AUTH_TOKEN_KEY); @@ -210,8 +211,14 @@ export function AuthProvider({ children }: { readonly children: ReactNode }) { // full teardown, and a sign-out queued behind a sign-in signs that new // session out (documented, correct FIFO semantics). await chainSave('auth-transition', async () => { + // Close admission before publishing the pending generation or writing credentials. + setSignOutTeardownActive(true); + setSignOutActive(true); bumpAuthEpoch(); + beginAuthenticatedOwner(); setAuthEpoch(currentAuthEpoch()); + setToken(undefined); + clearActiveToken(); // Bind the pending deep-link slot to the new user id at the same // place the auth epoch advances, so a destination captured while this // account is signed in restores only for this account. @@ -271,6 +278,7 @@ export function AuthProvider({ children }: { readonly children: ReactNode }) { // the read-cache mount unsubscribes and cannot resubscribe while the old // user id is still cached. setSignOutActive(true); + beginAuthenticatedOwner(); // Drop an account-bound pending deep-link destination synchronously, // before the first await, so a different account signed in later in this // process cannot navigate to the previous account's destination. A @@ -319,6 +327,7 @@ export function AuthProvider({ children }: { readonly children: ReactNode }) { // fence) and before any local deletion (no deferred save can land // after it). bumpAuthEpoch(); + beginAuthenticatedOwner(); setAuthEpoch(currentAuthEpoch()); // The signed-out session owns no user id: a destination captured // during teardown is recorded as "captured while signed out". diff --git a/apps/mobile/src/lib/context-scope.test.ts b/apps/mobile/src/lib/context-scope.test.ts new file mode 100644 index 0000000000..e690d5c7e8 --- /dev/null +++ b/apps/mobile/src/lib/context-scope.test.ts @@ -0,0 +1,70 @@ +import { beforeEach, describe, expect, it } from 'vitest'; + +import { bumpAuthEpoch } from '@/lib/auth/auth-epoch'; +import { setSignOutActive } from '@/lib/auth/sign-out-state'; +import { + beginAuthenticatedOwner, + confirmAuthenticatedOwner, + getAuthenticatedOwner, + isAuthenticatedOwner, + subscribeAuthenticatedOwner, +} from './context-scope'; + +describe('authenticated ownership', () => { + beforeEach(() => { + setSignOutActive(false); + bumpAuthEpoch(); + beginAuthenticatedOwner(); + }); + + it('revokes the confirmed owner synchronously when replacement begins', () => { + confirmAuthenticatedOwner(getAuthenticatedOwner(), 'user-a'); + const previous = getAuthenticatedOwner(); + const visibleOwners: (string | null)[] = []; + const unsubscribe = subscribeAuthenticatedOwner(() => { + visibleOwners.push(getAuthenticatedOwner().userId); + }); + + beginAuthenticatedOwner(); + + expect(isAuthenticatedOwner(previous)).toBe(false); + expect(visibleOwners).toEqual([null]); + unsubscribe(); + }); + + it('rejects a late confirmation even when replacement stays in the same epoch', () => { + const previous = getAuthenticatedOwner(); + const current = beginAuthenticatedOwner(); + confirmAuthenticatedOwner(current, 'user-b'); + + expect(confirmAuthenticatedOwner(previous, 'user-a')).toBe(false); + expect(getAuthenticatedOwner().userId).toBe('user-b'); + }); + + it('keeps repeated current confirmation stable and rejects conflicting identity', () => { + const pending = getAuthenticatedOwner(); + confirmAuthenticatedOwner(pending, 'user-a'); + const confirmed = getAuthenticatedOwner(); + + expect(confirmAuthenticatedOwner(pending, 'user-a')).toBe(true); + expect(confirmAuthenticatedOwner(pending, 'user-b')).toBe(false); + expect(getAuthenticatedOwner()).toBe(confirmed); + expect(isAuthenticatedOwner(confirmed)).toBe(true); + }); + + it('rejects confirmation while sign-out closes admission before the epoch moves', () => { + const pending = getAuthenticatedOwner(); + setSignOutActive(true); + + expect(confirmAuthenticatedOwner(pending, 'user-a')).toBe(false); + expect(getAuthenticatedOwner().userId).toBeNull(); + }); + + it('rejects a confirmation captured before the auth epoch moved', () => { + const pending = getAuthenticatedOwner(); + bumpAuthEpoch(); + + expect(confirmAuthenticatedOwner(pending, 'user-a')).toBe(false); + expect(getAuthenticatedOwner().userId).toBeNull(); + }); +}); diff --git a/apps/mobile/src/lib/context-scope.ts b/apps/mobile/src/lib/context-scope.ts new file mode 100644 index 0000000000..e27ac82f3c --- /dev/null +++ b/apps/mobile/src/lib/context-scope.ts @@ -0,0 +1,64 @@ +import { currentAuthEpoch, isCurrentAuthEpoch } from '@/lib/auth/auth-epoch'; +import { isSignOutActive } from '@/lib/auth/sign-out-state'; + +export type AuthenticatedOwner = Readonly<{ + authEpoch: number; + generation: number; + userId: string | null; +}>; + +let owner: AuthenticatedOwner = Object.freeze({ + authEpoch: currentAuthEpoch(), + generation: 0, + userId: null, +}); +const listeners = new Set<() => void>(); + +export function getAuthenticatedOwner(): AuthenticatedOwner { + return owner; +} + +export function subscribeAuthenticatedOwner(listener: () => void): () => void { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; +} + +function publish(next: AuthenticatedOwner): AuthenticatedOwner { + owner = Object.freeze(next); + for (const listener of listeners) { + listener(); + } + return owner; +} + +/** Revoke ownership before changing credentials. Ordinary refresh does not call this. */ +export function beginAuthenticatedOwner(): AuthenticatedOwner { + return publish({ authEpoch: currentAuthEpoch(), generation: owner.generation + 1, userId: null }); +} + +export function isCurrentOwner(captured: AuthenticatedOwner): boolean { + return ( + !isSignOutActive() && + isCurrentAuthEpoch(captured.authEpoch) && + captured.authEpoch === owner.authEpoch && + captured.generation === owner.generation && + (captured.userId === null || captured.userId === owner.userId) + ); +} + +/** Only a getMe response requested under committed credentials can confirm this generation. */ +export function confirmAuthenticatedOwner(captured: AuthenticatedOwner, userId: string): boolean { + if (!userId || !isCurrentOwner(captured) || (owner.userId !== null && owner.userId !== userId)) { + return false; + } + if (owner.userId === null) { + publish({ ...owner, userId }); + } + return true; +} + +export function isAuthenticatedOwner(captured: AuthenticatedOwner): boolean { + return captured.userId !== null && isCurrentOwner(captured); +} From d1716acb50b0078bd0646b2f8fb1016160c41fe0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Fri, 28 Aug 2026 15:23:24 +0200 Subject: [PATCH 02/12] test(dev): add deterministic child performance fixtures --- dev/seed/app/mobile-sheet-fixtures.ts | 99 ++--- dev/seed/lib/mobile-sheet-fixtures.test.ts | 438 +++++++++++++++++++++ dev/seed/lib/mobile-sheet-fixtures.ts | 348 +++++++++++++++- 3 files changed, 824 insertions(+), 61 deletions(-) diff --git a/dev/seed/app/mobile-sheet-fixtures.ts b/dev/seed/app/mobile-sheet-fixtures.ts index 7e76ae2795..a526ce9205 100644 --- a/dev/seed/app/mobile-sheet-fixtures.ts +++ b/dev/seed/app/mobile-sheet-fixtures.ts @@ -13,7 +13,9 @@ import { import type { SeedResult } from '../index'; import { buildChildIngestItems, + buildChildPerformanceFixtures, buildEmptyIngestItems, + buildMobileSheetFixtureResult, buildRootIngestItems, buildUnsupportedIngestItems, CHILD_SESSION_ID, @@ -21,8 +23,10 @@ import { EMPTY_SESSION_ID, EMPTY_SESSION_TITLE, expectedPartIdsFor, + fixtureCleanupSessionIds, fixtureSessionIds, parseSessionIngestServiceStatus, + pollForChildPerformanceFixture, ROOT_SESSION_ID, ROOT_SESSION_TITLE, UNSUPPORTED_SESSION_ID, @@ -42,10 +46,15 @@ function printUsage(): void { console.log('Seeds deterministic mobile transcripts for sheet hit-area E2E.'); console.log('Resets only the four fixture session IDs, then ingests history'); console.log('through the local cloudflare-session-ingest worker.'); + console.log('Use --child-performance to also reset and seed a separate tree'); + console.log('with 24 direct children, paged history, and one nested child.'); console.log(''); console.log('Examples:'); console.log(' pnpm dev:seed app:mobile-sheet-fixtures ada@example.com'); console.log(' pnpm -s dev:seed app:mobile-sheet-fixtures ada@example.com --json'); + console.log( + ' pnpm -s dev:seed app:mobile-sheet-fixtures ada@example.com --child-performance --json' + ); } function isValidEmail(email: string): boolean { @@ -167,7 +176,9 @@ export async function run(...args: string[]): Promise { return; } - const email = parseArgs(args); + const childPerformance = args.includes('--child-performance'); + const email = parseArgs(args.filter(arg => arg !== '--child-performance')); + const performanceFixtures = childPerformance ? buildChildPerformanceFixtures() : []; const secret = process.env.NEXTAUTH_SECRET; if (!secret) { @@ -229,41 +240,17 @@ export async function run(...args: string[]): Promise { } const sessionIngestUrl = `http://localhost:${serviceStatus.port}`; - // Reset only the four fixture sessions (child first, then root, then the - // parentless unsupported and empty sessions) so reruns are idempotent and - // the parent FK is never violated. - await db - .delete(cli_sessions_v2) - .where( - and( - eq(cli_sessions_v2.kilo_user_id, user.userId), - eq(cli_sessions_v2.session_id, CHILD_SESSION_ID) - ) - ); - await db - .delete(cli_sessions_v2) - .where( - and( - eq(cli_sessions_v2.kilo_user_id, user.userId), - eq(cli_sessions_v2.session_id, ROOT_SESSION_ID) - ) - ); - await db - .delete(cli_sessions_v2) - .where( - and( - eq(cli_sessions_v2.kilo_user_id, user.userId), - eq(cli_sessions_v2.session_id, UNSUPPORTED_SESSION_ID) - ) - ); - await db - .delete(cli_sessions_v2) - .where( - and( - eq(cli_sessions_v2.kilo_user_id, user.userId), - eq(cli_sessions_v2.session_id, EMPTY_SESSION_ID) - ) - ); + // Reset only fixture IDs, with every descendant before its parent. + for (const sessionId of fixtureCleanupSessionIds(childPerformance)) { + await db + .delete(cli_sessions_v2) + .where( + and( + eq(cli_sessions_v2.kilo_user_id, user.userId), + eq(cli_sessions_v2.session_id, sessionId) + ) + ); + } const remaining = await db .select({ sessionId: cli_sessions_v2.session_id }) @@ -271,7 +258,7 @@ export async function run(...args: string[]): Promise { .where( and( eq(cli_sessions_v2.kilo_user_id, user.userId), - inArray(cli_sessions_v2.session_id, fixtureSessionIds()) + inArray(cli_sessions_v2.session_id, fixtureSessionIds(childPerformance)) ) ); if (remaining.length > 0) { @@ -308,6 +295,14 @@ export async function run(...args: string[]): Promise { title: EMPTY_SESSION_TITLE, created_on_platform: 'cli', }, + ...performanceFixtures.map(fixture => ({ + session_id: fixture.sessionId, + kilo_user_id: user.userId, + title: fixture.title, + parent_session_id: fixture.parentId, + created_on_platform: 'cli', + git_url: fixture.parentId === undefined ? rootGitUrl : undefined, + })), ] satisfies Array; await db.insert(cli_sessions_v2).values(rows); @@ -321,23 +316,31 @@ export async function run(...args: string[]): Promise { buildUnsupportedIngestItems() ); await ingestSession(sessionIngestUrl, EMPTY_SESSION_ID, token, buildEmptyIngestItems()); + for (const fixture of performanceFixtures) { + await ingestSession(sessionIngestUrl, fixture.sessionId, token, fixture.items); + } await pollForParts(sessionIngestUrl, ROOT_SESSION_ID, token); await pollForParts(sessionIngestUrl, CHILD_SESSION_ID, token); + for (const fixture of performanceFixtures) { + await pollForChildPerformanceFixture(sessionIngestUrl, token, fixture); + } console.log(''); console.log('Seeded four mobile transcripts for sheet hit-area E2E.'); + if (childPerformance) { + console.log('Also seeded a performance tree with 24 direct children and one nested child.'); + } console.log('All fixtures are read-only history: no cloud-agent session ID is set.'); - return { - userId: user.userId, - email: user.email, - rootSessionId: ROOT_SESSION_ID, - childSessionId: CHILD_SESSION_ID, - unsupportedSessionId: UNSUPPORTED_SESSION_ID, - emptySessionId: EMPTY_SESSION_ID, - usedRepository, - sessionIngestPort: serviceStatus.port, - sessionIngestUrl, - }; + return buildMobileSheetFixtureResult( + { + userId: user.userId, + email: user.email, + usedRepository, + sessionIngestPort: serviceStatus.port, + sessionIngestUrl, + }, + childPerformance + ); } diff --git a/dev/seed/lib/mobile-sheet-fixtures.test.ts b/dev/seed/lib/mobile-sheet-fixtures.test.ts index aad37a22c0..706df802df 100644 --- a/dev/seed/lib/mobile-sheet-fixtures.test.ts +++ b/dev/seed/lib/mobile-sheet-fixtures.test.ts @@ -1,5 +1,6 @@ import assert from 'node:assert/strict'; import test from 'node:test'; +import { setImmediate } from 'node:timers/promises'; import { kiloSdkMessageSchema, @@ -9,7 +10,9 @@ import { import { buildChildIngestItems, + buildChildPerformanceFixtures, buildEmptyIngestItems, + buildMobileSheetFixtureResult, buildRootIngestItems, buildUnsupportedIngestItems, CHILD_ASSISTANT_MESSAGE_ID, @@ -17,11 +20,16 @@ import { CHILD_SESSION_ID, CHILD_SESSION_TITLE, CHILD_USER_MESSAGE_ID, + EMPTY_CHILD_SESSION_ID, EMPTY_SESSION_ID, EMPTY_SESSION_TITLE, expectedPartIdsFor, + fixtureCleanupSessionIds, fixtureSessionIds, + NESTED_CHILD_SESSION_ID, parseSessionIngestServiceStatus, + PERFORMANCE_ROOT_SESSION_ID, + pollForChildPerformanceFixture, ROOT_ASSISTANT_MESSAGE_ID, ROOT_FILE_PART_ID, ROOT_READ_PART_ID, @@ -29,9 +37,11 @@ import { ROOT_SESSION_TITLE, ROOT_TASK_PART_ID, ROOT_USER_MESSAGE_ID, + SELECTED_CHILD_SESSION_ID, UNSUPPORTED_SESSION_ID, UNSUPPORTED_SESSION_TITLE, UNSUPPORTED_USER_MESSAGE_ID, + type ChildPerformanceFixture, type SessionIngestItem, } from './mobile-sheet-fixtures'; @@ -313,3 +323,431 @@ void test('status JSON parsing extracts the session-ingest service', () => { assert.throws(() => parseSessionIngestServiceStatus('not json'), /did not return valid JSON/); }); + +function performanceFixture(sessionId: string): ChildPerformanceFixture { + const fixture = buildChildPerformanceFixtures().find(value => value.sessionId === sessionId); + assert.ok(fixture); + return fixture; +} + +function storedMessages(fixture: ChildPerformanceFixture) { + const parts = itemsOfType(fixture.items, 'part').map(item => parsePartData(item.data)); + return itemsOfType(fixture.items, 'message').map(item => { + const info = parseMessageData(item.data); + return { info, parts: parts.filter(part => part.messageID === info.id) }; + }); +} + +function historyResponse(fixture: ChildPerformanceFixture, history: unknown): Response { + return Response.json({ success: true, kiloSessionId: fixture.sessionId, history }); +} + +void test('performance fixtures keep deterministic identities, sizes, links, and ingestion order', () => { + const fixtures = buildChildPerformanceFixtures(); + assert.deepEqual(buildChildPerformanceFixtures(), fixtures); + assert.equal(fixtures.length, 26); + assert.equal( + fixtures.filter(fixture => fixture.parentId === PERFORMANCE_ROOT_SESSION_ID).length, + 24 + ); + assert.equal(performanceFixture(NESTED_CHILD_SESSION_ID).parentId, SELECTED_CHILD_SESSION_ID); + assert.equal(performanceFixture(EMPTY_CHILD_SESSION_ID).items.length, 1); + + const ids = new Set(); + const sizes: Record = {}; + for (const fixture of fixtures) { + const messages = storedMessages(fixture); + sizes[messages.length] = (sizes[messages.length] ?? 0) + 1; + const session = parseSessionData(singleItemOfType(fixture.items, 'session').data); + assert.equal(session.id, fixture.sessionId); + assert.equal(session.parentID, fixture.parentId); + if (fixture.parentId) { + assert.ok( + fixtures.findIndex(parent => parent.sessionId === fixture.parentId) < + fixtures.indexOf(fixture) + ); + } + assert.equal(messages.length, fixture.messageCount); + const messageIds = messages.map(message => message.info.id); + assert.deepEqual(messageIds, [...messageIds].sort()); + for (const [index, message] of messages.entries()) { + assert.equal(message.info.sessionID, fixture.sessionId); + assert.ok( + message.parts.some(part => part.type === 'text' && part.text.includes('Synthetic')) + ); + if (index > 0) assert.ok(message.info.time.created > messages[index - 1].info.time.created); + if (message.info.role === 'assistant') { + assert.equal(message.info.parentID, messages[index - 1].info.id); + assert.equal(messages[index - 1].info.role, 'user'); + } + } + + let stage = 0; + const ingestedMessages = new Set(); + for (const item of fixture.items) { + assert.notEqual(item.type, 'session_diff'); + if (item.type === 'session_diff') assert.fail('unexpected session diff'); + assert.equal(typeof item.data.id, 'string'); + if (typeof item.data.id !== 'string') assert.fail('missing fixture identity'); + assert.ok(!ids.has(item.data.id), `duplicate fixture identity: ${item.data.id}`); + ids.add(item.data.id); + const nextStage = item.type === 'session' ? 0 : item.type === 'message' ? 1 : 2; + assert.ok(nextStage >= stage, 'session, messages, and parts must arrive in order'); + stage = nextStage; + if (item.type === 'message') ingestedMessages.add(item.data.id); + if (item.type === 'part') { + const part = parsePartData(item.data); + assert.equal(part.sessionID, fixture.sessionId); + assert.ok(ingestedMessages.has(part.messageID)); + } + } + } + assert.deepEqual(sizes, { 0: 1, 2: 1, 12: 22, 120: 2 }); + const selected = storedMessages(performanceFixture(SELECTED_CHILD_SESSION_ID)); + assert.equal(selected[0].info.id, 'msg000000000007ChildPerf000010001'); + assert.equal(selected[119].info.id, 'msg000000000007ChildPerf000010120'); +}); + +void test('task metadata links every direct and nested child with useful mixed states', () => { + const fixtures = buildChildPerformanceFixtures(); + const linked = new Set(); + const rootStates: Record = {}; + for (const fixture of fixtures) { + for (const item of itemsOfType(fixture.items, 'part')) { + const part = parsePartData(item.data); + if (part.type !== 'tool' || part.tool !== 'task') continue; + assert.notEqual(part.state.status, 'pending'); + if (part.state.status === 'pending') assert.fail('pending task'); + const childSessionId = part.state.metadata?.sessionId; + const child = fixtures.find(value => value.sessionId === childSessionId); + assert.ok(child); + assert.equal(child.parentId, fixture.sessionId); + assert.equal(part.state.input.description, child.title); + assert.equal(part.state.input.subagent_type, 'Explorer'); + assert.equal(part.state.input.prompt, `Inspect synthetic fixture ${child.sessionId}.`); + assert.ok(!linked.has(child.sessionId)); + linked.add(child.sessionId); + if (part.state.status === 'error') { + assert.ok(part.state.error.includes('Synthetic')); + } else { + assert.equal(part.state.title, child.title); + } + if (part.state.status === 'running') { + assert.ok(!('end' in part.state.time)); + assert.ok(!('output' in part.state)); + } else { + assert.ok(part.state.time.end > part.state.time.start); + } + if (fixture.sessionId === PERFORMANCE_ROOT_SESSION_ID) { + rootStates[part.state.status] = (rootStates[part.state.status] ?? 0) + 1; + } + } + } + assert.equal(linked.size, 25); + assert.ok(linked.has(NESTED_CHILD_SESSION_ID)); + assert.ok(linked.has(EMPTY_CHILD_SESSION_ID)); + assert.deepEqual(rootStates, { completed: 9, running: 8, error: 7 }); +}); + +void test('cleanup owns exactly the selected fixtures and deletes descendants first', () => { + assert.deepEqual(fixtureCleanupSessionIds(), [ + CHILD_SESSION_ID, + ROOT_SESSION_ID, + UNSUPPORTED_SESSION_ID, + EMPTY_SESSION_ID, + ]); + const fixtures = buildChildPerformanceFixtures(); + const cleanup = fixtureCleanupSessionIds(true); + assert.equal(cleanup.length, 30); + assert.equal(new Set(cleanup).size, 30); + assert.deepEqual( + new Set(cleanup), + new Set([...fixtureSessionIds(), ...fixtures.map(fixture => fixture.sessionId)]) + ); + assert.deepEqual(new Set(fixtureSessionIds(true)), new Set(cleanup)); + for (const fixture of fixtures) { + assert.ok(!fixtureSessionIds().includes(fixture.sessionId)); + if (fixture.parentId) { + assert.ok(cleanup.indexOf(fixture.sessionId) < cleanup.indexOf(fixture.parentId)); + } + } +}); + +void test('default JSON stays unchanged and opt-in JSON preserves every existing field', () => { + const context = { + userId: 'fixture-user', + email: 'fixture@example.com', + usedRepository: 'fixture-owner/fixture-repo', + sessionIngestPort: 12345, + sessionIngestUrl: 'http://localhost:12345', + }; + const expected = { + userId: 'fixture-user', + email: 'fixture@example.com', + rootSessionId: 'ses_000000000001RootFixture001', + childSessionId: 'ses_000000000002ChildFixture01', + unsupportedSessionId: 'ses_000000000003Unsupported001', + emptySessionId: 'ses_000000000004EmptyFixture01', + usedRepository: 'fixture-owner/fixture-repo', + sessionIngestPort: 12345, + sessionIngestUrl: 'http://localhost:12345', + }; + assert.equal(JSON.stringify(buildMobileSheetFixtureResult(context)), JSON.stringify(expected)); + assert.deepEqual(buildMobileSheetFixtureResult(context, false), expected); + const result = buildMobileSheetFixtureResult(context, true); + for (const [key, value] of Object.entries(expected)) assert.equal(result[key], value); + assert.equal(Object.keys(result).length, 17); + assert.equal(typeof result.performanceChildSessionIds, 'string'); + if (typeof result.performanceChildSessionIds !== 'string') assert.fail('missing child IDs'); + const fixtures = buildChildPerformanceFixtures(); + const directChildren = fixtures.filter( + fixture => fixture.parentId === result.performanceRootSessionId + ); + assert.deepEqual( + result.performanceChildSessionIds.split(','), + directChildren.map(fixture => fixture.sessionId) + ); + assert.equal(result.performanceChildCount, directChildren.length); + assert.equal( + result.pagedChildMessageCount, + storedMessages(performanceFixture(String(result.selectedChildSessionId))).length + ); + assert.equal( + result.pagedChildMessageCount, + storedMessages(performanceFixture(String(result.nestedChildSessionId))).length + ); + assert.equal(storedMessages(performanceFixture(String(result.emptyChildSessionId))).length, 0); + assert.equal(result.ordinaryChildMessageCount, storedMessages(directChildren[1]).length); + assert.deepEqual(buildMobileSheetFixtureResult(context), expected); +}); + +void test('materialization follows independent older cursors for both paged children', async t => { + const fixtures = [ + performanceFixture(SELECTED_CHILD_SESSION_ID), + performanceFixture(NESTED_CHILD_SESSION_ID), + ]; + let now = 0; + t.mock.method(Date, 'now', () => (now += 30_000)); + t.mock.method(globalThis, 'fetch', async (input: string | URL | Request) => { + const url = new URL(String(input)); + const fixture = fixtures.find( + value => url.pathname === `/api/session/${value.sessionId}/messages` + ); + assert.ok(fixture); + const messages = storedMessages(fixture); + const cursor = url.searchParams.get('before'); + if (url.searchParams.get('limit') !== '50') + return historyResponse(fixture, { kind: 'invalid_data' }); + if (cursor === null) { + return historyResponse(fixture, { + messages: messages.slice(70), + nextCursor: `${fixture.sessionId}:70/+`, + }); + } + if (cursor === `${fixture.sessionId}:70/+`) { + return historyResponse(fixture, { + messages: messages.slice(20, 70), + nextCursor: `${fixture.sessionId}:20/+`, + }); + } + if (cursor === `${fixture.sessionId}:20/+`) { + return historyResponse(fixture, { messages: messages.slice(0, 20), nextCursor: null }); + } + return historyResponse(fixture, { kind: 'invalid_data' }); + }); + for (const fixture of fixtures) { + await assert.doesNotReject( + pollForChildPerformanceFixture('http://fixture.invalid', 'synthetic-token', fixture) + ); + } +}); + +for (const sessionId of [SELECTED_CHILD_SESSION_ID, NESTED_CHILD_SESSION_ID]) { + void test(`materialization rejects a paged child without an older cursor: ${sessionId}`, async t => { + const fixture = performanceFixture(sessionId); + let now = 0; + t.mock.method(Date, 'now', () => (now += 30_000)); + t.mock.method(globalThis, 'fetch', async () => + historyResponse(fixture, { + messages: storedMessages(fixture), + nextCursor: null, + }) + ); + await assert.rejects( + pollForChildPerformanceFixture('http://fixture.invalid', 'synthetic-token', fixture), + /Timed out/ + ); + }); +} + +void test('materialization waits through pending, retryable failure, and missing parts', async t => { + const fixture = buildChildPerformanceFixtures()[2]; + const messages = storedMessages(fixture); + const histories = [ + null, + { kind: 'retryable_failure', phase: 'page_parts' }, + { messages: messages.map(message => ({ ...message, parts: [] })), nextCursor: null }, + { messages, nextCursor: null }, + ]; + t.mock.timers.enable({ apis: ['Date', 'setTimeout'], now: 0 }); + t.mock.method(globalThis, 'fetch', async () => historyResponse(fixture, histories.shift())); + let outcome: unknown = 'pending'; + const polling = pollForChildPerformanceFixture( + 'http://fixture.invalid', + 'synthetic-token', + fixture + ).then( + () => { + outcome = 'ready'; + }, + error => { + outcome = error; + } + ); + for (let attempt = 0; attempt < 3; attempt += 1) { + await setImmediate(); + assert.equal(outcome, 'pending'); + t.mock.timers.tick(500); + } + await polling; + assert.equal(outcome, 'ready'); +}); + +void test('empty child readiness requires a successful empty history, not null staging', async t => { + const fixture = performanceFixture(EMPTY_CHILD_SESSION_ID); + let materialized = false; + t.mock.timers.enable({ apis: ['Date', 'setTimeout'], now: 0 }); + t.mock.method(globalThis, 'fetch', async () => + historyResponse( + fixture, + materialized ? { messages: [], nextCursor: null, omittedItemCount: 0 } : null + ) + ); + let outcome: unknown = 'pending'; + const polling = pollForChildPerformanceFixture( + 'http://fixture.invalid', + 'synthetic-token', + fixture + ).then( + () => { + outcome = 'ready'; + }, + error => { + outcome = error; + } + ); + await setImmediate(); + assert.equal(outcome, 'pending'); + materialized = true; + t.mock.timers.tick(500); + await polling; + assert.equal(outcome, 'ready'); +}); + +void test('materialization rejects unusable responses and incomplete or corrupt histories', async t => { + const fixture = buildChildPerformanceFixtures()[2]; + const messages = storedMessages(fixture); + const page = { messages, nextCursor: null }; + const cases = [ + { + name: 'HTTP denial', + response: () => new Response(null, { status: 403 }), + error: /failed \(403\)/, + }, + { + name: 'wrong content type', + response: () => new Response('not history'), + error: /application\/json/, + }, + { + name: 'invalid JSON', + response: () => new Response('{', { headers: { 'content-type': 'application/json' } }), + error: /JSON/, + }, + { + name: 'wrong session', + response: () => + Response.json({ success: true, kiloSessionId: ROOT_SESSION_ID, history: page }), + error: /unexpected shape/, + }, + { + name: 'invalid history shape', + response: () => historyResponse(fixture, { messages: 'not messages', nextCursor: null }), + error: /unexpected history shape/, + }, + { + name: 'terminal invalid data', + response: () => historyResponse(fixture, { kind: 'invalid_data' }), + error: /invalid_data/, + }, + { + name: 'terminal oversized data', + response: () => + historyResponse(fixture, { kind: 'too_large', maximumBytes: 1, phase: 'page_parts' }), + error: /too_large/, + }, + { + name: 'omitted items', + response: () => historyResponse(fixture, { ...page, omittedItemCount: 1 }), + error: /omitted/, + }, + { + name: 'missing messages', + response: () => historyResponse(fixture, { ...page, messages: messages.slice(1) }), + error: /Timed out/, + }, + { + name: 'missing parts', + response: () => + historyResponse(fixture, { + ...page, + messages: messages.map(message => ({ ...message, parts: [] })), + }), + error: /Timed out/, + }, + { + name: 'wrong message owner', + response: () => + historyResponse(fixture, { + ...page, + messages: messages.map(message => ({ + ...message, + info: { ...message.info, sessionID: ROOT_SESSION_ID }, + })), + }), + error: /Unexpected or duplicate message/, + }, + { + name: 'duplicate message', + response: () => historyResponse(fixture, { ...page, messages: [...messages, messages[0]] }), + error: /duplicate message/, + }, + { + name: 'wrong part parent', + response: () => + historyResponse(fixture, { + ...page, + messages: messages.map(message => ({ + ...message, + parts: message.parts.map(part => ({ ...part, messageID: 'msgWrongParent' })), + })), + }), + error: /part relationship/, + }, + { + name: 'repeated cursor', + response: () => historyResponse(fixture, { messages: [], nextCursor: 'repeated' }), + error: /repeated history cursor/, + }, + ]; + let now = 0; + t.mock.method(Date, 'now', () => (now += 30_000)); + for (const scenario of cases) { + t.mock.method(globalThis, 'fetch', async () => scenario.response()); + await assert.rejects( + pollForChildPerformanceFixture('http://fixture.invalid', 'synthetic-token', fixture), + scenario.error, + scenario.name + ); + } +}); diff --git a/dev/seed/lib/mobile-sheet-fixtures.ts b/dev/seed/lib/mobile-sheet-fixtures.ts index a75db21ff8..62ea7fedd0 100644 --- a/dev/seed/lib/mobile-sheet-fixtures.ts +++ b/dev/seed/lib/mobile-sheet-fixtures.ts @@ -1,6 +1,11 @@ -// Pure fixture builders and status parsing for the mobile sheet transcript -// seed. This module has no database or network side effects: the node test -// imports it without a live stack. +// Fixture builders, status parsing, and opt-in materialization polling. +// Importing this module has no database or network side effects. +import { + DEFAULT_KILO_SDK_MESSAGE_PAGE_SIZE, + kiloSdkMessageHistorySchema, +} from '@kilocode/session-ingest-contracts'; + +import type { SeedResult } from '../index'; export const ROOT_SESSION_ID = 'ses_000000000001RootFixture001'; export const CHILD_SESSION_ID = 'ses_000000000002ChildFixture01'; @@ -99,8 +104,14 @@ const CHILD_ASSISTANT_TOKENS: FixtureTokens = { }; /** The exact session IDs this seed resets. Nothing else is touched. */ -export function fixtureSessionIds(): string[] { - return [ROOT_SESSION_ID, CHILD_SESSION_ID, UNSUPPORTED_SESSION_ID, EMPTY_SESSION_ID]; +export function fixtureSessionIds(childPerformance = false): string[] { + return [ + ROOT_SESSION_ID, + CHILD_SESSION_ID, + UNSUPPORTED_SESSION_ID, + EMPTY_SESSION_ID, + ...(childPerformance ? PERFORMANCE_SESSION_IDS : []), + ]; } /** Part IDs that must appear in the materialized history for a fixture session. */ @@ -190,6 +201,7 @@ export function buildToolPartItem(params: { messageId: string; callId: string; tool: string; + status?: 'completed' | 'running' | 'error'; input: Record; output: string; title: string; @@ -206,14 +218,31 @@ export function buildToolPartItem(params: { type: 'tool', callID: params.callId, tool: params.tool, - state: { - status: 'completed', - input: params.input, - output: params.output, - title: params.title, - metadata: params.metadata, - time: { start: params.start, end: params.end }, - }, + state: + params.status === 'running' + ? { + status: 'running', + input: params.input, + title: params.title, + metadata: params.metadata, + time: { start: params.start }, + } + : params.status === 'error' + ? { + status: 'error', + input: params.input, + error: params.output, + metadata: params.metadata, + time: { start: params.start, end: params.end }, + } + : { + status: 'completed', + input: params.input, + output: params.output, + title: params.title, + metadata: params.metadata, + time: { start: params.start, end: params.end }, + }, }, }; } @@ -365,6 +394,299 @@ export function buildEmptyIngestItems(): SessionIngestItem[] { ]; } +export const PERFORMANCE_ROOT_SESSION_ID = 'ses_000000000006ChildPerfRoot1'; +export const SELECTED_CHILD_SESSION_ID = 'ses_000000000007ChildPerf00001'; +export const EMPTY_CHILD_SESSION_ID = 'ses_000000000007ChildPerf00024'; +export const NESTED_CHILD_SESSION_ID = 'ses_000000000008ChildPerfNest1'; + +const PERFORMANCE_CHILD_IDS = Array.from( + { length: 24 }, + (_, index) => `ses_000000000007ChildPerf${String(index + 1).padStart(5, '0')}` +); +const PERFORMANCE_SESSION_IDS = [ + PERFORMANCE_ROOT_SESSION_ID, + ...PERFORMANCE_CHILD_IDS, + NESTED_CHILD_SESSION_ID, +]; + +export type ChildPerformanceFixture = { + sessionId: string; + parentId?: string; + title: string; + messageCount: number; + items: SessionIngestItem[]; +}; + +/** Children precede their parents; the default reset order stays unchanged. */ +export function fixtureCleanupSessionIds(childPerformance = false): string[] { + return [ + ...(childPerformance ? [...PERFORMANCE_SESSION_IDS].reverse() : []), + CHILD_SESSION_ID, + ROOT_SESSION_ID, + UNSUPPORTED_SESSION_ID, + EMPTY_SESSION_ID, + ]; +} + +export function buildMobileSheetFixtureResult( + context: { + userId: string; + email: string; + usedRepository: string; + sessionIngestPort: number; + sessionIngestUrl: string; + }, + childPerformance = false +): SeedResult { + return { + userId: context.userId, + email: context.email, + rootSessionId: ROOT_SESSION_ID, + childSessionId: CHILD_SESSION_ID, + unsupportedSessionId: UNSUPPORTED_SESSION_ID, + emptySessionId: EMPTY_SESSION_ID, + usedRepository: context.usedRepository, + sessionIngestPort: context.sessionIngestPort, + sessionIngestUrl: context.sessionIngestUrl, + ...(childPerformance + ? { + performanceRootSessionId: PERFORMANCE_ROOT_SESSION_ID, + performanceChildSessionIds: PERFORMANCE_CHILD_IDS.join(','), + selectedChildSessionId: SELECTED_CHILD_SESSION_ID, + nestedChildSessionId: NESTED_CHILD_SESSION_ID, + emptyChildSessionId: EMPTY_CHILD_SESSION_ID, + performanceChildCount: PERFORMANCE_CHILD_IDS.length, + ordinaryChildMessageCount: 12, + pagedChildMessageCount: 120, + } + : {}), + }; +} + +/** A separate tree keeps default fixtures unchanged, even after an opt-in run. */ +export function buildChildPerformanceFixtures(): ChildPerformanceFixture[] { + const sessions = [ + { + sessionId: PERFORMANCE_ROOT_SESSION_ID, + parentId: undefined, + title: 'Child performance fixtures', + messageCount: 2, + }, + ...PERFORMANCE_CHILD_IDS.map((sessionId, index) => ({ + sessionId, + parentId: PERFORMANCE_ROOT_SESSION_ID, + title: + sessionId === EMPTY_CHILD_SESSION_ID + ? 'Empty child performance fixture' + : `Inspect performance child ${String(index + 1).padStart(2, '0')}`, + messageCount: + sessionId === EMPTY_CHILD_SESSION_ID + ? 0 + : sessionId === SELECTED_CHILD_SESSION_ID + ? 120 + : 12, + })), + { + sessionId: NESTED_CHILD_SESSION_ID, + parentId: SELECTED_CHILD_SESSION_ID, + title: 'Inspect nested performance child', + messageCount: 120, + }, + ]; + + return sessions.map((session, sessionIndex) => { + const messageIdFor = (index: number) => + `msg${session.sessionId.slice(4)}${String(index).padStart(4, '0')}`; + const messages: SessionIngestItem[] = []; + const parts: SessionIngestItem[] = []; + for (let index = 1; index <= session.messageCount; index += 1) { + const createdAt = FIXTURE_TIME_CREATED + index * 1_000; + const messageId = messageIdFor(index); + messages.push( + index % 2 === 1 + ? buildUserMessageItem({ messageId, sessionId: session.sessionId, createdAt }) + : buildAssistantMessageItem({ + messageId, + sessionId: session.sessionId, + parentId: messageIdFor(index - 1), + createdAt, + completedAt: createdAt + 200, + cost: CHILD_ASSISTANT_COST, + tokens: CHILD_ASSISTANT_TOKENS, + }) + ); + parts.push({ + type: 'part', + data: { + id: `prt${session.sessionId.slice(4)}${String(index).padStart(4, '0')}`, + sessionID: session.sessionId, + messageID: messageId, + type: 'text', + text: [ + session.title, + '', + `Synthetic ${index % 2 === 1 ? 'user' : 'assistant'} message ${index} of ${session.messageCount}.`, + 'This deterministic transcript contains no repository or user data.', + `Read-only fixture marker A: ${index}.`, + `Read-only fixture marker B: ${index}.`, + `Read-only fixture marker C: ${index}.`, + ].join('\n'), + }, + }); + } + + const children = sessions.filter(child => child.parentId === session.sessionId); + for (const [index, child] of children.entries()) { + const status = + child.sessionId === EMPTY_CHILD_SESSION_ID || index % 3 === 0 + ? 'completed' + : index % 3 === 1 + ? 'running' + : 'error'; + parts.push( + buildToolPartItem({ + partId: `prtTask${child.sessionId.slice(4)}`, + sessionId: session.sessionId, + messageId: messageIdFor(session.messageCount), + callId: `callTask${child.sessionId.slice(4)}`, + tool: 'task', + status, + input: { + subagent_type: 'Explorer', + description: child.title, + prompt: `Inspect synthetic fixture ${child.sessionId}.`, + }, + output: status === 'error' ? 'Synthetic task failure.' : 'Synthetic task completed.', + title: child.title, + metadata: { sessionId: child.sessionId }, + start: FIXTURE_TIME_CREATED + session.messageCount * 1_000, + end: FIXTURE_TIME_CREATED + session.messageCount * 1_000 + 200, + }) + ); + } + + return { + ...session, + items: [ + buildSessionItem({ + sessionId: session.sessionId, + parentId: session.parentId, + title: session.title, + slug: `child-performance-${sessionIndex}`, + }), + ...messages, + ...parts, + ], + }; + }); +} + +/** Walk each real cursor; a successful empty page is distinct from pending history. */ +export async function pollForChildPerformanceFixture( + baseUrl: string, + token: string, + fixture: ChildPerformanceFixture +): Promise { + const expectedMessages = fixture.items.flatMap(item => + item.type === 'message' && typeof item.data.id === 'string' ? [item.data.id] : [] + ); + const expectedParts = fixture.items.flatMap(item => + item.type === 'part' && typeof item.data.id === 'string' ? [item.data.id] : [] + ); + const deadline = Date.now() + 30_000; + + for (;;) { + const seenMessages = new Set(); + const seenParts = new Set(); + const cursors = new Set(); + let before: string | undefined; + let hasHistory = true; + let hasOlder = false; + do { + const query = new URLSearchParams({ limit: String(DEFAULT_KILO_SDK_MESSAGE_PAGE_SIZE) }); + if (before !== undefined) query.set('before', before); + const response = await fetch( + `${baseUrl}/api/session/${fixture.sessionId}/messages?${query}`, + { + headers: { Authorization: `Bearer ${token}`, Accept: 'application/json' }, + } + ); + if (!response.ok) { + throw new Error(`Messages read of ${fixture.sessionId} failed (${response.status})`); + } + if (response.headers.get('content-type')?.split(';')[0].trim() !== 'application/json') { + throw new Error(`Messages read of ${fixture.sessionId} did not return application/json`); + } + const payload: unknown = await response.json(); + if ( + !isRecord(payload) || + payload.success !== true || + payload.kiloSessionId !== fixture.sessionId + ) { + throw new Error(`Messages read of ${fixture.sessionId} returned an unexpected shape`); + } + if (payload.history === null) { + hasHistory = false; + break; + } + const parsed = kiloSdkMessageHistorySchema.safeParse(payload.history); + if (!parsed.success) { + throw new Error( + `Messages read of ${fixture.sessionId} returned an unexpected history shape` + ); + } + const page = parsed.data; + if ('kind' in page) { + if (page.kind === 'retryable_failure') { + hasHistory = false; + break; + } + throw new Error(`session-ingest reported ${page.kind} for ${fixture.sessionId}`); + } + if (page.omittedItemCount !== 0) { + throw new Error(`session-ingest omitted fixture items for ${fixture.sessionId}`); + } + if (before === undefined) hasOlder = page.nextCursor !== null; + for (const message of page.messages) { + if (message.info.sessionID !== fixture.sessionId || seenMessages.has(message.info.id)) { + throw new Error(`Unexpected or duplicate message in ${fixture.sessionId}`); + } + seenMessages.add(message.info.id); + for (const part of message.parts) { + if (part.sessionID !== fixture.sessionId || part.messageID !== message.info.id) { + throw new Error(`Unexpected part relationship in ${fixture.sessionId}`); + } + seenParts.add(part.id); + } + } + before = page.nextCursor ?? undefined; + if (before !== undefined) { + if (!before || cursors.has(before)) { + throw new Error(`Invalid or repeated history cursor for ${fixture.sessionId}`); + } + cursors.add(before); + } + } while (before !== undefined); + + if ( + hasHistory && + (expectedMessages.length <= DEFAULT_KILO_SDK_MESSAGE_PAGE_SIZE || hasOlder) && + seenMessages.size === expectedMessages.length && + expectedMessages.every(id => seenMessages.has(id)) && + seenParts.size === expectedParts.length && + expectedParts.every(id => seenParts.has(id)) + ) { + return; + } + if (Date.now() >= deadline) { + throw new Error( + `Timed out waiting for complete child performance history of ${fixture.sessionId}` + ); + } + await new Promise(resolve => setTimeout(resolve, 500)); + } +} + function isRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null && !Array.isArray(value); } From e7dba281def64ece2e44341be0ba7ccba6303332 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Fri, 28 Aug 2026 15:57:29 +0200 Subject: [PATCH 03/12] fix(mobile): restore identity confirmation feedback --- .../agent-chat/[session-id].mounted.test.tsx | 268 ++++++++++++++++-- .../src/app/(app)/agent-chat/[session-id].tsx | 44 ++- ...r-web-connection-provider.mounted.test.tsx | 85 +++++- .../agents/user-web-connection-provider.tsx | 69 ++++- 4 files changed, 423 insertions(+), 43 deletions(-) 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 dee1c1dbf9..ffe6564bfa 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 @@ -15,7 +15,12 @@ import { } 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 { QueryError } from '@/components/query-error'; +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 { @@ -55,8 +60,30 @@ const queryState = vi.hoisted(() => ({ refetch: vi.fn(), })); +const confirmationRequests = vi.hoisted(() => ({ + getMe: vi.fn<() => Promise<{ id: string }>>(), + ticket: vi.fn<() => Promise<{ token: string }>>(), +})); + vi.mock('react-native', () => ({ View: 'View', + Pressable: 'Pressable', + ActivityIndicator: 'ActivityIndicator', + Platform: { OS: 'android' }, +})); +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', + Lock: 'Lock', + SearchX: 'SearchX', + ServerCrash: 'ServerCrash', + WifiOff: 'WifiOff', })); vi.mock('expo-router', () => ({ @@ -87,6 +114,10 @@ vi.mock('@/lib/trpc', () => ({ }, }, }), + trpcClient: { + user: { getMe: { query: confirmationRequests.getMe } }, + activeSessions: { createWebTicket: { mutate: confirmationRequests.ticket } }, + }, })); vi.mock('@/components/invalid-route-state', () => ({ @@ -139,13 +170,6 @@ vi.mock('@/components/agents/mobile-session-manager', () => ({ createMobileAgentSessionManager: createMobileManagerMock, })); -vi.mock('@/components/agents/user-web-connection-provider', () => ({ - useUserWebConnection: () => ({ - subscribeToCliSession: vi.fn(() => vi.fn()), - onSystemEvent: vi.fn(() => vi.fn()), - }), -})); - vi.mock('@/components/agents/session-terminal-error', () => ({ buildTerminalErrorCopyText: () => '', })); @@ -154,21 +178,14 @@ 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, @@ -178,6 +195,12 @@ function findByType( root: TestRenderer.ReactTestInstance, type: string ): TestRenderer.ReactTestInstance[] { + if (type === 'QueryError') { + return root.findAllByType(QueryError); + } + if (type === 'Button') { + return root.findAllByType(Button); + } return root.findAll(node => typeof node.type === 'string' && (node.type as string) === type); } @@ -195,7 +218,7 @@ async function mountRoute( ): Promise { const ref: { current: TestRenderer.ReactTestRenderer | undefined } = { current: undefined }; await act(async () => { - ref.current = TestRenderer.create(element); + ref.current = TestRenderer.create(createElement(UserWebConnectionProvider, null, element)); await Promise.resolve(); }); if (!ref.current) { @@ -212,7 +235,9 @@ async function mountRoute( async function updateRoute(renderer: TestRenderer.ReactTestRenderer) { await act(async () => { - renderer.update(createElement(SessionDetailScreen)); + renderer.update( + createElement(UserWebConnectionProvider, null, createElement(SessionDetailScreen)) + ); await Promise.resolve(); }); } @@ -228,24 +253,39 @@ function queryInput(): { session_id?: string } | undefined { function beginReplacement() { setSignOutActive(true); + setSignOutTeardownActive(true); authState.isSigningOut = true; authState.token = undefined; bumpAuthEpoch(); authState.authEpoch = currentAuthEpoch(); beginAuthenticatedOwner(); + clearActiveToken(); } -function commitAccount(account: 'A' | 'B') { +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; @@ -729,3 +769,189 @@ describe('SessionDetailScreen fresh authentication scope', () => { 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('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')).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')); + let destination = 'session-detail'; + useRouterMock.mockReturnValue({ + replace: (href: string) => { + destination = href; + }, + }); + 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(destination).toBe('/(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); + } + ); +}); 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 75bb4d39b0..6b6b6ff182 100644 --- a/apps/mobile/src/app/(app)/agent-chat/[session-id].tsx +++ b/apps/mobile/src/app/(app)/agent-chat/[session-id].tsx @@ -19,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'; @@ -33,6 +34,9 @@ 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, @@ -125,17 +129,20 @@ export default function SessionDetailScreen() { return ; } - if (!isAuthenticatedOwner(owner)) { - return null; - } - - 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. An unconfirmed account must not show a cached title. return ( void sessionQuery.refetch(); const copyText = buildTerminalErrorCopyText({ sessionId, title, message }); return ( @@ -184,8 +200,8 @@ export default function SessionDetailScreen() { className="px-0 pt-0" title={title} message={message} - onRetry={notFound || unauthorized ? undefined : () => void sessionQuery.refetch()} - isRetrying={sessionQuery.isFetching} + onRetry={notFound || unauthorized ? undefined : retry} + isRetrying={identityFailed ? confirmation.isPending : sessionQuery.isFetching} />