Files
thzxx 9b45c445bf
CI / 类型检查 + Lint + 单元测试 (push) Failing after 9m8s
CI / 全量测试 (Electron ABI) (push) Failing after 6m0s
CI / 产物编译验证 (push) Successful in 10m58s
feat: v0.8.1 记忆深化 · 观测闭环 · 体验收口 — 窗口/输出上限全局单一配置 · 2478 用例全量回归 + E2E 冒烟
硬性契约:删除代码中一切写死的上下文窗口与最大输出上限(含六家模型元信息
钳制与全部兜底值)——唯一合法来源是设置面板「上下文长度」(llm.contextWindow)
与「最大输出上限」(llm.maxTokens),跨 Provider/模型原样透传。

P0 正确性收口:
- 迁移 11/12(SCHEMA_VERSION 5):记忆表 embedding 列 + 分 Provider 窗口键清理
- 记忆生命周期接线:会话终态清理 working memory / episodic 90 天 TTL / access_count 回写
- 回放缓冲模块化 + 会话终态清理(杜绝 4MB/会话内存滞留)
- i18n 收口:主进程 main-locale(zh/en,ui.locale 热切换)+ 渲染层 17 处出层

P1 能力演进:
- 本地向量混合检索:0.6×向量余弦 + 0.4×TF-IDF,Ollama embeddings 首次投产,
  存量记忆惰性回填,嵌入不可用自动回退 TF-IDF
- MEMORY.md 维护闭环:固化去重消除截断盲区;两阶段维护(AI 建议 → 用户确认 →
  原子改写 + 语义记忆双轨同步 + 审计);>50KB 告警
- 可观测闭环:cacheTokens 引擎→前端透传(Token 面板命中率/成本行)+ 输入框
  上下文占用指示条
- MCP Prompts/Resources 对话可用:/mcp:{server}:{prompt} 与 @mcp:{server}:{uri}

P2 体验补全:
- 工具自定义策略(正则白/黑名单 + 频率 + 强制确认,热生效)
- 连续 ≥3 同类工具确认聚合为单弹框
- 会话消息游标分页(首屏 200 条向上翻页)
- 开机自启;Playwright + Electron E2E 冒烟(本地 mock LLM 零外联)

Review 回归修复:MCP 大小写失配 / 分页状态复位 / 清空=未配置语义(Number(null)=0
隐患)/ MEMORY.md 告警位置 / working_memories FK(迁移 13)/ 全局配置层废键清理;
附带根治权限加固启动时序、代理回环放行、safeStorage 降级、悬空 symlink 逃逸。

验证:typecheck/lint 0 问题;test:electron 2478/2478(0 跳过);E2E 2/2;
docs/v0.8.1-迭代实施清单.md 全项留档。
2026-09-08 09:35:58 +08:00

