feat: add automatic review loop (#1840)
This commit is contained in:
committed by
GitHub
parent
45df19c3b2
commit
1f549e4525
+28
-1
@@ -36,7 +36,9 @@ import { useProjectsStore } from '@/stores/useProjectsStore';
|
|||||||
import { opencodeClient } from '@/lib/opencode/client';
|
import { opencodeClient } from '@/lib/opencode/client';
|
||||||
import { disposeTerminalInputTransport } from '@/lib/terminalApi';
|
import { disposeTerminalInputTransport } from '@/lib/terminalApi';
|
||||||
import { runtimeFetch } from '@/lib/runtime-fetch';
|
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 { SyncProvider } from '@/sync/sync-context';
|
||||||
import { useSync } from '@/sync/use-sync';
|
import { useSync } from '@/sync/use-sync';
|
||||||
import { ConfigUpdateOverlay } from '@/components/ui/ConfigUpdateOverlay';
|
import { ConfigUpdateOverlay } from '@/components/ui/ConfigUpdateOverlay';
|
||||||
@@ -268,6 +270,9 @@ function App({ apis }: AppProps) {
|
|||||||
return subscribeRuntimeEndpointChanged((detail) => {
|
return subscribeRuntimeEndpointChanged((detail) => {
|
||||||
useSessionUIStore.getState().prepareForRuntimeSwitch(detail.previousRuntimeKey);
|
useSessionUIStore.getState().prepareForRuntimeSwitch(detail.previousRuntimeKey);
|
||||||
useUIStore.getState().prepareForRuntimeSwitch(detail.previousRuntimeKey);
|
useUIStore.getState().prepareForRuntimeSwitch(detail.previousRuntimeKey);
|
||||||
|
if (detail.previousRuntimeKey) {
|
||||||
|
useAutoReviewStore.getState().stopRunningRunsForRuntime(detail.previousRuntimeKey);
|
||||||
|
}
|
||||||
disposeTerminalInputTransport();
|
disposeTerminalInputTransport();
|
||||||
opencodeClient.reconnectToRuntimeBaseUrl();
|
opencodeClient.reconnectToRuntimeBaseUrl();
|
||||||
useConfigStore.setState({
|
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(() => {
|
React.useEffect(() => {
|
||||||
document.documentElement.classList.toggle('wide-chat-layout', wideChatLayoutEnabled);
|
document.documentElement.classList.toggle('wide-chat-layout', wideChatLayoutEnabled);
|
||||||
return () => {
|
return () => {
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
<div className="pb-2 w-full px-1">
|
||||||
|
<div className="rounded-xl border border-border/60 bg-[var(--surface-elevated)] text-[var(--surface-elevated-foreground)] shadow-sm overflow-hidden">
|
||||||
|
<div className="flex w-full items-center gap-2 px-3 py-2 text-left">
|
||||||
|
<Icon name="loader-4" className="h-4 w-4 animate-spin text-muted-foreground" aria-hidden="true" />
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<span className="typography-ui-label font-medium text-foreground">
|
||||||
|
{t('chat.autoReview.title')}
|
||||||
|
<BusyDots />
|
||||||
|
</span>
|
||||||
|
<div className="typography-meta text-muted-foreground">
|
||||||
|
{statusLabel}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="secondary"
|
||||||
|
size="xs"
|
||||||
|
onClick={handleOpenReviewSession}
|
||||||
|
>
|
||||||
|
{t('chat.autoReview.actions.open')}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="secondary"
|
||||||
|
size="xs"
|
||||||
|
onClick={() => stopRun(currentSessionId)}
|
||||||
|
>
|
||||||
|
{t('chat.autoReview.actions.stop')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
AutoReviewBanner.displayName = 'AutoReviewBanner';
|
||||||
@@ -5,6 +5,7 @@ import { BrowserVoiceButton } from '@/components/voice';
|
|||||||
import { useConfigStore } from '@/stores/useConfigStore';
|
import { useConfigStore } from '@/stores/useConfigStore';
|
||||||
import { useUIStore } from '@/stores/useUIStore';
|
import { useUIStore } from '@/stores/useUIStore';
|
||||||
import { useMessageQueueStore, type QueuedMessage } from '@/stores/messageQueueStore';
|
import { useMessageQueueStore, type QueuedMessage } from '@/stores/messageQueueStore';
|
||||||
|
import { useAutoReviewStore } from '@/stores/useAutoReviewStore';
|
||||||
import { useSessionUIStore } from '@/sync/session-ui-store';
|
import { useSessionUIStore } from '@/sync/session-ui-store';
|
||||||
import { useSelectionStore } from '@/sync/selection-store';
|
import { useSelectionStore } from '@/sync/selection-store';
|
||||||
import { useInputStore } from '@/sync/input-store';
|
import { useInputStore } from '@/sync/input-store';
|
||||||
@@ -16,11 +17,13 @@ import { useSnippetsStore } from '@/stores/useSnippetsStore';
|
|||||||
import { appendInlineComments } from '@/lib/messages/inlineComments';
|
import { appendInlineComments } from '@/lib/messages/inlineComments';
|
||||||
import { renderMagicPrompt } from '@/lib/magicPrompts';
|
import { renderMagicPrompt } from '@/lib/magicPrompts';
|
||||||
import { startReviewFlow } from '@/lib/reviewFlow';
|
import { startReviewFlow } from '@/lib/reviewFlow';
|
||||||
|
import { getRuntimeKey } from '@/lib/runtime-switch';
|
||||||
import { ReviewFlowDialog, type ReviewFlowExecution } from '@/components/session/ReviewFlowDialog';
|
import { ReviewFlowDialog, type ReviewFlowExecution } from '@/components/session/ReviewFlowDialog';
|
||||||
import { AttachedFilesList, AttachedVSCodeFileChips, ActiveEditorFileSuggestion } from './FileAttachment';
|
import { AttachedFilesList, AttachedVSCodeFileChips, ActiveEditorFileSuggestion } from './FileAttachment';
|
||||||
import ToolOutputDialog from './message/ToolOutputDialog';
|
import ToolOutputDialog from './message/ToolOutputDialog';
|
||||||
import type { ToolPopupContent } from './message/types';
|
import type { ToolPopupContent } from './message/types';
|
||||||
import { QueuedMessageChips } from './QueuedMessageChips';
|
import { QueuedMessageChips } from './QueuedMessageChips';
|
||||||
|
import { AutoReviewBanner } from './AutoReviewBanner';
|
||||||
import { FileMentionAutocomplete, type FileMentionHandle } from './FileMentionAutocomplete';
|
import { FileMentionAutocomplete, type FileMentionHandle } from './FileMentionAutocomplete';
|
||||||
import { CommandAutocomplete, type CommandAutocompleteHandle, type CommandInfo } from './CommandAutocomplete';
|
import { CommandAutocomplete, type CommandAutocompleteHandle, type CommandInfo } from './CommandAutocomplete';
|
||||||
import { SkillAutocomplete, type SkillAutocompleteHandle } from './SkillAutocomplete';
|
import { SkillAutocomplete, type SkillAutocompleteHandle } from './SkillAutocomplete';
|
||||||
@@ -1131,6 +1134,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
|
|||||||
variant: execution.variant || undefined,
|
variant: execution.variant || undefined,
|
||||||
generateHandoff: execution.generateHandoff,
|
generateHandoff: execution.generateHandoff,
|
||||||
returnAfterHandoffRequest: execution.generateHandoff,
|
returnAfterHandoffRequest: execution.generateHandoff,
|
||||||
|
autoReview: execution.autoReview,
|
||||||
});
|
});
|
||||||
setReviewDialogOpen(false);
|
setReviewDialogOpen(false);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -1635,6 +1639,11 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
|
|||||||
|
|
||||||
// Session activity for queue availability and controls
|
// Session activity for queue availability and controls
|
||||||
const { phase: sessionPhase } = useCurrentSessionActivity();
|
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) => {
|
const handleOpenMobilePanel = React.useCallback((panel: MobileControlsPanel) => {
|
||||||
if (!isMobile) {
|
if (!isMobile) {
|
||||||
@@ -1764,6 +1773,10 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
|
|||||||
? queuedMessages.filter((message) => message.id === queuedMessageId)
|
? queuedMessages.filter((message) => message.id === queuedMessageId)
|
||||||
: queuedMessages;
|
: queuedMessages;
|
||||||
|
|
||||||
|
if (queuedOnly && autoReviewRunning) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (queuedOnly) {
|
if (queuedOnly) {
|
||||||
if (queuedMessagesToSend.length === 0 || !currentSessionId) return;
|
if (queuedMessagesToSend.length === 0 || !currentSessionId) return;
|
||||||
} else if ((!inputSnapshot.hasContent && !hasQueuedMessages) || (!currentSessionId && !newSessionDraftOpen)) {
|
} else if ((!inputSnapshot.hasContent && !hasQueuedMessages) || (!currentSessionId && !newSessionDraftOpen)) {
|
||||||
@@ -1790,6 +1803,11 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
|
|||||||
// queued-message auto-send hook delivers it as the next turn once the
|
// 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
|
// rejected turn winds down and the session returns to idle. This avoids
|
||||||
// aborting the turn (which would surface an "aborted" notice).
|
// aborting the turn (which would surface an "aborted" notice).
|
||||||
|
if (currentSessionId && !queuedOnly && autoReviewRunning) {
|
||||||
|
handleQueueMessage();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (currentSessionId && !queuedOnly) {
|
if (currentSessionId && !queuedOnly) {
|
||||||
const dismissedQuestions = await sessionActions.dismissOpenQuestionsForSession(currentSessionId);
|
const dismissedQuestions = await sessionActions.dismissOpenQuestionsForSession(currentSessionId);
|
||||||
if (dismissedQuestions) {
|
if (dismissedQuestions) {
|
||||||
@@ -2264,13 +2282,13 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
|
|||||||
// Primary action for send button - respects queue mode setting
|
// Primary action for send button - respects queue mode setting
|
||||||
const handlePrimaryAction = React.useCallback(() => {
|
const handlePrimaryAction = React.useCallback(() => {
|
||||||
const inputSnapshot = getCurrentInputSnapshot();
|
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) {
|
if (queueModeEnabled && canQueue) {
|
||||||
handleQueueMessage();
|
handleQueueMessage();
|
||||||
} else {
|
} else {
|
||||||
void handleSubmitRef.current();
|
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.
|
// Draft welcome presets: populate the composer and submit immediately.
|
||||||
// getCurrentInputSnapshot reads textareaRef.current.value first, so setting it
|
// getCurrentInputSnapshot reads textareaRef.current.value first, so setting it
|
||||||
@@ -2519,7 +2537,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
|
|||||||
// Normal mode: Enter sends, Ctrl+Enter queues
|
// Normal mode: Enter sends, Ctrl+Enter queues
|
||||||
// Note: Queueing only works when there's an existing session (currentSessionId)
|
// Note: Queueing only works when there's an existing session (currentSessionId)
|
||||||
// For new sessions (draft), always send immediately
|
// 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 (queueModeEnabled) {
|
||||||
if (isCtrlEnter || !canQueue) {
|
if (isCtrlEnter || !canQueue) {
|
||||||
@@ -4010,6 +4028,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
|
|||||||
onEditMessage={handleQueuedMessageEdit}
|
onEditMessage={handleQueuedMessageEdit}
|
||||||
onSendMessage={handleQueuedMessageSend}
|
onSendMessage={handleQueuedMessageSend}
|
||||||
/>
|
/>
|
||||||
|
<AutoReviewBanner />
|
||||||
{hasDrafts && (
|
{hasDrafts && (
|
||||||
<div className="flex flex-wrap items-center gap-2 pb-2">
|
<div className="flex flex-wrap items-center gap-2 pb-2">
|
||||||
{reviewCount > 0 ? (
|
{reviewCount > 0 ? (
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ export type ReviewFlowExecution = {
|
|||||||
variant: string;
|
variant: string;
|
||||||
agent: string;
|
agent: string;
|
||||||
generateHandoff: boolean;
|
generateHandoff: boolean;
|
||||||
|
autoReview: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
type ReviewFlowDialogProps = {
|
type ReviewFlowDialogProps = {
|
||||||
@@ -45,6 +46,7 @@ const getInitialExecution = (params: {
|
|||||||
variant: params.variant,
|
variant: params.variant,
|
||||||
agent: params.agent,
|
agent: params.agent,
|
||||||
generateHandoff: true,
|
generateHandoff: true,
|
||||||
|
autoReview: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
export function ReviewFlowDialog({
|
export function ReviewFlowDialog({
|
||||||
@@ -165,6 +167,16 @@ export function ReviewFlowDialog({
|
|||||||
<span>{t('diffView.reviewDialog.generateHandoff')}</span>
|
<span>{t('diffView.reviewDialog.generateHandoff')}</span>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
|
<label className="flex items-center gap-2 typography-ui-label text-foreground">
|
||||||
|
<Checkbox
|
||||||
|
checked={execution.autoReview}
|
||||||
|
onChange={(autoReview) => setExecution((prev) => ({ ...prev, autoReview }))}
|
||||||
|
disabled={submitting}
|
||||||
|
ariaLabel={t('diffView.reviewDialog.autoReview')}
|
||||||
|
/>
|
||||||
|
<span>{t('diffView.reviewDialog.autoReview')}</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
<div className="flex min-w-0 flex-col gap-1.5">
|
<div className="flex min-w-0 flex-col gap-1.5">
|
||||||
<span className="typography-meta font-medium text-muted-foreground">{t('chat.modelControls.model')}</span>
|
<span className="typography-meta font-medium text-muted-foreground">{t('chat.modelControls.model')}</span>
|
||||||
<ModelSelector
|
<ModelSelector
|
||||||
|
|||||||
@@ -1297,6 +1297,7 @@ export const DiffView: React.FC<DiffViewProps> = ({
|
|||||||
variant: execution.variant || undefined,
|
variant: execution.variant || undefined,
|
||||||
generateHandoff: execution.generateHandoff,
|
generateHandoff: execution.generateHandoff,
|
||||||
returnAfterHandoffRequest: execution.generateHandoff,
|
returnAfterHandoffRequest: execution.generateHandoff,
|
||||||
|
autoReview: execution.autoReview,
|
||||||
});
|
});
|
||||||
setReviewDialogOpen(false);
|
setReviewDialogOpen(false);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { useSessionUIStore } from '@/sync/session-ui-store';
|
|||||||
import { useSelectionStore } from '@/sync/selection-store';
|
import { useSelectionStore } from '@/sync/selection-store';
|
||||||
import { useConfigStore } from '@/stores/useConfigStore';
|
import { useConfigStore } from '@/stores/useConfigStore';
|
||||||
import { useContextStore } from '@/stores/contextStore';
|
import { useContextStore } from '@/stores/contextStore';
|
||||||
|
import { useAutoReviewStore } from '@/stores/useAutoReviewStore';
|
||||||
import { parseAgentMentions } from '@/lib/messages/agentMentions';
|
import { parseAgentMentions } from '@/lib/messages/agentMentions';
|
||||||
import { getSyncSessionStatus } from '@/sync/sync-refs';
|
import { getSyncSessionStatus } from '@/sync/sync-refs';
|
||||||
import { useDirectorySync } from '@/sync/sync-context';
|
import { useDirectorySync } from '@/sync/sync-context';
|
||||||
@@ -117,10 +118,12 @@ export const shouldDispatchQueuedAutoSend = (
|
|||||||
export function useQueuedMessageAutoSend(enabledOrOptions?: boolean | { enabled?: boolean }) {
|
export function useQueuedMessageAutoSend(enabledOrOptions?: boolean | { enabled?: boolean }) {
|
||||||
const enabled = typeof enabledOrOptions === 'boolean' ? enabledOrOptions : (enabledOrOptions?.enabled ?? true);
|
const enabled = typeof enabledOrOptions === 'boolean' ? enabledOrOptions : (enabledOrOptions?.enabled ?? true);
|
||||||
const queuedMessages = useMessageQueueStore((state) => state.queuedMessages);
|
const queuedMessages = useMessageQueueStore((state) => state.queuedMessages);
|
||||||
|
const autoReviewRuns = useAutoReviewStore((state) => state.runsByOriginalSessionID);
|
||||||
const sessionStatusRecord = useDirectorySync((state) => state.session_status);
|
const sessionStatusRecord = useDirectorySync((state) => state.session_status);
|
||||||
|
|
||||||
const inFlightSessionsRef = React.useRef<Set<string>>(new Set());
|
const inFlightSessionsRef = React.useRef<Set<string>>(new Set());
|
||||||
const previousStatusRef = React.useRef<Map<string, SessionStatusType>>(new Map());
|
const previousStatusRef = React.useRef<Map<string, SessionStatusType>>(new Map());
|
||||||
|
const autoReviewBlockedSessionsRef = React.useRef<Set<string>>(new Set());
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (!enabled) {
|
if (!enabled) {
|
||||||
@@ -137,6 +140,10 @@ export function useQueuedMessageAutoSend(enabledOrOptions?: boolean | { enabled?
|
|||||||
if (hasRecentAbort(sessionId)) {
|
if (hasRecentAbort(sessionId)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (useAutoReviewStore.getState().isRunningForSession(sessionId)) {
|
||||||
|
autoReviewBlockedSessionsRef.current.add(sessionId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const currentStatus = getSyncSessionStatus(sessionId)?.type ?? 'idle';
|
const currentStatus = getSyncSessionStatus(sessionId)?.type ?? 'idle';
|
||||||
if (currentStatus !== 'idle') {
|
if (currentStatus !== 'idle') {
|
||||||
@@ -191,8 +198,18 @@ export function useQueuedMessageAutoSend(enabledOrOptions?: boolean | { enabled?
|
|||||||
queueEntries.forEach(([sessionId, queue]) => {
|
queueEntries.forEach(([sessionId, queue]) => {
|
||||||
const currentStatusType = (statusRecord[sessionId]?.type ?? 'idle') as SessionStatusType;
|
const currentStatusType = (statusRecord[sessionId]?.type ?? 'idle') as SessionStatusType;
|
||||||
const previousStatusType = previousStatusRef.current.get(sessionId);
|
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);
|
void dispatchSessionQueue(sessionId, queue);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -200,5 +217,5 @@ export function useQueuedMessageAutoSend(enabledOrOptions?: boolean | { enabled?
|
|||||||
});
|
});
|
||||||
|
|
||||||
previousStatusRef.current = nextStatusMap;
|
previousStatusRef.current = nextStatusMap;
|
||||||
}, [enabled, queuedMessages, sessionStatusRecord]);
|
}, [enabled, queuedMessages, sessionStatusRecord, autoReviewRuns]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1622,7 +1622,6 @@ input[aria-label="Terminal input"] {
|
|||||||
animation: none;
|
animation: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Settings dialog: hide the overlay scrollbar; wheel/keyboard scroll still works. */
|
/* Settings dialog: hide the overlay scrollbar; wheel/keyboard scroll still works. */
|
||||||
|
|||||||
@@ -1232,12 +1232,19 @@ export const dict = {
|
|||||||
'diffView.reviewDialog.title': 'Review changes',
|
'diffView.reviewDialog.title': 'Review changes',
|
||||||
'diffView.reviewDialog.description': 'Start a separate review session for the current changes.',
|
'diffView.reviewDialog.description': 'Start a separate review session for the current changes.',
|
||||||
'diffView.reviewDialog.generateHandoff': 'Generate handoff',
|
'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.info': 'This flow works best when started from the session where the changes were implemented.',
|
||||||
'diffView.reviewDialog.actions.cancel': 'Cancel',
|
'diffView.reviewDialog.actions.cancel': 'Cancel',
|
||||||
'diffView.reviewDialog.actions.start': 'Review',
|
'diffView.reviewDialog.actions.start': 'Review',
|
||||||
'diffView.reviewDialog.actions.starting': 'Starting...',
|
'diffView.reviewDialog.actions.starting': 'Starting...',
|
||||||
'diffView.reviewDialog.toast.noSessionDirectory': 'Session directory is unavailable',
|
'diffView.reviewDialog.toast.noSessionDirectory': 'Session directory is unavailable',
|
||||||
'diffView.reviewDialog.toast.startFailed': 'Failed to start review flow',
|
'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.label': 'Hunks',
|
||||||
'diffView.hunk.stage': 'Stage',
|
'diffView.hunk.stage': 'Stage',
|
||||||
'diffView.hunk.unstage': 'Unstage',
|
'diffView.hunk.unstage': 'Unstage',
|
||||||
|
|||||||
@@ -1198,12 +1198,19 @@ export const dict: Record<I18nKey, string> = {
|
|||||||
'diffView.reviewDialog.title': 'Revisar cambios',
|
'diffView.reviewDialog.title': 'Revisar cambios',
|
||||||
'diffView.reviewDialog.description': 'Inicia una sesión de revisión separada para los cambios actuales.',
|
'diffView.reviewDialog.description': 'Inicia una sesión de revisión separada para los cambios actuales.',
|
||||||
'diffView.reviewDialog.generateHandoff': 'Generar handoff',
|
'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.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.cancel': 'Cancelar',
|
||||||
'diffView.reviewDialog.actions.start': 'Revisar',
|
'diffView.reviewDialog.actions.start': 'Revisar',
|
||||||
'diffView.reviewDialog.actions.starting': 'Iniciando...',
|
'diffView.reviewDialog.actions.starting': 'Iniciando...',
|
||||||
'diffView.reviewDialog.toast.noSessionDirectory': 'El directorio de la sesión no está disponible',
|
'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',
|
'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.label": "Fragmentos",
|
||||||
"diffView.hunk.stage": "Preparar",
|
"diffView.hunk.stage": "Preparar",
|
||||||
"diffView.hunk.unstage": "Quitar",
|
"diffView.hunk.unstage": "Quitar",
|
||||||
|
|||||||
@@ -1104,12 +1104,19 @@ export const dict = {
|
|||||||
'diffView.reviewDialog.title': 'Revoir les changements',
|
'diffView.reviewDialog.title': 'Revoir les changements',
|
||||||
'diffView.reviewDialog.description': 'Démarre une session de revue séparée pour les changements actuels.',
|
'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.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.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.cancel': 'Annuler',
|
||||||
'diffView.reviewDialog.actions.start': 'Revoir',
|
'diffView.reviewDialog.actions.start': 'Revoir',
|
||||||
'diffView.reviewDialog.actions.starting': 'Démarrage...',
|
'diffView.reviewDialog.actions.starting': 'Démarrage...',
|
||||||
'diffView.reviewDialog.toast.noSessionDirectory': 'Le dossier de session est indisponible',
|
'diffView.reviewDialog.toast.noSessionDirectory': 'Le dossier de session est indisponible',
|
||||||
'diffView.reviewDialog.toast.startFailed': 'Impossible de démarrer le flux de revue',
|
'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.label': 'Sections',
|
||||||
'diffView.hunk.stage': 'Préparer',
|
'diffView.hunk.stage': 'Préparer',
|
||||||
'diffView.hunk.unstage': 'Retirer',
|
'diffView.hunk.unstage': 'Retirer',
|
||||||
|
|||||||
@@ -1235,12 +1235,19 @@ export const dict: Record<I18nKey, string> = {
|
|||||||
'diffView.reviewDialog.title': '변경 사항 리뷰',
|
'diffView.reviewDialog.title': '변경 사항 리뷰',
|
||||||
'diffView.reviewDialog.description': '현재 변경 사항을 위한 별도 리뷰 세션을 시작합니다.',
|
'diffView.reviewDialog.description': '현재 변경 사항을 위한 별도 리뷰 세션을 시작합니다.',
|
||||||
'diffView.reviewDialog.generateHandoff': '핸드오프 생성',
|
'diffView.reviewDialog.generateHandoff': '핸드오프 생성',
|
||||||
|
'diffView.reviewDialog.autoReview': '자동 리뷰 루프 실행',
|
||||||
'diffView.reviewDialog.info': '이 흐름은 변경 사항을 구현한 세션에서 시작할 때 가장 잘 작동합니다.',
|
'diffView.reviewDialog.info': '이 흐름은 변경 사항을 구현한 세션에서 시작할 때 가장 잘 작동합니다.',
|
||||||
'diffView.reviewDialog.actions.cancel': '취소',
|
'diffView.reviewDialog.actions.cancel': '취소',
|
||||||
'diffView.reviewDialog.actions.start': '리뷰',
|
'diffView.reviewDialog.actions.start': '리뷰',
|
||||||
'diffView.reviewDialog.actions.starting': '시작 중...',
|
'diffView.reviewDialog.actions.starting': '시작 중...',
|
||||||
'diffView.reviewDialog.toast.noSessionDirectory': '세션 디렉터리를 사용할 수 없습니다',
|
'diffView.reviewDialog.toast.noSessionDirectory': '세션 디렉터리를 사용할 수 없습니다',
|
||||||
'diffView.reviewDialog.toast.startFailed': '리뷰 흐름을 시작하지 못했습니다',
|
'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.label': '허크',
|
||||||
'diffView.hunk.stage': '스테이지',
|
'diffView.hunk.stage': '스테이지',
|
||||||
'diffView.hunk.unstage': '스테이지 해제',
|
'diffView.hunk.unstage': '스테이지 해제',
|
||||||
|
|||||||
@@ -1432,12 +1432,19 @@ export const dict: Record<I18nKey, string> = {
|
|||||||
'diffView.reviewDialog.title': 'Przegląd zmian',
|
'diffView.reviewDialog.title': 'Przegląd zmian',
|
||||||
'diffView.reviewDialog.description': 'Uruchom osobną sesję review dla bieżących zmian.',
|
'diffView.reviewDialog.description': 'Uruchom osobną sesję review dla bieżących zmian.',
|
||||||
'diffView.reviewDialog.generateHandoff': 'Wygeneruj handoff',
|
'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.info': 'Ten flow działa najlepiej, gdy jest uruchomiony z sesji, w której zaimplementowano zmiany.',
|
||||||
'diffView.reviewDialog.actions.cancel': 'Anuluj',
|
'diffView.reviewDialog.actions.cancel': 'Anuluj',
|
||||||
'diffView.reviewDialog.actions.start': 'Review',
|
'diffView.reviewDialog.actions.start': 'Review',
|
||||||
'diffView.reviewDialog.actions.starting': 'Uruchamianie...',
|
'diffView.reviewDialog.actions.starting': 'Uruchamianie...',
|
||||||
'diffView.reviewDialog.toast.noSessionDirectory': 'Katalog sesji jest niedostępny',
|
'diffView.reviewDialog.toast.noSessionDirectory': 'Katalog sesji jest niedostępny',
|
||||||
'diffView.reviewDialog.toast.startFailed': 'Nie udało się uruchomić flow review',
|
'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.label': 'Fragmenty',
|
||||||
'diffView.hunk.stage': 'Przygotuj',
|
'diffView.hunk.stage': 'Przygotuj',
|
||||||
'diffView.hunk.unstage': 'Cofnij',
|
'diffView.hunk.unstage': 'Cofnij',
|
||||||
|
|||||||
@@ -1198,12 +1198,19 @@ export const dict: Record<I18nKey, string> = {
|
|||||||
'diffView.reviewDialog.title': 'Revisar alterações',
|
'diffView.reviewDialog.title': 'Revisar alterações',
|
||||||
'diffView.reviewDialog.description': 'Inicia uma sessão de revisão separada para as alterações atuais.',
|
'diffView.reviewDialog.description': 'Inicia uma sessão de revisão separada para as alterações atuais.',
|
||||||
'diffView.reviewDialog.generateHandoff': 'Gerar handoff',
|
'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.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.cancel': 'Cancelar',
|
||||||
'diffView.reviewDialog.actions.start': 'Revisar',
|
'diffView.reviewDialog.actions.start': 'Revisar',
|
||||||
'diffView.reviewDialog.actions.starting': 'Iniciando...',
|
'diffView.reviewDialog.actions.starting': 'Iniciando...',
|
||||||
'diffView.reviewDialog.toast.noSessionDirectory': 'O diretório da sessão está indisponível',
|
'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',
|
'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.label": "Trechos",
|
||||||
"diffView.hunk.stage": "Preparar",
|
"diffView.hunk.stage": "Preparar",
|
||||||
"diffView.hunk.unstage": "Remover",
|
"diffView.hunk.unstage": "Remover",
|
||||||
|
|||||||
@@ -1198,12 +1198,19 @@ export const dict: Record<I18nKey, string> = {
|
|||||||
'diffView.reviewDialog.title': 'Ревʼю змін',
|
'diffView.reviewDialog.title': 'Ревʼю змін',
|
||||||
'diffView.reviewDialog.description': 'Запустити окрему сесію ревʼю для поточних змін.',
|
'diffView.reviewDialog.description': 'Запустити окрему сесію ревʼю для поточних змін.',
|
||||||
'diffView.reviewDialog.generateHandoff': 'Згенерувати handoff',
|
'diffView.reviewDialog.generateHandoff': 'Згенерувати handoff',
|
||||||
|
'diffView.reviewDialog.autoReview': 'Запустити автоматичний цикл ревʼю',
|
||||||
'diffView.reviewDialog.info': 'Цей flow найкраще працює, якщо запускати його із сесії, де були імплементовані зміни.',
|
'diffView.reviewDialog.info': 'Цей flow найкраще працює, якщо запускати його із сесії, де були імплементовані зміни.',
|
||||||
'diffView.reviewDialog.actions.cancel': 'Скасувати',
|
'diffView.reviewDialog.actions.cancel': 'Скасувати',
|
||||||
'diffView.reviewDialog.actions.start': 'Ревʼю',
|
'diffView.reviewDialog.actions.start': 'Ревʼю',
|
||||||
'diffView.reviewDialog.actions.starting': 'Запуск...',
|
'diffView.reviewDialog.actions.starting': 'Запуск...',
|
||||||
'diffView.reviewDialog.toast.noSessionDirectory': 'Директорія сесії недоступна',
|
'diffView.reviewDialog.toast.noSessionDirectory': 'Директорія сесії недоступна',
|
||||||
'diffView.reviewDialog.toast.startFailed': 'Не вдалося запустити review flow',
|
'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.label": "Шматки",
|
||||||
"diffView.hunk.stage": "Додати",
|
"diffView.hunk.stage": "Додати",
|
||||||
"diffView.hunk.unstage": "Прибрати",
|
"diffView.hunk.unstage": "Прибрати",
|
||||||
|
|||||||
@@ -1198,12 +1198,19 @@ export const dict: Record<I18nKey, string> = {
|
|||||||
'diffView.reviewDialog.title': 'Review changes',
|
'diffView.reviewDialog.title': 'Review changes',
|
||||||
'diffView.reviewDialog.description': 'Start a separate review session for the current changes.',
|
'diffView.reviewDialog.description': 'Start a separate review session for the current changes.',
|
||||||
'diffView.reviewDialog.generateHandoff': 'Generate handoff',
|
'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.info': 'This flow works best if started in the session where the changes were implemented.',
|
||||||
'diffView.reviewDialog.actions.cancel': 'Cancel',
|
'diffView.reviewDialog.actions.cancel': 'Cancel',
|
||||||
'diffView.reviewDialog.actions.start': 'Review',
|
'diffView.reviewDialog.actions.start': 'Review',
|
||||||
'diffView.reviewDialog.actions.starting': 'Starting...',
|
'diffView.reviewDialog.actions.starting': 'Starting...',
|
||||||
'diffView.reviewDialog.toast.noSessionDirectory': 'Session directory is unavailable',
|
'diffView.reviewDialog.toast.noSessionDirectory': 'Session directory is unavailable',
|
||||||
'diffView.reviewDialog.toast.startFailed': 'Failed to start review flow',
|
'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.label': '代码块',
|
||||||
'diffView.hunk.stage': '暂存',
|
'diffView.hunk.stage': '暂存',
|
||||||
'diffView.hunk.unstage': '取消暂存',
|
'diffView.hunk.unstage': '取消暂存',
|
||||||
|
|||||||
@@ -1208,12 +1208,19 @@ export const dict: Record<I18nKey, string> = {
|
|||||||
'diffView.reviewDialog.title': 'Review changes',
|
'diffView.reviewDialog.title': 'Review changes',
|
||||||
'diffView.reviewDialog.description': 'Start a separate review session for the current changes.',
|
'diffView.reviewDialog.description': 'Start a separate review session for the current changes.',
|
||||||
'diffView.reviewDialog.generateHandoff': 'Generate handoff',
|
'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.info': 'This flow works best if started in the session where the changes were implemented.',
|
||||||
'diffView.reviewDialog.actions.cancel': 'Cancel',
|
'diffView.reviewDialog.actions.cancel': 'Cancel',
|
||||||
'diffView.reviewDialog.actions.start': 'Review',
|
'diffView.reviewDialog.actions.start': 'Review',
|
||||||
'diffView.reviewDialog.actions.starting': 'Starting...',
|
'diffView.reviewDialog.actions.starting': 'Starting...',
|
||||||
'diffView.reviewDialog.toast.noSessionDirectory': 'Session directory is unavailable',
|
'diffView.reviewDialog.toast.noSessionDirectory': 'Session directory is unavailable',
|
||||||
'diffView.reviewDialog.toast.startFailed': 'Failed to start review flow',
|
'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.label': '程式碼區塊',
|
||||||
'diffView.hunk.stage': '暫存',
|
'diffView.hunk.stage': '暫存',
|
||||||
'diffView.hunk.unstage': '取消暫存',
|
'diffView.hunk.unstage': '取消暫存',
|
||||||
|
|||||||
@@ -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!);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -11,17 +11,25 @@ import {
|
|||||||
withReviewSessionMarker,
|
withReviewSessionMarker,
|
||||||
} from '@/lib/sessionReviewMetadata';
|
} from '@/lib/sessionReviewMetadata';
|
||||||
import { useConfigStore } from '@/stores/useConfigStore';
|
import { useConfigStore } from '@/stores/useConfigStore';
|
||||||
|
import { useAutoReviewStore, type AutoReviewRun } from '@/stores/useAutoReviewStore';
|
||||||
import { useGlobalSessionsStore } from '@/stores/useGlobalSessionsStore';
|
import { useGlobalSessionsStore } from '@/stores/useGlobalSessionsStore';
|
||||||
import { useUIStore } from '@/stores/useUIStore';
|
import { useUIStore } from '@/stores/useUIStore';
|
||||||
import { optimisticSend, patchSessionMetadata, waitForConnectionOrThrow } from '@/sync/session-actions';
|
import { optimisticSend, patchSessionMetadata, waitForConnectionOrThrow } from '@/sync/session-actions';
|
||||||
import { useSelectionStore } from '@/sync/selection-store';
|
import { useSelectionStore } from '@/sync/selection-store';
|
||||||
import { useSessionUIStore } from '@/sync/session-ui-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 { markPendingUserSendAnimation } from '@/lib/userSendAnimation';
|
||||||
|
import { getRuntimeKey } from '@/lib/runtime-switch';
|
||||||
|
|
||||||
const HANDOFF_TIMEOUT_MS = 180_000;
|
const HANDOFF_TIMEOUT_MS = 180_000;
|
||||||
const HANDOFF_POLL_MS = 400;
|
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 REVIEW_SESSION_TITLE = 'Review of workspace changes';
|
||||||
|
const activeAutoReviewLoops = new Set<string>();
|
||||||
|
const activeAutoReviewForwardKeys = new Set<string>();
|
||||||
|
|
||||||
type SessionModelContext = {
|
type SessionModelContext = {
|
||||||
providerID: string;
|
providerID: string;
|
||||||
@@ -36,6 +44,12 @@ type StartReviewFlowInput = SessionModelContext & {
|
|||||||
agentMentionName?: string;
|
agentMentionName?: string;
|
||||||
generateHandoff?: boolean;
|
generateHandoff?: boolean;
|
||||||
returnAfterHandoffRequest?: boolean;
|
returnAfterHandoffRequest?: boolean;
|
||||||
|
autoReview?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
type AssistantTextMessage = {
|
||||||
|
id: string;
|
||||||
|
text: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
const isMessageCompleted = (message: Message): boolean => {
|
const isMessageCompleted = (message: Message): boolean => {
|
||||||
@@ -55,6 +69,234 @@ const getMessageRole = (message: Message): string => {
|
|||||||
return typeof role === 'string' ? role : '';
|
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 waitForAssistantText = async (sessionID: string, directory: string, afterCreatedAt: number): Promise<string> => {
|
||||||
const deadline = Date.now() + HANDOFF_TIMEOUT_MS;
|
const deadline = Date.now() + HANDOFF_TIMEOUT_MS;
|
||||||
while (Date.now() < deadline) {
|
while (Date.now() < deadline) {
|
||||||
@@ -118,7 +360,9 @@ const sendPlainMessage = async (
|
|||||||
text: string,
|
text: string,
|
||||||
modelContext?: SessionModelContext | null,
|
modelContext?: SessionModelContext | null,
|
||||||
additionalParts?: Array<{ text: string; synthetic?: boolean }>,
|
additionalParts?: Array<{ text: string; synthetic?: boolean }>,
|
||||||
): Promise<void> => {
|
expectedRuntimeKey?: string,
|
||||||
|
): Promise<string> => {
|
||||||
|
assertAutoReviewRuntimeStillCurrent(expectedRuntimeKey);
|
||||||
const resolved = modelContext ?? resolveModelContext(sessionID);
|
const resolved = modelContext ?? resolveModelContext(sessionID);
|
||||||
if (!resolved) throw new Error('Select a model before sending review flow messages');
|
if (!resolved) throw new Error('Select a model before sending review flow messages');
|
||||||
const selection = useSelectionStore.getState();
|
const selection = useSelectionStore.getState();
|
||||||
@@ -129,6 +373,7 @@ const sendPlainMessage = async (
|
|||||||
selection.saveAgentModelVariantForSession(sessionID, resolved.agent, resolved.providerID, resolved.modelID, resolved.variant);
|
selection.saveAgentModelVariantForSession(sessionID, resolved.agent, resolved.providerID, resolved.modelID, resolved.variant);
|
||||||
}
|
}
|
||||||
markPendingUserSendAnimation(sessionID);
|
markPendingUserSendAnimation(sessionID);
|
||||||
|
let sentMessageID: string | null = null;
|
||||||
await optimisticSend({
|
await optimisticSend({
|
||||||
sessionId: sessionID,
|
sessionId: sessionID,
|
||||||
content: text,
|
content: text,
|
||||||
@@ -136,19 +381,28 @@ const sendPlainMessage = async (
|
|||||||
providerID: resolved.providerID,
|
providerID: resolved.providerID,
|
||||||
modelID: resolved.modelID,
|
modelID: resolved.modelID,
|
||||||
agent: resolved.agent,
|
agent: resolved.agent,
|
||||||
|
onMessageID: (messageID) => {
|
||||||
|
sentMessageID = messageID;
|
||||||
|
},
|
||||||
|
beforeOptimisticInsert: () => assertAutoReviewRuntimeStillCurrent(expectedRuntimeKey),
|
||||||
onOptimisticInsert: () => requestChatForceScrollBottom(sessionID),
|
onOptimisticInsert: () => requestChatForceScrollBottom(sessionID),
|
||||||
send: (messageID) => opencodeClient.sendMessage({
|
send: (messageID) => {
|
||||||
id: sessionID,
|
assertAutoReviewRuntimeStillCurrent(expectedRuntimeKey);
|
||||||
directory,
|
return opencodeClient.sendMessage({
|
||||||
providerID: resolved.providerID,
|
id: sessionID,
|
||||||
modelID: resolved.modelID,
|
directory,
|
||||||
agent: resolved.agent,
|
providerID: resolved.providerID,
|
||||||
variant: resolved.variant,
|
modelID: resolved.modelID,
|
||||||
text,
|
agent: resolved.agent,
|
||||||
additionalParts,
|
variant: resolved.variant,
|
||||||
messageId: messageID,
|
text,
|
||||||
}).then(() => undefined),
|
additionalParts,
|
||||||
|
messageId: messageID,
|
||||||
|
}).then(() => undefined);
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
if (!sentMessageID) throw new Error('Failed to prepare review flow message');
|
||||||
|
return sentMessageID;
|
||||||
};
|
};
|
||||||
|
|
||||||
const requestChatForceScrollBottom = (sessionId: string): void => {
|
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);
|
const original = await opencodeClient.getSession(originalSessionID, directory);
|
||||||
|
assertAutoReviewRuntimeStillCurrent(expectedRuntimeKey);
|
||||||
const existingReviewID = getReviewSessionID(original);
|
const existingReviewID = getReviewSessionID(original);
|
||||||
if (existingReviewID) {
|
if (existingReviewID) {
|
||||||
const existing = await getSessionOrNull(existingReviewID, directory);
|
const existing = await getSessionOrNull(existingReviewID, directory);
|
||||||
|
assertAutoReviewRuntimeStillCurrent(expectedRuntimeKey);
|
||||||
if (existing && isReviewSession(existing)) return existing;
|
if (existing && isReviewSession(existing)) return existing;
|
||||||
await patchSessionMetadata(originalSessionID, directory, (metadata) => {
|
await patchSessionMetadata(originalSessionID, directory, (metadata) => {
|
||||||
const next = { ...metadata };
|
const next = { ...metadata };
|
||||||
@@ -192,14 +449,18 @@ const createOrReuseReviewSession = async (originalSessionID: string, directory:
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
assertAutoReviewRuntimeStillCurrent(expectedRuntimeKey);
|
||||||
const review = await opencodeClient.createSession({
|
const review = await opencodeClient.createSession({
|
||||||
title: REVIEW_SESSION_TITLE,
|
title: REVIEW_SESSION_TITLE,
|
||||||
metadata: withReviewSessionMarker({}, originalSessionID),
|
metadata: withReviewSessionMarker({}, originalSessionID),
|
||||||
}, directory);
|
}, directory);
|
||||||
|
assertAutoReviewRuntimeStillCurrent(expectedRuntimeKey);
|
||||||
registerSessionDirectory(review.id, directory);
|
registerSessionDirectory(review.id, directory);
|
||||||
try {
|
try {
|
||||||
|
assertAutoReviewRuntimeStillCurrent(expectedRuntimeKey);
|
||||||
await patchSessionMetadata(originalSessionID, directory, (metadata) => withReviewSessionLink(metadata, review.id));
|
await patchSessionMetadata(originalSessionID, directory, (metadata) => withReviewSessionLink(metadata, review.id));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
assertAutoReviewRuntimeStillCurrent(expectedRuntimeKey);
|
||||||
await opencodeClient.deleteSession(review.id, directory).catch((deleteError) => {
|
await opencodeClient.deleteSession(review.id, directory).catch((deleteError) => {
|
||||||
console.warn('[review-flow] failed to delete unlinked review session after link failure', 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> => {
|
export const startReviewFlow = async (input: StartReviewFlowInput): Promise<void> => {
|
||||||
await waitForConnectionOrThrow();
|
await waitForConnectionOrThrow();
|
||||||
|
const expectedAutoReviewRuntimeKey = input.autoReview ? getRuntimeKey() : undefined;
|
||||||
let reviewPrompt: string;
|
let reviewPrompt: string;
|
||||||
|
|
||||||
if (input.generateHandoff ?? true) {
|
if (input.generateHandoff ?? true) {
|
||||||
@@ -219,19 +481,38 @@ export const startReviewFlow = async (input: StartReviewFlowInput): Promise<void
|
|||||||
const startedAt = Date.now();
|
const startedAt = Date.now();
|
||||||
await sendPlainMessage(input.originalSessionID, input.directory, visibleText, null, [
|
await sendPlainMessage(input.originalSessionID, input.directory, visibleText, null, [
|
||||||
{ text: instructionsText, synthetic: true },
|
{ text: instructionsText, synthetic: true },
|
||||||
]);
|
], expectedAutoReviewRuntimeKey);
|
||||||
|
|
||||||
const continueFromHandoff = async (): Promise<void> => {
|
const continueFromHandoff = async (): Promise<void> => {
|
||||||
const handoff = await waitForAssistantText(input.originalSessionID, input.directory, startedAt);
|
const handoff = await waitForAssistantText(input.originalSessionID, input.directory, startedAt);
|
||||||
|
assertAutoReviewRuntimeStillCurrent(expectedAutoReviewRuntimeKey);
|
||||||
const handoffReviewPrompt = await renderMagicPrompt('session.reviewSession.visible', { handoff });
|
const handoffReviewPrompt = await renderMagicPrompt('session.reviewSession.visible', { handoff });
|
||||||
const reviewSession = await createOrReuseReviewSession(input.originalSessionID, input.directory);
|
const reviewSession = await createOrReuseReviewSession(input.originalSessionID, input.directory, expectedAutoReviewRuntimeKey);
|
||||||
await sendPlainMessage(reviewSession.id, input.directory, handoffReviewPrompt, {
|
const runtimeKey = expectedAutoReviewRuntimeKey ?? getRuntimeKey();
|
||||||
|
const waitAfterCreatedAt = Date.now();
|
||||||
|
const sentMessageID = await sendPlainMessage(reviewSession.id, input.directory, handoffReviewPrompt, {
|
||||||
providerID: input.providerID,
|
providerID: input.providerID,
|
||||||
modelID: input.modelID,
|
modelID: input.modelID,
|
||||||
agent: input.agent,
|
agent: input.agent,
|
||||||
variant: input.variant,
|
variant: input.variant,
|
||||||
});
|
}, input.autoReview ? autoReviewReviewerInstructions() : undefined, input.autoReview ? runtimeKey : undefined);
|
||||||
openReviewSessionPanel(input.directory, reviewSession);
|
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) {
|
if (input.returnAfterHandoffRequest) {
|
||||||
@@ -247,25 +528,46 @@ export const startReviewFlow = async (input: StartReviewFlowInput): Promise<void
|
|||||||
reviewPrompt = await renderMagicPrompt('session.reviewSessionWithoutHandoff.visible');
|
reviewPrompt = await renderMagicPrompt('session.reviewSessionWithoutHandoff.visible');
|
||||||
}
|
}
|
||||||
|
|
||||||
const reviewSession = await createOrReuseReviewSession(input.originalSessionID, input.directory);
|
const reviewSession = await createOrReuseReviewSession(input.originalSessionID, input.directory, expectedAutoReviewRuntimeKey);
|
||||||
await sendPlainMessage(reviewSession.id, input.directory, reviewPrompt, {
|
const runtimeKey = expectedAutoReviewRuntimeKey ?? getRuntimeKey();
|
||||||
|
const waitAfterCreatedAt = Date.now();
|
||||||
|
const sentMessageID = await sendPlainMessage(reviewSession.id, input.directory, reviewPrompt, {
|
||||||
providerID: input.providerID,
|
providerID: input.providerID,
|
||||||
modelID: input.modelID,
|
modelID: input.modelID,
|
||||||
agent: input.agent,
|
agent: input.agent,
|
||||||
variant: input.variant,
|
variant: input.variant,
|
||||||
});
|
}, input.autoReview ? autoReviewReviewerInstructions() : undefined, input.autoReview ? runtimeKey : undefined);
|
||||||
openReviewSessionPanel(input.directory, reviewSession);
|
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 reviewSession = await opencodeClient.getSession(reviewSessionID, directory);
|
||||||
const originalSessionID = getOriginalSessionID(reviewSession);
|
const originalSessionID = getOriginalSessionID(reviewSession);
|
||||||
if (!originalSessionID) throw new Error('Original session is missing');
|
if (!originalSessionID) throw new Error('Original session is missing');
|
||||||
const prompt = await renderMagicPrompt('session.reviewFeedbackToImplementer.visible', { review_feedback: reviewFeedback });
|
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 originalSession = await opencodeClient.getSession(originalSessionID, directory);
|
||||||
const reviewSessionID = getReviewSessionID(originalSession);
|
const reviewSessionID = getReviewSessionID(originalSession);
|
||||||
if (!reviewSessionID) throw new Error('Review session is missing');
|
if (!reviewSessionID) throw new Error('Review session is missing');
|
||||||
@@ -273,12 +575,17 @@ export const sendImplementationResponseToReviewer = async (originalSessionID: st
|
|||||||
try {
|
try {
|
||||||
reviewSession = await opencodeClient.getSession(reviewSessionID, directory);
|
reviewSession = await opencodeClient.getSession(reviewSessionID, directory);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
assertAutoReviewRuntimeStillCurrent(expectedRuntimeKey);
|
||||||
await patchSessionMetadata(originalSessionID, directory, (metadata) => withoutReviewSessionLink(metadata, reviewSessionID));
|
await patchSessionMetadata(originalSessionID, directory, (metadata) => withoutReviewSessionLink(metadata, reviewSessionID));
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
const prompt = await renderMagicPrompt('session.implementationResponseToReviewer.visible', { implementation_response: implementationResponse });
|
const prompt = await renderMagicPrompt('session.implementationResponseToReviewer.visible', { implementation_response: implementationResponse });
|
||||||
await sendPlainMessage(reviewSessionID, directory, prompt);
|
assertAutoReviewRuntimeStillCurrent(expectedRuntimeKey);
|
||||||
openReviewSessionPanel(directory, reviewSession);
|
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';
|
export type ReviewTransferDirection = 'review-to-original' | 'original-to-review';
|
||||||
|
|||||||
@@ -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<string, AutoReviewRun>;
|
||||||
|
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<AutoReviewState>()(
|
||||||
|
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 }),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
@@ -387,6 +387,57 @@ describe("optimisticSend target directory", () => {
|
|||||||
expect(targetStore.getState().session_status["session-new"]?.type).toBe("busy")
|
expect(targetStore.getState().session_status["session-new"]?.type).toBe("busy")
|
||||||
expect(currentStore.getState().session_status["session-new"]).toBe(undefined)
|
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", () => {
|
describe("respondToPermission passes directory", () => {
|
||||||
|
|||||||
@@ -629,6 +629,8 @@ export async function optimisticSend(input: {
|
|||||||
directory?: string | null
|
directory?: string | null
|
||||||
files?: Array<{ type: "file"; mime: string; url: string; filename: string }>
|
files?: Array<{ type: "file"; mime: string; url: string; filename: string }>
|
||||||
onOptimisticInsert?: () => void
|
onOptimisticInsert?: () => void
|
||||||
|
onMessageID?: (messageID: string) => void
|
||||||
|
beforeOptimisticInsert?: () => void
|
||||||
/** The actual API call — receives the optimistic messageID so the server can use the same ID */
|
/** The actual API call — receives the optimistic messageID so the server can use the same ID */
|
||||||
send: (messageID: string) => Promise<void>
|
send: (messageID: string) => Promise<void>
|
||||||
}): Promise<void> {
|
}): Promise<void> {
|
||||||
@@ -637,10 +639,12 @@ export async function optimisticSend(input: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
await waitForConnectionOrThrow()
|
await waitForConnectionOrThrow()
|
||||||
|
input.beforeOptimisticInsert?.()
|
||||||
|
|
||||||
const targetDirectory = input.directory ?? dir()
|
const targetDirectory = input.directory ?? dir()
|
||||||
const store = targetDirectory ? dirStoreForDirectory(targetDirectory) : dirStore()
|
const store = targetDirectory ? dirStoreForDirectory(targetDirectory) : dirStore()
|
||||||
const messageID = ascendingId("msg")
|
const messageID = ascendingId("msg")
|
||||||
|
input.onMessageID?.(messageID)
|
||||||
const textPartId = ascendingId("prt")
|
const textPartId = ascendingId("prt")
|
||||||
|
|
||||||
const optimisticParts: Part[] = [
|
const optimisticParts: Part[] = [
|
||||||
|
|||||||
Reference in New Issue
Block a user