Files
metona-ai-desktop/electron/harness/utils/__tests__/token-estimator.test.ts
T
thzxx 22028c91c3
CI / 类型检查 + Lint + 单元测试 (push) Failing after 5m41s
CI / 产物编译验证 (push) Successful in 10m6s
CI / 全量测试 (Electron ABI) (push) Failing after 5m21s
fix: v0.5.5 全量复检修复 — 图片 token 估算缺失(压缩预算失真)+ 过时文案清理
背景:v0.5.4 多模态增强(多轮图片记忆 + 总开关 + DeepSeek vision)发布后的
全量全方位复检,重点核查新增功能的跨模块边界。

复检通过项(无回归确认):
- 多轮图片记忆 × 五家 adapter:Anthropic(base64 块)/ OpenAI(image_url
  parts)/ Ollama(纯 base64 数组)/ Agnes / MiMo 均正确处理恢复的历史 images
- 多轮图片记忆 × 引擎运行时压缩:摘要请求仅含文本(图片不进摘要调用);
  toKeep 消息的 images 原样保留;孤立 tool 消息配对逻辑不受影响
- 多轮图片记忆 × 摘要服务:maybeSummarize 用截断文本 transcript,无图片干扰
- history 组装 slice(0,-1) × 图片恢复无冲突(当前消息 images 走独立通道)
- 编辑重发/重新生成路径与图片恢复同源(attachments.preview),行为一致

修复项:
- P1 token 估算器完全忽略 images(estimateMessagesTokens):
  带 10 张图的消息被按纯文本估算。影响:压缩 keepBudget 严重低估 →
  压缩后实际 token 仍超 80% 阈值 → 反复触发压缩循环(每轮多一次 LLM
  摘要调用);上下文占用显示严重失真。修复:每张图按 1000 tokens 计入
  (1024px 压缩图在主流 Provider 约 700~1500 视觉 token,取保守上界)
- 文案清理:ChatInput 附件按钮 Tooltip 硬编码"DeepSeek 不支持图片"改为
  按拒绝原因区分(开关未开启 vs 当前模型不支持);openai-format.ts 共享层
  注释更新(DeepSeek vision 已支持,非 vision 模型才丢弃)

测试(243 → 245 用例):
- 新增 token 估算图片用例 ×2:带 images 消息按每张 1000 tokens 计入 /
  无 images 字段消息行为不变(向后兼容)

验证: lint 0 / typecheck 双工程 0 / test:electron 245 全过 / build 成功
2026-08-21 23:09:36 +08:00

90 lines
3.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.
/**
* Token Estimator 单元测试(P1-14 测试基线)
*/
import { describe, it, expect } from 'vitest';
import { estimateStringTokens, estimateMessagesTokens } from '../token-estimator';
describe('estimateStringTokens', () => {
it('空值返回 0', () => {
expect(estimateStringTokens('')).toBe(0);
expect(estimateStringTokens(null)).toBe(0);
expect(estimateStringTokens(undefined)).toBe(0);
});
it('纯 ASCII4 字符 ≈ 1 token', () => {
// 16 个 ASCII 字符 → 16 * 0.25 = 4 tokens
expect(estimateStringTokens('abcdefghijklmnop')).toBe(4);
});
it('纯中文:1 字符 ≈ 1 token', () => {
expect(estimateStringTokens('你好世界')).toBe(4);
});
it('混合文本按系数分别计算', () => {
// 4 ASCII (1 token) + 2 中文 (2 tokens) = 3 tokens
expect(estimateStringTokens('abcd你好')).toBe(3);
});
it('Emoji 计为 1 token/字符', () => {
expect(estimateStringTokens('🎉🎊')).toBe(2);
});
it('结果向上取整', () => {
// 1 个 ASCII = 0.25 → ceil 为 1
expect(estimateStringTokens('a')).toBe(1);
});
});
describe('estimateMessagesTokens', () => {
// ===== v0.5.5: 图片 token 估算(多轮图片记忆场景) =====
it('带 images 的消息按每张 1000 tokens 计入(v0.5.5 — 此前完全忽略)', () => {
const textOnly = estimateMessagesTokens([{ content: '看这张图', role: 'user' } as never]);
const withImage = estimateMessagesTokens([
{ content: '看这张图', role: 'user', images: [{ url: 'data:image/jpeg;base64,x' }] } as never,
]);
// 差值 = 1 张图的估算(1000)
expect(withImage - textOnly).toBe(1000);
const withThree = estimateMessagesTokens([
{ content: '', role: 'user', images: [{ url: 'a' }, { url: 'b' }, { url: 'c' }] } as never,
]);
// 3 张图 + 消息开销(content 为空 = 0
expect(withThree).toBe(3 * 1000 + 4);
});
it('无 images 字段的消息行为不变(向后兼容)', () => {
expect(estimateMessagesTokens([{ content: 'abc', role: 'user' } as never])).toBe(4 + 1);
});
it('每条消息计入结构性开销(4 tokens)', () => {
const msgs = [{ content: '' }, { content: '' }];
expect(estimateMessagesTokens(msgs)).toBe(8); // 2 * 4 overhead
});
it('content 为 null 时只计开销(tool_calls 消息场景)', () => {
expect(estimateMessagesTokens([{ content: null }])).toBe(4);
});
it('toolCalls 计入 id/name/args 开销', () => {
const withToolCall = [
{
content: null,
toolCalls: [{ id: 'tc_12345678', name: 'read_file', args: { file_path: '/a/b.ts' } }],
},
];
const withoutToolCall = [{ content: null }];
const diff = estimateMessagesTokens(withToolCall) - estimateMessagesTokens(withoutToolCall);
// id(9 chars→3) + name(9→3) + args(~18→5) + overhead(8) ≈ 19 tokens
expect(diff).toBeGreaterThanOrEqual(15);
expect(diff).toBeLessThanOrEqual(30);
});
it('reasoningContent 计入 token', () => {
const withReasoning = [{ content: '', reasoningContent: 'abcd' }];
const without = [{ content: '' }];
expect(estimateMessagesTokens(withReasoning) - estimateMessagesTokens(without)).toBe(1);
});
});