diff --git a/apps/mobile/src/components/agents/mobile-session-manager.ts b/apps/mobile/src/components/agents/mobile-session-manager.ts index 175226a2ce..acbe781d80 100644 --- a/apps/mobile/src/components/agents/mobile-session-manager.ts +++ b/apps/mobile/src/components/agents/mobile-session-manager.ts @@ -18,6 +18,7 @@ import { } from '@/components/agents/mobile-session-diagnostics'; import { fetchMobileSessionSnapshotPage } from '@/components/agents/mobile-session-page-adapter'; import { type AgentMode } from '@/components/agents/mode-normalize'; +import { formatGitUrlProject } from '@/components/agents/session-list-helpers'; import { API_BASE_URL, CLOUD_AGENT_WS_URL, WEB_BASE_URL } from '@/lib/config'; import { SPAWNED_NOT_FOUND_MAX_ATTEMPTS } from '@/lib/spawned-not-found-retry'; import { trpcClient } from '@/lib/trpc'; @@ -382,17 +383,20 @@ export function createMobileAgentSessionManager({ fetchSession: async (kiloSessionId: KiloSessionId): Promise => { const sessionResult = await fetchSessionWithNotFoundRetry(kiloSessionId); const rs = sessionResult.runtimeState; + const repositoryUrl = rs?.gitUrl ?? sessionResult.git_url; return { kiloSessionId, cloudAgentSessionId: sessionResult.cloud_agent_session_id as CloudAgentSessionId | null, title: sessionResult.title, organizationId: sessionResult.organization_id, - gitUrl: sessionResult.git_url, + gitUrl: sessionResult.git_url ?? rs?.gitUrl ?? null, gitBranch: rs?.upstreamBranch ?? sessionResult.git_branch, mode: rs?.mode ?? null, model: rs?.model ?? null, variant: rs?.variant ?? null, - repository: rs?.githubRepo ?? null, + // GitLab/Bitbucket report gitUrl. Old records without runtime data use + // the stored URL until old clients/records and the 30-day window expire. + repository: rs?.githubRepo ?? (repositoryUrl ? formatGitUrlProject(repositoryUrl) : null), isInitiated: Boolean(rs?.initiatedAt), needsLegacyPrepare: Boolean(sessionResult.cloud_agent_session_id && !rs), isPreparingAsync: Boolean(rs && !rs.preparedAt), diff --git a/apps/mobile/src/components/agents/new-session-screen-body.mounted.test.tsx b/apps/mobile/src/components/agents/new-session-screen-body.mounted.test.tsx new file mode 100644 index 0000000000..33f2c46df7 --- /dev/null +++ b/apps/mobile/src/components/agents/new-session-screen-body.mounted.test.tsx @@ -0,0 +1,394 @@ +/* eslint-disable typescript-eslint/no-deprecated -- react-test-renderer mounts the actual screen and both creators without a DOM */ +/* eslint-disable require-await, @typescript-eslint/require-await -- native storage and transport doubles return promises */ +/* eslint-disable max-lines -- the screen dependencies and overlapping launch regressions share one mounted harness */ +import { type ComponentProps, createElement } from 'react'; +import TestRenderer, { act } from 'react-test-renderer'; +import { afterEach, beforeEach, expect, it, vi } from 'vitest'; + +import { listOutboxRows } from '@/lib/persist/mutation-outbox'; +import { NewSessionConfigureForm } from './new-session-configure-form'; +import { NewSessionScreenBody } from './new-session-screen-body'; + +type FormProps = ComponentProps; +type RemoveEvent = { data: { action: { type: string } } }; +const native = vi.hoisted(() => ({ + userId: 'user-1', + organizationId: 'org-1', + selectedRepo: 'github:owner/repo', + path: '/continue', + nextKey: 0, + errors: [] as string[], + leaveLocked: false, + beforeRemove: undefined as ((event: RemoveEvent) => void) | undefined, + rows: new Map(), +})); +vi.mock('react-native', () => ({ View: 'View' })); +vi.mock('@/components/ui/text', () => ({ Text: 'Text' })); +vi.mock('@/components/screen-header', () => ({ ScreenHeader: 'ScreenHeader' })); +vi.mock('./new-session-configure-form', () => ({ + NewSessionConfigureForm: (props: FormProps) => createElement('configure-form', props), +})); +vi.mock('@/i18n', () => ({ i18n: { t: (key: string) => key } })); +vi.mock('react-i18next', () => ({ useTranslation: () => ({ t: (key: string) => key }) })); +vi.mock('@expo/react-native-action-sheet', () => ({ + useActionSheet: () => ({ showActionSheetWithOptions: vi.fn() }), +})); +vi.mock('expo-router', () => ({ + useLocalSearchParams: () => ({ + organizationId: native.organizationId, + cloneFromKiloSessionId: 'ses_source', + }), + useRouter: () => ({ + replace: (path: string) => { + native.path = path; + }, + }), + useNavigation: () => ({ + dispatch: () => { + native.path = '/source'; + }, + }), +})); +vi.mock('@/lib/navigation/prevent-remove', () => ({ + usePreventRemove: (enabled: boolean, onRemove: (event: RemoveEvent) => void) => { + native.leaveLocked = enabled; + native.beforeRemove = onRemove; + }, +})); +vi.mock('@/app/(app)/agent-chat/use-new-session-discard-guard', () => ({ + useNewSessionDiscardGuard: vi.fn(), +})); +vi.mock('@/lib/hooks/use-current-user-id', () => ({ + useCurrentUserId: () => ({ userId: native.userId, isLoading: false }), +})); +vi.mock('@tanstack/react-query', () => ({ + useQueryClient: () => ({}), + useQuery: () => ({ data: { instances: [] }, isLoading: false, refetch: vi.fn() }), +})); +vi.mock('@/components/agents/new-session-model-provider', () => ({ + useNewSessionModelState: () => ({ + mode: 'code', + model: 'model', + variant: '', + setMode: vi.fn(), + setModel: vi.fn(), + setVariant: vi.fn(), + }), +})); +vi.mock('@/lib/hooks/use-available-models', () => ({ + useAvailableModels: () => ({ models: [], isLoading: false, isError: false, refetch: vi.fn() }), +})); +vi.mock('@/lib/hooks/use-instance-model-catalog', () => ({ + useInstanceModelCatalog: () => ({ catalog: null, isLoading: false }), +})); +vi.mock('@/lib/hooks/use-session-model-options', () => ({ + buildSessionModelOptions: () => ({ options: [] }), + createRemoteModelOverride: vi.fn(), +})); +vi.mock('@/lib/hooks/use-model-preferences', () => ({ + useModelPreferences: () => ({ setLastSelected: vi.fn() }), +})); +vi.mock('@/lib/hooks/use-persisted-agent-model', () => ({ + usePersistedAgentModel: () => ({ saveModel: vi.fn() }), +})); +vi.mock('@/lib/hooks/use-launch-folder', () => ({ useLaunchFolder: () => ['', vi.fn()] })); +vi.mock('@/components/agents/use-effective-profile-custom-modes', () => ({ + useEffectiveProfileCustomModes: () => ({ customOptions: [], profileAgents: [] }), +})); +vi.mock('@/components/agents/use-effective-agent-profile', () => ({ + useEffectiveAgentProfile: () => ({ + profile: null, + profileId: null, + isLoading: false, + isError: false, + refetch: vi.fn(), + }), +})); +vi.mock('@/components/agents/use-new-session-prefill', () => ({ + useNewSessionPrefillTargets: () => ({ + selectedRepo: native.selectedRepo, + setSelectedRepo: vi.fn(), + }), +})); +vi.mock('@/lib/use-new-session-repos', () => ({ + useNewSessionRepos: () => ({ + repositories: [{ platform: 'github', fullName: 'owner/repo', isPrivate: true }], + recents: [], + groups: [], + reposSettled: true, + isRetrying: false, + openIntegration: vi.fn(), + refreshReposForceFresh: vi.fn(), + }), +})); +vi.mock('@/lib/use-new-session-share-remote', () => ({ + useNewSessionShareRemote: () => ({ + remoteSpawn: { isSpawningRemote: false, onStart: vi.fn() }, + handleRunOnInstanceChange: vi.fn(), + }), +})); +vi.mock('@/lib/persist/drafts', () => ({ + NEW_SESSION_DRAFT_KEY: 'new-session', + clearDraft: vi.fn(), + saveDraft: vi.fn(), + resolvePrefillOverDraft: vi.fn(), +})); +vi.mock('@/lib/persist/use-draft-flush', () => ({ useDraftFlushOnBackground: vi.fn() })); +vi.mock('@/lib/persist/use-draft-load', () => ({ + useFencedDraftLoad: () => ({ settled: true, value: null }), + useRemoteSpawnDraftCleanup: () => ({ markRemoteSpawnAttempted: vi.fn() }), +})); +vi.mock('@/lib/share-payload', () => ({ peekSharePayload: vi.fn() })); +vi.mock('@/components/agents/attachment-picker', () => ({ pickAgentAttachments: vi.fn() })); +vi.mock('@/lib/agent-attachments/use-android-pending-picker-recovery', () => ({ + useAndroidPendingPickerRecovery: vi.fn(), +})); +vi.mock('@/lib/agent-attachments/use-agent-attachment-upload', () => ({ + useAgentAttachmentUpload: () => ({ + attachments: [], + isUploading: false, + hasFailedAttachments: false, + addCandidates: vi.fn(), + removeAttachment: vi.fn(), + retryAttachment: vi.fn(), + reset: vi.fn(), + uploadPending: async () => ({ ok: true }), + }), +})); +vi.mock('expo-crypto', () => ({ + randomUUID: () => { + native.nextKey += 1; + return `operation-${native.nextKey}`; + }, +})); +vi.mock('expo-haptics', () => ({ + notificationAsync: async () => undefined, + NotificationFeedbackType: { Success: 'success' }, +})); +vi.mock('sonner-native', () => ({ + toast: { + error: (message: string) => { + native.errors.push(message); + }, + }, +})); +vi.mock('@sentry/react-native', () => ({ captureException: vi.fn() })); +vi.mock('@/lib/agent-session-cache', () => ({ + invalidateAgentSessionQueries: async () => undefined, +})); +vi.mock('@/lib/analytics/posthog', () => ({ + captureEvent: vi.fn(), + SESSION_CREATED_EVENT: 'session_created', +})); +vi.mock('@kilocode/cloud-agent-sdk/message-id', () => ({ generateMessageId: () => 'msg_test' })); +// Keep both creators, their retry classifier, and the persisted outbox real. +vi.mock('@/lib/persist/encrypted-kv', () => ({ + getItem: async (scope: string, k: string) => native.rows.get(`${scope}\0${k}`)?.v ?? null, + setItem: async (scope: string, k: string, v: string) => { + native.rows.set(`${scope}\0${k}`, { scope, k, v }); + }, + removeItem: async (scope: string, k: string) => { + native.rows.delete(`${scope}\0${k}`); + }, + listEntries: async (scope: string) => + [...native.rows.values()].filter(row => row.scope === scope), +})); +vi.mock('@kilocode/cloud-agent-sdk', () => ({ createSessionManager: vi.fn() })); +vi.mock('@/lib/auth/token-owner', () => ({ getAuthTokenForRequest: vi.fn() })); +vi.mock('@/lib/config', () => ({ + API_BASE_URL: 'https://api.test', + CLOUD_AGENT_WS_URL: 'wss://ws.test', + WEB_BASE_URL: 'https://web.test', +})); +vi.mock('@/lib/user-web-connection-lifecycle', () => ({ + createNativeUserWebConnectionLifecycleHooks: vi.fn(), +})); +vi.mock('@/components/agents/mobile-session-transport-payload', () => ({ + normalizeTransportPayload: vi.fn(), +})); +vi.mock('@/components/agents/mobile-session-diagnostics', () => ({ + formatSafeCloudAgentFailureDiagnostic: vi.fn(), + withCloudAgentDiagnostics: vi.fn(), +})); +vi.mock('@/components/agents/mobile-session-page-adapter', () => ({ + fetchMobileSessionSnapshotPage: vi.fn(), +})); +vi.mock('@/components/agents/tool-card-image-cache', () => ({ cacheToolAttachment: vi.fn() })); +vi.mock('@/components/agents/file-part-cache', () => ({ cacheFilePart: vi.fn() })); +vi.mock('@/lib/trpc', () => ({ + useTRPC: () => ({ activeSessions: { listInstances: { queryOptions: () => ({}) } } }), + trpcClient: { + cloudAgentNext: { prepareSession: { mutate: prepare } }, + organizations: { cloudAgentNext: { prepareSession: { mutate: prepare } } }, + }, +})); + +type Payload = { operationKey: string; organizationId?: string }; +const requests: { + input: Payload; + response: ReturnType>; +}[] = []; +async function prepare(input: Payload) { + const response = Promise.withResolvers<{ kiloSessionId: string }>(); + requests.push({ input, response }); + return response.promise; +} +async function storedKeys() { + const rows = await listOutboxRows('user-1'); + return rows?.map(row => row.operationKey); +} +let screen: TestRenderer.ReactTestRenderer | undefined = undefined; +function form(): FormProps { + if (!screen) { + throw new Error('Screen did not mount'); + } + return screen.root.findByType(NewSessionConfigureForm).props as FormProps; +} +async function mount() { + await act(async () => { + screen = TestRenderer.create(createElement(NewSessionScreenBody)); + }); +} +async function start() { + expect(form().isStartDisabled).toBe(false); + await act(async () => { + form().onStartSession(); + }); +} +function goBack() { + if (native.leaveLocked) { + native.beforeRemove?.({ data: { action: { type: 'GO_BACK' } } }); + } else { + native.path = '/source'; + } +} +beforeEach(() => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + native.userId = 'user-1'; + native.organizationId = 'org-1'; + native.selectedRepo = 'github:owner/repo'; + native.path = '/continue'; + native.nextKey = 0; + native.errors = []; + native.leaveLocked = false; + native.beforeRemove = undefined; + native.rows.clear(); + requests.length = 0; +}); +afterEach(async () => { + await act(async () => { + screen?.unmount(); + }); + screen = undefined; + vi.unstubAllGlobals(); +}); + +it.each(['success', 'retryable', 'terminal'] as const)( + 'keeps the replacement Continue locked after the old owner returns %s', + async outcome => { + await mount(); + await start(); + const first = requests[0]; + if (!first) { + throw new Error('First launch was not admitted'); + } + expect(form().isCreating).toBe(true); + expect(form().isStartDisabled).toBe(true); + expect(native.leaveLocked).toBe(true); + + native.organizationId = 'org-2'; + await act(async () => { + screen?.update(createElement(NewSessionScreenBody)); + }); + expect(form().isCreating).toBe(false); + await start(); + const second = requests[1]; + if (!second) { + throw new Error('Replacement launch was not admitted'); + } + expect(first.input.organizationId).toBe('org-1'); + expect(second.input.organizationId).toBe('org-2'); + expect(second.input.operationKey).not.toBe(first.input.operationKey); + expect(form().isCreating).toBe(true); + + await act(async () => { + if (outcome === 'success') { + first.response.resolve({ kiloSessionId: 'session-old' }); + } else { + first.response.reject( + outcome === 'retryable' + ? new Error('Lost response') + : Object.assign(new Error('Denied'), { data: { code: 'BAD_REQUEST' } }) + ); + } + }); + expect(form().isCreating).toBe(true); + expect(form().isStartDisabled).toBe(true); + expect(native.leaveLocked).toBe(true); + goBack(); + expect(native.path).toBe('/continue'); + expect(native.errors).toEqual([]); + expect(new Set(await storedKeys())).toEqual( + new Set([first.input.operationKey, second.input.operationKey]) + ); + + await act(async () => { + second.response.resolve({ kiloSessionId: 'session-current' }); + }); + expect(form().isCreating).toBe(false); + expect(native.leaveLocked).toBe(false); + expect(native.path).toContain('agent-chat/session-current'); + expect(await storedKeys()).toEqual([first.input.operationKey]); + } +); + +it.each(['retryable', 'terminal'] as const)( + 'releases a current %s failure for a safe retry', + async outcome => { + await mount(); + await start(); + const first = requests[0]; + if (!first) { + throw new Error('Launch was not admitted'); + } + await act(async () => { + first.response.reject( + outcome === 'retryable' + ? new Error('Lost response') + : Object.assign(new Error('Denied'), { data: { code: 'BAD_REQUEST' } }) + ); + }); + expect(form().isCreating).toBe(false); + expect(native.leaveLocked).toBe(false); + expect(native.path).toBe('/continue'); + expect(native.errors).toEqual([ + outcome === 'retryable' ? 'agentChat.session.cloneFailedRetry' : 'Denied', + ]); + expect(await storedKeys()).toEqual(outcome === 'retryable' ? [first.input.operationKey] : []); + await start(); + const retry = requests[1]; + if (!retry) { + throw new Error('Retry was not admitted'); + } + if (outcome === 'retryable') { + expect(retry.input.operationKey).toBe(first.input.operationKey); + } else { + expect(retry.input.operationKey).not.toBe(first.input.operationKey); + } + await act(async () => { + retry.response.resolve({ kiloSessionId: 'session-retry' }); + }); + expect(native.path).toContain('agent-chat/session-retry'); + expect(await listOutboxRows('user-1')).toEqual([]); + } +); + +it('keeps Continue disabled when the selected repository is absent', async () => { + native.selectedRepo = ''; + await mount(); + expect(form().isStartDisabled).toBe(true); + expect(form().isCreating).toBe(false); + expect(native.leaveLocked).toBe(false); + expect(native.errors).toEqual([]); + expect(requests).toEqual([]); +}); diff --git a/apps/mobile/src/components/agents/new-session-screen-body.tsx b/apps/mobile/src/components/agents/new-session-screen-body.tsx index a518c9836b..cc083c35b2 100644 --- a/apps/mobile/src/components/agents/new-session-screen-body.tsx +++ b/apps/mobile/src/components/agents/new-session-screen-body.tsx @@ -390,6 +390,16 @@ export function NewSessionScreenBody() { }); const runCloudCreate = useContinueCloudCreate(organizationId, armCloneNavigateBypass); + // The creator retires its results; its caller must also retire busy/error completion. + const continueScope = useMemo(() => ({ userId, organizationId }), [userId, organizationId]); + const currentContinueScope = useRef(continueScope); + currentContinueScope.current = continueScope; + useEffect(() => { + currentContinueScope.current = continueScope; + return () => { + currentContinueScope.current = null; + }; + }, [continueScope]); const handleModelSelect = useCallback( (modelId: string, newVariant: string, pickerSelection?: ModelPickerSelection) => { @@ -567,6 +577,9 @@ export function NewSessionScreenBody() { // Cloud Agent clone: submit the clone-only prepare with the source id and // the form's repo/model/variant; success replaces the form. void (async () => { + if (currentContinueScope.current !== continueScope) { + return; + } setIsCreating(true); try { await runCloudCreate( @@ -575,13 +588,18 @@ export function NewSessionScreenBody() { mode ); } catch (error) { + if (currentContinueScope.current !== continueScope) { + return; + } const message = isCloudPrepareRetryableError(error) || !(error instanceof Error) || !error.message ? i18n.t('agentChat.session.cloneFailedRetry') : error.message; toast.error(message); } finally { - setIsCreating(false); + if (currentContinueScope.current === continueScope) { + setIsCreating(false); + } } })(); return; @@ -603,6 +621,7 @@ export function NewSessionScreenBody() { displayVariant, mode, runCloudCreate, + continueScope, remoteSpawn, createSessionFromDraft, submitWithVoiceSettled, diff --git a/apps/mobile/src/components/agents/provider-launch-input.test.ts b/apps/mobile/src/components/agents/provider-launch-input.test.ts new file mode 100644 index 0000000000..87a08bfdb4 --- /dev/null +++ b/apps/mobile/src/components/agents/provider-launch-input.test.ts @@ -0,0 +1,246 @@ +import { describe, expect, it, vi } from 'vitest'; +import { type LaunchRepositoryReference } from '@kilocode/app-shared/code-review/repository-identity'; +import { type SessionManagerConfig } from '@kilocode/cloud-agent-sdk'; + +import { resolveProviderLaunchInput } from './provider-launch-input'; +import { type NewSessionRepository } from './new-session-repository-state'; + +const reference: LaunchRepositoryReference = { + repository: { + provider: 'gitlab', + instanceUrl: 'https://git.example/base', + repositoryId: '42', + fullName: 'Group/Sub/repo', + defaultBranch: 'develop', + }, + authorization: { + kind: 'ownerIntegration', + owner: { type: 'org', id: 'org-1' }, + integrationId: 'integration-1', + }, +}; + +function resolve( + ref = reference, + upstreamBranch: string | undefined = 'release', + accountId = 'user-1' +) { + const row: NewSessionRepository = { + platform: ref.repository.provider, + fullName: ref.repository.fullName, + isPrivate: true, + }; + return resolveProviderLaunchInput(row, { + accountId, + organizationId: ref.authorization.owner.type === 'org' ? ref.authorization.owner.id : undefined, + launchSelection: { reference: ref, upstreamBranch }, + }); +} + +describe('provider launch boundary', () => { + it.each([ + { + provider: 'github', + instanceUrl: 'https://github.com', + expected: { githubRepo: 'owner/repo', githubIntegrationId: 'integration-1' }, + }, + { + provider: 'gitlab', + instanceUrl: 'https://git.example/base', + expected: { + gitlabProject: 'owner/repo', + gitlabIntegrationId: 'integration-1', + gitlabInstanceUrl: 'https://git.example/base', + }, + }, + { + provider: 'bitbucket', + instanceUrl: 'https://bitbucket.org', + expected: { + bitbucketRepo: { + fullName: 'owner/repo', + workspaceUuid: 'workspace-1', + repositoryUuid: '42', + }, + bitbucketIntegrationId: 'integration-1', + }, + }, + ] as const)( + 'maps $provider identity and the selected branch', + ({ provider, instanceUrl, expected }) => { + const repository = + provider === 'bitbucket' + ? { + ...reference.repository, + provider, + instanceUrl, + fullName: 'owner/repo', + workspaceUuid: 'workspace-1', + } + : { ...reference.repository, provider, instanceUrl, fullName: 'owner/repo' }; + expect(resolve({ ...reference, repository })?.input).toEqual({ + ...expected, + upstreamBranch: 'release', + }); + } + ); + + it('supports Personal GitLab without inventing a default branch', () => { + const personal: LaunchRepositoryReference = { + ...reference, + authorization: { ...reference.authorization, owner: { type: 'user', id: 'user-1' } }, + }; + const result = resolveProviderLaunchInput( + { platform: 'gitlab', fullName: 'Group/Sub/repo', isPrivate: true }, + { accountId: 'user-1', launchSelection: { reference: personal } } + ); + expect(result?.input).toEqual({ + gitlabProject: 'Group/Sub/repo', + gitlabIntegrationId: 'integration-1', + gitlabInstanceUrl: 'https://git.example/base', + }); + }); + + it('isolates every identity component and branch in the retry fingerprint', () => { + const variants = [ + resolve(reference, 'other'), + resolve(reference, 'release', 'other-account'), + resolve({ + ...reference, + authorization: { ...reference.authorization, integrationId: 'other-integration' }, + }), + resolve({ + ...reference, + authorization: { ...reference.authorization, owner: { type: 'org', id: 'other-org' } }, + }), + resolve({ + ...reference, + authorization: { ...reference.authorization, owner: { type: 'user', id: 'user-1' } }, + }), + resolve({ + ...reference, + repository: { ...reference.repository, instanceUrl: 'https://git.example/other' }, + }), + resolve({ ...reference, repository: { ...reference.repository, repositoryId: '43' } }), + resolve({ + ...reference, + repository: { ...reference.repository, fullName: 'Group/Other/repo' }, + }), + ]; + for (const result of variants) { + expect(result).not.toBeNull(); + expect(result?.fingerprint).not.toBe(resolve()?.fingerprint); + } + expect(new Set(variants.map(result => result?.fingerprint)).size).toBe(variants.length); + }); + + it('rejects an owner change, a stale row, an empty branch, and incomplete Bitbucket identity', () => { + const row: NewSessionRepository = { + platform: 'gitlab', + fullName: 'Group/Sub/repo', + isPrivate: true, + }; + expect( + resolveProviderLaunchInput(row, { + accountId: 'user-1', + organizationId: 'other-org', + launchSelection: { reference }, + }) + ).toBeNull(); + expect( + resolveProviderLaunchInput( + { ...row, fullName: 'other/repo' }, + { accountId: 'user-1', organizationId: 'org-1', launchSelection: { reference } } + ) + ).toBeNull(); + expect(resolve(reference, '')).toBeNull(); + expect( + resolveProviderLaunchInput( + { platform: 'bitbucket', fullName: 'workspace/repo', isPrivate: true }, + {} + ) + ).toBeNull(); + expect(resolveProviderLaunchInput(null, {})).toBeNull(); + }); + + it('keeps the old GitHub payload and retry bytes when selection additions are absent', () => { + const result = resolveProviderLaunchInput( + { platform: 'github', fullName: 'Owner/repo', isPrivate: true }, + {} + ); + expect(result?.input).toEqual({ githubRepo: 'Owner/repo' }); + expect(JSON.stringify(result?.fingerprint)).toBe( + '{"platform":"github","fullName":"Owner/repo"}' + ); + }); +}); + +// Exercise the real mobile adapter with its external transports replaced. +const manager = vi.hoisted(() => ({ config: null as SessionManagerConfig | null, query: vi.fn() })); +vi.mock('@kilocode/cloud-agent-sdk', () => ({ + createSessionManager: (config: SessionManagerConfig) => { + manager.config = config; + return {}; + }, +})); +vi.mock('sonner-native', () => ({ toast: { error: vi.fn() } })); +vi.mock('@/lib/auth/token-owner', () => ({ getAuthTokenForRequest: vi.fn() })); +vi.mock('@/lib/config', () => ({ + API_BASE_URL: 'https://api.test', + CLOUD_AGENT_WS_URL: 'wss://ws.test', + WEB_BASE_URL: 'https://web.test', +})); +vi.mock('@/lib/user-web-connection-lifecycle', () => ({ + createNativeUserWebConnectionLifecycleHooks: vi.fn(), +})); +vi.mock('@/components/agents/mobile-session-transport-payload', () => ({ + normalizeTransportPayload: vi.fn(), +})); +vi.mock('@/components/agents/mobile-session-diagnostics', () => ({ + formatSafeCloudAgentFailureDiagnostic: vi.fn(), + withCloudAgentDiagnostics: vi.fn(), +})); +vi.mock('@/components/agents/mobile-session-page-adapter', () => ({ + fetchMobileSessionSnapshotPage: vi.fn(), +})); +vi.mock('@/components/agents/tool-card-image-cache', () => ({ cacheToolAttachment: vi.fn() })); +vi.mock('@/components/agents/file-part-cache', () => ({ cacheFilePart: vi.fn() })); +vi.mock('@/lib/trpc', () => ({ + trpcClient: { cliSessionsV2: { getWithRuntimeState: { query: manager.query } } }, +})); + +it.each([ + [ + { githubRepo: 'Owner/repo' }, + 'https://github.com/other/repo', + { repository: 'Owner/repo', gitUrl: 'https://github.com/other/repo' }, + ], + [ + { gitUrl: 'https://git.example/base/Group/Sub/repo.git' }, + null, + { repository: 'base/Group/Sub/repo', gitUrl: 'https://git.example/base/Group/Sub/repo.git' }, + ], + [ + { gitUrl: 'https://bitbucket.org/workspace/repo.git' }, + null, + { repository: 'workspace/repo', gitUrl: 'https://bitbucket.org/workspace/repo.git' }, + ], + [ + null, + 'git@gitlab.com:group/sub/repo.git', + { repository: 'group/sub/repo', gitUrl: 'git@gitlab.com:group/sub/repo.git' }, + ], + [null, null, { repository: null, gitUrl: null }], +])( + 'reconstructs repository metadata from runtime or old history %j', + async (runtimeState, gitUrl, expected) => { + const { createMobileAgentSessionManager } = await import('./mobile-session-manager'); + const dependencies = { store: {}, userWebConnection: {} }; + createMobileAgentSessionManager( + dependencies as Parameters[0] + ); + manager.query.mockResolvedValue({ runtimeState, git_url: gitUrl }); + const result = await manager.config?.fetchSession('ses_test' as never); + expect(result).toMatchObject(expected); + } +); diff --git a/apps/mobile/src/components/agents/provider-launch-input.ts b/apps/mobile/src/components/agents/provider-launch-input.ts new file mode 100644 index 0000000000..98fff97e37 --- /dev/null +++ b/apps/mobile/src/components/agents/provider-launch-input.ts @@ -0,0 +1,128 @@ +import { + type LaunchRepositoryReference, + repositoryResourceKey, + requireLaunchRepository, +} from '@kilocode/app-shared/code-review/repository-identity'; +import { type PrepareInput } from '@kilocode/cloud-agent-sdk/session-manager'; + +import { type NewSessionRepository } from './new-session-repository-state'; + +export type ProviderLaunchSelection = { + reference: LaunchRepositoryReference; + upstreamBranch?: string; +}; + +export type ProviderPrepareInput = Pick< + PrepareInput, + | 'githubRepo' + | 'githubIntegrationId' + | 'gitlabProject' + | 'gitlabIntegrationId' + | 'gitlabInstanceUrl' + | 'bitbucketRepo' + | 'bitbucketIntegrationId' + | 'upstreamBranch' +>; + +export type ProviderLaunchContext = { + launchSelection?: ProviderLaunchSelection | null; + accountId?: string; + organizationId?: string; +}; + +export function isProviderLaunchSelectionCurrent({ + launchSelection, + accountId, + organizationId, +}: ProviderLaunchContext): boolean { + // Old picker callers omit normalized selection. Remove only after old clients + // and records disappear and the 30-day ledger window expires. + if (launchSelection === undefined) { + return true; + } + if (!launchSelection || !accountId) { + return false; + } + const { reference, upstreamBranch } = launchSelection; + const { owner } = reference.authorization; + return ( + owner.type === (organizationId ? 'org' : 'user') && + owner.id === (organizationId ?? accountId) && + (reference.repository.provider !== 'bitbucket' || Boolean(organizationId)) && + (upstreamBranch === undefined || upstreamBranch.trim().length > 0) + ); +} + +export function resolveProviderLaunchInput( + repository: NewSessionRepository | null, + context: ProviderLaunchContext +) { + if (!repository || !isProviderLaunchSelectionCurrent(context)) { + return null; + } + const { launchSelection, accountId } = context; + const reference = launchSelection ? requireLaunchRepository(launchSelection.reference) : null; + if ( + reference && + (reference.repository.provider !== repository.platform || + reference.repository.fullName !== repository.fullName) + ) { + return null; + } + const input: ProviderPrepareInput = {}; + const integrationId = reference?.authorization.integrationId; + if (repository.platform === 'github') { + input.githubRepo = repository.fullName; + if (integrationId) { + input.githubIntegrationId = integrationId; + } + } else if (repository.platform === 'gitlab') { + input.gitlabProject = repository.fullName; + if (integrationId) { + input.gitlabIntegrationId = integrationId; + input.gitlabInstanceUrl = reference.repository.instanceUrl; + } + } else { + const identity = reference?.repository; + const workspaceUuid = + identity?.provider === 'bitbucket' ? identity.workspaceUuid : repository.workspaceUuid; + const repositoryUuid = identity?.repositoryId ?? repository.repositoryUuid; + if ( + !workspaceUuid || + !repositoryUuid || + (reference && repository.workspaceUuid && repository.workspaceUuid !== workspaceUuid) || + (reference && repository.repositoryUuid && repository.repositoryUuid !== repositoryUuid) + ) { + return null; + } + input.bitbucketRepo = { fullName: repository.fullName, workspaceUuid, repositoryUuid }; + if (integrationId) { + input.bitbucketIntegrationId = integrationId; + } + } + if (launchSelection?.upstreamBranch !== undefined) { + input.upstreamBranch = launchSelection.upstreamBranch; + } + // Old picker rows retain their exact retry bytes and server-side unpinned + // lookup until old clients/records and the 30-day ledger window expire. + if (reference && accountId) { + return { + input, + fingerprint: JSON.stringify([ + 'provider-launch:v1', + repositoryResourceKey(accountId, reference), + input.upstreamBranch ?? null, + ]), + }; + } + const fingerprint = + repository.platform === 'bitbucket' + ? { + platform: repository.platform, + fullName: repository.fullName, + workspaceUuid: repository.workspaceUuid ?? null, + repositoryUuid: repository.repositoryUuid ?? null, + } + : { platform: repository.platform, fullName: repository.fullName }; + return { input, fingerprint }; +} diff --git a/apps/mobile/src/components/agents/use-continue-cloud-create.mounted.test.tsx b/apps/mobile/src/components/agents/use-continue-cloud-create.mounted.test.tsx new file mode 100644 index 0000000000..338c87750a --- /dev/null +++ b/apps/mobile/src/components/agents/use-continue-cloud-create.mounted.test.tsx @@ -0,0 +1,474 @@ +/* eslint-disable typescript-eslint/no-deprecated -- react-test-renderer exercises real React Native hook cleanup without a DOM */ +/* eslint-disable require-await, @typescript-eslint/require-await -- native transport and storage doubles return promises */ +/* eslint-disable max-lines -- the provider matrix and lifecycle regressions share the real outbox harness */ +import { createElement } from 'react'; +import TestRenderer, { act } from 'react-test-renderer'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { type KiloSessionId } from '@kilocode/cloud-agent-sdk'; +import { type LaunchRepositoryReference } from '@kilocode/app-shared/code-review/repository-identity'; + +import { listOutboxRows } from '@/lib/persist/mutation-outbox'; +import { useContinueCloudCreate } from './use-continue-cloud-create'; + +const native = vi.hoisted(() => ({ + userId: 'user-1', + nextKey: 0, + path: '/continue', + created: 0, + rows: new Map(), + beforeWrite: undefined as (() => Promise) | undefined, +})); +vi.mock('@/lib/hooks/use-current-user-id', () => ({ + useCurrentUserId: () => ({ userId: native.userId, isLoading: false }), +})); +vi.mock('expo-crypto', () => ({ + randomUUID: () => { + native.nextKey += 1; + return `operation-${native.nextKey}`; + }, +})); +vi.mock('expo-router', () => ({ + useRouter: () => ({ + replace: (path: string) => { + native.path = path; + }, + }), +})); +vi.mock('@tanstack/react-query', () => ({ useQueryClient: () => ({}) })); +vi.mock('expo-haptics', () => ({ + notificationAsync: async () => undefined, + NotificationFeedbackType: { Success: 'success' }, +})); +vi.mock('sonner-native', () => ({ toast: { error: vi.fn() } })); +vi.mock('@sentry/react-native', () => ({ captureException: vi.fn() })); +vi.mock('@/lib/agent-session-cache', () => ({ + invalidateAgentSessionQueries: async () => undefined, +})); +vi.mock('@/lib/analytics/posthog', () => ({ + captureEvent: vi.fn(), + SESSION_CREATED_EVENT: 'session_created', +})); +vi.mock('@/lib/persist/encrypted-kv', () => ({ + getItem: async (scope: string, k: string) => native.rows.get(`${scope}\0${k}`)?.v ?? null, + setItem: async (scope: string, k: string, v: string) => { + await native.beforeWrite?.(); + native.rows.set(`${scope}\0${k}`, { scope, k, v }); + }, + removeItem: async (scope: string, k: string) => { + native.rows.delete(`${scope}\0${k}`); + }, + listEntries: async (scope: string) => + [...native.rows.values()].filter(row => row.scope === scope), +})); +// Keep the real retry classifier; replace only its native and transport dependencies. +vi.mock('@kilocode/cloud-agent-sdk', () => ({ createSessionManager: vi.fn() })); +vi.mock('@/lib/auth/token-owner', () => ({ getAuthTokenForRequest: vi.fn() })); +vi.mock('@/lib/config', () => ({ + API_BASE_URL: 'https://api.test', + CLOUD_AGENT_WS_URL: 'wss://ws.test', + WEB_BASE_URL: 'https://web.test', +})); +vi.mock('@/lib/user-web-connection-lifecycle', () => ({ + createNativeUserWebConnectionLifecycleHooks: vi.fn(), +})); +vi.mock('@/components/agents/mobile-session-transport-payload', () => ({ + normalizeTransportPayload: vi.fn(), +})); +vi.mock('@/components/agents/mobile-session-diagnostics', () => ({ + formatSafeCloudAgentFailureDiagnostic: vi.fn(), + withCloudAgentDiagnostics: vi.fn(), +})); +vi.mock('@/components/agents/mobile-session-page-adapter', () => ({ + fetchMobileSessionSnapshotPage: vi.fn(), +})); +vi.mock('@/components/agents/tool-card-image-cache', () => ({ cacheToolAttachment: vi.fn() })); +vi.mock('@/components/agents/file-part-cache', () => ({ cacheFilePart: vi.fn() })); +vi.mock('@/lib/trpc', () => ({ + useTRPC: () => ({}), + trpcClient: { + cloudAgentNext: { + prepareSession: { mutate: async (input: Payload) => prepare(input, 'user') }, + }, + organizations: { + cloudAgentNext: { + prepareSession: { mutate: async (input: Payload) => prepare(input, 'org') }, + }, + }, + }, +})); + +type Run = ReturnType; +type Destination = Parameters[1]; +type Payload = { operationKey: string; organizationId?: string; [key: string]: unknown }; +const SOURCE = 'ses_source' as KiloSessionId; +const server = { + requests: [] as Payload[], + routes: [] as ('user' | 'org')[], + sessions: new Map(), + error: undefined as Error | undefined, + beforeResponse: undefined as (() => Promise) | undefined, +}; +async function storedKeys(userId = 'user-1') { + const rows = await listOutboxRows(userId); + return rows?.map(row => row.operationKey); +} +async function prepare(input: Payload, owner: 'user' | 'org') { + server.requests.push(input); + server.routes.push(owner); + expect(await storedKeys(native.userId)).toContain(input.operationKey); + const kiloSessionId = + server.sessions.get(input.operationKey) ?? `session-${server.sessions.size + 1}`; + server.sessions.set(input.operationKey, kiloSessionId); + await server.beforeResponse?.(); + if (server.error) { + throw server.error; + } + return { kiloSessionId }; +} +const providers = [ + { + platform: 'github', + instanceUrl: 'https://github.com', + fullName: 'owner/repo', + legacy: { githubRepo: 'owner/repo' }, + pin: { githubIntegrationId: 'integration-a' }, + pinField: 'githubIntegrationId', + }, + { + platform: 'gitlab', + instanceUrl: 'https://gitlab.com', + fullName: 'group/sub/repo', + legacy: { gitlabProject: 'group/sub/repo' }, + pin: { gitlabIntegrationId: 'integration-a', gitlabInstanceUrl: 'https://gitlab.com' }, + pinField: 'gitlabIntegrationId', + }, + { + platform: 'gitlab', + instanceUrl: 'https://git.example/base', + fullName: 'group/sub/repo', + legacy: { gitlabProject: 'group/sub/repo' }, + pin: { gitlabIntegrationId: 'integration-a', gitlabInstanceUrl: 'https://git.example/base' }, + pinField: 'gitlabIntegrationId', + }, + { + platform: 'bitbucket', + instanceUrl: 'https://bitbucket.org', + fullName: 'workspace/repo', + legacy: { + bitbucketRepo: { + fullName: 'workspace/repo', + workspaceUuid: 'workspace-1', + repositoryUuid: '42', + }, + }, + pin: { bitbucketIntegrationId: 'integration-a' }, + pinField: 'bitbucketIntegrationId', + }, +] as const; +const cases = providers.flatMap(provider => + (provider.platform === 'bitbucket' ? (['org'] as const) : (['user', 'org'] as const)).map( + type => { + const reference: LaunchRepositoryReference = { + repository: { + instanceUrl: provider.instanceUrl, + repositoryId: '42', + fullName: provider.fullName, + defaultBranch: 'main', + ...(provider.platform === 'bitbucket' + ? { provider: 'bitbucket' as const, workspaceUuid: 'workspace-1' } + : { provider: provider.platform }), + }, + authorization: { + kind: 'ownerIntegration', + owner: { type, id: type === 'org' ? 'org-1' : 'user-1' }, + integrationId: 'integration-a', + }, + }; + return { provider, type, reference, organizationId: type === 'org' ? 'org-1' : undefined }; + } + ) +); +function destinationFor(entry: (typeof cases)[number]): Destination { + return { + repository: { + platform: entry.provider.platform, + fullName: entry.provider.fullName, + isPrivate: true, + ...(entry.provider.platform === 'bitbucket' + ? { workspaceUuid: 'workspace-1', repositoryUuid: '42' } + : {}), + }, + model: 'model', + variant: 'high', + launchSelection: { reference: entry.reference, upstreamBranch: 'release/Case' }, + }; +} +function onCreated() { + native.created += 1; +} +function Form({ + organizationId, + result, +}: { + organizationId?: string; + result: { current: Run | null }; +}) { + result.current = useContinueCloudCreate(organizationId, onCreated); + return null; +} +const renderers = new Set(); +async function mount(organizationId?: string) { + const result: { current: Run | null } = { current: null }; + let renderer: TestRenderer.ReactTestRenderer | undefined = undefined; + await act(async () => { + renderer = TestRenderer.create(createElement(Form, { organizationId, result })); + renderers.add(renderer); + }); + const get = () => { + if (!result.current) { + throw new Error('Creator did not mount'); + } + return result.current; + }; + return { + get, + submit: async (destination: Destination) => { + let failure: unknown = undefined; + await act(async () => { + try { + await get()(SOURCE, destination, 'code'); + } catch (error) { + failure = error; + } + }); + return failure; + }, + update: async (next?: string) => { + await act(async () => { + renderer?.update(createElement(Form, { organizationId: next, result })); + }); + }, + unmount: async () => { + await act(async () => { + renderer?.unmount(); + }); + if (renderer) { + renderers.delete(renderer); + } + }, + }; +} +beforeEach(() => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + native.userId = 'user-1'; + native.nextKey = 0; + native.path = '/continue'; + native.created = 0; + native.rows.clear(); + native.beforeWrite = undefined; + server.requests = []; + server.routes = []; + server.sessions.clear(); + server.error = undefined; + server.beforeResponse = undefined; +}); +afterEach(async () => { + await act(async () => { + for (const renderer of renderers) { + renderer.unmount(); + } + }); + renderers.clear(); + vi.unstubAllGlobals(); +}); + +describe.each(cases)('continue $provider.platform $type $provider.instanceUrl', entry => { + it.each([false, true])('transmits the exact repository and owner, legacy=%s', async legacy => { + const destination = destinationFor(entry); + if (legacy) { + destination.launchSelection = undefined; + } + const form = await mount(entry.organizationId); + expect(await form.submit(destination)).toBeUndefined(); + expect(server.requests).toEqual([ + { + cloneFromKiloSessionId: SOURCE, + mode: 'code', + model: 'model', + variant: 'high', + autoCommit: false, + autoInitiate: true, + operationKey: expect.any(String), + ...entry.provider.legacy, + ...(legacy ? {} : { ...entry.provider.pin, upstreamBranch: 'release/Case' }), + ...(entry.organizationId ? { organizationId: entry.organizationId } : {}), + }, + ]); + expect(server.routes).toEqual([entry.type]); + expect(server.sessions.size).toBe(1); + expect(native.path).toContain('agent-chat/session-1'); + expect(native.created).toBe(1); + expect(await listOutboxRows('user-1')).toEqual([]); + }); + + it.each(['branch', 'integration'])( + 'recovers A/B/A after lost responses and a changed %s', + async change => { + const first = destinationFor(entry); + const second: Destination = { + ...first, + launchSelection: { + reference: { + ...entry.reference, + authorization: { + ...entry.reference.authorization, + integrationId: change === 'integration' ? 'integration-b' : 'integration-a', + }, + }, + upstreamBranch: change === 'branch' ? 'other/Case' : 'release/Case', + }, + }; + const form = await mount(entry.organizationId); + server.error = new Error('Lost response'); + await act(async () => { + const attempts = await Promise.allSettled([ + form.get()(SOURCE, first, 'code'), + form.get()(SOURCE, first, 'code'), + ]); + expect(attempts.map(attempt => attempt.status)).toEqual(['rejected', 'rejected']); + }); + const keyA = server.requests[0]?.operationKey; + expect(server.sessions.size).toBe(1); + expect(await form.submit(second)).toEqual(new Error('Lost response')); + const keyB = server.requests[2]?.operationKey; + expect(keyB).not.toBe(keyA); + expect(server.requests[2]).toMatchObject({ + ...entry.provider.legacy, + ...entry.provider.pin, + [entry.provider.pinField]: change === 'integration' ? 'integration-b' : 'integration-a', + upstreamBranch: change === 'branch' ? 'other/Case' : 'release/Case', + }); + expect(server.sessions.size).toBe(2); + expect(new Set(await storedKeys())).toEqual(new Set([keyA, keyB])); + server.error = undefined; + expect(await form.submit(first)).toBeUndefined(); + expect(server.requests.map(row => row.operationKey)).toEqual([keyA, keyA, keyB, keyA]); + expect(server.sessions.size).toBe(2); + expect(native.path).toContain('agent-chat/session-1'); + expect(await storedKeys()).toEqual([keyB]); + await form.unmount(); + const remounted = await mount(entry.organizationId); + expect(await remounted.submit(second)).toBeUndefined(); + expect(server.sessions.size).toBe(2); + expect(server.requests.at(-1)?.operationKey).toBe(keyB); + expect(native.path).toContain('agent-chat/session-2'); + expect(await listOutboxRows('user-1')).toEqual([]); + } + ); + + it('rejects the current and saved callbacks after an owner change', async () => { + const destination = destinationFor(entry); + const form = await mount(entry.organizationId); + const saved = form.get(); + if (entry.type === 'user') { + native.userId = 'other-user'; + } + await form.update(entry.type === 'org' ? 'other-org' : undefined); + await act(async () => { + await saved(SOURCE, destination, 'code'); + }); + expect(await form.submit(destination)).toMatchObject({ data: { code: 'BAD_REQUEST' } }); + expect(server.sessions.size).toBe(0); + expect(native.rows.size).toBe(0); + expect(native.path).toBe('/continue'); + expect(native.created).toBe(0); + }); +}); + +it.each(['before dispatch', 'success', 'retryable', 'terminal'])( + 'retains retry state after unmount during %s', + async phase => { + const entry = cases[0]; + if (!entry) { + throw new Error('Missing launch case'); + } + const gate = Promise.withResolvers(); + const entered = Promise.withResolvers(); + const pause = async () => { + entered.resolve(undefined); + await gate.promise; + }; + if (phase === 'before dispatch') { + native.beforeWrite = pause; + } else { + server.beforeResponse = pause; + } + if (phase === 'retryable') { + server.error = new Error('Lost response'); + } + if (phase === 'terminal') { + server.error = Object.assign(new Error('Rejected'), { data: { code: 'BAD_REQUEST' } }); + } + const form = await mount(entry.organizationId); + const destination = destinationFor(entry); + const saved = form.get(); + const request = saved(SOURCE, destination, 'code'); + await act(async () => { + await entered.promise; + }); + await form.unmount(); + await act(async () => { + gate.resolve(undefined); + await request; + await saved(SOURCE, destination, 'code'); + }); + expect(server.sessions.size).toBe(phase === 'before dispatch' ? 0 : 1); + expect(await storedKeys()).toEqual(['operation-1']); + expect(native.path).toBe('/continue'); + expect(native.created).toBe(0); + } +); + +it.each(['owner', 'account'])( + 'keeps a preparation retired after a %s A/B/A transition', + async change => { + const entry = cases.find(candidate => candidate.type === 'org'); + if (!entry) { + throw new Error('Missing organization case'); + } + const gate = Promise.withResolvers(); + const entered = Promise.withResolvers(); + server.beforeResponse = async () => { + entered.resolve(undefined); + await gate.promise; + }; + const destination = destinationFor(entry); + const form = await mount(entry.organizationId); + const request = form.get()(SOURCE, destination, 'code'); + await act(async () => { + await entered.promise; + }); + if (change === 'account') { + native.userId = 'other-user'; + } + await form.update(change === 'owner' ? 'other-org' : entry.organizationId); + native.userId = 'user-1'; + await form.update(entry.organizationId); + await act(async () => { + gate.resolve(undefined); + await request; + }); + expect(server.sessions.size).toBe(1); + expect(await storedKeys()).toEqual(['operation-1']); + expect(native.path).toBe('/continue'); + expect(native.created).toBe(0); + } +); + +it('keeps an absent continue repository inert', async () => { + const form = await mount(); + expect(await form.submit({ repository: null, model: 'model', variant: '' })).toBeUndefined(); + expect(server.sessions.size).toBe(0); + expect(native.rows.size).toBe(0); + expect(native.path).toBe('/continue'); +}); diff --git a/apps/mobile/src/components/agents/use-continue-cloud-create.test.ts b/apps/mobile/src/components/agents/use-continue-cloud-create.test.ts index d8dbf362a8..3d2ccc53c8 100644 --- a/apps/mobile/src/components/agents/use-continue-cloud-create.test.ts +++ b/apps/mobile/src/components/agents/use-continue-cloud-create.test.ts @@ -8,6 +8,10 @@ import { type KiloSessionId } from '@kilocode/cloud-agent-sdk'; import { type NewSessionRepository } from '@/components/agents/new-session-repository-state'; import { useContinueCloudCreate } from './use-continue-cloud-create'; +vi.mock('@/lib/hooks/use-current-user-id', () => ({ + useCurrentUserId: () => ({ userId: 'user-1' }), +})); + const prepareSessionMutate = vi.hoisted(() => vi.fn()); const routerReplace = vi.hoisted(() => vi.fn()); const routerPush = vi.hoisted(() => vi.fn()); @@ -153,6 +157,97 @@ beforeEach(() => { }); describe('useContinueCloudCreate', () => { + const repository: NewSessionRepository = { + platform: 'bitbucket', + fullName: 'workspace/repo', + isPrivate: true, + workspaceUuid: 'workspace-1', + repositoryUuid: 'repository-1', + }; + const reference = { + repository: { + provider: 'bitbucket' as const, + fullName: 'workspace/repo', + instanceUrl: 'https://bitbucket.org', + repositoryId: 'repository-1', + workspaceUuid: 'workspace-1', + defaultBranch: 'develop', + }, + authorization: { + kind: 'ownerIntegration' as const, + owner: { type: 'org' as const, id: 'org-1' }, + integrationId: 'integration-1', + }, + }; + it('pins Bitbucket continue launches and separates changed branch retries', async () => { + const run = mountContinue('org-1'); + operationKeyMock.getKey.mockImplementation(fingerprint => fingerprint); + prepareSessionMutate.mockRejectedValue(creationInProgressError()); + await Promise.all( + ['release', 'release', 'other'].map(async upstreamBranch => + expect( + run( + SOURCE_SESSION, + { ...DEST, repository, launchSelection: { reference, upstreamBranch } }, + 'code' + ) + ).rejects.toThrow('creation_in_progress') + ) + ); + const inputs = prepareSessionMutate.mock.calls.map(call => call[0] as Record); + expect(inputs[0]).toMatchObject({ + bitbucketRepo: { + fullName: 'workspace/repo', + workspaceUuid: 'workspace-1', + repositoryUuid: 'repository-1', + }, + bitbucketIntegrationId: 'integration-1', + upstreamBranch: 'release', + cloneFromKiloSessionId: 'ses_source', + }); + expect(inputs[0]).not.toHaveProperty('prompt'); + expect(inputs[1]?.operationKey).toBe(inputs[0]?.operationKey); + expect(inputs[2]?.operationKey).not.toBe(inputs[0]?.operationKey); + }); + + it('keeps an empty continue destination inert and rejects a stale owner without a retry key', async () => { + const run = mountContinue('org-2'); + await run(SOURCE_SESSION, { ...DEST, repository: null }, 'code'); + await expect( + run(SOURCE_SESSION, { ...DEST, repository, launchSelection: { reference } }, 'code') + ).rejects.toMatchObject({ data: { code: 'BAD_REQUEST' } }); + expect(prepareSessionMutate.mock.calls).toEqual([]); + }); + + it('recovers the same clone after a lost response and remount', async () => { + const stored = new Map(); + const sessions = new Map(); + let loseResponse = true; + outboxMock.getStoredOperationKey.mockImplementation( + fingerprint => stored.get(fingerprint) ?? null + ); + outboxMock.writeSafeRetry.mockImplementation(async (...args: unknown[]) => { + const row = args[0] as { fingerprint: string; operationKey: string }; + stored.set(row.fingerprint, row.operationKey); + }); + prepareSessionMutate.mockImplementation(async (payload: { operationKey: string }) => { + expect([...stored.values()]).toContain(payload.operationKey); + sessions.set(payload.operationKey, 'ses_recovered'); + if (loseResponse) { + throw new Error('Lost response'); + } + return { kiloSessionId: sessions.get(payload.operationKey) }; + }); + await expect(mountContinue('org-1')(SOURCE_SESSION, DEST, 'code')).rejects.toThrow( + 'Lost response' + ); + loseResponse = false; + operationKeyMock.getKey.mockReturnValue('replacement-key'); + await mountContinue('org-1')(SOURCE_SESSION, DEST, 'code'); + expect(sessions.size).toBe(1); + expect(routerReplace.mock.calls.at(-1)?.[0]).toContain('agent-chat/ses_recovered'); + }); + it('success replaces the route (replaceWithAgentSession, never push)', async () => { const run = mountContinue('org-1'); diff --git a/apps/mobile/src/components/agents/use-continue-cloud-create.ts b/apps/mobile/src/components/agents/use-continue-cloud-create.ts index d64d44c1e4..e1b25e39d8 100644 --- a/apps/mobile/src/components/agents/use-continue-cloud-create.ts +++ b/apps/mobile/src/components/agents/use-continue-cloud-create.ts @@ -1,37 +1,54 @@ // Performs one `prepareSession` clone for the Cloud Agent Continue entry: a // hoisted operation key, a safe-retry outbox row, and post-success navigation. -import { useCallback } from 'react'; +import { useCallback, useEffect, useMemo, useRef } from 'react'; import { useRouter } from 'expo-router'; import { useQueryClient } from '@tanstack/react-query'; import { type KiloSessionId } from '@kilocode/cloud-agent-sdk'; import * as Haptics from 'expo-haptics'; import { type AgentMode, normalizeAgentMode } from '@/components/agents/mode-normalize'; +import { type NewSessionRepository } from '@/components/agents/new-session-repository-state'; import { - type NewSessionRepository, - type RepositoryPlatform, -} from '@/components/agents/new-session-repository-state'; + type ProviderLaunchSelection, + type ProviderPrepareInput, + resolveProviderLaunchInput, +} from '@/components/agents/provider-launch-input'; import { isCloudPrepareRetryableError } from '@/components/agents/mobile-session-manager'; import { replaceWithAgentSession } from '@/components/agents/session-detail-routes'; import { i18n } from '@/i18n'; +import { useCurrentUserId } from '@/lib/hooks/use-current-user-id'; import { useHoistedOperationKey } from '@/lib/operation-key'; import { useMutationOutbox } from '@/lib/persist/use-mutation-outbox'; import { captureEvent, SESSION_CREATED_EVENT } from '@/lib/analytics/posthog'; import { invalidateAgentSessionQueries } from '@/lib/agent-session-cache'; import { trpcClient, useTRPC } from '@/lib/trpc'; +type ContinueDestination = { + repository: NewSessionRepository | null; + model: string; + variant: string; + launchSelection?: ProviderLaunchSelection | null; +}; + export function useContinueCloudCreate( organizationId: string | undefined, /** Invoked once the clone settled, right before the success navigation. */ onCreated?: () => void -): ( - sessionId: KiloSessionId, - dest: { repository: NewSessionRepository | null; model: string; variant: string }, - mode: string -) => Promise { +): (sessionId: KiloSessionId, dest: ContinueDestination, mode: string) => Promise { const router = useRouter(); const queryClient = useQueryClient(); const trpc = useTRPC(); + const { userId } = useCurrentUserId(); + const scopeKey = JSON.stringify([userId, organizationId]); + const scope = useMemo(() => ({ key: scopeKey }), [scopeKey]); + const currentScope = useRef(scope); + currentScope.current = scope; + useEffect(() => { + currentScope.current = scope; + return () => { + currentScope.current = null; + }; + }, [scope]); // P1-A-08b: cloud prepares and remote spawns are different intents, so each // destination family holds its own hoisted `operationKey`. const cloudOperationKey = useHoistedOperationKey(); @@ -45,14 +62,28 @@ export function useContinueCloudCreate( } = useMutationOutbox(); return useCallback( - async ( - sessionId: KiloSessionId, - dest: { repository: NewSessionRepository | null; model: string; variant: string }, - mode: string - ) => { + async (sessionId: KiloSessionId, dest: ContinueDestination, mode: string) => { + if (!dest.repository || currentScope.current !== scope) { + return; + } + const launch = resolveProviderLaunchInput(dest.repository, { + launchSelection: dest.launchSelection, + accountId: userId, + organizationId, + }); + if (!launch) { + throw Object.assign( + new Error( + i18n.t('agentChat.newSession.prefillRepoUnavailable', { + repo: dest.repository.fullName, + }) + ), + { data: { code: 'BAD_REQUEST' } } + ); + } const intentFingerprint = JSON.stringify({ cloneFromKiloSessionId: sessionId, - repo: resolveRepoFingerprint(dest.repository), + repo: launch.fingerprint, model: dest.model, variant: dest.variant || undefined, mode, @@ -65,7 +96,11 @@ export function useContinueCloudCreate( // duplicate. // A failed outbox read reads as no stored rows, so refuse instead of // minting a fresh key over a row whose POST the server may have accepted. - if (!(await whenLoaded())) { + const outboxLoaded = await whenLoaded(); + if (currentScope.current !== scope) { + return; + } + if (!outboxLoaded) { throw new Error(i18n.t('agentChat.newSession.couldNotReadPendingSessions')); } const operationKey = @@ -80,8 +115,8 @@ export function useContinueCloudCreate( autoInitiate: true, operationKey, cloneFromKiloSessionId: sessionId, + ...launch.input, }; - setRepositoryField(baseInput, dest.repository); try { // Persist the safe-retry row BEFORE the mutate so a crash mid-flight // reuses the same key on relaunch instead of minting a duplicate. @@ -90,6 +125,9 @@ export function useContinueCloudCreate( fingerprint: intentFingerprint, input: baseInput, }); + if (currentScope.current !== scope) { + return; + } const result = organizationId ? await trpcClient.organizations.cloudAgentNext.prepareSession.mutate({ @@ -97,11 +135,17 @@ export function useContinueCloudCreate( organizationId, }) : await trpcClient.cloudAgentNext.prepareSession.mutate(baseInput); + if (currentScope.current !== scope) { + return; + } // The intent settled; the next submit is a fresh intent. Rotate // before the post-success work so a UI failure cannot keep the // successful key for a retry or rotate it a second time. cloudOperationKey.rotateKey(); await removeOutboxRow(intentFingerprint); + if (currentScope.current !== scope) { + return; + } // The cloud session already exists, so no post-success UI failure may // report the create as failed or invite a duplicate retry. Each step is @@ -116,11 +160,17 @@ export function useContinueCloudCreate( } catch { // A failed cache invalidation is cosmetic; navigation must still run. } + if (currentScope.current !== scope) { + return; + } try { await Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success); } catch { // A failed haptics call is cosmetic; stay silent and navigate. } + if (currentScope.current !== scope) { + return; + } try { // Arm the route's busy leave-lock bypass right before the replace so // the success navigation is not intercepted as an abandon. @@ -128,6 +178,9 @@ export function useContinueCloudCreate( } catch { // The session exists; a host callback failure must not skip navigation. } + if (currentScope.current !== scope) { + return; + } try { // Replace (not push) the continue form with the cloned session so // back from the new session returns to the source session. @@ -136,17 +189,24 @@ export function useContinueCloudCreate( // A navigation failure is not a create failure. } } catch (error) { + if (currentScope.current !== scope) { + return; + } // Only `prepareSession` errors reach here; UI failures are contained // above. A typed terminal rejection ends the intent. if (!isCloudPrepareRetryableError(error)) { cloudOperationKey.rotateKey(); await removeOutboxRow(intentFingerprint); } - throw error; + if (currentScope.current === scope) { + throw error; + } } }, [ organizationId, + userId, + scope, queryClient, router, trpc, @@ -161,73 +221,12 @@ export function useContinueCloudCreate( } /** Clone-only prepare body. Mirrors the ordinary create path's repository fields. */ -type ContinuePrepareInput = { +type ContinuePrepareInput = ProviderPrepareInput & { mode: AgentMode; model: string; variant: string | undefined; - githubRepo?: string; - gitlabProject?: string; - bitbucketRepo?: { fullName: string; workspaceUuid: string; repositoryUuid: string }; autoCommit: boolean; autoInitiate: true; operationKey: string; cloneFromKiloSessionId: KiloSessionId; }; - -/** - * The retry fingerprint's repository identity. Mirrors the ordinary create - * path: includes the platform so two same-named repos on different providers - * mint distinct retry keys, and the Bitbucket workspace/repository uuids so a - * workspace rename cannot collide. - */ -function resolveRepoFingerprint(repository: NewSessionRepository | null): { - platform: RepositoryPlatform; - fullName: string; - workspaceUuid?: string | null; - repositoryUuid?: string | null; -} | null { - if (!repository) { - return null; - } - if (repository.platform === 'bitbucket') { - return { - platform: repository.platform, - fullName: repository.fullName, - workspaceUuid: repository.workspaceUuid ?? null, - repositoryUuid: repository.repositoryUuid ?? null, - }; - } - return { platform: repository.platform, fullName: repository.fullName }; -} - -/** - * Write exactly one repository field into the clone prepare body, matching - * the selected row's platform. Mirrors the ordinary create path's - * `setRepositoryField`: a picker key (`platform:fullName`) must never reach - * `githubRepo`. Bitbucket requires workspace + repository uuids, so it - * contributes nothing when those are missing (which cannot happen for a row - * that came from `listBitbucketRepositories`). - */ -function setRepositoryField( - input: ContinuePrepareInput, - repository: NewSessionRepository | null -): void { - if (!repository) { - return; - } - if (repository.platform === 'github') { - input.githubRepo = repository.fullName; - return; - } - if (repository.platform === 'gitlab') { - input.gitlabProject = repository.fullName; - return; - } - if (repository.workspaceUuid && repository.repositoryUuid) { - input.bitbucketRepo = { - fullName: repository.fullName, - workspaceUuid: repository.workspaceUuid, - repositoryUuid: repository.repositoryUuid, - }; - } -} diff --git a/apps/mobile/src/components/agents/use-new-session-creator.mounted.test.tsx b/apps/mobile/src/components/agents/use-new-session-creator.mounted.test.tsx new file mode 100644 index 0000000000..b759308d98 --- /dev/null +++ b/apps/mobile/src/components/agents/use-new-session-creator.mounted.test.tsx @@ -0,0 +1,717 @@ +/* eslint-disable typescript-eslint/no-deprecated -- react-test-renderer exercises real React Native hook cleanup without a DOM */ +/* eslint-disable require-await, @typescript-eslint/require-await -- native transport and storage doubles return promises */ +/* eslint-disable max-lines -- the producer/consumer matrix and lifecycle regressions share the real outbox harness */ +import { createElement } from 'react'; +import TestRenderer, { act } from 'react-test-renderer'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { type SessionManagerConfig } from '@kilocode/cloud-agent-sdk'; +import { type LaunchRepositoryReference } from '@kilocode/app-shared/code-review/repository-identity'; + +import { listOutboxRows } from '@/lib/persist/mutation-outbox'; +import { + resolveContinueStartDisabled, + resolveNewSessionStartDisabled, +} from '@/lib/new-session-submit'; +import { createMobileAgentSessionManager } from './mobile-session-manager'; +import { resolveProviderLaunchInput } from './provider-launch-input'; +import { useNewSessionCreator } from './use-new-session-creator'; + +const native = vi.hoisted(() => ({ + userId: 'user-1', + nextKey: 0, + path: '/new', + created: 0, + busy: [] as boolean[], + errors: [] as string[], + rows: new Map(), + beforeWrite: undefined as (() => Promise) | undefined, +})); +const manager = vi.hoisted(() => ({ config: null as SessionManagerConfig | null, query: vi.fn() })); +vi.mock('@/lib/hooks/use-current-user-id', () => ({ + useCurrentUserId: () => ({ userId: native.userId, isLoading: false }), +})); +vi.mock('expo-crypto', () => ({ + randomUUID: () => { + native.nextKey += 1; + return `operation-${native.nextKey}`; + }, +})); +vi.mock('expo-router', () => ({ + useRouter: () => ({ + replace: (path: string) => { + native.path = path; + }, + }), +})); +vi.mock('@tanstack/react-query', () => ({ useQueryClient: () => ({}) })); +vi.mock('expo-haptics', () => ({ + notificationAsync: async () => undefined, + NotificationFeedbackType: { Success: 'success' }, +})); +vi.mock('sonner-native', () => ({ + toast: { + error: (message: string) => { + native.errors.push(message); + }, + }, +})); +vi.mock('@sentry/react-native', () => ({ captureException: vi.fn() })); +vi.mock('@/lib/agent-session-cache', () => ({ + invalidateAgentSessionQueries: async () => undefined, +})); +vi.mock('@/lib/analytics/posthog', () => ({ + captureEvent: vi.fn(), + SESSION_CREATED_EVENT: 'session_created', +})); +vi.mock('@kilocode/cloud-agent-sdk/message-id', () => ({ generateMessageId: () => 'msg_test' })); +vi.mock('@/lib/persist/encrypted-kv', () => ({ + getItem: async (scope: string, k: string) => native.rows.get(`${scope}\0${k}`)?.v ?? null, + setItem: async (scope: string, k: string, v: string) => { + await native.beforeWrite?.(); + native.rows.set(`${scope}\0${k}`, { scope, k, v }); + }, + removeItem: async (scope: string, k: string) => { + native.rows.delete(`${scope}\0${k}`); + }, + listEntries: async (scope: string) => + [...native.rows.values()].filter(row => row.scope === scope), +})); +// Capture the adapter's SDK boundary; its mapping and retry classifier remain real. +vi.mock('@kilocode/cloud-agent-sdk', () => ({ + createSessionManager: (config: SessionManagerConfig) => { + manager.config = config; + return {}; + }, +})); +vi.mock('@/lib/auth/token-owner', () => ({ getAuthTokenForRequest: vi.fn() })); +vi.mock('@/lib/config', () => ({ + API_BASE_URL: 'https://api.test', + CLOUD_AGENT_WS_URL: 'wss://ws.test', + WEB_BASE_URL: 'https://web.test', +})); +vi.mock('@/lib/user-web-connection-lifecycle', () => ({ + createNativeUserWebConnectionLifecycleHooks: vi.fn(), +})); +vi.mock('@/components/agents/mobile-session-transport-payload', () => ({ + normalizeTransportPayload: vi.fn(), +})); +vi.mock('@/components/agents/mobile-session-diagnostics', () => ({ + formatSafeCloudAgentFailureDiagnostic: vi.fn(), + withCloudAgentDiagnostics: async ( + _action: string, + _organizationId: string | undefined, + operation: () => Promise + ) => operation(), +})); +vi.mock('@/components/agents/mobile-session-page-adapter', () => ({ + fetchMobileSessionSnapshotPage: vi.fn(), +})); +vi.mock('@/components/agents/tool-card-image-cache', () => ({ cacheToolAttachment: vi.fn() })); +vi.mock('@/components/agents/file-part-cache', () => ({ cacheFilePart: vi.fn() })); +vi.mock('@/lib/trpc', () => ({ + useTRPC: () => ({}), + trpcClient: { + cliSessionsV2: { getWithRuntimeState: { query: manager.query } }, + cloudAgentNext: { + prepareSession: { mutate: async (input: Payload) => prepare(input, 'user') }, + }, + organizations: { + cloudAgentNext: { + prepareSession: { mutate: async (input: Payload) => prepare(input, 'org') }, + }, + }, + }, +})); + +type Input = Parameters[0]; +type Result = ReturnType; +type Payload = { operationKey?: string; organizationId?: string; [key: string]: unknown }; +const server = { + requests: [] as Payload[], + routes: [] as ('user' | 'org')[], + sessions: new Map(), + error: undefined as Error | undefined, + beforeResponse: undefined as (() => Promise) | undefined, +}; +async function storedKeys(userId = 'user-1') { + const rows = await listOutboxRows(userId); + return rows?.map(row => row.operationKey); +} +async function prepare(input: Payload, owner: 'user' | 'org') { + server.requests.push(input); + server.routes.push(owner); + // SDK legacy callers omit operationKey; creator requests must persist it first. + if (input.operationKey) { + expect(await storedKeys(native.userId)).toContain(input.operationKey); + } + const operationKey = input.operationKey ?? `unkeyed-${server.sessions.size + 1}`; + const kiloSessionId = server.sessions.get(operationKey) ?? `session-${server.sessions.size + 1}`; + server.sessions.set(operationKey, kiloSessionId); + await server.beforeResponse?.(); + if (server.error) { + throw server.error; + } + return { kiloSessionId, cloudAgentSessionId: `cloud-${kiloSessionId}` }; +} + +const providers = [ + { + platform: 'github', + instanceUrl: 'https://github.com', + fullName: 'owner/repo', + displayName: 'owner/repo', + legacy: { githubRepo: 'owner/repo' }, + pin: { githubIntegrationId: 'integration-a' }, + pinField: 'githubIntegrationId', + }, + { + platform: 'gitlab', + instanceUrl: 'https://gitlab.com', + fullName: 'group/sub/repo', + displayName: 'group/sub/repo', + legacy: { gitlabProject: 'group/sub/repo' }, + pin: { gitlabIntegrationId: 'integration-a', gitlabInstanceUrl: 'https://gitlab.com' }, + pinField: 'gitlabIntegrationId', + }, + { + platform: 'gitlab', + instanceUrl: 'https://git.example/base', + fullName: 'group/sub/repo', + displayName: 'base/group/sub/repo', + legacy: { gitlabProject: 'group/sub/repo' }, + pin: { gitlabIntegrationId: 'integration-a', gitlabInstanceUrl: 'https://git.example/base' }, + pinField: 'gitlabIntegrationId', + }, + { + platform: 'bitbucket', + instanceUrl: 'https://bitbucket.org', + fullName: 'workspace/repo', + displayName: 'workspace/repo', + legacy: { + bitbucketRepo: { + fullName: 'workspace/repo', + workspaceUuid: 'workspace-1', + repositoryUuid: '42', + }, + }, + pin: { bitbucketIntegrationId: 'integration-a' }, + pinField: 'bitbucketIntegrationId', + }, +] as const; +// The same seven contexts drive both creator suites, SDK tests, and recent tests. +const cases = providers.flatMap(provider => + (provider.platform === 'bitbucket' ? (['org'] as const) : (['user', 'org'] as const)).map( + type => { + const reference: LaunchRepositoryReference = { + repository: { + instanceUrl: provider.instanceUrl, + repositoryId: '42', + fullName: provider.fullName, + defaultBranch: 'main', + ...(provider.platform === 'bitbucket' + ? { provider: 'bitbucket' as const, workspaceUuid: 'workspace-1' } + : { provider: provider.platform }), + }, + authorization: { + kind: 'ownerIntegration', + owner: { type, id: type === 'org' ? 'org-1' : 'user-1' }, + integrationId: 'integration-a', + }, + }; + return { provider, type, reference, organizationId: type === 'org' ? 'org-1' : undefined }; + } + ) +); +function inputFor(entry: (typeof cases)[number]): Input { + return { + attachments: { + attachments: [], + isUploading: false, + hasFailedAttachments: false, + addCandidates: vi.fn(async () => undefined), + removeAttachment: vi.fn(() => undefined), + retryAttachment: vi.fn(() => undefined), + reset: vi.fn(() => undefined), + uploadPending: async () => ({ ok: true, wire: undefined, submission: undefined }), + }, + mode: 'code', + model: 'model', + variant: 'high', + autoCommit: false, + organizationId: entry.organizationId, + selectedRepository: { + platform: entry.provider.platform, + fullName: entry.provider.fullName, + isPrivate: true, + ...(entry.provider.platform === 'bitbucket' + ? { workspaceUuid: 'workspace-1', repositoryUuid: '42' } + : {}), + }, + launchSelection: { reference: entry.reference, upstreamBranch: 'release/Case' }, + setIsCreating: value => { + native.busy.push(value); + }, + onCreated: () => { + native.created += 1; + }, + }; +} +function Form({ input, result }: { input: Input; result: { current: Result | null } }) { + result.current = useNewSessionCreator(input); + return null; +} +const renderers = new Set(); +async function mount(input: Input) { + const result: { current: Result | null } = { current: null }; + let renderer: TestRenderer.ReactTestRenderer | undefined = undefined; + await act(async () => { + renderer = TestRenderer.create(createElement(Form, { input, result })); + renderers.add(renderer); + }); + const get = () => { + if (!result.current) { + throw new Error('Creator did not mount'); + } + return result.current; + }; + get().promptRef.current = 'Keep this draft'; + return { + get, + submit: async () => { + await act(async () => { + await get().createSessionFromDraft(); + }); + }, + update: async (next: Input) => { + await act(async () => { + renderer?.update(createElement(Form, { input: next, result })); + }); + }, + unmount: async () => { + await act(async () => { + renderer?.unmount(); + }); + if (renderer) { + renderers.delete(renderer); + } + }, + }; +} +beforeEach(() => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + native.userId = 'user-1'; + native.nextKey = 0; + native.path = '/new'; + native.created = 0; + native.busy = []; + native.errors = []; + native.rows.clear(); + native.beforeWrite = undefined; + server.requests = []; + server.routes = []; + server.sessions.clear(); + server.error = undefined; + server.beforeResponse = undefined; +}); +afterEach(async () => { + await act(async () => { + for (const renderer of renderers) { + renderer.unmount(); + } + }); + renderers.clear(); + vi.unstubAllGlobals(); +}); + +describe.each(cases)('ordinary $provider.platform $type $provider.instanceUrl', entry => { + it.each([false, true])('transmits the exact repository and owner, legacy=%s', async legacy => { + const input = inputFor(entry); + if (legacy) { + input.launchSelection = undefined; + } + const form = await mount(input); + await form.submit(); + expect(server.requests).toEqual([ + { + prompt: 'Keep this draft', + initialMessageId: 'msg_test', + mode: 'code', + model: 'model', + variant: 'high', + autoCommit: false, + autoInitiate: true, + operationKey: expect.any(String), + ...entry.provider.legacy, + ...(legacy ? {} : { ...entry.provider.pin, upstreamBranch: 'release/Case' }), + ...(entry.organizationId ? { organizationId: entry.organizationId } : {}), + }, + ]); + expect(server.routes).toEqual([entry.type]); + expect(server.sessions.size).toBe(1); + expect(native.path).toContain('agent-chat/session-1'); + expect(native.created).toBe(1); + expect(await listOutboxRows('user-1')).toEqual([]); + }); + + it.each([false, true])( + 'passes the launch mapping through the mobile SDK adapter and recovers metadata, legacy=%s', + async legacy => { + const input = inputFor(entry); + const launch = resolveProviderLaunchInput(input.selectedRepository, { + accountId: 'user-1', + organizationId: entry.organizationId, + launchSelection: legacy ? undefined : input.launchSelection, + }); + if (!launch) { + throw new Error('Missing launch mapping'); + } + const dependencies = { + store: {}, + userWebConnection: {}, + organizationId: entry.organizationId, + }; + createMobileAgentSessionManager( + dependencies as Parameters[0] + ); + const config = manager.config; + if (!config) { + throw new Error('Missing manager configuration'); + } + const prepared = await config.prepare({ + prompt: 'hello', + mode: 'code', + model: 'model', + ...launch.input, + }); + expect(prepared).toEqual({ + kiloSessionId: 'session-1', + cloudAgentSessionId: 'cloud-session-1', + }); + expect(server.requests).toEqual([ + { + prompt: 'hello', + mode: 'code', + model: 'model', + initialPayload: undefined, + ...entry.provider.legacy, + ...(legacy ? {} : { ...entry.provider.pin, upstreamBranch: 'release/Case' }), + ...(entry.organizationId ? { organizationId: entry.organizationId } : {}), + }, + ]); + expect(server.routes).toEqual([entry.type]); + const gitUrl = `${entry.provider.instanceUrl}/${entry.provider.fullName}.git`; + manager.query.mockResolvedValue({ + git_url: legacy ? gitUrl : null, + git_branch: 'main', + organization_id: entry.organizationId ?? null, + runtimeState: legacy + ? null + : { + gitUrl, + upstreamBranch: 'release/Case', + ...(entry.provider.platform === 'github' ? { githubRepo: 'owner/repo' } : {}), + }, + }); + expect(await config.fetchSession(prepared.kiloSessionId)).toMatchObject({ + repository: entry.provider.displayName, + gitUrl, + gitBranch: legacy ? 'main' : 'release/Case', + organizationId: entry.organizationId ?? null, + }); + } + ); + + it('keeps both submit guards aligned with the selected identity', () => { + const launchSelection = { reference: entry.reference, upstreamBranch: 'release/Case' }; + const common = { + accountId: 'user-1', + organizationId: entry.organizationId, + launchSelection, + model: 'model', + selectedRepo: entry.provider.fullName, + selectedRepositoryResolved: true, + isCreating: false, + isSubmitting: false, + isRemoteTargetSelected: false, + }; + const ordinary = { + ...common, + hasPrompt: true, + attachmentsHasFailed: false, + attachmentsIsUploading: false, + isProfileLoading: false, + }; + const continuation = { + ...common, + isSpawningRemote: false, + instanceCatalogLoading: false, + instanceHasSessionClone: true, + cloneImportFailureKey: null, + isModelUnavailable: false, + }; + expect(resolveNewSessionStartDisabled(ordinary)).toBe(false); + expect(resolveContinueStartDisabled(continuation)).toBe(false); + for (const stale of [ + { organizationId: 'other-org' }, + { selectedRepositoryResolved: false }, + { launchSelection: { ...launchSelection, upstreamBranch: '' } }, + { launchSelection: null }, + ]) { + expect(resolveNewSessionStartDisabled({ ...ordinary, ...stale })).toBe(true); + expect(resolveContinueStartDisabled({ ...continuation, ...stale })).toBe(true); + } + expect(resolveNewSessionStartDisabled({ ...ordinary, launchSelection: undefined })).toBe(false); + expect(resolveContinueStartDisabled({ ...continuation, launchSelection: undefined })).toBe( + false + ); + }); + + it.each(['branch', 'integration'])( + 'recovers A/B/A after lost responses and a changed %s', + async change => { + const first = inputFor(entry); + const second: Input = { + ...first, + launchSelection: { + reference: { + ...entry.reference, + authorization: { + ...entry.reference.authorization, + integrationId: change === 'integration' ? 'integration-b' : 'integration-a', + }, + }, + upstreamBranch: change === 'branch' ? 'other/Case' : 'release/Case', + }, + }; + const form = await mount(first); + server.error = new Error('Lost response'); + await act(async () => { + await Promise.all([ + form.get().createSessionFromDraft(), + form.get().createSessionFromDraft(), + ]); + }); + const keyA = server.requests[0]?.operationKey; + expect(server.sessions.size).toBe(1); + await form.update(second); + await form.submit(); + const keyB = server.requests[2]?.operationKey; + expect(keyB).not.toBe(keyA); + expect(server.requests[2]).toMatchObject({ + ...entry.provider.legacy, + ...entry.provider.pin, + [entry.provider.pinField]: change === 'integration' ? 'integration-b' : 'integration-a', + upstreamBranch: change === 'branch' ? 'other/Case' : 'release/Case', + }); + expect(server.sessions.size).toBe(2); + expect(new Set(await storedKeys())).toEqual(new Set([keyA, keyB])); + await form.update(first); + server.error = undefined; + await form.submit(); + expect(server.requests.map(row => row.operationKey)).toEqual([keyA, keyA, keyB, keyA]); + expect(server.sessions.size).toBe(2); + expect(native.path).toContain('agent-chat/session-1'); + expect(await storedKeys()).toEqual([keyB]); + await form.unmount(); + const remounted = await mount(second); + await remounted.submit(); + expect(server.sessions.size).toBe(2); + expect(server.requests.at(-1)?.operationKey).toBe(keyB); + expect(native.path).toContain('agent-chat/session-2'); + expect(await listOutboxRows('user-1')).toEqual([]); + } + ); + + it('rejects the current and saved callbacks after an owner change', async () => { + const input = inputFor(entry); + const form = await mount(input); + const saved = form.get().createSessionFromDraft; + if (entry.type === 'user') { + native.userId = 'other-user'; + } + await form.update({ ...input, organizationId: entry.type === 'org' ? 'other-org' : undefined }); + await act(async () => { + await saved(); + }); + await form.submit(); + expect(server.sessions.size).toBe(0); + expect(native.rows.size).toBe(0); + expect(native.path).toBe('/new'); + expect(native.created).toBe(0); + }); +}); + +it.each(['before dispatch', 'success', 'retryable', 'terminal'])( + 'retains retry state after unmount during %s', + async phase => { + const entry = cases[0]; + if (!entry) { + throw new Error('Missing launch case'); + } + const gate = Promise.withResolvers(); + const entered = Promise.withResolvers(); + const pause = async () => { + entered.resolve(undefined); + await gate.promise; + }; + if (phase === 'before dispatch') { + native.beforeWrite = pause; + } else { + server.beforeResponse = pause; + } + if (phase === 'retryable') { + server.error = new Error('Lost response'); + } + if (phase === 'terminal') { + server.error = Object.assign(new Error('Rejected'), { data: { code: 'BAD_REQUEST' } }); + } + const form = await mount(inputFor(entry)); + const saved = form.get(); + const request = saved.createSessionFromDraft(); + await act(async () => { + await entered.promise; + }); + await form.unmount(); + const busyAtUnmount = [...native.busy]; + await act(async () => { + gate.resolve(undefined); + await request; + await saved.createSessionFromDraft(); + }); + expect(server.sessions.size).toBe(phase === 'before dispatch' ? 0 : 1); + expect(await storedKeys()).toEqual(['operation-1']); + expect(native.path).toBe('/new'); + expect(native.created).toBe(0); + expect(native.errors).toEqual([]); + expect(native.busy).toEqual(busyAtUnmount); + expect(saved.promptRef.current).toBe('Keep this draft'); + } +); + +it.each(['owner', 'account'])( + 'keeps a preparation retired after a %s A/B/A transition', + async change => { + const entry = cases.find(candidate => candidate.type === 'org'); + if (!entry) { + throw new Error('Missing organization case'); + } + const gate = Promise.withResolvers(); + const entered = Promise.withResolvers(); + server.beforeResponse = async () => { + entered.resolve(undefined); + await gate.promise; + }; + const input = inputFor(entry); + const form = await mount(input); + const request = form.get().createSessionFromDraft(); + await act(async () => { + await entered.promise; + }); + if (change === 'account') { + native.userId = 'other-user'; + } + await form.update({ + ...input, + organizationId: change === 'owner' ? 'other-org' : input.organizationId, + }); + native.userId = 'user-1'; + await form.update(input); + await act(async () => { + gate.resolve(undefined); + await request; + }); + expect(server.sessions.size).toBe(1); + expect(await storedKeys()).toEqual(['operation-1']); + expect(native.path).toBe('/new'); + expect(native.created).toBe(0); + expect(form.get().promptRef.current).toBe('Keep this draft'); + } +); + +it.each(['owner', 'repository'])( + 'releases busy state for a new %s without letting retired work clear its launch', + async change => { + const entry = cases.find(candidate => candidate.type === 'org'); + if (!entry) { + throw new Error('Missing organization case'); + } + const firstGate = Promise.withResolvers(); + const secondGate = Promise.withResolvers(); + const firstEntered = Promise.withResolvers(); + const secondEntered = Promise.withResolvers(); + server.beforeResponse = async () => { + if (server.requests.length === 1) { + firstEntered.resolve(undefined); + await firstGate.promise; + } else { + secondEntered.resolve(undefined); + await secondGate.promise; + } + }; + const input = inputFor(entry); + const form = await mount(input); + const firstRequest = form.get().createSessionFromDraft(); + await act(async () => { + await firstEntered.promise; + }); + expect(native.busy.at(-1)).toBe(true); + const reference: LaunchRepositoryReference = { + ...entry.reference, + repository: { + ...entry.reference.repository, + ...(change === 'repository' ? { repositoryId: '43', fullName: 'owner/other' } : {}), + }, + authorization: { + ...entry.reference.authorization, + ...(change === 'owner' ? { owner: { type: 'org', id: 'org-2' } as const } : {}), + }, + }; + await form.update({ + ...input, + organizationId: change === 'owner' ? 'org-2' : input.organizationId, + selectedRepository: { + platform: reference.repository.provider, + fullName: reference.repository.fullName, + isPrivate: true, + }, + launchSelection: { reference, upstreamBranch: 'release/Case' }, + }); + expect(native.busy.at(-1)).toBe(false); + const secondRequest = form.get().createSessionFromDraft(); + await act(async () => { + await secondEntered.promise; + }); + expect(native.busy.at(-1)).toBe(true); + await act(async () => { + firstGate.resolve(undefined); + await firstRequest; + }); + expect(native.busy.at(-1)).toBe(true); + expect(native.path).toBe('/new'); + expect(native.created).toBe(0); + expect(new Set(await storedKeys())).toEqual(new Set(['operation-1', 'operation-2'])); + await act(async () => { + secondGate.resolve(undefined); + await secondRequest; + }); + expect(native.busy.at(-1)).toBe(false); + expect(native.path).toContain('agent-chat/session-2'); + expect(await storedKeys()).toEqual(['operation-1']); + } +); + +it('keeps an absent repository and empty prompt inert', async () => { + const entry = cases[0]; + if (!entry) { + throw new Error('Missing launch case'); + } + const input = inputFor(entry); + const form = await mount({ ...input, selectedRepository: null, launchSelection: undefined }); + await form.submit(); + await form.update(input); + form.get().promptRef.current = ' '; + await form.submit(); + expect(server.sessions.size).toBe(0); + expect(native.rows.size).toBe(0); + expect(native.path).toBe('/new'); +}); diff --git a/apps/mobile/src/components/agents/use-new-session-creator.test.ts b/apps/mobile/src/components/agents/use-new-session-creator.test.ts index fb2407bee7..2de4b78afd 100644 --- a/apps/mobile/src/components/agents/use-new-session-creator.test.ts +++ b/apps/mobile/src/components/agents/use-new-session-creator.test.ts @@ -11,6 +11,10 @@ import { useNewSessionCreator } from './use-new-session-creator'; import { clearDraft, flushDraft, loadDraft } from '@/lib/persist/drafts'; import { useFencedDraftLoad, useRemoteSpawnDraftCleanup } from '@/lib/persist/use-draft-load'; +vi.mock('@/lib/hooks/use-current-user-id', () => ({ + useCurrentUserId: () => ({ userId: 'user-1' }), +})); + const prepareSessionMutate = vi.hoisted(() => vi.fn()); const routerReplace = vi.hoisted(() => vi.fn()); const toastError = vi.hoisted(() => vi.fn()); @@ -159,7 +163,7 @@ function createInput(overrides: Partial = {}): CreatorInput { mode: 'code' as AgentMode, model: 'anthropic/claude-sonnet-4', organizationId: undefined, - selectedRepository: null, + selectedRepository: { platform: 'github', fullName: 'owner/repo', isPrivate: false }, setIsCreating: vi.fn(() => undefined), variant: 'medium', autoCommit: false, @@ -272,6 +276,8 @@ type ReactInternals = { type HookDispatcher = { useCallback: (callback: T, _deps?: unknown) => T; + useEffect: () => void; + useMemo: (factory: () => T) => T; useRef: (initial: T) => { current: T }; }; @@ -281,6 +287,7 @@ function runCreator(args: { variant?: string; organizationId?: string; selectedRepository?: NewSessionRepository | null; + launchSelection?: CreatorInput['launchSelection']; autoCommit?: boolean; profileId?: string | null; }): CreatorResult { @@ -290,6 +297,9 @@ function runCreator(args: { let refIndex = 0; const dispatcher: HookDispatcher = { + // Lifecycle regressions use the real mounted harness, not this dispatcher. + useEffect: () => undefined, + useMemo: factory => factory(), useCallback: hookCallback => { hookIndex += 1; return hookCallback; @@ -331,6 +341,7 @@ function runCreator(args: { variant: args.variant ?? 'v1', autoCommit: args.autoCommit ?? false, profileId: args.profileId, + launchSelection: args.launchSelection, }); } finally { reactInternals.__CLIENT_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE.H = @@ -348,6 +359,145 @@ function sessionResult(): { kiloSessionId: string; cloudAgentSessionId: string } return { kiloSessionId: 'ses_12345678901234567890123456', cloudAgentSessionId: 'c-1' }; } +describe('normalized ordinary launch', () => { + const selectedRepository: NewSessionRepository = { + platform: 'gitlab', + fullName: 'group/sub/project', + isPrivate: true, + }; + const launchSelection: NonNullable = { + reference: { + repository: { + provider: 'gitlab', + instanceUrl: 'https://git.example/base', + repositoryId: '42', + fullName: 'group/sub/project', + defaultBranch: 'develop', + }, + authorization: { + kind: 'ownerIntegration', + owner: { type: 'org', id: 'org-1' }, + integrationId: 'integration-1', + }, + }, + upstreamBranch: 'release', + }; + const input = { organizationId: 'org-1', selectedRepository, launchSelection }; + + it('pins the branch and integration without consuming an unpinned GitHub retry', async () => { + outboxMock.getStoredOperationKey.mockImplementation(fingerprint => + (JSON.parse(fingerprint) as { repo: unknown }).repo === 'owner/repo' ? 'old-key' : null + ); + const creator = runCreator({ + ...input, + selectedRepository: { platform: 'github', fullName: 'owner/repo', isPrivate: true }, + launchSelection: { + ...launchSelection, + reference: { + ...launchSelection.reference, + repository: { + provider: 'github', + instanceUrl: 'https://github.com', + repositoryId: '9', + fullName: 'owner/repo', + defaultBranch: null, + }, + }, + }, + }); + creator.promptRef.current = 'hello'; + await creator.createSessionFromDraft(); + const payload = prepareSessionMutate.mock.calls[0]?.[0]; + expect(payload).toMatchObject({ + githubRepo: 'owner/repo', + githubIntegrationId: 'integration-1', + upstreamBranch: 'release', + }); + expect((payload as { operationKey: string }).operationKey).not.toBe('old-key'); + }); + + it('creates one session across repeat taps, a lost response, and a remount retry', async () => { + const stored = new Map(); + const sessions = new Map(); + let loseResponse = true; + outboxMock.getStoredOperationKey.mockImplementation( + fingerprint => stored.get(fingerprint) ?? null + ); + outboxMock.writeSafeRetry.mockImplementation(async row => { + stored.set(row.fingerprint, row.operationKey); + }); + outboxMock.remove.mockImplementation(async fingerprint => { + stored.delete(fingerprint); + }); + prepareSessionMutate.mockImplementation(async (payload: { operationKey: string }) => { + expect([...stored.values()]).toContain(payload.operationKey); + if (!sessions.has(payload.operationKey)) { + sessions.set(payload.operationKey, `session-${sessions.size + 1}`); + } + if (loseResponse) { + throw new Error('Lost response'); + } + return { kiloSessionId: sessions.get(payload.operationKey) }; + }); + const first = runCreator(input); + first.promptRef.current = 'hello'; + await Promise.all([first.createSessionFromDraft(), first.createSessionFromDraft()]); + expect(sessions.size).toBe(1); + expect(first.promptRef.current).toBe('hello'); + loseResponse = false; + const retry = runCreator(input); + retry.promptRef.current = 'hello'; + await retry.createSessionFromDraft(); + expect(sessions.size).toBe(1); + expect(stored.size).toBe(0); + expect(routerReplace.mock.calls.at(-1)?.[0]).toContain('agent-chat/session-1'); + }); + + it('does not clear the draft or navigate after the owner changes during prepare', async () => { + const pending = deferred>(); + prepareSessionMutate.mockReturnValueOnce(pending.promise); + const resultRef: { current: CreatorResult | null } = { current: null }; + const onCreated = () => { + requireResult(resultRef).promptRef.current = ''; + }; + let renderer: TestRenderer.ReactTestRenderer | undefined = undefined; + act(() => { + renderer = TestRenderer.create( + React.createElement(Harness, { input: createInput({ ...input, onCreated }), resultRef }) + ); + }); + requireResult(resultRef).promptRef.current = 'keep this draft'; + const request = requireResult(resultRef).createSessionFromDraft(); + await flushMicrotasks(); + act(() => { + renderer?.update( + React.createElement(Harness, { + input: createInput({ ...input, organizationId: 'org-2', onCreated }), + resultRef, + }) + ); + }); + await act(async () => { + pending.resolve(sessionResult()); + await request; + }); + expect(requireResult(resultRef).promptRef.current).toBe('keep this draft'); + expect(routerReplace.mock.calls).toEqual([]); + expect(outboxMock.remove.mock.calls).toEqual([]); + act(() => { + renderer?.unmount(); + }); + }); + + it('keeps a repository-free draft without sending an invalid prepare', async () => { + const result = requireResult(mountCreator(createInput({ selectedRepository: null }))); + result.promptRef.current = 'keep this draft'; + await result.createSessionFromDraft(); + expect(result.promptRef.current).toBe('keep this draft'); + expect(prepareSessionMutate.mock.calls).toEqual([]); + }); +}); + describe('useNewSessionCreator operationKey', () => { beforeEach(() => { prepareSessionMutate.mockReset(); @@ -815,12 +965,11 @@ describe('useNewSessionCreator intentFingerprint repo identity', () => { // migrate the row to the scoped fingerprint) instead of minting a duplicate // session. it('reuses a persisted legacy bare-name key for a GitHub intent', async () => { - outboxMock.getStoredOperationKey.mockImplementation((fingerprint: string) => { - const parsed = JSON.parse(fingerprint) as { repo: unknown }; - return typeof parsed.repo === 'string' && parsed.repo === 'owner/repo' - ? 'legacy-stored-key' - : null; - }); + const legacyFingerprint = + '{"prompt":"hello","mode":"code","model":"model-1","variant":"v1","repo":"owner/repo","autoCommit":false,"organizationId":null,"profileId":null,"attachments":null}'; + outboxMock.getStoredOperationKey.mockImplementation((fingerprint: string) => + fingerprint === legacyFingerprint ? 'legacy-stored-key' : null + ); const creator = runCreator({ selectedRepository: { platform: 'github', fullName: 'owner/repo', isPrivate: false }, diff --git a/apps/mobile/src/components/agents/use-new-session-creator.ts b/apps/mobile/src/components/agents/use-new-session-creator.ts index 618b86bc43..cee66a92d6 100644 --- a/apps/mobile/src/components/agents/use-new-session-creator.ts +++ b/apps/mobile/src/components/agents/use-new-session-creator.ts @@ -1,4 +1,4 @@ -import { type RefObject, useCallback, useRef } from 'react'; +import { type RefObject, useCallback, useEffect, useMemo, useRef } from 'react'; import { useRouter } from 'expo-router'; import { useQueryClient } from '@tanstack/react-query'; import { generateMessageId } from '@kilocode/cloud-agent-sdk/message-id'; @@ -7,15 +7,18 @@ import { toast } from 'sonner-native'; import { i18n } from '@/i18n'; import { type AgentMode } from '@/components/agents/mode-selector'; +import { type NewSessionRepository } from '@/components/agents/new-session-repository-state'; import { - type NewSessionRepository, - type RepositoryPlatform, -} from '@/components/agents/new-session-repository-state'; + type ProviderLaunchSelection, + type ProviderPrepareInput, + resolveProviderLaunchInput, +} from '@/components/agents/provider-launch-input'; import { resolveNewSessionPromptForCreate } from '@/components/agents/new-session-prompt-state'; import { isCloudPrepareRetryableError } from '@/components/agents/mobile-session-manager'; import { replaceWithAgentSession } from '@/components/agents/session-detail-routes'; import { invalidateAgentSessionQueries } from '@/lib/agent-session-cache'; import { captureEvent, SESSION_CREATED_EVENT } from '@/lib/analytics/posthog'; +import { useCurrentUserId } from '@/lib/hooks/use-current-user-id'; import { useHoistedOperationKey } from '@/lib/operation-key'; import { useMutationOutbox } from '@/lib/persist/use-mutation-outbox'; import { @@ -32,6 +35,7 @@ type UseNewSessionCreatorInput = { /** Invoked on the success path before navigation; failures never fire it. */ onCreated?: () => void; selectedRepository: NewSessionRepository | null; + launchSelection?: ProviderLaunchSelection | null; setIsCreating: (value: boolean) => void; variant: string; /** Commit and push the agent's changes (true) or leave them uncommitted (false). */ @@ -40,16 +44,12 @@ type UseNewSessionCreatorInput = { profileId?: string | null; }; -type PrepareSessionInput = { +type PrepareSessionInput = ProviderPrepareInput & { prompt: string; initialMessageId: string; mode: AgentMode; model: string; variant: string | undefined; - /** Exactly one repository field is set, matching the selected row's platform. */ - githubRepo?: string; - gitlabProject?: string; - bitbucketRepo?: { fullName: string; workspaceUuid: string; repositoryUuid: string }; autoCommit: boolean; autoInitiate: boolean; operationKey: string; @@ -76,6 +76,7 @@ export function useNewSessionCreator({ organizationId, onCreated, selectedRepository, + launchSelection, setIsCreating, variant, autoCommit, @@ -84,6 +85,26 @@ export function useNewSessionCreator({ const router = useRouter(); const queryClient = useQueryClient(); const trpc = useTRPC(); + const { userId } = useCurrentUserId(); + const launch = resolveProviderLaunchInput(selectedRepository, { + launchSelection, + accountId: userId, + organizationId, + }); + const scopeKey = JSON.stringify([userId, organizationId, launch?.fingerprint]); + const scope = useMemo(() => ({ key: scopeKey }), [scopeKey]); + const currentScope = useRef(scope); + currentScope.current = scope; + const setIsCreatingRef = useRef(setIsCreating); + setIsCreatingRef.current = setIsCreating; + useEffect(() => { + currentScope.current = scope; + // A replacement scope starts idle; retired requests cannot reset its launch. + setIsCreatingRef.current(false); + return () => { + currentScope.current = null; + }; + }, [scope]); const promptRef = useRef(''); // P1-A-08b: one `operationKey` per submit intent, so a retry of the same // intent dedupes on the ledger instead of spawning a second session. @@ -106,7 +127,17 @@ export function useNewSessionCreator({ // already presented its own feedback, so a no-op here preserves the // user's draft and screen state without toasting. const prompt = resolveNewSessionPromptForCreate(promptRef.current); - if (prompt === null) { + if (prompt === null || currentScope.current !== scope) { + return; + } + if (!launch) { + if (selectedRepository) { + toast.error( + i18n.t('agentChat.newSession.prefillRepoUnavailable', { + repo: selectedRepository.fullName, + }) + ); + } return; } if (prompt.startsWith('/') && attachments.attachments.length > 0) { @@ -126,8 +157,10 @@ export function useNewSessionCreator({ // payload. `uploaded` is a plain object; `{ ok: false }` is truthy, so // test `ok`. const uploaded = await attachments.uploadPending(); - if (!uploaded.ok) { - setIsCreating(false); + if (!uploaded.ok || currentScope.current !== scope) { + if (currentScope.current === scope) { + setIsCreating(false); + } return; } @@ -139,18 +172,17 @@ export function useNewSessionCreator({ mode, model, variant: variant || undefined, - repo: resolveRepoFingerprint(selectedRepository), + repo: launch.fingerprint, autoCommit, organizationId: organizationId ?? null, profileId: profileId ?? null, attachments: attachmentWire ?? null, }); - // Pre-fix safe-retry rows persisted the bare `fullName` as `repo`. A GitHub - // `owner/repo` is inherently a single-provider identity, so only GitHub - // intents fall back to the legacy bare-name lookup: two same-named - // GitLab/Bitbucket rows must never share the stale retry key. + // Old GitHub safe-retry rows used the bare name. Never apply that lookup to + // a pinned selection. Remove only after old clients/records disappear and + // the 30-day ledger window expires; preserve the serialized field order. const legacyIntentFingerprint = - selectedRepository?.platform === 'github' + launchSelection === undefined && selectedRepository?.platform === 'github' ? JSON.stringify({ prompt, mode, @@ -169,7 +201,11 @@ export function useNewSessionCreator({ // that races the launch load would read empty rows and mint a duplicate. // A failed outbox read reads as no stored rows, so refuse instead of // minting a fresh key over a row whose POST the server may have accepted. - if (!(await whenLoaded())) { + const outboxLoaded = await whenLoaded(); + if (currentScope.current !== scope) { + return; + } + if (!outboxLoaded) { toast.error(i18n.t('agentChat.newSession.couldNotReadPendingSessions')); setIsCreating(false); return; @@ -199,8 +235,8 @@ export function useNewSessionCreator({ autoCommit, autoInitiate: true, operationKey, + ...launch.input, }; - setRepositoryField(baseInput, selectedRepository); if (profileId) { baseInput.profileId = profileId; } @@ -215,9 +251,15 @@ export function useNewSessionCreator({ fingerprint: intentFingerprint, input: baseInput, }); + if (currentScope.current !== scope) { + return; + } if (legacyRowToDrop !== null) { await removeOutboxRow(legacyRowToDrop); } + if (currentScope.current !== scope) { + return; + } const result = organizationId ? await trpcClient.organizations.cloudAgentNext.prepareSession.mutate({ @@ -225,11 +267,18 @@ export function useNewSessionCreator({ organizationId, }) : await trpcClient.cloudAgentNext.prepareSession.mutate(baseInput); + // A late prepare cannot clear or navigate the newly selected owner's form. + if (currentScope.current !== scope) { + return; + } // Rotate before the post-success work so a UI failure cannot keep the // successful key for a retry. rotateKey(); await removeOutboxRow(intentFingerprint); + if (currentScope.current !== scope) { + return; + } // The cloud session already exists, so no post-success UI failure may // report the create as failed or invite a duplicate retry. @@ -241,6 +290,9 @@ export function useNewSessionCreator({ } catch { // Analytics and cache invalidation are cosmetic; stay silent. } + if (currentScope.current !== scope) { + return; + } // Signal the host (e.g. clear the new-session draft) before navigating, // so the draft is gone by the time the route unmounts and can never be // flushed back by an unmount write. @@ -249,6 +301,9 @@ export function useNewSessionCreator({ } catch { // The session exists; a host callback failure must not skip navigation. } + if (currentScope.current !== scope) { + return; + } // The uploads now live on the server: drop the composer's local cache // copies so owned temp files never outlive the session handoff. attachments.reset(); @@ -258,6 +313,9 @@ export function useNewSessionCreator({ } catch { // A failed haptics call is cosmetic; stay silent and navigate. } + if (currentScope.current !== scope) { + return; + } // One atomic navigation: `replace` drops the new-session route as it // pushes the session route, so back still lands on the session list. // The previous form — `push` plus a `RESET` dispatched one frame later — @@ -269,6 +327,9 @@ export function useNewSessionCreator({ // Stay silent: no create-failure toast, no duplicate-create retry. } } catch (error) { + if (currentScope.current !== scope) { + return; + } // Only `prepareSession` errors reach here; UI failures are swallowed. const message = error instanceof Error ? error.message : i18n.t('agentChat.newSession.failedToCreate'); @@ -279,10 +340,15 @@ export function useNewSessionCreator({ await removeOutboxRow(intentFingerprint); } } finally { - setIsCreating(false); + if (currentScope.current === scope) { + setIsCreating(false); + } } }, [ selectedRepository, + launchSelection, + launch, + scope, model, mode, variant, @@ -305,58 +371,3 @@ export function useNewSessionCreator({ return { createSessionFromDraft, promptRef }; } - -/** - * The retry fingerprint's repository identity. Includes the platform so two - * same-named repos on different providers mint distinct retry keys, and the - * Bitbucket workspace/repository uuids so a workspace rename cannot collide. - */ -function resolveRepoFingerprint(repository: NewSessionRepository | null): { - platform: RepositoryPlatform; - fullName: string; - workspaceUuid?: string | null; - repositoryUuid?: string | null; -} | null { - if (!repository) { - return null; - } - if (repository.platform === 'bitbucket') { - return { - platform: repository.platform, - fullName: repository.fullName, - workspaceUuid: repository.workspaceUuid ?? null, - repositoryUuid: repository.repositoryUuid ?? null, - }; - } - return { platform: repository.platform, fullName: repository.fullName }; -} - -/** - * Write exactly one repository field into the create body, matching the - * selected row's platform. Bitbucket requires workspace + run ids, so it - * contributes nothing when those are missing (which cannot happen for a row - * that came from `listBitbucketRepositories`). - */ -function setRepositoryField( - input: PrepareSessionInput, - repository: NewSessionRepository | null -): void { - if (!repository) { - return; - } - if (repository.platform === 'github') { - input.githubRepo = repository.fullName; - return; - } - if (repository.platform === 'gitlab') { - input.gitlabProject = repository.fullName; - return; - } - if (repository.workspaceUuid && repository.repositoryUuid) { - input.bitbucketRepo = { - fullName: repository.fullName, - workspaceUuid: repository.workspaceUuid, - repositoryUuid: repository.repositoryUuid, - }; - } -} diff --git a/apps/mobile/src/lib/hooks/use-agent-sessions.ts b/apps/mobile/src/lib/hooks/use-agent-sessions.ts index 8737fc9ceb..43380c0001 100644 --- a/apps/mobile/src/lib/hooks/use-agent-sessions.ts +++ b/apps/mobile/src/lib/hooks/use-agent-sessions.ts @@ -41,6 +41,10 @@ type RouterOutputs = inferRouterOutputs; export type StoredSession = RouterOutputs['cliSessionsV2']['list']['cliSessions'][number]; +// Keep gitUrl/lastUsedAt for old pickers; new pickers consume the qualified identity. +export type RecentAgentRepository = + RouterOutputs['cliSessionsV2']['recentRepositories']['repositories'][number]; + export type ActiveSession = RouterOutputs['activeSessions']['list']['sessions'][number]; type UseAgentSessionsOptions = { diff --git a/apps/mobile/src/lib/new-session-submit.test.ts b/apps/mobile/src/lib/new-session-submit.test.ts index 11932696ac..7413f14cdc 100644 --- a/apps/mobile/src/lib/new-session-submit.test.ts +++ b/apps/mobile/src/lib/new-session-submit.test.ts @@ -144,6 +144,59 @@ describe('resolveNewSessionSubmitDisabled', () => { }); }); +it('blocks unresolved normalized selection without changing legacy or remote gates', () => { + expect( + resolveNewSessionStartDisabled({ + ...validInput(), + selectedRepositoryResolved: true, + isProfileLoading: false, + launchSelection: null, + }) + ).toBe(true); + expect(resolveContinueStartDisabled(continueInput({ launchSelection: null }))).toBe(true); + expect( + resolveContinueStartDisabled( + continueInput({ launchSelection: null, isRemoteTargetSelected: true }) + ) + ).toBe(false); +}); + +it('blocks both launch forms when normalized selection belongs to another owner', () => { + const context = { + accountId: 'user-1', + organizationId: 'org-2', + launchSelection: { + reference: { + repository: { + provider: 'gitlab' as const, + instanceUrl: 'https://gitlab.com', + repositoryId: '42', + fullName: 'group/project', + defaultBranch: 'develop', + }, + authorization: { + kind: 'ownerIntegration' as const, + owner: { type: 'org' as const, id: 'org-1' }, + integrationId: 'integration-1', + }, + }, + upstreamBranch: 'release', + }, + }; + expect( + resolveNewSessionStartDisabled({ + ...validInput(), + selectedRepositoryResolved: true, + isProfileLoading: false, + ...context, + }) + ).toBe(true); + expect(resolveContinueStartDisabled({ ...continueInput(), ...context })).toBe(true); + expect( + resolveContinueStartDisabled({ ...continueInput(), ...context, organizationId: 'org-1' }) + ).toBe(false); +}); + describe('resolveNewSessionStartDisabled', () => { function startInput( overrides: Partial[0]> = {} diff --git a/apps/mobile/src/lib/new-session-submit.ts b/apps/mobile/src/lib/new-session-submit.ts index 9576eaec87..dcb86149bb 100644 --- a/apps/mobile/src/lib/new-session-submit.ts +++ b/apps/mobile/src/lib/new-session-submit.ts @@ -1,3 +1,8 @@ +import { + isProviderLaunchSelectionCurrent, + type ProviderLaunchContext, +} from '@/components/agents/provider-launch-input'; + /** * Pure boolean predicate for whether the "Start session" button on the * new-agent screen may submit right now. Lives in `lib/` (not next to @@ -79,23 +84,27 @@ export function resolveNewSessionSubmitDisabled(input: { * still-loading profile blocks Start, so an unsettled default is never * silently dropped. */ -export function resolveNewSessionStartDisabled(input: { - attachmentsHasFailed: boolean; - attachmentsIsUploading: boolean; - hasPrompt: boolean; - isCreating: boolean; - isRemoteTargetSelected: boolean; - isSubmitting: boolean; - model: string; - selectedRepo: string; - /** True when the selected repo key still resolves to a picker row. */ - selectedRepositoryResolved: boolean; - isProfileLoading: boolean; -}): boolean { +export function resolveNewSessionStartDisabled( + input: { + attachmentsHasFailed: boolean; + attachmentsIsUploading: boolean; + hasPrompt: boolean; + isCreating: boolean; + isRemoteTargetSelected: boolean; + isSubmitting: boolean; + model: string; + selectedRepo: string; + /** True when the selected repo key still resolves to a picker row. */ + selectedRepositoryResolved: boolean; + isProfileLoading: boolean; + } & ProviderLaunchContext +): boolean { // A selected key that no longer resolves to a row (after a refresh or a // provider change) must not submit: the create body would carry no // repository field and the server would reject it. - const staleSelection = input.selectedRepo !== '' && !input.selectedRepositoryResolved; + const staleSelection = + (input.selectedRepo !== '' && !input.selectedRepositoryResolved) || + !isProviderLaunchSelectionCurrent(input); return ( staleSelection || resolveNewSessionSubmitDisabled({ @@ -125,19 +134,21 @@ export function resolveNewSessionStartDisabled(input: { * shown inline. Empty prompt, empty repository, and profile loading do NOT * block. */ -export function resolveContinueStartDisabled(input: { - isCreating: boolean; - isSubmitting: boolean; - isSpawningRemote: boolean; - model: string; - selectedRepo: string; - selectedRepositoryResolved: boolean; - isRemoteTargetSelected: boolean; - instanceCatalogLoading: boolean; - instanceHasSessionClone: boolean; - cloneImportFailureKey: string | null; - isModelUnavailable: boolean; -}): boolean { +export function resolveContinueStartDisabled( + input: { + isCreating: boolean; + isSubmitting: boolean; + isSpawningRemote: boolean; + model: string; + selectedRepo: string; + selectedRepositoryResolved: boolean; + isRemoteTargetSelected: boolean; + instanceCatalogLoading: boolean; + instanceHasSessionClone: boolean; + cloneImportFailureKey: string | null; + isModelUnavailable: boolean; + } & ProviderLaunchContext +): boolean { if (input.isRemoteTargetSelected) { return ( input.isSpawningRemote || @@ -152,7 +163,9 @@ export function resolveContinueStartDisabled(input: { // A selected picker key that no longer resolves to a row must not submit: // the clone prepare body would carry no repository field and the server // would reject it. Mirrors `resolveNewSessionStartDisabled`'s stale gate. - const staleSelection = input.selectedRepo !== '' && !input.selectedRepositoryResolved; + const staleSelection = + (input.selectedRepo !== '' && !input.selectedRepositoryResolved) || + !isProviderLaunchSelectionCurrent(input); return ( input.isCreating || input.isSubmitting || diff --git a/apps/mobile/src/lib/persist/use-mutation-outbox.test.ts b/apps/mobile/src/lib/persist/use-mutation-outbox.test.ts index 62cb5df968..898e2e4b5e 100644 --- a/apps/mobile/src/lib/persist/use-mutation-outbox.test.ts +++ b/apps/mobile/src/lib/persist/use-mutation-outbox.test.ts @@ -321,6 +321,111 @@ describe('useMutationOutbox load gating', () => { }); }); +describe('useMutationOutbox live snapshot', () => { + it('retains each new intent key without a refresh or remount', async () => { + const { resultRef } = mountOutbox(); + await flushMicrotasks(); + const outbox = requireResult(resultRef); + await act(async () => { + await outbox.writeSafeRetry({ operationKey: 'key-A', fingerprint: 'A', input: null }); + await outbox.writeSafeRetry({ operationKey: 'key-B', fingerprint: 'B', input: null }); + await outbox.writeSafeRetry({ operationKey: 'replacement-A', fingerprint: 'A', input: null }); + }); + expect(outbox.getStoredOperationKey('A')).toBe('key-A'); + expect(outbox.getStoredOperationKey('B')).toBe('key-B'); + await act(async () => { + await outbox.remove('A'); + }); + expect(outbox.getStoredOperationKey('A')).toBeNull(); + expect(outbox.getStoredOperationKey('B')).toBe('key-B'); + }); + + it('publishes a new key only after its persistence completes', async () => { + const gate = deferred(); + writeOutboxRowMock.mockReturnValueOnce(gate.promise); + const { resultRef } = mountOutbox(); + await flushMicrotasks(); + const outbox = requireResult(resultRef); + const write = outbox.writeSafeRetry({ operationKey: 'key-A', fingerprint: 'A', input: null }); + expect(outbox.getStoredOperationKey('A')).toBeNull(); + await act(async () => { + gate.resolve(undefined); + await write; + }); + expect(outbox.getStoredOperationKey('A')).toBe('key-A'); + }); + + it.each(['safe-retry', 'reconcile-first'] as const)( + 'never publishes a late %s write into another identity', + async taxonomy => { + const { resultRef, rerender } = mountOutbox(); + await flushMicrotasks(); + const gate = deferred(); + writeOutboxRowMock.mockReturnValueOnce(gate.promise); + const oldOutbox = requireResult(resultRef); + const row = { operationKey: 'old-key', fingerprint: 'same', input: null, scope: 'personal' }; + const write = + taxonomy === 'safe-retry' + ? oldOutbox.writeSafeRetry(row) + : oldOutbox.writeReconcileFirst(row); + identityMock.value = { userId: 'u2', isLoading: false }; + listOutboxRowsMock.mockResolvedValue([ + safeRetryRow({ operationKey: 'new-key', fingerprint: 'same' }), + ]); + rerender(); + await flushMicrotasks(); + await act(async () => { + gate.resolve(undefined); + await write; + }); + expect(requireResult(resultRef).getStoredOperationKey('same')).toBe('new-key'); + expect(requireResult(resultRef).needsReconcile).toEqual([]); + } + ); + + it('never removes the current identity key after a previous identity removal completes', async () => { + listOutboxRowsMock.mockResolvedValue([ + safeRetryRow({ fingerprint: 'same', operationKey: 'old-key' }), + ]); + const { resultRef, rerender } = mountOutbox(); + await flushMicrotasks(); + const gate = deferred(); + removeOutboxRowMock.mockReturnValueOnce(gate.promise); + const removal = requireResult(resultRef).remove('same'); + identityMock.value = { userId: 'u2', isLoading: false }; + listOutboxRowsMock.mockResolvedValue([ + safeRetryRow({ fingerprint: 'same', operationKey: 'new-key' }), + ]); + rerender(); + await flushMicrotasks(); + await act(async () => { + gate.resolve(undefined); + await removal; + }); + expect(requireResult(resultRef).getStoredOperationKey('same')).toBe('new-key'); + }); + + it('publishes a new reconcile row and preserves its key on another write', async () => { + const { resultRef } = mountOutbox(); + await flushMicrotasks(); + const row = { + operationKey: 'original', + fingerprint: 'reconcile', + input: null, + scope: 'personal', + }; + await act(async () => { + await requireResult(resultRef).writeReconcileFirst(row); + expect( + await requireResult(resultRef).writeReconcileFirst({ ...row, operationKey: 'replacement' }) + ).toBe('original'); + }); + expect(requireResult(resultRef).needsReconcile).toEqual([ + { ...row, taxonomy: 'reconcile-first' }, + ]); + }); +}); + describe('useMutationOutbox key preservation and reconcile list', () => { it('preserves a stored safe-retry key instead of overwriting it with a fresh key', async () => { listOutboxRowsMock.mockResolvedValue([ diff --git a/apps/mobile/src/lib/persist/use-mutation-outbox.ts b/apps/mobile/src/lib/persist/use-mutation-outbox.ts index 99fa35a919..1432d1febb 100644 --- a/apps/mobile/src/lib/persist/use-mutation-outbox.ts +++ b/apps/mobile/src/lib/persist/use-mutation-outbox.ts @@ -125,10 +125,10 @@ export function useMutationOutbox() { // `loaded` stays false while the identity is still resolving, so a submit // cannot read rows before the user is known. useEffect(() => { + rowsRef.current = []; + setRows([]); if (isLoading) { loadGenerationRef.current += 1; - rowsRef.current = []; - setRows([]); resetLoaded(); return undefined; } @@ -157,7 +157,16 @@ export function useMutationOutbox() { r => r.fingerprint === row.fingerprint && r.taxonomy === 'safe-retry' ); const operationKey = stored?.operationKey ?? row.operationKey; - await writeOutboxRow(userId, { ...row, operationKey, taxonomy: 'safe-retry' }); + const generation = loadGenerationRef.current; + const nextRow: OutboxRow = { ...row, operationKey, taxonomy: 'safe-retry' }; + await writeOutboxRow(userId, nextRow); + if (loadGenerationRef.current === generation) { + rowsRef.current = [ + ...rowsRef.current.filter(r => r.fingerprint !== row.fingerprint), + nextRow, + ]; + setRows(rowsRef.current); + } }, [userId] ); @@ -175,7 +184,16 @@ export function useMutationOutbox() { r => r.fingerprint === row.fingerprint && r.taxonomy === 'reconcile-first' ); const operationKey = stored?.operationKey ?? row.operationKey; - await writeOutboxRow(userId, { ...row, operationKey, taxonomy: 'reconcile-first' }); + const generation = loadGenerationRef.current; + const nextRow: OutboxRow = { ...row, operationKey, taxonomy: 'reconcile-first' }; + await writeOutboxRow(userId, nextRow); + if (loadGenerationRef.current === generation) { + rowsRef.current = [ + ...rowsRef.current.filter(r => r.fingerprint !== row.fingerprint), + nextRow, + ]; + setRows(rowsRef.current); + } return operationKey; }, [userId] @@ -186,9 +204,12 @@ export function useMutationOutbox() { if (!userId) { return; } + const generation = loadGenerationRef.current; await removeOutboxRow(userId, fingerprint); - rowsRef.current = rowsRef.current.filter(r => r.fingerprint !== fingerprint); - setRows(previous => previous.filter(r => r.fingerprint !== fingerprint)); + if (loadGenerationRef.current === generation) { + rowsRef.current = rowsRef.current.filter(r => r.fingerprint !== fingerprint); + setRows(rowsRef.current); + } }, [userId] ); diff --git a/apps/web/src/lib/cloud-agent/github-integration-helpers.test.ts b/apps/web/src/lib/cloud-agent/github-integration-helpers.test.ts index f1e994f5a5..617c31926d 100644 --- a/apps/web/src/lib/cloud-agent/github-integration-helpers.test.ts +++ b/apps/web/src/lib/cloud-agent/github-integration-helpers.test.ts @@ -13,6 +13,7 @@ const mockUpdateRepositoriesForIntegration = jest.fn<(integrationId: string, repositories: unknown[]) => Promise>(); const mockGetIntegrationsByOrganization = jest.fn<(organizationId: string, platform: string) => Promise>(); +const mockGetAllIntegrationsForOwner = jest.fn<(owner: Owner) => Promise>(); const mockFetchGitHubRepositories = jest.fn<(installationId: string, appType: string) => Promise>(); const mockGenerateGitHubInstallationToken = @@ -34,6 +35,7 @@ jest.mock('@/lib/integrations/db/platform-integrations', () => ({ getIntegrationForOwner: mockGetIntegrationForOwner, getPrimaryGitHubIntegrationForOrganization: mockGetPrimaryGitHubIntegrationForOrganization, getIntegrationsByOrganization: mockGetIntegrationsByOrganization, + getAllIntegrationsForOwner: mockGetAllIntegrationsForOwner, updateRepositoriesForIntegration: mockUpdateRepositoriesForIntegration, })); @@ -158,6 +160,68 @@ describe('github-integration-helpers', () => { }); }); + describe('complete Personal repository discovery', () => { + it('keeps both authorized installation identities for the same repository', async () => { + mockGetAllIntegrationsForOwner.mockResolvedValue([ + buildIntegration(), + buildIntegration({ id: 'integration-2', platform_installation_id: 'installation-2' }), + buildIntegration({ id: 'other-platform', platform: 'gitlab' }), + buildIntegration({ id: 'suspended', integration_status: 'suspended' }), + buildIntegration({ id: 'invalid-auth', auth_invalid_at: '2026-06-25 18:00:00+00' }), + ]); + const { fetchGitHubRepositoriesForUser } = await import('./github-integration-helpers'); + const result = await fetchGitHubRepositoriesForUser('oauth/user', false, { + requireComplete: true, + }); + expect(result.repositories.map(row => row.repositoryReference.authorization)).toEqual([ + { + kind: 'ownerIntegration', + owner: { type: 'user', id: 'oauth/user' }, + integrationId: 'integration-1', + }, + { + kind: 'ownerIntegration', + owner: { type: 'user', id: 'oauth/user' }, + integrationId: 'integration-2', + }, + ]); + }); + + it.each(['failed fetch', 'missing installation'])( + 'rejects an incomplete Personal set after a sibling %s without changing browsing', + async failure => { + mockGetIntegrationForOwner.mockResolvedValue(buildIntegration()); + mockGetAllIntegrationsForOwner.mockResolvedValue([ + buildIntegration(), + buildIntegration({ + id: 'integration-2', + repositories: null, + platform_installation_id: failure === 'missing installation' ? null : 'installation-2', + }), + ]); + mockFetchGitHubRepositories.mockRejectedValue(new Error('GitHub unavailable')); + const { fetchGitHubRepositoriesForUser } = await import('./github-integration-helpers'); + await expect( + fetchGitHubRepositoriesForUser('oauth/user', false, { requireComplete: true }) + ).rejects.toThrow('Failed to fetch GitHub repositories'); + const browsing = await fetchGitHubRepositoriesForUser('oauth/user'); + expect(browsing.repositories.map(row => row.platformIntegrationId)).toEqual([ + 'integration-1', + ]); + } + ); + + it('keeps an empty complete Personal set empty', async () => { + mockGetAllIntegrationsForOwner.mockResolvedValue([]); + const { fetchGitHubRepositoriesForUser } = await import('./github-integration-helpers'); + const result = await fetchGitHubRepositoriesForUser('oauth/user', false, { + requireComplete: true, + }); + expect(result.repositories).toEqual([]); + expect(result.integrationInstalled).toBe(false); + }); + }); + describe('fetchGitHubRepositoriesForOrganization', () => { it('returns cached repositories for an active integration', async () => { mockGetIntegrationsByOrganization.mockResolvedValue([buildIntegration()]); @@ -241,6 +305,46 @@ describe('github-integration-helpers', () => { ]); }); + it.each(['failed fetch', 'missing installation'])( + 'requires a complete candidate set after a sibling %s', + async failure => { + mockGetIntegrationsByOrganization.mockResolvedValue([ + buildIntegration(), + buildIntegration({ + id: 'integration-2', + repositories: null, + platform_installation_id: failure === 'missing installation' ? null : 'installation-2', + }), + ]); + mockFetchGitHubRepositories.mockRejectedValue(new Error('GitHub unavailable')); + const { fetchAllGitHubRepositoriesForOrganization } = + await import('./github-integration-helpers'); + + await expect( + fetchAllGitHubRepositoriesForOrganization('org-123', false, { requireComplete: true }) + ).rejects.toThrow('Failed to fetch GitHub repositories'); + const partial = await fetchAllGitHubRepositoriesForOrganization('org-123'); + expect(partial.repositories.map(row => row.platformIntegrationId)).toEqual([ + 'integration-1', + ]); + } + ); + + it('keeps both authorized matches when complete discovery succeeds', async () => { + mockGetIntegrationsByOrganization.mockResolvedValue([ + buildIntegration(), + buildIntegration({ id: 'integration-2' }), + ]); + const { fetchAllGitHubRepositoriesForOrganization } = + await import('./github-integration-helpers'); + const complete = await fetchAllGitHubRepositoriesForOrganization('org-123', false, { + requireComplete: true, + }); + expect( + complete.repositories.map(row => row.repositoryReference.authorization.integrationId) + ).toEqual(['integration-1', 'integration-2']); + }); + it('fails when no installation can provide repositories', async () => { mockGetIntegrationsByOrganization.mockResolvedValue([ buildIntegration({ repositories: null }), diff --git a/apps/web/src/lib/cloud-agent/github-integration-helpers.ts b/apps/web/src/lib/cloud-agent/github-integration-helpers.ts index fdcb12ee61..84a455d40b 100644 --- a/apps/web/src/lib/cloud-agent/github-integration-helpers.ts +++ b/apps/web/src/lib/cloud-agent/github-integration-helpers.ts @@ -1,5 +1,6 @@ import { TRPCError } from '@trpc/server'; import { + getAllIntegrationsForOwner, getIntegrationsByOrganization, getIntegrationForOrganization, getIntegrationForOwner, @@ -209,21 +210,25 @@ export async function fetchGitHubRepositoriesForOrganization( export async function fetchAllGitHubRepositoriesForOrganization( organizationId: string, - forceRefresh: boolean = false + forceRefresh: boolean = false, + { requireComplete = false }: { requireComplete?: boolean } = {} ): Promise { const integrations = ( await getIntegrationsByOrganization(organizationId, PLATFORM.GITHUB) ).filter(isPlatformIntegrationHealthy); - return fetchRepositoriesForIntegrations(integrations, forceRefresh, { - type: 'org', - id: organizationId, - }); + return fetchRepositoriesForIntegrations( + integrations, + forceRefresh, + { type: 'org', id: organizationId }, + requireComplete + ); } async function fetchRepositoriesForIntegrations( integrations: Awaited>, forceRefresh: boolean, - owner: Owner + owner: Owner, + requireComplete: boolean ): Promise { if (integrations.length === 0) { return missingIntegrationResponse('No GitHub integration found for this organization'); @@ -232,7 +237,10 @@ async function fetchRepositoriesForIntegrations( try { const settledResults = await Promise.allSettled( integrations.map(async integration => { - if (!integration.platform_installation_id) return { repositories: [], syncedAt: null }; + if (!integration.platform_installation_id) { + if (requireComplete) throw new Error('GitHub installation is not configured'); + return { repositories: [], syncedAt: null }; + } const cachedRepositories = requireNumericPlatformRepositories(integration.repositories); if (forceRefresh || !cachedRepositories?.length) { const repositories = await fetchGitHubRepositories( @@ -254,8 +262,9 @@ async function fetchRepositoriesForIntegrations( const results = settledResults .filter(result => result.status === 'fulfilled') .map(result => result.value); - if (results.length === 0) { - throw new Error('All GitHub repository fetches failed'); + // Browsing keeps partial results; URL-only identity resolution cannot use them. + if (results.length === 0 || (requireComplete && results.length !== integrations.length)) { + throw new Error('GitHub repository discovery is incomplete'); } return { integrationInstalled: true, @@ -276,9 +285,21 @@ async function fetchRepositoriesForIntegrations( export async function fetchGitHubRepositoriesForUser( userId: string, - forceRefresh: boolean = false + forceRefresh: boolean = false, + { requireComplete = false }: { requireComplete?: boolean } = {} ): Promise { const owner: Owner = { type: 'user', id: userId }; + // URL-only history needs every authorized installation; preserve the browsing default. + if (requireComplete) { + const integrations = (await getAllIntegrationsForOwner(owner)).filter( + integration => + integration.platform === PLATFORM.GITHUB && isPlatformIntegrationHealthy(integration) + ); + if (integrations.length === 0) { + return missingIntegrationResponse('No GitHub integration found for this user'); + } + return fetchRepositoriesForIntegrations(integrations, forceRefresh, owner, true); + } const integration = await getIntegrationForOwner({ type: 'user', id: userId }, PLATFORM.GITHUB); if (!integration) { diff --git a/apps/web/src/routers/cli-sessions-v2-router.test.ts b/apps/web/src/routers/cli-sessions-v2-router.test.ts index d999dfb4c5..0f02af4363 100644 --- a/apps/web/src/routers/cli-sessions-v2-router.test.ts +++ b/apps/web/src/routers/cli-sessions-v2-router.test.ts @@ -14,7 +14,12 @@ import { eq, and, inArray } from 'drizzle-orm'; import type { User, Organization } from '@kilocode/db/schema'; import * as githubAdapter from '@/lib/integrations/platforms/github/adapter'; import { TRPCError } from '@trpc/server'; -import { parseGitHubOwnerRepo, parseGitHubPrUrl } from '@/routers/cli-sessions-v2-router'; +import { + parseGitHubOwnerRepo, + parseGitHubPrUrl, + resolveRecentRepositoryIdentity, +} from '@/routers/cli-sessions-v2-router'; +import type { LaunchRepositoryReference } from '@kilocode/app-shared/code-review/repository-identity'; import type { fetchSessionMessagesPage as FetchSessionMessagesPageType } from '@/lib/session-ingest-client'; import { notifyCliSessionRenamed } from '@/lib/cloud-agent/session-events'; import { captureException } from '@sentry/nextjs'; @@ -86,6 +91,9 @@ jest.mock('@/lib/integrations/platforms/github/adapter', () => { return { ...actual, fetchPullRequestByNumber: jest.fn(), + fetchGitHubRepositories: jest.fn( + actual.fetchGitHubRepositories as typeof githubAdapter.fetchGitHubRepositories + ), }; }); @@ -115,6 +123,194 @@ const mockedFetchPullRequestByNumber = typeof githubAdapter.fetchPullRequestByNumber >; +describe('recent repository identity resolution', () => { + it.each([ + ['github', 'user', 'https://github.com', 'owner/repo'], + ['github', 'org', 'https://github.com', 'owner/repo'], + ['gitlab', 'user', 'https://gitlab.com', 'group/sub/repo'], + ['gitlab', 'org', 'https://gitlab.com', 'group/sub/repo'], + ['gitlab', 'user', 'https://git.example/base', 'group/sub/repo'], + ['gitlab', 'org', 'https://git.example/base', 'group/sub/repo'], + ['bitbucket', 'org', 'https://bitbucket.org', 'workspace/repo'], + ] as const)( + 'resolves only the authorized %s %s history on %s', + (provider, type, instanceUrl, fullName) => { + const owner = { type, id: type === 'org' ? 'org-1' : 'user-1' }; + const reference: LaunchRepositoryReference = { + repository: { + instanceUrl, + repositoryId: '42', + fullName, + defaultBranch: 'main', + ...(provider === 'bitbucket' + ? { provider: 'bitbucket' as const, workspaceUuid: 'workspace-1' } + : { provider }), + }, + authorization: { kind: 'ownerIntegration', owner, integrationId: 'integration-a' }, + }; + const url = `${instanceUrl}/${fullName}.git`; + const otherOwner: LaunchRepositoryReference = { + ...reference, + authorization: { ...reference.authorization, owner: { type, id: 'other' } }, + }; + for (const recordedProvider of [provider, null]) { + expect( + resolveRecentRepositoryIdentity('user-1', owner, url, recordedProvider, [ + otherOwner, + reference, + ]) + ).toEqual({ + kind: 'resolved', + accountId: 'user-1', + reference, + }); + expect( + resolveRecentRepositoryIdentity('user-1', owner, url, recordedProvider, [otherOwner]) + ).toEqual({ + kind: 'legacy-unresolved', + accountId: 'user-1', + owner, + reason: 'not-found', + }); + expect( + resolveRecentRepositoryIdentity('user-1', owner, url, recordedProvider, [ + reference, + { + ...reference, + authorization: { ...reference.authorization, integrationId: 'integration-b' }, + }, + ]) + ).toEqual({ + kind: 'legacy-unresolved', + accountId: 'user-1', + owner, + reason: 'ambiguous', + }); + expect( + resolveRecentRepositoryIdentity('user-1', owner, url, recordedProvider, null) + ).toEqual({ + kind: 'legacy-unresolved', + accountId: 'user-1', + owner, + reason: 'unavailable', + }); + } + } + ); + + const reference: LaunchRepositoryReference = { + repository: { + provider: 'gitlab', + instanceUrl: 'https://git.example/base', + repositoryId: '42', + fullName: 'Group/Sub/repo', + defaultBranch: null, + }, + authorization: { + kind: 'ownerIntegration', + owner: { type: 'user', id: 'user-1' }, + integrationId: 'integration-1', + }, + }; + const owner = reference.authorization.owner; + const url = 'https://git.example/base/Group/Sub/repo.git'; + it('resolves an authorized full path without substituting a same-named provider', () => { + const github: LaunchRepositoryReference = { + ...reference, + repository: { + ...reference.repository, + provider: 'github', + instanceUrl: 'https://github.com', + }, + }; + expect( + resolveRecentRepositoryIdentity('user-1', owner, url, null, [github, reference]) + ).toEqual({ kind: 'resolved', accountId: 'user-1', reference }); + }); + it.each([ + ['case change', url.toLowerCase(), null, [reference], 'not-found'], + ['host change', url.replace('git.example', 'other.example'), null, [reference], 'not-found'], + ['subpath change', url.replace('/base/', '/other/'), null, [reference], 'not-found'], + ['provider mismatch', url, 'github', [reference], 'not-found'], + [ + 'wrong owner', + url, + null, + [ + { + ...reference, + authorization: { ...reference.authorization, owner: { type: 'org', id: 'other' } }, + }, + ], + 'not-found', + ], + [ + 'ambiguous integrations', + url, + null, + [ + reference, + { ...reference, authorization: { ...reference.authorization, integrationId: 'other' } }, + ], + 'ambiguous', + ], + ['discovery failure', url, null, null, 'unavailable'], + ['no authorized match', url, null, [], 'not-found'], + ] as const)( + 'retains unresolved history after %s', + (_name, gitUrl, provider, references, reason) => { + expect( + resolveRecentRepositoryIdentity( + 'user-1', + owner, + gitUrl, + provider, + references === null ? null : [...references] + ) + ).toEqual({ kind: 'legacy-unresolved', accountId: 'user-1', owner, reason }); + } + ); + it('retains Bitbucket UUIDs from the authorized organization match', () => { + const bitbucket: LaunchRepositoryReference = { + repository: { + provider: 'bitbucket', + instanceUrl: 'https://bitbucket.org', + repositoryId: 'repository-uuid', + workspaceUuid: 'workspace-uuid', + fullName: 'workspace/repo', + defaultBranch: 'develop', + }, + authorization: { ...reference.authorization, owner: { type: 'org', id: 'org-1' } }, + }; + expect( + resolveRecentRepositoryIdentity( + 'user-1', + bitbucket.authorization.owner, + 'https://bitbucket.org/workspace/repo.git', + 'bitbucket', + [bitbucket] + ) + ).toEqual({ kind: 'resolved', accountId: 'user-1', reference: bitbucket }); + }); + it('resolves old GitHub SSH history through the exact authorized repository', () => { + const github: LaunchRepositoryReference = { + ...reference, + repository: { + provider: 'github', + instanceUrl: 'https://github.com', + repositoryId: '9', + fullName: 'Owner/Repo', + defaultBranch: null, + }, + }; + expect( + resolveRecentRepositoryIdentity('user-1', owner, 'git@github.com:owner/repo.git', null, [ + github, + ]) + ).toEqual({ kind: 'resolved', accountId: 'user-1', reference: github }); + }); +}); + let regularUser: User; let otherUser: User; let adminUser: User; @@ -1089,6 +1285,183 @@ describe('cli-sessions-v2-router', () => { expect(result.results).toEqual([]); }); + it.each(['user', 'org'] as const)( + 'recentRepositories requires a complete %s GitHub candidate set', + async type => { + const gitUrl = 'https://github.com/owner/repo.git'; + const repository = { id: 42, name: 'repo', full_name: 'owner/repo', private: true }; + const organizationId = type === 'org' ? testOrganization.id : null; + const ownership = + type === 'org' + ? { owned_by_organization_id: testOrganization.id } + : { owned_by_user_id: regularUser.id }; + await db + .update(cli_sessions_v2) + .set({ git_url: gitUrl, platform: 'github', organization_id: organizationId }) + .where(eq(cli_sessions_v2.session_id, organizationSessionId)); + const integrations = await db + .insert(platform_integrations) + .values([ + { + ...ownership, + platform: 'github', + integration_type: 'app', + platform_installation_id: 'recent-1', + integration_status: 'active', + repositories: [repository], + }, + { + ...ownership, + platform: 'github', + integration_type: 'app', + platform_installation_id: 'recent-2', + integration_status: 'active', + repositories: null, + }, + ]) + .returning({ id: platform_integrations.id }); + const [first, second] = integrations; + if (!first || !second) throw new Error('Missing integration fixtures'); + const owner = { type, id: type === 'org' ? testOrganization.id : regularUser.id }; + try { + jest + .mocked(githubAdapter.fetchGitHubRepositories) + .mockRejectedValueOnce(new Error('GitHub unavailable')); + const caller = await createCallerForUser(regularUser.id); + const input = { organizationId, updatedSince: '2026-01-01T00:00:00.000Z' }; + const partial = await caller.cliSessionsV2.recentRepositories(input); + expect(partial.repositories).toEqual([ + expect.objectContaining({ + gitUrl, + lastUsedAt: expect.any(String), + identity: { + kind: 'legacy-unresolved', + accountId: regularUser.id, + owner, + reason: 'unavailable', + }, + }), + ]); + await db + .update(platform_integrations) + .set({ repositories: [repository] }) + .where(eq(platform_integrations.id, second.id)); + const ambiguous = await caller.cliSessionsV2.recentRepositories(input); + expect(ambiguous.repositories[0]?.identity).toEqual({ + kind: 'legacy-unresolved', + accountId: regularUser.id, + owner, + reason: 'ambiguous', + }); + await db.delete(platform_integrations).where(eq(platform_integrations.id, second.id)); + const complete = await caller.cliSessionsV2.recentRepositories(input); + expect(complete.repositories[0]).toMatchObject({ + gitUrl, + identity: { + kind: 'resolved', + accountId: regularUser.id, + reference: { + repository: { + provider: 'github', + instanceUrl: 'https://github.com', + repositoryId: '42', + fullName: 'owner/repo', + defaultBranch: null, + }, + authorization: { kind: 'ownerIntegration', owner, integrationId: first.id }, + }, + }, + }); + } finally { + await db.delete(platform_integrations).where( + inArray( + platform_integrations.id, + integrations.map(row => row.id) + ) + ); + } + } + ); + + it.each(['owners', 'null platform', 'conflicting platforms'] as const)( + 'recentRepositories keeps ten distinct URLs across duplicate %s', + async duplicate => { + const caller = await createCallerForUser(regularUser.id); + const input = { updatedSince: '2030-01-01T00:00:00.000Z' }; + expect((await caller.cliSessionsV2.recentRepositories(input)).repositories).toEqual([]); + const rows = Array.from({ length: 14 }, (_, index) => ({ + session_id: `ses_recent_identity_${index}`, + kilo_user_id: index === 13 ? otherUser.id : regularUser.id, + organization_id: duplicate === 'owners' && index === 1 ? testOrganization.id : null, + platform: + index === 1 && duplicate !== 'owners' + ? duplicate === 'null platform' + ? null + : 'github' + : 'gitlab', + created_on_platform: 'cloud-agent', + git_url: + index < 2 + ? 'https://gitlab.com/group/shared' + : `https://gitlab.com/group/repo-${index}`, + updated_at: + index === 13 + ? '2031-01-01T00:00:00.000Z' + : new Date(Date.UTC(2030, 1, 1, 0, 0, 20 - index)).toISOString(), + })); + await db.insert(cli_sessions_v2).values(rows); + try { + const result = await caller.cliSessionsV2.recentRepositories(input); + expect(result.repositories.map(row => row.gitUrl)).toEqual([ + 'https://gitlab.com/group/shared', + ...Array.from( + { length: 9 }, + (_, index) => `https://gitlab.com/group/repo-${index + 2}` + ), + ]); + const shared = result.repositories[0]; + expect(new Date(shared.lastUsedAt).toISOString()).toBe(rows[0].updated_at); + expect(shared.identity).toEqual({ + kind: 'legacy-unresolved', + accountId: regularUser.id, + owner: duplicate === 'owners' ? null : { type: 'user', id: regularUser.id }, + reason: duplicate === 'null platform' ? 'not-found' : 'ambiguous', + }); + expect( + result.repositories.every( + row => row.lastUsedAt && row.identity.accountId === regularUser.id + ) + ).toBe(true); + if (duplicate === 'owners') { + for (const organizationId of [null, testOrganization.id]) { + const scoped = await caller.cliSessionsV2.recentRepositories({ + ...input, + organizationId, + }); + expect(scoped.repositories[0]).toMatchObject({ + gitUrl: 'https://gitlab.com/group/shared', + identity: { + kind: 'legacy-unresolved', + accountId: regularUser.id, + owner: organizationId + ? { type: 'org', id: organizationId } + : { type: 'user', id: regularUser.id }, + reason: 'not-found', + }, + }); + } + } + } finally { + await db.delete(cli_sessions_v2).where( + inArray( + cli_sessions_v2.session_id, + rows.map(row => row.session_id) + ) + ); + } + } + ); + it('recentRepositories omits organization sessions after their creator loses membership', async () => { const personalSessionId = 'ses_recent_repo_personal_1234'; const personalGitUrl = 'https://github.com/kilo/personal-repository'; diff --git a/apps/web/src/routers/cli-sessions-v2-router.ts b/apps/web/src/routers/cli-sessions-v2-router.ts index 680a08e7a2..6531375f9f 100644 --- a/apps/web/src/routers/cli-sessions-v2-router.ts +++ b/apps/web/src/routers/cli-sessions-v2-router.ts @@ -54,6 +54,105 @@ import { normalizeGitUrl } from '@/lib/integrations/platforms/github/normalize-g import { triggerBatchReviewDecisionFetchIfNeeded } from '@/lib/integrations/platforms/github/batch-review-decisions'; import { notifyCliSessionRenamed } from '@/lib/cloud-agent/session-events'; import { after } from 'next/server'; +import type { + LaunchRepositoryReference, + Owner, +} from '@kilocode/app-shared/code-review/repository-identity'; +import { + fetchAllGitHubRepositoriesForOrganization, + fetchGitHubRepositoriesForUser, +} from '@/lib/cloud-agent/github-integration-helpers'; +import { + fetchGitLabRepositoriesForOrganization, + fetchGitLabRepositoriesForUser, +} from '@/lib/cloud-agent/gitlab-integration-helpers'; +import { fetchBitbucketRepositoriesForOrganization } from '@/lib/cloud-agent/bitbucket-integration-helpers'; + +type RecentRepositoryIdentity = + | { kind: 'resolved'; accountId: string; reference: LaunchRepositoryReference } + | { + kind: 'legacy-unresolved'; + accountId: string; + /** URL-only history can span owners, so no single owner is known. */ + owner: Owner | null; + reason: 'unavailable' | 'not-found' | 'ambiguous'; + }; + +export function resolveRecentRepositoryIdentity( + accountId: string, + owner: Owner, + gitUrl: string, + provider: string | null, + references: LaunchRepositoryReference[] | null +): RecentRepositoryIdentity { + const matches = references?.filter(reference => { + const { repository, authorization } = reference; + if ( + authorization.owner.type !== owner.type || + authorization.owner.id !== owner.id || + (provider !== null && provider !== repository.provider) + ) + return false; + try { + const actual = new URL( + gitUrl.replace(/^git@([^:]+):/, 'https://$1/').replace(/^ssh:/, 'https:') + ); + const expected = new URL( + `${repository.instanceUrl.replace(/\/+$/, '')}/${repository.fullName}` + ); + if ( + !['http:', 'https:'].includes(actual.protocol) || + actual.host !== expected.host || + actual.search || + actual.hash + ) + return false; + const path = actual.pathname.replace(/\/+$/, '').replace(/\.git$/, ''); + return repository.provider === 'github' + ? path.toLowerCase() === expected.pathname.toLowerCase() + : path === expected.pathname; + } catch { + return false; + } + }); + // URL-only history has no integration pin. Resolve only an authorized exact + // match; retain ambiguous/missing rows. Remove after old records/clients and + // the 30-day ledger window expire, never by guessing another integration. + const match = matches?.length === 1 ? matches[0] : undefined; + return match + ? { kind: 'resolved', accountId, reference: match } + : { + kind: 'legacy-unresolved', + accountId, + owner, + reason: references === null ? 'unavailable' : matches?.length ? 'ambiguous' : 'not-found', + }; +} + +async function recentRepositoryReferences(owner: Owner, accountId: string) { + try { + const [github, gitlab, bitbucket] = await Promise.all([ + owner.type === 'org' + ? fetchAllGitHubRepositoriesForOrganization(owner.id, false, { requireComplete: true }) + : fetchGitHubRepositoriesForUser(owner.id, false, { requireComplete: true }), + owner.type === 'org' + ? fetchGitLabRepositoriesForOrganization(owner.id, accountId) + : fetchGitLabRepositoriesForUser(owner.id), + owner.type === 'org' ? fetchBitbucketRepositoriesForOrganization(owner.id, accountId) : null, + ]); + if (bitbucket?.status === 'temporarily_unavailable') return null; + return [ + ...github.repositories, + ...gitlab.repositories, + ...(bitbucket?.status === 'available' ? bitbucket.repositories : []), + ].flatMap(repository => + repository.repositoryReference ? [repository.repositoryReference] : [] + ); + } catch { + // A failed discovery must not erase history or resolve against a partial set. + return null; + } +} /** * Check if an error indicates the session was not found in the cloud-agent DO. @@ -917,20 +1016,61 @@ export const cliSessionsV2Router = createTRPCRouter({ const { rows } = await db.execute<{ git_url: string; + organization_ids: (string | null)[]; + platforms: (string | null)[]; last_used_at: string; }>(sql` - SELECT ${cli_sessions_v2.git_url} AS git_url, MAX(${cli_sessions_v2.updated_at}) AS last_used_at + SELECT ${cli_sessions_v2.git_url} AS git_url, + ARRAY_AGG(DISTINCT ${cli_sessions_v2.organization_id}) AS organization_ids, + ARRAY_AGG(DISTINCT ${cli_sessions_v2.platform}) AS platforms, + MAX(${cli_sessions_v2.updated_at}) AS last_used_at FROM ${cli_sessions_v2} WHERE ${joinWithAnd(whereConditions)} GROUP BY ${cli_sessions_v2.git_url} ORDER BY last_used_at DESC LIMIT 10`); + const referencesByOwner = new Map>(); return { - repositories: rows.map(row => ({ - gitUrl: row.git_url, - lastUsedAt: row.last_used_at, - })), + repositories: await Promise.all( + rows.map(async row => { + const organizationId = row.organization_ids[0]; + const owner: Owner | null = + row.organization_ids.length !== 1 + ? null + : organizationId + ? { type: 'org', id: organizationId } + : { type: 'user', id: ctx.user.id }; + const providers = row.platforms.filter(provider => provider !== null); + const recent = { gitUrl: row.git_url, lastUsedAt: row.last_used_at }; + // Legacy consumers key by URL. A URL spanning owners cannot select one owner. + if (!owner || providers.length > 1) { + const identity: RecentRepositoryIdentity = { + kind: 'legacy-unresolved', + accountId: ctx.user.id, + owner, + reason: 'ambiguous', + }; + return { ...recent, identity }; + } + const ownerKey = JSON.stringify(owner); + let references = referencesByOwner.get(ownerKey); + if (!references) { + references = recentRepositoryReferences(owner, ctx.user.id); + referencesByOwner.set(ownerKey, references); + } + return { + ...recent, + identity: resolveRecentRepositoryIdentity( + ctx.user.id, + owner, + row.git_url, + providers[0] ?? null, + await references + ), + }; + }) + ), }; }), diff --git a/packages/cloud-agent-sdk/src/session-manager.test.ts b/packages/cloud-agent-sdk/src/session-manager.test.ts index 04bda6280d..65547475a0 100644 --- a/packages/cloud-agent-sdk/src/session-manager.test.ts +++ b/packages/cloud-agent-sdk/src/session-manager.test.ts @@ -3933,6 +3933,83 @@ describe('createSessionManager', () => { // ------------------------------------------------------------------------- describe('createAndStart', () => { + const launchCases = [ + { + provider: 'github', + gitUrl: 'https://github.com/owner/repo.git', + repository: 'owner/repo', + legacy: { githubRepo: 'owner/repo' }, + pin: { githubIntegrationId: 'integration-a' }, + }, + { + provider: 'gitlab', + gitUrl: 'https://gitlab.com/group/sub/repo.git', + repository: 'group/sub/repo', + legacy: { gitlabProject: 'group/sub/repo' }, + pin: { gitlabIntegrationId: 'integration-a', gitlabInstanceUrl: 'https://gitlab.com' }, + }, + { + provider: 'gitlab', + gitUrl: 'https://git.example/base/group/sub/repo.git', + repository: 'base/group/sub/repo', + legacy: { gitlabProject: 'group/sub/repo' }, + pin: { + gitlabIntegrationId: 'integration-a', + gitlabInstanceUrl: 'https://git.example/base', + }, + }, + { + provider: 'bitbucket', + gitUrl: 'https://bitbucket.org/workspace/repo.git', + repository: 'workspace/repo', + legacy: { + bitbucketRepo: { + fullName: 'workspace/repo', + workspaceUuid: 'workspace-1', + repositoryUuid: '42', + }, + }, + pin: { bitbucketIntegrationId: 'integration-a' }, + }, + ].flatMap(entry => + (entry.provider === 'bitbucket' ? ['org-1'] : [null, 'org-1']).flatMap(organizationId => + [false, true].map(legacy => ({ ...entry, organizationId, isLegacy: legacy })) + ) + ); + it.each(launchCases)( + 'preserves $provider payload and recovered identity, organization=$organizationId legacy=$isLegacy $gitUrl', + async ({ legacy, pin, isLegacy, gitUrl, repository, organizationId }) => { + const gitBranch = isLegacy ? 'main' : 'release/Case'; + const config = createMockConfig({ + fetchSession: jest.fn().mockResolvedValue({ + ...defaultFetchedSession, + gitUrl, + gitBranch, + repository, + organizationId, + }), + }); + const mgr = createSessionManager(config); + const input = { + prompt: 'Fix the bug', + mode: 'code', + model: 'model', + initialMessageId: 'msg_fixed', + ...legacy, + ...(isLegacy ? {} : { ...pin, upstreamBranch: 'release/Case' }), + }; + await mgr.createAndStart(input); + expect(jest.mocked(config.prepare).mock.calls[0]?.[0]).toEqual(input); + expect(config.store.get(mgr.atoms.fetchedSessionData)).toMatchObject({ + gitUrl, + gitBranch, + repository, + organizationId, + }); + mgr.destroy(); + } + ); + it('calls prepare then initiate then switchSession', async () => { const config = createMockConfig(); const mgr = createSessionManager(config); diff --git a/packages/cloud-agent-sdk/src/session-manager.ts b/packages/cloud-agent-sdk/src/session-manager.ts index cbee12be54..42b46b48ec 100644 --- a/packages/cloud-agent-sdk/src/session-manager.ts +++ b/packages/cloud-agent-sdk/src/session-manager.ts @@ -250,6 +250,13 @@ type PrepareInput = { variant?: string; githubRepo?: string; gitlabProject?: string; + bitbucketRepo?: { fullName: string; workspaceUuid: string; repositoryUuid: string }; + // Old manager clients omit pins. Keep the authorized server lookup until old + // clients/records disappear and the 30-day ledger window expires. + githubIntegrationId?: string; + gitlabIntegrationId?: string; + gitlabInstanceUrl?: string; + bitbucketIntegrationId?: string; envVars?: Record; setupCommands?: string[]; upstreamBranch?: string;