Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
155 changes: 136 additions & 19 deletions apps/web/src/app/cloud-agent-fork/review/[reviewId]/route.test.ts
Original file line number Diff line number Diff line change
@@ -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 = {
Expand All @@ -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;
Expand Down Expand Up @@ -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 }>;
};
Expand All @@ -55,6 +63,8 @@ type RouteGet = (request: NextRequest, context: RouteContext) => Promise<Respons

const mockCreateTRPCContext = jest.fn<() => Promise<TrpcContextFixture>>();
const mockCodeReviewsGet = jest.fn<(input: { reviewId: string }) => Promise<ReviewResult>>();
const mockGetIntegrationById =
jest.fn<(integrationId: string) => Promise<IntegrationFixture | null>>();
const mockPersonalPrepareSession =
jest.fn<(input: PrepareSessionInput) => Promise<PrepareSessionOutput>>();
const mockOrganizationPrepareSession =
Expand Down Expand Up @@ -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> = {}): 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> = {}): 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,
};
}
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -188,53 +222,56 @@ 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<string, unknown>;
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(
`/cloud/chat?sessionId=${PERSONAL_KILO_SESSION_ID}`
);
});

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<string, unknown>;
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}`
Expand Down Expand Up @@ -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<ReviewFixture>;
integrationOverrides: Partial<IntegrationFixture>;
}>)('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();
});

Expand Down
37 changes: 24 additions & 13 deletions apps/web/src/app/cloud-agent-fork/review/[reviewId]/route.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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,
Expand Down
13 changes: 3 additions & 10 deletions apps/web/src/lib/bot/agent-runner.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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';
Expand Down Expand Up @@ -209,9 +204,7 @@ export async function runBotAgent(params: RunBotAgentParams): Promise<BotAgentCo
headers,
});

const modelSlug =
((params.platformIntegration.metadata || {}) as { model_slug?: string }).model_slug ??
DEFAULT_BOT_MODEL;
const modelSlug = resolveBotModelSlug(params.platformIntegration);
const owner = ownerFromIntegration(params.platformIntegration);
const chatPlatform = params.thread.adapter.name;
const botPlatform = botPlatforms.requireByAdapter(params.thread.adapter);
Expand Down
23 changes: 23 additions & 0 deletions apps/web/src/lib/bot/model.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { DEFAULT_BOT_MODEL } from '@/lib/bot/constants';
import { resolveBotModelSlug } from './model';

describe('resolveBotModelSlug', () => {
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);
});
});
19 changes: 19 additions & 0 deletions apps/web/src/lib/bot/model.ts
Original file line number Diff line number Diff line change
@@ -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;
}