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