diff --git a/apps/web/src/app/api/internal/code-review-status/[reviewId]/route.test.ts b/apps/web/src/app/api/internal/code-review-status/[reviewId]/route.test.ts index cb41737a08..05ebde1ad1 100644 --- a/apps/web/src/app/api/internal/code-review-status/[reviewId]/route.test.ts +++ b/apps/web/src/app/api/internal/code-review-status/[reviewId]/route.test.ts @@ -534,6 +534,102 @@ describe('POST /api/internal/code-review-status/[reviewId]', () => { ); }); + it('infers selected-model-unavailable callbacks as action-required failures', 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()); + + const response = await POST( + makeRequest({ + status: 'failed', + errorMessage, + }), + makeParams(REVIEW_ID) + ); + + expect(response.status).toBe(200); + expect(mockUpdateCodeReviewAttemptForCallback).toHaveBeenCalledWith( + expect.objectContaining({ + status: 'failed', + errorMessage, + terminalReason: 'selected_model_unavailable', + }) + ); + expect(mockUpdateCodeReviewStatus).toHaveBeenCalledWith( + REVIEW_ID, + 'failed', + expect.objectContaining({ + errorMessage, + terminalReason: 'selected_model_unavailable', + }) + ); + expect(mockUpdateCodeReviewStatusIfNonTerminal).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(mockUpdateCheckRun).toHaveBeenCalledWith( + 'inst-1', + 'owner', + 'repo', + 12345, + expect.objectContaining({ + conclusion: 'action_required', + output: expect.objectContaining({ title: 'Selected model unavailable' }), + }), + 'standard' + ); + expect(mockFindKiloReviewComment).not.toHaveBeenCalled(); + }); + + it('infers model-not-allowed callbacks as action-required failures', async () => { + const errorMessage = + 'prepareSession failed (400): {"error":{"message":"Not Found: The requested model is not allowed for your team.","code":-32600,"data":{"code":"BAD_REQUEST","httpStatus":400,"path":"prepareSession"}}}'; + mockGetCodeReviewById.mockResolvedValue(makeReview()); + + const response = await POST( + makeRequest({ + status: 'failed', + errorMessage, + }), + makeParams(REVIEW_ID) + ); + + expect(response.status).toBe(200); + expect(mockUpdateCodeReviewStatus).toHaveBeenCalledWith( + REVIEW_ID, + 'failed', + expect.objectContaining({ + errorMessage, + terminalReason: 'selected_model_unavailable', + }) + ); + expect(mockDisableCodeReviewForActionRequiredFailure).toHaveBeenCalledWith( + expect.objectContaining({ + reason: 'selected_model_unavailable', + errorMessage, + }) + ); + expect(mockUpdateCheckRun).toHaveBeenCalledWith( + 'inst-1', + 'owner', + 'repo', + 12345, + expect.objectContaining({ + conclusion: 'action_required', + output: expect.objectContaining({ title: 'Selected model unavailable' }), + }), + 'standard' + ); + }); + it('infers GitHub installation and IP allow-list callback failures', async () => { mockGetCodeReviewById.mockResolvedValue(makeReview()); diff --git a/apps/web/src/lib/code-reviews/action-required-shared.ts b/apps/web/src/lib/code-reviews/action-required-shared.ts index 2940d53833..aae542053c 100644 --- a/apps/web/src/lib/code-reviews/action-required-shared.ts +++ b/apps/web/src/lib/code-reviews/action-required-shared.ts @@ -4,6 +4,7 @@ export const CODE_REVIEW_ACTION_REQUIRED_REASONS = [ 'github_installation_required', 'github_ip_allow_list', 'byok_invalid_key', + 'selected_model_unavailable', ] as const; export type CodeReviewActionRequiredReason = (typeof CODE_REVIEW_ACTION_REQUIRED_REASONS)[number]; @@ -61,6 +62,17 @@ const COPY_BY_REASON = { 'Code Reviewer was disabled because the selected BYOK API key is invalid or has been revoked. Update the key or choose another model, then enable Code Reviewer again.', gitlabDescription: 'BYOK API key needs attention for Code Reviewer', }, + selected_model_unavailable: { + title: 'Code Reviewer needs attention', + description: + 'Code Reviewer was disabled because the selected model is not available for cloud agent sessions. Choose an available model, then enable Code Reviewer again.', + recoveryLabel: 'Update Code Reviewer settings', + emailReason: 'The selected model is not available for cloud agent sessions.', + checkTitle: 'Selected model unavailable', + checkSummary: + 'Code Reviewer was disabled because the selected model is not available for cloud agent sessions. Choose an available model, then enable Code Reviewer again.', + gitlabDescription: 'Selected model unavailable for Code Reviewer', + }, } satisfies Record; const ACTION_REQUIRED_REASON_SET = new Set(CODE_REVIEW_ACTION_REQUIRED_REASONS); @@ -91,5 +103,9 @@ export function getCodeReviewActionRequiredRecoveryHref( return 'mailto:hi@kilocode.ai?subject=GitHub%20IP%20allow%20list%20for%20Code%20Reviewer'; } + if (reason === 'selected_model_unavailable') { + return organizationId ? `/organizations/${organizationId}/code-reviews` : '/code-reviews'; + } + return organizationId ? `/organizations/${organizationId}/byok` : '/byok'; } diff --git a/apps/web/src/lib/code-reviews/action-required.test.ts b/apps/web/src/lib/code-reviews/action-required.test.ts index 09a678e8d1..14b2633fcd 100644 --- a/apps/web/src/lib/code-reviews/action-required.test.ts +++ b/apps/web/src/lib/code-reviews/action-required.test.ts @@ -11,11 +11,12 @@ import { and, eq } from 'drizzle-orm'; import { classifyCodeReviewActionRequiredFailure, disableCodeReviewForActionRequiredFailure, + getCodeReviewActionRequiredRecoveryHref, getCodeReviewActionRequiredState, } from './action-required'; describe('classifyCodeReviewActionRequiredFailure', () => { - it('classifies GitHub installation, GitHub IP allow-list, and BYOK invalid key failures', () => { + it('classifies GitHub installation, GitHub IP allow-list, BYOK invalid key, and selected model failures', () => { expect( classifyCodeReviewActionRequiredFailure( 'GitHub token or active app installation required for this repository (no_installation_found)' @@ -39,6 +40,30 @@ describe('classifyCodeReviewActionRequiredFailure', () => { 'Although you appear to have the correct authorization credentials, the `acme` organization has an IP allow list enabled, and 192.0.2.1 is not permitted.' ) ).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.' + ) + ).toBe('selected_model_unavailable'); + + expect( + classifyCodeReviewActionRequiredFailure( + 'prepareSession failed (400): {"error":{"message":"Not Found: The requested model is not allowed for your team.","code":-32600,"data":{"code":"BAD_REQUEST","httpStatus":400,"path":"prepareSession"}}}' + ) + ).toBe('selected_model_unavailable'); }); it('does not classify unrelated auth, rate-limit, or BYOK quota failures', () => { @@ -49,6 +74,15 @@ describe('classifyCodeReviewActionRequiredFailure', () => { classifyCodeReviewActionRequiredFailure('[BYOK] Your account quota is exhausted.') ).toBeNull(); }); + + it('routes selected model recovery to Code Reviewer settings', () => { + expect(getCodeReviewActionRequiredRecoveryHref('selected_model_unavailable')).toBe( + '/code-reviews' + ); + expect(getCodeReviewActionRequiredRecoveryHref('selected_model_unavailable', 'org-1')).toBe( + '/organizations/org-1/code-reviews' + ); + }); }); describe('disableCodeReviewForActionRequiredFailure', () => { diff --git a/apps/web/src/lib/code-reviews/action-required.ts b/apps/web/src/lib/code-reviews/action-required.ts index 72cf213687..c3da23be97 100644 --- a/apps/web/src/lib/code-reviews/action-required.ts +++ b/apps/web/src/lib/code-reviews/action-required.ts @@ -36,6 +36,11 @@ 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'; + type AgentConfigWithRuntimeState = { runtime_state?: Record | null; }; @@ -102,6 +107,13 @@ export function classifyCodeReviewActionRequiredFailure( return 'github_ip_allow_list'; } + if ( + normalized.includes(SELECTED_MODEL_UNAVAILABLE_MESSAGE) || + normalized.includes(REQUESTED_MODEL_NOT_ALLOWED_FOR_TEAM_MESSAGE) + ) { + return 'selected_model_unavailable'; + } + return null; } diff --git a/apps/web/src/lib/code-reviews/alerting/detectors.test.ts b/apps/web/src/lib/code-reviews/alerting/detectors.test.ts index 8da8e7466e..9b50e5df27 100644 --- a/apps/web/src/lib/code-reviews/alerting/detectors.test.ts +++ b/apps/web/src/lib/code-reviews/alerting/detectors.test.ts @@ -236,12 +236,22 @@ describe('code review alert detectors', () => { reviewValues({ status: 'failed', terminal_reason: 'github_installation_required' }), reviewValues({ status: 'failed', terminal_reason: 'github_ip_allow_list' }), reviewValues({ status: 'failed', terminal_reason: 'byok_invalid_key' }), + reviewValues({ status: 'failed', terminal_reason: 'selected_model_unavailable' }), ...Array.from({ length: 13 }, () => reviewValues()), ]); await expect(evaluateErrorSpike(db)).resolves.toEqual({ tripped: false }); }); + it('excludes selected model unavailable from error-spike counts', async () => { + await insertReviews([ + reviewValues({ status: 'failed', terminal_reason: 'selected_model_unavailable' }), + ...Array.from({ length: 3 }, () => reviewValues()), + ]); + + await expect(evaluateErrorSpike(db)).resolves.toEqual({ tripped: false }); + }); + it('excludes legacy model-not-found failed rows from error-spike counts', async () => { await insertReviews([ reviewValues({ diff --git a/apps/web/src/lib/code-reviews/dispatch/dispatch-pending-reviews.test.ts b/apps/web/src/lib/code-reviews/dispatch/dispatch-pending-reviews.test.ts index 14c3d85da2..092107d2e2 100644 --- a/apps/web/src/lib/code-reviews/dispatch/dispatch-pending-reviews.test.ts +++ b/apps/web/src/lib/code-reviews/dispatch/dispatch-pending-reviews.test.ts @@ -369,6 +369,58 @@ describe('tryDispatchPendingReviews', () => { expect(mockSendCodeReviewDisabledEmail).toHaveBeenCalledTimes(1); }); + it('disables Code Reviewer for selected-model worker status failures', async () => { + const recentTimestamp = minutesAgo(1); + const owner = { type: 'user', id: testUser.id } satisfies ReviewOwner; + const errorMessage = + 'prepareSession failed (400): {"error":{"message":"Selected model is not available for this cloud agent session"}}'; + const agentConfig = await insertAgentConfigForUser(); + mockGetAgentConfigForOwner.mockResolvedValue(agentConfig); + mockDispatchReview.mockRejectedValue( + new Error("Dispatch returned terminal status 'failed' for review selected-model-review") + ); + mockGetReviewStatus.mockResolvedValue({ + reviewId: 'unused', + status: 'failed', + errorMessage, + terminalReason: 'selected_model_unavailable', + }); + + const [review] = await db + .insert(cloud_agent_code_reviews) + .values( + reviewValues({ + owner, + status: 'pending', + createdAt: recentTimestamp, + updatedAt: recentTimestamp, + }) + ) + .returning({ id: cloud_agent_code_reviews.id }); + + const result = await tryDispatchPendingReviews({ + type: 'user', + id: testUser.id, + userId: testUser.id, + }); + + const storedReview = await getStoredReview(review.id); + const storedConfig = await db.query.agent_configs.findFirst({ + where: eq(agent_configs.id, agentConfig.id), + }); + + expect(result).toEqual({ dispatched: 1, notDispatched: 0, activeCount: 1 }); + expect(storedReview).toEqual( + expect.objectContaining({ + status: 'failed', + terminalReason: 'selected_model_unavailable', + errorMessage, + }) + ); + expect(storedConfig?.is_enabled).toBe(false); + expect(mockSendCodeReviewDisabledEmail).toHaveBeenCalledTimes(1); + }); + it('refuses to prepare pending work while action-required state is present', async () => { const recentTimestamp = minutesAgo(1); const owner = { type: 'user', id: testUser.id } satisfies ReviewOwner; diff --git a/apps/web/src/routers/admin-code-reviews-router.test.ts b/apps/web/src/routers/admin-code-reviews-router.test.ts index f4120b00ee..1720b78279 100644 --- a/apps/web/src/routers/admin-code-reviews-router.test.ts +++ b/apps/web/src/routers/admin-code-reviews-router.test.ts @@ -398,6 +398,52 @@ describe('adminCodeReviewsRouter', () => { expect(segmentation.ownershipBreakdown[0]).toMatchObject({ failed: 1 }); }); + it('buckets selected-model-unavailable terminal reasons as action required', async () => { + const owner = { type: 'user', id: adminUser.id } satisfies ReviewOwner; + const [review] = await db + .insert(cloud_agent_code_reviews) + .values( + reviewValues({ + owner, + status: 'failed', + createdAt: timestamp(760), + terminalReason: 'selected_model_unavailable', + errorMessage: 'Selected model is not available for this cloud agent session', + }) + ) + .returning({ id: cloud_agent_code_reviews.id }); + + await db.insert(cloud_agent_code_review_attempts).values({ + code_review_id: review.id, + attempt_number: 1, + status: 'failed', + terminal_reason: 'selected_model_unavailable', + error_message: 'Selected model is not available for this cloud agent session', + created_at: timestamp(761), + started_at: timestamp(762), + completed_at: timestamp(763), + }); + + const caller = await createCallerForUser(adminUser.id); + const finalErrors = await caller.admin.codeReviews.getErrorAnalysis(filterInput()); + const attemptErrors = await caller.admin.codeReviews.getErrorAnalysis( + filterInput({ retryAccountingMode: 'all_attempts' }) + ); + + expect(finalErrors.categories).toEqual([ + expect.objectContaining({ category: 'Action Required', count: 1 }), + ]); + expect(finalErrors.details).toEqual([ + expect.objectContaining({ category: 'Action Required', count: 1 }), + ]); + expect(attemptErrors.categories).toEqual([ + expect.objectContaining({ category: 'Action Required', count: 1 }), + ]); + expect(attemptErrors.details).toEqual([ + expect.objectContaining({ category: 'Action Required', count: 1 }), + ]); + }); + it('classifies all-attempt model-not-found outcomes as cancellations instead of failures', async () => { const [review] = await db .insert(cloud_agent_code_reviews) diff --git a/apps/web/src/routers/admin-code-reviews-router.ts b/apps/web/src/routers/admin-code-reviews-router.ts index 5307fe2822..4a47afad62 100644 --- a/apps/web/src/routers/admin-code-reviews-router.ts +++ b/apps/web/src/routers/admin-code-reviews-router.ts @@ -73,7 +73,7 @@ const excludeModelNotFoundAttempt = sql`COALESCE(${cloud_agent_code_review_attem * Pattern matching is ordered from most-specific to least-specific. */ const errorCategoryExpr = sql`CASE - WHEN ${cloud_agent_code_reviews.terminal_reason} IN ('github_installation_required', 'github_ip_allow_list', 'byok_invalid_key') THEN 'Action Required' + WHEN ${cloud_agent_code_reviews.terminal_reason} IN ('github_installation_required', 'github_ip_allow_list', 'byok_invalid_key', 'selected_model_unavailable') THEN 'Action Required' WHEN ${cloud_agent_code_reviews.error_message} LIKE '%rate limit%' OR ${cloud_agent_code_reviews.error_message} LIKE '%Rate limit%' OR ${cloud_agent_code_reviews.error_message} LIKE '%429%' THEN 'Rate Limited' WHEN ${cloud_agent_code_reviews.error_message} LIKE '%timeout%' OR ${cloud_agent_code_reviews.error_message} LIKE '%Timeout%' OR ${cloud_agent_code_reviews.error_message} LIKE '%ETIMEDOUT%' OR ${cloud_agent_code_reviews.error_message} LIKE '%timed out%' THEN 'Timeout' WHEN ${cloud_agent_code_reviews.error_message} LIKE '%context window%' OR ${cloud_agent_code_reviews.error_message} LIKE '%token limit%' OR ${cloud_agent_code_reviews.error_message} LIKE '%too large%' OR ${cloud_agent_code_reviews.error_message} LIKE '%maximum context length%' THEN 'Context Window Exceeded' @@ -87,7 +87,7 @@ const errorCategoryExpr = sql`CASE END`; const attemptErrorCategoryExpr = sql`CASE - WHEN ${cloud_agent_code_review_attempts.terminal_reason} IN ('github_installation_required', 'github_ip_allow_list', 'byok_invalid_key') THEN 'Action Required' + WHEN ${cloud_agent_code_review_attempts.terminal_reason} IN ('github_installation_required', 'github_ip_allow_list', 'byok_invalid_key', 'selected_model_unavailable') THEN 'Action Required' WHEN ${cloud_agent_code_review_attempts.error_message} LIKE '%rate limit%' OR ${cloud_agent_code_review_attempts.error_message} LIKE '%Rate limit%' OR ${cloud_agent_code_review_attempts.error_message} LIKE '%429%' THEN 'Rate Limited' WHEN ${cloud_agent_code_review_attempts.error_message} LIKE '%timeout%' OR ${cloud_agent_code_review_attempts.error_message} LIKE '%Timeout%' OR ${cloud_agent_code_review_attempts.error_message} LIKE '%ETIMEDOUT%' OR ${cloud_agent_code_review_attempts.error_message} LIKE '%timed out%' THEN 'Timeout' WHEN ${cloud_agent_code_review_attempts.error_message} LIKE '%context window%' OR ${cloud_agent_code_review_attempts.error_message} LIKE '%token limit%' OR ${cloud_agent_code_review_attempts.error_message} LIKE '%too large%' OR ${cloud_agent_code_review_attempts.error_message} LIKE '%maximum context length%' THEN 'Context Window Exceeded' diff --git a/packages/db/src/schema-types.ts b/packages/db/src/schema-types.ts index 39bc90c332..a108087ad1 100644 --- a/packages/db/src/schema-types.ts +++ b/packages/db/src/schema-types.ts @@ -1286,6 +1286,7 @@ export const CODE_REVIEW_TERMINAL_REASONS = [ 'github_installation_required', 'github_ip_allow_list', 'byok_invalid_key', + 'selected_model_unavailable', 'user_cancelled', 'superseded', 'interrupted', @@ -1312,6 +1313,7 @@ export const CODE_REVIEW_BENIGN_TERMINAL_REASONS = [ 'github_installation_required', 'github_ip_allow_list', 'byok_invalid_key', + 'selected_model_unavailable', 'user_cancelled', 'superseded', ] as const satisfies readonly CodeReviewTerminalReason[]; diff --git a/packages/worker-utils/src/cloud-agent-next-client.ts b/packages/worker-utils/src/cloud-agent-next-client.ts index 12da76ec54..b136e1c70e 100644 --- a/packages/worker-utils/src/cloud-agent-next-client.ts +++ b/packages/worker-utils/src/cloud-agent-next-client.ts @@ -122,6 +122,7 @@ export type CloudAgentTerminalReason = | 'github_installation_required' | 'github_ip_allow_list' | 'byok_invalid_key' + | 'selected_model_unavailable' | 'user_cancelled' | 'superseded' | 'interrupted' diff --git a/services/code-review-infra/src/code-review-orchestrator.ts b/services/code-review-infra/src/code-review-orchestrator.ts index 30a547a131..ffc0a3e1ef 100644 --- a/services/code-review-infra/src/code-review-orchestrator.ts +++ b/services/code-review-infra/src/code-review-orchestrator.ts @@ -98,6 +98,11 @@ const RISKY_COMMAND_PATTERNS = [ 'vitest', ]; +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'; + function findRiskyPattern(command: string): string | null { const normalized = command.toLowerCase(); const match = RISKY_COMMAND_PATTERNS.find(pattern => normalized.includes(pattern)); @@ -731,6 +736,14 @@ export class CodeReviewOrchestrator extends DurableObject { } const message = error.message.toLowerCase(); + + if ( + message.includes(SELECTED_MODEL_UNAVAILABLE_MESSAGE) || + message.includes(REQUESTED_MODEL_NOT_ALLOWED_FOR_TEAM_MESSAGE) + ) { + return 'selected_model_unavailable'; + } + if ( message.includes('timeout') || message.includes('timed out') || diff --git a/services/code-review-infra/src/types.ts b/services/code-review-infra/src/types.ts index 78a07180da..d8eb254402 100644 --- a/services/code-review-infra/src/types.ts +++ b/services/code-review-infra/src/types.ts @@ -107,6 +107,7 @@ export const InternalStatusResponseSchema = z.object({ 'github_installation_required', 'github_ip_allow_list', 'byok_invalid_key', + 'selected_model_unavailable', 'user_cancelled', 'superseded', 'interrupted', diff --git a/services/code-review-infra/test/integration/code-review-orchestrator.test.ts b/services/code-review-infra/test/integration/code-review-orchestrator.test.ts index 3dcb0e835a..b60b03d45a 100644 --- a/services/code-review-infra/test/integration/code-review-orchestrator.test.ts +++ b/services/code-review-infra/test/integration/code-review-orchestrator.test.ts @@ -886,6 +886,86 @@ describe('CodeReviewOrchestrator recovery', () => { expect(stored?.sandboxRetryAttempted).toBeUndefined(); }); + it('maps selected-model prepareSession 400 failures to action-required terminal reason', async () => { + const stub = getReviewStub(); + const fetchMock = vi.fn(async (request: RequestInfo | URL) => { + const url = String(request); + if (url.includes('/api/internal/code-review-status/')) { + return Response.json({ success: true }); + } + if (url.includes('/trpc/prepareSession')) { + return trpcError( + 400, + 'Selected model is not available for this cloud agent session', + 'BAD_REQUEST' + ); + } + return new Response('unexpected fetch', { status: 500 }); + }); + globalThis.fetch = fetchMock; + + await runInDurableObject(stub, async (_instance: CodeReviewOrchestrator, state) => { + await state.storage.put('state', codeReview()); + await state.storage.setAlarm(Date.now() + 30_000); + }); + + const ran = await runDurableObjectAlarm(stub); + + expect(ran).toBe(true); + await expect(stub.status()).resolves.toMatchObject({ + status: 'failed', + terminalReason: 'selected_model_unavailable', + }); + expect(fetchCalls(fetchMock, '/trpc/prepareSession')).toHaveLength(1); + expect(fetchCalls(fetchMock, '/trpc/initiateFromKilocodeSessionV2')).toHaveLength(0); + expect(lastStatusUpdateBody(fetchMock)).toMatchObject({ + status: 'failed', + terminalReason: 'selected_model_unavailable', + }); + await expect(storedReview(stub)).resolves.toMatchObject({ + status: 'failed', + terminalReason: 'selected_model_unavailable', + }); + }); + + it('maps model-not-allowed prepareSession 400 failures to action-required terminal reason', async () => { + const stub = getReviewStub(); + const fetchMock = vi.fn(async (request: RequestInfo | URL) => { + const url = String(request); + if (url.includes('/api/internal/code-review-status/')) { + return Response.json({ success: true }); + } + if (url.includes('/trpc/prepareSession')) { + return trpcError( + 400, + 'Not Found: The requested model is not allowed for your team.', + 'BAD_REQUEST' + ); + } + return new Response('unexpected fetch', { status: 500 }); + }); + globalThis.fetch = fetchMock; + + await runInDurableObject(stub, async (_instance: CodeReviewOrchestrator, state) => { + await state.storage.put('state', codeReview()); + await state.storage.setAlarm(Date.now() + 30_000); + }); + + const ran = await runDurableObjectAlarm(stub); + + expect(ran).toBe(true); + await expect(stub.status()).resolves.toMatchObject({ + status: 'failed', + terminalReason: 'selected_model_unavailable', + }); + expect(fetchCalls(fetchMock, '/trpc/prepareSession')).toHaveLength(1); + expect(fetchCalls(fetchMock, '/trpc/initiateFromKilocodeSessionV2')).toHaveLength(0); + expect(lastStatusUpdateBody(fetchMock)).toMatchObject({ + status: 'failed', + terminalReason: 'selected_model_unavailable', + }); + }); + it('does not retry configured-session lookup failures nested in wrapper readiness output', async () => { const stub = getReviewStub(); const fetchMock = vi.fn(async (request: RequestInfo | URL) => {