diff --git a/apps/web/src/app/cloud-agent-fork/review/[reviewId]/route.test.ts b/apps/web/src/app/cloud-agent-fork/review/[reviewId]/route.test.ts index 5301a68b4d..7ebecf3b99 100644 --- a/apps/web/src/app/cloud-agent-fork/review/[reviewId]/route.test.ts +++ b/apps/web/src/app/cloud-agent-fork/review/[reviewId]/route.test.ts @@ -1,10 +1,8 @@ import { beforeAll, beforeEach, describe, expect, it, jest } from '@jest/globals'; import { NextRequest } from 'next/server'; import { TRPCError } from '@trpc/server'; -import { - DEFAULT_CODE_REVIEW_MODE, - DEFAULT_CODE_REVIEW_MODEL, -} from '@/lib/code-reviews/core/constants'; +import { DEFAULT_BOT_MODEL } from '@/lib/bot/constants'; +import { DEFAULT_CODE_REVIEW_MODE } from '@/lib/code-reviews/core/constants'; import { buildFixReviewPrompt } from '@/lib/code-reviews/prompts/fix-review-prompt'; type TrpcContextFixture = { @@ -15,7 +13,9 @@ type TrpcContextFixture = { type ReviewFixture = { id: string; + owned_by_user_id: string | null; owned_by_organization_id: string | null; + platform_integration_id: string | null; repo_full_name: string; pr_url: string; platform: string; @@ -47,6 +47,14 @@ type PrepareSessionOutput = { cloudAgentSessionId: string; }; +type IntegrationFixture = { + id: string; + platform: string; + owned_by_user_id: string | null; + owned_by_organization_id: string | null; + metadata: unknown; +}; + type RouteContext = { params: Promise<{ reviewId: string }>; }; @@ -55,6 +63,8 @@ type RouteGet = (request: NextRequest, context: RouteContext) => Promise Promise>(); const mockCodeReviewsGet = jest.fn<(input: { reviewId: string }) => Promise>(); +const mockGetIntegrationById = + jest.fn<(integrationId: string) => Promise>(); const mockPersonalPrepareSession = jest.fn<(input: PrepareSessionInput) => Promise>(); const mockOrganizationPrepareSession = @@ -87,22 +97,45 @@ jest.mock('@/routers/root-router', () => ({ rootRouter: {}, })); +jest.mock('@/lib/integrations/db/platform-integrations', () => ({ + getIntegrationById: mockGetIntegrationById, +})); + let getRoute: RouteGet; const REVIEW_ID = '00000000-0000-4000-8000-000000000001'; +const USER_ID = 'user_1'; +const OTHER_USER_ID = 'user_2'; const ORG_ID = '11111111-1111-4111-8111-111111111111'; +const OTHER_ORG_ID = '22222222-2222-4222-8222-222222222222'; +const REVIEW_INTEGRATION_ID = '33333333-3333-4333-8333-333333333333'; const PR_URL = 'https://github.com/owner/repo/pull/123'; +const CONFIGURED_BOT_MODEL = 'z-ai/glm-5.2'; +const RESOLVED_USAGE_MODEL = 'z-ai/glm-5.2-20260616'; const PERSONAL_KILO_SESSION_ID = 'ses_12345678901234567890123456'; const ORG_KILO_SESSION_ID = 'ses_abcdefabcdefabcdefabcdefab'; function makeReview(overrides: Partial = {}): ReviewFixture { return { id: REVIEW_ID, + owned_by_user_id: USER_ID, owned_by_organization_id: null, + platform_integration_id: REVIEW_INTEGRATION_ID, repo_full_name: 'owner/repo', pr_url: PR_URL, platform: 'github', - model: 'anthropic/custom-model', + model: RESOLVED_USAGE_MODEL, + ...overrides, + }; +} + +function makeIntegration(overrides: Partial = {}): IntegrationFixture { + return { + id: REVIEW_INTEGRATION_ID, + platform: 'github', + owned_by_user_id: USER_ID, + owned_by_organization_id: null, + metadata: { model_slug: CONFIGURED_BOT_MODEL }, ...overrides, }; } @@ -150,8 +183,9 @@ describe('GET /cloud-agent-fork/review/[reviewId]', () => { beforeEach(() => { jest.clearAllMocks(); - mockCreateTRPCContext.mockResolvedValue({ user: { id: 'user_1' } }); + mockCreateTRPCContext.mockResolvedValue({ user: { id: USER_ID } }); mockSuccessfulReview(); + mockGetIntegrationById.mockResolvedValue(makeIntegration()); mockPersonalPrepareSession.mockResolvedValue({ kiloSessionId: PERSONAL_KILO_SESSION_ID, cloudAgentSessionId: 'agent_personal', @@ -188,25 +222,23 @@ describe('GET /cloud-agent-fork/review/[reviewId]', () => { expectNoSessionCreation(); }); - it('starts personal review fix sessions with a free-text Cloud Agent Next prompt', async () => { + it('starts personal review fix sessions with the exact linked integration bot model', async () => { const response = await requestReview(); const redirectUrl = getRedirectUrl(response); expect(mockCodeReviewsGet).toHaveBeenCalledWith({ reviewId: REVIEW_ID }); + expect(mockGetIntegrationById).toHaveBeenCalledWith(REVIEW_INTEGRATION_ID); + expect(mockGetIntegrationById).toHaveBeenCalledTimes(1); expect(mockPersonalPrepareSession).toHaveBeenCalledWith({ githubRepo: 'owner/repo', prompt: buildFixReviewPrompt(PR_URL), mode: DEFAULT_CODE_REVIEW_MODE, - model: 'anthropic/custom-model', + model: CONFIGURED_BOT_MODEL, autoInitiate: true, autoCommit: false, }); expect(mockOrganizationPrepareSession).not.toHaveBeenCalled(); - const input = mockPersonalPrepareSession.mock.calls[0][0] as Record; - expect(input).not.toHaveProperty('upstreamBranch'); - expect(input).not.toHaveProperty('initialPayload'); - expect(response.status).toBe(303); expect(response.headers.get('Cache-Control')).toBe('no-store'); expect(`${redirectUrl.pathname}${redirectUrl.search}`).toBe( @@ -214,27 +246,32 @@ describe('GET /cloud-agent-fork/review/[reviewId]', () => { ); }); - it('starts organization review fix sessions on the organization chat path with model fallback', async () => { - mockSuccessfulReview({ owned_by_organization_id: ORG_ID, model: null }); + it('starts organization review fix sessions with the exact linked integration bot model', async () => { + mockSuccessfulReview({ + owned_by_user_id: null, + owned_by_organization_id: ORG_ID, + model: RESOLVED_USAGE_MODEL, + }); + mockGetIntegrationById.mockResolvedValue( + makeIntegration({ owned_by_user_id: null, owned_by_organization_id: ORG_ID }) + ); const response = await requestReview(); const redirectUrl = getRedirectUrl(response); + expect(mockGetIntegrationById).toHaveBeenCalledWith(REVIEW_INTEGRATION_ID); + expect(mockGetIntegrationById).toHaveBeenCalledTimes(1); expect(mockPersonalPrepareSession).not.toHaveBeenCalled(); expect(mockOrganizationPrepareSession).toHaveBeenCalledWith({ githubRepo: 'owner/repo', prompt: buildFixReviewPrompt(PR_URL), mode: DEFAULT_CODE_REVIEW_MODE, - model: DEFAULT_CODE_REVIEW_MODEL, + model: CONFIGURED_BOT_MODEL, autoInitiate: true, autoCommit: false, organizationId: ORG_ID, }); - const input = mockOrganizationPrepareSession.mock.calls[0][0] as Record; - expect(input).not.toHaveProperty('upstreamBranch'); - expect(input).not.toHaveProperty('initialPayload'); - expect(response.status).toBe(303); expect(`${redirectUrl.pathname}${redirectUrl.search}`).toBe( `/organizations/${ORG_ID}/cloud/chat?sessionId=${ORG_KILO_SESSION_ID}` @@ -295,6 +332,86 @@ describe('GET /cloud-agent-fork/review/[reviewId]', () => { const response = await requestReview(); expectErrorRedirect(response, 'unsupported_platform'); + expect(mockGetIntegrationById).not.toHaveBeenCalled(); + expectNoSessionCreation(); + }); + + it('uses the default bot model for unlinked reviews without owner-wide fallback lookup', async () => { + mockSuccessfulReview({ platform_integration_id: null }); + + const response = await requestReview(); + + expect(mockGetIntegrationById).not.toHaveBeenCalled(); + expect(mockPersonalPrepareSession).toHaveBeenCalledWith({ + githubRepo: 'owner/repo', + prompt: buildFixReviewPrompt(PR_URL), + mode: DEFAULT_CODE_REVIEW_MODE, + model: DEFAULT_BOT_MODEL, + autoInitiate: true, + autoCommit: false, + }); + expect(response.status).toBe(303); + }); + + it('uses the default bot model when the linked integration was deleted', async () => { + mockGetIntegrationById.mockResolvedValue(null); + + const response = await requestReview(); + + expect(mockGetIntegrationById).toHaveBeenCalledWith(REVIEW_INTEGRATION_ID); + expect(mockGetIntegrationById).toHaveBeenCalledTimes(1); + expect(mockPersonalPrepareSession).toHaveBeenCalledWith({ + githubRepo: 'owner/repo', + prompt: buildFixReviewPrompt(PR_URL), + mode: DEFAULT_CODE_REVIEW_MODE, + model: DEFAULT_BOT_MODEL, + autoInitiate: true, + autoCommit: false, + }); + expect(response.status).toBe(303); + }); + + it.each([ + { + name: 'wrong platform', + integrationOverrides: { platform: 'gitlab' }, + }, + { + name: 'wrong personal owner', + integrationOverrides: { owned_by_user_id: OTHER_USER_ID }, + }, + { + name: 'wrong organization owner', + reviewOverrides: { owned_by_user_id: null, owned_by_organization_id: ORG_ID }, + integrationOverrides: { + owned_by_user_id: null, + owned_by_organization_id: OTHER_ORG_ID, + }, + }, + ] satisfies Array<{ + name: string; + reviewOverrides?: Partial; + integrationOverrides: Partial; + }>)('redirects invalid linked integrations with $name provenance', async testCase => { + mockSuccessfulReview(testCase.reviewOverrides); + mockGetIntegrationById.mockResolvedValue(makeIntegration(testCase.integrationOverrides)); + + const response = await requestReview(); + + expectErrorRedirect(response, 'fix_session_failed'); + expect(mockGetIntegrationById).toHaveBeenCalledWith(REVIEW_INTEGRATION_ID); + expect(mockGetIntegrationById).toHaveBeenCalledTimes(1); + expectNoSessionCreation(); + }); + + it('redirects integration lookup failures without creating a session', async () => { + mockGetIntegrationById.mockRejectedValue(new Error('integration unavailable')); + + const response = await requestReview(); + + expectErrorRedirect(response, 'fix_session_failed'); + expect(mockGetIntegrationById).toHaveBeenCalledWith(REVIEW_INTEGRATION_ID); + expect(mockGetIntegrationById).toHaveBeenCalledTimes(1); expectNoSessionCreation(); }); diff --git a/apps/web/src/app/cloud-agent-fork/review/[reviewId]/route.ts b/apps/web/src/app/cloud-agent-fork/review/[reviewId]/route.ts index 9ec6c9634c..f8a79117ba 100644 --- a/apps/web/src/app/cloud-agent-fork/review/[reviewId]/route.ts +++ b/apps/web/src/app/cloud-agent-fork/review/[reviewId]/route.ts @@ -1,10 +1,9 @@ import type { NextRequest } from 'next/server'; import { NextResponse } from 'next/server'; import { buildFixReviewPrompt } from '@/lib/code-reviews/prompts/fix-review-prompt'; -import { - DEFAULT_CODE_REVIEW_MODE, - DEFAULT_CODE_REVIEW_MODEL, -} from '@/lib/code-reviews/core/constants'; +import { DEFAULT_CODE_REVIEW_MODE } from '@/lib/code-reviews/core/constants'; +import { resolveBotModelSlug } from '@/lib/bot/model'; +import { getIntegrationById } from '@/lib/integrations/db/platform-integrations'; import { createCallerFactory, createTRPCContext } from '@/lib/trpc/init'; import { rootRouter } from '@/routers/root-router'; import { TRPCError } from '@trpc/server'; @@ -72,17 +71,29 @@ export async function GET(request: NextRequest, context: RouteContext) { return redirectToError(url.origin, 'unsupported_platform'); } - const sessionInput = { - githubRepo: review.repo_full_name, - prompt: buildFixReviewPrompt(review.pr_url), - mode: DEFAULT_CODE_REVIEW_MODE, - model: review.model ?? DEFAULT_CODE_REVIEW_MODEL, - autoInitiate: true, - autoCommit: false, - }; - try { const organizationId = review.owned_by_organization_id; + const integration = review.platform_integration_id + ? await getIntegrationById(review.platform_integration_id) + : null; + + if ( + integration && + (integration.platform !== review.platform || + integration.owned_by_user_id !== review.owned_by_user_id || + integration.owned_by_organization_id !== review.owned_by_organization_id) + ) { + return redirectToError(url.origin, 'fix_session_failed'); + } + + const sessionInput = { + githubRepo: review.repo_full_name, + prompt: buildFixReviewPrompt(review.pr_url), + mode: DEFAULT_CODE_REVIEW_MODE, + model: resolveBotModelSlug(integration), + autoInitiate: true, + autoCommit: false, + }; const session = organizationId ? await caller.organizations.cloudAgentNext.prepareSession({ ...sessionInput, diff --git a/apps/web/src/lib/bot/agent-runner.ts b/apps/web/src/lib/bot/agent-runner.ts index 397e58ba19..75f317fdab 100644 --- a/apps/web/src/lib/bot/agent-runner.ts +++ b/apps/web/src/lib/bot/agent-runner.ts @@ -1,10 +1,4 @@ -import { - BOT_USER_AGENT, - BOT_VERSION, - DEFAULT_BOT_MODEL, - MAX_ITERATIONS, - SUMMARY_MODEL, -} from '@/lib/bot/constants'; +import { BOT_USER_AGENT, BOT_VERSION, MAX_ITERATIONS, SUMMARY_MODEL } from '@/lib/bot/constants'; import { botPlatforms, type BotPlatform } from '@/lib/bot/platforms'; import { buildPrSignature } from '@/lib/bot/pr-signature'; import { @@ -13,6 +7,7 @@ import { updateBotRequest, } from '@/lib/bot/request-logging'; import { getNextBotCallbackStep, getRemainingBotIterations } from '@/lib/bot/step-budget'; +import { resolveBotModelSlug } from '@/lib/bot/model'; import spawnCloudAgentSession, { spawnCloudAgentInputSchema, } from '@/lib/bot/tools/spawn-cloud-agent-session'; @@ -209,9 +204,7 @@ export async function runBotAgent(params: RunBotAgentParams): Promise { + it('returns a trimmed configured bot model slug', () => { + expect(resolveBotModelSlug({ metadata: { model_slug: ' z-ai/glm-5.2 ' } })).toBe( + 'z-ai/glm-5.2' + ); + }); + + it.each([ + { name: 'null integration', integration: null }, + { name: 'undefined integration', integration: undefined }, + { name: 'missing metadata', integration: { metadata: undefined } }, + { name: 'missing model slug', integration: { metadata: {} } }, + { name: 'empty model slug', integration: { metadata: { model_slug: '' } } }, + { name: 'whitespace model slug', integration: { metadata: { model_slug: ' ' } } }, + { name: 'non-string model slug', integration: { metadata: { model_slug: 42 } } }, + { name: 'non-object metadata', integration: { metadata: 'z-ai/glm-5.2' } }, + ])('falls back to the default bot model for $name', ({ integration }) => { + expect(resolveBotModelSlug(integration)).toBe(DEFAULT_BOT_MODEL); + }); +}); diff --git a/apps/web/src/lib/bot/model.ts b/apps/web/src/lib/bot/model.ts new file mode 100644 index 0000000000..0e2262b1d9 --- /dev/null +++ b/apps/web/src/lib/bot/model.ts @@ -0,0 +1,19 @@ +import { DEFAULT_BOT_MODEL } from '@/lib/bot/constants'; +import { z } from 'zod'; + +type BotModelIntegration = { + metadata: unknown; +}; + +const BotModelMetadataSchema = z + .object({ + model_slug: z.string().trim().min(1), + }) + .passthrough(); + +export function resolveBotModelSlug(integration: BotModelIntegration | null | undefined): string { + const parsed = BotModelMetadataSchema.safeParse(integration?.metadata); + if (!parsed.success) return DEFAULT_BOT_MODEL; + + return parsed.data.model_slug; +}