Skip to content

Commit e80d7cc

Browse files
authored
feat: allow queuing messages during compression (#24071) (#26506)
1 parent 7cc19c2 commit e80d7cc

6 files changed

Lines changed: 179 additions & 38 deletions

File tree

packages/cli/src/ui/AppContainer.test.tsx

Lines changed: 62 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -100,7 +100,7 @@ import { type LoadedSettings } from '../config/settings.js';
100100
import { createMockSettings } from '../test-utils/settings.js';
101101
import type { InitializationResult } from '../core/initializer.js';
102102
import { useQuotaAndFallback } from './hooks/useQuotaAndFallback.js';
103-
import { StreamingState } from './types.js';
103+
import { StreamingState, MessageType } from './types.js';
104104
import { UIStateContext, type UIState } from './contexts/UIStateContext.js';
105105
import {
106106
UIActionsContext,
@@ -3576,4 +3576,65 @@ describe('AppContainer State Management', () => {
35763576
unmount();
35773577
});
35783578
});
3579+
3580+
describe('Compression Queuing', () => {
3581+
beforeEach(async () => {
3582+
const { checkPermissions } = await import(
3583+
'./hooks/atCommandProcessor.js'
3584+
);
3585+
vi.mocked(checkPermissions).mockResolvedValue([]);
3586+
3587+
vi.spyOn(mockConfig, 'isModelSteeringEnabled').mockReturnValue(true);
3588+
3589+
const actual = await vi.importActual('./hooks/useMessageQueue.js');
3590+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
3591+
const { useMessageQueue: realUseMessageQueue } = actual as any;
3592+
mockedUseMessageQueue.mockImplementation(realUseMessageQueue);
3593+
3594+
// Start compression by mocking pendingHistoryItems to include a pending compression
3595+
mockedUseGeminiStream.mockImplementation(() => ({
3596+
...DEFAULT_GEMINI_STREAM_MOCK,
3597+
pendingHistoryItems: [
3598+
{
3599+
type: MessageType.COMPRESSION,
3600+
compression: {
3601+
isPending: true,
3602+
originalTokenCount: null,
3603+
newTokenCount: null,
3604+
compressionStatus: null,
3605+
},
3606+
},
3607+
],
3608+
}));
3609+
});
3610+
3611+
it('queues messages during compression instead of handling as steering hints', async () => {
3612+
const { unmount } = await act(async () => renderAppContainer());
3613+
3614+
// Verify state isolation
3615+
expect(capturedUIState.streamingState).toBe(StreamingState.Idle);
3616+
3617+
// Submit a message
3618+
await act(async () =>
3619+
capturedUIActions.handleFinalSubmit('follow up message'),
3620+
);
3621+
3622+
// Verify it was queued, not submitted as steering hint
3623+
expect(capturedUIState.messageQueue).toContain('follow up message');
3624+
3625+
unmount();
3626+
});
3627+
3628+
it('executes slash commands immediately during compression', async () => {
3629+
const { unmount } = await act(async () => renderAppContainer());
3630+
3631+
// Submit a slash command
3632+
await act(async () => capturedUIActions.handleFinalSubmit('/help'));
3633+
3634+
// Verify it was NOT queued
3635+
expect(capturedUIState.messageQueue).not.toContain('/help');
3636+
3637+
unmount();
3638+
});
3639+
});
35793640
});

packages/cli/src/ui/AppContainer.tsx

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1310,6 +1310,15 @@ Logging in with Google... Restarting Gemini CLI to continue.
13101310

13111311
const { isMcpReady } = useMcpStatus(config);
13121312

1313+
const isCompressing = useMemo(
1314+
() =>
1315+
pendingHistoryItems.some(
1316+
(item) =>
1317+
item.type === MessageType.COMPRESSION && item.compression.isPending,
1318+
),
1319+
[pendingHistoryItems],
1320+
);
1321+
13131322
const {
13141323
messageQueue,
13151324
addMessage,
@@ -1321,6 +1330,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
13211330
streamingState,
13221331
submitQuery,
13231332
isMcpReady,
1333+
isCompressing,
13241334
});
13251335

13261336
cancelHandlerRef.current = useCallback(
@@ -1415,7 +1425,10 @@ Logging in with Google... Restarting Gemini CLI to continue.
14151425
}
14161426

