diff --git a/docs/design/web-shell/web-shell-image-drag-and-drop.md b/docs/design/web-shell/web-shell-image-drag-and-drop.md
index 72229a6cffc..403cc3f8567 100644
--- a/docs/design/web-shell/web-shell-image-drag-and-drop.md
+++ b/docs/design/web-shell/web-shell-image-drag-and-drop.md
@@ -510,8 +510,8 @@ BMP 以 `image/bmp` 进入缩略图 data URL 和 daemon image block。Core 的
> 及其 BMP 尺寸解析)已作为孤儿代码删除。BMP 支持现在仅依赖 `SUPPORTED_IMAGE_MIME_TYPES`
> 接受清单与 converter 透传;token 计数使用 `compactionInputSlimming.ts` 中的固定
> `DEFAULT_IMAGE_TOKEN_ESTIMATE`。下文对 BMP 路径的 E2E/人工验收要求不变。
-浏览器若不能解码缩略图,不影响附件数据传输,但 E2E 必须覆盖 Chromium 解码,
-Firefox/Linux 必须完成人工验收。
+> 浏览器若不能解码缩略图,不影响附件数据传输,但 E2E 必须覆盖 Chromium 解码,
+> Firefox/Linux 必须完成人工验收。
提交后的 user transcript 还经过 `isSafeImageSrc`,因此其被动位图 data-URI allowlist 必须
加入精确的 `image/bmp;base64,`,否则 composer 预览可见而 user message 会静默隐藏 BMP。
diff --git a/packages/cli/src/acp-integration/session/Session.test.ts b/packages/cli/src/acp-integration/session/Session.test.ts
index 090ae123347..17bc7896104 100644
--- a/packages/cli/src/acp-integration/session/Session.test.ts
+++ b/packages/cli/src/acp-integration/session/Session.test.ts
@@ -11928,6 +11928,37 @@ describe('Session', () => {
});
});
+ it('marks estimated compression counts in the ACP notice (#9309)', async () => {
+ mockGeminiClient.tryCompressChat.mockResolvedValueOnce({
+ originalTokenCount: 26600,
+ newTokenCount: 14500,
+ originalTokenCountIsEstimated: true,
+ newTokenCountIsEstimated: false,
+ compressionStatus: core.CompressionStatus.COMPRESSED,
+ });
+ mockChat.sendMessageStream = vi
+ .fn()
+ .mockResolvedValue(createEmptyStream());
+
+ await session.prompt({
+ sessionId: 'test-session-id',
+ prompt: [{ type: 'text', text: 'hello' }],
+ });
+
+ expect(mockClient.sessionUpdate).toHaveBeenCalledWith({
+ sessionId: 'test-session-id',
+ update: {
+ sessionUpdate: 'agent_message_chunk',
+ content: {
+ type: 'text',
+ text:
+ 'IMPORTANT: This conversation approached the input token limit for qwen3-code-plus. ' +
+ 'A compressed context will be sent for future messages (compressed from: ~26600 to 14500 tokens).',
+ },
+ },
+ });
+ });
+
it('continues sending when automatic compression fails', async () => {
mockGeminiClient.tryCompressChat.mockRejectedValueOnce(
new Error('compression rate limited'),
diff --git a/packages/cli/src/acp-integration/session/Session.ts b/packages/cli/src/acp-integration/session/Session.ts
index ba1c6ee4ec4..80971a5e113 100644
--- a/packages/cli/src/acp-integration/session/Session.ts
+++ b/packages/cli/src/acp-integration/session/Session.ts
@@ -6762,11 +6762,20 @@ export class Session implements SessionContext {
const warningSuffix = compressed.warning
? `\n⚠️ ${compressed.warning}`
: '';
+ // Estimated counts (#9309) get a '~' prefix so the notice doesn't
+ // read as an API-reported figure on a different scale than a later
+ // banner.
+ const formatCount = (count?: number, isEstimated?: boolean) =>
+ count === undefined
+ ? 'unknown'
+ : isEstimated
+ ? `~${count}`
+ : String(count);
compressionDiagnostic =
`IMPORTANT: This conversation ${reasonClause}. ` +
`A compressed context will be sent for future messages (compressed from: ` +
- `${compressed.originalTokenCount ?? 'unknown'} to ` +
- `${compressed.newTokenCount ?? 'unknown'} tokens).` +
+ `${formatCount(compressed.originalTokenCount, compressed.originalTokenCountIsEstimated)} to ` +
+ `${formatCount(compressed.newTokenCount, compressed.newTokenCountIsEstimated)} tokens).` +
warningSuffix;
}
} catch (compressionError) {
diff --git a/packages/cli/src/ui/commands/compressCommand.test.ts b/packages/cli/src/ui/commands/compressCommand.test.ts
index b80fcc30671..6231e93fa02 100644
--- a/packages/cli/src/ui/commands/compressCommand.test.ts
+++ b/packages/cli/src/ui/commands/compressCommand.test.ts
@@ -133,6 +133,109 @@ describe('compressCommand', () => {
expect(context.ui.setPendingItem).toHaveBeenCalledWith(null);
});
+ // 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
+ // provenance for the renderer to mark estimated numbers.
+ it('should pass token-count provenance to the compression item (interactive)', async () => {
+ // Asymmetric flags so a swapped provenance assignment is detectable.
+ mockTryCompressChat.mockResolvedValue({
+ originalTokenCount: 200,
+ newTokenCount: 100,
+ originalTokenCountIsEstimated: true,
+ newTokenCountIsEstimated: false,
+ compressionStatus: CompressionStatus.COMPRESSED,
+ } satisfies ChatCompressionInfo);
+
+ await compressCommand.action!(context, '');
+
+ expect(context.ui.addItem).toHaveBeenCalledWith(
+ {
+ type: MessageType.COMPRESSION,
+ compression: {
+ isPending: false,
+ compressionStatus: CompressionStatus.COMPRESSED,
+ originalTokenCount: 200,
+ newTokenCount: 100,
+ compressionKind: 'summarize',
+ originalTokenCountIsEstimated: true,
+ newTokenCountIsEstimated: false,
+ },
+ },
+ expect.any(Number),
+ );
+ });
+
+ 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.
+ mockTryCompressChat.mockResolvedValue({
+ originalTokenCount: 200,
+ newTokenCount: 100,
+ originalTokenCountIsEstimated: true,
+ newTokenCountIsEstimated: false,
+ compressionStatus: CompressionStatus.COMPRESSED,
+ } satisfies ChatCompressionInfo);
+
+ const ctx = createMockCommandContext({
+ executionMode: 'non_interactive',
+ services: {
+ config: {
+ getGeminiClient: () =>
+ ({
+ tryCompressChat: mockTryCompressChat,
+ }) as unknown as GeminiClient,
+ },
+ },
+ });
+
+ const result = await compressCommand.action!(ctx, '');
+
+ expect(result).toEqual({
+ type: 'message',
+ messageType: 'info',
+ content: 'Context compressed (~200 -> 100).',
+ });
+ });
+
+ it('should mark estimated counts in the ACP stream_messages branch', async () => {
+ // Asymmetric flags mirror the real post-/compress-fast scenario and catch
+ // a swapped flag-argument mutation at the ACP formatTokenCount site.
+ mockTryCompressChat.mockResolvedValue({
+ originalTokenCount: 200,
+ newTokenCount: 100,
+ originalTokenCountIsEstimated: true,
+ newTokenCountIsEstimated: false,
+ compressionStatus: CompressionStatus.COMPRESSED,
+ } satisfies ChatCompressionInfo);
+
+ const ctx = createMockCommandContext({
+ executionMode: 'acp',
+ services: {
+ config: {
+ getGeminiClient: () =>
+ ({
+ tryCompressChat: mockTryCompressChat,
+ }) as unknown as GeminiClient,
+ },
+ },
+ });
+
+ 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: 'info', content: 'Context compressed (~200 -> 100).' },
+ ]);
+ });
+
it('should add an error message if tryCompressChat throws', async () => {
const error = new Error('Compression failed');
mockTryCompressChat.mockRejectedValue(error);
diff --git a/packages/cli/src/ui/commands/compressCommand.ts b/packages/cli/src/ui/commands/compressCommand.ts
index b85a3f7ae49..afb63d4099c 100644
--- a/packages/cli/src/ui/commands/compressCommand.ts
+++ b/packages/cli/src/ui/commands/compressCommand.ts
@@ -17,6 +17,11 @@ 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'],
@@ -109,7 +114,7 @@ export const compressCommand: SlashCommand = {
yield {
messageType: 'info' as const,
content:
- `Context compressed (${compressed.originalTokenCount} -> ${compressed.newTokenCount}).` +
+ `Context compressed (${formatTokenCount(compressed.originalTokenCount, compressed.originalTokenCountIsEstimated)} -> ${formatTokenCount(compressed.newTokenCount, compressed.newTokenCountIsEstimated)}).` +
(compressed.warning ? `\n⚠️ ${compressed.warning}` : ''),
};
} catch (e) {
@@ -171,6 +176,9 @@ export const compressCommand: SlashCommand = {
newTokenCount: compressed.newTokenCount,
compressionStatus: compressed.compressionStatus,
compressionKind: 'summarize',
+ originalTokenCountIsEstimated:
+ compressed.originalTokenCountIsEstimated,
+ newTokenCountIsEstimated: compressed.newTokenCountIsEstimated,
},
} as HistoryItemCompression,
Date.now(),
@@ -188,7 +196,7 @@ export const compressCommand: SlashCommand = {
type: 'message',
messageType: 'info',
content:
- `${truncationNotice ? `${truncationNotice} ` : ''}Context compressed (${compressed.originalTokenCount} -> ${compressed.newTokenCount}).` +
+ `${truncationNotice ? `${truncationNotice} ` : ''}Context compressed (${formatTokenCount(compressed.originalTokenCount, compressed.originalTokenCountIsEstimated)} -> ${formatTokenCount(compressed.newTokenCount, compressed.newTokenCountIsEstimated)}).` +
(compressed.warning ? `\n⚠️ ${compressed.warning}` : ''),
};
} catch (e) {
diff --git a/packages/cli/src/ui/commands/compressFastCommand.test.ts b/packages/cli/src/ui/commands/compressFastCommand.test.ts
index 4714fd3bdff..ace8cc9cd85 100644
--- a/packages/cli/src/ui/commands/compressFastCommand.test.ts
+++ b/packages/cli/src/ui/commands/compressFastCommand.test.ts
@@ -181,6 +181,67 @@ describe('compressFastCommand', () => {
);
});
+ // Issue #9309: the fast-compression banner mixes an API-reported baseline
+ // with a locally adjusted count, so the UI item must carry per-side
+ // provenance for the renderer to mark estimated numbers.
+ it('should pass token-count provenance to the compression item (interactive)', async () => {
+ mockTryCompressChatFast.mockResolvedValue({
+ originalTokenCount: 200,
+ newTokenCount: 100,
+ originalTokenCountIsEstimated: false,
+ newTokenCountIsEstimated: true,
+ compressionStatus: CompressionStatus.COMPRESSED,
+ } satisfies ChatCompressionInfo);
+
+ await compressFastCommand.action!(context, '');
+
+ expect(context.ui.addItem).toHaveBeenCalledWith(
+ {
+ type: MessageType.COMPRESSION,
+ compression: {
+ isPending: false,
+ originalTokenCount: 200,
+ newTokenCount: 100,
+ compressionStatus: CompressionStatus.COMPRESSED,
+ compressionKind: 'fast',
+ originalTokenCountIsEstimated: false,
+ newTokenCountIsEstimated: true,
+ },
+ },
+ expect.any(Number),
+ );
+ });
+
+ it('should mark estimated counts in the non-interactive message', async () => {
+ mockTryCompressChatFast.mockResolvedValue({
+ originalTokenCount: 200,
+ newTokenCount: 100,
+ originalTokenCountIsEstimated: false,
+ newTokenCountIsEstimated: true,
+ compressionStatus: CompressionStatus.COMPRESSED,
+ } satisfies ChatCompressionInfo);
+
+ const ctx = createMockCommandContext({
+ executionMode: 'non_interactive',
+ services: {
+ config: {
+ getGeminiClient: () =>
+ ({
+ tryCompressChatFast: mockTryCompressChatFast,
+ }) as unknown as GeminiClient,
+ },
+ },
+ });
+
+ const result = await compressFastCommand.action!(ctx, '');
+
+ expect(result).toEqual({
+ type: 'message',
+ messageType: 'info',
+ content: 'Context compressed (200 -> ~100).',
+ });
+ });
+
it('should handle errors gracefully', async () => {
const error = new Error('Compression failed');
mockTryCompressChatFast.mockRejectedValue(error);
diff --git a/packages/cli/src/ui/commands/compressFastCommand.ts b/packages/cli/src/ui/commands/compressFastCommand.ts
index ab6b199f56d..b0687a35828 100644
--- a/packages/cli/src/ui/commands/compressFastCommand.ts
+++ b/packages/cli/src/ui/commands/compressFastCommand.ts
@@ -11,6 +11,11 @@ import type { SlashCommand } from './types.js';
import { CommandKind } from './types.js';
import { t } from '../../i18n/index.js';
+// 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 compressFastCommand: SlashCommand = {
name: 'compress-fast',
get description() {
@@ -85,7 +90,7 @@ export const compressFastCommand: SlashCommand = {
}
yield {
messageType: 'info' as const,
- content: `Context compressed (${compressed.originalTokenCount} -> ${compressed.newTokenCount}).`,
+ content: `Context compressed (${formatTokenCount(compressed.originalTokenCount, compressed.originalTokenCountIsEstimated)} -> ${formatTokenCount(compressed.newTokenCount, compressed.newTokenCountIsEstimated)}).`,
};
} catch (e) {
yield {
@@ -139,6 +144,9 @@ export const compressFastCommand: SlashCommand = {
newTokenCount: compressed.newTokenCount,
compressionStatus: compressed.compressionStatus,
compressionKind: 'fast',
+ originalTokenCountIsEstimated:
+ compressed.originalTokenCountIsEstimated,
+ newTokenCountIsEstimated: compressed.newTokenCountIsEstimated,
},
} as HistoryItemCompression,
Date.now(),
@@ -149,7 +157,7 @@ export const compressFastCommand: SlashCommand = {
return {
type: 'message',
messageType: 'info',
- content: `Context compressed (${compressed.originalTokenCount} -> ${compressed.newTokenCount}).`,
+ content: `Context compressed (${formatTokenCount(compressed.originalTokenCount, compressed.originalTokenCountIsEstimated)} -> ${formatTokenCount(compressed.newTokenCount, compressed.newTokenCountIsEstimated)}).`,
};
} catch (e) {
if (executionMode === 'interactive') {
diff --git a/packages/cli/src/ui/components/messages/CompressionMessage.test.tsx b/packages/cli/src/ui/components/messages/CompressionMessage.test.tsx
index b121fb738d5..1b48636b6eb 100644
--- a/packages/cli/src/ui/components/messages/CompressionMessage.test.tsx
+++ b/packages/cli/src/ui/components/messages/CompressionMessage.test.tsx
@@ -77,6 +77,49 @@ describe('', () => {
});
});
+ // Issue #9309: /compress-fast and /compress report on different scales
+ // (API-reported baseline vs local history-only estimate), so estimated
+ // numbers must be visibly marked to keep consecutive banners from reading
+ // as lost context.
+ describe('estimated token counts', () => {
+ it('marks an estimated new count with a ~ prefix', () => {
+ const props = createCompressionProps({
+ originalTokenCount: 100,
+ newTokenCount: 50,
+ compressionStatus: CompressionStatus.COMPRESSED,
+ newTokenCountIsEstimated: true,
+ });
+ const { lastFrame } = render();
+
+ expect(lastFrame()).toContain('compressed from 100 to ~50 tokens');
+ });
+
+ it('marks an estimated original count with a ~ prefix', () => {
+ const props = createCompressionProps({
+ originalTokenCount: 100,
+ newTokenCount: 50,
+ compressionStatus: CompressionStatus.COMPRESSED,
+ originalTokenCountIsEstimated: true,
+ newTokenCountIsEstimated: true,
+ });
+ const { lastFrame } = render();
+
+ expect(lastFrame()).toContain('compressed from ~100 to ~50 tokens');
+ });
+
+ it('does not mark authoritative counts', () => {
+ const props = createCompressionProps({
+ originalTokenCount: 100,
+ newTokenCount: 50,
+ compressionStatus: CompressionStatus.COMPRESSED,
+ });
+ const { lastFrame } = render();
+
+ expect(lastFrame()).toContain('compressed from 100 to 50 tokens');
+ expect(lastFrame()).not.toContain('~');
+ });
+ });
+
describe('skipped compression (tokens increased or same)', () => {
it('renders skip message when compression would increase token count', () => {
const props = createCompressionProps({
diff --git a/packages/cli/src/ui/components/messages/CompressionMessage.tsx b/packages/cli/src/ui/components/messages/CompressionMessage.tsx
index ab1a7c264cb..9e0e536e979 100644
--- a/packages/cli/src/ui/components/messages/CompressionMessage.tsx
+++ b/packages/cli/src/ui/components/messages/CompressionMessage.tsx
@@ -24,12 +24,24 @@ export interface CompressionDisplayProps {
export function CompressionMessage({
compression,
}: CompressionDisplayProps): React.JSX.Element {
- const { isPending, originalTokenCount, newTokenCount, compressionStatus } =
- compression;
+ const {
+ isPending,
+ originalTokenCount,
+ newTokenCount,
+ compressionStatus,
+ originalTokenCountIsEstimated,
+ 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');
@@ -40,8 +52,11 @@ export function CompressionMessage({
return t(
'Chat history compressed from {{originalTokens}} to {{newTokens}} tokens.',
{
- originalTokens: String(originalTokens),
- newTokens: String(newTokens),
+ originalTokens: formatTokens(
+ originalTokens,
+ originalTokenCountIsEstimated,
+ ),
+ newTokens: formatTokens(newTokens, newTokenCountIsEstimated),
},
);
case CompressionStatus.COMPRESSION_FAILED_INFLATED_TOKEN_COUNT:
diff --git a/packages/cli/src/ui/hooks/useGeminiStream.test.tsx b/packages/cli/src/ui/hooks/useGeminiStream.test.tsx
index a62e0b0ad80..11213382fef 100644
--- a/packages/cli/src/ui/hooks/useGeminiStream.test.tsx
+++ b/packages/cli/src/ui/hooks/useGeminiStream.test.tsx
@@ -11459,6 +11459,76 @@ describe('useGeminiStream', () => {
}),
]);
});
+
+ // Issue #9309: auto-compaction numbers can be local estimates rather
+ // than API-reported counts; the notice must mark them so consecutive
+ // compression banners on different scales don't read as lost context.
+ it('marks estimated compression counts in the auto-compaction notice', async () => {
+ mockSendMessageStream.mockReturnValue(
+ (async function* () {
+ yield {
+ type: ServerGeminiEventType.ChatCompressed,
+ value: {
+ originalTokenCount: 100,
+ newTokenCount: 50,
+ // Asymmetric flags so a swapped flag-argument mutation in
+ // formatCount is detectable.
+ originalTokenCountIsEstimated: true,
+ newTokenCountIsEstimated: false,
+ },
+ };
+ yield {
+ type: ServerGeminiEventType.Finished,
+ value: { reason: 'STOP', usageMetadata: undefined },
+ };
+ })(),
+ );
+
+ const { result } = renderTestHook();
+ await act(async () => {
+ await result.current.submitQuery('test estimated compression');
+ });
+
+ const infoItems = mockAddItem.mock.calls
+ .map(([item]) => item as HistoryItem)
+ .filter((item) => item.type === 'info');
+ expect(infoItems).toEqual([
+ expect.objectContaining({
+ text: expect.stringContaining('compressed from: ~100 to 50 tokens'),
+ }),
+ ]);
+ });
+
+ it('renders unknown counts when the auto-compaction event value is null', async () => {
+ mockSendMessageStream.mockReturnValue(
+ (async function* () {
+ yield {
+ type: ServerGeminiEventType.ChatCompressed,
+ value: null,
+ };
+ yield {
+ type: ServerGeminiEventType.Finished,
+ value: { reason: 'STOP', usageMetadata: undefined },
+ };
+ })(),
+ );
+
+ const { result } = renderTestHook();
+ await act(async () => {
+ await result.current.submitQuery('test null compression event');
+ });
+
+ const infoItems = mockAddItem.mock.calls
+ .map(([item]) => item as HistoryItem)
+ .filter((item) => item.type === 'info');
+ expect(infoItems).toEqual([
+ expect.objectContaining({
+ text: expect.stringContaining(
+ 'compressed from: unknown to unknown tokens',
+ ),
+ }),
+ ]);
+ });
});
describe('handleFinishedEvent', () => {
diff --git a/packages/cli/src/ui/hooks/useGeminiStream.ts b/packages/cli/src/ui/hooks/useGeminiStream.ts
index 0b0cc0454ad..6992dcf0baa 100644
--- a/packages/cli/src/ui/hooks/useGeminiStream.ts
+++ b/packages/cli/src/ui/hooks/useGeminiStream.ts
@@ -2257,14 +2257,22 @@ export const useGeminiStream = (
const warningSuffix = eventValue?.warning
? `\n⚠️ ${eventValue.warning}`
: '';
+ // Estimated counts (#9309) get a '~' prefix so the notice doesn't read
+ // as an API-reported figure on a different scale than a later banner.
+ const formatCount = (count?: number, isEstimated?: boolean) =>
+ count === undefined
+ ? 'unknown'
+ : isEstimated
+ ? `~${count}`
+ : String(count);
return addItem(
{
type: 'info',
text:
`IMPORTANT: This conversation ${reasonClause}. ` +
`A compressed context will be sent for future messages (compressed from: ` +
- `${eventValue?.originalTokenCount ?? 'unknown'} to ` +
- `${eventValue?.newTokenCount ?? 'unknown'} tokens).` +
+ `${formatCount(eventValue?.originalTokenCount, eventValue?.originalTokenCountIsEstimated)} to ` +
+ `${formatCount(eventValue?.newTokenCount, eventValue?.newTokenCountIsEstimated)} tokens).` +
warningSuffix,
},
Date.now(),
diff --git a/packages/cli/src/ui/types.ts b/packages/cli/src/ui/types.ts
index 92d9a2b2e7c..17497daf529 100644
--- a/packages/cli/src/ui/types.ts
+++ b/packages/cli/src/ui/types.ts
@@ -110,6 +110,13 @@ export interface CompressionProps {
* older sessions, which are treated as 'summarize'.
*/
compressionKind?: 'summarize' | 'fast';
+ /**
+ * Token-count provenance (#9309). The compression paths measure on
+ * different scales, so estimated numbers are rendered with a '~' prefix
+ * to keep consecutive banners from reading as lost context.
+ */
+ originalTokenCountIsEstimated?: boolean;
+ newTokenCountIsEstimated?: boolean;
}
export interface SummaryProps {
diff --git a/packages/core/src/agents/runtime/agent-core.ts b/packages/core/src/agents/runtime/agent-core.ts
index 1784527e582..23c503dd8d1 100644
--- a/packages/core/src/agents/runtime/agent-core.ts
+++ b/packages/core/src/agents/runtime/agent-core.ts
@@ -242,7 +242,8 @@ export function extractParentToolNames(
new Set(
(
generationConfig?.tools as
- Array<{ functionDeclarations?: FunctionDeclaration[] }> | undefined
+ | Array<{ functionDeclarations?: FunctionDeclaration[] }>
+ | undefined
)
?.flatMap((tool) => tool.functionDeclarations ?? [])
.map((declaration) => declaration.name)
@@ -1539,7 +1540,8 @@ export class AgentCore {
const registeredTool = this.runtimeContext
.getToolRegistry()
.getTool(toolName) as
- { serverName?: unknown; serverToolName?: unknown } | undefined;
+ | { serverName?: unknown; serverToolName?: unknown }
+ | undefined;
if (
typeof registeredTool?.serverName !== 'string' ||
typeof registeredTool.serverToolName !== 'string'
diff --git a/packages/core/src/config/config-session-env.test.ts b/packages/core/src/config/config-session-env.test.ts
index ff3fba98df8..7b530a627a7 100644
--- a/packages/core/src/config/config-session-env.test.ts
+++ b/packages/core/src/config/config-session-env.test.ts
@@ -486,11 +486,13 @@ describe('Config.getModelRouteIdentity (#9454 route key)', () => {
baseUrl: 'https://route.example/v1',
} as ContentGeneratorConfig);
expect(explicit).toMatch(/^route-model@[0-9a-f]{8}$/);
- expect(config.getModelRouteIdentity('route-model', {
- model: 'route-model',
- authType: 'openai',
- baseUrl: 'https://route.example/v1',
- } as ContentGeneratorConfig)).toBe(explicit);
+ expect(
+ config.getModelRouteIdentity('route-model', {
+ model: 'route-model',
+ authType: 'openai',
+ baseUrl: 'https://route.example/v1',
+ } as ContentGeneratorConfig),
+ ).toBe(explicit);
});
it('does not mix the registry base URL into a non-active model identity', async () => {
@@ -523,13 +525,17 @@ describe('Config.getModelRouteIdentity (#9454 route key)', () => {
registrySpy.mockReturnValue('https://registry.example/v1');
const activeWithRegistry = config.getModelRouteIdentity();
- const foreignWithRegistry =
- config.getModelRouteIdentity('foreign-model', foreignGeneratorConfig);
+ const foreignWithRegistry = config.getModelRouteIdentity(
+ 'foreign-model',
+ foreignGeneratorConfig,
+ );
registrySpy.mockReturnValue(null);
const activeWithoutRegistry = config.getModelRouteIdentity();
- const foreignWithoutRegistry =
- config.getModelRouteIdentity('foreign-model', foreignGeneratorConfig);
+ const foreignWithoutRegistry = config.getModelRouteIdentity(
+ 'foreign-model',
+ foreignGeneratorConfig,
+ );
// The fallback is load-bearing for the ACTIVE model…
expect(activeWithRegistry).toMatch(/^active-model@[0-9a-f]{8}$/);
diff --git a/packages/core/src/core/geminiChat.test.ts b/packages/core/src/core/geminiChat.test.ts
index 64f7eb7c4b5..ef87b072cf5 100644
--- a/packages/core/src/core/geminiChat.test.ts
+++ b/packages/core/src/core/geminiChat.test.ts
@@ -4256,7 +4256,17 @@ describe('GeminiChat', async () => {
}
expect(compressSpy).toHaveBeenCalledTimes(1);
- expect(compressSpy.mock.calls[0][1].originalTokenCount).toBe(123_456);
+ // The seeded authoritative count is the baseline of this attempt's
+ // effective count (seed + locally-estimated pending message), so the
+ // published number is the effective count, not the bare seed. Pin the
+ // exact deterministic value — seed 123,456 + char/4 estimate of the
+ // pending 'go' message (2 chars -> ceil(2/4) = 1 token), with
+ // lastOutputTokenCount still 0 — so over-counting regressions on the
+ // send path are caught too, not just under-counting.
+ expect(compressSpy.mock.calls[0][1].originalTokenCount).toBe(123_457);
+ expect(compressSpy.mock.calls[0][1].precomputedEffectiveTokens).toBe(
+ 123_457,
+ );
});
it('yields a COMPRESSED stream event as the first event after auto-compression succeeds', async () => {
@@ -4632,6 +4642,12 @@ describe('GeminiChat', async () => {
).mock.calls[1]![0];
expect(secondRequest.contents).toEqual(expectedRequestContents);
expect(events[0]?.type).toBe(StreamEventType.COMPRESSED);
+ // The overflow message reports the actual token count (135000), so the
+ // published original count is provider-authoritative — no `~` marker.
+ expect(
+ (events[0] as { type: StreamEventType; info: ChatCompressionInfo }).info
+ .originalTokenCountIsEstimated,
+ ).toBe(false);
expect(events[1]?.type).toBe(StreamEventType.RETRY);
expect(events[1]).not.toHaveProperty('retryInfo');
expect(
@@ -4678,12 +4694,23 @@ describe('GeminiChat', async () => {
{ message: 'latest' },
'prompt-id-reactive-limit-only',
);
- for await (const _ of stream) {
- /* consume */
+ const events: StreamEvent[] = [];
+ for await (const event of stream) {
+ events.push(event);
}
expect(compressSpy).toHaveBeenCalledTimes(2);
expect(compressSpy.mock.calls[1][1].originalTokenCount).toBe(128_000);
+ // The overflow message carries no actual token count, so the published
+ // original count is the parsed limit — a projection that must keep
+ // the `~` estimated marker.
+ const compressedEvent = events.find(
+ (event) => event.type === StreamEventType.COMPRESSED,
+ );
+ expect(
+ (compressedEvent as { info: ChatCompressionInfo }).info
+ .originalTokenCountIsEstimated,
+ ).toBe(true);
});
it('uses the configured context window when reactive overflow has no token counts', async () => {
@@ -4723,12 +4750,22 @@ describe('GeminiChat', async () => {
{ message: 'latest' },
'prompt-id-reactive-window-fallback',
);
- for await (const _ of stream) {
- /* consume */
+ const events: StreamEvent[] = [];
+ for await (const event of stream) {
+ events.push(event);
}
expect(compressSpy).toHaveBeenCalledTimes(2);
expect(compressSpy.mock.calls[1][1].originalTokenCount).toBe(262_144);
+ // Neither actual nor limit tokens parsed — the configured window is a
+ // fallback projection and must keep the `~` estimated marker.
+ const compressedEvent = events.find(
+ (event) => event.type === StreamEventType.COMPRESSED,
+ );
+ expect(
+ (compressedEvent as { info: ChatCompressionInfo }).info
+ .originalTokenCountIsEstimated,
+ ).toBe(true);
});
it('does not attempt reactive compression more than once per send', async () => {
@@ -16536,6 +16573,186 @@ describe('GeminiChat', async () => {
}),
);
});
+
+ // Reproduction for issue #9309: /compress-fast followed by /compress
+ // shows banners on two different scales — compressFast anchors on the
+ // API-reported prompt count (system prompt + tools + history) while a
+ // later tryCompress re-estimates history-only once the stored count is
+ // estimate-derived. The numbers therefore cannot chain across commands;
+ // each banner must at least expose which side is an estimate so the UI
+ // can mark it instead of presenting the jump as lost context.
+ it('exposes provenance on fast-compression info: API baseline authoritative, adjusted count estimated', () => {
+ vi.mocked(mockConfig.getClearContextOnIdle).mockReturnValue({
+ toolResultsThresholdMinutes: 30,
+ toolResultsNumToKeep: 1,
+ });
+ const fastChat = new GeminiChat(
+ mockConfig,
+ config,
+ [
+ userMsg('question'),
+ {
+ role: 'model',
+ parts: [
+ { text: 'reasoning '.repeat(100), thought: true },
+ { text: 'answer' },
+ ],
+ },
+ ],
+ undefined,
+ uiTelemetryService,
+ );
+ fastChat.seedResumeTokenCounts(5000, 0, false);
+
+ const { info } = fastChat.compressFast();
+
+ expect(info.compressionStatus).toBe(CompressionStatus.COMPRESSED);
+ expect(info.originalTokenCountIsEstimated).toBe(false);
+ expect(info.newTokenCountIsEstimated).toBe(true);
+ });
+
+ it('marks the fast-compression baseline as estimated when no API count exists', () => {
+ vi.mocked(mockConfig.getClearContextOnIdle).mockReturnValue({
+ toolResultsThresholdMinutes: 30,
+ toolResultsNumToKeep: 1,
+ });
+ const fastChat = new GeminiChat(
+ mockConfig,
+ config,
+ [
+ userMsg('question'),
+ {
+ role: 'model',
+ parts: [
+ { text: 'reasoning '.repeat(100), thought: true },
+ { text: 'answer' },
+ ],
+ },
+ ],
+ undefined,
+ uiTelemetryService,
+ );
+
+ const { info } = fastChat.compressFast();
+
+ expect(info.compressionStatus).toBe(CompressionStatus.COMPRESSED);
+ expect(info.originalTokenCount).toBeGreaterThan(0);
+ expect(info.originalTokenCountIsEstimated).toBe(true);
+ });
+
+ it('reports original-count provenance on the summarize path after a fast compression', async () => {
+ vi.mocked(mockConfig.getClearContextOnIdle).mockReturnValue({
+ toolResultsThresholdMinutes: 30,
+ toolResultsNumToKeep: 1,
+ });
+ const compressSpy = mockCompressionService('compressed');
+ chat.setHistory([
+ userMsg('question'),
+ {
+ role: 'model',
+ parts: [
+ { text: 'reasoning '.repeat(100), thought: true },
+ { text: 'answer' },
+ ],
+ },
+ ]);
+ chat.seedResumeTokenCounts(5000, 0, false);
+
+ // /compress-fast leaves the stored count estimate-derived...
+ chat.compressFast();
+ expect(chat.isLastPromptTokenCountEstimated()).toBe(true);
+ const adjustedAfterFast = chat.getLastPromptTokenCount();
+
+ // ...so the subsequent /compress re-estimates the history locally
+ // (a different scale than the fast banner) and must expose that its
+ // "before" number is an estimate.
+ const info = await chat.tryCompress('p-after-fast', true);
+
+ expect(compressSpy.mock.calls[0][1].originalTokenCount).not.toBe(
+ adjustedAfterFast,
+ );
+ expect(info.originalTokenCountIsEstimated).toBe(true);
+ });
+
+ it('reports an authoritative original count when the API count is fresh', async () => {
+ mockCompressionService('compressed');
+ chat.setHistory([userMsg('a'), modelMsg('b')]);
+ chat.seedResumeTokenCounts(5000, 0, false);
+
+ const info = await chat.tryCompress('p-authoritative-original', true);
+
+ expect(info.originalTokenCountIsEstimated).toBe(false);
+ });
+
+ it('marks the precomputed effective count as estimated even when the stored API count is authoritative', async () => {
+ // Normal/hard-rescue auto-compaction: the send path publishes the
+ // precomputed effective count (stored baseline + locally estimated
+ // pending message / previous output). That projection must carry the
+ // `~` marker even though the stored baseline came from the API
+ // (review probe on #9568).
+ const compressSpy = mockCompressionService('compressed');
+ chat.setHistory([userMsg('a'), modelMsg('b')]);
+ chat.seedResumeTokenCounts(5000, 0, false);
+
+ const info = await chat.tryCompress(
+ 'p-precomputed-effective-estimated',
+ true,
+ undefined,
+ {
+ precomputedEffectiveTokens: 6200,
+ pendingUserMessage: userMsg('next'),
+ trigger: 'auto',
+ },
+ );
+
+ expect(compressSpy.mock.calls[0][1].originalTokenCount).toBe(6200);
+ expect(info.originalTokenCountIsEstimated).toBe(true);
+ });
+
+ it('keeps a fallback override estimated while publishing its count', async () => {
+ // Reactive overflow without a provider-reported actual count: the
+ // limit/config/default fallback override is a projection and must
+ // keep the `~` marker.
+ const compressSpy = mockCompressionService('compressed');
+ chat.setHistory([userMsg('a'), modelMsg('b')]);
+ chat.seedResumeTokenCounts(5000, 0, false);
+
+ const info = await chat.tryCompress(
+ 'p-override-fallback-estimated',
+ true,
+ undefined,
+ {
+ originalTokenCountOverride: { count: 128_000, isEstimated: true },
+ precomputedEffectiveTokens: 128_000,
+ trigger: 'auto',
+ },
+ );
+
+ expect(compressSpy.mock.calls[0][1].originalTokenCount).toBe(128_000);
+ expect(info.originalTokenCountIsEstimated).toBe(true);
+ });
+
+ it('treats a provider-reported actualTokens override as authoritative', async () => {
+ // Reactive overflow whose error message reports the actual token
+ // count: only this override variant may drop the `~` marker.
+ const compressSpy = mockCompressionService('compressed');
+ chat.setHistory([userMsg('a'), modelMsg('b')]);
+ chat.seedResumeTokenCounts(5000, 0, false);
+
+ const info = await chat.tryCompress(
+ 'p-override-actual-authoritative',
+ true,
+ undefined,
+ {
+ originalTokenCountOverride: { count: 135_000, isEstimated: false },
+ precomputedEffectiveTokens: 135_000,
+ trigger: 'auto',
+ },
+ );
+
+ expect(compressSpy.mock.calls[0][1].originalTokenCount).toBe(135_000);
+ expect(info.originalTokenCountIsEstimated).toBe(false);
+ });
});
// Route-scoped token counts (#9454): API-reported prompt/output token
diff --git a/packages/core/src/core/geminiChat.ts b/packages/core/src/core/geminiChat.ts
index cd3f5c6e84b..8832149da75 100644
--- a/packages/core/src/core/geminiChat.ts
+++ b/packages/core/src/core/geminiChat.ts
@@ -451,7 +451,17 @@ export interface GeminiChatSendOptions {
}
interface TryCompressOptions {
- originalTokenCountOverride?: number;
+ /**
+ * Explicit original token count for this attempt, with its provenance.
+ * Only a provider-reported count (e.g. `actualTokens` parsed from a
+ * context-overflow error) may claim `isEstimated: false`; limit/config/
+ * default fallbacks must carry `isEstimated: true` so UIs mark them
+ * instead of presenting them as API-reported counts.
+ */
+ originalTokenCountOverride?: {
+ count: number;
+ isEstimated: boolean;
+ };
trigger?: CompactTrigger;
/**
* Pending user message about to be sent. Threaded through to the
@@ -1942,7 +1952,8 @@ export class GeminiChat {
*/
private pendingPartialAssistantTurnIndex: number | null = null;
private pendingPartialAssistantRecord:
- Parameters[0] | null = null;
+ | Parameters[0]
+ | null = null;
private readonly imagePayloadStore = new InMemoryImagePayloadStore();
@@ -2296,19 +2307,37 @@ export class GeminiChat {
// route so the adoption never re-adopts the active route's retained
// counts mid-send (#9506).
this.adoptTokenCountsForRoute(options?.requestRouteKey);
+
+ const originalTokenCountOverride = options?.originalTokenCountOverride;
+ // Provenance follows the count source selected for THIS attempt, not
+ // which inputs merely happen to be present:
+ // - an override is authoritative only when it carries a provider-reported
+ // count (reactive overflow `actualTokens`); limit/config/default
+ // fallbacks stay estimated;
+ // - a caller-precomputed effective count (auto-compaction / hard-tier
+ // rescue) always folds in locally estimated parts (pending user
+ // message, previous output), so it stays estimated even when the
+ // stored baseline came from the API;
+ // - otherwise the count is the stored lastPromptTokenCount and inherits
+ // the provenance tracked for it.
const originalTokenCountIsEstimated =
- options?.originalTokenCountOverride === undefined &&
- this.promptCountIsEstimateDerived();
- const originalTokenCount = originalTokenCountIsEstimated
- ? (options?.precomputedEffectiveTokens ??
- estimateContentTokens(
- options?.pendingUserMessage
- ? [...this.getHistoryShallow(true), options.pendingUserMessage]
- : this.getHistoryShallow(true),
- resolveSlimmingConfig(this.config.getChatCompression())
- .imageTokenEstimate,
- ))
- : (options?.originalTokenCountOverride ?? this.lastPromptTokenCount);
+ originalTokenCountOverride !== undefined
+ ? originalTokenCountOverride.isEstimated
+ : options?.precomputedEffectiveTokens !== undefined ||
+ this.promptCountIsEstimateDerived();
+ const originalTokenCount =
+ originalTokenCountOverride !== undefined
+ ? originalTokenCountOverride.count
+ : originalTokenCountIsEstimated
+ ? (options?.precomputedEffectiveTokens ??
+ estimateContentTokens(
+ options?.pendingUserMessage
+ ? [...this.getHistoryShallow(true), options.pendingUserMessage]
+ : this.getHistoryShallow(true),
+ resolveSlimmingConfig(this.config.getChatCompression())
+ .imageTokenEstimate,
+ ))
+ : this.lastPromptTokenCount;
debugLogger.debug(
`[compaction] token-count provenance: prompt_id=${promptId}, ` +
`originalTokenCount=${originalTokenCount}, ` +
@@ -2328,6 +2357,11 @@ export class GeminiChat {
customInstructions: options?.customInstructions,
signal,
});
+ // The service owns the compression outcome; GeminiChat owns the input
+ // provenance. Expose it so UIs can mark estimated banner numbers
+ // instead of presenting cross-path scale changes as lost context
+ // (#9309).
+ info.originalTokenCountIsEstimated = originalTokenCountIsEstimated;
// ChatCompressionService reads the keyless count getters, which adopt
// the ACTIVE route — flipping the slots away from the request route
@@ -2449,6 +2483,7 @@ export class GeminiChat {
info: {
originalTokenCount: apiBaseline,
newTokenCount: apiBaseline,
+ originalTokenCountIsEstimated: this.promptCountIsEstimateDerived(),
compressionStatus: CompressionStatus.NOOP,
},
};
@@ -2467,6 +2502,7 @@ export class GeminiChat {
const info: ChatCompressionInfo = {
originalTokenCount: apiBaseline,
newTokenCount: adjustedTokenCount,
+ originalTokenCountIsEstimated: baselineIsEstimated,
newTokenCountIsEstimated: true,
compressionStatus: CompressionStatus.COMPRESSED,
triggerReason: 'manual',
@@ -3513,6 +3549,11 @@ export class GeminiChat {
if (contextOverflow.isExceeded) {
if (!exactRoute && !reactiveCompressionAttempted) {
reactiveCompressionAttempted = true;
+ // Only the provider-reported actual count is authoritative.
+ // Limit/config/default fallbacks are projections and must
+ // keep the estimated marker in compression banners.
+ const reactiveOriginalTokenCountIsEstimated =
+ contextOverflow.actualTokens === undefined;
const reactiveOriginalTokenCount =
contextOverflow.actualTokens ??
contextOverflow.limitTokens ??
@@ -3527,7 +3568,10 @@ export class GeminiChat {
true,
params.config?.abortSignal,
{
- originalTokenCountOverride: reactiveOriginalTokenCount,
+ originalTokenCountOverride: {
+ count: reactiveOriginalTokenCount,
+ isEstimated: reactiveOriginalTokenCountIsEstimated,
+ },
precomputedEffectiveTokens: reactiveOriginalTokenCount,
requestGenerationConfig: params.config,
requestRouteKey,
diff --git a/packages/core/src/core/turn.ts b/packages/core/src/core/turn.ts
index 1c7c593854d..09e826c3ed9 100644
--- a/packages/core/src/core/turn.ts
+++ b/packages/core/src/core/turn.ts
@@ -393,6 +393,15 @@ export type CompactionTriggerReason =
export interface ChatCompressionInfo {
originalTokenCount: number;
newTokenCount: number;
+ /**
+ * Whether originalTokenCount came from a local estimate rather than an
+ * API-reported prompt count. The two compression paths measure on
+ * different scales (see #9309): /compress-fast anchors on the last
+ * API-reported prompt count (system prompt + tools + history) while a
+ * later /compress re-estimates history-only once the stored count is
+ * estimate-derived, so UIs must not present the numbers as one chain.
+ */
+ originalTokenCountIsEstimated?: boolean;
/** Whether newTokenCount ultimately came from a local estimate. */
newTokenCountIsEstimated?: boolean;
compressionStatus: CompressionStatus;