diff --git a/apps/api/src/handlers/discord/__tests__/fast-agent.test.ts b/apps/api/src/handlers/discord/__tests__/fast-agent.test.ts
index 1bd5a0830a..4f2bc12234 100644
--- a/apps/api/src/handlers/discord/__tests__/fast-agent.test.ts
+++ b/apps/api/src/handlers/discord/__tests__/fast-agent.test.ts
@@ -1,4 +1,10 @@
const mocks = vi.hoisted(() => ({
+ connectionAdapter: {
+ getSourceControlReadiness: vi.fn(),
+ requestSourceControlConnection: vi.fn(),
+ supersedeSourceControlConnectionRequests: vi.fn(),
+ },
+ connectionEnabled: vi.fn(async () => false),
acquireLock: vi.fn(),
answerQuestion: vi.fn(),
fetchHistory: vi.fn(),
@@ -41,6 +47,8 @@ vi.mock('@roomote/cloud-agents/server', () => ({
}));
vi.mock('@roomote/sdk/server', () => ({
+ createSourceControlConnectionAdapter: vi.fn(() => mocks.connectionAdapter),
+ isSourceControlConnectionEnabled: mocks.connectionEnabled,
admitFastAgentHumanFollowUp: mocks.admitHumanFollowUp,
createFastAgentConversationArtifact: mocks.createConversationArtifact,
persistFastAgentInlineHumanTurn: vi.fn(async () => null),
@@ -149,133 +157,147 @@ describe('processDiscordFastAgentMessage', () => {
});
});
- it('accepts a suggestion on its original conversation and sends replies and delegated work to the canonical target', async () => {
- const conversation = {
- surface: 'discord',
- workspaceId: 'guild-1',
- conversationId: 'original-report-event',
- sessionId: 'origin-session',
- replyTarget: { channelId: 'report-channel', threadId: 'report-thread' },
- };
- const channel = {
- channelId: 'report-thread',
- parentChannelId: 'report-channel',
- channelType: 11,
- channelName: 'Report',
- guildId: 'guild-1',
- isThread: true,
- isDirectMessage: false,
- };
- mocks.resolveSuggestionConversation.mockResolvedValueOnce(conversation);
- mocks.resolveChannel.mockResolvedValueOnce(channel);
- mocks.fetchHistory.mockResolvedValueOnce([
- {
- id: 'history-1',
- user: 'author',
- username: 'Author',
- text: 'Original report',
- },
- ]);
- const onAccepted = vi.fn();
- const provider = { editMessage: vi.fn().mockResolvedValue(undefined) };
- mocks.answerQuestion.mockImplementationOnce(async ({ adapter }) => {
- const reply = await adapter.postReply({ message: 'Working on it' });
- await adapter.replaceReply(reply, { message: 'Updated' });
- await adapter.launchTask({
- prompt: 'Fix errors',
- environmentId: ALL_REPOSITORIES,
- parentSessionId: 'fast-session-1',
- postKickoff: async () => {},
- });
- return null;
- });
- await expect(
- processDiscordFastAgentMessage({
- eventId: 'new-interaction',
- originSessionId: 'origin-session',
- question: 'Investigate errors',
- sender: { id: 'clicker', username: 'Matt' },
- senderUserId: 'acting-user',
- provider: provider as never,
- applicationId: 'app-1',
- channel: {
- ...channel,
- channelId: 'card-thread',
- parentChannelId: 'card-channel',
- },
- metadata: {
- communicationChannelId: 'card-channel',
- communicationThreadId: 'card-thread',
- } as never,
- conversationId: 'card-thread',
- anchorMessageId: 'clicked-card',
- interaction: {
- interaction: { id: 'new-interaction', token: 'token' } as never,
- interactionDeferred: true,
+ it.each([true, false])(
+ 'accepts a suggestion on its original conversation with trusted connection rollout %s',
+ async (enabled) => {
+ mocks.connectionEnabled.mockResolvedValueOnce(enabled);
+ const conversation = {
+ surface: 'discord',
+ workspaceId: 'guild-1',
+ conversationId: 'original-report-event',
+ sessionId: 'origin-session',
+ replyTarget: { channelId: 'report-channel', threadId: 'report-thread' },
+ };
+ const channel = {
+ channelId: 'report-thread',
+ parentChannelId: 'report-channel',
+ channelType: 11,
+ channelName: 'Report',
+ guildId: 'guild-1',
+ isThread: true,
+ isDirectMessage: false,
+ };
+ mocks.resolveSuggestionConversation.mockResolvedValueOnce(conversation);
+ mocks.resolveChannel.mockResolvedValueOnce(channel);
+ mocks.fetchHistory.mockResolvedValueOnce([
+ {
+ id: 'history-1',
+ user: 'author',
+ username: 'Author',
+ text: 'Original report',
},
- onAccepted,
- }),
- ).resolves.toBe(true);
-
- expect(onAccepted).toHaveBeenCalledWith(expect.any(Function));
- expect(mocks.resolveSuggestionConversation).toHaveBeenCalledWith(
- expect.objectContaining({
- userId: 'acting-user',
- originSessionId: 'origin-session',
- conversation: expect.objectContaining({
+ ]);
+ const onAccepted = vi.fn();
+ const provider = { editMessage: vi.fn().mockResolvedValue(undefined) };
+ mocks.answerQuestion.mockImplementationOnce(async ({ adapter }) => {
+ const reply = await adapter.postReply({ message: 'Working on it' });
+ await adapter.replaceReply(reply, { message: 'Updated' });
+ await adapter.launchTask({
+ prompt: 'Fix errors',
+ environmentId: ALL_REPOSITORIES,
+ parentSessionId: 'fast-session-1',
+ postKickoff: async () => {},
+ });
+ return null;
+ });
+ await expect(
+ processDiscordFastAgentMessage({
+ eventId: 'new-interaction',
+ originSessionId: 'origin-session',
+ question: 'Investigate errors',
+ sender: { id: 'clicker', username: 'Matt' },
+ senderUserId: 'acting-user',
+ provider: provider as never,
+ applicationId: 'app-1',
+ channel: {
+ ...channel,
+ channelId: 'card-thread',
+ parentChannelId: 'card-channel',
+ },
+ metadata: {
+ communicationChannelId: 'card-channel',
+ communicationThreadId: 'card-thread',
+ } as never,
conversationId: 'card-thread',
+ anchorMessageId: 'clicked-card',
+ interaction: {
+ interaction: { id: 'new-interaction', token: 'token' } as never,
+ interactionDeferred: true,
+ },
+ onAccepted,
}),
- }),
- );
- expect(mocks.acquireLock).toHaveBeenCalledWith({
- conversation,
- maxWaitMs: 0,
- });
- expect(mocks.getSession).toHaveBeenCalledWith({
- userId: 'acting-user',
- conversation,
- });
- expect(mocks.fetchHistory).toHaveBeenCalledWith({
- provider,
- channelId: 'report-thread',
- parentChannelId: 'report-channel',
- });
- expect(mocks.answerQuestion).toHaveBeenCalledWith(
- expect.objectContaining({
+ ).resolves.toBe(true);
+
+ expect(onAccepted).toHaveBeenCalledWith(expect.any(Function));
+ expect(mocks.connectionEnabled).toHaveBeenCalledWith();
+ expect(mocks.answerQuestion).toHaveBeenCalledWith(
+ expect.objectContaining({
+ userId: 'acting-user',
+ adapter: expect.objectContaining({
+ ...mocks.connectionAdapter,
+ sourceControlConnectionEnabled: enabled,
+ }),
+ }),
+ );
+ expect(mocks.resolveSuggestionConversation).toHaveBeenCalledWith(
+ expect.objectContaining({
+ userId: 'acting-user',
+ originSessionId: 'origin-session',
+ conversation: expect.objectContaining({
+ conversationId: 'card-thread',
+ }),
+ }),
+ );
+ expect(mocks.acquireLock).toHaveBeenCalledWith({
+ conversation,
+ maxWaitMs: 0,
+ });
+ expect(mocks.getSession).toHaveBeenCalledWith({
userId: 'acting-user',
conversation,
- threadContext: [expect.objectContaining({ text: 'Original report' })],
- }),
- );
- expect(mocks.reply).toHaveBeenCalledWith(
- expect.objectContaining({ channel }),
- );
- expect(mocks.reply.mock.calls[0]![0]).not.toHaveProperty('interaction');
- expect(mocks.reply.mock.calls[0]![0]).not.toHaveProperty(
- 'replyToMessageId',
- );
- expect(provider.editMessage).toHaveBeenCalledWith(
- expect.objectContaining({ channelId: 'report-thread' }),
- );
- expect(mocks.startTask).toHaveBeenCalledWith(
- expect.objectContaining({
- channel,
- launchOwnerUserId: 'acting-user',
- requesterDiscordUserId: 'clicker',
- metadata: expect.objectContaining({
- communicationChannelId: 'report-channel',
- communicationThreadId: 'report-thread',
- }),
- queuedMessage: expect.objectContaining({
- channel: 'report-channel',
- threadTs: 'report-thread',
+ });
+ expect(mocks.fetchHistory).toHaveBeenCalledWith({
+ provider,
+ channelId: 'report-thread',
+ parentChannelId: 'report-channel',
+ });
+ expect(mocks.answerQuestion).toHaveBeenCalledWith(
+ expect.objectContaining({
userId: 'acting-user',
+ conversation,
+ threadContext: [expect.objectContaining({ text: 'Original report' })],
}),
- fastAgentParent: { sessionId: 'fast-session-1', conversation },
- }),
- );
- expect(mocks.releaseLock).toHaveBeenCalledOnce();
- });
+ );
+ expect(mocks.reply).toHaveBeenCalledWith(
+ expect.objectContaining({ channel }),
+ );
+ expect(mocks.reply.mock.calls[0]![0]).not.toHaveProperty('interaction');
+ expect(mocks.reply.mock.calls[0]![0]).not.toHaveProperty(
+ 'replyToMessageId',
+ );
+ expect(provider.editMessage).toHaveBeenCalledWith(
+ expect.objectContaining({ channelId: 'report-thread' }),
+ );
+ expect(mocks.startTask).toHaveBeenCalledWith(
+ expect.objectContaining({
+ channel,
+ launchOwnerUserId: 'acting-user',
+ requesterDiscordUserId: 'clicker',
+ metadata: expect.objectContaining({
+ communicationChannelId: 'report-channel',
+ communicationThreadId: 'report-thread',
+ }),
+ queuedMessage: expect.objectContaining({
+ channel: 'report-channel',
+ threadTs: 'report-thread',
+ userId: 'acting-user',
+ }),
+ fastAgentParent: { sessionId: 'fast-session-1', conversation },
+ }),
+ );
+ expect(mocks.releaseLock).toHaveBeenCalledOnce();
+ },
+ );
it('creates artifacts against the canonical Fast conversation', async () => {
mocks.answerQuestion.mockImplementationOnce(
diff --git a/apps/api/src/handlers/discord/__tests__/index.test.ts b/apps/api/src/handlers/discord/__tests__/index.test.ts
index 794f76d401..efead3f95e 100644
--- a/apps/api/src/handlers/discord/__tests__/index.test.ts
+++ b/apps/api/src/handlers/discord/__tests__/index.test.ts
@@ -103,6 +103,12 @@ vi.mock('../provider.js', () => {
});
vi.mock('@roomote/sdk/server', () => ({
+ createSourceControlConnectionAdapter: vi.fn(() => ({
+ getSourceControlReadiness: vi.fn(),
+ requestSourceControlConnection: vi.fn(),
+ supersedeSourceControlConnectionRequests: vi.fn(),
+ })),
+ isSourceControlConnectionEnabled: vi.fn(async () => false),
findDiscordMappedUserId: mocks.findMappedUserId,
findDiscordInstallationByGuildId: mocks.findInstallation,
consumeDiscordLinkCode: mocks.consumeLinkCode,
diff --git a/apps/api/src/handlers/discord/fast-agent.ts b/apps/api/src/handlers/discord/fast-agent.ts
index 8b1c6b175f..02d42c3430 100644
--- a/apps/api/src/handlers/discord/fast-agent.ts
+++ b/apps/api/src/handlers/discord/fast-agent.ts
@@ -28,6 +28,8 @@ import {
} from '@roomote/communication';
import {
admitFastAgentHumanFollowUp,
+ createSourceControlConnectionAdapter,
+ isSourceControlConnectionEnabled,
createFastAgentConversationArtifact,
persistFastAgentInlineHumanTurn,
recordFastAgentConversationMessageBestEffort,
@@ -390,6 +392,9 @@ export async function processDiscordFastAgentMessage(
entry.user !== input.sender.id,
),
adapter: {
+ ...createSourceControlConnectionAdapter(),
+ sourceControlConnectionEnabled:
+ await isSourceControlConnectionEnabled(),
createArtifact: (artifact) =>
createFastAgentConversationArtifact({
fastConversationId: session.id,
diff --git a/apps/api/src/handlers/github/__tests__/handleInstallationCreated.test.ts b/apps/api/src/handlers/github/__tests__/handleInstallationCreated.test.ts
index d07bc57da9..e6ba484410 100644
--- a/apps/api/src/handlers/github/__tests__/handleInstallationCreated.test.ts
+++ b/apps/api/src/handlers/github/__tests__/handleInstallationCreated.test.ts
@@ -1,97 +1,260 @@
-const {
- mockCompletePendingGitHubInstallation,
- mockSendUserDirectMessage,
- mockRequestBrainBackfill,
-} = vi.hoisted(() => ({
- mockCompletePendingGitHubInstallation: vi.fn(),
- mockSendUserDirectMessage: vi.fn(),
- mockRequestBrainBackfill: vi.fn(async () => undefined),
+import {
+ db,
+ eq,
+ githubInstallations,
+ githubInstallationFactory,
+ githubPendingInstallations,
+ githubUserMappings,
+ userFactory,
+ users,
+} from '@roomote/db/server';
+import { encryptJSON } from '@roomote/db/encryption';
+const mocks = vi.hoisted(() => ({
+ pending: vi.fn(),
+ sync: vi.fn(),
+ installation: vi.fn(),
+ notify: vi.fn(),
+ backfill: vi.fn(),
+ reconcile: vi.fn(),
+ syncStartedAt: vi.fn(async () => '2026-09-08T12:00:00.123456Z'),
+ configuredApp: null as null | {
+ value: string;
+ createdByUserId: string;
+ lastUpdatedByUserId: string | null;
+ },
}));
-
vi.mock('@roomote/github', () => ({
- completePendingGitHubInstallation: mockCompletePendingGitHubInstallation,
+ completePendingGitHubInstallation: mocks.pending,
+ getGitHubInstallation: mocks.installation,
+ syncGitHubInstallation: mocks.sync,
}));
-
vi.mock('@roomote/sdk/server', () => ({
- sendUserDirectMessageBestEffort: mockSendUserDirectMessage,
+ sendUserDirectMessageBestEffort: mocks.notify,
+ reconcileSourceControlConnectionRequests: mocks.reconcile,
+ getSourceControlSyncStartedAt: mocks.syncStartedAt,
}));
-
vi.mock('@roomote/sdk/server/request-instance-ping', () => ({
- requestBrainBackfill: mockRequestBrainBackfill,
+ requestBrainBackfill: mocks.backfill,
}));
-
-vi.mock('@roomote/env', () => ({
+vi.mock('@roomote/env', async (original) => ({
+ ...(await original()),
+ getEncryptionKey: () => 'installation-created-test-key',
Env: { R_APP_URL: 'https://roomote.example.com' },
}));
-
+vi.mock('@roomote/db/server', async (original) => ({
+ ...(await original()),
+ resolveDeploymentEnvVar: async () => '77',
+}));
import { handleInstallationCreated } from '../handleInstallationCreated';
import type { WebhookInstallationCreated } from '../types';
-const payload = {
- installation: { id: 42 },
-} as unknown as WebhookInstallationCreated;
-
-describe('handleInstallationCreated', () => {
- beforeEach(() => {
+describe('installation.created authoritative synchronization', () => {
+ let admin: Awaited>;
+ let member: Awaited>;
+ let installationId: number;
+ let accountId: number;
+ let senderId: number;
+ const payload = () =>
+ ({
+ installation: {
+ id: installationId,
+ app_id: 77,
+ account: { id: accountId },
+ },
+ sender: { id: senderId },
+ }) as WebhookInstallationCreated;
+ const pending = () =>
+ db
+ .insert(githubPendingInstallations)
+ .values({ appId: accountId, requestedByUserId: admin.id, payload: {} });
+ beforeEach(async () => {
vi.clearAllMocks();
- mockSendUserDirectMessage.mockResolvedValue(['slack']);
- });
-
- it('notifies the requesting user after completing a pending installation', async () => {
- mockCompletePendingGitHubInstallation.mockResolvedValue({
+ admin = await userFactory.create({ role: 'admin' });
+ member = await userFactory.create({ role: 'member' });
+ installationId = Math.floor(Math.random() * 1e12);
+ accountId = installationId + 1;
+ senderId = installationId + 2;
+ mocks.configuredApp = null;
+ vi.spyOn(db.query.environmentVariables, 'findFirst').mockImplementation(
+ () => Promise.resolve(mocks.configuredApp) as never,
+ );
+ mocks.installation.mockResolvedValue({
+ id: installationId,
+ app_id: 77,
+ account: { id: accountId },
+ suspended_at: null,
+ });
+ mocks.sync.mockResolvedValue({
success: true,
- githubInstallation: { accountLogin: 'acme-inc' },
+ githubInstallation: { accountLogin: 'example' },
repositories: [],
- requestedByUserId: 'user-1',
});
-
- const response = await handleInstallationCreated(payload);
-
- expect(response).toEqual({ status: 'ok' });
- expect(mockCompletePendingGitHubInstallation).toHaveBeenCalledWith(42);
- expect(mockSendUserDirectMessage).toHaveBeenCalledWith({
- userId: 'user-1',
- text: 'Your GitHub installation request for acme-inc was approved, and Roomote is now connected. Continue setup here: https://roomote.example.com/setup',
- logContext: 'handleInstallationCreated',
+ mocks.pending.mockResolvedValue({
+ success: true,
+ githubInstallation: { accountLogin: 'example' },
+ repositories: [],
+ requestedByUserId: admin.id,
});
});
-
- it('does not notify when completion fails', async () => {
- mockCompletePendingGitHubInstallation.mockResolvedValue({
- success: false,
- error: 'sync failed',
+ afterEach(async () => {
+ vi.restoreAllMocks();
+ await db
+ .delete(githubInstallations)
+ .where(eq(githubInstallations.installationId, installationId));
+ await db
+ .delete(githubPendingInstallations)
+ .where(eq(githubPendingInstallations.appId, accountId));
+ await db.delete(users).where(eq(users.id, admin.id));
+ await db.delete(users).where(eq(users.id, member.id));
+ });
+ it('synchronizes a direct install without a pending request or personal link using the current App configuring admin', async () => {
+ mocks.configuredApp = {
+ value: encryptJSON('77'),
+ createdByUserId: admin.id,
+ lastUpdatedByUserId: null,
+ };
+ expect(await handleInstallationCreated(payload())).toEqual({
+ status: 'ok',
});
-
- const response = await handleInstallationCreated(payload);
-
- expect(response).toEqual({ status: 'ok' });
- expect(mockSendUserDirectMessage).not.toHaveBeenCalled();
+ expect(mocks.sync).toHaveBeenCalledWith({
+ userId: admin.id,
+ installationId,
+ });
+ expect(mocks.pending).not.toHaveBeenCalled();
+ expect(mocks.syncStartedAt).toHaveBeenCalledWith('github');
+ expect(mocks.syncStartedAt.mock.invocationCallOrder[0]).toBeLessThan(
+ mocks.installation.mock.invocationCallOrder[0]!,
+ );
+ expect(mocks.reconcile).toHaveBeenCalledWith(
+ { provider: 'github' },
+ {
+ successfulSync: {
+ startedAt: '2026-09-08T12:00:00.123456Z',
+ repositoryFullNames: [],
+ },
+ },
+ );
+ expect(mocks.reconcile.mock.invocationCallOrder[0]).toBeGreaterThan(
+ mocks.sync.mock.invocationCallOrder[0]!,
+ );
+ expect(mocks.backfill).toHaveBeenCalledWith('github-installation-created');
+ expect(mocks.notify).not.toHaveBeenCalled();
});
-
- it('kicks the Memory backfill for pending and direct installs alike', async () => {
- mockCompletePendingGitHubInstallation.mockResolvedValue({
- success: false,
- error: 'no pending installation',
+ it('supports a verified linked admin sender when the App credentials were configured at runtime', async () => {
+ await db.insert(githubUserMappings).values({
+ githubLogin: 'installer',
+ githubUserId: senderId,
+ userId: admin.id,
});
-
- await handleInstallationCreated(payload);
-
- expect(mockRequestBrainBackfill).toHaveBeenCalledWith(
- 'github-installation-created',
+ expect((await handleInstallationCreated(payload())).status).toBe('ok');
+ expect(mocks.sync).toHaveBeenCalledWith({
+ userId: admin.id,
+ installationId,
+ });
+ });
+ it('reuses a known installation actor without inventing attribution on redelivery', async () => {
+ await githubInstallationFactory.create({
+ installationId,
+ appId: 77,
+ installedByUserId: admin.id,
+ });
+ expect((await handleInstallationCreated(payload())).status).toBe('ok');
+ expect(mocks.sync).toHaveBeenCalledWith({
+ userId: admin.id,
+ installationId,
+ });
+ });
+ it('retains pending-approval completion and notifies only its recorded requester', async () => {
+ await pending();
+ expect(await handleInstallationCreated(payload())).toEqual({
+ status: 'ok',
+ });
+ expect(mocks.pending).toHaveBeenCalledWith(installationId);
+ expect(mocks.sync).not.toHaveBeenCalled();
+ expect(mocks.notify).toHaveBeenCalledWith(
+ expect.objectContaining({ userId: admin.id }),
);
+ expect(mocks.reconcile).toHaveBeenCalledTimes(1);
});
-
- it('still acks the webhook when completion throws', async () => {
- const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
- mockCompletePendingGitHubInstallation.mockRejectedValue(
- new Error('Pending GitHub installation not found'),
+ it.each([false, true])(
+ 'never reconciles or backfills after sync failure (pending=%s)',
+ async (hasPending) => {
+ if (hasPending) await pending();
+ else
+ mocks.configuredApp = {
+ value: encryptJSON('77'),
+ createdByUserId: admin.id,
+ lastUpdatedByUserId: null,
+ };
+ mocks.pending.mockResolvedValue({
+ success: false,
+ error: 'provider failure',
+ });
+ mocks.sync.mockResolvedValue({
+ success: false,
+ error: 'provider failure',
+ });
+ expect(await handleInstallationCreated(payload())).toEqual({
+ status: 'error',
+ message: 'installation_sync_failed',
+ });
+ expect(mocks.reconcile).not.toHaveBeenCalled();
+ expect(mocks.backfill).not.toHaveBeenCalled();
+ expect(mocks.notify).not.toHaveBeenCalled();
+ },
+ );
+ it('rejects a webhook for a different App before making provider calls', async () => {
+ const foreign = payload();
+ foreign.installation.app_id = 88;
+ expect((await handleInstallationCreated(foreign)).message).toBe(
+ 'installation_app_mismatch',
);
-
- const response = await handleInstallationCreated(payload);
-
- expect(response).toEqual({ status: 'ok' });
- expect(mockSendUserDirectMessage).not.toHaveBeenCalled();
-
- errorSpy.mockRestore();
+ expect(mocks.installation).not.toHaveBeenCalled();
+ expect(mocks.sync).not.toHaveBeenCalled();
+ });
+ it.each(['app', 'id', 'account', 'suspended'])(
+ 'rejects mismatched authoritative installation %s',
+ async (mismatch) => {
+ mocks.installation.mockResolvedValue({
+ id: mismatch === 'id' ? installationId + 9 : installationId,
+ app_id: mismatch === 'app' ? 88 : 77,
+ account: { id: mismatch === 'account' ? accountId + 9 : accountId },
+ suspended_at: mismatch === 'suspended' ? '2026-01-01' : null,
+ });
+ expect((await handleInstallationCreated(payload())).message).toBe(
+ 'installation_unavailable',
+ );
+ expect(mocks.pending).not.toHaveBeenCalled();
+ expect(mocks.sync).not.toHaveBeenCalled();
+ expect(mocks.reconcile).not.toHaveBeenCalled();
+ },
+ );
+ it('fails closed when App-auth installation lookup rejects the id', async () => {
+ vi.spyOn(console, 'error').mockImplementation(() => {});
+ mocks.installation.mockRejectedValue(new Error('Not Found'));
+ expect((await handleInstallationCreated(payload())).status).toBe('error');
+ expect(mocks.sync).not.toHaveBeenCalled();
+ expect(mocks.reconcile).not.toHaveBeenCalled();
});
+ it.each(['unknown', 'member', 'deleted', 'other-app'])(
+ 'does not select an arbitrary admin when actor attribution is %s',
+ async (scenario) => {
+ if (scenario !== 'unknown')
+ mocks.configuredApp = {
+ value: encryptJSON(scenario === 'other-app' ? '88' : '77'),
+ createdByUserId: scenario === 'member' ? member.id : admin.id,
+ lastUpdatedByUserId: null,
+ };
+ if (scenario === 'deleted')
+ await db
+ .update(users)
+ .set({ deletedAt: new Date() })
+ .where(eq(users.id, admin.id));
+ expect((await handleInstallationCreated(payload())).message).toBe(
+ 'installation_sync_actor_required',
+ );
+ expect(mocks.sync).not.toHaveBeenCalled();
+ expect(mocks.reconcile).not.toHaveBeenCalled();
+ },
+ );
});
diff --git a/apps/api/src/handlers/github/__tests__/handleInstallationRepositoriesChange.test.ts b/apps/api/src/handlers/github/__tests__/handleInstallationRepositoriesChange.test.ts
index 5fa5fac325..23bd1ed57f 100644
--- a/apps/api/src/handlers/github/__tests__/handleInstallationRepositoriesChange.test.ts
+++ b/apps/api/src/handlers/github/__tests__/handleInstallationRepositoriesChange.test.ts
@@ -1,4 +1,12 @@
import { describe, expect, it, vi, beforeEach } from 'vitest';
+const { reconcile, syncStartedAt } = vi.hoisted(() => ({
+ reconcile: vi.fn(async () => ({ ready: 0 })),
+ syncStartedAt: vi.fn(async () => '2026-09-08T12:00:00.123456Z'),
+}));
+vi.mock('@roomote/sdk/server', () => ({
+ reconcileSourceControlConnectionRequests: reconcile,
+ getSourceControlSyncStartedAt: syncStartedAt,
+}));
const { mockFindFirst, mockSyncGitHubInstallation } = vi.hoisted(() => ({
mockFindFirst: vi.fn(),
@@ -32,7 +40,10 @@ describe('handleInstallationRepositoriesChange', () => {
mockSyncGitHubInstallation.mockResolvedValue({
success: true,
githubInstallation: {},
- repositories: [{ id: 'repo-1' }, { id: 'repo-2' }],
+ repositories: [
+ { id: 'repo-1', fullName: 'org/one' },
+ { id: 'repo-2', fullName: 'org/two' },
+ ],
});
});
@@ -47,6 +58,19 @@ describe('handleInstallationRepositoriesChange', () => {
});
expect(response.status).toBe('ok');
expect(response.metadata).toEqual({ repositoryCount: 2 });
+ expect(syncStartedAt).toHaveBeenCalledWith('github');
+ expect(syncStartedAt.mock.invocationCallOrder[0]).toBeLessThan(
+ mockSyncGitHubInstallation.mock.invocationCallOrder[0]!,
+ );
+ expect(reconcile).toHaveBeenCalledWith(
+ { provider: 'github' },
+ {
+ successfulSync: {
+ startedAt: '2026-09-08T12:00:00.123456Z',
+ repositoryFullNames: ['org/one', 'org/two'],
+ },
+ },
+ );
});
it('short-circuits when the payload has no installation id', async () => {
@@ -80,5 +104,6 @@ describe('handleInstallationRepositoriesChange', () => {
expect(response.status).toBe('error');
expect(response.message).toContain('boom');
+ expect(reconcile).not.toHaveBeenCalled();
});
});
diff --git a/apps/api/src/handlers/github/handleInstallationCreated.ts b/apps/api/src/handlers/github/handleInstallationCreated.ts
index 8036d02a13..eb8b31f96b 100644
--- a/apps/api/src/handlers/github/handleInstallationCreated.ts
+++ b/apps/api/src/handlers/github/handleInstallationCreated.ts
@@ -1,5 +1,26 @@
-import { completePendingGitHubInstallation } from '@roomote/github';
-import { sendUserDirectMessageBestEffort } from '@roomote/sdk/server';
+import {
+ completePendingGitHubInstallation,
+ getGitHubInstallation,
+ syncGitHubInstallation,
+} from '@roomote/github';
+import { decryptSecrets } from '@roomote/db/encryption';
+import {
+ and,
+ db,
+ eq,
+ githubInstallations,
+ githubPendingInstallations,
+ githubUserMappings,
+ environmentVariables,
+ isNull,
+ resolveDeploymentEnvVar,
+ users,
+} from '@roomote/db/server';
+import {
+ sendUserDirectMessageBestEffort,
+ reconcileSourceControlConnectionRequests,
+ getSourceControlSyncStartedAt,
+} from '@roomote/sdk/server';
import { requestBrainBackfill } from '@roomote/sdk/server/request-instance-ping';
import { Env } from '@roomote/env';
@@ -17,9 +38,99 @@ export async function handleInstallationCreated(
payload: WebhookInstallationCreated,
): Promise {
try {
- const result = await completePendingGitHubInstallation(
- payload.installation.id,
- );
+ const startedAt = await getSourceControlSyncStartedAt('github');
+ const installationId = payload.installation.id;
+ const appId = Number(await resolveDeploymentEnvVar('R_GITHUB_APP_ID'));
+ if (
+ !Number.isSafeInteger(appId) ||
+ appId <= 0 ||
+ payload.installation.app_id !== appId
+ )
+ return { status: 'error', message: 'installation_app_mismatch' };
+ // Uses this deployment's App JWT, not an installation id/account supplied
+ // by the browser. GitHub rejects installations belonging to another App.
+ const installation = await getGitHubInstallation(installationId);
+ if (
+ installation.id !== installationId ||
+ installation.app_id !== appId ||
+ !installation.account ||
+ installation.account.id !== payload.installation.account?.id ||
+ installation.suspended_at
+ )
+ return { status: 'error', message: 'installation_unavailable' };
+
+ const pending = await db.query.githubPendingInstallations.findFirst({
+ where: eq(githubPendingInstallations.appId, installation.account.id),
+ });
+ let actorUserId = pending?.requestedByUserId;
+ if (!pending) {
+ const [existing, sender, configuredApp] = await Promise.all([
+ db.query.githubInstallations.findFirst({
+ where: and(
+ eq(githubInstallations.installationId, installationId),
+ eq(githubInstallations.appId, appId),
+ ),
+ columns: { installedByUserId: true },
+ }),
+ payload.sender?.id
+ ? db.query.githubUserMappings.findFirst({
+ where: eq(githubUserMappings.githubUserId, payload.sender.id),
+ columns: { userId: true },
+ })
+ : undefined,
+ db.query.environmentVariables.findFirst({
+ where: eq(environmentVariables.name, 'R_GITHUB_APP_ID'),
+ columns: {
+ value: true,
+ lastUpdatedByUserId: true,
+ createdByUserId: true,
+ },
+ }),
+ ]);
+ // For a new unlinked installer, the admin who configured this exact App
+ // supplies inventory attribution only. Continuation keeps its own actor.
+ const configuredBy =
+ configuredApp &&
+ Number(await decryptSecrets(configuredApp.value)) === appId
+ ? (configuredApp.lastUpdatedByUserId ?? configuredApp.createdByUserId)
+ : undefined;
+ for (const candidate of [
+ existing?.installedByUserId,
+ sender?.userId,
+ configuredBy,
+ ]) {
+ if (!candidate) continue;
+ const admin = await db.query.users.findFirst({
+ where: and(
+ eq(users.id, candidate),
+ eq(users.role, 'admin'),
+ isNull(users.deletedAt),
+ ),
+ columns: { id: true },
+ });
+ if (admin) {
+ actorUserId = admin.id;
+ break;
+ }
+ }
+ } else {
+ const admin = await db.query.users.findFirst({
+ where: and(
+ eq(users.id, actorUserId!),
+ eq(users.role, 'admin'),
+ isNull(users.deletedAt),
+ ),
+ columns: { id: true },
+ });
+ if (!admin) actorUserId = undefined;
+ }
+ if (!actorUserId)
+ return { status: 'error', message: 'installation_sync_actor_required' };
+ const result = pending
+ ? await completePendingGitHubInstallation(installationId)
+ : await syncGitHubInstallation({ userId: actorUserId, installationId });
+ if (!result.success)
+ return { status: 'error', message: 'installation_sync_failed' };
// The webhook is the universal completion point for new installations
// (pending-approval and direct installs alike): repositories just became
@@ -27,7 +138,21 @@ export async function handleInstallationCreated(
// 15-minute schedules.
void requestBrainBackfill('github-installation-created');
- if (result.success) {
+ await reconcileSourceControlConnectionRequests(
+ { provider: 'github' },
+ {
+ successfulSync: {
+ startedAt,
+ repositoryFullNames: result.repositories.map(
+ (repository) => repository.fullName,
+ ),
+ },
+ },
+ );
+ if (
+ 'requestedByUserId' in result &&
+ typeof result.requestedByUserId === 'string'
+ ) {
// The requester was waiting on a GitHub org owner's approval; let them
// know on whichever chat integrations they have linked.
await sendUserDirectMessageBestEffort({
@@ -42,6 +167,7 @@ export async function handleInstallationCreated(
console.error(
`[handleInstallationCreated] Failed to complete pending GitHub installation: ${error instanceof Error ? error.message : String(error)}`,
);
+ return { status: 'error', message: 'installation_sync_failed' };
}
return { status: 'ok' };
diff --git a/apps/api/src/handlers/github/handleInstallationRepositoriesChange.ts b/apps/api/src/handlers/github/handleInstallationRepositoriesChange.ts
index aa51b2ea36..c8e61e8626 100644
--- a/apps/api/src/handlers/github/handleInstallationRepositoriesChange.ts
+++ b/apps/api/src/handlers/github/handleInstallationRepositoriesChange.ts
@@ -1,5 +1,9 @@
import { db, eq, githubInstallations } from '@roomote/db/server';
import * as GitHub from '@roomote/github';
+import {
+ getSourceControlSyncStartedAt,
+ reconcileSourceControlConnectionRequests,
+} from '@roomote/sdk/server';
import type { WebhookResponse } from '../../types';
@@ -33,6 +37,7 @@ export async function handleInstallationRepositoriesChange(
return { status: 'ok', message: 'unknown_installation' };
}
+ const startedAt = await getSourceControlSyncStartedAt('github');
const result = await GitHub.syncGitHubInstallation({
userId: installation.installedByUserId,
installationId,
@@ -45,6 +50,17 @@ export async function handleInstallationRepositoriesChange(
};
}
+ await reconcileSourceControlConnectionRequests(
+ { provider: 'github' },
+ {
+ successfulSync: {
+ startedAt,
+ repositoryFullNames: result.repositories.map(
+ (repository) => repository.fullName,
+ ),
+ },
+ },
+ );
return {
status: 'ok',
message: `Resynced installation ${installationId}`,
diff --git a/apps/api/src/handlers/slack/events/fast-agent-processing.test.ts b/apps/api/src/handlers/slack/events/fast-agent-processing.test.ts
index ffb7d90f9d..e6e416a8c2 100644
--- a/apps/api/src/handlers/slack/events/fast-agent-processing.test.ts
+++ b/apps/api/src/handlers/slack/events/fast-agent-processing.test.ts
@@ -1,4 +1,10 @@
const mocks = vi.hoisted(() => ({
+ connectionAdapter: {
+ getSourceControlReadiness: vi.fn(),
+ requestSourceControlConnection: vi.fn(),
+ supersedeSourceControlConnectionRequests: vi.fn(),
+ },
+ connectionEnabled: vi.fn(async () => false),
acquireLock: vi.fn(),
acquireRootBindingLock: vi.fn(),
hasSession: vi.fn(),
@@ -66,6 +72,8 @@ vi.mock('@roomote/cloud-agents', () => ({
}));
vi.mock('@roomote/sdk/server', () => ({
+ createSourceControlConnectionAdapter: vi.fn(() => mocks.connectionAdapter),
+ isSourceControlConnectionEnabled: mocks.connectionEnabled,
findSlackConversationSubjectByUserId: vi.fn(async () => null),
admitFastAgentHumanFollowUp: mocks.admitHumanFollowUp,
createFastAgentConversationArtifact: mocks.createConversationArtifact,
@@ -149,6 +157,41 @@ describe('processFastAgentMessage', () => {
);
});
+ it.each([[true], [false]])(
+ 'attaches trusted connection rollout %s and callbacks for the Slack actor',
+ async (enabled) => {
+ mocks.connectionEnabled.mockResolvedValueOnce(enabled);
+ await processFastAgentMessage({
+ event: {
+ type: 'message',
+ channel: 'C123',
+ user: 'U123',
+ text: 'Connect repository',
+ ts: '100.001',
+ thread_ts: '100.000',
+ } as never,
+ slack: {
+ addReaction: vi.fn(),
+ removeReaction: vi.fn(),
+ fetchThreadMessages: vi.fn(async () => []),
+ } as never,
+ userId: 'origin-member',
+ teamId: 'T123',
+ isExistingConversation: true,
+ });
+ expect(mocks.connectionEnabled).toHaveBeenCalledWith();
+ expect(mocks.answerQuestion).toHaveBeenCalledWith(
+ expect.objectContaining({
+ userId: 'origin-member',
+ adapter: expect.objectContaining({
+ ...mocks.connectionAdapter,
+ sourceControlConnectionEnabled: enabled,
+ }),
+ }),
+ );
+ },
+ );
+
it.each([
['Roomote can you hear me?', 'Roomote can you hear me?'],
['Roomote, can you hear me?', 'Roomote, can you hear me?'],
diff --git a/apps/api/src/handlers/slack/events/fast-agent-reaction.test.ts b/apps/api/src/handlers/slack/events/fast-agent-reaction.test.ts
index df7944565f..406fbb8fb0 100644
--- a/apps/api/src/handlers/slack/events/fast-agent-reaction.test.ts
+++ b/apps/api/src/handlers/slack/events/fast-agent-reaction.test.ts
@@ -1,6 +1,12 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
const mocks = vi.hoisted(() => ({
+ connectionAdapter: {
+ getSourceControlReadiness: vi.fn(),
+ requestSourceControlConnection: vi.fn(),
+ supersedeSourceControlConnectionRequests: vi.fn(),
+ },
+ connectionEnabled: vi.fn(async () => false),
acquireLock: vi.fn(),
answerQuestion: vi.fn(),
createArtifact: vi.fn(),
@@ -36,6 +42,8 @@ vi.mock('@roomote/communication', () => ({
}));
vi.mock('@roomote/sdk/server', () => ({
+ createSourceControlConnectionAdapter: vi.fn(() => mocks.connectionAdapter),
+ isSourceControlConnectionEnabled: mocks.connectionEnabled,
findSlackConversationSubjectByUserId: vi.fn(async () => null),
buildFastAgentArtifactCreator: vi.fn(() => mocks.createArtifact),
findFastAgentSessionForProviderMessage: mocks.findSession,
@@ -184,69 +192,86 @@ describe('Fast Slack reaction input', () => {
);
});
- it('includes the Fast-authored message when a reaction can directly answer it', async () => {
- const slack = {
- getMessage: vi.fn(async () => ({
- text: 'React to this message with your favorite emoji.',
- thread_ts: '100.000',
- })),
- normalizeIncomingText: vi.fn(async () => '@alice'),
- updateMessage: vi.fn(),
- };
-
- await expect(
- maybeRouteFastAgentReaction({
- context: {
- teamId: 'T1',
- slackInstallation: { botUserId: 'UROOMOTE' },
- slack,
- } as never,
- event: {
- type: 'reaction_added',
- user: 'UALICE',
- reaction: 'sparkling_heart',
- item: { type: 'message', channel: 'C1', ts: '101.000' },
- event_ts: '102.000',
- },
- }),
- ).resolves.toBe(true);
+ it.each([true, false])(
+ 'includes the reaction actor and connection wiring with rollout %s',
+ async (enabled) => {
+ mocks.connectionEnabled.mockResolvedValueOnce(enabled);
+ const slack = {
+ getMessage: vi.fn(async () => ({
+ text: 'React to this message with your favorite emoji.',
+ thread_ts: '100.000',
+ })),
+ normalizeIncomingText: vi.fn(async () => '@alice'),
+ updateMessage: vi.fn(),
+ };
- await vi.waitFor(() => expect(mocks.answerQuestion).toHaveBeenCalledOnce());
- expect(mocks.createActivity).toHaveBeenCalledWith({
- slack: expect.anything(),
- workspaceId: 'T1',
- channel: 'C1',
- threadTs: '100.000',
- title: 'Investigate Slack agent status',
- resolveTitle: expect.any(Function),
- });
- expect(mocks.findSession).toHaveBeenCalledWith({
- provider: 'slack',
- workspaceId: 'T1',
- channelId: 'C1',
- messageId: '101.000',
- userId: 'user-1',
- });
- expect(mocks.answerQuestion).toHaveBeenCalledWith(
- expect.objectContaining({
- currentMessageId: 'slack-reaction:102.000',
- senderExternalId: 'UALICE',
- senderDisplayName: '@alice',
- input: {
- type: 'reaction',
- externalInput: expect.objectContaining({
+ await expect(
+ maybeRouteFastAgentReaction({
+ context: {
+ teamId: 'T1',
+ slackInstallation: { botUserId: 'UROOMOTE' },
+ slack,
+ } as never,
+ event: {
type: 'reaction_added',
- provider: 'slack',
- reactions: [{ name: 'sparkling_heart' }],
+ user: 'UALICE',
+ reaction: 'sparkling_heart',
+ item: { type: 'message', channel: 'C1', ts: '101.000' },
+ event_ts: '102.000',
+ },
+ }),
+ ).resolves.toBe(true);
+
+ await vi.waitFor(() =>
+ expect(mocks.answerQuestion).toHaveBeenCalledOnce(),
+ );
+ expect(mocks.connectionEnabled).toHaveBeenCalledWith();
+ expect(mocks.answerQuestion).toHaveBeenCalledWith(
+ expect.objectContaining({
+ userId: 'user-1',
+ senderExternalId: 'UALICE',
+ adapter: expect.objectContaining({
+ ...mocks.connectionAdapter,
+ sourceControlConnectionEnabled: enabled,
}),
- },
- question: expect.stringContaining(
- 'React to this message with your favorite emoji.',
- ),
- }),
- );
- expect(mocks.postThreadMessage).not.toHaveBeenCalled();
- });
+ }),
+ );
+ expect(mocks.createActivity).toHaveBeenCalledWith({
+ slack: expect.anything(),
+ workspaceId: 'T1',
+ channel: 'C1',
+ threadTs: '100.000',
+ title: 'Investigate Slack agent status',
+ resolveTitle: expect.any(Function),
+ });
+ expect(mocks.findSession).toHaveBeenCalledWith({
+ provider: 'slack',
+ workspaceId: 'T1',
+ channelId: 'C1',
+ messageId: '101.000',
+ userId: 'user-1',
+ });
+ expect(mocks.answerQuestion).toHaveBeenCalledWith(
+ expect.objectContaining({
+ currentMessageId: 'slack-reaction:102.000',
+ senderExternalId: 'UALICE',
+ senderDisplayName: '@alice',
+ input: {
+ type: 'reaction',
+ externalInput: expect.objectContaining({
+ type: 'reaction_added',
+ provider: 'slack',
+ reactions: [{ name: 'sparkling_heart' }],
+ }),
+ },
+ question: expect.stringContaining(
+ 'React to this message with your favorite emoji.',
+ ),
+ }),
+ );
+ expect(mocks.postThreadMessage).not.toHaveBeenCalled();
+ },
+ );
it.each([
['', true],
diff --git a/apps/api/src/handlers/slack/events/fast-agent-reaction.ts b/apps/api/src/handlers/slack/events/fast-agent-reaction.ts
index e7efdd1692..ea15939e0a 100644
--- a/apps/api/src/handlers/slack/events/fast-agent-reaction.ts
+++ b/apps/api/src/handlers/slack/events/fast-agent-reaction.ts
@@ -15,6 +15,8 @@ import {
} from '@roomote/communication';
import {
buildFastAgentArtifactCreator,
+ createSourceControlConnectionAdapter,
+ isSourceControlConnectionEnabled,
findFastAgentSessionForProviderMessage,
persistFastAgentInlineHumanTurn,
recordFastAgentConversationMessageBestEffort,
@@ -147,6 +149,9 @@ async function processFastAgentReaction(params: {
...(durableTurn ? { durableAdmission: { eventId: durableTurn.id } } : {}),
...(durableTurn?.resumed ? { resumedAfterInterruption: true } : {}),
adapter: {
+ ...createSourceControlConnectionAdapter(),
+ sourceControlConnectionEnabled:
+ await isSourceControlConnectionEnabled(),
...(durableTurn
? {
requestDurableResume: () =>
diff --git a/apps/api/src/handlers/slack/events/fast-agent.ts b/apps/api/src/handlers/slack/events/fast-agent.ts
index ac72f36519..8df3f51d1b 100644
--- a/apps/api/src/handlers/slack/events/fast-agent.ts
+++ b/apps/api/src/handlers/slack/events/fast-agent.ts
@@ -25,6 +25,8 @@ import { appendAttachmentTextsToPromptText } from '@roomote/cloud-agents';
import { buildDataVisualizationBlocks } from '@roomote/types';
import {
admitFastAgentHumanFollowUp,
+ createSourceControlConnectionAdapter,
+ isSourceControlConnectionEnabled,
createFastAgentConversationArtifact,
persistFastAgentInlineHumanTurn,
wakeFastAgentParentEventAt,
@@ -304,6 +306,9 @@ export async function processFastAgentMessage(params: {
!directedAtRoomote,
...(roomoteSlackUserId ? { slackRoomoteUserId: roomoteSlackUserId } : {}),
adapter: {
+ ...createSourceControlConnectionAdapter(),
+ sourceControlConnectionEnabled:
+ await isSourceControlConnectionEnabled(),
createArtifact: (artifact) =>
createFastAgentConversationArtifact({
fastConversationId: session.id,
diff --git a/apps/docs/self-hosting.mdx b/apps/docs/self-hosting.mdx
index 8809652286..184f883cb1 100644
--- a/apps/docs/self-hosting.mdx
+++ b/apps/docs/self-hosting.mdx
@@ -211,6 +211,13 @@ setup; ask Roomote to retry that item. Automation recommendations are analyzed
after repository sync and appear only after at least one starter task launches.
They remain optional.
+On deployments with optional source-control setup enabled, inference and a
+sandbox provider are sufficient to complete setup. Source control can be
+connected later in **Settings > Source control** when you need repository work.
+Repository starter tasks and repository recommendations still require connected,
+synchronized repositories. The default setup flow continues to require source
+control; disabling the optional flow does not reopen already completed setup.
+
After setup, run a small task that uses the first environment if you did not
start one from the starter list. A healthy deployment should let you:
diff --git a/apps/web/src/app/(centered)/github/callback/GitHubCallbackPage.tsx b/apps/web/src/app/(centered)/github/callback/GitHubCallbackPage.tsx
index 25e665a21d..fe371f5550 100644
--- a/apps/web/src/app/(centered)/github/callback/GitHubCallbackPage.tsx
+++ b/apps/web/src/app/(centered)/github/callback/GitHubCallbackPage.tsx
@@ -3,6 +3,9 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
import { z } from 'zod';
+import { useMutation } from '@tanstack/react-query';
+import { useTRPC } from '@/trpc/client';
+import { normalizeSourceControlOAuthReturnTarget } from '@/lib/server/source-control-oauth-redirect';
import {
CircleX,
CircleCheck,
@@ -55,6 +58,27 @@ import { GitHubInstallRequestPending } from '@/components/github/GitHubInstallRe
export default function Page() {
const router = useRouter();
const params = useSearchParams();
+ const trpc = useTRPC();
+ const connectionCallback = useMutation(
+ trpc.sourceControl.githubConnectionCallback.mutationOptions({
+ onSuccess: (result) => {
+ setIsLoading(false);
+ if (!result.success) {
+ setError(result.error);
+ return;
+ }
+ if ('installUrl' in result && result.installUrl)
+ window.location.assign(result.installUrl);
+ else router.push(result.returnTarget);
+ },
+ onError: () => {
+ setIsLoading(false);
+ setError(
+ 'Connection attempt is no longer available. Return to the Session to try again.',
+ );
+ },
+ }),
+ );
const { authStatus, isSignedIn } = useUser();
const setupBootstrapOpen = useSetupBootstrapOpen();
@@ -84,20 +108,16 @@ export default function Page() {
const redirect = decodedState?.redirect;
- const isValidRedirect =
- redirect &&
- redirect.startsWith('/') &&
- !redirect.startsWith('//') &&
- !redirect.includes('://');
+ const isValidRedirect = normalizeSourceControlOAuthReturnTarget(redirect);
const setupCompletedRedirect =
- isValidRedirect && redirect.startsWith('/setup') && !setupBootstrapOpen
+ isValidRedirect &&
+ isValidRedirect.startsWith('/setup') &&
+ !setupBootstrapOpen
? '/settings/source-control'
: null;
- router.push(
- setupCompletedRedirect ?? (isValidRedirect ? redirect : '/settings'),
- );
+ router.push(setupCompletedRedirect ?? isValidRedirect ?? '/settings');
}, [params, router, setupBootstrapOpen]);
const finishAuthentication = useFinishAuthenticateGitHubAccount({
@@ -192,6 +212,19 @@ export default function Page() {
if (error) {
setIsLoading(false);
setError(error);
+ } else if (decodedState?.connectionState) {
+ connectionCallback.mutate({
+ state: decodedState.connectionState,
+ action: isAppManifestFlow
+ ? 'manifest'
+ : setupAction === 'request'
+ ? 'request'
+ : 'install',
+ ...(code ? { code } : {}),
+ ...(params.get('installation_id')
+ ? { installationId: Number(params.get('installation_id')) }
+ : {}),
+ });
} else if (isAppManifestFlow) {
if (!code) {
setIsLoading(false);
@@ -244,6 +277,7 @@ export default function Page() {
isSignedIn,
params,
syncInstall,
+ connectionCallback,
]);
return (
diff --git a/apps/web/src/app/(centered)/github/callback/page.client.test.tsx b/apps/web/src/app/(centered)/github/callback/page.client.test.tsx
index 71787e592b..8bed1140ca 100644
--- a/apps/web/src/app/(centered)/github/callback/page.client.test.tsx
+++ b/apps/web/src/app/(centered)/github/callback/page.client.test.tsx
@@ -12,6 +12,20 @@ const mockSyncInstallMutate = vi.fn();
const mockFinishInstallMutate = vi.fn();
const mockFinishAppManifestMutate = vi.fn();
const mockFinishAuthenticationMutate = vi.fn();
+const mockConnectionCallback = vi.fn();
+
+vi.mock('@/trpc/client', () => ({
+ useTRPC: () => ({
+ sourceControl: {
+ githubConnectionCallback: {
+ mutationOptions: (options: object) => options,
+ },
+ },
+ }),
+}));
+vi.mock('@tanstack/react-query', () => ({
+ useMutation: () => ({ mutate: mockConnectionCallback }),
+}));
let searchParams = new URLSearchParams();
let userState:
@@ -59,6 +73,21 @@ vi.mock('@/hooks/github', () => ({
}));
describe('GitHub callback page', () => {
+ it('routes bound Session callbacks through server verification, not the ordinary sync action', async () => {
+ searchParams = new URLSearchParams({
+ installation_id: '123',
+ state: encodeRecord({ connectionState: 'sc.signed.state' }),
+ });
+ render();
+ await waitFor(() =>
+ expect(mockConnectionCallback).toHaveBeenCalledWith({
+ state: 'sc.signed.state',
+ action: 'install',
+ installationId: 123,
+ }),
+ );
+ expect(mockSyncInstallMutate).not.toHaveBeenCalled();
+ });
beforeEach(() => {
vi.clearAllMocks();
searchParams = new URLSearchParams();
diff --git a/apps/web/src/app/(sandbox)/SandboxShell.tsx b/apps/web/src/app/(sandbox)/SandboxShell.tsx
index bc6337ec31..aadce4f283 100644
--- a/apps/web/src/app/(sandbox)/SandboxShell.tsx
+++ b/apps/web/src/app/(sandbox)/SandboxShell.tsx
@@ -2,7 +2,7 @@
import Link from 'next/link';
import { useRouter } from 'next/navigation';
-import { usePathname } from 'next/navigation';
+import { usePathname, useSearchParams } from 'next/navigation';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useQuery } from '@tanstack/react-query';
@@ -29,6 +29,9 @@ export function SandboxShell({
const router = useRouter();
const { authStatus, isSignedIn, user } = useUser();
const pathname = usePathname();
+ const searchParams = useSearchParams();
+ const isConnectionReturn =
+ pathname.startsWith('/sessions/') && searchParams.has('connectionRequest');
const shouldRedirectToSignIn = requireAuth && authStatus === 'signed-out';
useRedirectToSignIn(shouldRedirectToSignIn);
@@ -88,6 +91,9 @@ export function SandboxShell({
);
useEffect(() => {
+ // A handoff can be opened by an admin while setup changes in another tab.
+ // The request view still performs its own live authorization.
+ if (isConnectionReturn) return;
// Wait for the setup-session lookup before routing. Otherwise a direct
// visit to the in-progress setup session can briefly see no session ID
// and be redirected to /setup before the lookup resolves.
@@ -97,6 +103,7 @@ export function SandboxShell({
router.replace('/onboarding');
}
}, [
+ isConnectionReturn,
isAllowedSetupSession,
isOnboardingError,
isSetupSessionLoading,
diff --git a/apps/web/src/app/(sandbox)/sessions/[sessionId]/SessionConnectionRequest.client.test.tsx b/apps/web/src/app/(sandbox)/sessions/[sessionId]/SessionConnectionRequest.client.test.tsx
new file mode 100644
index 0000000000..c2cbb5833e
--- /dev/null
+++ b/apps/web/src/app/(sandbox)/sessions/[sessionId]/SessionConnectionRequest.client.test.tsx
@@ -0,0 +1,156 @@
+import { fireEvent, render, screen } from '@testing-library/react';
+import { SessionConnectionRequest } from './SessionConnectionRequest';
+
+const state = vi.hoisted(() => ({
+ data: null as null | {
+ id: string;
+ url: string;
+ status: string;
+ reason: string;
+ provider: string;
+ repositoryFullName: string;
+ canConnect: boolean;
+ canCheck: boolean;
+ canCancel: boolean;
+ enabled?: boolean;
+ },
+ check: vi.fn(),
+ cancel: vi.fn(),
+ search: '',
+}));
+vi.mock('next/navigation', () => ({
+ useSearchParams: () => new URLSearchParams(state.search),
+}));
+vi.mock('@/trpc/client', () => ({
+ useTRPC: () => ({
+ sourceControl: {
+ connectionRequest: {
+ queryOptions: (input: object) => ({
+ queryKey: ['connectionRequest', input],
+ }),
+ },
+ checkConnectionRequest: { mutationOptions: () => ({ kind: 'check' }) },
+ cancelConnectionRequest: { mutationOptions: () => ({ kind: 'cancel' }) },
+ },
+ }),
+}));
+vi.mock('@tanstack/react-query', () => ({
+ useQuery: () => ({
+ data: state.data,
+ refetch: vi.fn(),
+ isPending: false,
+ isError: false,
+ }),
+ useMutation: ({ kind }: { kind: string }) => ({
+ mutate: kind === 'check' ? state.check : state.cancel,
+ isPending: false,
+ }),
+}));
+vi.mock('@/components/settings/SourceControl', () => ({
+ SourceControl: ({
+ connectionRequestId,
+ connectionReturnTarget,
+ }: {
+ connectionRequestId: string;
+ connectionReturnTarget: string;
+ }) => (
+
+ {connectionRequestId} {connectionReturnTarget}
+
+ ),
+}));
+
+describe('Session connection request', () => {
+ it('explains rollout disablement without offering a connection or check', () => {
+ Object.assign(state.data!, {
+ enabled: false,
+ canConnect: false,
+ canCheck: false,
+ });
+ render();
+ expect(
+ screen.getByText(/Session connection requests are disabled/),
+ ).toBeInTheDocument();
+ expect(
+ screen.queryByRole('button', { name: 'Configure source control' }),
+ ).not.toBeInTheDocument();
+ expect(
+ screen.queryByRole('button', { name: 'Check status' }),
+ ).not.toBeInTheDocument();
+ expect(
+ screen.getByRole('button', { name: 'Cancel request' }),
+ ).toBeInTheDocument();
+ });
+ beforeEach(() => {
+ vi.clearAllMocks();
+ state.search = 'connectionRequest=request-id&gitlab=connected';
+ state.data = {
+ id: 'request-id',
+ url: '/sessions/session-id?connectionRequest=request-id',
+ status: 'pending',
+ reason: 'repository_unavailable',
+ provider: 'gitlab',
+ repositoryFullName: 'org/exact-target',
+ canConnect: true,
+ canCheck: true,
+ canCancel: true,
+ };
+ });
+ it('does not authorize or resume from connected query parameters; checks only on explicit input', () => {
+ render();
+ expect(state.check).not.toHaveBeenCalled();
+ expect(screen.getByText('org/exact-target')).toBeInTheDocument();
+ fireEvent.click(screen.getByRole('button', { name: 'Check status' }));
+ expect(state.check).toHaveBeenCalledWith({
+ sessionId: 'session-id',
+ requestId: 'request-id',
+ });
+ fireEvent.click(screen.getByRole('button', { name: 'Cancel request' }));
+ expect(state.cancel).toHaveBeenCalledWith({
+ sessionId: 'session-id',
+ requestId: 'request-id',
+ });
+ });
+ it('passes the stored request and canonical return into the trusted provider UI', () => {
+ render();
+ fireEvent.click(
+ screen.getByRole('button', { name: 'Configure source control' }),
+ );
+ expect(screen.getByTestId('trusted-config')).toHaveTextContent(
+ 'request-id /sessions/session-id?connectionRequest=request-id',
+ );
+ });
+ it('does not expose admin configuration or cancellation to a transcript reader', () => {
+ Object.assign(state.data!, {
+ canConnect: false,
+ canCancel: false,
+ canCheck: false,
+ });
+ render();
+ expect(screen.queryByRole('button')).not.toBeInTheDocument();
+ expect(
+ screen.getByText(/deployment admin can connect/),
+ ).toBeInTheDocument();
+ });
+ it('reports terminal state without promising continuation', () => {
+ Object.assign(state.data!, {
+ status: 'superseded',
+ canConnect: false,
+ canCancel: false,
+ canCheck: false,
+ });
+ render();
+ expect(screen.getByRole('status')).toHaveTextContent(
+ 'Connection request superseded.',
+ );
+ expect(screen.queryByRole('button')).not.toBeInTheDocument();
+ });
+ it('reports unavailable request without rendering controls', () => {
+ state.data = null;
+ render();
+ expect(screen.getByRole('status')).toHaveTextContent(
+ 'not available in this Session',
+ );
+ expect(screen.queryByRole('button')).not.toBeInTheDocument();
+ });
+});
diff --git a/apps/web/src/app/(sandbox)/sessions/[sessionId]/SessionConnectionRequest.tsx b/apps/web/src/app/(sandbox)/sessions/[sessionId]/SessionConnectionRequest.tsx
new file mode 100644
index 0000000000..312f1dd18f
--- /dev/null
+++ b/apps/web/src/app/(sandbox)/sessions/[sessionId]/SessionConnectionRequest.tsx
@@ -0,0 +1,180 @@
+'use client';
+
+import { useState } from 'react';
+import { useSearchParams } from 'next/navigation';
+import { useMutation, useQuery } from '@tanstack/react-query';
+import { toast } from 'sonner';
+import { useTRPC } from '@/trpc/client';
+import { SourceControl } from '@/components/settings/SourceControl';
+import { GitHubInstallRequestPending } from '@/components/github/GitHubInstallRequestPending';
+import {
+ Button,
+ Card,
+ CardContent,
+ CardHeader,
+ CardTitle,
+ CardFooter,
+ Dialog,
+ DialogContent,
+ DialogHeader,
+ DialogTitle,
+ DialogDescription,
+ Skeleton,
+} from '@/components/system';
+
+const reasons: Record = {
+ not_connected: 'Source control is not connected.',
+ approval_pending: 'The GitHub installation is awaiting approval.',
+ sync_pending: 'Repository synchronization is pending.',
+ sync_failed:
+ 'Repository synchronization failed. Refresh the provider and check again.',
+ repository_unavailable:
+ 'The requested repository is not available. Check the provider grants and refresh its repositories.',
+ discovery_unavailable:
+ 'Source-control tools could not be reached. Check status to retry discovery; reconnecting may not be necessary.',
+ forbidden: 'The requester no longer has access.',
+ ready: 'Access was verified. The original Session continuation is queued.',
+};
+
+export function SessionConnectionRequest({ sessionId }: { sessionId: string }) {
+ const trpc = useTRPC();
+ const search = useSearchParams();
+ const requestId = search.get('connectionRequest') ?? undefined;
+ const [open, setOpen] = useState(false);
+ const query = useQuery({
+ ...trpc.sourceControl.connectionRequest.queryOptions({
+ sessionId,
+ requestId,
+ }),
+ refetchInterval: 5000,
+ });
+ const options = {
+ onSuccess: () => {
+ void query.refetch();
+ },
+ onError: () => toast.error('Unable to update this connection request.'),
+ };
+ const check = useMutation(
+ trpc.sourceControl.checkConnectionRequest.mutationOptions(options),
+ );
+ const cancel = useMutation(
+ trpc.sourceControl.cancelConnectionRequest.mutationOptions(options),
+ );
+ if (query.isError)
+ return Unable to load the connection request.
;
+ if (query.isPending && requestId)
+ return (
+
+
+
+
+
+
+
+
+
+
+ );
+ const request = query.data;
+ if (!request)
+ return requestId && !query.isPending ? (
+
+ This connection request is not available in this Session.
+
+ ) : null;
+ const pending = request.status === 'pending';
+ return (
+
+
+ Source-control access
+
+
+ {request.enabled === false ? (
+
+ Session connection requests are disabled. Source control can still
+ be configured in settings; this request will not continue
+ automatically.
+
+ ) : null}
+ {request.provider && search.get(request.provider) === 'error' ? (
+
+ Authorization or synchronization failed. Check the provider
+ configuration and try again.
+
+ ) : null}
+
+ {request.repositoryFullName ??
+ request.environmentId ??
+ request.provider}
+
+
+ {pending
+ ? (reasons[request.reason] ?? 'Access is not ready.')
+ : request.status === 'ready'
+ ? reasons.ready
+ : `Connection request ${request.status}.`}
+
+ {pending && request.enabled !== false && !request.canConnect ? (
+
+ A deployment admin can connect source control using this Session
+ link.
+
+ ) : null}
+
+ Cancelling this request does not remove the deployment connection.
+
+ {pending &&
+ request.canConnect &&
+ request.reason === 'approval_pending' ? (
+ {
+ void query.refetch();
+ }}
+ />
+ ) : null}
+
+
+ {request.canConnect ? (
+
+ ) : null}
+ {request.canCheck ? (
+
+ ) : null}
+ {request.canCancel ? (
+
+ ) : null}
+
+ {request.canConnect ? (
+
+ ) : null}
+
+ );
+}
diff --git a/apps/web/src/app/(sandbox)/sessions/[sessionId]/page.test.tsx b/apps/web/src/app/(sandbox)/sessions/[sessionId]/page.test.tsx
index 0d83c3fad8..e3a1ef84d5 100644
--- a/apps/web/src/app/(sandbox)/sessions/[sessionId]/page.test.tsx
+++ b/apps/web/src/app/(sandbox)/sessions/[sessionId]/page.test.tsx
@@ -44,6 +44,9 @@ vi.mock('@/lib/server/auth-context', () => ({ authorize: authorizeMock }));
vi.mock('next/navigation', () => ({
useRouter: () => ({ replace: vi.fn() }),
useSearchParams: () => new URLSearchParams(),
+ redirect: (target: string) => {
+ throw new Error(`NEXT_REDIRECT:${target}`);
+ },
notFound: () => {
throw new Error('NEXT_NOT_FOUND');
},
@@ -108,6 +111,20 @@ vi.mock('@/components/sessions/SessionViewers', () => ({
import SessionDetailPage, { generateMetadata } from './page';
+it('preserves a connection request when an unauthenticated visitor opens its Session', async () => {
+ authorizeMock.mockResolvedValue({ success: false });
+ const sessionId = '11111111-1111-4111-8111-111111111111';
+ const connectionRequest = '22222222-2222-4222-8222-222222222222';
+ await expect(
+ SessionDetailPage({
+ params: Promise.resolve({ sessionId }),
+ searchParams: Promise.resolve({ connectionRequest }),
+ }),
+ ).rejects.toThrow(
+ `NEXT_REDIRECT:/sign-in?${new URLSearchParams({ redirect_url: `/sessions/${sessionId}?connectionRequest=${connectionRequest}` })}`,
+ );
+});
+
describe('Session detail page', () => {
beforeEach(() => {
vi.clearAllMocks();
@@ -367,9 +384,7 @@ describe('Session detail page', () => {
expect(transcriptMock.mock.calls[0]?.[0]).not.toHaveProperty(
'initialConversationResponding',
);
- expect(transcriptMock.mock.calls[0]?.[0]).not.toHaveProperty(
- 'timelineExtras',
- );
+ expect(transcriptMock.mock.calls[0]?.[0]).toHaveProperty('timelineExtras');
expect(transcriptMock.mock.calls[0]?.[0]).toHaveProperty('headerExtras');
const headerExtras = transcriptMock.mock.calls[0]?.[0].headerExtras;
expect(isValidElement(headerExtras)).toBe(true);
diff --git a/apps/web/src/app/(sandbox)/sessions/[sessionId]/page.tsx b/apps/web/src/app/(sandbox)/sessions/[sessionId]/page.tsx
index 7a50055c66..ef35e07628 100644
--- a/apps/web/src/app/(sandbox)/sessions/[sessionId]/page.tsx
+++ b/apps/web/src/app/(sandbox)/sessions/[sessionId]/page.tsx
@@ -1,6 +1,6 @@
import { cache } from 'react';
import type { Metadata } from 'next';
-import { notFound } from 'next/navigation';
+import { notFound, redirect } from 'next/navigation';
import { z } from 'zod';
import { resolveEffectiveModelRuntimeEnv } from '@roomote/db/server';
@@ -30,6 +30,7 @@ import {
type SessionInfo,
} from './SessionWorkspace';
import { SessionReadTracker } from './SessionReadTracker';
+import { SessionConnectionRequest } from './SessionConnectionRequest';
import { SetupAutomationRecommendationsCard } from './setup/SetupAutomationRecommendationsCard';
import { SetupSandboxCard } from './setup/SetupSandboxCard';
import { SetupSessionSourceControlCard } from './setup/SetupSourceControlCard';
@@ -38,46 +39,57 @@ import {
SESSION_HEADER_TITLE_CLASS_NAME,
} from './session-header-layout';
-const getSessionPageData = cache(async (sessionId: string) => {
- const authorizedUser = await authorize();
- if (!authorizedUser.success) {
- notFound();
- }
- // Both lookup columns are uuid; a garbage route param would otherwise throw
- // 22P02 in Postgres instead of 404ing.
- if (!z.string().uuid().safeParse(sessionId).success) {
- notFound();
- }
+const getSessionPageData = cache(
+ async (sessionId: string, connectionRequest?: string) => {
+ const authorizedUser = await authorize();
+ if (!authorizedUser.success) {
+ const target = `/sessions/${encodeURIComponent(sessionId)}${connectionRequest ? `?connectionRequest=${encodeURIComponent(connectionRequest)}` : ''}`;
+ redirect(`/sign-in?${new URLSearchParams({ redirect_url: target })}`);
+ }
+ // Both lookup columns are uuid; a garbage route param would otherwise throw
+ // 22P02 in Postgres instead of 404ing.
+ if (!z.string().uuid().safeParse(sessionId).success) {
+ notFound();
+ }
- // Old links may carry a fast-conversation id whose session row hasn't been
- // backfilled yet; getSessionByIdCommand falls back by fastConversationId,
- // and the fast lookup below covers a conversation with no session row.
- const unifiedSession = await getSessionByIdCommand(authorizedUser, sessionId);
- const session = unifiedSession?.fastConversationId
- ? await getFastSessionById(
- authorizedUser,
- unifiedSession.fastConversationId,
- )
- : unifiedSession
- ? null
- : await getFastSessionById(authorizedUser, sessionId);
+ // Old links may carry a fast-conversation id whose session row hasn't been
+ // backfilled yet; getSessionByIdCommand falls back by fastConversationId,
+ // and the fast lookup below covers a conversation with no session row.
+ const unifiedSession = await getSessionByIdCommand(
+ authorizedUser,
+ sessionId,
+ );
+ const session = unifiedSession?.fastConversationId
+ ? await getFastSessionById(
+ authorizedUser,
+ unifiedSession.fastConversationId,
+ )
+ : unifiedSession
+ ? null
+ : await getFastSessionById(authorizedUser, sessionId);
- if (!unifiedSession && !session) {
- notFound();
- }
+ if (!unifiedSession && !session) {
+ notFound();
+ }
- return { authorizedUser, unifiedSession, session };
-});
+ return { authorizedUser, unifiedSession, session };
+ },
+);
type SessionDetailPageProps = {
params: Promise<{ sessionId: string }>;
+ searchParams?: Promise<{ connectionRequest?: string }>;
};
export async function generateMetadata({
params,
+ searchParams,
}: SessionDetailPageProps): Promise {
const { sessionId } = await params;
- const { unifiedSession, session } = await getSessionPageData(sessionId);
+ const { unifiedSession, session } = await getSessionPageData(
+ sessionId,
+ (await searchParams)?.connectionRequest,
+ );
const initialUserMessage = session?.messages.find(
(message) => message.role === 'user',
);
@@ -93,10 +105,13 @@ export async function generateMetadata({
export default async function SessionDetailPage({
params,
+ searchParams,
}: SessionDetailPageProps) {
const { sessionId } = await params;
- const { authorizedUser, unifiedSession, session } =
- await getSessionPageData(sessionId);
+ const { authorizedUser, unifiedSession, session } = await getSessionPageData(
+ sessionId,
+ (await searchParams)?.connectionRequest,
+ );
// The chip's "default" must reflect what Fast actually runs with: the
// deployment's orchestration model, not the task launch default.
const modelEnv: Record =
@@ -180,9 +195,12 @@ export default async function SessionDetailPage({
headerActions={
}
- {...(isSetupSession
- ? { timelineExtras: setupTimelineExtras }
- : {})}
+ timelineExtras={
+ <>
+
+ {setupTimelineExtras}
+ >
+ }
/>
@@ -262,6 +280,7 @@ export default async function SessionDetailPage({
sessionReasoningEffort={session.reasoningEffort}
defaultModelId={defaultModelId}
defaultReasoningEffort={defaultReasoningEffort}
+ timelineExtras={}
{...(session.userId
? {
owner: {
diff --git a/apps/web/src/app/(sandbox)/sessions/[sessionId]/setup/SetupSourceControlCard.client.test.tsx b/apps/web/src/app/(sandbox)/sessions/[sessionId]/setup/SetupSourceControlCard.client.test.tsx
new file mode 100644
index 0000000000..f52e89057d
--- /dev/null
+++ b/apps/web/src/app/(sandbox)/sessions/[sessionId]/setup/SetupSourceControlCard.client.test.tsx
@@ -0,0 +1,188 @@
+import { fireEvent, render, screen, waitFor } from '@testing-library/react';
+import type { ReactNode } from 'react';
+
+const { state } = vi.hoisted(() => ({
+ state: {
+ optionalSourceControlEnabled: true,
+ repositoryCount: 0,
+ sourceControlSkipped: false,
+ skipFails: false,
+ invalidateQueries: vi.fn(),
+ },
+}));
+
+vi.mock('next/navigation', () => ({
+ useRouter: () => ({ refresh: vi.fn() }),
+ useSearchParams: () => new URLSearchParams(),
+}));
+vi.mock('@/trpc/client', () => ({
+ useTRPC: () => ({
+ setupNew: {
+ status: { queryOptions: () => ({}), queryKey: () => ['setup-status'] },
+ saveSourceControlProviderChoice: { mutationOptions: () => ({}) },
+ },
+ setup: {
+ skipSourceControl: {
+ mutationOptions: (options: object) => ({ ...options, skip: true }),
+ },
+ },
+ }),
+}));
+vi.mock('@tanstack/react-query', () => ({
+ useQuery: () => ({
+ data: {
+ optionalSourceControlEnabled: state.optionalSourceControlEnabled,
+ sourceControlSkipped: state.sourceControlSkipped,
+ setupNewState: { sourceControlProvider: null },
+ sourceControlSetup: {
+ preselectedProvider: 'github',
+ providers: [
+ {
+ provider: 'github',
+ label: 'GitHub',
+ connected: state.repositoryCount > 0,
+ repositoryCount: state.repositoryCount,
+ },
+ ],
+ },
+ },
+ }),
+ useMutation: (options: {
+ skip?: boolean;
+ onSuccess?: () => void;
+ onError?: (error: Error) => void;
+ }) => ({
+ mutate: () => {
+ if (!options.skip) return;
+ if (state.skipFails) {
+ options.onError?.(new Error('Skip failed'));
+ } else {
+ state.sourceControlSkipped = true;
+ options.onSuccess?.();
+ }
+ },
+ isPending: false,
+ }),
+ useQueryClient: () => ({ invalidateQueries: state.invalidateQueries }),
+}));
+vi.mock('./source-control-card-stage', () => ({
+ getInitialSourceControlCardStage: () => 'provider',
+}));
+vi.mock('./SourceControlProviderPicker', () => ({
+ SourceControlProviderPicker: () => Provider picker
,
+}));
+vi.mock('./SourceControlConfiguration', () => ({
+ SourceControlConfiguration: () => null,
+}));
+vi.mock('./SourceControlConnection', () => ({
+ SourceControlConnection: () => null,
+}));
+vi.mock('./SetupSessionActionCard', () => ({
+ SetupSessionActionCardActions: ({ children }: { children: ReactNode }) => (
+ {children}
+ ),
+ SetupSessionActionCard: ({
+ title,
+ intro,
+ children,
+ }: {
+ title: string;
+ intro: string;
+ children: ReactNode;
+ }) => (
+
+ {title}
+ {intro}
+ {children}
+
+ ),
+}));
+
+import { SetupSessionSourceControlCard } from './SetupSourceControlCard';
+
+describe('SetupSessionSourceControlCard', () => {
+ beforeEach(() => {
+ state.optionalSourceControlEnabled = true;
+ state.repositoryCount = 0;
+ state.sourceControlSkipped = false;
+ state.skipFails = false;
+ state.invalidateQueries.mockReset();
+ });
+
+ it('offers to skip source-control setup by default', () => {
+ render();
+ expect(
+ screen.getByText('Where do you keep your code?'),
+ ).toBeInTheDocument();
+ expect(
+ screen.getByText(
+ 'Connect to your source control provider for me to work on your code. You can also do that later in Settings → Source control.',
+ ),
+ ).toBeInTheDocument();
+ expect(screen.getByText('Provider picker')).toBeInTheDocument();
+ expect(
+ screen.getByRole('button', { name: 'Skip for now' }),
+ ).toBeInTheDocument();
+ });
+
+ it('hides the card when source-control setup is skipped', () => {
+ render();
+ fireEvent.click(screen.getByRole('button', { name: 'Skip for now' }));
+ expect(screen.queryByText('Provider picker')).not.toBeInTheDocument();
+ });
+
+ it('keeps the card hidden after a successful skip and a fresh mount', async () => {
+ const view = render(
+ ,
+ );
+ fireEvent.click(screen.getByRole('button', { name: 'Skip for now' }));
+ await waitFor(() =>
+ expect(state.invalidateQueries).toHaveBeenCalledWith({
+ queryKey: ['setup-status'],
+ }),
+ );
+ view.unmount();
+ render();
+ expect(screen.queryByText('Provider picker')).not.toBeInTheDocument();
+ });
+
+ it('does not show the card on a later visit with persisted skipped status', () => {
+ state.sourceControlSkipped = true;
+ render();
+ expect(screen.queryByText('Provider picker')).not.toBeInTheDocument();
+ });
+
+ it('keeps the card available when saving the skip fails', () => {
+ state.skipFails = true;
+ render();
+ fireEvent.click(screen.getByRole('button', { name: 'Skip for now' }));
+ expect(screen.getByText('Provider picker')).toBeInTheDocument();
+ });
+
+ it('keeps required setup visible when optional source control is disabled', () => {
+ state.optionalSourceControlEnabled = false;
+ state.sourceControlSkipped = true;
+ render();
+ expect(screen.getByText('Provider picker')).toBeInTheDocument();
+ expect(
+ screen.queryByRole('button', { name: 'Skip for now' }),
+ ).not.toBeInTheDocument();
+ });
+
+ it('offers connection as optional without claiming repository access', () => {
+ render();
+ expect(
+ screen.getByText(
+ 'Connect to your source control provider for me to work on your code. You can also do that later in Settings → Source control.',
+ ),
+ ).toBeInTheDocument();
+ expect(screen.getByText('Provider picker')).toBeInTheDocument();
+ });
+
+ it('hides the action once a connected provider has synchronized repositories', () => {
+ state.optionalSourceControlEnabled = true;
+ state.repositoryCount = 1;
+ render();
+ expect(screen.queryByText('Provider picker')).not.toBeInTheDocument();
+ });
+});
diff --git a/apps/web/src/app/(sandbox)/sessions/[sessionId]/setup/SetupSourceControlCard.tsx b/apps/web/src/app/(sandbox)/sessions/[sessionId]/setup/SetupSourceControlCard.tsx
index c40a03108f..c4e5013c32 100644
--- a/apps/web/src/app/(sandbox)/sessions/[sessionId]/setup/SetupSourceControlCard.tsx
+++ b/apps/web/src/app/(sandbox)/sessions/[sessionId]/setup/SetupSourceControlCard.tsx
@@ -1,7 +1,7 @@
'use client';
import { useState } from 'react';
-import { useSearchParams } from 'next/navigation';
+import { useRouter, useSearchParams } from 'next/navigation';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner';
import type {
@@ -22,7 +22,10 @@ import {
GitBranch,
} from '@/components/system';
-import { SetupSessionActionCard } from './SetupSessionActionCard';
+import {
+ SetupSessionActionCard,
+ SetupSessionActionCardActions,
+} from './SetupSessionActionCard';
import { SourceControlConfiguration } from './SourceControlConfiguration';
import { SourceControlConnection } from './SourceControlConnection';
import { SourceControlProviderPicker } from './SourceControlProviderPicker';
@@ -42,13 +45,16 @@ function SetupSessionSourceControlCardBody({
sourceControlSetup,
explicitlySelectedProvider,
sessionId,
+ optionalSourceControlEnabled,
}: {
sourceControlSetup: SetupSourceControlStatus;
explicitlySelectedProvider: SourceControlProvider | null;
sessionId: string;
+ optionalSourceControlEnabled: boolean;
}) {
const trpc = useTRPC();
const queryClient = useQueryClient();
+ const router = useRouter();
const searchParams = useSearchParams();
const [stage, setStage] = useState(() =>
getInitialSourceControlCardStage(
@@ -58,6 +64,19 @@ function SetupSessionSourceControlCardBody({
),
);
const [configOpen, setConfigOpen] = useState(false);
+ const [dismissed, setDismissed] = useState(false);
+ const skipSourceControl = useMutation(
+ trpc.setup.skipSourceControl.mutationOptions({
+ onSuccess: () => {
+ setDismissed(true);
+ void queryClient.invalidateQueries({
+ queryKey: trpc.setupNew.status.queryKey(),
+ });
+ router.refresh();
+ },
+ onError: (error) => toast.error(error.message),
+ }),
+ );
const [activeProvider, setActiveProvider] =
useState(null);
const saveSourceControlProviderChoice = useMutation(
@@ -92,15 +111,17 @@ function SetupSessionSourceControlCardBody({
? searchParams.get('reason') || `${provider} authorization was cancelled.`
: null;
+ if (dismissed) return null;
+
const cardTitle =
stage === 'provider'
- ? 'Connect source control'
+ ? 'Where do you keep your code?'
: stage === 'config'
? `Set up ${providerLabel}`
: `Authorize ${providerLabel}`;
const cardIntro =
stage === 'provider'
- ? 'Connect the service that hosts your repositories so I can work on your code.'
+ ? 'Connect to your source control provider for me to work on your code. You can also do that later in Settings → Source control.'
: stage === 'config'
? `Add the ${providerLabel} app credentials. The detailed setup opens in a separate dialog.`
: `Give me access to ${providerLabel} and sync the repositories I can work with.`;
@@ -117,13 +138,29 @@ function SetupSessionSourceControlCardBody({
) : null}
{stage === 'provider' ? (
-
- saveSourceControlProviderChoice.mutate({ provider: nextProvider })
- }
- disabled={saveSourceControlProviderChoice.isPending}
- />
+ <>
+
+ saveSourceControlProviderChoice.mutate({ provider: nextProvider })
+ }
+ disabled={saveSourceControlProviderChoice.isPending}
+ />
+ {optionalSourceControlEnabled ? (
+
+
+
+ ) : null}
+ >
) : stage === 'config' ? (
<>