CI / verify (push) Successful in 1m53s
- P0: 发送路径用户消息重复注入根治(history-builder 纯模块 + 单测,连带修复 maxCount 截断保留最旧消息缺陷);/undo、/retry 消息删除差量落库(新增 db:getMessageIds/deleteMessages 四层通道);/compress 摘要 role:user + 可折叠卡片渲染 + 旧 system 行读取归一化 - P1: memory search 默认 limit=8;工具缓存键/去重改稳定序列化;run_command 超时联动主进程杀子进程;记忆访问统计写回纳入写入锁;搜索自动抓取单页限幅 8k;Token 趋势采样移出 calculateContextStats 并记录裁剪后值;空白 assistant 幽灵消息跳过入库(新迭代/中止两路径) - P2: 新增 tool-security(18 用例,平台自适应)与 history-builder(11 用例)测试;帮助/README/DEVELOPMENT 文案与代码事实对齐;vendor 失效 sourcemap 与 .npmrc 弃用配置清理;备份导入携带 attachments 修复 - 版本号升级 0.17.2(5 文件白名单);typecheck 零错误 / 301 测试通过 / 构建通过
157 lines
6.0 KiB
TypeScript
157 lines
6.0 KiB
TypeScript
import { describe, it, expect } from 'vitest';
|
|
import { buildHistoryMessages } from '../src/renderer/services/history-builder.js';
|
|
import type { ChatMessage } from '../src/renderer/types.js';
|
|
|
|
function userMsg(content: string, over: Partial<ChatMessage> = {}): ChatMessage {
|
|
return { role: 'user', content, timestamp: Date.now(), ...over };
|
|
}
|
|
|
|
function assistantMsg(content: string, over: Partial<ChatMessage> = {}): ChatMessage {
|
|
return { role: 'assistant', content, timestamp: Date.now(), ...over };
|
|
}
|
|
|
|
describe('buildHistoryMessages — 历史消息构建', () => {
|
|
it('排除末尾的当前用户消息(防止重复注入)', () => {
|
|
const msgs = [
|
|
userMsg('第一轮问题'),
|
|
assistantMsg('第一轮回答'),
|
|
userMsg('当前轮问题'), // 当前输入,必须被排除
|
|
];
|
|
const out = buildHistoryMessages(msgs, 30);
|
|
expect(out.some(m => m.role === 'user' && m.content === '当前轮问题')).toBe(false);
|
|
expect(out.some(m => m.content === '第一轮问题')).toBe(true);
|
|
expect(out.some(m => m.content === '第一轮回答')).toBe(true);
|
|
});
|
|
|
|
it('当前用户消息之后的孤立消息一并排除', () => {
|
|
const msgs = [
|
|
userMsg('历史'),
|
|
userMsg('当前轮'),
|
|
assistantMsg('当前轮的回复'), // 位于当前输入之后,不属于历史
|
|
];
|
|
const out = buildHistoryMessages(msgs, 30);
|
|
expect(out.some(m => m.content === '当前轮的回复')).toBe(false);
|
|
expect(out.some(m => m.content === '历史')).toBe(true);
|
|
});
|
|
|
|
it('当前用户消息的 _apiContent 不进入历史(由 handleInit 重新注入)', () => {
|
|
const msgs = [
|
|
userMsg('第一轮'),
|
|
assistantMsg('第一轮回答'),
|
|
userMsg('当前轮', { _apiContent: '{"file_name":"a.txt","context":"..."}' } as Partial<ChatMessage>),
|
|
];
|
|
const out = buildHistoryMessages(msgs, 30);
|
|
expect(JSON.stringify(out)).not.toContain('file_name');
|
|
});
|
|
|
|
it('用户消息优先使用 _apiContent(附件结构化数据)', () => {
|
|
const msgs = [
|
|
userMsg('显示文本', { _apiContent: '{"file_name":"a.txt"}' } as Partial<ChatMessage>),
|
|
assistantMsg('回答'),
|
|
userMsg('当前轮'),
|
|
];
|
|
const out = buildHistoryMessages(msgs, 30);
|
|
const first = out.find(m => m.role === 'user')!;
|
|
expect(first.content).toBe('{"file_name":"a.txt"}');
|
|
});
|
|
|
|
it('注入 assistant 的 tool_calls 与 role:tool 结果信封', () => {
|
|
const msgs = [
|
|
userMsg('读一下'),
|
|
assistantMsg('', {
|
|
toolCalls: [{
|
|
name: 'read_file',
|
|
arguments: { path: 'a.txt' },
|
|
result: { success: true, path: 'a.txt', content: 'hello' },
|
|
status: 'success',
|
|
timestamp: Date.now(),
|
|
}],
|
|
}),
|
|
userMsg('当前轮'),
|
|
];
|
|
const out = buildHistoryMessages(msgs, 30);
|
|
const assistant = out.find(m => m.role === 'assistant')!;
|
|
expect(assistant.tool_calls?.length).toBe(1);
|
|
expect(assistant.tool_calls![0].function.name).toBe('read_file');
|
|
const toolMsg = out.find(m => m.role === 'tool')!;
|
|
expect(toolMsg.content).toContain('<<<TOOL_RESULT_START name="read_file">>>');
|
|
expect(toolMsg.content).toContain('<<<TOOL_RESULT_END>>>');
|
|
expect(toolMsg.tool_name).toBe('read_file');
|
|
});
|
|
|
|
it('无结果的 toolCalls 不产生 tool 消息', () => {
|
|
const msgs = [
|
|
userMsg('问'),
|
|
assistantMsg('', {
|
|
toolCalls: [{ name: 'read_file', arguments: { path: 'a' }, result: null, status: 'pending', timestamp: Date.now() }],
|
|
}),
|
|
userMsg('当前轮'),
|
|
];
|
|
const out = buildHistoryMessages(msgs, 30);
|
|
expect(out.some(m => m.role === 'tool')).toBe(false);
|
|
});
|
|
|
|
it('永不注入 system 消息(系统提示词由 handleInit 统一构建)', () => {
|
|
const msgs = [
|
|
{ role: 'system', content: '系统提示词' } as ChatMessage,
|
|
userMsg('历史'),
|
|
assistantMsg('回答'),
|
|
userMsg('当前轮'),
|
|
];
|
|
const out = buildHistoryMessages(msgs, 30);
|
|
expect(out.some(m => m.role === 'system')).toBe(false);
|
|
});
|
|
|
|
it('超过 maxCount 从尾部截取', () => {
|
|
const msgs: ChatMessage[] = [];
|
|
for (let i = 0; i < 20; i++) {
|
|
msgs.push(userMsg(`问题${i}`));
|
|
msgs.push(assistantMsg(`回答${i}`));
|
|
}
|
|
msgs.push(userMsg('当前轮'));
|
|
const out = buildHistoryMessages(msgs, 10);
|
|
expect(out.length).toBeLessThanOrEqual(10);
|
|
// 保留的是最近的消息
|
|
expect(JSON.stringify(out)).toContain('回答19');
|
|
expect(JSON.stringify(out)).not.toContain('问题0');
|
|
});
|
|
|
|
it('裁剪点不落在 tool 消息上(不产生孤立 tool 结果)', () => {
|
|
const msgs: ChatMessage[] = [userMsg('起点')];
|
|
for (let i = 0; i < 5; i++) {
|
|
msgs.push(assistantMsg('', {
|
|
toolCalls: [{
|
|
name: 'read_file', arguments: { path: `f${i}.txt` },
|
|
result: { success: true, content: `内容${i}` }, status: 'success', timestamp: Date.now(),
|
|
}],
|
|
}));
|
|
msgs.push(userMsg(`追问${i}`));
|
|
}
|
|
const out = buildHistoryMessages(msgs, 5);
|
|
expect(out.length).toBeLessThanOrEqual(5);
|
|
// 首条消息不能是孤立 tool 结果
|
|
expect(out[0].role).not.toBe('tool');
|
|
// 若首条是带 tool_calls 的 assistant,其 tool 结果必须紧随其后
|
|
if (out[0].role === 'assistant' && (out[0] as { tool_calls?: unknown[] }).tool_calls?.length) {
|
|
expect(out[1]?.role).toBe('tool');
|
|
}
|
|
});
|
|
|
|
it('空消息列表与无用户消息时安全返回', () => {
|
|
expect(buildHistoryMessages([], 30)).toEqual([]);
|
|
const onlyAssistant = [assistantMsg('只有回答')];
|
|
expect(buildHistoryMessages(onlyAssistant, 30).length).toBe(1);
|
|
});
|
|
|
|
it('assistant 的 thinking 映射为 thinking 字段', () => {
|
|
const msgs = [
|
|
userMsg('问'),
|
|
assistantMsg('答', { think: '推理过程' }),
|
|
userMsg('当前轮'),
|
|
];
|
|
const out = buildHistoryMessages(msgs, 30);
|
|
const assistant = out.find(m => m.role === 'assistant')!;
|
|
expect((assistant as { thinking?: string }).thinking).toBe('推理过程');
|
|
});
|
|
});
|