From 1f549e4525fa77750d9c152a70bdae1a520bc359 Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Fri, 26 Jun 2026 19:29:44 +0300 Subject: [PATCH] feat: add automatic review loop (#1840) --- packages/ui/src/App.tsx | 29 +- .../src/components/chat/AutoReviewBanner.tsx | 76 ++++ packages/ui/src/components/chat/ChatInput.tsx | 25 +- .../components/session/ReviewFlowDialog.tsx | 12 + packages/ui/src/components/views/DiffView.tsx | 1 + .../ui/src/hooks/useQueuedMessageAutoSend.ts | 21 +- packages/ui/src/index.css | 1 - packages/ui/src/lib/i18n/messages/en.ts | 7 + packages/ui/src/lib/i18n/messages/es.ts | 7 + packages/ui/src/lib/i18n/messages/fr.ts | 7 + packages/ui/src/lib/i18n/messages/ko.ts | 7 + packages/ui/src/lib/i18n/messages/pl.ts | 7 + packages/ui/src/lib/i18n/messages/pt-BR.ts | 7 + packages/ui/src/lib/i18n/messages/uk.ts | 7 + packages/ui/src/lib/i18n/messages/zh-CN.ts | 7 + packages/ui/src/lib/i18n/messages/zh-TW.ts | 7 + packages/ui/src/lib/reviewFlow.test.ts | 81 ++++ packages/ui/src/lib/reviewFlow.ts | 363 ++++++++++++++++-- packages/ui/src/stores/useAutoReviewStore.ts | 95 +++++ packages/ui/src/sync/session-actions.test.ts | 51 +++ packages/ui/src/sync/session-actions.ts | 4 + 21 files changed, 787 insertions(+), 35 deletions(-) create mode 100644 packages/ui/src/components/chat/AutoReviewBanner.tsx create mode 100644 packages/ui/src/lib/reviewFlow.test.ts create mode 100644 packages/ui/src/stores/useAutoReviewStore.ts diff --git a/packages/ui/src/App.tsx b/packages/ui/src/App.tsx index 583d4b9d..8ce5f1b6 100644 --- a/packages/ui/src/App.tsx +++ b/packages/ui/src/App.tsx @@ -36,7 +36,9 @@ import { useProjectsStore } from '@/stores/useProjectsStore'; import { opencodeClient } from '@/lib/opencode/client'; import { disposeTerminalInputTransport } from '@/lib/terminalApi'; import { runtimeFetch } from '@/lib/runtime-fetch'; -import { subscribeRuntimeEndpointChanged } from '@/lib/runtime-switch'; +import { getRuntimeKey, subscribeRuntimeEndpointChanged } from '@/lib/runtime-switch'; +import { useAutoReviewStore } from '@/stores/useAutoReviewStore'; +import { resumeAutoReviewRun } from '@/lib/reviewFlow'; import { SyncProvider } from '@/sync/sync-context'; import { useSync } from '@/sync/use-sync'; import { ConfigUpdateOverlay } from '@/components/ui/ConfigUpdateOverlay'; @@ -268,6 +270,9 @@ function App({ apis }: AppProps) { return subscribeRuntimeEndpointChanged((detail) => { useSessionUIStore.getState().prepareForRuntimeSwitch(detail.previousRuntimeKey); useUIStore.getState().prepareForRuntimeSwitch(detail.previousRuntimeKey); + if (detail.previousRuntimeKey) { + useAutoReviewStore.getState().stopRunningRunsForRuntime(detail.previousRuntimeKey); + } disposeTerminalInputTransport(); opencodeClient.reconnectToRuntimeBaseUrl(); useConfigStore.setState({ @@ -288,6 +293,28 @@ function App({ apis }: AppProps) { }); }, []); + const autoReviewResumeSignature = useAutoReviewStore((state) => { + const runtimeKey = getRuntimeKey(); + return Object.values(state.runsByOriginalSessionID) + .filter((run) => run.status === 'running' && run.runtimeKey === runtimeKey) + .map((run) => `${run.originalSessionID}:${run.phase}:${run.lastForwardedMessageID ?? ''}:${run.expectedAssistantParentID ?? ''}`) + .sort() + .join('|'); + }); + + React.useEffect(() => { + if (embeddedSessionChat) { + return; + } + + const runtimeKey = getRuntimeKey(); + const runs = Object.values(useAutoReviewStore.getState().runsByOriginalSessionID) + .filter((run) => run.status === 'running' && run.runtimeKey === runtimeKey); + for (const run of runs) { + resumeAutoReviewRun(run.originalSessionID); + } + }, [autoReviewResumeSignature, embeddedSessionChat, runtimeEndpointEpoch]); + React.useEffect(() => { document.documentElement.classList.toggle('wide-chat-layout', wideChatLayoutEnabled); return () => { diff --git a/packages/ui/src/components/chat/AutoReviewBanner.tsx b/packages/ui/src/components/chat/AutoReviewBanner.tsx new file mode 100644 index 00000000..8156ada3 --- /dev/null +++ b/packages/ui/src/components/chat/AutoReviewBanner.tsx @@ -0,0 +1,76 @@ +import React, { memo } from 'react'; + +import { Icon } from '@/components/icon/Icon'; +import { BusyDots } from '@/components/chat/message/parts/BusyDots'; +import { Button } from '@/components/ui/button'; +import { useI18n } from '@/lib/i18n'; +import { getRuntimeKey } from '@/lib/runtime-switch'; +import { useAutoReviewStore } from '@/stores/useAutoReviewStore'; +import { useUIStore } from '@/stores/useUIStore'; +import { useSessionUIStore } from '@/sync/session-ui-store'; + +export const AutoReviewBanner = memo(() => { + const { t } = useI18n(); + const currentSessionId = useSessionUIStore((state) => state.currentSessionId); + const run = useAutoReviewStore(React.useCallback((state) => { + if (!currentSessionId) return null; + const run = state.runsByOriginalSessionID[currentSessionId] ?? null; + return run?.runtimeKey === getRuntimeKey() ? run : null; + }, [currentSessionId])); + const stopRun = useAutoReviewStore((state) => state.stopRun); + const openContextPanelTab = useUIStore((state) => state.openContextPanelTab); + + if (!currentSessionId || !run || run.status !== 'running') { + return null; + } + + const statusLabel = run.phase === 'waiting_for_reviewer' + ? t('chat.autoReview.status.waitingForReviewer') + : t('chat.autoReview.status.waitingForImplementer'); + + const handleOpenReviewSession = () => { + openContextPanelTab(run.directory, { + mode: 'chat', + dedupeKey: `session:${run.reviewSessionID}`, + label: t('chat.autoReview.reviewSessionLabel'), + readOnly: true, + }); + }; + + return ( +
+
+
+
+
+
+ ); +}); + +AutoReviewBanner.displayName = 'AutoReviewBanner'; diff --git a/packages/ui/src/components/chat/ChatInput.tsx b/packages/ui/src/components/chat/ChatInput.tsx index 12f5c143..abf4bd4c 100644 --- a/packages/ui/src/components/chat/ChatInput.tsx +++ b/packages/ui/src/components/chat/ChatInput.tsx @@ -5,6 +5,7 @@ import { BrowserVoiceButton } from '@/components/voice'; import { useConfigStore } from '@/stores/useConfigStore'; import { useUIStore } from '@/stores/useUIStore'; import { useMessageQueueStore, type QueuedMessage } from '@/stores/messageQueueStore'; +import { useAutoReviewStore } from '@/stores/useAutoReviewStore'; import { useSessionUIStore } from '@/sync/session-ui-store'; import { useSelectionStore } from '@/sync/selection-store'; import { useInputStore } from '@/sync/input-store'; @@ -16,11 +17,13 @@ import { useSnippetsStore } from '@/stores/useSnippetsStore'; import { appendInlineComments } from '@/lib/messages/inlineComments'; import { renderMagicPrompt } from '@/lib/magicPrompts'; import { startReviewFlow } from '@/lib/reviewFlow'; +import { getRuntimeKey } from '@/lib/runtime-switch'; import { ReviewFlowDialog, type ReviewFlowExecution } from '@/components/session/ReviewFlowDialog'; import { AttachedFilesList, AttachedVSCodeFileChips, ActiveEditorFileSuggestion } from './FileAttachment'; import ToolOutputDialog from './message/ToolOutputDialog'; import type { ToolPopupContent } from './message/types'; import { QueuedMessageChips } from './QueuedMessageChips'; +import { AutoReviewBanner } from './AutoReviewBanner'; import { FileMentionAutocomplete, type FileMentionHandle } from './FileMentionAutocomplete'; import { CommandAutocomplete, type CommandAutocompleteHandle, type CommandInfo } from './CommandAutocomplete'; import { SkillAutocomplete, type SkillAutocompleteHandle } from './SkillAutocomplete'; @@ -1131,6 +1134,7 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo variant: execution.variant || undefined, generateHandoff: execution.generateHandoff, returnAfterHandoffRequest: execution.generateHandoff, + autoReview: execution.autoReview, }); setReviewDialogOpen(false); } catch (error) { @@ -1635,6 +1639,11 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo // Session activity for queue availability and controls const { phase: sessionPhase } = useCurrentSessionActivity(); + const autoReviewRunning = useAutoReviewStore(React.useCallback((state) => { + if (!currentSessionId) return false; + const run = state.runsByOriginalSessionID[currentSessionId]; + return run?.status === 'running' && run.runtimeKey === getRuntimeKey(); + }, [currentSessionId])); const handleOpenMobilePanel = React.useCallback((panel: MobileControlsPanel) => { if (!isMobile) { @@ -1764,6 +1773,10 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo ? queuedMessages.filter((message) => message.id === queuedMessageId) : queuedMessages; + if (queuedOnly && autoReviewRunning) { + return; + } + if (queuedOnly) { if (queuedMessagesToSend.length === 0 || !currentSessionId) return; } else if ((!inputSnapshot.hasContent && !hasQueuedMessages) || (!currentSessionId && !newSessionDraftOpen)) { @@ -1790,6 +1803,11 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo // queued-message auto-send hook delivers it as the next turn once the // rejected turn winds down and the session returns to idle. This avoids // aborting the turn (which would surface an "aborted" notice). + if (currentSessionId && !queuedOnly && autoReviewRunning) { + handleQueueMessage(); + return; + } + if (currentSessionId && !queuedOnly) { const dismissedQuestions = await sessionActions.dismissOpenQuestionsForSession(currentSessionId); if (dismissedQuestions) { @@ -2264,13 +2282,13 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo // Primary action for send button - respects queue mode setting const handlePrimaryAction = React.useCallback(() => { const inputSnapshot = getCurrentInputSnapshot(); - const canQueue = inputMode === 'normal' && inputSnapshot.hasContent && currentSessionId && sessionPhase !== 'idle'; + const canQueue = inputMode === 'normal' && inputSnapshot.hasContent && currentSessionId && (sessionPhase !== 'idle' || autoReviewRunning); if (queueModeEnabled && canQueue) { handleQueueMessage(); } else { void handleSubmitRef.current(); } - }, [inputMode, getCurrentInputSnapshot, currentSessionId, sessionPhase, queueModeEnabled, handleQueueMessage]); + }, [inputMode, getCurrentInputSnapshot, currentSessionId, sessionPhase, autoReviewRunning, queueModeEnabled, handleQueueMessage]); // Draft welcome presets: populate the composer and submit immediately. // getCurrentInputSnapshot reads textareaRef.current.value first, so setting it @@ -2519,7 +2537,7 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo // Normal mode: Enter sends, Ctrl+Enter queues // Note: Queueing only works when there's an existing session (currentSessionId) // For new sessions (draft), always send immediately - const canQueue = inputMode === 'normal' && hasContent && currentSessionId && sessionPhase !== 'idle'; + const canQueue = inputMode === 'normal' && hasContent && currentSessionId && (sessionPhase !== 'idle' || autoReviewRunning); if (queueModeEnabled) { if (isCtrlEnter || !canQueue) { @@ -4010,6 +4028,7 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo onEditMessage={handleQueuedMessageEdit} onSendMessage={handleQueuedMessageSend} /> + {hasDrafts && (
{reviewCount > 0 ? ( diff --git a/packages/ui/src/components/session/ReviewFlowDialog.tsx b/packages/ui/src/components/session/ReviewFlowDialog.tsx index 13d09524..4d2ad09c 100644 --- a/packages/ui/src/components/session/ReviewFlowDialog.tsx +++ b/packages/ui/src/components/session/ReviewFlowDialog.tsx @@ -24,6 +24,7 @@ export type ReviewFlowExecution = { variant: string; agent: string; generateHandoff: boolean; + autoReview: boolean; }; type ReviewFlowDialogProps = { @@ -45,6 +46,7 @@ const getInitialExecution = (params: { variant: params.variant, agent: params.agent, generateHandoff: true, + autoReview: false, }); export function ReviewFlowDialog({ @@ -165,6 +167,16 @@ export function ReviewFlowDialog({ {t('diffView.reviewDialog.generateHandoff')} + +
{t('chat.modelControls.model')} = ({ variant: execution.variant || undefined, generateHandoff: execution.generateHandoff, returnAfterHandoffRequest: execution.generateHandoff, + autoReview: execution.autoReview, }); setReviewDialogOpen(false); } catch (error) { diff --git a/packages/ui/src/hooks/useQueuedMessageAutoSend.ts b/packages/ui/src/hooks/useQueuedMessageAutoSend.ts index 6d52eb85..59867ef0 100644 --- a/packages/ui/src/hooks/useQueuedMessageAutoSend.ts +++ b/packages/ui/src/hooks/useQueuedMessageAutoSend.ts @@ -4,6 +4,7 @@ import { useSessionUIStore } from '@/sync/session-ui-store'; import { useSelectionStore } from '@/sync/selection-store'; import { useConfigStore } from '@/stores/useConfigStore'; import { useContextStore } from '@/stores/contextStore'; +import { useAutoReviewStore } from '@/stores/useAutoReviewStore'; import { parseAgentMentions } from '@/lib/messages/agentMentions'; import { getSyncSessionStatus } from '@/sync/sync-refs'; import { useDirectorySync } from '@/sync/sync-context'; @@ -117,10 +118,12 @@ export const shouldDispatchQueuedAutoSend = ( export function useQueuedMessageAutoSend(enabledOrOptions?: boolean | { enabled?: boolean }) { const enabled = typeof enabledOrOptions === 'boolean' ? enabledOrOptions : (enabledOrOptions?.enabled ?? true); const queuedMessages = useMessageQueueStore((state) => state.queuedMessages); + const autoReviewRuns = useAutoReviewStore((state) => state.runsByOriginalSessionID); const sessionStatusRecord = useDirectorySync((state) => state.session_status); const inFlightSessionsRef = React.useRef>(new Set()); const previousStatusRef = React.useRef>(new Map()); + const autoReviewBlockedSessionsRef = React.useRef>(new Set()); React.useEffect(() => { if (!enabled) { @@ -137,6 +140,10 @@ export function useQueuedMessageAutoSend(enabledOrOptions?: boolean | { enabled? if (hasRecentAbort(sessionId)) { return; } + if (useAutoReviewStore.getState().isRunningForSession(sessionId)) { + autoReviewBlockedSessionsRef.current.add(sessionId); + return; + } const currentStatus = getSyncSessionStatus(sessionId)?.type ?? 'idle'; if (currentStatus !== 'idle') { @@ -191,8 +198,18 @@ export function useQueuedMessageAutoSend(enabledOrOptions?: boolean | { enabled? queueEntries.forEach(([sessionId, queue]) => { const currentStatusType = (statusRecord[sessionId]?.type ?? 'idle') as SessionStatusType; const previousStatusType = previousStatusRef.current.get(sessionId); + const wasAutoReviewBlocked = autoReviewBlockedSessionsRef.current.has(sessionId); + const isAutoReviewRunning = useAutoReviewStore.getState().isRunningForSession(sessionId); + if (isAutoReviewRunning) { + autoReviewBlockedSessionsRef.current.add(sessionId); + } else if (wasAutoReviewBlocked) { + autoReviewBlockedSessionsRef.current.delete(sessionId); + } - if (queue.length > 0 && shouldDispatchQueuedAutoSend(previousStatusType, currentStatusType)) { + if (queue.length > 0 && ( + shouldDispatchQueuedAutoSend(previousStatusType, currentStatusType) + || (wasAutoReviewBlocked && !isAutoReviewRunning && currentStatusType === 'idle') + )) { void dispatchSessionQueue(sessionId, queue); } @@ -200,5 +217,5 @@ export function useQueuedMessageAutoSend(enabledOrOptions?: boolean | { enabled? }); previousStatusRef.current = nextStatusMap; - }, [enabled, queuedMessages, sessionStatusRecord]); + }, [enabled, queuedMessages, sessionStatusRecord, autoReviewRuns]); } diff --git a/packages/ui/src/index.css b/packages/ui/src/index.css index cf573a83..92da1e46 100644 --- a/packages/ui/src/index.css +++ b/packages/ui/src/index.css @@ -1622,7 +1622,6 @@ input[aria-label="Terminal input"] { animation: none; } - } /* Settings dialog: hide the overlay scrollbar; wheel/keyboard scroll still works. */ diff --git a/packages/ui/src/lib/i18n/messages/en.ts b/packages/ui/src/lib/i18n/messages/en.ts index dba49cb5..0423b342 100644 --- a/packages/ui/src/lib/i18n/messages/en.ts +++ b/packages/ui/src/lib/i18n/messages/en.ts @@ -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', diff --git a/packages/ui/src/lib/i18n/messages/es.ts b/packages/ui/src/lib/i18n/messages/es.ts index a92e4127..93e04110 100644 --- a/packages/ui/src/lib/i18n/messages/es.ts +++ b/packages/ui/src/lib/i18n/messages/es.ts @@ -1198,12 +1198,19 @@ export const dict: Record = { '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", diff --git a/packages/ui/src/lib/i18n/messages/fr.ts b/packages/ui/src/lib/i18n/messages/fr.ts index 9d66af07..df12dcfe 100644 --- a/packages/ui/src/lib/i18n/messages/fr.ts +++ b/packages/ui/src/lib/i18n/messages/fr.ts @@ -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 lorsqu’il 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 l’implé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', diff --git a/packages/ui/src/lib/i18n/messages/ko.ts b/packages/ui/src/lib/i18n/messages/ko.ts index 06532993..15546246 100644 --- a/packages/ui/src/lib/i18n/messages/ko.ts +++ b/packages/ui/src/lib/i18n/messages/ko.ts @@ -1235,12 +1235,19 @@ export const dict: Record = { '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': '스테이지 해제', diff --git a/packages/ui/src/lib/i18n/messages/pl.ts b/packages/ui/src/lib/i18n/messages/pl.ts index 664f62ed..78c86d4f 100644 --- a/packages/ui/src/lib/i18n/messages/pl.ts +++ b/packages/ui/src/lib/i18n/messages/pl.ts @@ -1432,12 +1432,19 @@ export const dict: Record = { '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', diff --git a/packages/ui/src/lib/i18n/messages/pt-BR.ts b/packages/ui/src/lib/i18n/messages/pt-BR.ts index 8f345e85..d3075f8d 100644 --- a/packages/ui/src/lib/i18n/messages/pt-BR.ts +++ b/packages/ui/src/lib/i18n/messages/pt-BR.ts @@ -1198,12 +1198,19 @@ export const dict: Record = { '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", diff --git a/packages/ui/src/lib/i18n/messages/uk.ts b/packages/ui/src/lib/i18n/messages/uk.ts index 42c09339..a216f818 100644 --- a/packages/ui/src/lib/i18n/messages/uk.ts +++ b/packages/ui/src/lib/i18n/messages/uk.ts @@ -1198,12 +1198,19 @@ export const dict: Record = { '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": "Прибрати", diff --git a/packages/ui/src/lib/i18n/messages/zh-CN.ts b/packages/ui/src/lib/i18n/messages/zh-CN.ts index 1ffd934d..a6080c26 100644 --- a/packages/ui/src/lib/i18n/messages/zh-CN.ts +++ b/packages/ui/src/lib/i18n/messages/zh-CN.ts @@ -1198,12 +1198,19 @@ export const dict: Record = { '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': '取消暂存', diff --git a/packages/ui/src/lib/i18n/messages/zh-TW.ts b/packages/ui/src/lib/i18n/messages/zh-TW.ts index 08d30254..388bf30e 100644 --- a/packages/ui/src/lib/i18n/messages/zh-TW.ts +++ b/packages/ui/src/lib/i18n/messages/zh-TW.ts @@ -1208,12 +1208,19 @@ export const dict: Record = { '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': '取消暫存', diff --git a/packages/ui/src/lib/reviewFlow.test.ts b/packages/ui/src/lib/reviewFlow.test.ts new file mode 100644 index 00000000..608d6b19 --- /dev/null +++ b/packages/ui/src/lib/reviewFlow.test.ts @@ -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!); + }); +}); diff --git a/packages/ui/src/lib/reviewFlow.ts b/packages/ui/src/lib/reviewFlow.ts index 353d1271..e9b83442 100644 --- a/packages/ui/src/lib/reviewFlow.ts +++ b/packages/ui/src/lib/reviewFlow.ts @@ -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(); +const activeAutoReviewForwardKeys = new Set(); 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(); + 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 => { + 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 => { 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 => { + expectedRuntimeKey?: string, +): Promise => { + 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 => { +const createOrReuseReviewSession = async (originalSessionID: string, directory: string, expectedRuntimeKey?: string): Promise => { + 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 => { 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 => { 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 => { +export const sendReviewFeedbackToOriginal = async (reviewSessionID: string, directory: string, reviewFeedback: string, expectedRuntimeKey?: string): Promise => { + 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 => { +export const sendImplementationResponseToReviewer = async (originalSessionID: string, directory: string, implementationResponse: string, autoReview = false, expectedRuntimeKey?: string): Promise => { + 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'; diff --git a/packages/ui/src/stores/useAutoReviewStore.ts b/packages/ui/src/stores/useAutoReviewStore.ts new file mode 100644 index 00000000..d335494b --- /dev/null +++ b/packages/ui/src/stores/useAutoReviewStore.ts @@ -0,0 +1,95 @@ +import { create } from 'zustand'; +import { persist, createJSONStorage } from 'zustand/middleware'; +import { getSafeStorage } from '@/stores/utils/safeStorage'; + +export type AutoReviewPhase = 'waiting_for_reviewer' | 'waiting_for_implementer'; +export type AutoReviewStatus = 'running' | 'completed' | 'stopped' | 'error'; + +export type AutoReviewRun = { + originalSessionID: string; + reviewSessionID: string; + directory: string; + runtimeKey: string; + status: AutoReviewStatus; + phase: AutoReviewPhase; + iteration: number; + maxIterations: number; + lastForwardedMessageID?: string; + expectedAssistantParentID?: string; + waitAfterCreatedAt?: number; + error?: string; +}; + +type AutoReviewState = { + runsByOriginalSessionID: Record; + upsertRun: (run: AutoReviewRun) => void; + updateRun: (originalSessionID: string, updater: (run: AutoReviewRun) => AutoReviewRun) => void; + stopRun: (originalSessionID: string) => void; + completeRun: (originalSessionID: string) => void; + stopRunningRunsForRuntime: (runtimeKey: string) => void; + isRunningForSession: (sessionID: string) => boolean; +}; + +export const useAutoReviewStore = create()( + persist( + (set, get) => ({ + runsByOriginalSessionID: {}, + upsertRun: (run) => set((state) => ({ + runsByOriginalSessionID: { + ...state.runsByOriginalSessionID, + [run.originalSessionID]: run, + }, + })), + updateRun: (originalSessionID, updater) => set((state) => { + const current = state.runsByOriginalSessionID[originalSessionID]; + if (!current) return state; + return { + runsByOriginalSessionID: { + ...state.runsByOriginalSessionID, + [originalSessionID]: updater(current), + }, + }; + }), + stopRun: (originalSessionID) => set((state) => { + const current = state.runsByOriginalSessionID[originalSessionID]; + if (!current) return state; + return { + runsByOriginalSessionID: { + ...state.runsByOriginalSessionID, + [originalSessionID]: { ...current, status: 'stopped' }, + }, + }; + }), + completeRun: (originalSessionID) => set((state) => { + const current = state.runsByOriginalSessionID[originalSessionID]; + if (!current) return state; + return { + runsByOriginalSessionID: { + ...state.runsByOriginalSessionID, + [originalSessionID]: { ...current, status: 'completed' }, + }, + }; + }), + stopRunningRunsForRuntime: (runtimeKey) => set((state) => { + let changed = false; + const next = { ...state.runsByOriginalSessionID }; + for (const [sessionID, run] of Object.entries(next)) { + if (run.runtimeKey === runtimeKey && run.status === 'running') { + next[sessionID] = { ...run, status: 'stopped' }; + changed = true; + } + } + return changed ? { runsByOriginalSessionID: next } : state; + }), + isRunningForSession: (sessionID) => { + const run = get().runsByOriginalSessionID[sessionID]; + return run?.status === 'running'; + }, + }), + { + name: 'auto-review-store', + storage: createJSONStorage(() => getSafeStorage()), + partialize: (state) => ({ runsByOriginalSessionID: state.runsByOriginalSessionID }), + }, + ), +); diff --git a/packages/ui/src/sync/session-actions.test.ts b/packages/ui/src/sync/session-actions.test.ts index a9115409..a583bc0a 100644 --- a/packages/ui/src/sync/session-actions.test.ts +++ b/packages/ui/src/sync/session-actions.test.ts @@ -387,6 +387,57 @@ describe("optimisticSend target directory", () => { expect(targetStore.getState().session_status["session-new"]?.type).toBe("busy") expect(currentStore.getState().session_status["session-new"]).toBe(undefined) }) + + test("allows callers to block final send when runtime changes after optimistic insert", async () => { + const targetStore = createStore({}) + const childStores = createChildStores([["/target/project", targetStore]]) + let optimisticAdd: OptimisticAddCall | null = null + let optimisticRemove: OptimisticRemoveCall | null = null + let finalSendCalled = false + const { getRuntimeKey, switchRuntimeEndpoint } = await import("../lib/runtime-switch") + switchRuntimeEndpoint({ apiBaseUrl: "http://runtime-a.test", runtimeKey: "runtime-a" }) + + const { optimisticSend, setActionRefs, setOptimisticRefs } = await import("./session-actions") + setActionRefs(mockSdk as unknown as OpencodeClient, childStores, () => "/target/project") + setOptimisticRefs( + (input) => { + optimisticAdd = input + }, + (input) => { + optimisticRemove = input + }, + ) + + let caught: unknown = null + try { + await optimisticSend({ + sessionId: "session-race", + directory: "/target/project", + content: "hello", + providerID: "provider", + modelID: "model", + beforeOptimisticInsert: () => { + expect(getRuntimeKey()).toBe("runtime-a") + }, + send: async () => { + switchRuntimeEndpoint({ apiBaseUrl: "http://runtime-b.test", runtimeKey: "runtime-b" }) + if (getRuntimeKey() !== "runtime-a") throw new Error("Auto-review stopped because the runtime changed.") + finalSendCalled = true + }, + }) + } catch (error) { + caught = error + } + + expect(caught).toBeInstanceOf(Error) + expect((caught as Error).message).toContain("runtime changed") + + expect(optimisticAdd).not.toBeNull() + expect(finalSendCalled).toBe(false) + expect(optimisticRemove).not.toBeNull() + expect((optimisticRemove as unknown as OptimisticRemoveCall).sessionID).toBe("session-race") + expect(targetStore.getState().session_status["session-race"]?.type).toBe("idle") + }) }) describe("respondToPermission passes directory", () => { diff --git a/packages/ui/src/sync/session-actions.ts b/packages/ui/src/sync/session-actions.ts index 66789135..05a9092c 100644 --- a/packages/ui/src/sync/session-actions.ts +++ b/packages/ui/src/sync/session-actions.ts @@ -629,6 +629,8 @@ export async function optimisticSend(input: { directory?: string | null files?: Array<{ type: "file"; mime: string; url: string; filename: string }> onOptimisticInsert?: () => void + onMessageID?: (messageID: string) => void + beforeOptimisticInsert?: () => void /** The actual API call — receives the optimistic messageID so the server can use the same ID */ send: (messageID: string) => Promise }): Promise { @@ -637,10 +639,12 @@ export async function optimisticSend(input: { } await waitForConnectionOrThrow() + input.beforeOptimisticInsert?.() const targetDirectory = input.directory ?? dir() const store = targetDirectory ? dirStoreForDirectory(targetDirectory) : dirStore() const messageID = ascendingId("msg") + input.onMessageID?.(messageID) const textPartId = ascendingId("prt") const optimisticParts: Part[] = [