diff --git a/packages/cli/src/acp-integration/session/Session.test.ts b/packages/cli/src/acp-integration/session/Session.test.ts index afd28a48bcd..0c6d566543e 100644 --- a/packages/cli/src/acp-integration/session/Session.test.ts +++ b/packages/cli/src/acp-integration/session/Session.test.ts @@ -33247,65 +33247,70 @@ describe('Session', () => { expect(mockChat.sendMessageStream).toHaveBeenCalledTimes(2); }); - it('does not count a failed Guard compression or block later automatic work', async () => { - rebuildSessionWithGuard(); - installPendingTodoTool(); - queuePendingTodoThenNaturalStops(); - const noCompression = { - originalTokenCount: 50, - newTokenCount: 50, - compressionStatus: core.CompressionStatus.NOOP, - }; - mockLlmClient.tryCompressChat - .mockResolvedValueOnce(noCompression) - .mockResolvedValueOnce(noCompression) - .mockResolvedValueOnce({ - originalTokenCount: 120, - newTokenCount: 120, - compressionStatus: - core.CompressionStatus.COMPRESSION_FAILED_EMPTY_SUMMARY, - }) - .mockResolvedValue(noCompression); + it.each([ + core.CompressionStatus.COMPRESSION_FAILED_EMPTY_SUMMARY, + core.CompressionStatus.COMPRESSION_FAILED_API_ERROR, + ])( + 'does not count a failed Guard compression status %s or block later automatic work', + async (compressionStatus) => { + rebuildSessionWithGuard(); + installPendingTodoTool(); + queuePendingTodoThenNaturalStops(); + const noCompression = { + originalTokenCount: 50, + newTokenCount: 50, + compressionStatus: core.CompressionStatus.NOOP, + }; + mockLlmClient.tryCompressChat + .mockResolvedValueOnce(noCompression) + .mockResolvedValueOnce(noCompression) + .mockResolvedValueOnce({ + originalTokenCount: 120, + newTokenCount: 120, + compressionStatus, + }) + .mockResolvedValue(noCompression); - await runGuardPrompt(); + await runGuardPrompt(); - expect(mockChat.sendMessageStream).toHaveBeenCalledTimes(2); - expect( - vi - .mocked(mockClient.sessionUpdate) - .mock.calls.some( - ([params]) => - params.update.sessionUpdate === 'agent_message_chunk' && - params.update._meta?.['source'] === 'todo_stop_guard', - ), - ).toBe(false); + expect(mockChat.sendMessageStream).toHaveBeenCalledTimes(2); + expect( + vi + .mocked(mockClient.sessionUpdate) + .mock.calls.some( + ([params]) => + params.update.sessionUpdate === 'agent_message_chunk' && + params.update._meta?.['source'] === 'todo_stop_guard', + ), + ).toBe(false); - const callback = - mockBackgroundTaskRegistry.setNotificationCallback.mock.calls.at( - -1, - )?.[0] as ( - displayText: string, - modelText: string, - meta: { agentId: string; status: string }, - ) => void; - callback('independent background done', '', { - agentId: 'after-guard-compression-failure', - status: 'completed', - }); + const callback = + mockBackgroundTaskRegistry.setNotificationCallback.mock.calls.at( + -1, + )?.[0] as ( + displayText: string, + modelText: string, + meta: { agentId: string; status: string }, + ) => void; + callback('independent background done', '', { + agentId: 'after-guard-compression-failure', + status: 'completed', + }); - await vi.waitFor(() => { - expect(mockChat.sendMessageStream).toHaveBeenCalledTimes(3); - }); - expect( - vi - .mocked(mockClient.sessionUpdate) - .mock.calls.some( - ([params]) => - params.update.sessionUpdate === 'agent_message_chunk' && - params.update._meta?.['source'] === 'todo_stop_guard', - ), - ).toBe(false); - }); + await vi.waitFor(() => { + expect(mockChat.sendMessageStream).toHaveBeenCalledTimes(3); + }); + expect( + vi + .mocked(mockClient.sessionUpdate) + .mock.calls.some( + ([params]) => + params.update.sessionUpdate === 'agent_message_chunk' && + params.update._meta?.['source'] === 'todo_stop_guard', + ), + ).toBe(false); + }, + ); it('keeps external Stop hook continuation when Guard compression throws', async () => { rebuildSessionWithGuard(); diff --git a/packages/cli/src/acp-integration/session/Session.ts b/packages/cli/src/acp-integration/session/Session.ts index 2e19ef3935a..0a62a6418b0 100644 --- a/packages/cli/src/acp-integration/session/Session.ts +++ b/packages/cli/src/acp-integration/session/Session.ts @@ -58,6 +58,7 @@ import { AuthType, ApprovalMode, CompressionStatus, + isCompressionFailureStatus, RUNTIME_SNAPSHOT_PREFIX, detectLoopSentinel, detectAutonomousSentinel, @@ -451,15 +452,6 @@ function isTodoStopGuardPromptText(text: unknown): text is string { ); } -function isCompressionFailureStatus(status: CompressionStatus): boolean { - return ( - status === CompressionStatus.COMPRESSION_FAILED_INFLATED_TOKEN_COUNT || - status === CompressionStatus.COMPRESSION_FAILED_TOKEN_COUNT_ERROR || - status === CompressionStatus.COMPRESSION_FAILED_EMPTY_SUMMARY || - status === CompressionStatus.COMPRESSION_FAILED_OUTPUT_TRUNCATED - ); -} - /** Finalizes preparations without allowing ACP cleanup to change the stream outcome. */ async function finalizeToolCallPreparations( tracker: ToolCallPreparationTracker, diff --git a/packages/cli/src/i18n/locales/ca.js b/packages/cli/src/i18n/locales/ca.js index 23cc0350e0c..1d3192c546a 100644 --- a/packages/cli/src/i18n/locales/ca.js +++ b/packages/cli/src/i18n/locales/ca.js @@ -1179,6 +1179,12 @@ export default { "La compressió de l'historial del xat no ha reduït la mida. Això pot indicar problemes amb el missatge de compressió.", 'Could not compress chat history due to a token counting error.': "No s'ha pogut comprimir l'historial del xat per un error de recompte de tokens.", + 'Could not compress chat history because the compression summary was empty.': + "No s'ha pogut comprimir l'historial del xat perquè el resum de compressió era buit.", + 'Could not compress chat history because the compression summary was truncated.': + "No s'ha pogut comprimir l'historial del xat perquè el resum de compressió s'ha truncat.", + 'Could not compress chat history due to an API error.': + "No s'ha pogut comprimir l'historial del xat per un error de l'API.", // ============================================================================ // Ordres - Directori // ============================================================================ diff --git a/packages/cli/src/i18n/locales/de.js b/packages/cli/src/i18n/locales/de.js index f3589ab1083..3f6f7d711e9 100644 --- a/packages/cli/src/i18n/locales/de.js +++ b/packages/cli/src/i18n/locales/de.js @@ -1057,6 +1057,12 @@ export default { 'Chatverlauf-Komprimierung hat die Größe nicht reduziert. Dies kann auf Probleme mit dem Komprimierungs-Prompt hindeuten.', 'Could not compress chat history due to a token counting error.': 'Chatverlauf konnte aufgrund eines Token-Zählfehlers nicht komprimiert werden.', + 'Could not compress chat history because the compression summary was empty.': + 'Chatverlauf konnte nicht komprimiert werden, da die Komprimierungszusammenfassung leer war.', + 'Could not compress chat history because the compression summary was truncated.': + 'Chatverlauf konnte nicht komprimiert werden, da die Komprimierungszusammenfassung abgeschnitten wurde.', + 'Could not compress chat history due to an API error.': + 'Chatverlauf konnte aufgrund eines API-Fehlers nicht komprimiert werden.', // ============================================================================ // Commands - Directory // ============================================================================ diff --git a/packages/cli/src/i18n/locales/en.js b/packages/cli/src/i18n/locales/en.js index a91b8db0220..18243b98371 100644 --- a/packages/cli/src/i18n/locales/en.js +++ b/packages/cli/src/i18n/locales/en.js @@ -1621,6 +1621,12 @@ export default { 'Chat history compression did not reduce size. This may indicate issues with the compression prompt.', 'Could not compress chat history due to a token counting error.': 'Could not compress chat history due to a token counting error.', + 'Could not compress chat history because the compression summary was empty.': + 'Could not compress chat history because the compression summary was empty.', + 'Could not compress chat history because the compression summary was truncated.': + 'Could not compress chat history because the compression summary was truncated.', + 'Could not compress chat history due to an API error.': + 'Could not compress chat history due to an API error.', // ============================================================================ // Commands - Directory // ============================================================================ diff --git a/packages/cli/src/i18n/locales/fr.js b/packages/cli/src/i18n/locales/fr.js index 05fb30ba605..aa47c76457a 100644 --- a/packages/cli/src/i18n/locales/fr.js +++ b/packages/cli/src/i18n/locales/fr.js @@ -1187,6 +1187,12 @@ export default { "La compression de l'historique du chat n'a pas réduit la taille. Cela peut indiquer des problèmes avec l'invite de compression.", 'Could not compress chat history due to a token counting error.': "Impossible de compresser l'historique du chat en raison d'une erreur de comptage de tokens.", + 'Could not compress chat history because the compression summary was empty.': + "Impossible de compresser l'historique du chat, car le résumé de compression était vide.", + 'Could not compress chat history because the compression summary was truncated.': + "Impossible de compresser l'historique du chat, car le résumé de compression a été tronqué.", + 'Could not compress chat history due to an API error.': + "Impossible de compresser l'historique du chat en raison d'une erreur d'API.", // ============================================================================ // Commandes - Répertoire // ============================================================================ diff --git a/packages/cli/src/i18n/locales/ja.js b/packages/cli/src/i18n/locales/ja.js index a500bb69fab..911a9925e50 100644 --- a/packages/cli/src/i18n/locales/ja.js +++ b/packages/cli/src/i18n/locales/ja.js @@ -816,6 +816,12 @@ export default { 'チャット履歴の圧縮でサイズが減少しませんでした。圧縮プロンプトに問題がある可能性があります', 'Could not compress chat history due to a token counting error.': 'トークンカウントエラーのため、チャット履歴を圧縮できませんでした', + 'Could not compress chat history because the compression summary was empty.': + '圧縮サマリーが空だったため、チャット履歴を圧縮できませんでした', + 'Could not compress chat history because the compression summary was truncated.': + '圧縮サマリーが切り詰められたため、チャット履歴を圧縮できませんでした', + 'Could not compress chat history due to an API error.': + 'API エラーのため、チャット履歴を圧縮できませんでした', // Directory 'Configuration is not available.': '設定が利用できません', 'Please provide at least one path to add.': diff --git a/packages/cli/src/i18n/locales/pt.js b/packages/cli/src/i18n/locales/pt.js index 8553bee5f45..61c278b14fa 100644 --- a/packages/cli/src/i18n/locales/pt.js +++ b/packages/cli/src/i18n/locales/pt.js @@ -1060,6 +1060,12 @@ export default { 'A compressão do histórico do chat não reduziu o tamanho. Isso pode indicar problemas com o prompt de compressão.', 'Could not compress chat history due to a token counting error.': 'Não foi possível comprimir o histórico do chat devido a um erro de contagem de tokens.', + 'Could not compress chat history because the compression summary was empty.': + 'Não foi possível comprimir o histórico do chat porque o resumo da compressão estava vazio.', + 'Could not compress chat history because the compression summary was truncated.': + 'Não foi possível comprimir o histórico do chat porque o resumo da compressão foi truncado.', + 'Could not compress chat history due to an API error.': + 'Não foi possível comprimir o histórico do chat devido a um erro da API.', // ============================================================================ // Commands - Directory // ============================================================================ diff --git a/packages/cli/src/i18n/locales/ru.js b/packages/cli/src/i18n/locales/ru.js index 97938379fe8..2b1d2d774bb 100644 --- a/packages/cli/src/i18n/locales/ru.js +++ b/packages/cli/src/i18n/locales/ru.js @@ -1066,6 +1066,12 @@ export default { 'Сжатие истории чата не уменьшило размер. Это может указывать на проблемы с промптом сжатия.', 'Could not compress chat history due to a token counting error.': 'Не удалось сжать историю чата из-за ошибки подсчета токенов.', + 'Could not compress chat history because the compression summary was empty.': + 'Не удалось сжать историю чата, потому что сводка сжатия была пустой.', + 'Could not compress chat history because the compression summary was truncated.': + 'Не удалось сжать историю чата, потому что сводка сжатия была усечена.', + 'Could not compress chat history due to an API error.': + 'Не удалось сжать историю чата из-за ошибки API.', // ============================================================================ // Команды - Директория // ============================================================================ diff --git a/packages/cli/src/i18n/locales/zh-TW.js b/packages/cli/src/i18n/locales/zh-TW.js index 758527c27d4..1dfcb1c6c98 100644 --- a/packages/cli/src/i18n/locales/zh-TW.js +++ b/packages/cli/src/i18n/locales/zh-TW.js @@ -1424,6 +1424,12 @@ export default { '聊天歷史壓縮未能減小大小。這可能表明壓縮提示存在問題。', 'Could not compress chat history due to a token counting error.': '由於 token 計數錯誤,無法壓縮聊天歷史。', + 'Could not compress chat history because the compression summary was empty.': + '由於壓縮摘要為空,無法壓縮聊天歷史。', + 'Could not compress chat history because the compression summary was truncated.': + '由於壓縮摘要被截斷,無法壓縮聊天歷史。', + 'Could not compress chat history due to an API error.': + '由於 API 錯誤,無法壓縮聊天歷史。', 'Configuration is not available.': '配置不可用。', 'Please provide at least one path to add.': '請提供至少一個要添加的路徑。', 'The /directory add command is not supported in restrictive sandbox profiles. Please use --include-directories when starting the session instead.': diff --git a/packages/cli/src/i18n/locales/zh.js b/packages/cli/src/i18n/locales/zh.js index 58f179d6a81..36d926a0ce5 100644 --- a/packages/cli/src/i18n/locales/zh.js +++ b/packages/cli/src/i18n/locales/zh.js @@ -1546,6 +1546,12 @@ export default { '聊天历史压缩未能减小大小。这可能表明压缩提示存在问题。', 'Could not compress chat history due to a token counting error.': '由于 token 计数错误,无法压缩聊天历史。', + 'Could not compress chat history because the compression summary was empty.': + '由于压缩摘要为空,无法压缩聊天历史。', + 'Could not compress chat history because the compression summary was truncated.': + '由于压缩摘要被截断,无法压缩聊天历史。', + 'Could not compress chat history due to an API error.': + '由于 API 错误,无法压缩聊天历史。', // ============================================================================ // Commands - Directory // ============================================================================ diff --git a/packages/cli/src/ui/commands/compressCommand.test.ts b/packages/cli/src/ui/commands/compressCommand.test.ts index d48c9c5e3b0..ff89a669eab 100644 --- a/packages/cli/src/ui/commands/compressCommand.test.ts +++ b/packages/cli/src/ui/commands/compressCommand.test.ts @@ -133,6 +133,31 @@ describe('compressCommand', () => { expect(context.ui.setPendingItem).toHaveBeenCalledWith(null); }); + it('should keep compression failure statuses in the interactive history', async () => { + const failedResult: ChatCompressionInfo = { + originalTokenCount: 100000, + newTokenCount: 100000, + compressionStatus: CompressionStatus.COMPRESSION_FAILED_API_ERROR, + }; + mockTryCompressChat.mockResolvedValue(failedResult); + + await compressCommand.action!(context, ''); + + expect(context.ui.addItem).toHaveBeenCalledWith( + { + type: MessageType.COMPRESSION, + compression: { + isPending: false, + compressionStatus: CompressionStatus.COMPRESSION_FAILED_API_ERROR, + originalTokenCount: 100000, + newTokenCount: 100000, + compressionKind: 'summarize', + }, + }, + expect.any(Number), + ); + }); + // Issue #9309: after /compress-fast the summarize banner is measured on a // different scale (local history-only estimate vs the fast banner's // API-reported baseline), so the compression item must carry per-side @@ -166,6 +191,56 @@ describe('compressCommand', () => { ); }); + it('should return an error in non-interactive mode for compression failure statuses', async () => { + const failedResult: ChatCompressionInfo = { + originalTokenCount: 100000, + newTokenCount: 100000, + compressionStatus: CompressionStatus.COMPRESSION_FAILED_API_ERROR, + }; + mockTryCompressChat.mockResolvedValue(failedResult); + const ctx = createMockCommandContext({ + executionMode: 'non_interactive', + services: context.services, + }); + + await expect(compressCommand.action!(ctx, '')).resolves.toEqual({ + type: 'message', + messageType: 'error', + content: 'Could not compress chat history due to an API error.', + }); + }); + + it('should yield an ACP error for compression failure statuses', async () => { + const failedResult: ChatCompressionInfo = { + originalTokenCount: 100000, + newTokenCount: 100000, + compressionStatus: CompressionStatus.COMPRESSION_FAILED_API_ERROR, + }; + mockTryCompressChat.mockResolvedValue(failedResult); + const ctx = createMockCommandContext({ + executionMode: 'acp', + services: context.services, + }); + + const result = await compressCommand.action!(ctx, ''); + expect(result?.type).toBe('stream_messages'); + + const messages = []; + if (result?.type === 'stream_messages') { + for await (const message of result.messages) { + messages.push(message); + } + } + + expect(messages).toEqual([ + { messageType: 'info', content: 'Compressing context...' }, + { + messageType: 'error', + content: 'Could not compress chat history due to an API error.', + }, + ]); + }); + it('should mark estimated counts in the non-interactive message', async () => { // Asymmetric flags mirror the real post-/compress-fast scenario and catch // a swapped flag-argument mutation at the formatTokenCount call sites. diff --git a/packages/cli/src/ui/commands/compressCommand.ts b/packages/cli/src/ui/commands/compressCommand.ts index 182818ff08a..2620fec7b33 100644 --- a/packages/cli/src/ui/commands/compressCommand.ts +++ b/packages/cli/src/ui/commands/compressCommand.ts @@ -5,10 +5,15 @@ */ import type { HistoryItemCompression } from '../types.js'; +import { isCompressionFailureStatus } from '@qwen-code/qwen-code-core'; import { MessageType } from '../types.js'; import type { SlashCommand } from './types.js'; import { CommandKind } from './types.js'; import { t } from '../../i18n/index.js'; +import { + formatCompressionTokenCount, + getCompressionFailureStatusText, +} from '../utils/compression-text.js'; // Cap user-supplied compression instructions. The compression side-query has // no input-truncation retry today, so an unbounded instruction string would @@ -17,11 +22,6 @@ import { t } from '../../i18n/index.js'; // without exposing that failure mode. const MAX_COMPRESS_INSTRUCTIONS_CHARS = 2000; -// Estimated counts (#9309) get a '~' prefix so structured/headless consumers -// don't treat locally estimated numbers as API-reported token counts. -const formatTokenCount = (count: number, isEstimated?: boolean) => - isEstimated ? `~${count}` : String(count); - export const compressCommand: SlashCommand = { name: 'compress', altNames: ['summarize'], @@ -104,17 +104,25 @@ export const compressCommand: SlashCommand = { content: 'Compressing context...', }; const compressed = await doCompress(); - if (!compressed) { + if ( + !compressed || + isCompressionFailureStatus(compressed.compressionStatus) + ) { yield { messageType: 'error' as const, - content: t('Failed to compress chat history.'), + content: compressed + ? getCompressionFailureStatusText({ + compressionStatus: compressed.compressionStatus, + originalTokenCount: compressed.originalTokenCount, + }) + : t('Failed to compress chat history.'), }; return; } yield { messageType: 'info' as const, content: - `Context compressed (${formatTokenCount(compressed.originalTokenCount, compressed.originalTokenCountIsEstimated)} -> ${formatTokenCount(compressed.newTokenCount, compressed.newTokenCountIsEstimated)}).` + + `Context compressed (${formatCompressionTokenCount(compressed.originalTokenCount, compressed.originalTokenCountIsEstimated)} -> ${formatCompressionTokenCount(compressed.newTokenCount, compressed.newTokenCountIsEstimated)}).` + (compressed.warning ? `\n⚠️ ${compressed.warning}` : ''), }; } catch (e) { @@ -147,7 +155,11 @@ export const compressCommand: SlashCommand = { return; } - if (!compressed) { + if ( + !compressed || + (executionMode !== 'interactive' && + isCompressionFailureStatus(compressed.compressionStatus)) + ) { if (executionMode === 'interactive') { ui.addItem( { @@ -162,7 +174,12 @@ export const compressCommand: SlashCommand = { return { type: 'message', messageType: 'error', - content: t('Failed to compress chat history.'), + content: compressed + ? getCompressionFailureStatusText({ + compressionStatus: compressed.compressionStatus, + originalTokenCount: compressed.originalTokenCount, + }) + : t('Failed to compress chat history.'), }; } @@ -196,7 +213,7 @@ export const compressCommand: SlashCommand = { type: 'message', messageType: 'info', content: - `${truncationNotice ? `${truncationNotice} ` : ''}Context compressed (${formatTokenCount(compressed.originalTokenCount, compressed.originalTokenCountIsEstimated)} -> ${formatTokenCount(compressed.newTokenCount, compressed.newTokenCountIsEstimated)}).` + + `${truncationNotice ? `${truncationNotice} ` : ''}Context compressed (${formatCompressionTokenCount(compressed.originalTokenCount, compressed.originalTokenCountIsEstimated)} -> ${formatCompressionTokenCount(compressed.newTokenCount, compressed.newTokenCountIsEstimated)}).` + (compressed.warning ? `\n⚠️ ${compressed.warning}` : ''), }; } catch (e) { diff --git a/packages/cli/src/ui/components/messages/CompressionMessage.test.tsx b/packages/cli/src/ui/components/messages/CompressionMessage.test.tsx index 1b48636b6eb..b52276c7c36 100644 --- a/packages/cli/src/ui/components/messages/CompressionMessage.test.tsx +++ b/packages/cli/src/ui/components/messages/CompressionMessage.test.tsx @@ -237,5 +237,51 @@ describe('', () => { expect(output).not.toContain('Compression was not beneficial'); }); }); + + it('shows API error message when compression side-query fails', () => { + const props = createCompressionProps({ + isPending: false, + originalTokenCount: 100000, + newTokenCount: 100000, + compressionStatus: CompressionStatus.COMPRESSION_FAILED_API_ERROR, + }); + const { lastFrame } = render(); + const output = lastFrame(); + + expect(output).toContain( + 'Could not compress chat history due to an API error.', + ); + }); + + it('shows empty summary failure message', () => { + const props = createCompressionProps({ + isPending: false, + originalTokenCount: 100000, + newTokenCount: 0, + compressionStatus: CompressionStatus.COMPRESSION_FAILED_EMPTY_SUMMARY, + }); + const { lastFrame } = render(); + const output = lastFrame(); + + expect(output).toContain( + 'Could not compress chat history because the compression summary was empty.', + ); + }); + + it('shows truncated output failure message', () => { + const props = createCompressionProps({ + isPending: false, + originalTokenCount: 100000, + newTokenCount: 0, + compressionStatus: + CompressionStatus.COMPRESSION_FAILED_OUTPUT_TRUNCATED, + }); + const { lastFrame } = render(); + const output = lastFrame(); + + expect(output).toContain( + 'Could not compress chat history because the compression summary was truncated.', + ); + }); }); }); diff --git a/packages/cli/src/ui/components/messages/CompressionMessage.tsx b/packages/cli/src/ui/components/messages/CompressionMessage.tsx index 9e0e536e979..298bcea6fa7 100644 --- a/packages/cli/src/ui/components/messages/CompressionMessage.tsx +++ b/packages/cli/src/ui/components/messages/CompressionMessage.tsx @@ -9,9 +9,8 @@ import type { CompressionProps } from '../../types.js'; import Spinner from 'ink-spinner'; import { theme } from '../../semantic-colors.js'; import { SCREEN_READER_MODEL_PREFIX } from '../../textConstants.js'; -import { CompressionStatus } from '@qwen-code/qwen-code-core'; -import { t } from '../../../i18n/index.js'; import { ICON } from '../../constants.js'; +import { getCompressionStatusText } from '../../utils/compression-text.js'; export interface CompressionDisplayProps { compression: CompressionProps; @@ -33,54 +32,14 @@ export function CompressionMessage({ newTokenCountIsEstimated, } = compression; - const originalTokens = originalTokenCount ?? 0; - const newTokens = newTokenCount ?? 0; - - // Estimated counts (#9309): the two compression paths measure on different - // scales, so a '~' prefix marks which banner numbers are local estimates - // rather than API-reported counts. - const formatTokens = (count: number, isEstimated?: boolean) => - isEstimated ? `~${count}` : String(count); - - const getCompressionText = () => { - if (isPending) { - return t('Compressing chat history'); - } - - switch (compressionStatus) { - case CompressionStatus.COMPRESSED: - return t( - 'Chat history compressed from {{originalTokens}} to {{newTokens}} tokens.', - { - originalTokens: formatTokens( - originalTokens, - originalTokenCountIsEstimated, - ), - newTokens: formatTokens(newTokens, newTokenCountIsEstimated), - }, - ); - case CompressionStatus.COMPRESSION_FAILED_INFLATED_TOKEN_COUNT: - // For smaller histories (< 50k tokens), compression overhead likely exceeds benefits - if (originalTokens < 50000) { - return t('Compression was not beneficial for this history size.'); - } - // For larger histories where compression should work but didn't, - // this suggests an issue with the compression process itself - return t( - 'Chat history compression did not reduce size. This may indicate issues with the compression prompt.', - ); - case CompressionStatus.COMPRESSION_FAILED_TOKEN_COUNT_ERROR: - return t( - 'Could not compress chat history due to a token counting error.', - ); - case CompressionStatus.NOOP: - return 'Nothing to compress.'; - default: - return ''; - } - }; - - const text = getCompressionText(); + const text = getCompressionStatusText({ + isPending, + originalTokenCount, + newTokenCount, + compressionStatus, + originalTokenCountIsEstimated, + newTokenCountIsEstimated, + }); return ( diff --git a/packages/cli/src/ui/utils/compression-text.ts b/packages/cli/src/ui/utils/compression-text.ts new file mode 100644 index 00000000000..34df613a08d --- /dev/null +++ b/packages/cli/src/ui/utils/compression-text.ts @@ -0,0 +1,99 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + CompressionStatus, + isCompressionFailureStatus, +} from '@qwen-code/qwen-code-core'; +import { t } from '../../i18n/index.js'; + +export const formatCompressionTokenCount = ( + count: number, + isEstimated?: boolean, +) => (isEstimated ? `~${count}` : String(count)); + +export interface CompressionStatusTextOptions { + isPending?: boolean; + originalTokenCount?: number | null; + newTokenCount?: number | null; + compressionStatus?: CompressionStatus | null; + originalTokenCountIsEstimated?: boolean; + newTokenCountIsEstimated?: boolean; +} + +export function getCompressionStatusText({ + isPending, + originalTokenCount, + newTokenCount, + compressionStatus, + originalTokenCountIsEstimated, + newTokenCountIsEstimated, +}: CompressionStatusTextOptions) { + if (isPending) { + return t('Compressing chat history'); + } + + const originalTokens = originalTokenCount ?? 0; + const newTokens = newTokenCount ?? 0; + + switch (compressionStatus) { + case CompressionStatus.COMPRESSED: + return t( + 'Chat history compressed from {{originalTokens}} to {{newTokens}} tokens.', + { + originalTokens: formatCompressionTokenCount( + originalTokens, + originalTokenCountIsEstimated, + ), + newTokens: formatCompressionTokenCount( + newTokens, + newTokenCountIsEstimated, + ), + }, + ); + case CompressionStatus.COMPRESSION_FAILED_INFLATED_TOKEN_COUNT: + // For smaller histories (< 50k tokens), compression overhead likely exceeds benefits. + if (originalTokens < 50000) { + return t('Compression was not beneficial for this history size.'); + } + return t( + 'Chat history compression did not reduce size. This may indicate issues with the compression prompt.', + ); + case CompressionStatus.COMPRESSION_FAILED_TOKEN_COUNT_ERROR: + return t( + 'Could not compress chat history due to a token counting error.', + ); + case CompressionStatus.COMPRESSION_FAILED_EMPTY_SUMMARY: + return t( + 'Could not compress chat history because the compression summary was empty.', + ); + case CompressionStatus.COMPRESSION_FAILED_OUTPUT_TRUNCATED: + return t( + 'Could not compress chat history because the compression summary was truncated.', + ); + case CompressionStatus.COMPRESSION_FAILED_API_ERROR: + return t('Could not compress chat history due to an API error.'); + case CompressionStatus.NOOP: + return 'Nothing to compress.'; + default: + return ''; + } +} + +export function getCompressionFailureStatusText( + options: CompressionStatusTextOptions, +) { + if (!isCompressionFailureStatus(options.compressionStatus)) { + return t('Failed to compress chat history.'); + } + + return ( + getCompressionStatusText({ + ...options, + isPending: false, + }) || t('Failed to compress chat history.') + ); +} diff --git a/packages/core/src/core/llm-chat.test.ts b/packages/core/src/core/llm-chat.test.ts index 8677e7df441..e7515ee03b3 100644 --- a/packages/core/src/core/llm-chat.test.ts +++ b/packages/core/src/core/llm-chat.test.ts @@ -4616,8 +4616,7 @@ describe('LlmChat', async () => { info: { originalTokenCount: 100_000, newTokenCount: 100_000, - compressionStatus: - CompressionStatus.COMPRESSION_FAILED_INFLATED_TOKEN_COUNT, + compressionStatus: CompressionStatus.COMPRESSION_FAILED_API_ERROR, }, }); vi.mocked(mockContentGenerator.generateContentStream).mockResolvedValue( diff --git a/packages/core/src/core/llm-chat.ts b/packages/core/src/core/llm-chat.ts index 9d0d349fe81..0b3ce157d11 100644 --- a/packages/core/src/core/llm-chat.ts +++ b/packages/core/src/core/llm-chat.ts @@ -105,7 +105,11 @@ import { makeChatCompressionEvent, } from '../telemetry/types.js'; import type { UiTelemetryService } from '../telemetry/uiTelemetry.js'; -import { type ChatCompressionInfo, CompressionStatus } from './turn.js'; +import { + type ChatCompressionInfo, + CompressionStatus, + isCompressionFailureStatus, +} from './turn.js'; import { getContextLengthExceededInfo } from '../utils/contextLengthError.js'; import { getStartupContextLength, @@ -331,15 +335,6 @@ export function redactStructuredOutputArgsForRecording( }; } -function isCompressionFailureStatus(status: CompressionStatus): boolean { - return ( - status === CompressionStatus.COMPRESSION_FAILED_INFLATED_TOKEN_COUNT || - status === CompressionStatus.COMPRESSION_FAILED_EMPTY_SUMMARY || - status === CompressionStatus.COMPRESSION_FAILED_TOKEN_COUNT_ERROR || - status === CompressionStatus.COMPRESSION_FAILED_OUTPUT_TRUNCATED - ); -} - function shouldStopAfterHardRescue( shouldForceFromHard: boolean, hardLimit: number, diff --git a/packages/core/src/core/turn.test.ts b/packages/core/src/core/turn.test.ts index 847bde64bad..98849de2012 100644 --- a/packages/core/src/core/turn.test.ts +++ b/packages/core/src/core/turn.test.ts @@ -12,6 +12,7 @@ import type { } from './turn.js'; import { CompressionStatus, + isCompressionFailureStatus, Turn, LlmEventType, createDuplicateProviderToolCallResponse, @@ -54,6 +55,49 @@ vi.mock('../utils/errorReporting', () => ({ reportError: vi.fn(), })); +describe('isCompressionFailureStatus', () => { + it('treats each compression failure status as failed', () => { + expect( + isCompressionFailureStatus( + CompressionStatus.COMPRESSION_FAILED_INFLATED_TOKEN_COUNT, + ), + ).toBe(true); + expect( + isCompressionFailureStatus( + CompressionStatus.COMPRESSION_FAILED_TOKEN_COUNT_ERROR, + ), + ).toBe(true); + expect( + isCompressionFailureStatus( + CompressionStatus.COMPRESSION_FAILED_EMPTY_SUMMARY, + ), + ).toBe(true); + expect( + isCompressionFailureStatus( + CompressionStatus.COMPRESSION_FAILED_OUTPUT_TRUNCATED, + ), + ).toBe(true); + expect( + isCompressionFailureStatus( + CompressionStatus.COMPRESSION_FAILED_API_ERROR, + ), + ).toBe(true); + }); + + it('keeps API errors distinct from other compression failure statuses', () => { + expect(CompressionStatus.COMPRESSION_FAILED_API_ERROR).not.toBe( + CompressionStatus.COMPRESSION_FAILED_EMPTY_SUMMARY, + ); + expect(CompressionStatus.COMPRESSION_FAILED_API_ERROR).not.toBe( + CompressionStatus.COMPRESSION_FAILED_TOKEN_COUNT_ERROR, + ); + expect(isCompressionFailureStatus(CompressionStatus.COMPRESSED)).toBe( + false, + ); + expect(isCompressionFailureStatus(CompressionStatus.NOOP)).toBe(false); + }); +}); + describe('findRepeatedDuplicateProviderToolCall', () => { const getProviderCallId = (item: { providerCallId?: string }) => item.providerCallId; diff --git a/packages/core/src/core/turn.ts b/packages/core/src/core/turn.ts index c3a68e4d441..8b77c0cdddb 100644 --- a/packages/core/src/core/turn.ts +++ b/packages/core/src/core/turn.ts @@ -387,6 +387,25 @@ export enum CompressionStatus { * splitter). (R5.2) */ COMPRESSION_FAILED_OUTPUT_TRUNCATED, + + /** + * The compression side-query failed before producing a summary. Kept + * distinct from empty summaries so callers can tell API/provider failures + * apart from model output quality failures. + */ + COMPRESSION_FAILED_API_ERROR, +} + +export function isCompressionFailureStatus( + status: CompressionStatus | null | undefined, +): boolean { + return ( + status === CompressionStatus.COMPRESSION_FAILED_INFLATED_TOKEN_COUNT || + status === CompressionStatus.COMPRESSION_FAILED_TOKEN_COUNT_ERROR || + status === CompressionStatus.COMPRESSION_FAILED_EMPTY_SUMMARY || + status === CompressionStatus.COMPRESSION_FAILED_OUTPUT_TRUNCATED || + status === CompressionStatus.COMPRESSION_FAILED_API_ERROR + ); } /** diff --git a/packages/core/src/services/chatCompressionService.test.ts b/packages/core/src/services/chatCompressionService.test.ts index 02aa39e9807..8d3108bb024 100644 --- a/packages/core/src/services/chatCompressionService.test.ts +++ b/packages/core/src/services/chatCompressionService.test.ts @@ -1910,6 +1910,100 @@ describe('ChatCompressionService', () => { expect(mockFirePostCompactEvent).not.toHaveBeenCalled(); }); + it('should return API error status when the compression side-query fails', async () => { + const history: Content[] = [ + { role: 'user', parts: [{ text: 'msg1' }] }, + { role: 'model', parts: [{ text: 'msg2' }] }, + { role: 'user', parts: [{ text: 'msg3' }] }, + { role: 'model', parts: [{ text: 'msg4' }] }, + ]; + vi.mocked(mockChat.getHistory).mockReturnValue(history); + vi.mocked(uiTelemetryService.getLastPromptTokenCount).mockReturnValue( + 100, + ); + vi.mocked(tokenLimit).mockReturnValue(1000); + + const warn = vi.fn(); + ( + mockConfig as unknown as { + getDebugLogger: () => { + warn: typeof warn; + debug: ReturnType; + }; + } + ).getDebugLogger = () => ({ + warn, + debug: vi.fn(), + }); + vi.spyOn(sideQueryModule, 'runSideQuery').mockRejectedValue( + new Error('context window exceeded'), + ); + + const result = await service.compress(mockChat, { + promptId: mockPromptId, + force: true, + config: mockConfig, + consecutiveFailures: 0, + originalTokenCount: uiTelemetryService.getLastPromptTokenCount(), + }); + + expect(result.info.compressionStatus).toBe( + CompressionStatus.COMPRESSION_FAILED_API_ERROR, + ); + expect(result.info.newTokenCount).toBe(100); + expect(result.newHistory).toBeNull(); + expect(warn).toHaveBeenCalledWith( + expect.stringContaining('compression side-query failed'), + ); + expect(mockFirePostCompactEvent).not.toHaveBeenCalled(); + }); + + it('should rethrow aborts from the compression side-query', async () => { + const history: Content[] = [ + { role: 'user', parts: [{ text: 'msg1' }] }, + { role: 'model', parts: [{ text: 'msg2' }] }, + { role: 'user', parts: [{ text: 'msg3' }] }, + { role: 'model', parts: [{ text: 'msg4' }] }, + ]; + vi.mocked(mockChat.getHistory).mockReturnValue(history); + vi.mocked(uiTelemetryService.getLastPromptTokenCount).mockReturnValue( + 100, + ); + vi.mocked(tokenLimit).mockReturnValue(1000); + + const warn = vi.fn(); + ( + mockConfig as unknown as { + getDebugLogger: () => { + warn: typeof warn; + debug: ReturnType; + }; + } + ).getDebugLogger = () => ({ + warn, + debug: vi.fn(), + }); + const controller = new AbortController(); + vi.spyOn(sideQueryModule, 'runSideQuery').mockImplementation(() => { + controller.abort(); + return Promise.reject(new Error('cancelled')); + }); + + await expect( + service.compress(mockChat, { + promptId: mockPromptId, + force: true, + config: mockConfig, + consecutiveFailures: 0, + originalTokenCount: uiTelemetryService.getLastPromptTokenCount(), + signal: controller.signal, + }), + ).rejects.toThrow('cancelled'); + expect(warn).not.toHaveBeenCalledWith( + expect.stringContaining('compression side-query failed'), + ); + }); + it('should handle PostCompact hook errors gracefully', async () => { const history: Content[] = [ { role: 'user', parts: [{ text: 'msg1' }] }, diff --git a/packages/core/src/services/chatCompressionService.ts b/packages/core/src/services/chatCompressionService.ts index d27a9b667b1..12f991e634b 100644 --- a/packages/core/src/services/chatCompressionService.ts +++ b/packages/core/src/services/chatCompressionService.ts @@ -872,7 +872,24 @@ export class ChatCompressionService { if (!summaryResult) { abortSignal.throwIfAborted(); - summaryResult = await runColdCompression(); + try { + summaryResult = await runColdCompression(); + } catch (error) { + if (abortSignal.aborted) throw error; + config + .getDebugLogger() + .warn( + `[chat-compression] compression side-query failed: ${String(error)}`, + ); + return { + newHistory: null, + info: { + originalTokenCount, + newTokenCount: originalTokenCount, + compressionStatus: CompressionStatus.COMPRESSION_FAILED_API_ERROR, + }, + }; + } } const summary = summaryResult.text; // Check the PROCESSED summary: postProcessSummary strips