Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 19 additions & 2 deletions packages/cli/src/ui/AppContainer.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1811,13 +1811,26 @@ describe('AppContainer State Management', () => {
});

it('restores the prompt when onCancelSubmit is called with shouldRestorePrompt=true (or undefined)', async () => {
// Mock history containing the message we want to restore
const mockHistory = [{ type: 'user', text: 'previous message' }];
mockedUseHistory.mockReturnValue({
history: mockHistory,
addItem: vi.fn(),
updateItem: vi.fn(),
clearItems: vi.fn(),
loadHistory: vi.fn(),
});

// Mock logger to return the same message so userMessages is populated
mockedUseLogger.mockReturnValue({
getPreviousUserMessages: vi
.fn()
.mockResolvedValue(['previous message']),
});

const { unmount } = renderAppContainer();

// Wait for userMessages to be populated
await waitFor(() =>
expect(capturedUIState.userMessages).toContain('previous message'),
);
Expand All @@ -1826,11 +1839,15 @@ describe('AppContainer State Management', () => {
mockedUseGeminiStream.mock.lastCall!,
);

await act(async () => {
// Trigger cancel/restore
act(() => {
onCancelSubmit(true);
});

expect(mockSetText).toHaveBeenCalledWith('previous message');
// Wait for the effect to run and restore the text
await waitFor(() => {
expect(mockSetText).toHaveBeenCalledWith('previous message');
});

unmount();
});
Expand Down
39 changes: 36 additions & 3 deletions packages/cli/src/ui/AppContainer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,8 @@ export const AppContainer = (props: AppContainerProps) => {
const [warningBannerText, setWarningBannerText] = useState('');
const [bannerVisible, setBannerVisible] = useState(true);

const [pendingPromptRestore, setPendingPromptRestore] = useState(false);

const extensionManager = config.getExtensionLoader() as ExtensionManager;
// We are in the interactive CLI, update how we request consent and settings.
extensionManager.setRequestConsent((description) =>
Expand Down Expand Up @@ -725,8 +727,15 @@ Logging in with Google... Please restart Gemini CLI to continue.
return;
}

const lastUserMessage = userMessages.at(-1);
let textToSet = shouldRestorePrompt ? lastUserMessage || '' : '';
let textToSet = '';
if (shouldRestorePrompt) {
// Defer restoration to useEffect to ensure we get the latest user message
setPendingPromptRestore(true);
return;
}

// If we are not restoring, ensure we cancel any pending restore
setPendingPromptRestore(false);

const queuedText = getQueuedMessagesText();
if (queuedText) {
Expand All @@ -740,14 +749,38 @@ Logging in with Google... Please restart Gemini CLI to continue.
},
[
buffer,
userMessages,
getQueuedMessagesText,
clearQueue,
pendingSlashCommandHistoryItems,
pendingGeminiHistoryItems,
],
);

useEffect(() => {
if (pendingPromptRestore) {
// Check if userMessages is up to date with historyManager
const lastHistoryItem = historyManager.history.findLast(
(item) => item.type === 'user' && item.text,
);
const lastUserMessage = userMessages.at(-1);

if (
lastHistoryItem &&
lastHistoryItem.text === lastUserMessage &&
lastUserMessage
) {
buffer.setText(lastUserMessage);
setPendingPromptRestore(false);
}
}
}, [
pendingPromptRestore,
userMessages,
historyManager.history,
buffer,
setPendingPromptRestore,
]);
Comment on lines +759 to +782
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

critical

While this useEffect approach does fix the original race condition, it introduces a new, more subtle one. If a user submits a second prompt very quickly after the one that caused the context overflow, this logic could restore the second prompt instead of the one that overflowed. This happens because the effect always looks for the latest user message in historyManager.history and userMessages to be in sync, but it doesn't track which message was supposed to be restored.

A simpler and more robust solution would be to avoid the useEffect and the pendingPromptRestore state altogether. You can directly access the correct history in the onCancelSubmit handler.

I suggest the following changes:

  1. Remove the pendingPromptRestore state.
  2. Remove this useEffect block.
  3. Update cancelHandlerRef.current to get the last message directly from historyManager.history and add historyManager.history to its useCallback dependency array.

Here's how cancelHandlerRef.current could look:

cancelHandlerRef.current = useCallback(
  (shouldRestorePrompt: boolean = true) => {
    const pendingHistoryItems = [
      ...pendingSlashCommandHistoryItems,
      ...pendingGeminiHistoryItems,
    ];
    if (isToolExecuting(pendingHistoryItems)) {
      buffer.setText(''); // Just clear the prompt
      return;
    }

    let textToSet = '';
    if (shouldRestorePrompt) {
      const lastHistoryItem = historyManager.history.findLast(
        (item) => item.type === 'user' && item.text,
      );
      textToSet = lastHistoryItem?.text || '';
    }

    const queuedText = getQueuedMessagesText();
    if (queuedText) {
      textToSet = textToSet ? `${textToSet}\n\n${queuedText}` : queuedText;
      clearQueue();
    }

    if (textToSet || !shouldRestorePrompt) {
      buffer.setText(textToSet);
    }
  },
  [
    buffer,
    getQueuedMessagesText,
    clearQueue,
    pendingSlashCommandHistoryItems,
    pendingGeminiHistoryItems,
    historyManager.history, // Add this dependency
  ],
);

This approach is more direct, less complex, and avoids the potential for new race conditions.


const handleFinalSubmit = useCallback(
(submittedValue: string) => {
addMessage(submittedValue);
Expand Down