Skip to content

Commit acccb9e

Browse files
committed
feat(compress): allow forced /compress to always run and repair broken tool_call context; add compress_status UI feedback
1 parent ccc0b93 commit acccb9e

11 files changed

Lines changed: 272 additions & 29 deletions

File tree

packages/core/src/services/compressionService.ts

Lines changed: 32 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -571,6 +571,9 @@ export class CompressionService {
571571
* @param originalTokenCount 原始token数量(可选,如果提供则跳过重复计算)
572572
* @param overridePreserveRatio 可选的覆盖保留比例(0-1),用于激进压缩
573573
* @param isModelSwitchCompression 是否是模型切换时的压缩(默认false)
574+
* @param force 是否为用户主动触发的强制压缩(默认false)。强制压缩时放宽
575+
* "历史过少"与"找不到边界"两个守卫,只要有内容就尽力压缩,
576+
* 并允许压缩全部历史(用于修复残缺的 tool_call 上下文)
574577
* @returns 压缩结果
575578
*/
576579
async compressHistory(
@@ -583,7 +586,8 @@ export class CompressionService {
583586
abortSignal: AbortSignal,
584587
originalTokenCount?: number,
585588
overridePreserveRatio?: number,
586-
isModelSwitchCompression: boolean = false
589+
isModelSwitchCompression: boolean = false,
590+
force: boolean = false
587591
): Promise<CompressionResult> {
588592
try {
589593
// 获取或计算原始token数量
@@ -606,12 +610,20 @@ export class CompressionService {
606610
const conversationHistory = history.slice(this.skipEnvironmentMessages);
607611

608612
// 如果对话历史太少,不进行压缩
609-
if (conversationHistory.length <= 2) {
613+
// 强制压缩(用户主动 /compress)时放宽此限制:只要有内容就允许压缩,
614+
// 这样用户可以随时通过压缩来修复残缺的 tool_call 上下文。
615+
if (!force && conversationHistory.length <= 2) {
610616
return {
611617
success: false,
612618
error: 'Insufficient conversation history to compress'
613619
};
614620
}
621+
if (force && conversationHistory.length === 0) {
622+
return {
623+
success: false,
624+
error: 'No conversation history to compress'
625+
};
626+
}
615627

616628
// 确定保留比例:优先使用 override,否则使用配置默认值
617629
const preserveRatio = overridePreserveRatio ?? this.compressionPreserveThreshold;
@@ -627,7 +639,7 @@ export class CompressionService {
627639
// 寻找最近的完整工具调用对边界,统一处理主agent和subAgent场景
628640
compressBeforeIndex = this.findToolCallBoundary(conversationHistory, compressBeforeIndex);
629641

630-
// 如果没有找到合适的压缩边界,不进行压缩
642+
// 如果没有找到合适的压缩边界
631643
if (compressBeforeIndex === -1) {
632644
console.warn(`[compressHistory] Could not find suitable compression boundary. Conversation history structure may prevent compression.`);
633645
console.log(`[compressHistory] Last 5 messages in conversationHistory:`);
@@ -641,10 +653,19 @@ export class CompressionService {
641653
}).join(',') || 'empty';
642654
console.log(` [${i}] role=${msg.role}, parts=[${partTypes}]`);
643655
}
644-
return {
645-
success: false,
646-
error: 'Could not find suitable compression boundary'
647-
};
656+
657+
if (force) {
658+
// 用户主动压缩:即使找不到"干净"的边界也尽力而为,
659+
// 退化为压缩全部对话历史(保留 0 条)。validateAndCleanHistory
660+
// 会在拼接后清理孤立的 tool_use/tool_result,从而修复残缺上下文。
661+
console.warn(`[compressHistory] Force compression: no clean boundary found, compressing entire conversation history.`);
662+
compressBeforeIndex = conversationHistory.length;
663+
} else {
664+
return {
665+
success: false,
666+
error: 'Could not find suitable compression boundary'
667+
};
668+
}
648669
}
649670

650671
const historyToCompress = conversationHistory.slice(0, compressBeforeIndex);
@@ -1019,7 +1040,10 @@ IMPORTANT POST-COMPRESSION RULES:
10191040
geminiClient,
10201041
prompt_id,
10211042
abortSignal,
1022-
shouldCompressResult.tokenCount
1043+
shouldCompressResult.tokenCount,
1044+
undefined,
1045+
false,
1046+
force
10231047
);
10241048

10251049
// 如果压缩失败且没有明确的跳过原因,抛出错误以触发重试

packages/vscode-ui-plugin/src/extension.ts

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3068,6 +3068,12 @@ function setupSlashCommandHandlers() {
30683068
// 通过现有 system-notification / chat_compressed 通道反馈给 webview。
30693069
// ─────────────────────────────────────────────────────────────────
30703070
communicationService.addMessageHandler('builtin_compress', async () => {
3071+
// 本次压缩对应的 session(用于 webview 把状态消息归位到正确的会话)。
3072+
const compressSessionId = sessionManager.getCurrentSession()?.info.id || null;
3073+
// 给本次压缩分配一个稳定 statusId,让 webview 能把 start → done/error 的
3074+
// 多条 compress_status 更新到同一条 in-chat 通知上(而不是不断追加新条目)。
3075+
const statusId = `compress-${Date.now()}`;
3076+
30713077
try {
30723078
// 当前会话的 AIService —— 与 setupChatHandlers 内 send_message 的
30733079
// 取法(getCurrentInitializedAIService)保持一致。
@@ -3090,6 +3096,14 @@ function setupSlashCommandHandlers() {
30903096
return;
30913097
}
30923098

3099+
// ── 阶段 1:start ──────────────────────────────────────────────
3100+
// 立即把"正在压缩"状态推给 webview,让它在对话流里插入一条持久的
3101+
// in-chat 通知 + 底部进度条,用户不再面对一片空白干等。
3102+
communicationService.sendMessage({
3103+
type: 'compress_status',
3104+
payload: { phase: 'start', statusId, sessionId: compressSessionId },
3105+
});
3106+
30933107
logger.info('[/compress] Manual compression triggered via slash command');
30943108
const promptId = `slash-compress-${Date.now()}`;
30953109
const result = await geminiClient.tryCompressChat(promptId, new AbortController().signal, true);
@@ -3099,6 +3113,20 @@ function setupSlashCommandHandlers() {
30993113
const after = (result as any).newTokenCount;
31003114
logger.info(`[/compress] Compressed ${before}${after} tokens`);
31013115
const fmt = (n: any) => (typeof n === 'number' ? n.toLocaleString() : String(n));
3116+
3117+
// ── 阶段 2:done ─────────────────────────────────────────────
3118+
// 把压缩结果(前后 token 数)回写到同一条 in-chat 通知上。
3119+
communicationService.sendMessage({
3120+
type: 'compress_status',
3121+
payload: {
3122+
phase: 'done',
3123+
statusId,
3124+
sessionId: compressSessionId,
3125+
originalTokenCount: before,
3126+
newTokenCount: after,
3127+
},
3128+
});
3129+
31023130
communicationService.sendMessage({
31033131
type: 'slash_command_result',
31043132
payload: {
@@ -3108,13 +3136,29 @@ function setupSlashCommandHandlers() {
31083136
});
31093137
} else {
31103138
logger.warn('[/compress] tryCompressChat returned falsy result');
3139+
// ── 阶段 2':skipped ─────────────────────────────────────────
3140+
// 历史本来就不大、无需压缩:也要给一个明确的结束态,避免进度条悬挂。
3141+
communicationService.sendMessage({
3142+
type: 'compress_status',
3143+
payload: { phase: 'skipped', statusId, sessionId: compressSessionId },
3144+
});
31113145
communicationService.sendMessage({
31123146
type: 'slash_command_result',
31133147
payload: { success: false, error: 'Compression returned no result. The history may already be small enough.' },
31143148
});
31153149
}
31163150
} catch (error) {
31173151
logger.error('[/compress] Compression failed', error instanceof Error ? error : undefined);
3152+
// ── 阶段 2'':error ───────────────────────────────────────────
3153+
communicationService.sendMessage({
3154+
type: 'compress_status',
3155+
payload: {
3156+
phase: 'error',
3157+
statusId,
3158+
sessionId: compressSessionId,
3159+
error: error instanceof Error ? error.message : String(error),
3160+
},
3161+
});
31183162
communicationService.sendMessage({
31193163
type: 'slash_command_result',
31203164
payload: {

packages/vscode-ui-plugin/src/types/messages.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -510,6 +510,19 @@ export type ExtensionToWebViewMessage =
510510
info?: string;
511511
error?: string;
512512
} }
513+
// 🎯 手动 /compress 的状态推送(让 webview 在对话流里展示"正在压缩 / 压缩结果")
514+
| { type: 'compress_status'; payload: {
515+
/** start = 开始压缩;done = 完成;skipped = 历史太小无需压缩;error = 失败 */
516+
phase: 'start' | 'done' | 'skipped' | 'error';
517+
/** 同一次压缩的稳定 id,让 start → done/error 落到同一条 in-chat 通知 */
518+
statusId: string;
519+
sessionId: string | null;
520+
/** done 时携带:压缩前后的 token 数 */
521+
originalTokenCount?: number;
522+
newTokenCount?: number;
523+
/** error 时携带:错误信息 */
524+
error?: string;
525+
} }
513526
// 🎯 模型切换压缩确认
514527
| { type: 'compression_confirmation_request'; payload: { requestId: string; sessionId: string; targetModel: string; currentTokens: number; targetTokenLimit: number; compressionThreshold: number; message: string } }
515528
// 🎯 Token使用情况更新(压缩后)

packages/vscode-ui-plugin/webview/src/components/MessageInput.tsx

Lines changed: 14 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -970,30 +970,26 @@ export const MessageInput = React.forwardRef<MessageInputHandle, MessageInputPro
970970
if (result.success && result.sideEffect === 'compress') {
971971
// ─────────────────────────────────────────────────────
972972
// 内置 /compress:不发 AI,转发副作用消息让 backend 调
973-
// tryCompressChat。结果通过 show_notification 显示。
973+
// tryCompressChat。
974+
//
975+
// 状态反馈走 backend 主动推送的 `compress_status`(start →
976+
// done/error/skipped),由 MultiSessionApp 在对话流里插入并
977+
// 原地更新一条持久的 in-chat 通知(带 spinner + 结果),
978+
// 用户不再面对空白干等。这里**不再**弹 info/success toast,
979+
// 避免与 in-chat 通知重复。
980+
//
981+
// 仅保留:早退失败的兜底 toast —— 当 geminiClient 尚未就绪
982+
// 或已有压缩在进行时,backend 会直接返回 error 且**不会**发出
983+
// compress_status:start(因此没有 in-chat 通知可承载该错误)。
974984
// ─────────────────────────────────────────────────────
975985
consumedBySlashCommand = true;
976986
console.log(`🎯 [SlashCommand] /${commandName} → side effect: compress`);
977987

978-
if (window.vscode && result.info) {
979-
window.vscode.postMessage({
980-
type: 'show_notification',
981-
payload: { message: result.info, type: 'info' },
982-
});
983-
}
984-
985-
// 异步触发 backend 真正的压缩,结果再通知
988+
// 异步触发 backend 真正的压缩。成功/进行中状态由 compress_status
989+
// 驱动的 in-chat 通知体现;这里只在「压缩根本没启动」的早退失败时兜底提示。
986990
slashCommandHandler.triggerBuiltinCompress().then((compressResult) => {
987991
if (!window.vscode) return;
988-
if (compressResult.success) {
989-
window.vscode.postMessage({
990-
type: 'show_notification',
991-
payload: {
992-
message: compressResult.info || 'Compression completed.',
993-
type: 'info',
994-
},
995-
});
996-
} else {
992+
if (!compressResult.success) {
997993
window.vscode.postMessage({
998994
type: 'show_notification',
999995
payload: {

packages/vscode-ui-plugin/webview/src/components/MultiSessionApp.tsx

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -923,6 +923,105 @@ export const MultiSessionApp: React.FC = () => {
923923
setStreamRecoveryVisible(false);
924924
}));
925925

926+
// 🎯 监听手动 /compress 的状态推送(start → done/error/skipped)。
927+
// 目标:用户敲 /compress 后不再面对空白干等 —— 在对话流里插入一条
928+
// 持久的 in-chat 通知(带 spinner),压缩结束后原地更新为最终结果,
929+
// 同时复用底部 compression-progress-bar 作为全局进度提示。
930+
cleanups.push(messageService.onExtensionMessage('compress_status', (payload: any) => {
931+
console.log('🗜️ [MultiSessionApp] Received compress_status:', payload);
932+
const targetSessionId: string | undefined =
933+
payload?.sessionId || stateRef.current.currentSessionId || undefined;
934+
if (!targetSessionId) {
935+
console.warn('🗜️ [MultiSessionApp] compress_status without resolvable sessionId, ignoring');
936+
return;
937+
}
938+
// 用 statusId 派生稳定的消息 id,保证 start/done/error 落到同一条通知。
939+
const statusId: string = payload?.statusId || `compress-${Date.now()}`;
940+
const notificationId = `notif-${statusId}`;
941+
942+
const fmt = (n: any) => (typeof n === 'number' ? n.toLocaleString() : String(n));
943+
944+
if (payload?.phase === 'start') {
945+
// 底部进度条
946+
setIsCompressing(true);
947+
// in-chat 持久通知(进行中)
948+
addMessage(targetSessionId, {
949+
id: notificationId,
950+
type: 'notification',
951+
content: createTextMessageContent(''),
952+
timestamp: Date.now(),
953+
notificationType: 'compression',
954+
notificationTitle: t('compression.manualTitle', {}, 'Context Compression'),
955+
notificationDescription: t(
956+
'compression.manualInProgressDesc',
957+
{},
958+
'Summarizing older messages while preserving recent context. This may take a moment.'
959+
),
960+
severity: 'info',
961+
notificationInProgress: true,
962+
statusId,
963+
} as any);
964+
return;
965+
}
966+
967+
// 任何结束态都要收起底部进度条
968+
setIsCompressing(false);
969+
970+
if (payload?.phase === 'done') {
971+
const original = Number(payload?.originalTokenCount);
972+
const compressed = Number(payload?.newTokenCount);
973+
const saved =
974+
Number.isFinite(original) && Number.isFinite(compressed)
975+
? Math.max(0, original - compressed)
976+
: undefined;
977+
const percent =
978+
Number.isFinite(original) && original > 0 && saved !== undefined
979+
? `-${Math.round((saved / original) * 100)}%`
980+
: '';
981+
updateMessage(targetSessionId, notificationId, {
982+
notificationInProgress: false,
983+
notificationTitle: t('compression.manualDone', {}, 'Context compressed'),
984+
notificationDescription: t(
985+
'compression.manualDoneDesc',
986+
{
987+
original: fmt(original),
988+
compressed: fmt(compressed),
989+
saved: fmt(saved),
990+
percent,
991+
},
992+
`Reduced from ${fmt(original)} to ${fmt(compressed)} tokens.`
993+
),
994+
severity: 'info',
995+
} as any);
996+
return;
997+
}
998+
999+
if (payload?.phase === 'skipped') {
1000+
updateMessage(targetSessionId, notificationId, {
1001+
notificationInProgress: false,
1002+
notificationTitle: t('compression.manualSkipped', {}, 'Compression skipped'),
1003+
notificationDescription: t(
1004+
'compression.manualSkippedDesc',
1005+
{},
1006+
'The conversation history is already small enough — nothing to compress.'
1007+
),
1008+
severity: 'info',
1009+
} as any);
1010+
return;
1011+
}
1012+
1013+
if (payload?.phase === 'error') {
1014+
updateMessage(targetSessionId, notificationId, {
1015+
notificationInProgress: false,
1016+
notificationType: 'warning',
1017+
notificationTitle: t('compression.manualFailed', {}, 'Compression failed'),
1018+
notificationDescription: String(payload?.error || 'Compression failed.'),
1019+
severity: 'error',
1020+
} as any);
1021+
return;
1022+
}
1023+
}));
1024+
9261025
// 🔐 监听认证过期通知(服务端返回 HTTP 401 时由 extension 主动推送)
9271026
cleanups.push(messageService.onAuthExpired(({ reason }) => {
9281027
console.log('🔐 [MultiSessionApp] Auth expired notification received:', reason);

packages/vscode-ui-plugin/webview/src/components/SystemNotificationMessage.css

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,38 @@
5151
flex-shrink: 0;
5252
}
5353

54+
/* 进行中状态的内联 spinner(替代图标,体现"正在处理") */
55+
.notification-spinner {
56+
width: 12px;
57+
height: 12px;
58+
margin-right: 6px;
59+
flex-shrink: 0;
60+
border: 2px solid var(--vscode-editorInfo-foreground, #469af8);
61+
border-top-color: transparent;
62+
border-radius: 50%;
63+
animation: notification-spin 0.8s linear infinite;
64+
}
65+
66+
@keyframes notification-spin {
67+
to {
68+
transform: rotate(360deg);
69+
}
70+
}
71+
72+
/* 进行中通知整体轻微脉动,强化"还在进行"的感知 */
73+
.system-notification.notification-in-progress {
74+
animation: notification-pulse 1.6s ease-in-out infinite;
75+
}
76+
77+
@keyframes notification-pulse {
78+
0%, 100% {
79+
opacity: 1;
80+
}
81+
50% {
82+
opacity: 0.7;
83+
}
84+
}
85+
5486
.notification-title {
5587
flex: 1;
5688
}

packages/vscode-ui-plugin/webview/src/components/SystemNotificationMessage.tsx

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -42,10 +42,14 @@ export const SystemNotificationMessage: React.FC<SystemNotificationMessageProps>
4242
};
4343

4444
return (
45-
<div className={`system-notification ${getNotificationStyle()}`}>
46-
{/* 通知头部 - 图标 + 标题 */}
45+
<div className={`system-notification ${getNotificationStyle()}${message.notificationInProgress ? ' notification-in-progress' : ''}`}>
46+
{/* 通知头部 - 图标/spinner + 标题 */}
4747
<div className="notification-header">
48-
<span className="notification-icon" aria-hidden="true">{getNotificationIcon()}</span>
48+
{message.notificationInProgress ? (
49+
<span className="notification-spinner" aria-label="loading" role="status" />
50+
) : (
51+
<span className="notification-icon" aria-hidden="true">{getNotificationIcon()}</span>
52+
)}
4953
<span className="notification-title">{message.notificationTitle}</span>
5054
</div>
5155

0 commit comments

Comments
 (0)