summaryrefslogtreecommitdiff
path: root/packages/cli/src
diff options
context:
space:
mode:
authordarkcocoa <[email protected]>2025-07-22 06:57:11 +0900
committerGitHub <[email protected]>2025-07-21 21:57:11 +0000
commit4c3532d2b395859fb0d9db00bc5209f160ce2e29 (patch)
treeec8066df91e155ab318ef107eff7c0ac813292f6 /packages/cli/src
parentdc2ac144b7059ec2d66f1e90316df40d3822c8b5 (diff)
fix: Add warning message for token limit truncation (#2260)
Co-authored-by: Sandy Tao <[email protected]>
Diffstat (limited to 'packages/cli/src')
-rw-r--r--packages/cli/src/ui/hooks/useGeminiStream.test.tsx238
-rw-r--r--packages/cli/src/ui/hooks/useGeminiStream.ts50
2 files changed, 286 insertions, 2 deletions
diff --git a/packages/cli/src/ui/hooks/useGeminiStream.test.tsx b/packages/cli/src/ui/hooks/useGeminiStream.test.tsx
index 7e45cab2..d7fd35c8 100644
--- a/packages/cli/src/ui/hooks/useGeminiStream.test.tsx
+++ b/packages/cli/src/ui/hooks/useGeminiStream.test.tsx
@@ -16,7 +16,12 @@ import {
TrackedExecutingToolCall,
TrackedCancelledToolCall,
} from './useReactToolScheduler.js';
-import { Config, EditorType, AuthType } from '@google/gemini-cli-core';
+import {
+ Config,
+ EditorType,
+ AuthType,
+ GeminiEventType as ServerGeminiEventType,
+} from '@google/gemini-cli-core';
import { Part, PartListUnion } from '@google/genai';
import { UseHistoryManagerReturn } from './useHistoryManager.js';
import {
@@ -1178,4 +1183,235 @@ describe('useGeminiStream', () => {
});
});
});
+
+ describe('handleFinishedEvent', () => {
+ it('should add info message for MAX_TOKENS finish reason', async () => {
+ // Setup mock to return a stream with MAX_TOKENS finish reason
+ mockSendMessageStream.mockReturnValue(
+ (async function* () {
+ yield {
+ type: ServerGeminiEventType.Content,
+ value: 'This is a truncated response...',
+ };
+ yield { type: ServerGeminiEventType.Finished, value: 'MAX_TOKENS' };
+ })(),
+ );
+
+ const { result } = renderHook(() =>
+ useGeminiStream(
+ new MockedGeminiClientClass(mockConfig),
+ [],
+ mockAddItem,
+ mockSetShowHelp,
+ mockConfig,
+ mockOnDebugMessage,
+ mockHandleSlashCommand,
+ false,
+ () => 'vscode' as EditorType,
+ () => {},
+ () => Promise.resolve(),
+ false,
+ () => {},
+ ),
+ );
+
+ // Submit a query
+ await act(async () => {
+ await result.current.submitQuery('Generate long text');
+ });
+
+ // Check that the info message was added
+ await waitFor(() => {
+ expect(mockAddItem).toHaveBeenCalledWith(
+ {
+ type: 'info',
+ text: '⚠️ Response truncated due to token limits.',
+ },
+ expect.any(Number),
+ );
+ });
+ });
+
+ it('should not add message for STOP finish reason', async () => {
+ // Setup mock to return a stream with STOP finish reason
+ mockSendMessageStream.mockReturnValue(
+ (async function* () {
+ yield {
+ type: ServerGeminiEventType.Content,
+ value: 'Complete response',
+ };
+ yield { type: ServerGeminiEventType.Finished, value: 'STOP' };
+ })(),
+ );
+
+ const { result } = renderHook(() =>
+ useGeminiStream(
+ new MockedGeminiClientClass(mockConfig),
+ [],
+ mockAddItem,
+ mockSetShowHelp,
+ mockConfig,
+ mockOnDebugMessage,
+ mockHandleSlashCommand,
+ false,
+ () => 'vscode' as EditorType,
+ () => {},
+ () => Promise.resolve(),
+ false,
+ () => {},
+ ),
+ );
+
+ // Submit a query
+ await act(async () => {
+ await result.current.submitQuery('Test normal completion');
+ });
+
+ // Wait a bit to ensure no message is added
+ await new Promise((resolve) => setTimeout(resolve, 100));
+
+ // Check that no info message was added for STOP
+ const infoMessages = mockAddItem.mock.calls.filter(
+ (call) => call[0].type === 'info',
+ );
+ expect(infoMessages).toHaveLength(0);
+ });
+
+ it('should not add message for FINISH_REASON_UNSPECIFIED', async () => {
+ // Setup mock to return a stream with FINISH_REASON_UNSPECIFIED
+ mockSendMessageStream.mockReturnValue(
+ (async function* () {
+ yield {
+ type: ServerGeminiEventType.Content,
+ value: 'Response with unspecified finish',
+ };
+ yield {
+ type: ServerGeminiEventType.Finished,
+ value: 'FINISH_REASON_UNSPECIFIED',
+ };
+ })(),
+ );
+
+ const { result } = renderHook(() =>
+ useGeminiStream(
+ new MockedGeminiClientClass(mockConfig),
+ [],
+ mockAddItem,
+ mockSetShowHelp,
+ mockConfig,
+ mockOnDebugMessage,
+ mockHandleSlashCommand,
+ false,
+ () => 'vscode' as EditorType,
+ () => {},
+ () => Promise.resolve(),
+ false,
+ () => {},
+ ),
+ );
+
+ // Submit a query
+ await act(async () => {
+ await result.current.submitQuery('Test unspecified finish');
+ });
+
+ // Wait a bit to ensure no message is added
+ await new Promise((resolve) => setTimeout(resolve, 100));
+
+ // Check that no info message was added
+ const infoMessages = mockAddItem.mock.calls.filter(
+ (call) => call[0].type === 'info',
+ );
+ expect(infoMessages).toHaveLength(0);
+ });
+
+ it('should add appropriate messages for other finish reasons', async () => {
+ const testCases = [
+ {
+ reason: 'SAFETY',
+ message: '⚠️ Response stopped due to safety reasons.',
+ },
+ {
+ reason: 'RECITATION',
+ message: '⚠️ Response stopped due to recitation policy.',
+ },
+ {
+ reason: 'LANGUAGE',
+ message: '⚠️ Response stopped due to unsupported language.',
+ },
+ {
+ reason: 'BLOCKLIST',
+ message: '⚠️ Response stopped due to forbidden terms.',
+ },
+ {
+ reason: 'PROHIBITED_CONTENT',
+ message: '⚠️ Response stopped due to prohibited content.',
+ },
+ {
+ reason: 'SPII',
+ message:
+ '⚠️ Response stopped due to sensitive personally identifiable information.',
+ },
+ { reason: 'OTHER', message: '⚠️ Response stopped for other reasons.' },
+ {
+ reason: 'MALFORMED_FUNCTION_CALL',
+ message: '⚠️ Response stopped due to malformed function call.',
+ },
+ {
+ reason: 'IMAGE_SAFETY',
+ message: '⚠️ Response stopped due to image safety violations.',
+ },
+ {
+ reason: 'UNEXPECTED_TOOL_CALL',
+ message: '⚠️ Response stopped due to unexpected tool call.',
+ },
+ ];
+
+ for (const { reason, message } of testCases) {
+ // Reset mocks for each test case
+ mockAddItem.mockClear();
+ mockSendMessageStream.mockReturnValue(
+ (async function* () {
+ yield {
+ type: ServerGeminiEventType.Content,
+ value: `Response for ${reason}`,
+ };
+ yield { type: ServerGeminiEventType.Finished, value: reason };
+ })(),
+ );
+
+ const { result } = renderHook(() =>
+ useGeminiStream(
+ new MockedGeminiClientClass(mockConfig),
+ [],
+ mockAddItem,
+ mockSetShowHelp,
+ mockConfig,
+ mockOnDebugMessage,
+ mockHandleSlashCommand,
+ false,
+ () => 'vscode' as EditorType,
+ () => {},
+ () => Promise.resolve(),
+ false,
+ () => {},
+ ),
+ );
+
+ await act(async () => {
+ await result.current.submitQuery(`Test ${reason}`);
+ });
+
+ await waitFor(() => {
+ expect(mockAddItem).toHaveBeenCalledWith(
+ {
+ type: 'info',
+ text: message,
+ },
+ expect.any(Number),
+ );
+ });
+ }
+ });
+ });
});
diff --git a/packages/cli/src/ui/hooks/useGeminiStream.ts b/packages/cli/src/ui/hooks/useGeminiStream.ts
index 9fa23c52..295b5650 100644
--- a/packages/cli/src/ui/hooks/useGeminiStream.ts
+++ b/packages/cli/src/ui/hooks/useGeminiStream.ts
@@ -14,6 +14,7 @@ import {
ServerGeminiContentEvent as ContentEvent,
ServerGeminiErrorEvent as ErrorEvent,
ServerGeminiChatCompressedEvent,
+ ServerGeminiFinishedEvent,
getErrorMessage,
isNodeError,
MessageSenderType,
@@ -26,7 +27,7 @@ import {
UserPromptEvent,
DEFAULT_GEMINI_FLASH_MODEL,
} from '@google/gemini-cli-core';
-import { type Part, type PartListUnion } from '@google/genai';
+import { type Part, type PartListUnion, FinishReason } from '@google/genai';
import {
StreamingState,
HistoryItem,
@@ -422,6 +423,46 @@ export const useGeminiStream = (
[addItem, pendingHistoryItemRef, setPendingHistoryItem, config],
);
+ const handleFinishedEvent = useCallback(
+ (event: ServerGeminiFinishedEvent, userMessageTimestamp: number) => {
+ const finishReason = event.value;
+
+ const finishReasonMessages: Record<FinishReason, string | undefined> = {
+ [FinishReason.FINISH_REASON_UNSPECIFIED]: undefined,
+ [FinishReason.STOP]: undefined,
+ [FinishReason.MAX_TOKENS]: 'Response truncated due to token limits.',
+ [FinishReason.SAFETY]: 'Response stopped due to safety reasons.',
+ [FinishReason.RECITATION]: 'Response stopped due to recitation policy.',
+ [FinishReason.LANGUAGE]:
+ 'Response stopped due to unsupported language.',
+ [FinishReason.BLOCKLIST]: 'Response stopped due to forbidden terms.',
+ [FinishReason.PROHIBITED_CONTENT]:
+ 'Response stopped due to prohibited content.',
+ [FinishReason.SPII]:
+ 'Response stopped due to sensitive personally identifiable information.',
+ [FinishReason.OTHER]: 'Response stopped for other reasons.',
+ [FinishReason.MALFORMED_FUNCTION_CALL]:
+ 'Response stopped due to malformed function call.',
+ [FinishReason.IMAGE_SAFETY]:
+ 'Response stopped due to image safety violations.',
+ [FinishReason.UNEXPECTED_TOOL_CALL]:
+ 'Response stopped due to unexpected tool call.',
+ };
+
+ const message = finishReasonMessages[finishReason];
+ if (message) {
+ addItem(
+ {
+ type: 'info',
+ text: `⚠️ ${message}`,
+ },
+ userMessageTimestamp,
+ );
+ }
+ },
+ [addItem],
+ );
+
const handleChatCompressionEvent = useCallback(
(eventValue: ServerGeminiChatCompressedEvent['value']) =>
addItem(
@@ -501,6 +542,12 @@ export const useGeminiStream = (
case ServerGeminiEventType.MaxSessionTurns:
handleMaxSessionTurnsEvent();
break;
+ case ServerGeminiEventType.Finished:
+ handleFinishedEvent(
+ event as ServerGeminiFinishedEvent,
+ userMessageTimestamp,
+ );
+ break;
case ServerGeminiEventType.LoopDetected:
// handle later because we want to move pending history to history
// before we add loop detected message to history
@@ -524,6 +571,7 @@ export const useGeminiStream = (
handleErrorEvent,
scheduleToolCalls,
handleChatCompressionEvent,
+ handleFinishedEvent,
handleMaxSessionTurnsEvent,
],
);