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
Original file line number Diff line number Diff line change
Expand Up @@ -534,10 +534,11 @@ describe('POST /api/internal/code-review-status/[reviewId]', () => {
);
});

it('infers selected-model-unavailable callbacks as action-required failures', async () => {
it('reclassifies preflight unavailable-model callbacks as atomic model-not-found cancellations', async () => {
const errorMessage =
'prepareSession failed (400): {"error":{"message":"Selected model is not available for this cloud agent session","code":-32600,"data":{"code":"BAD_REQUEST","httpStatus":400,"path":"prepareSession"}}}';
mockGetCodeReviewById.mockResolvedValue(makeReview());
mockFindKiloReviewComment.mockResolvedValue(null);

const response = await POST(
makeRequest({
Expand All @@ -550,43 +551,78 @@ describe('POST /api/internal/code-review-status/[reviewId]', () => {
expect(response.status).toBe(200);
expect(mockUpdateCodeReviewAttemptForCallback).toHaveBeenCalledWith(
expect.objectContaining({
status: 'failed',
status: 'cancelled',
errorMessage,
terminalReason: 'selected_model_unavailable',
terminalReason: 'model_not_found',
})
);
expect(mockUpdateCodeReviewStatus).toHaveBeenCalledWith(
expect(mockUpdateCodeReviewStatusIfNonTerminal).toHaveBeenCalledWith(
REVIEW_ID,
'failed',
'cancelled',
expect.objectContaining({
errorMessage,
terminalReason: 'selected_model_unavailable',
terminalReason: 'model_not_found',
})
);
expect(mockUpdateCodeReviewStatusIfNonTerminal).not.toHaveBeenCalled();
expect(mockUpdateCodeReviewStatus).not.toHaveBeenCalled();
expect(mockCreateInfraRetryAttemptIfMissing).not.toHaveBeenCalled();
expect(mockRetryReviewFresh).not.toHaveBeenCalled();
expect(mockDisableCodeReviewForActionRequiredFailure).toHaveBeenCalledWith(
expect.objectContaining({
owner: { type: 'user', id: 'user-1', userId: 'user-1' },
platform: 'github',
reviewId: REVIEW_ID,
reason: 'selected_model_unavailable',
errorMessage,
})
);
expect(mockDisableCodeReviewForActionRequiredFailure).not.toHaveBeenCalled();
expect(mockUpdateCheckRun).toHaveBeenCalledWith(
'inst-1',
'owner',
'repo',
12345,
expect.objectContaining({
conclusion: 'action_required',
output: expect.objectContaining({ title: 'Selected model unavailable' }),
status: 'completed',
conclusion: 'cancelled',
output: expect.objectContaining({ title: 'Selected model is no longer available' }),
}),
'standard'
);
expect(mockFindKiloReviewComment).not.toHaveBeenCalled();
expect(mockCreatePRComment).toHaveBeenCalledWith(
'inst-1',
'owner',
'repo',
1,
expect.stringContaining('Choose another model in Kilo Code review settings'),
'standard'
);
});

it('reclassifies rolling-deploy selected-model-unavailable preflight callbacks as cancellations', async () => {
const errorMessage =
'prepareSession failed (400): {"error":{"message":"Selected model is not available for this cloud agent session","code":-32600,"data":{"code":"BAD_REQUEST","httpStatus":400,"path":"prepareSession"}}}';
mockGetCodeReviewById.mockResolvedValue(makeReview());
mockFindKiloReviewComment.mockResolvedValue(null);

const response = await POST(
makeRequest({
status: 'failed',
errorMessage,
terminalReason: 'selected_model_unavailable',
}),
makeParams(REVIEW_ID)
);

expect(response.status).toBe(200);
expect(mockUpdateCodeReviewAttemptForCallback).toHaveBeenCalledWith(
expect.objectContaining({
status: 'cancelled',
errorMessage,
terminalReason: 'model_not_found',
})
);
expect(mockUpdateCodeReviewStatusIfNonTerminal).toHaveBeenCalledWith(
REVIEW_ID,
'cancelled',
expect.objectContaining({
errorMessage,
terminalReason: 'model_not_found',
})
);
expect(mockUpdateCodeReviewStatus).not.toHaveBeenCalled();
expect(mockDisableCodeReviewForActionRequiredFailure).not.toHaveBeenCalled();
});

it('infers model-not-allowed callbacks as action-required failures', async () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ import {
type CodeReviewActionRequiredReason,
} from '@/lib/code-reviews/action-required';
import type { Owner } from '@/lib/code-reviews/core';
import { isCodeReviewModelUnavailableFailure } from '@/lib/code-reviews/model-unavailable';

/**
* Payload from the orchestrator DO (legacy format).
Expand Down Expand Up @@ -169,7 +170,7 @@ function normalizePayload(raw: StatusUpdatePayload): {

if (
(raw.status === 'failed' || raw.status === 'interrupted') &&
isModelNotFoundCodeReviewTerminalReason(terminalReason, raw.errorMessage)
isCodeReviewModelUnavailableFailure(terminalReason, raw.errorMessage)
) {
status = 'cancelled';
terminalReason = 'model_not_found';
Expand Down Expand Up @@ -207,17 +208,6 @@ function isBillingCodeReviewTerminalReason(
);
}

function isModelNotFoundCodeReviewTerminalReason(
terminalReason?: CodeReviewTerminalReason,
errorMessage?: string | null
): boolean {
if (terminalReason === 'model_not_found') {
return true;
}

return /\bmodel\s+not\s+found\b/i.test(errorMessage ?? '');
}

function getActionRequiredTerminalReason(
terminalReason?: CodeReviewTerminalReason,
errorMessage?: string | null
Expand All @@ -239,7 +229,7 @@ function isRetryableInfraFailure(
if (isCodeReviewActionRequiredReason(terminalReason)) return false;
if (classifyCodeReviewActionRequiredFailure(errorMessage)) return false;
if (isBillingCodeReviewTerminalReason(terminalReason, errorMessage)) return false;
if (isModelNotFoundCodeReviewTerminalReason(terminalReason, errorMessage)) return false;
if (isCodeReviewModelUnavailableFailure(terminalReason, errorMessage)) return false;

const message = errorMessage?.toLowerCase();
if (!message) return false;
Expand Down Expand Up @@ -431,7 +421,7 @@ function mapStatusToCheckRun(
: null;
const modelNotFoundCancellation =
reviewStatus === 'cancelled' &&
isModelNotFoundCodeReviewTerminalReason(terminalReason, errorMessage);
isCodeReviewModelUnavailableFailure(terminalReason, errorMessage);
const actionRequiredCopy = actionRequiredReason
? getCodeReviewActionRequiredCopy(actionRequiredReason)
: null;
Expand Down Expand Up @@ -508,7 +498,7 @@ function getGitLabStatusDescription(
if (reviewStatus === 'completed') return 'Kilo Code Review completed';
if (
reviewStatus === 'cancelled' &&
isModelNotFoundCodeReviewTerminalReason(terminalReason, errorMessage)
isCodeReviewModelUnavailableFailure(terminalReason, errorMessage)
) {
return MODEL_NOT_FOUND_GITLAB_DESCRIPTION;
}
Expand Down Expand Up @@ -988,8 +978,7 @@ export async function POST(
: undefined,
};
const isModelNotFoundCancellation =
status === 'cancelled' &&
isModelNotFoundCodeReviewTerminalReason(terminalReason, errorMessage);
status === 'cancelled' && isCodeReviewModelUnavailableFailure(terminalReason, errorMessage);

if (isModelNotFoundCancellation) {
const claimedTerminalUpdate = await updateCodeReviewStatusIfNonTerminal(
Expand Down
24 changes: 11 additions & 13 deletions apps/web/src/lib/code-reviews/action-required.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,18 +41,6 @@ describe('classifyCodeReviewActionRequiredFailure', () => {
)
).toBe('github_ip_allow_list');

expect(
classifyCodeReviewActionRequiredFailure(
'Selected model is not available for this cloud agent session'
)
).toBe('selected_model_unavailable');

expect(
classifyCodeReviewActionRequiredFailure(
'prepareSession failed (400): {"error":{"message":"Selected model is not available for this cloud agent session","code":-32600,"data":{"code":"BAD_REQUEST","httpStatus":400,"path":"prepareSession"}}}'
)
).toBe('selected_model_unavailable');

expect(
classifyCodeReviewActionRequiredFailure(
'Not Found: The requested model is not allowed for your team.'
Expand All @@ -66,13 +54,23 @@ describe('classifyCodeReviewActionRequiredFailure', () => {
).toBe('selected_model_unavailable');
});

it('does not classify unrelated auth, rate-limit, or BYOK quota failures', () => {
it('does not classify unrelated auth, rate-limit, BYOK quota, or preflight model failures', () => {
expect(classifyCodeReviewActionRequiredFailure('GitHub returned 401 Unauthorized')).toBeNull();
expect(classifyCodeReviewActionRequiredFailure('GitHub returned 403 Forbidden')).toBeNull();
expect(classifyCodeReviewActionRequiredFailure('Rate limit exceeded: 429')).toBeNull();
expect(
classifyCodeReviewActionRequiredFailure('[BYOK] Your account quota is exhausted.')
).toBeNull();
expect(
classifyCodeReviewActionRequiredFailure(
'Selected model is not available for this cloud agent session'
)
).toBeNull();
expect(
classifyCodeReviewActionRequiredFailure(
'prepareSession failed (400): {"error":{"message":"Selected model is not available for this cloud agent session","code":-32600,"data":{"code":"BAD_REQUEST","httpStatus":400,"path":"prepareSession"}}}'
)
).toBeNull();
});

it('routes selected model recovery to Code Reviewer settings', () => {
Expand Down
7 changes: 1 addition & 6 deletions apps/web/src/lib/code-reviews/action-required.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,8 +36,6 @@ const CodeReviewActionRequiredStateSchema = z.object({
emailSentAt: z.string().optional(),
});

const SELECTED_MODEL_UNAVAILABLE_MESSAGE =
'selected model is not available for this cloud agent session';
const REQUESTED_MODEL_NOT_ALLOWED_FOR_TEAM_MESSAGE =
'the requested model is not allowed for your team';

Expand Down Expand Up @@ -107,10 +105,7 @@ export function classifyCodeReviewActionRequiredFailure(
return 'github_ip_allow_list';
}

if (
normalized.includes(SELECTED_MODEL_UNAVAILABLE_MESSAGE) ||
normalized.includes(REQUESTED_MODEL_NOT_ALLOWED_FOR_TEAM_MESSAGE)
) {
if (normalized.includes(REQUESTED_MODEL_NOT_ALLOWED_FOR_TEAM_MESSAGE)) {
return 'selected_model_unavailable';
}

Expand Down
28 changes: 28 additions & 0 deletions apps/web/src/lib/code-reviews/alerting/detectors.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -276,6 +276,34 @@ describe('code review alert detectors', () => {
await expect(evaluateErrorSpike(db)).resolves.toEqual({ tripped: false });
});

it('excludes legacy preflight model-unavailable failed rows without terminal reasons', async () => {
await insertReviews([
reviewValues({ status: 'failed', terminal_reason: null, error_message: 'Checkout failed' }),
reviewValues({ status: 'failed', terminal_reason: null, error_message: 'Checkout failed' }),
reviewValues({ status: 'failed', terminal_reason: null, error_message: 'Checkout failed' }),
reviewValues({ status: 'failed', terminal_reason: null, error_message: 'Checkout failed' }),
reviewValues({
status: 'failed',
terminal_reason: null,
error_message:
'prepareSession failed (400): Selected model is not available for this cloud agent session',
}),
...Array.from({ length: 15 }, () => reviewValues()),
]);

await expect(evaluateErrorSpike(db)).resolves.toMatchObject({
tripped: true,
details: {
kind: 'error_spike',
startedCount: 20,
errorCount: 4,
rate: 0.2,
topReason: 'unknown',
topReasonCount: 4,
},
});
});

it('continues counting non-model not-found failures as errors', async () => {
await insertReviews([
reviewValues({
Expand Down
2 changes: 2 additions & 0 deletions apps/web/src/lib/code-reviews/alerting/detectors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import type { db as defaultDb } from '@/lib/drizzle';
import { sql } from '@/lib/drizzle';
import { cloud_agent_code_reviews } from '@kilocode/db/schema';
import { CODE_REVIEW_BENIGN_TERMINAL_REASONS } from '@kilocode/db/schema-types';
import { CODE_REVIEW_MODEL_UNAVAILABLE_PREFLIGHT_SQL_LITERAL } from '@/lib/code-reviews/model-unavailable';
import {
ERROR_SPIKE_RATE_THRESHOLD,
ERROR_SPIKE_WINDOW_MINUTES,
Expand Down Expand Up @@ -61,6 +62,7 @@ const systemFailureSql = sql`(
const modelNotFoundSql = sql`(
COALESCE(terminal_reason, '') = 'model_not_found'
OR COALESCE(error_message, '') ILIKE '%model not found%'
OR COALESCE(error_message, '') ILIKE ${sql.raw(CODE_REVIEW_MODEL_UNAVAILABLE_PREFLIGHT_SQL_LITERAL)}
)`;

const startedReviewsCteSql = sql`
Expand Down
Loading