Files
CloudSSH/tests/worker/user-db-memory.test.ts
TanXin fe2b5f00ed release: 发布 v2.2.1 AI Agent 连续记忆合并与交互增强版本 (#127)
## 版本概述

发布 **v2.2.1** 版本,核心聚焦于 **AI Agent
连续运维记忆合并、实体对齐演进与前端交互体验增强**。彻底解决在复杂运维场景下,连续排查导致工作日志碎片化刷屏、记忆提炼过于草率简写、配置更新冲突并存以及前端卡片展示等痛点。

---

### 更新内容

1. **任务快照锚定与连续运维任务合并**:
   - 逆序定位本轮任务起点用户诉求,确保复杂长交互(超过 16 步)不丢失初始核心目标与显式参数;
- 引入时序紧邻度与连续任务强合并指引(30分钟内),指导大模型优先采用 `mode: "update_latest"`
承前启后地合并上一阶段成果,彻底解决排障过程中工作日志碎片化与 10 条配额被刷爆的问题;
   - 数据库 `UserDBDO` 支持原子合并更新最新一条工作历程,并在模型缺省时自动容错兜底。

2. **知识实体对齐与版本平滑演进**:
   - 提炼时注入当前服务器已存的知识项与凭据清单,强制约束修改配置或更换凭据时复用完全相同的 key 名;
- 结合底层 SQLite 原生 `UNIQUE(user_id, server_id, key)` 与 `ON CONFLICT DO
UPDATE` 实现原子更新,杜绝同一配置产生多条冲突脏数据;
   - 支持 `action: "delete"` 标记,允许用户明确废弃某项实体时在后台原子清理。

3. **长短时记忆职责彻底解耦**:
   - 会话内摘要(`this.state.summary`)专职保留未完结的任务目标、用户决策与待办上下文,彻底剔除冗长具体的命令流水账;
- System Prompt
中对“当前会话未决任务与决策摘要”与“服务器近期工作历程与操作备忘”进行语义与注入层隔离,消除同类信息双份冗余堆叠。

4. **真实凭据流通与通用协议规范**:
   - 彻底移除 Agent 工具输出中的破坏性正则硬脱敏代码,解除密钥与 Token 自动回填流转的阻碍;
- 移除非标准 `reasoning_effort` 参数,仅保留标准 `temperature: 0.1`,杜绝 OpenAI 官方
GPT-4o 等模型报 HTTP 400 校验错误;
   - 前置清洗 `<think>` 思考标签,避免 DeepSeek-R1 / QwQ 等推理模型思考阶段的草稿代码块被错误截获;
   - 补充循环步数上限与异常终止时的阶段性记忆提炼兜底,并增加纯问候本地快速熔断与并发排队补偿。

5. **前端工作历程呈现与交互增强**:
- 放宽提炼摘要字数限制至 100 字内(title 扩至 20
字内),引导大模型在不超过限制的前提下详实具体地记录排查背景、配置变更、服务状态及验证结果,消除草率简略;
   - 前端工作历程卡片应用 `-webkit-line-clamp: 2` 配合文本省略(`...`),保持整洁紧凑布局;
   - 支持鼠标悬停浮动展示完整文本 Tooltip,并支持点击直接展开/收起完整摘要内容。

---

### 提交列表

- `d99bd7b` release: 发布 v2.2.1 AI Agent 连续记忆合并与交互增强版本
- `542381a` feat: 放宽工作历程摘要长度至100字并支持前端两行截断悬停与点击展开
- `8d55927` refactor: 重构与优化 AI Agent 上下文管理与长期记忆系统

---

### 验证结果

全套本地门禁全部通过(`pnpm run verify`):
- **TypeScript 类型检查**:双端全部通过(0 errors)
- **单元与集成测试**:69 个测试文件、784 项用例全部通过(100% pass)
- **前端生产构建**:Vite 生产构建与单文件 HTML 内联成功
- **Playwright E2E & 无障碍测试**:75 个端到端测试全部通过(100% pass)
2026-09-08 22:36:58 +08:00

486 lines
16 KiB
TypeScript

import { beforeEach, describe, expect, it, vi } from 'vitest';
const { inferLocationHintMock } = vi.hoisted(() => ({
inferLocationHintMock: vi.fn(),
}));
vi.mock('../../src/worker/ip-geo', () => ({
inferLocationHint: inferLocationHintMock,
}));
import type { Env } from '../../src/types';
import { UserDBDO } from '../../src/worker/user-db';
interface WorkLogRowRecord {
id: number;
user_id: number;
server_id: number;
title: string;
summary: string;
created_at: number;
updated_at: number;
}
interface KnowledgeRowRecord {
id: number;
user_id: number;
server_id: number;
category: string;
key: string;
value: string;
created_at: number;
updated_at: number;
}
class FakeSql {
workLogs: WorkLogRowRecord[] = [];
knowledge: KnowledgeRowRecord[] = [];
servers: Array<{ id: number; user_id: number; name: string }> = [
{ id: 1, user_id: 10, name: 'Prod Server' },
{ id: 2, user_id: 20, name: 'Other User Server' },
];
private nextLogId = 1;
private nextKnowledgeId = 1;
statements: Array<{ query: string; values: unknown[] }> = [];
exec(query: string, ...values: unknown[]): { toArray: () => unknown[] } {
this.statements.push({ query, values });
const q = query.replace(/\s+/g, ' ');
if (
q.includes('CREATE TABLE') ||
q.includes('CREATE INDEX') ||
q.includes('PRAGMA table_info') ||
q.includes('DROP TABLE')
) {
if (q.includes('PRAGMA table_info(servers)')) {
return { toArray: () => [{ name: 'region' }, { name: 'inferred_hint' }] as unknown[] };
}
return { toArray: () => [] };
}
if (q.includes('SELECT user_id FROM servers WHERE id = ?')) {
const serverId = values[0];
const s = this.servers.find((srv) => srv.id === serverId);
return { toArray: () => (s ? [{ user_id: s.user_id }] : []) };
}
if (q.includes('FROM servers WHERE id = ?')) {
const serverId = values[0];
const s = this.servers.find((srv) => srv.id === serverId);
if (!s) return { toArray: () => [] };
return {
toArray: () => [
{
id: s.id,
user_id: s.user_id,
name: s.name,
host: '1.2.3.4',
port: 22,
username: 'root',
auth_method: 'password',
region: null,
inferred_hint: null,
tags: '[]',
os: null,
jump_server_id: null,
created_at: '2026-01-01',
updated_at: '2026-01-01',
},
],
};
}
if (q.startsWith('UPDATE servers SET')) {
return { toArray: () => [] };
}
// Work logs query
if (q.includes('FROM server_work_logs WHERE server_id = ? AND user_id = ?')) {
const [serverId, userId] = values as [number, number];
const rows = this.workLogs
.filter((l) => l.server_id === serverId && l.user_id === userId)
.sort((a, b) => b.updated_at - a.updated_at);
return { toArray: () => rows as unknown[] };
}
if (q.includes('SELECT user_id FROM server_work_logs WHERE id = ? AND server_id = ?')) {
const [id, serverId] = values as [number, number];
const found = this.workLogs.filter((l) => l.id === id && l.server_id === serverId);
return { toArray: () => found as unknown[] };
}
if (q.includes('SELECT id FROM server_work_logs') && q.includes('ORDER BY updated_at DESC LIMIT 1')) {
const [serverId, userId] = values as [number, number];
const sorted = this.workLogs
.filter((l) => l.server_id === serverId && l.user_id === userId)
.sort((a, b) => b.updated_at - a.updated_at);
return { toArray: () => (sorted.length > 0 ? [{ id: sorted[0].id }] : []) };
}
if (q.startsWith('UPDATE server_work_logs SET title = ?, summary = ?, updated_at = ? WHERE id = ?')) {
const [title, summary, updatedAt, id] = values as [string, string, number, number];
const idx = this.workLogs.findIndex((l) => l.id === id);
if (idx >= 0) {
this.workLogs[idx] = { ...this.workLogs[idx], title, summary, updated_at: updatedAt };
}
return { toArray: () => [] };
}
if (q.startsWith('INSERT INTO server_work_logs')) {
const [userId, serverId, title, summary, createdAt, updatedAt] = values as [
number,
number,
string,
string,
number,
number,
];
const row: WorkLogRowRecord = {
id: this.nextLogId++,
user_id: userId,
server_id: serverId,
title,
summary,
created_at: createdAt,
updated_at: updatedAt,
};
this.workLogs.push(row);
return { toArray: () => [] };
}
if (q.startsWith('DELETE FROM server_work_logs WHERE id = ? AND server_id = ?')) {
const [id, serverId, userId] = values as [number, number, number];
this.workLogs = this.workLogs.filter(
(l) => !(l.id === id && l.server_id === serverId && l.user_id === userId)
);
return { toArray: () => [] };
}
if (q.startsWith('DELETE FROM server_work_logs WHERE server_id = ? AND user_id = ? AND id NOT IN')) {
const [serverId, userId] = values as [number, number];
const sLogs = this.workLogs
.filter((l) => l.server_id === serverId && l.user_id === userId)
.sort((a, b) => b.updated_at - a.updated_at);
const keep = new Set(sLogs.slice(0, 10).map((l) => l.id));
this.workLogs = this.workLogs.filter(
(l) => !(l.server_id === serverId && l.user_id === userId && !keep.has(l.id))
);
return { toArray: () => [] };
}
if (q.startsWith('DELETE FROM server_work_logs WHERE server_id = ?')) {
const serverId = values[0];
this.workLogs = this.workLogs.filter((l) => l.server_id !== serverId);
return { toArray: () => [] };
}
// Knowledge query
if (q.includes('FROM server_knowledge WHERE server_id = ? AND user_id = ? AND key = ?')) {
const [serverId, userId, key] = values as [number, number, string];
const rows = this.knowledge.filter(
(k) => k.server_id === serverId && k.user_id === userId && k.key === key
);
return { toArray: () => rows as unknown[] };
}
if (q.includes('FROM server_knowledge WHERE server_id = ? AND user_id = ?')) {
const [serverId, userId] = values as [number, number];
const rows = this.knowledge
.filter((k) => k.server_id === serverId && k.user_id === userId)
.sort((a, b) => b.updated_at - a.updated_at);
return { toArray: () => rows as unknown[] };
}
if (q.includes('SELECT user_id FROM server_knowledge WHERE id = ? AND server_id = ?')) {
const [id, serverId] = values as [number, number];
const found = this.knowledge.filter((k) => k.id === id && k.server_id === serverId);
return { toArray: () => found as unknown[] };
}
if (q.startsWith('DELETE FROM server_knowledge WHERE user_id = ? AND server_id = ? AND key = ?')) {
const [userId, serverId, key] = values as [number, number, string];
this.knowledge = this.knowledge.filter(
(k) => !(k.user_id === userId && k.server_id === serverId && k.key === key)
);
return { toArray: () => [] };
}
if (q.startsWith('INSERT INTO server_knowledge')) {
const [userId, serverId, category, key, value, createdAt, updatedAt] = values as [
number,
number,
string,
string,
string,
number,
number,
];
const existingIdx = this.knowledge.findIndex(
(k) => k.user_id === userId && k.server_id === serverId && k.key === key
);
if (existingIdx >= 0) {
this.knowledge[existingIdx] = {
...this.knowledge[existingIdx],
category,
value,
updated_at: updatedAt,
};
} else {
const row: KnowledgeRowRecord = {
id: this.nextKnowledgeId++,
user_id: userId,
server_id: serverId,
category,
key,
value,
created_at: createdAt,
updated_at: updatedAt,
};
this.knowledge.push(row);
}
return { toArray: () => [] };
}
if (q.startsWith('DELETE FROM server_knowledge WHERE id = ? AND server_id = ?')) {
const [id, serverId, userId] = values as [number, number, number];
this.knowledge = this.knowledge.filter(
(k) => !(k.id === id && k.server_id === serverId && k.user_id === userId)
);
return { toArray: () => [] };
}
if (q.startsWith('DELETE FROM server_knowledge WHERE server_id = ? AND user_id = ? AND id NOT IN')) {
const [serverId, userId] = values as [number, number];
const sK = this.knowledge
.filter((k) => k.server_id === serverId && k.user_id === userId)
.sort((a, b) => b.updated_at - a.updated_at);
const keep = new Set(sK.slice(0, 50).map((k) => k.id));
this.knowledge = this.knowledge.filter(
(k) => !(k.server_id === serverId && k.user_id === userId && !keep.has(k.id))
);
return { toArray: () => [] };
}
if (q.startsWith('DELETE FROM server_knowledge WHERE server_id = ?')) {
const serverId = values[0];
this.knowledge = this.knowledge.filter((k) => k.server_id !== serverId);
return { toArray: () => [] };
}
if (q.startsWith('DELETE FROM servers WHERE id = ?')) {
return { toArray: () => [] };
}
return { toArray: () => [] };
}
}
describe('UserDBDO unified server memory', () => {
let fakeSql: FakeSql;
let userDb: UserDBDO;
beforeEach(() => {
fakeSql = new FakeSql();
userDb = new UserDBDO(
{
storage: {
sql: fakeSql,
get: vi.fn(),
put: vi.fn(),
delete: vi.fn(),
},
} as never,
{} as Env
);
});
it('fetches server memory with ownership enforcement', async () => {
// Other user's server -> 403
const resForbidden = await userDb.fetch(
new Request('http://internal/internal/servers/2/memory?user_id=10')
);
expect(resForbidden.status).toBe(403);
// Non-existent server -> 404
const resNotFound = await userDb.fetch(
new Request('http://internal/internal/servers/999/memory?user_id=10')
);
expect(resNotFound.status).toBe(404);
// Owned server -> 200
const res = await userDb.fetch(
new Request('http://internal/internal/servers/1/memory?user_id=10')
);
expect(res.status).toBe(200);
const data = (await res.json()) as { workLogs: unknown[]; knowledge: unknown[] };
expect(data.workLogs).toHaveLength(0);
expect(data.knowledge).toHaveLength(0);
});
it('saves and deletes a work log', async () => {
// Valid work log -> 201
const resCreate = await userDb.fetch(
new Request('http://internal/internal/servers/1/work-logs', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
user_id: 10,
title: '巡检服务器硬件',
summary: 'CPU与内存占用正常',
}),
})
);
expect(resCreate.status).toBe(201);
const created = (await resCreate.json()) as WorkLogRowRecord;
expect(created.title).toBe('巡检服务器硬件');
// Delete with wrong user -> 403
const resForbidden = await userDb.fetch(
new Request(`http://internal/internal/servers/1/work-logs/${created.id}?user_id=99`, {
method: 'DELETE',
})
);
expect(resForbidden.status).toBe(403);
// Delete with owner -> 200
const resDel = await userDb.fetch(
new Request(`http://internal/internal/servers/1/work-logs/${created.id}?user_id=10`, {
method: 'DELETE',
})
);
expect(resDel.status).toBe(200);
expect(fakeSql.workLogs).toHaveLength(0);
});
it('saves and deletes knowledge and credentials', async () => {
// Save deploy token -> 201
const resCreate = await userDb.fetch(
new Request('http://internal/internal/servers/1/knowledge', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
user_id: 10,
category: 'credential',
key: 'deploy_token',
value: 'ghp_secret_token_123',
}),
})
);
expect(resCreate.status).toBe(201);
const created = (await resCreate.json()) as KnowledgeRowRecord;
expect(created.key).toBe('deploy_token');
expect(created.value).toBe('ghp_secret_token_123');
// Delete with owner -> 200
const resDel = await userDb.fetch(
new Request(`http://internal/internal/servers/1/knowledge/${created.id}?user_id=10`, {
method: 'DELETE',
})
);
expect(resDel.status).toBe(200);
expect(fakeSql.knowledge).toHaveLength(0);
});
it('handles batch save of memory from AI session', async () => {
const resBatch = await userDb.fetch(
new Request('http://internal/internal/servers/1/memory/batch', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
user_id: 10,
workLog: {
title: '部署应用并更新配置',
summary: '构建镜像并成功启动,端口正常监听',
},
knowledge: [
{ category: 'credential', key: 'api_token', value: 'sk-123456' },
{ category: 'config', key: 'app_port', value: '3000' },
],
}),
})
);
expect(resBatch.status).toBe(200);
expect(fakeSql.workLogs).toHaveLength(1);
expect(fakeSql.knowledge).toHaveLength(2);
});
it('supports update_latest mode for work log and delete action for knowledge in batch save', async () => {
// 1. 先有一条工作日志和一条知识项
fakeSql.workLogs = [
{
id: 101,
user_id: 10,
server_id: 1,
title: '初始排查',
summary: '正在检查端口',
created_at: 1000,
updated_at: 1000,
},
];
fakeSql.knowledge = [
{
id: 201,
user_id: 10,
server_id: 1,
category: 'config',
key: 'app_port',
value: '3000',
created_at: 1000,
updated_at: 1000,
},
];
// 2. 批量调用:以 update_latest 更新工作日志,以 delete 删除废弃的知识项
const resUpdate = await userDb.fetch(
new Request('http://internal/internal/servers/1/memory/batch', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
user_id: 10,
workLog: {
mode: 'update_latest',
title: '排查并修复端口冲突',
summary: '已将冲突端口修改为 3001 并成功启动',
},
knowledge: [
{ action: 'delete', key: 'app_port' },
{ category: 'config', key: 'new_app_port', value: '3001' },
],
}),
})
);
expect(resUpdate.status).toBe(200);
// 工作日志未增加,标题与内容已原子更新
expect(fakeSql.workLogs).toHaveLength(1);
expect(fakeSql.workLogs[0].title).toBe('排查并修复端口冲突');
expect(fakeSql.workLogs[0].summary).toBe('已将冲突端口修改为 3001 并成功启动');
// 旧知识项已删除,新知识项已添加
expect(fakeSql.knowledge).toHaveLength(1);
expect(fakeSql.knowledge[0].key).toBe('new_app_port');
expect(fakeSql.knowledge[0].value).toBe('3001');
});
it('drops legacy tables on init and cleans up on server delete without 500 error', async () => {
const droppedMemories = fakeSql.statements.some((s) =>
s.query.includes('DROP TABLE IF EXISTS server_memories')
);
const droppedCheckpoints = fakeSql.statements.some((s) =>
s.query.includes('DROP TABLE IF EXISTS server_task_checkpoints')
);
expect(droppedMemories).toBe(true);
expect(droppedCheckpoints).toBe(true);
const resDel = await userDb.fetch(
new Request('http://internal/internal/servers/1', {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ user_id: 10 }),
})
);
expect(resDel.status).toBe(200);
});
});