474 lines
18 KiB
TypeScript
Raw Permalink 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.
/**
* BaseAdapter 单元测试(v0.6.4 P3-2 错误分类单轨化后重写)
*
* 契约变更说明:
* - mapError 已删除(生产路径死代码,与 engine.isRetryableError 双轨漂移)。
* 错误分类的唯一事实来源是 engine.isRetryableError —— 本文件改为验证
* "BaseAdapter 抛出的错误携带可判定字段"的形状契约:
* throwHttpError → error.statusfetchWithTimeout 超时 → code='ETIMEDOUT'
* + 'timed out' message(命中引擎网络超时分支)。
* - 新增:错误体长度截断、外部 abort 与自身超时的区分。
*/
import { describe, it, expect, vi, afterEach } from 'vitest';
import { BaseAdapter, ContentFilterError } from '../base-adapter';
import { MetonaStreamEventType } from '../../types';
import type {
IMetonaProviderAdapter,
AdapterConfig,
MetonaRequest,
MetonaResponse,
MetonaStreamEvent,
} from '../../types';
/** 测试用具体 Adapter 实现(暴露 protected 方法供测试) */
class TestAdapter extends BaseAdapter {
readonly providerId = 'test';
readonly supportedModels = ['test-model-a', 'test-model-b'];
readonly supportsToolCalling = true;
readonly supportsThinking = false;
async send(_request: MetonaRequest): Promise<MetonaResponse> {
throw new Error('not implemented');
}
async *sendStream(_request: MetonaRequest): AsyncIterable<MetonaStreamEvent> {
// 空实现
}
/** 测试辅助: 暴露 protected throwHttpError */
async throwHttpErrorPublic(response: Response, context: string) {
return this.throwHttpError(response, context);
}
/** 测试辅助: 暴露 protected fetchWithTimeout */
async fetchWithTimeoutPublic(url: string, init: RequestInit, timeoutMs: number) {
return this.fetchWithTimeout(url, init, timeoutMs);
}
}
function makeAdapter(config: Partial<AdapterConfig> = {}): TestAdapter {
return new TestAdapter({
provider: 'test',
baseURL: 'https://api.test.com',
apiKey: 'sk-test',
defaultModel: 'test-model-a',
...config,
});
}
// ===== 错误形状契约(engine.isRetryableError 的输入保证) =====
describe('BaseAdapter — 抛出错误的可判定形状(单轨化契约)', () => {
/** 与 engine.isRetryableError 相同的判定逻辑(镜像断言用) */
const isRetryableShape = (err: unknown): boolean => {
const e = err as { status?: number; code?: string; message?: string };
if (e.status === 429) return true;
if (e.status && e.status >= 500 && e.status < 600) return true;
if (e.code === 'ECONNRESET' || e.code === 'ETIMEDOUT' || e.code === 'ENOTFOUND') return true;
const msg = e.message?.toLowerCase() ?? '';
if (msg.includes('aborted') || msg.includes('socket hang up')) return true;
return false;
};
function makeResponse(status: number, body: string): Response {
return new Response(body, { status, statusText: 'Status' });
}
it('throwHttpError 携带 status429/5xx 形状可重试,4xx 不可', async () => {
const adapter = makeAdapter();
try {
await adapter.throwHttpErrorPublic(makeResponse(429, 'rate limited'), 'T');
expect.fail('should throw');
} catch (e) {
expect((e as Error & { status?: number }).status).toBe(429);
expect(isRetryableShape(e)).toBe(true);
}
try {
await adapter.throwHttpErrorPublic(makeResponse(401, ''), 'T');
expect.fail('should throw');
} catch (e) {
expect((e as Error & { status?: number }).status).toBe(401);
expect(isRetryableShape(e)).toBe(false);
}
});
it('content_filter 错误体抛出 ContentFilterError(含 status', async () => {
const adapter = makeAdapter();
const body = JSON.stringify({ error: { code: 'content_filter', message: 'high risk' } });
await expect(
adapter.throwHttpErrorPublic(makeResponse(400, body), 'MiMo'),
).rejects.toBeInstanceOf(ContentFilterError);
try {
await adapter.throwHttpErrorPublic(makeResponse(400, body), 'MiMo');
} catch (e) {
expect((e as ContentFilterError & { status: number }).status).toBe(400);
expect((e as ContentFilterError).message).toContain('安全审核拦截');
}
});
it('巨大 HTML 错误体在消息中被截断(v0.6.4', async () => {
const adapter = makeAdapter();
const bigHtml = `<html>${'x'.repeat(100_000)}</html>`;
let caught: unknown;
try {
await adapter.throwHttpErrorPublic(makeResponse(502, bigHtml), 'GW');
expect.fail('should throw');
} catch (e) {
caught = e;
}
expect((caught as Error).message.length).toBeLessThan(1_000);
expect((caught as Error).message).toContain('[truncated');
// 截断不影响 status 判定
expect((caught as Error & { status?: number }).status).toBe(502);
});
});
describe('BaseAdapter — getContextWindow', () => {
it('配置了 contextWindow 时返回配置值', () => {
const adapter = makeAdapter({ contextWindow: 128_000 });
expect(adapter.getContextWindow()).toBe(128_000);
});
// v0.8.1 硬性契约: 上下文窗口唯一来源是设置面板 llm.contextWindow
// 未配置返回 0(引擎据此跳过压缩预算)—— 任何写死兜底值均已删除
it('未配置时返回 0(无任何写死兜底窗口,引擎跳过压缩判定)', () => {
const adapter = makeAdapter();
expect(adapter.getContextWindow()).toBe(0);
});
});
describe('BaseAdapter — listModels / healthCheck', () => {
it('listModels 将 supportedModels 映射为 MetonaModelInfo', async () => {
const adapter = makeAdapter();
const models = await adapter.listModels();
expect(models.map((m) => m.id)).toEqual(['test-model-a', 'test-model-b']);
});
it('healthCheck 成功返回 true', async () => {
const adapter = makeAdapter();
expect(await adapter.healthCheck()).toBe(true);
});
});
describe('BaseAdapter — fetchWithTimeoutP3-2 超时分类单轨化)', () => {
afterEach(() => {
vi.unstubAllGlobals();
vi.restoreAllMocks();
vi.useRealTimers();
});
it('正常请求返回 Response 并清理 timer', async () => {
const adapter = makeAdapter();
const mockResponse = new Response('{"ok":true}');
const fetchMock = vi.fn().mockResolvedValue(mockResponse);
vi.stubGlobal('fetch', fetchMock);
const result = await adapter.fetchWithTimeoutPublic('https://api.test.com/v1/chat', {}, 5_000);
expect(result).toBe(mockResponse);
expect(fetchMock).toHaveBeenCalledOnce();
});
it('自身超时 → 显式 ETIMEDOUT + timed out 消息(命中引擎网络超时可重试分支)', async () => {
const adapter = makeAdapter();
vi.useFakeTimers();
const fetchMock = vi.fn(
(_url: string, init: RequestInit) =>
new Promise<Response>((_resolve, reject) => {
init.signal?.addEventListener('abort', () =>
reject(new DOMException('This operation was aborted', 'AbortError')),
);
}),
);
vi.stubGlobal('fetch', fetchMock);
const promise = adapter.fetchWithTimeoutPublic('https://api.test.com/v1/chat', {}, 100);
const expectation = expect(promise).rejects.toSatisfy((e: Error & { code?: string }) => {
expect(e.code).toBe('ETIMEDOUT');
expect(e.message).toContain('timed out after 100ms');
// 关键:消息不再是裸 "Aborted" —— 引擎按网络超时(而非碰巧可重试)分类
return true;
});
vi.advanceTimersByTime(150);
await expectation;
vi.useRealTimers();
});
it('外部 abort(用户中断)→ 原样 AbortError,不被改写为超时', async () => {
const adapter = makeAdapter();
const controller = new AbortController();
adapter.setAbortSignal(controller.signal);
const fetchMock = vi.fn(
(_url: string, init: RequestInit) =>
new Promise<Response>((_resolve, reject) => {
init.signal?.addEventListener('abort', () =>
reject(new DOMException('This operation was aborted', 'AbortError')),
);
}),
);
vi.stubGlobal('fetch', fetchMock);
const promise = adapter.fetchWithTimeoutPublic('https://api.test.com/v1/chat', {}, 30_000);
controller.abort();
await promise.catch((e: Error & { code?: string }) => {
expect(e.name).toBe('AbortError');
// 未被转译为字符串型 ETIMEDOUT(注意 Node DOMException 自带数字 code=20
expect(e.code).not.toBe('ETIMEDOUT');
expect(e.message).not.toContain('timed out after');
});
});
});
describe('BaseAdapter — 接口契约', () => {
it('providerId / 能力声明符合 IMetonaProviderAdapter 契约', () => {
const adapter: IMetonaProviderAdapter = makeAdapter();
expect(adapter.providerId).toBe('test');
expect(typeof adapter.send).toBe('function');
expect(typeof adapter.sendStream).toBe('function');
expect(typeof adapter.getContextWindow).toBe('function');
expect(typeof adapter.setAbortSignal).toBe('function');
});
it('sendStream 是 AsyncGenerator(可迭代)', async () => {
const adapter = makeAdapter();
const events: MetonaStreamEvent[] = [];
for await (const ev of adapter.sendStream({} as MetonaRequest)) {
events.push(ev);
}
expect(events).toHaveLength(0);
expect(MetonaStreamEventType.TEXT_DELTA).toBe('text_delta');
});
});
// ===== 追加:fetchWithTimeout 更多边界 =====
describe('BaseAdapter — fetchWithTimeout 补充边界', () => {
afterEach(() => {
vi.unstubAllGlobals();
vi.restoreAllMocks();
vi.useRealTimers();
});
function hangingFetch(signalKey = 'signal'): ReturnType<typeof vi.fn> {
return vi.fn(
(_url: string, init: RequestInit) =>
new Promise<Response>((resolve, reject) => {
const signal = init[signalKey as 'signal'];
// 模拟真实 fetch:信号已提前 abort 时同步拒绝
if (signal?.aborted) {
reject(new DOMException('This operation was aborted', 'AbortError'));
return;
}
signal?.addEventListener('abort', () =>
reject(new DOMException('This operation was aborted', 'AbortError')),
);
}),
);
}
it('底层网络错误(非超时非外部 abort)原样抛出', async () => {
const adapter = makeAdapter();
const fetchMock = vi.fn().mockRejectedValue(new Error('socket hang up'));
vi.stubGlobal('fetch', fetchMock);
await expect(
adapter.fetchWithTimeoutPublic('https://api.test.com/v1/chat', {}, 5_000),
).rejects.toThrow('socket hang up');
});
it('外部信号已提前 abort → 直接 AbortError,不注册监听', async () => {
const adapter = makeAdapter();
const controller = new AbortController();
controller.abort();
adapter.setAbortSignal(controller.signal);
const addListenerSpy = vi.spyOn(controller.signal, 'addEventListener');
const fetchMock = hangingFetch();
vi.stubGlobal('fetch', fetchMock);
await adapter
.fetchWithTimeoutPublic('https://api.test.com/v1/chat', {}, 30_000)
.catch((e: Error) => {
expect(e.name).toBe('AbortError');
});
// 已 aborted 的信号走 controller.abort() 分支,不再注册监听
expect(addListenerSpy).not.toHaveBeenCalled();
});
it('外部 abort 后 listener 被移除(无监听泄漏)', async () => {
const adapter = makeAdapter();
const controller = new AbortController();
adapter.setAbortSignal(controller.signal);
const removeListenerSpy = vi.spyOn(controller.signal, 'removeEventListener');
const fetchMock = hangingFetch();
vi.stubGlobal('fetch', fetchMock);
const promise = adapter.fetchWithTimeoutPublic('https://api.test.com/v1/chat', {}, 30_000);
controller.abort();
await promise.catch(() => undefined);
expect(removeListenerSpy).toHaveBeenCalled();
});
it('自身超时但外部信号同时触发 → 外部 abort 优先,抛 AbortError 而非 ETIMEDOUT', async () => {
const adapter = makeAdapter();
const controller = new AbortController();
adapter.setAbortSignal(controller.signal);
vi.useFakeTimers();
const fetchMock = hangingFetch();
vi.stubGlobal('fetch', fetchMock);
const promise = adapter.fetchWithTimeoutPublic('https://api.test.com/v1/chat', {}, 100);
const assertion = expect(promise).rejects.toSatisfy((e: Error & { code?: string }) => {
expect(e.name).toBe('AbortError');
expect(e.code).not.toBe('ETIMEDOUT');
return true;
});
controller.abort();
await vi.advanceTimersByTimeAsync(150);
await assertion;
});
it('请求成功后 timer 已清理(fake timers 验证无残留定时器)', async () => {
const adapter = makeAdapter();
vi.useFakeTimers();
const fetchMock = vi.fn().mockResolvedValue(new Response('{"ok":true}'));
vi.stubGlobal('fetch', fetchMock);
await adapter.fetchWithTimeoutPublic('https://api.test.com/v1/chat', {}, 5_000);
expect(vi.getTimerCount()).toBe(0);
});
it('超时后 timer 已清理(无泄漏)', async () => {
const adapter = makeAdapter();
vi.useFakeTimers();
const fetchMock = hangingFetch();
vi.stubGlobal('fetch', fetchMock);
const promise = adapter.fetchWithTimeoutPublic('https://api.test.com/v1/chat', {}, 100);
const assertion = expect(promise).rejects.toMatchObject({ code: 'ETIMEDOUT' });
await vi.advanceTimersByTimeAsync(150);
await assertion;
expect(vi.getTimerCount()).toBe(0);
});
});
// ===== 追加:throwHttpError 错误体解析矩阵 =====
describe('BaseAdapter — throwHttpError 错误体解析', () => {
function makeResponse(status: number, body: string): Response {
return new Response(body, { status, statusText: 'Status' });
}
it('OpenAI 格式 {error:{code,message}} 携带 code 时识别 content_filter(大小写不敏感)', async () => {
const adapter = makeAdapter();
for (const code of ['content_filter', 'Content_Filter', 'CONTENT_FILTER']) {
const body = JSON.stringify({ error: { code, message: 'high risk' } });
await expect(
adapter.throwHttpErrorPublic(makeResponse(400, body), 'Ctx'),
).rejects.toBeInstanceOf(ContentFilterError);
}
});
it('type 字段承载错误码时同样识别 content_filterMiMo 兼容)', async () => {
const adapter = makeAdapter();
const body = JSON.stringify({ error: { type: 'content_filter', message: 'blocked' } });
const err = await adapter
.throwHttpErrorPublic(makeResponse(403, body), 'MiMo')
.catch((e: unknown) => e);
expect(err).toBeInstanceOf(ContentFilterError);
expect((err as ContentFilterError).providerMessage).toBe('blocked');
});
it('content_filter 无 message → 默认提示文案', async () => {
const adapter = makeAdapter();
const body = JSON.stringify({ error: { code: 'content_filter' } });
const err = await adapter
.throwHttpErrorPublic(makeResponse(400, body), 'Ctx')
.catch((e: unknown) => e);
expect((err as ContentFilterError).providerMessage).toBe('内容触发安全过滤策略');
});
it('顶层 error 结构(无嵌套)识别 content_filter', async () => {
const adapter = makeAdapter();
const body = JSON.stringify({ code: 'content_filter', message: 'rejected' });
const err = await adapter
.throwHttpErrorPublic(makeResponse(400, body), 'Ctx')
.catch((e: unknown) => e);
expect(err).toBeInstanceOf(ContentFilterError);
});
it('非 JSON 错误体 → 普通 Error 携带 status', async () => {
const adapter = makeAdapter();
const err = await adapter
.throwHttpErrorPublic(makeResponse(502, '<html>bad gateway</html>'), 'GW')
.catch((e: unknown) => e);
expect(err).not.toBeInstanceOf(ContentFilterError);
expect((err as Error & { status?: number }).status).toBe(502);
expect((err as Error).message).toContain('<html>bad gateway</html>');
});
it('错误体恰好 500 字符不截断;超过 500 字符截断并标注原长度', async () => {
const adapter = makeAdapter();
const exactly500 = 'x'.repeat(500);
const err500 = await adapter
.throwHttpErrorPublic(makeResponse(500, exactly500), 'Ctx')
.catch((e: unknown) => e);
expect((err500 as Error).message).toContain(exactly500);
expect((err500 as Error).message).not.toContain('[truncated');
const over = 'x'.repeat(10_000);
const errOver = await adapter
.throwHttpErrorPublic(makeResponse(500, over), 'Ctx')
.catch((e: unknown) => e);
expect((errOver as Error).message).toContain('[truncated 10000 chars]');
expect((errOver as Error).message.length).toBeLessThan(1_000);
});
it('错误体为响应头 JSON 但带尾随空白 → 仍能解析(trim 后 JSON.parse 成功路径)', async () => {
const adapter = makeAdapter();
const body = `{"error":{"code":"content_filter","message":"blocked"}} `;
const err = await adapter
.throwHttpErrorPublic(makeResponse(400, body), 'Ctx')
.catch((e: unknown) => e);
// response.text() 返回原文,JSON.parse 对尾随空白容忍 → 走 content_filter 分支
expect(err).toBeInstanceOf(ContentFilterError);
});
it('statusText 拼接进错误消息', async () => {
const adapter = makeAdapter();
const res = new Response('', { status: 429, statusText: 'Too Many Requests' });
const err = await adapter.throwHttpErrorPublic(res, 'Ctx').catch((e: unknown) => e);
expect((err as Error).message).toContain('429 Too Many Requests');
});
});
// ===== 追加:getContextWindow / listModels / healthCheck =====
describe('BaseAdapter — getContextWindow 非法值契约', () => {
it('contextWindow=0 视为未配置(返回 0,引擎跳过压缩判定)', () => {
const adapter = makeAdapter({ contextWindow: 0 });
expect(adapter.getContextWindow()).toBe(0);
});
it('contextWindow 为负数视为未配置(返回 0)', () => {
const adapter = makeAdapter({ contextWindow: -1 });
expect(adapter.getContextWindow()).toBe(0);
});
});
describe('BaseAdapter — listModels 默认映射与 healthCheck', () => {
it('listModels 映射保留默认模型顺序', async () => {
const adapter = makeAdapter();
const models = await adapter.listModels();
expect(models).toHaveLength(2);
expect(models[0]).toEqual({ id: 'test-model-a' });
expect(models[1]).toEqual({ id: 'test-model-b' });
});
it('healthCheck 在 listModels 抛错时返回 false', async () => {
const adapter = makeAdapter();
vi.spyOn(adapter, 'listModels').mockRejectedValue(new Error('API down'));
expect(await adapter.healthCheck()).toBe(false);
});
});