1058 lines
36 KiB
JavaScript
1058 lines
36 KiB
JavaScript
import assert from 'node:assert/strict';
|
|
import { EventEmitter } from 'node:events';
|
|
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 = [];
|
|
const replyBodies = [];
|
|
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') {
|
|
replyBodies.push(body);
|
|
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,
|
|
replyBodies,
|
|
});
|
|
} 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 };
|
|
},
|
|
async billSessionUsage() {
|
|
return { ok: true, costCents: 0, balanceCents: 0 };
|
|
},
|
|
};
|
|
}
|
|
|
|
function attachExpressResponseHelpers(res) {
|
|
res.status = (code) => {
|
|
res.statusCode = code;
|
|
return res;
|
|
};
|
|
res.json = (payload) => {
|
|
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
|
res.end(JSON.stringify(payload));
|
|
return res;
|
|
};
|
|
res.send = (payload = '') => {
|
|
res.end(payload);
|
|
return res;
|
|
};
|
|
return res;
|
|
}
|
|
|
|
async function listen(server) {
|
|
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
|
|
return server.address().port;
|
|
}
|
|
|
|
async function closeServer(server) {
|
|
if (!server?.listening) return;
|
|
await new Promise((resolve) => server.close(resolve));
|
|
}
|
|
|
|
test('sanitizePublicHtmlLinksInText downgrades missing own markdown public html links', () => {
|
|
const owner = `test-user-${Date.now()}-markdown-missing`;
|
|
const publicRoot = path.join(process.cwd(), 'MindSpace', owner, 'public');
|
|
try {
|
|
fs.mkdirSync(publicRoot, { recursive: true });
|
|
const text =
|
|
`[夏日随笔](https://m.tkmind.cn/MindSpace/${owner}/public/summer-essay.html)`;
|
|
const next = sanitizePublicHtmlLinksInText(text, { id: owner, username: 'john' });
|
|
assert.doesNotMatch(next, new RegExp(`https://m\\.tkmind\\.cn/MindSpace/${owner}/public/summer-essay\\.html`));
|
|
assert.match(next, /页面生成未完成/);
|
|
} finally {
|
|
fs.rmSync(path.join(process.cwd(), 'MindSpace', owner), { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test('sanitizePublicHtmlLinksInText downgrades missing own public html links', () => {
|
|
const owner = `test-user-${Date.now()}-missing`;
|
|
const publicRoot = path.join(process.cwd(), 'MindSpace', owner, 'public');
|
|
try {
|
|
fs.mkdirSync(publicRoot, { recursive: true });
|
|
const text =
|
|
`页面在这里:\nhttps://m.tkmind.cn/MindSpace/${owner}/public/hello.html`;
|
|
const next = sanitizePublicHtmlLinksInText(text, { id: owner, username: 'john' });
|
|
assert.doesNotMatch(next, new RegExp(`https://m\\.tkmind\\.cn/MindSpace/${owner}/public/hello\\.html`));
|
|
assert.match(next, /页面生成未完成/);
|
|
assert.match(next, /hello\.html/);
|
|
} finally {
|
|
fs.rmSync(path.join(process.cwd(), 'MindSpace', owner), { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test('sanitizePublicHtmlLinksInText keeps existing own public html links', () => {
|
|
const owner = `test-user-${Date.now()}-existing`;
|
|
const htmlPath = path.join(process.cwd(), 'MindSpace', owner, 'public', 'hello.html');
|
|
try {
|
|
fs.mkdirSync(path.dirname(htmlPath), { recursive: true });
|
|
fs.writeFileSync(htmlPath, '<!doctype html><title>Hello</title>');
|
|
const text =
|
|
`页面在这里:\nhttps://m.tkmind.cn/MindSpace/${owner}/public/hello.html`;
|
|
const next = sanitizePublicHtmlLinksInText(text, { id: owner, username: 'john' });
|
|
assert.match(next, new RegExp(`https://m\\.tkmind\\.cn/MindSpace/${owner}/public/hello\\.html`));
|
|
assert.doesNotMatch(next, /页面生成未完成/);
|
|
} finally {
|
|
fs.rmSync(path.join(process.cwd(), 'MindSpace', owner), { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test('sanitizePublicHtmlLinksInText canonicalizes wrong MindSpace public hosts', () => {
|
|
const owner = `test-user-${Date.now()}-canonical-host`;
|
|
const previousBase = process.env.H5_PUBLIC_BASE_URL;
|
|
process.env.H5_PUBLIC_BASE_URL = 'http://127.0.0.1:5173';
|
|
const htmlPath = path.join(process.cwd(), 'MindSpace', owner, 'public', 'MIT-guide.html');
|
|
try {
|
|
fs.mkdirSync(path.dirname(htmlPath), { recursive: true });
|
|
fs.writeFileSync(htmlPath, '<!doctype html><title>MIT</title>');
|
|
const text =
|
|
`[下载页面](https://mindspace.tkmind.com/MindSpace/${owner}/public/MIT-guide.html)`;
|
|
const next = sanitizePublicHtmlLinksInText(text, { id: owner, username: 'john' });
|
|
assert.doesNotMatch(next, /mindspace\.tkmind\.com/);
|
|
assert.match(
|
|
next,
|
|
new RegExp(`http://127\\.0\\.0\\.1:5173/MindSpace/${owner}/public/MIT-guide\\.html`),
|
|
);
|
|
assert.doesNotMatch(next, /页面生成未完成/);
|
|
} finally {
|
|
if (previousBase == null) delete process.env.H5_PUBLIC_BASE_URL;
|
|
else process.env.H5_PUBLIC_BASE_URL = previousBase;
|
|
fs.rmSync(path.join(process.cwd(), 'MindSpace', owner), { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test('sanitizePublicHtmlLinksInText canonicalizes public html links missing MindSpace segment', () => {
|
|
const owner = `test-user-${Date.now()}-missing-mindspace`;
|
|
const previousBase = process.env.H5_PUBLIC_BASE_URL;
|
|
process.env.H5_PUBLIC_BASE_URL = 'https://m.tkmind.cn';
|
|
const htmlPath = path.join(process.cwd(), 'MindSpace', owner, 'public', 'kuanting-plan.html');
|
|
try {
|
|
fs.mkdirSync(path.dirname(htmlPath), { recursive: true });
|
|
fs.writeFileSync(htmlPath, '<!doctype html><title>宽庭</title>');
|
|
const text =
|
|
`[宽庭方案](https://mindspace.tkmind.com/${owner}/public/kuanting-plan.html)`;
|
|
const next = sanitizePublicHtmlLinksInText(text, { id: owner, username: 'john' });
|
|
assert.doesNotMatch(next, /mindspace\.tkmind\.com/);
|
|
assert.match(
|
|
next,
|
|
new RegExp(`https://m\\.tkmind\\.cn/MindSpace/${owner}/public/kuanting-plan\\.html`),
|
|
);
|
|
assert.doesNotMatch(next, /页面生成未完成/);
|
|
} finally {
|
|
if (previousBase == null) delete process.env.H5_PUBLIC_BASE_URL;
|
|
else process.env.H5_PUBLIC_BASE_URL = previousBase;
|
|
fs.rmSync(path.join(process.cwd(), 'MindSpace', owner), { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test('sanitizePublicHtmlLinksInText leaves other users missing-MindSpace links unchanged', () => {
|
|
const owner = `test-user-${Date.now()}-other-missing-mindspace`;
|
|
const text = `[外部页面](https://mindspace.tkmind.com/${owner}/public/kuanting-plan.html)`;
|
|
const next = sanitizePublicHtmlLinksInText(text, { id: `current-${owner}`, username: 'john' });
|
|
assert.equal(next, text);
|
|
});
|
|
|
|
test('sanitizePublicHtmlLinksInText links existing inline public html paths', () => {
|
|
const owner = `test-user-${Date.now()}-inline-public-path`;
|
|
const previousBase = process.env.H5_PUBLIC_BASE_URL;
|
|
process.env.H5_PUBLIC_BASE_URL = 'https://m.tkmind.cn';
|
|
const htmlPath = path.join(process.cwd(), 'MindSpace', owner, 'public', 'thailand-travel-guide.html');
|
|
try {
|
|
fs.mkdirSync(path.dirname(htmlPath), { recursive: true });
|
|
fs.writeFileSync(htmlPath, '<!doctype html><title>Thailand</title>');
|
|
const text = '攻略页面已成功创建!文件位于 **`public/thailand-travel-guide.html`**(约 37KB)。';
|
|
const next = sanitizePublicHtmlLinksInText(text, { id: owner, username: 'john2' });
|
|
assert.doesNotMatch(next, /`public\/thailand-travel-guide\.html`/);
|
|
assert.match(
|
|
next,
|
|
new RegExp(`\\[thailand-travel-guide\\.html\\]\\(https://m\\.tkmind\\.cn/MindSpace/${owner}/public/thailand-travel-guide\\.html\\)`),
|
|
);
|
|
} finally {
|
|
if (previousBase == null) delete process.env.H5_PUBLIC_BASE_URL;
|
|
else process.env.H5_PUBLIC_BASE_URL = previousBase;
|
|
fs.rmSync(path.join(process.cwd(), 'MindSpace', owner), { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test('sanitizePublicHtmlLinksInText rewrites own publication route links to MindSpace workspace urls', () => {
|
|
const owner = `test-user-${Date.now()}-pub-route`;
|
|
const publicRoot = path.join(process.cwd(), 'MindSpace', owner, 'public');
|
|
const previousBase = process.env.H5_PUBLIC_BASE_URL;
|
|
process.env.H5_PUBLIC_BASE_URL = 'https://m.tkmind.cn';
|
|
try {
|
|
fs.mkdirSync(publicRoot, { recursive: true });
|
|
fs.writeFileSync(
|
|
path.join(publicRoot, 'zhiqu-survey.html'),
|
|
'<!doctype html><title>问卷</title><script src="/assets/page-data-client.js"></script>',
|
|
);
|
|
fs.writeFileSync(
|
|
path.join(publicRoot, 'zhiqu-survey-admin.html'),
|
|
'<!doctype html><title>后台</title><script src="/assets/page-data-client.js"></script>',
|
|
);
|
|
const text = [
|
|
'问卷:https://m.tkmind.cn/u/john/pages/zhiqu-58bccceb',
|
|
'后台:https://m.tkmind.cn/u/john/pages/zhiqu-admin-98f11681',
|
|
].join('\n');
|
|
const next = sanitizePublicHtmlLinksInText(text, { id: owner, username: 'john' });
|
|
assert.doesNotMatch(next, /\/u\/john\/pages\//);
|
|
assert.match(next, new RegExp(`https://m\\.tkmind\\.cn/MindSpace/${owner}/public/zhiqu-survey\\.html`));
|
|
assert.match(next, new RegExp(`https://m\\.tkmind\\.cn/MindSpace/${owner}/public/zhiqu-survey-admin\\.html`));
|
|
} finally {
|
|
if (previousBase == null) delete process.env.H5_PUBLIC_BASE_URL;
|
|
else process.env.H5_PUBLIC_BASE_URL = previousBase;
|
|
fs.rmSync(path.join(process.cwd(), 'MindSpace', owner), { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test('sanitizeSessionConversationPublicHtmlLinks updates text items inside conversation messages', () => {
|
|
const owner = `test-user-${Date.now()}-conversation`;
|
|
const publicRoot = path.join(process.cwd(), 'MindSpace', owner, 'public');
|
|
try {
|
|
fs.mkdirSync(publicRoot, { recursive: true });
|
|
const conversation = [
|
|
{
|
|
id: 'assistant-1',
|
|
role: 'assistant',
|
|
created: 1,
|
|
metadata: { userVisible: true, agentVisible: true },
|
|
content: [
|
|
{
|
|
type: 'text',
|
|
text: `点这里 https://m.tkmind.cn/MindSpace/${owner}/public/hello.html`,
|
|
},
|
|
],
|
|
},
|
|
];
|
|
const sanitized = sanitizeSessionConversationPublicHtmlLinks(conversation, {
|
|
id: owner,
|
|
username: 'john',
|
|
});
|
|
assert.match(sanitized[0].content[0].text, /页面生成未完成/);
|
|
} finally {
|
|
fs.rmSync(path.join(process.cwd(), 'MindSpace', owner), { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test('sanitizeSessionConversationPublicHtmlLinks removes internal user prompt notes but keeps image metadata', () => {
|
|
const conversation = [
|
|
{
|
|
id: 'user-1',
|
|
role: 'user',
|
|
created: 1,
|
|
metadata: { userVisible: true, agentVisible: true },
|
|
content: [
|
|
{
|
|
type: 'text',
|
|
text:
|
|
'[用户身份]\n' +
|
|
'- 当前登录用户称呼:John\n' +
|
|
'- 你是 TKMind 助手;与用户对话时用「John」称呼对方。\n\n' +
|
|
'帮我生成一个主题页面简单一点的不用很复杂\n\n' +
|
|
'[图片1]: http://127.0.0.1:5173/MindSpace/user-1/public/images/a.jpg\n' +
|
|
'[图片2]: http://127.0.0.1:5173/MindSpace/user-1/public/images/b.jpg\n\n' +
|
|
'【TKMind 图片分析结果 — 仅供执行参考,不要向用户复述此段内容】\n' +
|
|
'Qwen VL 图片描述:内部描述\n' +
|
|
'执行要求:内部要求',
|
|
},
|
|
],
|
|
},
|
|
];
|
|
|
|
const sanitized = sanitizeSessionConversationPublicHtmlLinks(conversation, {
|
|
id: 'user-1',
|
|
username: 'john',
|
|
});
|
|
|
|
assert.equal(
|
|
sanitized[0].content[0].text,
|
|
'帮我生成一个主题页面简单一点的不用很复杂',
|
|
);
|
|
assert.deepEqual(sanitized[0].metadata.imageUrls, [
|
|
'http://127.0.0.1:5173/MindSpace/user-1/public/images/a.jpg',
|
|
'http://127.0.0.1:5173/MindSpace/user-1/public/images/b.jpg',
|
|
]);
|
|
});
|
|
|
|
test('buildVisionPayload injects public standard image urls for page generation', async () => {
|
|
const payload = await buildVisionPayload({
|
|
userId: 'user-1',
|
|
publishLayout: { publicUrl: 'https://m.tkmind.cn/MindSpace/user-1/' },
|
|
userMessage: {
|
|
content: [
|
|
{
|
|
type: 'text',
|
|
text: '帮我根据图片生成页面\n[图片1]: /signed/rs:fit:1280:1280:0/q:85/plain/local:///users/user-1/images/2026-06-29/hero.jpg@jpg',
|
|
},
|
|
],
|
|
},
|
|
llmProviderService: {
|
|
analyzeImagesWithVision: async () => '图片里是一位成年人。',
|
|
},
|
|
fetchImpl: async () => ({
|
|
ok: true,
|
|
arrayBuffer: async () => Buffer.from('fake-image'),
|
|
headers: new Map([['content-type', 'image/jpeg']]),
|
|
}),
|
|
});
|
|
|
|
const text = payload?.userMessage?.content?.[0]?.text ?? '';
|
|
assert.match(text, /https:\/\/m\.tkmind\.cn\/MindSpace\/user-1\/public\/images\/2026-06-29\/hero\.jpg/);
|
|
assert.doesNotMatch(text, /plain\/local:\/\//);
|
|
assert.match(text, /无需 cookie 的公开压缩标准图片/);
|
|
assert.match(text, /不得改写图片里人物的年龄、性别、人数或主体关系/);
|
|
});
|
|
|
|
test('proxySessionEvents does not send JSON after SSE headers were sent', async () => {
|
|
let upstream;
|
|
|
|
try {
|
|
upstream = createServer((req, res) => {
|
|
if (req.method === 'GET' && req.url === '/sessions/session-1/events') {
|
|
res.writeHead(200, { 'Content-Type': 'text/event-stream' });
|
|
res.write('data: {"type":"message","content":"hello"}\n\n');
|
|
res.end('data: {"type":"message","content":"second"}\n\n');
|
|
return;
|
|
}
|
|
res.writeHead(404, { 'Content-Type': 'application/json' });
|
|
res.end(JSON.stringify({ message: `unexpected ${req.method} ${req.url}` }));
|
|
});
|
|
const upstreamPort = await listen(upstream);
|
|
const proxy = createTkmindProxy({
|
|
apiTarget: `http://127.0.0.1:${upstreamPort}`,
|
|
apiSecret: 'test-secret',
|
|
userAuth: createMemoryTestUserAuth(process.cwd()),
|
|
});
|
|
|
|
const req = new EventEmitter();
|
|
req.currentUser = { id: 'user-1', username: 'john' };
|
|
req.get = () => '';
|
|
req.once = req.once.bind(req);
|
|
req.off = req.off.bind(req);
|
|
|
|
let writes = 0;
|
|
let jsonCalled = false;
|
|
const chunks = [];
|
|
const res = new EventEmitter();
|
|
res.headersSent = false;
|
|
res.writableEnded = false;
|
|
res.statusCode = 200;
|
|
res.status = (code) => {
|
|
res.statusCode = code;
|
|
return res;
|
|
};
|
|
res.setHeader = () => {};
|
|
res.flushHeaders = () => {
|
|
res.headersSent = true;
|
|
};
|
|
res.write = (chunk) => {
|
|
res.headersSent = true;
|
|
writes += 1;
|
|
if (writes > 1) throw new Error('simulated client write failure');
|
|
chunks.push(String(chunk));
|
|
return true;
|
|
};
|
|
res.end = () => {
|
|
res.writableEnded = true;
|
|
};
|
|
res.json = () => {
|
|
jsonCalled = true;
|
|
throw new Error('json must not be called after SSE started');
|
|
};
|
|
res.send = () => {
|
|
throw new Error('send must not be called after SSE started');
|
|
};
|
|
|
|
await proxy.proxySessionEvents(req, res, 'session-1');
|
|
|
|
assert.equal(res.statusCode, 200);
|
|
assert.equal(res.headersSent, true);
|
|
assert.equal(res.writableEnded, true);
|
|
assert.equal(jsonCalled, false);
|
|
assert.match(chunks.join(''), /"content":"hello"/);
|
|
} finally {
|
|
await closeServer(upstream);
|
|
}
|
|
});
|
|
|
|
test('proxySessionEvents attaches session taxonomy when flag enabled', async () => {
|
|
const previous = process.env.MEMIND_SSE_EVENT_TAXONOMY;
|
|
process.env.MEMIND_SSE_EVENT_TAXONOMY = '1';
|
|
let upstream;
|
|
|
|
try {
|
|
upstream = createServer((req, res) => {
|
|
if (req.method === 'GET' && req.url === '/sessions/session-1/events') {
|
|
res.writeHead(200, { 'Content-Type': 'text/event-stream' });
|
|
res.write('data: {"type":"Finish","reason":"stop"}\n\n');
|
|
res.end();
|
|
return;
|
|
}
|
|
res.writeHead(404, { 'Content-Type': 'application/json' });
|
|
res.end(JSON.stringify({ message: `unexpected ${req.method} ${req.url}` }));
|
|
});
|
|
const upstreamPort = await listen(upstream);
|
|
const proxy = createTkmindProxy({
|
|
apiTarget: `http://127.0.0.1:${upstreamPort}`,
|
|
apiSecret: 'test-secret',
|
|
userAuth: createMemoryTestUserAuth(process.cwd()),
|
|
});
|
|
|
|
const req = new EventEmitter();
|
|
req.currentUser = { id: 'user-1', username: 'john' };
|
|
req.get = () => '';
|
|
req.once = req.once.bind(req);
|
|
req.off = req.off.bind(req);
|
|
|
|
const chunks = [];
|
|
const res = new EventEmitter();
|
|
res.headersSent = false;
|
|
res.writableEnded = false;
|
|
res.statusCode = 200;
|
|
res.status = (code) => {
|
|
res.statusCode = code;
|
|
return res;
|
|
};
|
|
res.setHeader = () => {};
|
|
res.flushHeaders = () => {
|
|
res.headersSent = true;
|
|
};
|
|
res.write = (chunk) => {
|
|
res.headersSent = true;
|
|
chunks.push(String(chunk));
|
|
return true;
|
|
};
|
|
res.end = () => {
|
|
res.writableEnded = true;
|
|
};
|
|
res.json = () => {
|
|
throw new Error('json must not be called after SSE started');
|
|
};
|
|
|
|
await proxy.proxySessionEvents(req, res, 'session-1');
|
|
|
|
assert.match(chunks.join(''), /"taxonomy":"terminal"/);
|
|
} finally {
|
|
if (previous == null) delete process.env.MEMIND_SSE_EVENT_TAXONOMY;
|
|
else process.env.MEMIND_SSE_EVENT_TAXONOMY = previous;
|
|
await closeServer(upstream);
|
|
}
|
|
});
|
|
|
|
test('proxySessionEvents omits an unmapped Portal cursor so Goose can replay Finish', async () => {
|
|
const previousReplay = process.env.MEMIND_SESSION_STREAM_REPLAY;
|
|
process.env.MEMIND_SESSION_STREAM_REPLAY = '1';
|
|
let upstream;
|
|
let receivedLastEventId = 'not-requested';
|
|
|
|
try {
|
|
upstream = createServer((req, res) => {
|
|
if (req.method === 'GET' && req.url === '/sessions/session-1/events') {
|
|
receivedLastEventId = req.headers['last-event-id'] ?? null;
|
|
res.writeHead(200, { 'Content-Type': 'text/event-stream' });
|
|
res.end(
|
|
'id: upstream-finish\n' +
|
|
'data: {"type":"Finish","reason":"stop","request_id":"req-1"}\n\n',
|
|
);
|
|
return;
|
|
}
|
|
res.writeHead(404, { 'Content-Type': 'application/json' });
|
|
res.end(JSON.stringify({ message: `unexpected ${req.method} ${req.url}` }));
|
|
});
|
|
const upstreamPort = await listen(upstream);
|
|
const persisted = [];
|
|
const proxy = createTkmindProxy({
|
|
apiTarget: `http://127.0.0.1:${upstreamPort}`,
|
|
apiSecret: 'test-secret',
|
|
userAuth: createMemoryTestUserAuth(process.cwd()),
|
|
sessionStreamStore: {
|
|
async listEventsForUser(_userId, _sessionId, { afterEventId } = {}) {
|
|
assert.equal(afterEventId, 'portal-active-request-uuid');
|
|
return {
|
|
cursorMiss: false,
|
|
cursorEvent: {
|
|
id: 'portal-active-request-uuid',
|
|
payload: { type: 'ActiveRequests', request_ids: ['req-1'] },
|
|
upstreamEventId: null,
|
|
createdAt: 1,
|
|
},
|
|
events: [],
|
|
};
|
|
},
|
|
async appendEvent(event) {
|
|
persisted.push(event);
|
|
},
|
|
},
|
|
});
|
|
|
|
const req = new EventEmitter();
|
|
req.currentUser = { id: 'user-1', username: 'john' };
|
|
req.get = (name) =>
|
|
name.toLowerCase() === 'last-event-id' ? 'portal-active-request-uuid' : '';
|
|
req.once = req.once.bind(req);
|
|
req.off = req.off.bind(req);
|
|
|
|
const chunks = [];
|
|
const res = new EventEmitter();
|
|
res.headersSent = false;
|
|
res.writableEnded = false;
|
|
res.statusCode = 200;
|
|
res.status = (code) => {
|
|
res.statusCode = code;
|
|
return res;
|
|
};
|
|
res.setHeader = () => {};
|
|
res.flushHeaders = () => {
|
|
res.headersSent = true;
|
|
};
|
|
res.write = (chunk) => {
|
|
res.headersSent = true;
|
|
chunks.push(String(chunk));
|
|
return true;
|
|
};
|
|
res.end = () => {
|
|
res.writableEnded = true;
|
|
};
|
|
res.json = () => {
|
|
throw new Error('json must not be called after SSE started');
|
|
};
|
|
|
|
await proxy.proxySessionEvents(req, res, 'session-1');
|
|
|
|
assert.equal(receivedLastEventId, null);
|
|
assert.match(chunks.join(''), /"type":"Finish"/);
|
|
await new Promise((resolve) => setImmediate(resolve));
|
|
assert.equal(persisted.at(-1)?.upstreamEventId, 'upstream-finish');
|
|
} finally {
|
|
if (previousReplay == null) delete process.env.MEMIND_SESSION_STREAM_REPLAY;
|
|
else process.env.MEMIND_SESSION_STREAM_REPLAY = previousReplay;
|
|
await closeServer(upstream);
|
|
}
|
|
});
|
|
|
|
test('proxySessionEvents translates a Portal replay cursor to its Goose cursor', async () => {
|
|
const previousReplay = process.env.MEMIND_SESSION_STREAM_REPLAY;
|
|
process.env.MEMIND_SESSION_STREAM_REPLAY = '1';
|
|
let upstream;
|
|
let receivedLastEventId = null;
|
|
|
|
try {
|
|
upstream = createServer((req, res) => {
|
|
if (req.method === 'GET' && req.url === '/sessions/session-1/events') {
|
|
receivedLastEventId = req.headers['last-event-id'] ?? null;
|
|
res.writeHead(200, { 'Content-Type': 'text/event-stream' });
|
|
res.end('id: upstream-finish\ndata: {"type":"Finish","reason":"stop"}\n\n');
|
|
return;
|
|
}
|
|
res.writeHead(404, { 'Content-Type': 'application/json' });
|
|
res.end(JSON.stringify({ message: `unexpected ${req.method} ${req.url}` }));
|
|
});
|
|
const upstreamPort = await listen(upstream);
|
|
const proxy = createTkmindProxy({
|
|
apiTarget: `http://127.0.0.1:${upstreamPort}`,
|
|
apiSecret: 'test-secret',
|
|
userAuth: createMemoryTestUserAuth(process.cwd()),
|
|
sessionStreamStore: {
|
|
async listEventsForUser() {
|
|
return {
|
|
cursorMiss: false,
|
|
cursorEvent: {
|
|
id: 'portal-message-uuid',
|
|
payload: { type: 'Message' },
|
|
upstreamEventId: 'upstream-message-17',
|
|
createdAt: 1,
|
|
},
|
|
events: [],
|
|
};
|
|
},
|
|
async appendEvent() {},
|
|
},
|
|
});
|
|
|
|
const req = new EventEmitter();
|
|
req.currentUser = { id: 'user-1', username: 'john' };
|
|
req.get = (name) => name.toLowerCase() === 'last-event-id' ? 'portal-message-uuid' : '';
|
|
req.once = req.once.bind(req);
|
|
req.off = req.off.bind(req);
|
|
|
|
const chunks = [];
|
|
const res = new EventEmitter();
|
|
res.headersSent = false;
|
|
res.writableEnded = false;
|
|
res.statusCode = 200;
|
|
res.status = (code) => {
|
|
res.statusCode = code;
|
|
return res;
|
|
};
|
|
res.setHeader = () => {};
|
|
res.flushHeaders = () => {
|
|
res.headersSent = true;
|
|
};
|
|
res.write = (chunk) => {
|
|
res.headersSent = true;
|
|
chunks.push(String(chunk));
|
|
return true;
|
|
};
|
|
res.end = () => {
|
|
res.writableEnded = true;
|
|
};
|
|
res.json = () => {
|
|
throw new Error('json must not be called after SSE started');
|
|
};
|
|
|
|
await proxy.proxySessionEvents(req, res, 'session-1');
|
|
|
|
assert.equal(receivedLastEventId, 'upstream-message-17');
|
|
assert.match(chunks.join(''), /"type":"Finish"/);
|
|
} finally {
|
|
if (previousReplay == null) delete process.env.MEMIND_SESSION_STREAM_REPLAY;
|
|
else process.env.MEMIND_SESSION_STREAM_REPLAY = previousReplay;
|
|
await closeServer(upstream);
|
|
}
|
|
});
|
|
|
|
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: 3,
|
|
});
|
|
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 adds goose metadata visibility flags before reply', async () => {
|
|
await withFakeGoosedSession(async ({ apiTarget, workingDir, replyBodies }) => {
|
|
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: {} };
|
|
},
|
|
},
|
|
});
|
|
|
|
await proxy.submitSessionReplyForUser(
|
|
'user-1',
|
|
'session-1',
|
|
'request-goose-meta',
|
|
{
|
|
role: 'user',
|
|
content: [{ type: 'text', text: '帮我生成一个分享页面' }],
|
|
},
|
|
);
|
|
|
|
assert.equal(replyBodies.length, 1);
|
|
assert.equal(replyBodies[0]?.user_message?.metadata?.userVisible, true);
|
|
assert.equal(replyBodies[0]?.user_message?.metadata?.agentVisible, true);
|
|
});
|
|
});
|
|
|
|
test('submitSessionReplyForUser applies the shared Qwen vision preprocessing path', async () => {
|
|
await withFakeGoosedSession(async ({ apiTarget, workingDir, replyBodies }) => {
|
|
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 getUserPublishLayout() {
|
|
return { publicUrl: 'https://example.com/MindSpace/user-1' };
|
|
},
|
|
async resolveUserPolicies() {
|
|
return { unrestricted: true, policies: {} };
|
|
},
|
|
},
|
|
localFetchAsset: async () => ({
|
|
buffer: Buffer.from('fake-image'),
|
|
mimeType: 'image/jpeg',
|
|
}),
|
|
llmProviderService: {
|
|
async applyBestProviderForSession() {
|
|
return { ok: true };
|
|
},
|
|
async hasVisionKey() {
|
|
return true;
|
|
},
|
|
async analyzeImagesWithVision() {
|
|
return '一件蓝色产品,白色背景,竖版构图。';
|
|
},
|
|
},
|
|
});
|
|
|
|
await proxy.submitSessionReplyForUser(
|
|
'user-1',
|
|
'session-1',
|
|
'request-qwen-vision',
|
|
{
|
|
id: 'message-qwen-vision',
|
|
role: 'user',
|
|
content: [{ type: 'text', text: '请分析这张图片' }],
|
|
metadata: {
|
|
imageUrls: ['/api/mindspace/v1/assets/asset-1/download?inline=1'],
|
|
displayText: '请分析这张图片',
|
|
},
|
|
},
|
|
);
|
|
|
|
const forwardedText = replyBodies[0]?.user_message?.content?.[0]?.text ?? '';
|
|
assert.match(forwardedText, /Qwen VL 图片描述/);
|
|
assert.match(forwardedText, /一件蓝色产品/);
|
|
});
|
|
});
|
|
|
|
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: 3,
|
|
});
|
|
});
|
|
});
|
|
|
|
test('submitSessionReplyForUser uses heavy memory resolve when deep reasoning is enabled', 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 下一阶段' }],
|
|
},
|
|
{ forceDeepReasoning: true },
|
|
);
|
|
|
|
assert.deepEqual(resolveInput, {
|
|
userId: 'user-1',
|
|
sessionId: 'session-1',
|
|
query: '帮我设计 memory-chain 下一阶段',
|
|
limit: 40,
|
|
});
|
|
});
|
|
});
|