diff --git a/apps/web/src/app/(app)/code-reviews/[reviewId]/CodeReviewDetailClient.tsx b/apps/web/src/app/(app)/code-reviews/[reviewId]/CodeReviewDetailClient.tsx index 8d517e502c..a3ba11bf84 100644 --- a/apps/web/src/app/(app)/code-reviews/[reviewId]/CodeReviewDetailClient.tsx +++ b/apps/web/src/app/(app)/code-reviews/[reviewId]/CodeReviewDetailClient.tsx @@ -106,6 +106,13 @@ export function CodeReviewDetailClient({ reviewId }: CodeReviewDetailClientProps } const review = data.review; + const latestAttempt = data.attempts.at(-1); + const displaySessionId = + review.session_id ?? + review.cli_session_id ?? + latestAttempt?.session_id ?? + latestAttempt?.cli_session_id ?? + null; const status = review.status as CodeReviewStatus; const statusInfo = getCodeReviewStatusIcon(status); const statusLabel = CODE_REVIEW_STATUS_LABELS[status] ?? review.status; @@ -209,7 +216,15 @@ export function CodeReviewDetailClient({ reviewId }: CodeReviewDetailClientProps {review.model && (
Model
-
{review.model}
+
{review.model}
+
+ )} + {displaySessionId && ( +
+
Session
+
+ {displaySessionId} +
)}
diff --git a/apps/web/src/components/code-reviews/CodeReviewStreamView.tsx b/apps/web/src/components/code-reviews/CodeReviewStreamView.tsx index 0b39f74a95..e952905875 100644 --- a/apps/web/src/components/code-reviews/CodeReviewStreamView.tsx +++ b/apps/web/src/components/code-reviews/CodeReviewStreamView.tsx @@ -20,8 +20,14 @@ import { } from '@/lib/cloud-agent-next/websocket-manager'; import type { CloudAgentEvent, StreamError } from '@/lib/cloud-agent-next/event-types'; import { CLOUD_AGENT_NEXT_WS_URL } from '@/lib/constants'; +import { isInFlightReviewStatus } from '@kilocode/app-shared/code-review'; import { getCodeReviewDisplayBehavior } from './code-review-stream-behavior'; import { fetchStreamTicket } from './fetch-stream-ticket'; +import { + appendCodeReviewDisplayEvent, + toCodeReviewDisplayEvent, + type CodeReviewDisplayEvent, +} from './code-review-stream-events'; type CodeReviewStreamViewProps = { reviewId: string; @@ -40,107 +46,7 @@ type CodeReviewAttemptSummary = { terminal_reason: string | null; }; -/** Simplified event for display in the code review log */ -type DisplayEvent = { - timestamp: string; - message: string; - content?: string; - eventType: string; -}; - -// --------------------------------------------------------------------------- -// cloud-agent-next event conversion (WebSocket flow) -// --------------------------------------------------------------------------- - -function toDisplayEvent(event: CloudAgentEvent): DisplayEvent | null { - const { streamEventType, timestamp, data } = event; - const payload = data as Record | undefined; - - if (streamEventType === 'started') { - return { timestamp, message: 'Execution started', eventType: streamEventType }; - } - if (streamEventType === 'complete') { - return { timestamp, message: 'Review completed', eventType: streamEventType }; - } - if (streamEventType === 'interrupted') { - return { timestamp, message: 'Review interrupted', eventType: streamEventType }; - } - if (streamEventType === 'error') { - const errorMsg = typeof payload?.message === 'string' ? payload.message : 'An error occurred'; - return { timestamp, message: `Error: ${errorMsg}`, eventType: streamEventType }; - } - if (streamEventType === 'kilocode' && payload) { - return toDisplayEventFromKilocode(timestamp, payload); - } - if (streamEventType === 'status') { - const status = typeof payload?.status === 'string' ? payload.status : ''; - if (status) { - return { timestamp, message: `Status: ${status}`, eventType: streamEventType }; - } - } - return null; -} - -function toDisplayEventFromKilocode( - timestamp: string, - payload: Record -): DisplayEvent | null { - const type = payload.type as string | undefined; - const properties = payload.properties as Record | undefined; - if (!type || !properties) return null; - - if (type === 'message.part.updated') { - const part = properties.part as Record | undefined; - if (!part) return null; - const partType = part.type as string | undefined; - - if (partType === 'tool') { - const toolName = part.name as string | undefined; - const state = part.state as string | undefined; - if (toolName && state === 'running') { - const input = part.input as Record | undefined; - let detail: string | undefined; - if (input) { - const filePath = input.filePath ?? input.file_path ?? input.path; - const command = input.command; - const query = input.query ?? input.pattern; - if (typeof filePath === 'string') detail = filePath; - else if (typeof command === 'string') - detail = command.length > 100 ? command.slice(0, 100) + '...' : command; - else if (typeof query === 'string') detail = query; - } - return { timestamp, message: `Tool: ${toolName}`, content: detail, eventType: 'tool' }; - } - return null; - } - - if (partType === 'text') { - const state = part.state as string | undefined; - if (state && state !== 'complete') return null; - const text = part.text as string | undefined; - if (text && text.trim()) { - const truncated = text.length > 200 ? text.slice(0, 200) + '...' : text; - return { timestamp, message: truncated, eventType: 'text' }; - } - return null; - } - return null; - } - - if (type === 'session.status') { - const status = properties.status as string | undefined; - if (status === 'idle') return { timestamp, message: 'Agent idle', eventType: 'status' }; - if (status === 'busy') return { timestamp, message: 'Agent working...', eventType: 'status' }; - return null; - } - - if (type === 'session.error') { - const error = properties.error as string | undefined; - return { timestamp, message: `Session error: ${error ?? 'Unknown error'}`, eventType: 'error' }; - } - - return null; -} +type DisplayEvent = CodeReviewDisplayEvent; // --------------------------------------------------------------------------- // Shared helpers @@ -202,7 +108,10 @@ export function CodeReviewStreamView({ const latestCompletedAttempt = [...orderedAttempts] .reverse() .find(attempt => attempt.status === 'completed'); - const defaultAttemptId = latestCompletedAttempt?.id ?? latestAttempt?.id; + const defaultAttemptId = + latestAttempt && isInFlightReviewStatus(latestAttempt.status) + ? latestAttempt.id + : (latestCompletedAttempt?.id ?? latestAttempt?.id); const queryAttemptId = searchParams.get('attemptId'); const queryAttemptExists = orderedAttempts.some(attempt => attempt.id === queryAttemptId); const effectiveAttemptId = queryAttemptExists ? (queryAttemptId ?? undefined) : defaultAttemptId; @@ -312,9 +221,9 @@ export function CodeReviewStreamView({ const handleEvent = useCallback( (event: CloudAgentEvent) => { - const displayEvent = toDisplayEvent(event); + const displayEvent = toCodeReviewDisplayEvent(event); if (displayEvent) { - setEvents(prev => [...prev, displayEvent]); + setEvents(prev => appendCodeReviewDisplayEvent(prev, displayEvent)); } if (event.streamEventType === 'complete' || event.streamEventType === 'interrupted') { setIsComplete(true); @@ -458,7 +367,7 @@ export function CodeReviewStreamView({ {shouldLoadHistory ? 'Session Log' : 'Code Review Progress'} - {shouldLoadHistory && cloudAgentSessionId && ( + {cloudAgentSessionId && ( {events.map((event, index) => (
diff --git a/apps/web/src/components/code-reviews/code-review-stream-events.test.ts b/apps/web/src/components/code-reviews/code-review-stream-events.test.ts new file mode 100644 index 0000000000..06ea4ae9cd --- /dev/null +++ b/apps/web/src/components/code-reviews/code-review-stream-events.test.ts @@ -0,0 +1,201 @@ +import { + appendCodeReviewDisplayEvent, + toCodeReviewDisplayEvent, +} from './code-review-stream-events'; +import type { CloudAgentEvent } from '@/lib/cloud-agent-next/event-types'; + +function event(streamEventType: string, data: unknown): CloudAgentEvent { + return { + eventId: 1, + executionId: 'exec-1', + sessionId: 'ses-1', + streamEventType, + timestamp: '2026-08-18T12:00:00.000Z', + data, + }; +} + +function kilocode(type: string, properties: unknown): CloudAgentEvent { + return event('kilocode', { type, properties }); +} + +describe('toCodeReviewDisplayEvent', () => { + it('shows started and complete stream events', () => { + expect(toCodeReviewDisplayEvent(event('started', {}))).toEqual({ + timestamp: '2026-08-18T12:00:00.000Z', + message: 'Execution started', + eventType: 'started', + }); + expect(toCodeReviewDisplayEvent(event('complete', {}))).toEqual({ + timestamp: '2026-08-18T12:00:00.000Z', + message: 'Review completed', + eventType: 'complete', + }); + }); + + it('shows live tool parts that use object state and part.tool', () => { + expect( + toCodeReviewDisplayEvent( + kilocode('message.part.updated', { + part: { + id: 'prt_read', + type: 'tool', + tool: 'read', + state: { status: 'running', input: { path: '/src/bug.ts' } }, + }, + }) + ) + ).toEqual({ + timestamp: '2026-08-18T12:00:00.000Z', + message: 'Tool: read', + content: '/src/bug.ts', + eventType: 'tool', + key: 'prt_read', + }); + }); + + it('shows completed tool parts so mid-run reconnects still render progress', () => { + expect( + toCodeReviewDisplayEvent( + kilocode('message.part.updated', { + part: { + id: 'prt_bash', + type: 'tool', + name: 'bash', + state: { status: 'completed', input: { command: 'ls src' } }, + }, + }) + ) + ).toEqual({ + timestamp: '2026-08-18T12:00:00.000Z', + message: 'Tool: bash', + content: 'ls src', + eventType: 'tool', + key: 'prt_bash', + }); + }); + + it('drops running tool ticks that have no part id', () => { + expect( + toCodeReviewDisplayEvent( + kilocode('message.part.updated', { + part: { + type: 'tool', + tool: 'bash', + state: { status: 'running', input: { command: 'sleep 10' } }, + }, + }) + ) + ).toBeNull(); + }); + + it('skips pending tool parts', () => { + expect( + toCodeReviewDisplayEvent( + kilocode('message.part.updated', { + part: { + type: 'tool', + tool: 'read', + state: { status: 'pending', input: {} }, + }, + }) + ) + ).toBeNull(); + }); + + it('skips streaming text parts until they complete', () => { + expect( + toCodeReviewDisplayEvent( + kilocode('message.part.updated', { + part: { type: 'text', text: 'Looking at the diff now.' }, + }) + ) + ).toBeNull(); + }); + + it('does not drop completed text parts whose state is an object', () => { + expect( + toCodeReviewDisplayEvent( + kilocode('message.part.updated', { + part: { + id: 'prt_text', + type: 'text', + text: 'Review summary', + state: { status: 'completed' }, + }, + }) + ) + ).toEqual({ + timestamp: '2026-08-18T12:00:00.000Z', + message: 'Review summary', + eventType: 'text', + key: 'prt_text', + }); + }); + + it('shows session.status when status is an object', () => { + expect( + toCodeReviewDisplayEvent( + kilocode('session.status', { sessionID: 'ses-1', status: { type: 'busy' } }) + ) + ).toEqual({ + timestamp: '2026-08-18T12:00:00.000Z', + message: 'Agent working...', + eventType: 'status', + }); + }); + + it('still accepts legacy string tool state and session status', () => { + expect( + toCodeReviewDisplayEvent( + kilocode('message.part.updated', { + part: { + id: 'prt_grep', + type: 'tool', + name: 'grep', + state: 'running', + input: { query: 'TODO' }, + }, + }) + ) + ).toEqual({ + timestamp: '2026-08-18T12:00:00.000Z', + message: 'Tool: grep', + content: 'TODO', + eventType: 'tool', + key: 'prt_grep', + }); + expect(toCodeReviewDisplayEvent(kilocode('session.status', { status: 'idle' }))).toEqual({ + timestamp: '2026-08-18T12:00:00.000Z', + message: 'Agent idle', + eventType: 'status', + }); + }); + + it('replaces a keyed live event instead of appending another row', () => { + const running = toCodeReviewDisplayEvent( + kilocode('message.part.updated', { + part: { + id: 'prt_bash', + type: 'tool', + tool: 'bash', + state: { status: 'running', input: { command: 'sleep 10' } }, + }, + }) + ); + const completed = toCodeReviewDisplayEvent( + kilocode('message.part.updated', { + part: { + id: 'prt_bash', + type: 'tool', + tool: 'bash', + state: { status: 'completed', input: { command: 'sleep 10' } }, + }, + }) + ); + expect(running).not.toBeNull(); + expect(completed).not.toBeNull(); + if (!running || !completed) return; + expect(appendCodeReviewDisplayEvent([running], completed)).toEqual([completed]); + }); +}); diff --git a/apps/web/src/components/code-reviews/code-review-stream-events.ts b/apps/web/src/components/code-reviews/code-review-stream-events.ts new file mode 100644 index 0000000000..6315236bfa --- /dev/null +++ b/apps/web/src/components/code-reviews/code-review-stream-events.ts @@ -0,0 +1,160 @@ +import type { CloudAgentEvent } from '@/lib/cloud-agent-next/event-types'; + +export type CodeReviewDisplayEvent = { + timestamp: string; + message: string; + content?: string; + eventType: string; + key?: string; +}; + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null; +} + +function partStateStatus(state: unknown): string | undefined { + if (typeof state === 'string') return state; + if (isRecord(state) && typeof state.status === 'string') return state.status; + return undefined; +} + +function partStateInput(state: unknown): Record | undefined { + if (!isRecord(state) || !isRecord(state.input)) return undefined; + return state.input; +} + +function toolDetail(input: Record | undefined): string | undefined { + if (!input) return undefined; + const filePath = input.filePath ?? input.file_path ?? input.path; + const command = input.command; + const query = input.query ?? input.pattern; + if (typeof filePath === 'string') return filePath; + if (typeof command === 'string') { + return command.length > 100 ? `${command.slice(0, 100)}...` : command; + } + if (typeof query === 'string') return query; + return undefined; +} + +function sessionStatusLabel(status: unknown): string | undefined { + if (typeof status === 'string') return status; + if (isRecord(status) && typeof status.type === 'string') return status.type; + return undefined; +} + +function partKey(part: Record): string | undefined { + return typeof part.id === 'string' && part.id ? part.id : undefined; +} + +function isCompletedStatus(status: string | undefined): boolean { + return status === 'complete' || status === 'completed'; +} + +export function appendCodeReviewDisplayEvent( + events: CodeReviewDisplayEvent[], + next: CodeReviewDisplayEvent +): CodeReviewDisplayEvent[] { + if (!next.key) return [...events, next]; + const index = events.findIndex(event => event.key === next.key); + if (index < 0) return [...events, next]; + const updated = events.slice(); + updated[index] = next; + return updated; +} + +function toDisplayEventFromKilocode( + timestamp: string, + payload: Record +): CodeReviewDisplayEvent | null { + const type = payload.type; + const properties = isRecord(payload.properties) ? payload.properties : undefined; + if (typeof type !== 'string' || !properties) return null; + + if (type === 'message.part.updated') { + const part = isRecord(properties.part) ? properties.part : undefined; + if (!part) return null; + const partType = part.type; + + if (partType === 'tool') { + const toolName = + (typeof part.tool === 'string' && part.tool) || + (typeof part.name === 'string' && part.name) || + undefined; + const status = partStateStatus(part.state); + const key = partKey(part); + if (!toolName || !status || status === 'pending') return null; + const isRunning = status === 'running'; + const isTerminal = isCompletedStatus(status) || status === 'error'; + if (!isRunning && !isTerminal) return null; + if (isRunning && !key) return null; + const detail = toolDetail( + partStateInput(part.state) ?? (isRecord(part.input) ? part.input : undefined) + ); + if (status === 'error') { + return { + timestamp, + message: `Tool: ${toolName} — error`, + content: detail, + eventType: 'error', + key, + }; + } + return { timestamp, message: `Tool: ${toolName}`, content: detail, eventType: 'tool', key }; + } + + if (partType === 'text') { + const status = partStateStatus(part.state); + if (!isCompletedStatus(status)) return null; + const text = typeof part.text === 'string' ? part.text : undefined; + if (text && text.trim()) { + const truncated = text.length > 200 ? `${text.slice(0, 200)}...` : text; + return { timestamp, message: truncated, eventType: 'text', key: partKey(part) }; + } + return null; + } + return null; + } + + if (type === 'session.status') { + const status = sessionStatusLabel(properties.status); + if (status === 'idle') return { timestamp, message: 'Agent idle', eventType: 'status' }; + if (status === 'busy') return { timestamp, message: 'Agent working...', eventType: 'status' }; + return null; + } + + if (type === 'session.error') { + const error = typeof properties.error === 'string' ? properties.error : undefined; + return { timestamp, message: `Session error: ${error ?? 'Unknown error'}`, eventType: 'error' }; + } + + return null; +} + +export function toCodeReviewDisplayEvent(event: CloudAgentEvent): CodeReviewDisplayEvent | null { + const { streamEventType, timestamp, data } = event; + const payload = isRecord(data) ? data : undefined; + + if (streamEventType === 'started') { + return { timestamp, message: 'Execution started', eventType: streamEventType }; + } + if (streamEventType === 'complete') { + return { timestamp, message: 'Review completed', eventType: streamEventType }; + } + if (streamEventType === 'interrupted') { + return { timestamp, message: 'Review interrupted', eventType: streamEventType }; + } + if (streamEventType === 'error') { + const errorMsg = typeof payload?.message === 'string' ? payload.message : 'An error occurred'; + return { timestamp, message: `Error: ${errorMsg}`, eventType: streamEventType }; + } + if (streamEventType === 'kilocode' && payload) { + return toDisplayEventFromKilocode(timestamp, payload); + } + if (streamEventType === 'status') { + const status = typeof payload?.status === 'string' ? payload.status : ''; + if (status) { + return { timestamp, message: `Status: ${status}`, eventType: streamEventType }; + } + } + return null; +} diff --git a/apps/web/src/lib/code-reviews/core/model-selection.test.ts b/apps/web/src/lib/code-reviews/core/model-selection.test.ts index 9ed61dfc2f..f6cc0d36a3 100644 --- a/apps/web/src/lib/code-reviews/core/model-selection.test.ts +++ b/apps/web/src/lib/code-reviews/core/model-selection.test.ts @@ -1,4 +1,4 @@ -import { resolveEffectiveModel } from './model-selection'; +import { resolveEffectiveModel, selectedModelFromReviewSources } from './model-selection'; import type { CodeReviewAgentConfig } from '@kilocode/db/schema-types'; const FALLBACK = 'anthropic/claude-sonnet-4.6'; @@ -119,3 +119,37 @@ describe('resolveEffectiveModel', () => { expect(result.source).toBe('global'); }); }); + +describe('selectedModelFromReviewSources', () => { + it('prefers a persisted review model', () => { + expect( + selectedModelFromReviewSources({ + persistedModel: 'openai/gpt-5', + repoFullName: 'acme/api', + config: baseConfig(), + }) + ).toBe('openai/gpt-5'); + }); + + it('uses the selected config model when the review row has none yet', () => { + expect( + selectedModelFromReviewSources({ + persistedModel: null, + repoFullName: 'acme/api', + config: baseConfig([ + { repository_id: 1, repo_full_name: 'acme/api', model_slug: 'openai/gpt-5' }, + ]), + }) + ).toBe('openai/gpt-5'); + }); + + it('returns null when neither the review nor a config is available', () => { + expect( + selectedModelFromReviewSources({ + persistedModel: null, + repoFullName: 'acme/api', + config: null, + }) + ).toBeNull(); + }); +}); diff --git a/apps/web/src/lib/code-reviews/core/model-selection.ts b/apps/web/src/lib/code-reviews/core/model-selection.ts index 73f5237add..503804e503 100644 --- a/apps/web/src/lib/code-reviews/core/model-selection.ts +++ b/apps/web/src/lib/code-reviews/core/model-selection.ts @@ -13,6 +13,7 @@ */ import type { CodeReviewAgentConfig } from '@kilocode/db/schema-types'; +import { DEFAULT_CODE_REVIEW_MODEL } from './constants'; export type EffectiveModelSelection = { modelSlug: string; @@ -59,3 +60,17 @@ export function resolveEffectiveModel( source: 'repository_override', }; } + +export function selectedModelFromReviewSources(params: { + persistedModel: string | null | undefined; + repoFullName: string | null | undefined; + config: + | Pick + | null + | undefined; +}): string | null { + if (params.persistedModel) return params.persistedModel; + if (!params.config) return null; + return resolveEffectiveModel(params.config, params.repoFullName, DEFAULT_CODE_REVIEW_MODEL) + .modelSlug; +} 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 af2e84b453..1a744dae08 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 @@ -254,6 +254,7 @@ describe('tryDispatchPendingReviews', () => { terminalReason: cloud_agent_code_reviews.terminal_reason, dispatchReservationId: cloud_agent_code_reviews.dispatch_reservation_id, errorMessage: cloud_agent_code_reviews.error_message, + model: cloud_agent_code_reviews.model, }) .from(cloud_agent_code_reviews) .where(eq(cloud_agent_code_reviews.id, reviewId)) @@ -301,6 +302,27 @@ describe('tryDispatchPendingReviews', () => { ); }); + it('persists the selected model when the review is dispatched', async () => { + const timestamp = minutesAgo(1); + const owner = { type: 'user', id: testUser.id } satisfies ReviewOwner; + mockPrepareReviewPayload.mockImplementation((params: { reviewId: string }) => ({ + reviewId: params.reviewId, + sessionInput: { prompt: 'Review this change.', model: 'openai/gpt-5' }, + })); + const [review] = await db + .insert(cloud_agent_code_reviews) + .values( + reviewValues({ owner, status: 'pending', createdAt: timestamp, updatedAt: timestamp }) + ) + .returning({ id: cloud_agent_code_reviews.id }); + + await tryDispatchPendingReviews({ type: 'user', id: testUser.id, userId: testUser.id }); + + expect(await getStoredReview(review.id)).toEqual( + expect.objectContaining({ model: 'openai/gpt-5' }) + ); + }); + it('applies a per-repository model override to the dispatched config', async () => { const timestamp = minutesAgo(1); const owner = { type: 'user', id: testUser.id } satisfies ReviewOwner; diff --git a/apps/web/src/lib/code-reviews/dispatch/dispatch-pending-reviews.ts b/apps/web/src/lib/code-reviews/dispatch/dispatch-pending-reviews.ts index ee05380ecc..9ddee330cf 100644 --- a/apps/web/src/lib/code-reviews/dispatch/dispatch-pending-reviews.ts +++ b/apps/web/src/lib/code-reviews/dispatch/dispatch-pending-reviews.ts @@ -622,7 +622,12 @@ async function dispatchReservedReview(reservation: ReservedReview, owner: Owner) try { await db .update(cloud_agent_code_reviews) - .set({ agent_version: 'v2' }) + .set({ + agent_version: 'v2', + ...(typeof dispatchPayload.sessionInput.model === 'string' + ? { model: dispatchPayload.sessionInput.model } + : {}), + }) .where(eq(cloud_agent_code_reviews.id, review.id)); } catch (error) { errorExceptInTest('[dispatchReview] Failed to persist agent version after dispatch', { diff --git a/apps/web/src/routers/code-reviews-router.test.ts b/apps/web/src/routers/code-reviews-router.test.ts index 7e430f7e3f..a800288e31 100644 --- a/apps/web/src/routers/code-reviews-router.test.ts +++ b/apps/web/src/routers/code-reviews-router.test.ts @@ -1856,6 +1856,98 @@ describe('codeReviewRouter attempts', () => { ); }); + it('returns the selected model for an in-flight review before usage is persisted', async () => { + await db.insert(agent_configs).values({ + owned_by_user_id: testUser.id, + agent_type: 'code_review', + platform: 'github', + config: { model_slug: 'openai/gpt-5' }, + is_enabled: true, + runtime_state: {}, + created_by: testUser.id, + }); + const [review] = await db + .insert(cloud_agent_code_reviews) + .values( + reviewValues(testUser.id, 'running', { + session_id: 'agent-running', + cli_session_id: 'ses_running', + model: null, + }) + ) + .returning({ id: cloud_agent_code_reviews.id }); + + const caller = await createCallerForUser(testUser.id); + const result = await caller.codeReviews.get({ reviewId: review.id }); + + expect(result).toEqual( + expect.objectContaining({ + success: true, + review: expect.objectContaining({ + model: 'openai/gpt-5', + session_id: 'agent-running', + }), + }) + ); + }); + + it('does not replace a historical review model with the current config', async () => { + await db.insert(agent_configs).values({ + owned_by_user_id: testUser.id, + agent_type: 'code_review', + platform: 'github', + config: { model_slug: 'openai/gpt-5' }, + is_enabled: true, + runtime_state: {}, + created_by: testUser.id, + }); + const sessionId = `ses_router_model_${crypto.randomUUID()}`; + const usageId = crypto.randomUUID(); + usageIds.push(usageId); + const [review] = await db + .insert(cloud_agent_code_reviews) + .values( + reviewValues(testUser.id, 'completed', { + cli_session_id: sessionId, + model: null, + created_at: '2026-06-18T09:00:00.000Z', + started_at: '2026-06-18T09:10:00.000Z', + completed_at: '2026-06-18T11:00:00.000Z', + }) + ) + .returning({ id: cloud_agent_code_reviews.id }); + + await db.insert(microdollar_usage).values({ + id: usageId, + kilo_user_id: testUser.id, + cost: 100, + input_tokens: 1000, + output_tokens: 200, + cache_write_tokens: 0, + cache_hit_tokens: 0, + created_at: '2026-06-18T10:00:00.000Z', + model: 'anthropic/claude-sonnet-4.6', + }); + await db.insert(microdollar_usage_metadata).values({ + id: usageId, + message_id: `msg_${usageId}`, + session_id: sessionId, + created_at: '2026-06-18T10:00:00.000Z', + }); + + const caller = await createCallerForUser(testUser.id); + const result = await caller.codeReviews.get({ reviewId: review.id }); + + expect(result).toEqual( + expect.objectContaining({ + success: true, + review: expect.objectContaining({ + model: 'anthropic/claude-sonnet-4.6', + }), + }) + ); + }); + it('retrigger dispatches using the newly created attempt id', async () => { await insertEnabledAgentConfig(); const [review] = await db diff --git a/apps/web/src/routers/code-reviews/code-reviews-router.ts b/apps/web/src/routers/code-reviews/code-reviews-router.ts index 743a831963..5172891e4e 100644 --- a/apps/web/src/routers/code-reviews/code-reviews-router.ts +++ b/apps/web/src/routers/code-reviews/code-reviews-router.ts @@ -50,6 +50,8 @@ import { type ListCodeReviewsResponse, } from '@/lib/code-reviews/core'; import { DEFAULT_LIST_LIMIT } from '@/lib/code-reviews/core/constants'; +import { selectedModelFromReviewSources } from '@/lib/code-reviews/core/model-selection'; +import type { CodeReviewAgentConfig } from '@kilocode/db/schema-types'; import { codeReviewWorkerClient } from '@/lib/code-reviews/client/code-review-worker-client'; import { tryDispatchPendingReviews } from '@/lib/code-reviews/dispatch/dispatch-pending-reviews'; import { getBotUserId } from '@/lib/bot-users/bot-user-service'; @@ -377,7 +379,35 @@ export const codeReviewRouter = createTRPCRouter({ cached: 0, }; - return successResult({ review: { ...review, council_result }, attempts, tokenUsage }); + let selectedModel = review.model ?? (isTerminal ? (billingUsage?.model ?? null) : null); + if (!selectedModel && !isTerminal) { + selectedModel = selectedModelFromReviewSources({ + persistedModel: null, + repoFullName: review.repo_full_name, + config: getManualCodeReviewConfig(review)?.agentConfig ?? null, + }); + if (!selectedModel) { + const owner = review.owned_by_organization_id + ? { type: 'org' as const, id: review.owned_by_organization_id } + : review.owned_by_user_id + ? { type: 'user' as const, id: review.owned_by_user_id } + : null; + if (owner) { + const agentConfig = await getAgentConfigForOwner(owner, 'code_review', review.platform); + selectedModel = selectedModelFromReviewSources({ + persistedModel: null, + repoFullName: review.repo_full_name, + config: (agentConfig?.config as CodeReviewAgentConfig | undefined) ?? null, + }); + } + } + } + + return successResult({ + review: { ...review, council_result, model: selectedModel ?? review.model }, + attempts, + tokenUsage, + }); } catch (error) { if (error instanceof TRPCError) { throw error;