Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions docs/design/web-shell/web-shell-image-drag-and-drop.md
Original file line number Diff line number Diff line change
Expand Up @@ -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。
Expand Down
31 changes: 31 additions & 0 deletions packages/cli/src/acp-integration/session/Session.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'),
Expand Down
13 changes: 11 additions & 2 deletions packages/cli/src/acp-integration/session/Session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
103 changes: 103 additions & 0 deletions packages/cli/src/ui/commands/compressCommand.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
12 changes: 10 additions & 2 deletions packages/cli/src/ui/commands/compressCommand.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Comment thread
yiliang114 marked this conversation as resolved.
Resolved

export const compressCommand: SlashCommand = {
name: 'compress',
altNames: ['summarize'],
Expand Down Expand Up @@ -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)}).` +
Comment thread
yiliang114 marked this conversation as resolved.
Resolved
(compressed.warning ? `\n⚠️ ${compressed.warning}` : ''),
};
} catch (e) {
Expand Down Expand Up @@ -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(),
Expand All @@ -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) {
Expand Down
61 changes: 61 additions & 0 deletions packages/cli/src/ui/commands/compressFastCommand.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
12 changes: 10 additions & 2 deletions packages/cli/src/ui/commands/compressFastCommand.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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(),
Expand All @@ -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') {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,49 @@ describe('<CompressionMessage />', () => {
});
});

// 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(<CompressionMessage {...props} />);

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(<CompressionMessage {...props} />);

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(<CompressionMessage {...props} />);

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({
Expand Down
Loading
Loading