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;