14171427
const isMcpOrConfigReady = isConfigInitialized && isMcpReady;
1418-
if ((isSlash && isConfigInitialized) || (isIdle && isMcpOrConfigReady)) {
1428+
if (
1429+
(isSlash && isConfigInitialized) ||
1430+
(!isCompressing && isIdle && isMcpOrConfigReady)
1431+
) {
14191432
if (!isSlash) {
14201433
const permissions = await checkPermissions(submittedValue, config);
14211434
if (permissions.length > 0) {
@@ -1438,7 +1451,12 @@ Logging in with Google... Restarting Gemini CLI to continue.
14381451
void submitQuery(submittedValue);
14391452
} else {
14401453
// Check messageQueue.length === 0 to only notify on the first queued item
1441-
if (isIdle && !isMcpOrConfigReady && messageQueue.length === 0) {
1454+
if (
1455+
isIdle &&
1456+
!isCompressing &&
1457+
!isMcpOrConfigReady &&
1458+
messageQueue.length === 0
1459+
) {
14421460
coreEvents.emitFeedback(
14431461
'info',
14441462
!isConfigInitialized
@@ -1458,6 +1476,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
14581476
slashCommands,
14591477
isMcpReady,
14601478
streamingState,
1479+
isCompressing,
14611480
messageQueue.length,
14621481
pendingHistoryItems,
14631482
config,

packages/cli/src/ui/commands/compressCommand.test.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ describe('compressCommand', () => {
4242
},
4343
};
4444
await compressCommand.action!(context, '');
45+
await new Promise((r) => setTimeout(r, 0));
4546
expect(context.ui.addItem).toHaveBeenCalledWith(
4647
expect.objectContaining({
4748
type: MessageType.ERROR,
@@ -62,6 +63,7 @@ describe('compressCommand', () => {
6263
mockTryCompressChat.mockResolvedValue(compressedResult);
6364

6465
await compressCommand.action!(context, '');
66+
await new Promise((r) => setTimeout(r, 0));
6567

6668
expect(context.ui.setPendingItem).toHaveBeenNthCalledWith(1, {
6769
type: MessageType.COMPRESSION,
@@ -98,6 +100,7 @@ describe('compressCommand', () => {
98100
mockTryCompressChat.mockResolvedValue(null);
99101

100102
await compressCommand.action!(context, '');
103+
await new Promise((r) => setTimeout(r, 0));
101104

102105
expect(context.ui.addItem).toHaveBeenCalledWith(
103106
expect.objectContaining({
@@ -114,6 +117,7 @@ describe('compressCommand', () => {
114117
mockTryCompressChat.mockRejectedValue(error);
115118

116119
await compressCommand.action!(context, '');
120+
await new Promise((r) => setTimeout(r, 0));
117121

118122
expect(context.ui.addItem).toHaveBeenCalledWith(
119123
expect.objectContaining({
@@ -128,6 +132,7 @@ describe('compressCommand', () => {
128132
it('should clear the pending item in a finally block', async () => {
129133
mockTryCompressChat.mockRejectedValue(new Error('some error'));
130134
await compressCommand.action!(context, '');
135+
await new Promise((r) => setTimeout(r, 0));
131136
expect(context.ui.setPendingItem).toHaveBeenCalledWith(null);
132137
});
133138

packages/cli/src/ui/commands/compressCommand.ts

Lines changed: 38 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -36,48 +36,51 @@ export const compressCommand: SlashCommand = {
3636
},
3737
};
3838

39-
try {
40-
ui.setPendingItem(pendingMessage);
41-
const promptId = `compress-${Date.now()}`;
42-
const compressed =
43-
await context.services.agentContext?.geminiClient?.tryCompressChat(
44-
promptId,
45-
true,
46-
);
47-
if (compressed) {
48-
ui.addItem(
49-
{
50-
type: MessageType.COMPRESSION,
51-
compression: {
52-
isPending: false,
53-
originalTokenCount: compressed.originalTokenCount,
54-
newTokenCount: compressed.newTokenCount,
55-
compressionStatus: compressed.compressionStatus,
39+
ui.setPendingItem(pendingMessage);
40+
41+
void (async () => {
42+
try {
43+
const promptId = `compress-${Date.now()}`;
44+
const compressed =
45+
await context.services.agentContext?.geminiClient?.tryCompressChat(
46+
promptId,
47+
true,
48+
);
49+
if (compressed) {
50+
ui.addItem(
51+
{
52+
type: MessageType.COMPRESSION,
53+
compression: {
54+
isPending: false,
55+
originalTokenCount: compressed.originalTokenCount,
56+
newTokenCount: compressed.newTokenCount,
57+
compressionStatus: compressed.compressionStatus,
58+
},
59+
} as HistoryItemCompression,
60+
Date.now(),
61+
);
62+
} else {
63+
ui.addItem(
64+
{
65+
type: MessageType.ERROR,
66+
text: 'Failed to compress chat history.',
5667
},
57-
} as HistoryItemCompression,
58-
Date.now(),
59-
);
60-
} else {
68+
Date.now(),
69+
);
70+
}
71+
} catch (e) {
6172
ui.addItem(
6273
{
6374
type: MessageType.ERROR,
64-
text: 'Failed to compress chat history.',
75+
text: `Failed to compress chat history: ${
76+
e instanceof Error ? e.message : String(e)
77+
}`,
6578
},
6679
Date.now(),
6780
);
81+
} finally {
82+
ui.setPendingItem(null);
6883
}
69-
} catch (e) {
70-
ui.addItem(
71-
{
72-
type: MessageType.ERROR,
73-
text: `Failed to compress chat history: ${
74-
e instanceof Error ? e.message : String(e)
75-
}`,
76-
},
77-
Date.now(),
78-
);
79-
} finally {
80-
ui.setPendingItem(null);
81-
}
84+
})();
8285
},
8386
};

packages/cli/src/ui/hooks/useMessageQueue.test.tsx

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ describe('useMessageQueue', () => {
2929
streamingState: StreamingState;
3030
submitQuery: (query: string) => void;
3131
isMcpReady: boolean;
32+
isCompressing?: boolean;
3233
}) => {
3334
let hookResult: ReturnType<typeof useMessageQueue>;
3435
function TestComponent(props: typeof initialProps) {
@@ -402,4 +403,52 @@ describe('useMessageQueue', () => {
402403
expect(result.current.messageQueue).toEqual([]);
403404
});
404405
});
406+
407+
describe('isCompressing logic', () => {
408+
it('should not auto-submit when isCompressing is true, even if streamingState is Idle', async () => {
409+
const { result } = await renderMessageQueueHook({
410+
isConfigInitialized: true,
411+
streamingState: StreamingState.Idle,
412+
submitQuery: mockSubmitQuery,
413+
isMcpReady: true,
414+
isCompressing: true,
415+
});
416+
417+
// Add messages
418+
act(() => {
419+
result.current.addMessage('Compression message');
420+
});
421+
422+
expect(mockSubmitQuery).not.toHaveBeenCalled();
423+
expect(result.current.messageQueue).toEqual(['Compression message']);
424+
});
425+
426+
it('should auto-submit queued messages when isCompressing becomes false', async () => {
427+
const { result, rerender } = await renderMessageQueueHook({
428+
isConfigInitialized: true,
429+
streamingState: StreamingState.Idle,
430+
submitQuery: mockSubmitQuery,
431+
isMcpReady: true,
432+
isCompressing: true,
433+
});
434+
435+
// Add messages
436+
act(() => {
437+
result.current.addMessage('Pending compression message 1');
438+
result.current.addMessage('Pending compression message 2');
439+
});
440+
441+
expect(mockSubmitQuery).not.toHaveBeenCalled();
442+
443+
// Transition isCompressing to false
444+
rerender({ isCompressing: false });
445+
446+
await waitFor(() => {
447+
expect(mockSubmitQuery).toHaveBeenCalledWith(
448+
'Pending compression message 1\n\nPending compression message 2',
449+
);
450+
expect(result.current.messageQueue).toEqual([]);
451+
});
452+
});
453+
});
405454
});

packages/cli/src/ui/hooks/useMessageQueue.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ export interface UseMessageQueueOptions {
1212
streamingState: StreamingState;
1313
submitQuery: (query: string) => void;
1414
isMcpReady: boolean;
15+
isCompressing?: boolean;
1516
}
1617

1718
export interface UseMessageQueueReturn {
@@ -32,6 +33,7 @@ export function useMessageQueue({
3233
streamingState,
3334
submitQuery,
3435
isMcpReady,
36+
isCompressing = false,
3537
}: UseMessageQueueOptions): UseMessageQueueReturn {
3638
const [messageQueue, setMessageQueue] = useState<string[]>([]);
3739

@@ -69,6 +71,7 @@ export function useMessageQueue({
6971
if (
7072
isConfigInitialized &&
7173
streamingState === StreamingState.Idle &&
74+
!isCompressing &&
7275
isMcpReady &&
7376
messageQueue.length > 0
7477
) {
@@ -84,6 +87,7 @@ export function useMessageQueue({
8487
isMcpReady,
8588
messageQueue,
8689
submitQuery,
90+
isCompressing,
8791
]);
8892

8993
return {

0 commit comments

Comments
 (0)