feat: add automatic review loop (#1840)

This commit is contained in:
Bohdan Triapitsyn
2026-06-26 19:29:44 +03:00
committed by GitHub
parent 45df19c3b2
commit 1f549e4525
21 changed files with 787 additions and 35 deletions
+7
View File
@@ -1232,12 +1232,19 @@ export const dict = {
'diffView.reviewDialog.title': 'Review changes',
'diffView.reviewDialog.description': 'Start a separate review session for the current changes.',
'diffView.reviewDialog.generateHandoff': 'Generate handoff',
'diffView.reviewDialog.autoReview': 'Run automatic review loop',
'diffView.reviewDialog.info': 'This flow works best when started from the session where the changes were implemented.',
'diffView.reviewDialog.actions.cancel': 'Cancel',
'diffView.reviewDialog.actions.start': 'Review',
'diffView.reviewDialog.actions.starting': 'Starting...',
'diffView.reviewDialog.toast.noSessionDirectory': 'Session directory is unavailable',
'diffView.reviewDialog.toast.startFailed': 'Failed to start review flow',
'chat.autoReview.title': 'Code review loop is running',
'chat.autoReview.status.waitingForReviewer': 'Waiting for reviewer',
'chat.autoReview.status.waitingForImplementer': 'Waiting for implementer',
'chat.autoReview.reviewSessionLabel': 'Review session',
'chat.autoReview.actions.open': 'Open',
'chat.autoReview.actions.stop': 'Stop',
'diffView.hunk.label': 'Hunks',
'diffView.hunk.stage': 'Stage',
'diffView.hunk.unstage': 'Unstage',
+7
View File
@@ -1198,12 +1198,19 @@ export const dict: Record<I18nKey, string> = {
'diffView.reviewDialog.title': 'Revisar cambios',
'diffView.reviewDialog.description': 'Inicia una sesión de revisión separada para los cambios actuales.',
'diffView.reviewDialog.generateHandoff': 'Generar handoff',
'diffView.reviewDialog.autoReview': 'Ejecutar ciclo de revisión automática',
'diffView.reviewDialog.info': 'Este flujo funciona mejor si se inicia desde la sesión donde se implementaron los cambios.',
'diffView.reviewDialog.actions.cancel': 'Cancelar',
'diffView.reviewDialog.actions.start': 'Revisar',
'diffView.reviewDialog.actions.starting': 'Iniciando...',
'diffView.reviewDialog.toast.noSessionDirectory': 'El directorio de la sesión no está disponible',
'diffView.reviewDialog.toast.startFailed': 'No se pudo iniciar el flujo de revisión',
'chat.autoReview.title': 'El ciclo de revisión de código está en curso',
'chat.autoReview.status.waitingForReviewer': 'Esperando al revisor',
'chat.autoReview.status.waitingForImplementer': 'Esperando al implementador',
'chat.autoReview.reviewSessionLabel': 'Sesión de revisión',
'chat.autoReview.actions.open': 'Abrir',
'chat.autoReview.actions.stop': 'Detener',
"diffView.hunk.label": "Fragmentos",
"diffView.hunk.stage": "Preparar",
"diffView.hunk.unstage": "Quitar",
+7
View File
@@ -1104,12 +1104,19 @@ export const dict = {
'diffView.reviewDialog.title': 'Revoir les changements',
'diffView.reviewDialog.description': 'Démarre une session de revue séparée pour les changements actuels.',
'diffView.reviewDialog.generateHandoff': 'Générer un handoff',
'diffView.reviewDialog.autoReview': 'Lancer la boucle de revue automatique',
'diffView.reviewDialog.info': 'Ce flux fonctionne mieux lorsquil est lancé depuis la session où les changements ont été implémentés.',
'diffView.reviewDialog.actions.cancel': 'Annuler',
'diffView.reviewDialog.actions.start': 'Revoir',
'diffView.reviewDialog.actions.starting': 'Démarrage...',
'diffView.reviewDialog.toast.noSessionDirectory': 'Le dossier de session est indisponible',
'diffView.reviewDialog.toast.startFailed': 'Impossible de démarrer le flux de revue',
'chat.autoReview.title': 'La boucle de revue de code est en cours',
'chat.autoReview.status.waitingForReviewer': 'En attente du reviewer',
'chat.autoReview.status.waitingForImplementer': 'En attente de limplémenteur',
'chat.autoReview.reviewSessionLabel': 'Session de revue',
'chat.autoReview.actions.open': 'Ouvrir',
'chat.autoReview.actions.stop': 'Arrêter',
'diffView.hunk.label': 'Sections',
'diffView.hunk.stage': 'Préparer',
'diffView.hunk.unstage': 'Retirer',
+7
View File
@@ -1235,12 +1235,19 @@ export const dict: Record<I18nKey, string> = {
'diffView.reviewDialog.title': '변경 사항 리뷰',
'diffView.reviewDialog.description': '현재 변경 사항을 위한 별도 리뷰 세션을 시작합니다.',
'diffView.reviewDialog.generateHandoff': '핸드오프 생성',
'diffView.reviewDialog.autoReview': '자동 리뷰 루프 실행',
'diffView.reviewDialog.info': '이 흐름은 변경 사항을 구현한 세션에서 시작할 때 가장 잘 작동합니다.',
'diffView.reviewDialog.actions.cancel': '취소',
'diffView.reviewDialog.actions.start': '리뷰',
'diffView.reviewDialog.actions.starting': '시작 중...',
'diffView.reviewDialog.toast.noSessionDirectory': '세션 디렉터리를 사용할 수 없습니다',
'diffView.reviewDialog.toast.startFailed': '리뷰 흐름을 시작하지 못했습니다',
'chat.autoReview.title': '코드 리뷰 루프 실행 중',
'chat.autoReview.status.waitingForReviewer': '리뷰어를 기다리는 중',
'chat.autoReview.status.waitingForImplementer': '구현 에이전트를 기다리는 중',
'chat.autoReview.reviewSessionLabel': '리뷰 세션',
'chat.autoReview.actions.open': '열기',
'chat.autoReview.actions.stop': '중지',
'diffView.hunk.label': '허크',
'diffView.hunk.stage': '스테이지',
'diffView.hunk.unstage': '스테이지 해제',
+7
View File
@@ -1432,12 +1432,19 @@ export const dict: Record<I18nKey, string> = {
'diffView.reviewDialog.title': 'Przegląd zmian',
'diffView.reviewDialog.description': 'Uruchom osobną sesję review dla bieżących zmian.',
'diffView.reviewDialog.generateHandoff': 'Wygeneruj handoff',
'diffView.reviewDialog.autoReview': 'Uruchom automatyczną pętlę review',
'diffView.reviewDialog.info': 'Ten flow działa najlepiej, gdy jest uruchomiony z sesji, w której zaimplementowano zmiany.',
'diffView.reviewDialog.actions.cancel': 'Anuluj',
'diffView.reviewDialog.actions.start': 'Review',
'diffView.reviewDialog.actions.starting': 'Uruchamianie...',
'diffView.reviewDialog.toast.noSessionDirectory': 'Katalog sesji jest niedostępny',
'diffView.reviewDialog.toast.startFailed': 'Nie udało się uruchomić flow review',
'chat.autoReview.title': 'Pętla code review trwa',
'chat.autoReview.status.waitingForReviewer': 'Oczekiwanie na reviewera',
'chat.autoReview.status.waitingForImplementer': 'Oczekiwanie na implementatora',
'chat.autoReview.reviewSessionLabel': 'Sesja review',
'chat.autoReview.actions.open': 'Otwórz',
'chat.autoReview.actions.stop': 'Zatrzymaj',
'diffView.hunk.label': 'Fragmenty',
'diffView.hunk.stage': 'Przygotuj',
'diffView.hunk.unstage': 'Cofnij',
@@ -1198,12 +1198,19 @@ export const dict: Record<I18nKey, string> = {
'diffView.reviewDialog.title': 'Revisar alterações',
'diffView.reviewDialog.description': 'Inicia uma sessão de revisão separada para as alterações atuais.',
'diffView.reviewDialog.generateHandoff': 'Gerar handoff',
'diffView.reviewDialog.autoReview': 'Executar ciclo de revisão automática',
'diffView.reviewDialog.info': 'Este fluxo funciona melhor quando iniciado na sessão em que as alterações foram implementadas.',
'diffView.reviewDialog.actions.cancel': 'Cancelar',
'diffView.reviewDialog.actions.start': 'Revisar',
'diffView.reviewDialog.actions.starting': 'Iniciando...',
'diffView.reviewDialog.toast.noSessionDirectory': 'O diretório da sessão está indisponível',
'diffView.reviewDialog.toast.startFailed': 'Falha ao iniciar o fluxo de revisão',
'chat.autoReview.title': 'O ciclo de revisão de código está em andamento',
'chat.autoReview.status.waitingForReviewer': 'Aguardando o revisor',
'chat.autoReview.status.waitingForImplementer': 'Aguardando o implementador',
'chat.autoReview.reviewSessionLabel': 'Sessão de revisão',
'chat.autoReview.actions.open': 'Abrir',
'chat.autoReview.actions.stop': 'Parar',
"diffView.hunk.label": "Trechos",
"diffView.hunk.stage": "Preparar",
"diffView.hunk.unstage": "Remover",
+7
View File
@@ -1198,12 +1198,19 @@ export const dict: Record<I18nKey, string> = {
'diffView.reviewDialog.title': 'Ревʼю змін',
'diffView.reviewDialog.description': 'Запустити окрему сесію ревʼю для поточних змін.',
'diffView.reviewDialog.generateHandoff': 'Згенерувати handoff',
'diffView.reviewDialog.autoReview': 'Запустити автоматичний цикл ревʼю',
'diffView.reviewDialog.info': 'Цей flow найкраще працює, якщо запускати його із сесії, де були імплементовані зміни.',
'diffView.reviewDialog.actions.cancel': 'Скасувати',
'diffView.reviewDialog.actions.start': 'Ревʼю',
'diffView.reviewDialog.actions.starting': 'Запуск...',
'diffView.reviewDialog.toast.noSessionDirectory': 'Директорія сесії недоступна',
'diffView.reviewDialog.toast.startFailed': 'Не вдалося запустити review flow',
'chat.autoReview.title': 'Цикл код-ревʼю триває',
'chat.autoReview.status.waitingForReviewer': 'Очікуємо ревʼювера',
'chat.autoReview.status.waitingForImplementer': 'Очікуємо імплементатора',
'chat.autoReview.reviewSessionLabel': 'Сесія ревʼю',
'chat.autoReview.actions.open': 'Відкрити',
'chat.autoReview.actions.stop': 'Зупинити',
"diffView.hunk.label": "Шматки",
"diffView.hunk.stage": "Додати",
"diffView.hunk.unstage": "Прибрати",
@@ -1198,12 +1198,19 @@ export const dict: Record<I18nKey, string> = {
'diffView.reviewDialog.title': 'Review changes',
'diffView.reviewDialog.description': 'Start a separate review session for the current changes.',
'diffView.reviewDialog.generateHandoff': 'Generate handoff',
'diffView.reviewDialog.autoReview': '运行自动审查循环',
'diffView.reviewDialog.info': 'This flow works best if started in the session where the changes were implemented.',
'diffView.reviewDialog.actions.cancel': 'Cancel',
'diffView.reviewDialog.actions.start': 'Review',
'diffView.reviewDialog.actions.starting': 'Starting...',
'diffView.reviewDialog.toast.noSessionDirectory': 'Session directory is unavailable',
'diffView.reviewDialog.toast.startFailed': 'Failed to start review flow',
'chat.autoReview.title': '代码审查循环正在运行',
'chat.autoReview.status.waitingForReviewer': '等待审查者',
'chat.autoReview.status.waitingForImplementer': '等待实现者',
'chat.autoReview.reviewSessionLabel': '审查会话',
'chat.autoReview.actions.open': '打开',
'chat.autoReview.actions.stop': '停止',
'diffView.hunk.label': '代码块',
'diffView.hunk.stage': '暂存',
'diffView.hunk.unstage': '取消暂存',
@@ -1208,12 +1208,19 @@ export const dict: Record<I18nKey, string> = {
'diffView.reviewDialog.title': 'Review changes',
'diffView.reviewDialog.description': 'Start a separate review session for the current changes.',
'diffView.reviewDialog.generateHandoff': 'Generate handoff',
'diffView.reviewDialog.autoReview': '執行自動審查循環',
'diffView.reviewDialog.info': 'This flow works best if started in the session where the changes were implemented.',
'diffView.reviewDialog.actions.cancel': 'Cancel',
'diffView.reviewDialog.actions.start': 'Review',
'diffView.reviewDialog.actions.starting': 'Starting...',
'diffView.reviewDialog.toast.noSessionDirectory': 'Session directory is unavailable',
'diffView.reviewDialog.toast.startFailed': 'Failed to start review flow',
'chat.autoReview.title': '程式碼審查循環執行中',
'chat.autoReview.status.waitingForReviewer': '等待審查者',
'chat.autoReview.status.waitingForImplementer': '等待實作者',
'chat.autoReview.reviewSessionLabel': '審查工作階段',
'chat.autoReview.actions.open': '開啟',
'chat.autoReview.actions.stop': '停止',
'diffView.hunk.label': '程式碼區塊',
'diffView.hunk.stage': '暫存',
'diffView.hunk.unstage': '取消暫存',
+81
View File
@@ -0,0 +1,81 @@
import { beforeEach, describe, expect, test } from 'bun:test';
import type { Message } from '@opencode-ai/sdk/v2/client';
import { switchRuntimeEndpoint } from './runtime-switch';
import {
assertAutoReviewRuntimeStillCurrent,
claimAutoReviewForward,
releaseAutoReviewForward,
hasFinalReviewMarker,
isAutoReviewRuntimeCurrent,
isExpectedAutoReviewAssistantParent,
stripFinalReviewMarker,
} from './reviewFlow';
import type { AutoReviewRun } from '@/stores/useAutoReviewStore';
describe('reviewFlow auto-review helpers', () => {
beforeEach(() => {
switchRuntimeEndpoint({ apiBaseUrl: 'http://runtime-a.test', runtimeKey: 'runtime-a' });
});
test('detects and strips final review marker only from the final line', () => {
const text = 'No remaining issues.\n\nFINAL_REVIEW_STATUS: no_remaining_findings\n';
expect(hasFinalReviewMarker(text)).toBe(true);
expect(stripFinalReviewMarker(text)).toBe('No remaining issues.');
});
test('detects and strips final review marker case-insensitively', () => {
const text = 'No findings.\nFINAL_REVIEW_STATUS: no_remaining_findINGS\n';
expect(hasFinalReviewMarker(text)).toBe(true);
expect(stripFinalReviewMarker(text)).toBe('No findings.');
});
test('does not treat quoted or non-final marker text as completion', () => {
const text = 'The marker is FINAL_REVIEW_STATUS: no_remaining_findings, but issues remain.';
expect(hasFinalReviewMarker(text)).toBe(false);
expect(stripFinalReviewMarker(text)).toBe(text);
});
test('requires assistant parent to match the auto-sent user message when provided', () => {
const matching = { id: 'msg_assistant_1', parentID: 'msg_user_auto' } as Message;
const unrelated = { id: 'msg_assistant_2', parentID: 'msg_user_manual' } as Message;
expect(isExpectedAutoReviewAssistantParent(matching, 'msg_user_auto')).toBe(true);
expect(isExpectedAutoReviewAssistantParent(unrelated, 'msg_user_auto')).toBe(false);
expect(isExpectedAutoReviewAssistantParent(unrelated)).toBe(true);
});
test('runtime guard rejects runs from a stale runtime', () => {
expect(isAutoReviewRuntimeCurrent('runtime-a')).toBe(true);
switchRuntimeEndpoint({ apiBaseUrl: 'http://runtime-b.test', runtimeKey: 'runtime-b' });
expect(isAutoReviewRuntimeCurrent('runtime-a')).toBe(false);
expect(() => assertAutoReviewRuntimeStillCurrent('runtime-a')).toThrow('runtime changed');
});
test('claims only one in-flight forward for the same auto-review message', () => {
const run: AutoReviewRun = {
originalSessionID: 'original-1',
reviewSessionID: 'review-1',
directory: '/workspace',
runtimeKey: 'runtime-a',
status: 'running',
phase: 'waiting_for_reviewer',
iteration: 0,
maxIterations: 15,
expectedAssistantParentID: 'msg_user_prompt',
};
const key = claimAutoReviewForward(run, 'msg_assistant_review');
expect(typeof key).toBe('string');
expect(claimAutoReviewForward(run, 'msg_assistant_review')).toBeNull();
releaseAutoReviewForward(key!);
const nextKey = claimAutoReviewForward(run, 'msg_assistant_review');
expect(nextKey).toBe(key);
releaseAutoReviewForward(nextKey!);
});
});
+335 -28
View File
@@ -11,17 +11,25 @@ import {
withReviewSessionMarker,
} from '@/lib/sessionReviewMetadata';
import { useConfigStore } from '@/stores/useConfigStore';
import { useAutoReviewStore, type AutoReviewRun } from '@/stores/useAutoReviewStore';
import { useGlobalSessionsStore } from '@/stores/useGlobalSessionsStore';
import { useUIStore } from '@/stores/useUIStore';
import { optimisticSend, patchSessionMetadata, waitForConnectionOrThrow } from '@/sync/session-actions';
import { useSelectionStore } from '@/sync/selection-store';
import { useSessionUIStore } from '@/sync/session-ui-store';
import { getSyncMessages, getSyncParts, registerSessionDirectory } from '@/sync/sync-refs';
import { getSyncMessages, getSyncParts, getSyncSessionStatus, registerSessionDirectory } from '@/sync/sync-refs';
import { markPendingUserSendAnimation } from '@/lib/userSendAnimation';
import { getRuntimeKey } from '@/lib/runtime-switch';
const HANDOFF_TIMEOUT_MS = 180_000;
const HANDOFF_POLL_MS = 400;
const AUTO_REVIEW_POLL_MS = 300;
const AUTO_REVIEW_MAX_ITERATIONS = 15;
const AUTO_REVIEW_FINAL_MARKER = 'FINAL_REVIEW_STATUS: no_remaining_findings';
const AUTO_REVIEW_FINAL_MARKER_NORMALIZED = AUTO_REVIEW_FINAL_MARKER.toLowerCase();
const REVIEW_SESSION_TITLE = 'Review of workspace changes';
const activeAutoReviewLoops = new Set<string>();
const activeAutoReviewForwardKeys = new Set<string>();
type SessionModelContext = {
providerID: string;
@@ -36,6 +44,12 @@ type StartReviewFlowInput = SessionModelContext & {
agentMentionName?: string;
generateHandoff?: boolean;
returnAfterHandoffRequest?: boolean;
autoReview?: boolean;
};
type AssistantTextMessage = {
id: string;
text: string;
};
const isMessageCompleted = (message: Message): boolean => {
@@ -55,6 +69,234 @@ const getMessageRole = (message: Message): string => {
return typeof role === 'string' ? role : '';
};
const getMessageParentID = (message: Message): string | null => {
const parentID = (message as { parentID?: unknown }).parentID;
return typeof parentID === 'string' && parentID.trim().length > 0 ? parentID : null;
};
const isCompactionCommandMessage = (message: Message, directory: string): boolean => {
const parts = getSyncParts(message.id, directory);
return parts.some((part) => {
const type = (part as { type?: unknown }).type;
if (type === 'compaction') return true;
if (type !== 'text') return false;
const text = (part as { text?: unknown }).text;
return typeof text === 'string' && text.trim() === '/compact';
});
};
const getLatestAssistantTextMessage = (
sessionID: string,
directory: string,
lastForwardedMessageID?: string,
afterCreatedAt = 0,
expectedParentID?: string,
): AssistantTextMessage | null => {
const messages = getSyncMessages(sessionID, directory);
const compactionCommandIDs = new Set<string>();
for (const message of messages) {
if (isCompactionCommandMessage(message, directory)) {
compactionCommandIDs.add(message.id);
}
}
for (let index = messages.length - 1; index >= 0; index -= 1) {
const message = messages[index];
if (message.id === lastForwardedMessageID) return null;
if (getMessageRole(message) !== 'assistant') continue;
if (!isMessageCompleted(message)) continue;
if (getMessageCreatedAt(message) < afterCreatedAt - 1000) continue;
const parentID = getMessageParentID(message);
if (!isExpectedAutoReviewAssistantParent(message, expectedParentID)) continue;
if (parentID && compactionCommandIDs.has(parentID)) continue;
const text = flattenAssistantTextParts(getSyncParts(message.id, directory)).trim();
if (!text) continue;
return { id: message.id, text };
}
return null;
};
const isSessionIdle = (sessionID: string, directory: string): boolean => {
const status = getSyncSessionStatus(sessionID, directory);
return status?.type === 'idle';
};
export const isAutoReviewRuntimeCurrent = (runtimeKey: string): boolean => runtimeKey === getRuntimeKey();
const stopRunForRuntimeMismatch = (run: AutoReviewRun): void => {
useAutoReviewStore.getState().updateRun(run.originalSessionID, (current) => ({
...current,
status: 'stopped',
error: 'Auto-review stopped because the runtime changed.',
}));
};
export const assertAutoReviewRuntimeStillCurrent = (expectedRuntimeKey?: string): void => {
if (expectedRuntimeKey && !isAutoReviewRuntimeCurrent(expectedRuntimeKey)) {
throw new Error('Auto-review stopped because the runtime changed.');
}
};
const isRuntimeChangeError = (error: unknown): boolean => {
const message = error instanceof Error ? error.message : String(error);
return message.includes('runtime changed');
};
export const hasFinalReviewMarker = (text: string): boolean => {
const lines = text.trim().split('\n').map((line) => line.trim()).filter(Boolean);
return lines.at(-1)?.toLowerCase() === AUTO_REVIEW_FINAL_MARKER_NORMALIZED;
};
export const stripFinalReviewMarker = (text: string): string => {
const lines = text.trimEnd().split('\n');
while (lines.length > 0 && lines[lines.length - 1].trim() === '') lines.pop();
if (lines.at(-1)?.trim().toLowerCase() === AUTO_REVIEW_FINAL_MARKER_NORMALIZED) {
lines.pop();
}
return lines.join('\n').trim();
};
export const isExpectedAutoReviewAssistantParent = (message: Message, expectedParentID?: string): boolean => {
if (!expectedParentID) return true;
return getMessageParentID(message) === expectedParentID;
};
export const getAutoReviewForwardKey = (run: AutoReviewRun, messageID: string): string => [
run.runtimeKey,
run.originalSessionID,
run.phase,
run.expectedAssistantParentID ?? '',
messageID,
].join(':');
export const claimAutoReviewForward = (run: AutoReviewRun, messageID: string): string | null => {
const key = getAutoReviewForwardKey(run, messageID);
if (activeAutoReviewForwardKeys.has(key)) return null;
activeAutoReviewForwardKeys.add(key);
return key;
};
export const releaseAutoReviewForward = (key: string): void => {
activeAutoReviewForwardKeys.delete(key);
};
const autoReviewReviewerInstructions = (): Array<{ text: string; synthetic: true }> => [{
synthetic: true,
text: `This review is part of an automatic review loop. If there are no remaining issues, end your response with this exact final line:\n${AUTO_REVIEW_FINAL_MARKER}\nIf you found issues that require changes, do not include that final status line.`,
}];
const runAutoReviewLoop = async (originalSessionID: string): Promise<void> => {
while (true) {
const run = useAutoReviewStore.getState().runsByOriginalSessionID[originalSessionID];
if (!run || run.status !== 'running') return;
if (!isAutoReviewRuntimeCurrent(run.runtimeKey)) {
stopRunForRuntimeMismatch(run);
return;
}
const sourceSessionID = run.phase === 'waiting_for_reviewer' ? run.reviewSessionID : run.originalSessionID;
if (!isSessionIdle(sourceSessionID, run.directory)) {
await new Promise((resolve) => setTimeout(resolve, AUTO_REVIEW_POLL_MS));
continue;
}
const latest = getLatestAssistantTextMessage(
sourceSessionID,
run.directory,
run.lastForwardedMessageID,
run.waitAfterCreatedAt,
run.expectedAssistantParentID,
);
if (!latest) {
await new Promise((resolve) => setTimeout(resolve, AUTO_REVIEW_POLL_MS));
continue;
}
if (run.phase === 'waiting_for_reviewer') {
const forwardKey = claimAutoReviewForward(run, latest.id);
if (!forwardKey) {
await new Promise((resolve) => setTimeout(resolve, AUTO_REVIEW_POLL_MS));
continue;
}
if (!isAutoReviewRuntimeCurrent(run.runtimeKey)) {
releaseAutoReviewForward(forwardKey);
stopRunForRuntimeMismatch(run);
return;
}
try {
const waitAfterCreatedAt = Date.now();
const isFinalReview = hasFinalReviewMarker(latest.text);
const reviewFeedback = isFinalReview ? stripFinalReviewMarker(latest.text) : latest.text;
const sentMessageID = await sendReviewFeedbackToOriginal(run.reviewSessionID, run.directory, reviewFeedback, run.runtimeKey);
if (isFinalReview) {
useAutoReviewStore.getState().completeRun(run.originalSessionID);
return;
}
useAutoReviewStore.getState().updateRun(run.originalSessionID, (current) => ({
...current,
phase: 'waiting_for_implementer',
lastForwardedMessageID: latest.id,
expectedAssistantParentID: sentMessageID,
waitAfterCreatedAt,
}));
} finally {
releaseAutoReviewForward(forwardKey);
}
} else {
if (run.iteration >= run.maxIterations) {
useAutoReviewStore.getState().stopRun(run.originalSessionID);
return;
}
const forwardKey = claimAutoReviewForward(run, latest.id);
if (!forwardKey) {
await new Promise((resolve) => setTimeout(resolve, AUTO_REVIEW_POLL_MS));
continue;
}
if (!isAutoReviewRuntimeCurrent(run.runtimeKey)) {
releaseAutoReviewForward(forwardKey);
stopRunForRuntimeMismatch(run);
return;
}
try {
const waitAfterCreatedAt = Date.now();
const sentMessageID = await sendImplementationResponseToReviewer(run.originalSessionID, run.directory, latest.text, true, run.runtimeKey);
useAutoReviewStore.getState().updateRun(run.originalSessionID, (current) => ({
...current,
phase: 'waiting_for_reviewer',
iteration: current.iteration + 1,
lastForwardedMessageID: latest.id,
expectedAssistantParentID: sentMessageID,
waitAfterCreatedAt,
}));
} finally {
releaseAutoReviewForward(forwardKey);
}
}
}
};
const startAutoReviewRun = (run: AutoReviewRun): void => {
useAutoReviewStore.getState().upsertRun(run);
resumeAutoReviewRun(run.originalSessionID);
};
export const resumeAutoReviewRun = (originalSessionID: string): void => {
const run = useAutoReviewStore.getState().runsByOriginalSessionID[originalSessionID];
if (!run || run.status !== 'running' || !isAutoReviewRuntimeCurrent(run.runtimeKey) || activeAutoReviewLoops.has(originalSessionID)) return;
activeAutoReviewLoops.add(originalSessionID);
void runAutoReviewLoop(run.originalSessionID).catch((error) => {
console.error('[review-flow] auto-review loop failed', error);
useAutoReviewStore.getState().updateRun(run.originalSessionID, (current) => ({
...current,
status: isRuntimeChangeError(error) ? 'stopped' : 'error',
error: error instanceof Error ? error.message : String(error),
}));
}).finally(() => {
activeAutoReviewLoops.delete(originalSessionID);
});
};
const waitForAssistantText = async (sessionID: string, directory: string, afterCreatedAt: number): Promise<string> => {
const deadline = Date.now() + HANDOFF_TIMEOUT_MS;
while (Date.now() < deadline) {
@@ -118,7 +360,9 @@ const sendPlainMessage = async (
text: string,
modelContext?: SessionModelContext | null,
additionalParts?: Array<{ text: string; synthetic?: boolean }>,
): Promise<void> => {
expectedRuntimeKey?: string,
): Promise<string> => {
assertAutoReviewRuntimeStillCurrent(expectedRuntimeKey);
const resolved = modelContext ?? resolveModelContext(sessionID);
if (!resolved) throw new Error('Select a model before sending review flow messages');
const selection = useSelectionStore.getState();
@@ -129,6 +373,7 @@ const sendPlainMessage = async (
selection.saveAgentModelVariantForSession(sessionID, resolved.agent, resolved.providerID, resolved.modelID, resolved.variant);
}
markPendingUserSendAnimation(sessionID);
let sentMessageID: string | null = null;
await optimisticSend({
sessionId: sessionID,
content: text,
@@ -136,19 +381,28 @@ const sendPlainMessage = async (
providerID: resolved.providerID,
modelID: resolved.modelID,
agent: resolved.agent,
onMessageID: (messageID) => {
sentMessageID = messageID;
},
beforeOptimisticInsert: () => assertAutoReviewRuntimeStillCurrent(expectedRuntimeKey),
onOptimisticInsert: () => requestChatForceScrollBottom(sessionID),
send: (messageID) => opencodeClient.sendMessage({
id: sessionID,
directory,
providerID: resolved.providerID,
modelID: resolved.modelID,
agent: resolved.agent,
variant: resolved.variant,
text,
additionalParts,
messageId: messageID,
}).then(() => undefined),
send: (messageID) => {
assertAutoReviewRuntimeStillCurrent(expectedRuntimeKey);
return opencodeClient.sendMessage({
id: sessionID,
directory,
providerID: resolved.providerID,
modelID: resolved.modelID,
agent: resolved.agent,
variant: resolved.variant,
text,
additionalParts,
messageId: messageID,
}).then(() => undefined);
},
});
if (!sentMessageID) throw new Error('Failed to prepare review flow message');
return sentMessageID;
};
const requestChatForceScrollBottom = (sessionId: string): void => {
@@ -174,11 +428,14 @@ const getSessionOrNull = async (sessionID: string, directory: string): Promise<S
}
};
const createOrReuseReviewSession = async (originalSessionID: string, directory: string): Promise<Session> => {
const createOrReuseReviewSession = async (originalSessionID: string, directory: string, expectedRuntimeKey?: string): Promise<Session> => {
assertAutoReviewRuntimeStillCurrent(expectedRuntimeKey);
const original = await opencodeClient.getSession(originalSessionID, directory);
assertAutoReviewRuntimeStillCurrent(expectedRuntimeKey);
const existingReviewID = getReviewSessionID(original);
if (existingReviewID) {
const existing = await getSessionOrNull(existingReviewID, directory);
assertAutoReviewRuntimeStillCurrent(expectedRuntimeKey);
if (existing && isReviewSession(existing)) return existing;
await patchSessionMetadata(originalSessionID, directory, (metadata) => {
const next = { ...metadata };
@@ -192,14 +449,18 @@ const createOrReuseReviewSession = async (originalSessionID: string, directory:
});
}
assertAutoReviewRuntimeStillCurrent(expectedRuntimeKey);
const review = await opencodeClient.createSession({
title: REVIEW_SESSION_TITLE,
metadata: withReviewSessionMarker({}, originalSessionID),
}, directory);
assertAutoReviewRuntimeStillCurrent(expectedRuntimeKey);
registerSessionDirectory(review.id, directory);
try {
assertAutoReviewRuntimeStillCurrent(expectedRuntimeKey);
await patchSessionMetadata(originalSessionID, directory, (metadata) => withReviewSessionLink(metadata, review.id));
} catch (error) {
assertAutoReviewRuntimeStillCurrent(expectedRuntimeKey);
await opencodeClient.deleteSession(review.id, directory).catch((deleteError) => {
console.warn('[review-flow] failed to delete unlinked review session after link failure', deleteError);
});
@@ -211,6 +472,7 @@ const createOrReuseReviewSession = async (originalSessionID: string, directory:
export const startReviewFlow = async (input: StartReviewFlowInput): Promise<void> => {
await waitForConnectionOrThrow();
const expectedAutoReviewRuntimeKey = input.autoReview ? getRuntimeKey() : undefined;
let reviewPrompt: string;
if (input.generateHandoff ?? true) {
@@ -219,19 +481,38 @@ export const startReviewFlow = async (input: StartReviewFlowInput): Promise<void
const startedAt = Date.now();
await sendPlainMessage(input.originalSessionID, input.directory, visibleText, null, [
{ text: instructionsText, synthetic: true },
]);
], expectedAutoReviewRuntimeKey);
const continueFromHandoff = async (): Promise<void> => {
const handoff = await waitForAssistantText(input.originalSessionID, input.directory, startedAt);
assertAutoReviewRuntimeStillCurrent(expectedAutoReviewRuntimeKey);
const handoffReviewPrompt = await renderMagicPrompt('session.reviewSession.visible', { handoff });
const reviewSession = await createOrReuseReviewSession(input.originalSessionID, input.directory);
await sendPlainMessage(reviewSession.id, input.directory, handoffReviewPrompt, {
const reviewSession = await createOrReuseReviewSession(input.originalSessionID, input.directory, expectedAutoReviewRuntimeKey);
const runtimeKey = expectedAutoReviewRuntimeKey ?? getRuntimeKey();
const waitAfterCreatedAt = Date.now();
const sentMessageID = await sendPlainMessage(reviewSession.id, input.directory, handoffReviewPrompt, {
providerID: input.providerID,
modelID: input.modelID,
agent: input.agent,
variant: input.variant,
});
openReviewSessionPanel(input.directory, reviewSession);
}, input.autoReview ? autoReviewReviewerInstructions() : undefined, input.autoReview ? runtimeKey : undefined);
if (input.autoReview) {
startAutoReviewRun({
originalSessionID: input.originalSessionID,
reviewSessionID: reviewSession.id,
directory: input.directory,
runtimeKey,
status: 'running',
phase: 'waiting_for_reviewer',
iteration: 0,
maxIterations: AUTO_REVIEW_MAX_ITERATIONS,
expectedAssistantParentID: sentMessageID,
waitAfterCreatedAt,
});
}
if (!input.autoReview) {
openReviewSessionPanel(input.directory, reviewSession);
}
};
if (input.returnAfterHandoffRequest) {
@@ -247,25 +528,46 @@ export const startReviewFlow = async (input: StartReviewFlowInput): Promise<void
reviewPrompt = await renderMagicPrompt('session.reviewSessionWithoutHandoff.visible');
}
const reviewSession = await createOrReuseReviewSession(input.originalSessionID, input.directory);
await sendPlainMessage(reviewSession.id, input.directory, reviewPrompt, {
const reviewSession = await createOrReuseReviewSession(input.originalSessionID, input.directory, expectedAutoReviewRuntimeKey);
const runtimeKey = expectedAutoReviewRuntimeKey ?? getRuntimeKey();
const waitAfterCreatedAt = Date.now();
const sentMessageID = await sendPlainMessage(reviewSession.id, input.directory, reviewPrompt, {
providerID: input.providerID,
modelID: input.modelID,
agent: input.agent,
variant: input.variant,
});
openReviewSessionPanel(input.directory, reviewSession);
}, input.autoReview ? autoReviewReviewerInstructions() : undefined, input.autoReview ? runtimeKey : undefined);
if (input.autoReview) {
startAutoReviewRun({
originalSessionID: input.originalSessionID,
reviewSessionID: reviewSession.id,
directory: input.directory,
runtimeKey,
status: 'running',
phase: 'waiting_for_reviewer',
iteration: 0,
maxIterations: AUTO_REVIEW_MAX_ITERATIONS,
expectedAssistantParentID: sentMessageID,
waitAfterCreatedAt,
});
}
if (!input.autoReview) {
openReviewSessionPanel(input.directory, reviewSession);
}
};
export const sendReviewFeedbackToOriginal = async (reviewSessionID: string, directory: string, reviewFeedback: string): Promise<void> => {
export const sendReviewFeedbackToOriginal = async (reviewSessionID: string, directory: string, reviewFeedback: string, expectedRuntimeKey?: string): Promise<string> => {
assertAutoReviewRuntimeStillCurrent(expectedRuntimeKey);
const reviewSession = await opencodeClient.getSession(reviewSessionID, directory);
const originalSessionID = getOriginalSessionID(reviewSession);
if (!originalSessionID) throw new Error('Original session is missing');
const prompt = await renderMagicPrompt('session.reviewFeedbackToImplementer.visible', { review_feedback: reviewFeedback });
await sendPlainMessage(originalSessionID, directory, prompt);
assertAutoReviewRuntimeStillCurrent(expectedRuntimeKey);
return sendPlainMessage(originalSessionID, directory, prompt, undefined, undefined, expectedRuntimeKey);
};
export const sendImplementationResponseToReviewer = async (originalSessionID: string, directory: string, implementationResponse: string): Promise<void> => {
export const sendImplementationResponseToReviewer = async (originalSessionID: string, directory: string, implementationResponse: string, autoReview = false, expectedRuntimeKey?: string): Promise<string> => {
assertAutoReviewRuntimeStillCurrent(expectedRuntimeKey);
const originalSession = await opencodeClient.getSession(originalSessionID, directory);
const reviewSessionID = getReviewSessionID(originalSession);
if (!reviewSessionID) throw new Error('Review session is missing');
@@ -273,12 +575,17 @@ export const sendImplementationResponseToReviewer = async (originalSessionID: st
try {
reviewSession = await opencodeClient.getSession(reviewSessionID, directory);
} catch (error) {
assertAutoReviewRuntimeStillCurrent(expectedRuntimeKey);
await patchSessionMetadata(originalSessionID, directory, (metadata) => withoutReviewSessionLink(metadata, reviewSessionID));
throw error;
}
const prompt = await renderMagicPrompt('session.implementationResponseToReviewer.visible', { implementation_response: implementationResponse });
await sendPlainMessage(reviewSessionID, directory, prompt);
openReviewSessionPanel(directory, reviewSession);
assertAutoReviewRuntimeStillCurrent(expectedRuntimeKey);
const sentMessageID = await sendPlainMessage(reviewSessionID, directory, prompt, undefined, autoReview ? autoReviewReviewerInstructions() : undefined, expectedRuntimeKey);
if (!autoReview) {
openReviewSessionPanel(directory, reviewSession);
}
return sentMessageID;
};
export type ReviewTransferDirection = 'review-to-original' | 'original-to-review';