From 3cdd59be797502d476e7f589ff8dd31eac94be2c Mon Sep 17 00:00:00 2001 From: Alex Alecu Date: Thu, 18 Jun 2026 12:21:02 +0300 Subject: [PATCH 1/2] fix(review): use configured model for fix sessions Resolve the owner catalog model before preparing a review fix session so usage-resolved model IDs are not rejected. --- .../review/[reviewId]/route.test.ts | 65 ++++++++++++++++--- .../review/[reviewId]/route.ts | 28 ++++---- 2 files changed, 70 insertions(+), 23 deletions(-) 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..7c321e25e3 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,7 @@ 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_CODE_REVIEW_MODE } from '@/lib/code-reviews/core/constants'; import { buildFixReviewPrompt } from '@/lib/code-reviews/prompts/fix-review-prompt'; type TrpcContextFixture = { @@ -47,6 +44,10 @@ type PrepareSessionOutput = { cloudAgentSessionId: string; }; +type ReviewConfigOutput = { + modelSlug: string; +}; + type RouteContext = { params: Promise<{ reviewId: string }>; }; @@ -55,6 +56,10 @@ type RouteGet = (request: NextRequest, context: RouteContext) => Promise Promise>(); const mockCodeReviewsGet = jest.fn<(input: { reviewId: string }) => Promise>(); +const mockPersonalGetReviewConfig = + jest.fn<(input: { platform: 'github' }) => Promise>(); +const mockOrganizationGetReviewConfig = + jest.fn<(input: { organizationId: string; platform: 'github' }) => Promise>(); const mockPersonalPrepareSession = jest.fn<(input: PrepareSessionInput) => Promise>(); const mockOrganizationPrepareSession = @@ -66,10 +71,16 @@ const mockCaller = { codeReviews: { get: mockCodeReviewsGet, }, + personalReviewAgent: { + getReviewConfig: mockPersonalGetReviewConfig, + }, cloudAgentNext: { prepareSession: mockPersonalPrepareSession, }, organizations: { + reviewAgent: { + getReviewConfig: mockOrganizationGetReviewConfig, + }, cloudAgentNext: { prepareSession: mockOrganizationPrepareSession, }, @@ -92,6 +103,10 @@ let getRoute: RouteGet; const REVIEW_ID = '00000000-0000-4000-8000-000000000001'; const ORG_ID = '11111111-1111-4111-8111-111111111111'; const PR_URL = 'https://github.com/owner/repo/pull/123'; +const PERSONAL_CONFIGURED_MODEL = 'z-ai/glm-5.2'; +const PERSONAL_USAGE_MODEL = 'z-ai/glm-5.2-20260616'; +const ORGANIZATION_CONFIGURED_MODEL = 'anthropic/claude-sonnet-4.6'; +const ORGANIZATION_USAGE_MODEL = 'anthropic/claude-4.6-sonnet-20260217'; const PERSONAL_KILO_SESSION_ID = 'ses_12345678901234567890123456'; const ORG_KILO_SESSION_ID = 'ses_abcdefabcdefabcdefabcdefab'; @@ -102,7 +117,7 @@ function makeReview(overrides: Partial = {}): ReviewFixture { repo_full_name: 'owner/repo', pr_url: PR_URL, platform: 'github', - model: 'anthropic/custom-model', + model: PERSONAL_USAGE_MODEL, ...overrides, }; } @@ -152,6 +167,10 @@ describe('GET /cloud-agent-fork/review/[reviewId]', () => { jest.clearAllMocks(); mockCreateTRPCContext.mockResolvedValue({ user: { id: 'user_1' } }); mockSuccessfulReview(); + mockPersonalGetReviewConfig.mockResolvedValue({ modelSlug: PERSONAL_CONFIGURED_MODEL }); + mockOrganizationGetReviewConfig.mockResolvedValue({ + modelSlug: ORGANIZATION_CONFIGURED_MODEL, + }); mockPersonalPrepareSession.mockResolvedValue({ kiloSessionId: PERSONAL_KILO_SESSION_ID, cloudAgentSessionId: 'agent_personal', @@ -188,19 +207,24 @@ 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 configured catalog model', async () => { const response = await requestReview(); const redirectUrl = getRedirectUrl(response); expect(mockCodeReviewsGet).toHaveBeenCalledWith({ reviewId: REVIEW_ID }); + expect(mockPersonalGetReviewConfig).toHaveBeenCalledWith({ platform: 'github' }); + expect(mockOrganizationGetReviewConfig).not.toHaveBeenCalled(); expect(mockPersonalPrepareSession).toHaveBeenCalledWith({ githubRepo: 'owner/repo', prompt: buildFixReviewPrompt(PR_URL), mode: DEFAULT_CODE_REVIEW_MODE, - model: 'anthropic/custom-model', + model: PERSONAL_CONFIGURED_MODEL, autoInitiate: true, autoCommit: false, }); + expect(mockPersonalPrepareSession).not.toHaveBeenCalledWith( + expect.objectContaining({ model: PERSONAL_USAGE_MODEL }) + ); expect(mockOrganizationPrepareSession).not.toHaveBeenCalled(); const input = mockPersonalPrepareSession.mock.calls[0][0] as Record; @@ -214,22 +238,33 @@ 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 configured catalog model', async () => { + mockSuccessfulReview({ + owned_by_organization_id: ORG_ID, + model: ORGANIZATION_USAGE_MODEL, + }); const response = await requestReview(); const redirectUrl = getRedirectUrl(response); + expect(mockPersonalGetReviewConfig).not.toHaveBeenCalled(); + expect(mockOrganizationGetReviewConfig).toHaveBeenCalledWith({ + organizationId: ORG_ID, + platform: 'github', + }); 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: ORGANIZATION_CONFIGURED_MODEL, autoInitiate: true, autoCommit: false, organizationId: ORG_ID, }); + expect(mockOrganizationPrepareSession).not.toHaveBeenCalledWith( + expect.objectContaining({ model: ORGANIZATION_USAGE_MODEL }) + ); const input = mockOrganizationPrepareSession.mock.calls[0][0] as Record; expect(input).not.toHaveProperty('upstreamBranch'); @@ -298,6 +333,16 @@ describe('GET /cloud-agent-fork/review/[reviewId]', () => { expectNoSessionCreation(); }); + it('redirects configuration lookup failures without creating a session', async () => { + mockPersonalGetReviewConfig.mockRejectedValue(new Error('configuration unavailable')); + + const response = await requestReview(); + + expectErrorRedirect(response, 'fix_session_failed'); + expect(mockPersonalGetReviewConfig).toHaveBeenCalledTimes(1); + expectNoSessionCreation(); + }); + it('redirects generic preparation failures to fix_session_failed', async () => { mockPersonalPrepareSession.mockRejectedValue(new Error('worker unavailable')); 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..5d44d2a5fa 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,7 @@ 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 { createCallerFactory, createTRPCContext } from '@/lib/trpc/init'; import { rootRouter } from '@/routers/root-router'; import { TRPCError } from '@trpc/server'; @@ -72,17 +69,22 @@ 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 reviewConfig = organizationId + ? await caller.organizations.reviewAgent.getReviewConfig({ + organizationId, + platform: 'github', + }) + : await caller.personalReviewAgent.getReviewConfig({ platform: 'github' }); + const sessionInput = { + githubRepo: review.repo_full_name, + prompt: buildFixReviewPrompt(review.pr_url), + mode: DEFAULT_CODE_REVIEW_MODE, + model: reviewConfig.modelSlug, + autoInitiate: true, + autoCommit: false, + }; const session = organizationId ? await caller.organizations.cloudAgentNext.prepareSession({ ...sessionInput, From f22197cfe566bb1faa3b91fd4c22fd2d42fb2a61 Mon Sep 17 00:00:00 2001 From: Alex Alecu Date: Thu, 18 Jun 2026 15:35:58 +0300 Subject: [PATCH 2/2] fix(review): use linked bot model for fix sessions --- .../review/[reviewId]/route.test.ts | 174 +++++++++++++----- .../review/[reviewId]/route.ts | 23 ++- apps/web/src/lib/bot/agent-runner.ts | 13 +- apps/web/src/lib/bot/model.test.ts | 23 +++ apps/web/src/lib/bot/model.ts | 19 ++ 5 files changed, 184 insertions(+), 68 deletions(-) create mode 100644 apps/web/src/lib/bot/model.test.ts create mode 100644 apps/web/src/lib/bot/model.ts 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 7c321e25e3..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,6 +1,7 @@ import { beforeAll, beforeEach, describe, expect, it, jest } from '@jest/globals'; import { NextRequest } from 'next/server'; import { TRPCError } from '@trpc/server'; +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'; @@ -12,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; @@ -44,8 +47,12 @@ type PrepareSessionOutput = { cloudAgentSessionId: string; }; -type ReviewConfigOutput = { - modelSlug: string; +type IntegrationFixture = { + id: string; + platform: string; + owned_by_user_id: string | null; + owned_by_organization_id: string | null; + metadata: unknown; }; type RouteContext = { @@ -56,10 +63,8 @@ type RouteGet = (request: NextRequest, context: RouteContext) => Promise Promise>(); const mockCodeReviewsGet = jest.fn<(input: { reviewId: string }) => Promise>(); -const mockPersonalGetReviewConfig = - jest.fn<(input: { platform: 'github' }) => Promise>(); -const mockOrganizationGetReviewConfig = - jest.fn<(input: { organizationId: string; platform: 'github' }) => Promise>(); +const mockGetIntegrationById = + jest.fn<(integrationId: string) => Promise>(); const mockPersonalPrepareSession = jest.fn<(input: PrepareSessionInput) => Promise>(); const mockOrganizationPrepareSession = @@ -71,16 +76,10 @@ const mockCaller = { codeReviews: { get: mockCodeReviewsGet, }, - personalReviewAgent: { - getReviewConfig: mockPersonalGetReviewConfig, - }, cloudAgentNext: { prepareSession: mockPersonalPrepareSession, }, organizations: { - reviewAgent: { - getReviewConfig: mockOrganizationGetReviewConfig, - }, cloudAgentNext: { prepareSession: mockOrganizationPrepareSession, }, @@ -98,26 +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 PERSONAL_CONFIGURED_MODEL = 'z-ai/glm-5.2'; -const PERSONAL_USAGE_MODEL = 'z-ai/glm-5.2-20260616'; -const ORGANIZATION_CONFIGURED_MODEL = 'anthropic/claude-sonnet-4.6'; -const ORGANIZATION_USAGE_MODEL = 'anthropic/claude-4.6-sonnet-20260217'; +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: PERSONAL_USAGE_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, }; } @@ -165,12 +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(); - mockPersonalGetReviewConfig.mockResolvedValue({ modelSlug: PERSONAL_CONFIGURED_MODEL }); - mockOrganizationGetReviewConfig.mockResolvedValue({ - modelSlug: ORGANIZATION_CONFIGURED_MODEL, - }); + mockGetIntegrationById.mockResolvedValue(makeIntegration()); mockPersonalPrepareSession.mockResolvedValue({ kiloSessionId: PERSONAL_KILO_SESSION_ID, cloudAgentSessionId: 'agent_personal', @@ -207,30 +222,23 @@ describe('GET /cloud-agent-fork/review/[reviewId]', () => { expectNoSessionCreation(); }); - it('starts personal review fix sessions with the configured catalog model', 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(mockPersonalGetReviewConfig).toHaveBeenCalledWith({ platform: 'github' }); - expect(mockOrganizationGetReviewConfig).not.toHaveBeenCalled(); + 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: PERSONAL_CONFIGURED_MODEL, + model: CONFIGURED_BOT_MODEL, autoInitiate: true, autoCommit: false, }); - expect(mockPersonalPrepareSession).not.toHaveBeenCalledWith( - expect.objectContaining({ model: PERSONAL_USAGE_MODEL }) - ); 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( @@ -238,37 +246,31 @@ describe('GET /cloud-agent-fork/review/[reviewId]', () => { ); }); - it('starts organization review fix sessions with the configured catalog model', async () => { + 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: ORGANIZATION_USAGE_MODEL, + 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(mockPersonalGetReviewConfig).not.toHaveBeenCalled(); - expect(mockOrganizationGetReviewConfig).toHaveBeenCalledWith({ - organizationId: ORG_ID, - platform: 'github', - }); + 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: ORGANIZATION_CONFIGURED_MODEL, + model: CONFIGURED_BOT_MODEL, autoInitiate: true, autoCommit: false, organizationId: ORG_ID, }); - expect(mockOrganizationPrepareSession).not.toHaveBeenCalledWith( - expect.objectContaining({ model: ORGANIZATION_USAGE_MODEL }) - ); - - 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( @@ -330,16 +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 configuration lookup failures without creating a session', async () => { - mockPersonalGetReviewConfig.mockRejectedValue(new Error('configuration unavailable')); + 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(mockPersonalGetReviewConfig).toHaveBeenCalledTimes(1); + 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 5d44d2a5fa..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 @@ -2,6 +2,8 @@ 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 } 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'; @@ -71,17 +73,24 @@ export async function GET(request: NextRequest, context: RouteContext) { try { const organizationId = review.owned_by_organization_id; - const reviewConfig = organizationId - ? await caller.organizations.reviewAgent.getReviewConfig({ - organizationId, - platform: 'github', - }) - : await caller.personalReviewAgent.getReviewConfig({ platform: 'github' }); + 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: reviewConfig.modelSlug, + model: resolveBotModelSlug(integration), autoInitiate: true, autoCommit: false, }; 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; +}