Files
ai-terminal-assistant/packages/core/tests/unit/context/compaction.test.ts
T
kurihada f54f24b079 feat(context): 优化对话压缩系统
- 添加独立摘要模型配置支持(SUMMARY_PROVIDER/MODEL/API_KEY/BASE_URL)
- 添加 CompressionStatus 枚举和 DetailedCompressionResult 详细返回类型
- 实现压缩失败检测(空摘要、token膨胀)
- 添加首条 user-assistant 对保护,确保上下文连贯性
- CompressionManager 支持独立摘要模型(优先使用小模型降低成本)
- Agent 自动压缩时显示详细状态信息
- 更新相关测试用例
2025-12-13 11:13:20 +08:00

282 lines
8.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { describe, it, expect, vi } from 'vitest';
import { isSummaryMessage, simpleCompact } from '../../../src/context/compaction.js';
import {
SUMMARY_MARKER,
type CompressionConfig,
} from '../../../src/context/types.js';
import type { ModelMessage } from 'ai';
// 创建用户消息
function createUserMessage(content: string): ModelMessage {
return { role: 'user', content };
}
// 创建助手消息
function createAssistantMessage(content: string): ModelMessage {
return { role: 'assistant', content };
}
// 创建摘要消息
function createSummaryMessage(summary: string): ModelMessage {
return {
role: 'assistant',
content: `${SUMMARY_MARKER}\n## 对话摘要\n\n${summary}\n${SUMMARY_MARKER}`,
};
}
// 创建带文本部分的消息
function createMessageWithTextParts(texts: string[]): ModelMessage {
return {
role: 'assistant',
content: texts.map((text) => ({ type: 'text', text })),
} as ModelMessage;
}
describe('isSummaryMessage - 检测摘要消息', () => {
it('字符串内容包含摘要标记返回 true', () => {
const message = createSummaryMessage('这是摘要内容');
expect(isSummaryMessage(message)).toBe(true);
});
it('字符串内容不包含摘要标记返回 false', () => {
const message = createAssistantMessage('普通助手消息');
expect(isSummaryMessage(message)).toBe(false);
});
it('数组内容包含摘要标记返回 true', () => {
const message = createMessageWithTextParts([
'一些文本',
`${SUMMARY_MARKER}\n摘要内容\n${SUMMARY_MARKER}`,
]);
expect(isSummaryMessage(message)).toBe(true);
});
it('数组内容不包含摘要标记返回 false', () => {
const message = createMessageWithTextParts(['文本1', '文本2']);
expect(isSummaryMessage(message)).toBe(false);
});
it('用户消息不是摘要消息', () => {
const message = createUserMessage(SUMMARY_MARKER);
// 虽然包含标记,但这种情况下 isSummaryMessage 还是会返回 true
// 因为它只检查内容是否包含标记
expect(isSummaryMessage(message)).toBe(true);
});
it('空数组内容返回 false', () => {
const message: ModelMessage = {
role: 'assistant',
content: [],
};
expect(isSummaryMessage(message)).toBe(false);
});
it('非文本部分不匹配', () => {
const message: ModelMessage = {
role: 'assistant',
content: [
{
type: 'tool-call',
toolCallId: 'call_1',
toolName: 'test',
args: { key: SUMMARY_MARKER },
},
],
} as ModelMessage;
expect(isSummaryMessage(message)).toBe(false);
});
});
describe('simpleCompact - 简单压缩', () => {
const testConfig: CompressionConfig = {
contextLimit: 1000,
outputReserve: 100,
pruneProtect: 200, // 保护最近 200 tokens
pruneMinimum: 50,
overflowThreshold: 0.85,
};
describe('基本压缩行为', () => {
it('空消息数组不压缩', () => {
const result = simpleCompact([], testConfig);
expect(result.messages).toHaveLength(0);
expect(result.freedTokens).toBe(0);
});
it('消息在保护范围内不压缩', () => {
const messages: ModelMessage[] = [
createUserMessage('Hello'),
createAssistantMessage('Hi'),
];
const result = simpleCompact(messages, testConfig);
// 消息很短,应该在保护范围内
expect(result.freedTokens).toBe(0);
expect(result.messages).toEqual(messages);
});
it('压缩超出保护范围的消息', () => {
// 创建大量消息超出保护范围
const messages: ModelMessage[] = [];
for (let i = 0; i < 50; i++) {
messages.push(createUserMessage(`Message ${i}: ${'a'.repeat(100)}`));
messages.push(createAssistantMessage(`Response ${i}: ${'b'.repeat(100)}`));
}
const result = simpleCompact(messages, testConfig);
// 应该压缩一些旧消息
expect(result.freedTokens).toBeGreaterThan(0);
expect(result.messages.length).toBeLessThan(messages.length);
});
});
describe('保留消息数量', () => {
it('至少保留 2 条消息(正常模式)', () => {
const messages: ModelMessage[] = [];
for (let i = 0; i < 10; i++) {
messages.push(createUserMessage(`Long message ${'a'.repeat(500)}`));
}
const result = simpleCompact(messages, testConfig);
// 即使压缩,也至少保留 2 条
expect(result.messages.length).toBeGreaterThanOrEqual(2 + 1); // 2 保留 + 1 摘要
});
it('强制模式下至少保留 1 条消息', () => {
const forceConfig: CompressionConfig = {
...testConfig,
pruneProtect: 0, // 强制模式
};
const messages: ModelMessage[] = [];
for (let i = 0; i < 10; i++) {
messages.push(createUserMessage(`Message ${i}`));
}
const result = simpleCompact(messages, forceConfig);
// 强制模式下至少保留 1 条消息
expect(result.messages.length).toBeGreaterThanOrEqual(2); // 1 保留 + 1 摘要
});
});
describe('摘要消息生成', () => {
it('压缩后生成摘要消息', () => {
const messages: ModelMessage[] = [];
for (let i = 0; i < 50; i++) {
messages.push(createUserMessage(`Message ${i}: ${'a'.repeat(100)}`));
}
// 禁用首条保护以便测试摘要消息在第一位
const result = simpleCompact(messages, testConfig, { protectFirstPair: false });
if (result.freedTokens > 0) {
// 第一条消息应该是摘要
expect(isSummaryMessage(result.messages[0])).toBe(true);
}
});
it('摘要消息包含移除数量信息', () => {
const messages: ModelMessage[] = [];
for (let i = 0; i < 50; i++) {
messages.push(createUserMessage(`Message ${i}: ${'a'.repeat(100)}`));
}
// 禁用首条保护以便测试摘要消息
const result = simpleCompact(messages, testConfig, { protectFirstPair: false });
if (result.freedTokens > 0) {
const summaryContent = result.messages[0].content as string;
expect(summaryContent).toContain('对话历史已压缩');
expect(summaryContent).toContain('条消息');
}
});
});
describe('保护范围计算', () => {
it('短消息全部在保护范围内', () => {
const messages: ModelMessage[] = [
createUserMessage('Hi'),
createAssistantMessage('Hello'),
createUserMessage('How?'),
createAssistantMessage('Good!'),
];
const result = simpleCompact(messages, testConfig);
// 短消息应该全部保留
expect(result.messages).toEqual(messages);
});
});
describe('不修改原数组', () => {
it('原消息数组不被修改', () => {
const messages: ModelMessage[] = [];
for (let i = 0; i < 20; i++) {
messages.push(createUserMessage(`Message ${i}: ${'a'.repeat(100)}`));
}
const originalLength = messages.length;
simpleCompact(messages, testConfig);
expect(messages.length).toBe(originalLength);
});
});
});
// 注意: compact 函数需要真实的 LanguageModel
// 这里只测试 simpleCompact 和辅助函数
// compact 的测试应该在集成测试中进行
describe('Compaction 边界情况', () => {
it('单条消息不压缩', () => {
const messages: ModelMessage[] = [
createUserMessage('Single message'),
];
const testConfig: CompressionConfig = {
contextLimit: 100,
outputReserve: 10,
pruneProtect: 50,
pruneMinimum: 10,
overflowThreshold: 0.85,
};
const result = simpleCompact(messages, testConfig);
expect(result.messages).toEqual(messages);
expect(result.freedTokens).toBe(0);
});
it('两条消息不压缩(最小保留)', () => {
const messages: ModelMessage[] = [
createUserMessage('First'),
createAssistantMessage('Second'),
];
const testConfig: CompressionConfig = {
contextLimit: 100,
outputReserve: 10,
pruneProtect: 10, // 很小的保护范围
pruneMinimum: 1,
overflowThreshold: 0.85,
};
const result = simpleCompact(messages, testConfig);
// 即使配置很激进,也至少保留 2 条
expect(result.messages.length).toBeGreaterThanOrEqual(2);
});
});