Merge Memory V2 runtime facade
This commit is contained in:
@@ -1,13 +1,115 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import { createServer } from 'node:http';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import test from 'node:test';
|
||||
import {
|
||||
buildVisionPayload,
|
||||
createTkmindProxy,
|
||||
sanitizePublicHtmlLinksInText,
|
||||
sanitizeSessionConversationPublicHtmlLinks,
|
||||
} from './tkmind-proxy.mjs';
|
||||
import { createMemoryV2 } from './memory-v2.mjs';
|
||||
|
||||
async function withFakeGoosedSession(handler) {
|
||||
const workingDir = fs.mkdtempSync(path.join(os.tmpdir(), 'memind-memory-v2-'));
|
||||
const harnessEntries = [];
|
||||
let server;
|
||||
|
||||
try {
|
||||
server = createServer(async (req, res) => {
|
||||
const chunks = [];
|
||||
for await (const chunk of req) chunks.push(chunk);
|
||||
const rawBody = Buffer.concat(chunks).toString('utf8');
|
||||
const body = rawBody ? JSON.parse(rawBody) : {};
|
||||
|
||||
if (req.method === 'POST' && req.url === '/agent/start') {
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ id: 'session-1' }));
|
||||
return;
|
||||
}
|
||||
if (req.method === 'POST' && req.url === '/agent/update_session') {
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ ok: true }));
|
||||
return;
|
||||
}
|
||||
if (req.method === 'GET' && req.url === '/sessions/session-1') {
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({
|
||||
id: 'session-1',
|
||||
working_dir: workingDir,
|
||||
goose_mode: 'chat',
|
||||
}));
|
||||
return;
|
||||
}
|
||||
if (req.method === 'GET' && req.url === '/sessions/session-1/extensions') {
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({
|
||||
extensions: [{ name: 'memory', available_tools: [] }],
|
||||
}));
|
||||
return;
|
||||
}
|
||||
if (req.method === 'POST' && req.url === '/sessions/session-1/reply') {
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ ok: true }));
|
||||
return;
|
||||
}
|
||||
if (req.method === 'POST' && req.url === '/agent/harness_remember') {
|
||||
harnessEntries.push(body);
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ ok: true }));
|
||||
return;
|
||||
}
|
||||
if (req.method === 'POST' && req.url === '/agent/harness_bootstrap') {
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ ok: true }));
|
||||
return;
|
||||
}
|
||||
res.writeHead(404, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ message: `unexpected ${req.method} ${req.url}` }));
|
||||
});
|
||||
|
||||
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
|
||||
const { port } = server.address();
|
||||
return await handler({
|
||||
apiTarget: `http://127.0.0.1:${port}`,
|
||||
workingDir,
|
||||
harnessEntries,
|
||||
});
|
||||
} finally {
|
||||
if (server?.listening) {
|
||||
await new Promise((resolve) => server.close(resolve));
|
||||
}
|
||||
fs.rmSync(workingDir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
function createMemoryTestUserAuth(workingDir) {
|
||||
return {
|
||||
async resolveWorkingDir() {
|
||||
return workingDir;
|
||||
},
|
||||
async getAgentSessionPolicy() {
|
||||
return {
|
||||
gooseMode: 'chat',
|
||||
enableContextMemory: true,
|
||||
extensionOverrides: [{ name: 'memory', available_tools: [] }],
|
||||
};
|
||||
},
|
||||
async getUserPublishLayout() {
|
||||
return {
|
||||
displayName: 'John',
|
||||
username: 'john',
|
||||
slug: 'john',
|
||||
};
|
||||
},
|
||||
async registerAgentSession() {},
|
||||
async getSessionTarget() {
|
||||
return { target: null, node: 0 };
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test('sanitizePublicHtmlLinksInText downgrades missing own markdown public html links', () => {
|
||||
const owner = `test-user-${Date.now()}-markdown-missing`;
|
||||
@@ -148,3 +250,206 @@ test('buildVisionPayload injects public standard image urls for page generation'
|
||||
assert.match(text, /无需 cookie 的公开压缩标准图片/);
|
||||
assert.match(text, /不得改写图片里人物的年龄、性别、人数或主体关系/);
|
||||
});
|
||||
|
||||
test('startSessionForUser resolves memories through Memory V2 facade', async () => {
|
||||
let resolveInput = null;
|
||||
await withFakeGoosedSession(async ({ apiTarget, workingDir, harnessEntries }) => {
|
||||
const proxy = createTkmindProxy({
|
||||
apiTarget,
|
||||
apiSecret: 'test-secret',
|
||||
userAuth: createMemoryTestUserAuth(workingDir),
|
||||
memoryV2: {
|
||||
async resolve(input) {
|
||||
resolveInput = input;
|
||||
return {
|
||||
memories: [{ label: 'interest', text: '用户关注 Memory V2 架构边界' }],
|
||||
};
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await proxy.startSessionForUser('user-1');
|
||||
|
||||
assert.deepEqual(resolveInput, {
|
||||
userId: 'user-1',
|
||||
sessionId: 'session-1',
|
||||
query: null,
|
||||
limit: 40,
|
||||
});
|
||||
assert.ok(
|
||||
harnessEntries.some((entry) => String(entry.content ?? '').includes('用户关注 Memory V2 架构边界')),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test('startSessionForUser does not inject memories when Memory V2 is disabled', async () => {
|
||||
let legacyTouched = false;
|
||||
await withFakeGoosedSession(async ({ apiTarget, workingDir, harnessEntries }) => {
|
||||
const proxy = createTkmindProxy({
|
||||
apiTarget,
|
||||
apiSecret: 'test-secret',
|
||||
userAuth: createMemoryTestUserAuth(workingDir),
|
||||
memoryV2: createMemoryV2({
|
||||
logger: { warn() {} },
|
||||
legacyMemoryService: {
|
||||
async listMemories() {
|
||||
legacyTouched = true;
|
||||
return [{ label: 'fact', text: 'disabled memory should not appear' }];
|
||||
},
|
||||
},
|
||||
env: { MEMORY_ENABLED: '0' },
|
||||
}),
|
||||
});
|
||||
|
||||
await proxy.startSessionForUser('user-1');
|
||||
|
||||
assert.equal(legacyTouched, false);
|
||||
assert.equal(
|
||||
harnessEntries.some((entry) => String(entry.content ?? '').includes('disabled memory should not appear')),
|
||||
false,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test('getRuntimeStatus exposes Memory V2 status without new runtime paths', async () => {
|
||||
await withFakeGoosedSession(async ({ apiTarget, workingDir }) => {
|
||||
const proxy = createTkmindProxy({
|
||||
apiTarget,
|
||||
apiSecret: 'test-secret',
|
||||
userAuth: createMemoryTestUserAuth(workingDir),
|
||||
memoryV2: createMemoryV2({
|
||||
logger: { warn() {} },
|
||||
backends: [
|
||||
{
|
||||
name: 'legacy-conversation-memory',
|
||||
async resolve() {
|
||||
return { memories: [] };
|
||||
},
|
||||
},
|
||||
],
|
||||
env: {
|
||||
MEMORY_ENABLED: '1',
|
||||
MEMORY_BACKEND: 'legacy',
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
const status = await proxy.getRuntimeStatus();
|
||||
|
||||
assert.equal(status.memory.enabled, true);
|
||||
assert.equal(status.memory.backend, 'legacy');
|
||||
assert.equal(status.memory.selectedBackend, 'legacy-conversation-memory');
|
||||
assert.deepEqual(status.memory.backends[0].supports, {
|
||||
resolve: true,
|
||||
write: false,
|
||||
compact: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test('getRuntimeStatus preserves Memory V2 status contract for release gates', async () => {
|
||||
await withFakeGoosedSession(async ({ apiTarget, workingDir }) => {
|
||||
const proxy = createTkmindProxy({
|
||||
apiTarget,
|
||||
apiSecret: 'test-secret',
|
||||
userAuth: createMemoryTestUserAuth(workingDir),
|
||||
memoryV2: createMemoryV2({
|
||||
logger: { warn() {} },
|
||||
legacyMemoryService: {
|
||||
async listMemories() {
|
||||
return [];
|
||||
},
|
||||
async saveAndAnalyze() {
|
||||
return { saved: 1, analyzed: 1, memories: 1 };
|
||||
},
|
||||
async analyzeUser() {
|
||||
return { analyzed: 1, memories: 1 };
|
||||
},
|
||||
},
|
||||
env: {
|
||||
MEMORY_ENABLED: '1',
|
||||
MEMORY_BACKEND: 'qdrant',
|
||||
MEMORY_FAIL_OPEN: '1',
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
const status = await proxy.getRuntimeStatus();
|
||||
const memory = status.memory;
|
||||
const byName = new Map(memory.backends.map((backend) => [backend.name, backend]));
|
||||
|
||||
assert.deepEqual(Object.keys(memory).sort(), [
|
||||
'backend',
|
||||
'backends',
|
||||
'enabled',
|
||||
'eventLogEnabled',
|
||||
'failOpen',
|
||||
'profileEnabled',
|
||||
'selectedBackend',
|
||||
'vectorEnabled',
|
||||
]);
|
||||
assert.equal(memory.enabled, true);
|
||||
assert.equal(memory.backend, 'qdrant');
|
||||
assert.equal(memory.selectedBackend, 'legacy-conversation-memory');
|
||||
assert.equal(memory.failOpen, true);
|
||||
assert.deepEqual(byName.get('legacy-conversation-memory').supports, {
|
||||
resolve: true,
|
||||
write: true,
|
||||
compact: true,
|
||||
});
|
||||
assert.equal(byName.get('qdrant').available, false);
|
||||
assert.equal(byName.get('qdrant').reason, 'not_configured');
|
||||
assert.equal(byName.get('pgvector').category, 'semantic');
|
||||
assert.equal(byName.get('mem0').category, 'extraction');
|
||||
assert.equal(byName.get('letta').category, 'lifecycle');
|
||||
assert.equal(byName.get('langgraph').category, 'policy');
|
||||
});
|
||||
});
|
||||
|
||||
test('submitSessionReplyForUser passes current prompt to Memory V2 resolve before existing reply path', async () => {
|
||||
let resolveInput = null;
|
||||
await withFakeGoosedSession(async ({ apiTarget, workingDir }) => {
|
||||
const proxy = createTkmindProxy({
|
||||
apiTarget,
|
||||
apiSecret: 'test-secret',
|
||||
userAuth: {
|
||||
...createMemoryTestUserAuth(workingDir),
|
||||
async ownsSession() {
|
||||
return true;
|
||||
},
|
||||
async canUseChat() {
|
||||
return { ok: true };
|
||||
},
|
||||
async getUserById() {
|
||||
return { id: 'user-1' };
|
||||
},
|
||||
async resolveUserPolicies() {
|
||||
return { unrestricted: true, policies: {} };
|
||||
},
|
||||
},
|
||||
memoryV2: {
|
||||
async resolve(input) {
|
||||
resolveInput = input;
|
||||
return { memories: [] };
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await proxy.submitSessionReplyForUser(
|
||||
'user-1',
|
||||
'session-1',
|
||||
'request-1',
|
||||
{
|
||||
role: 'user',
|
||||
content: [{ type: 'text', text: '帮我设计 memory-chain 下一阶段' }],
|
||||
},
|
||||
);
|
||||
|
||||
assert.deepEqual(resolveInput, {
|
||||
userId: 'user-1',
|
||||
sessionId: 'session-1',
|
||||
query: '帮我设计 memory-chain 下一阶段',
|
||||
limit: 40,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user