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;