a3f669d4e7
Signed-off-by: john <cynell@139.com>
370 lines
12 KiB
JavaScript
370 lines
12 KiB
JavaScript
#!/usr/bin/env node
|
|
import assert from 'node:assert/strict';
|
|
import { spawn } from 'node:child_process';
|
|
import fs from 'node:fs/promises';
|
|
import http from 'node:http';
|
|
import path from 'node:path';
|
|
import { createDbPool } from '../db.mjs';
|
|
import { loadH5Environment } from './load-env.mjs';
|
|
|
|
loadH5Environment(import.meta.dirname);
|
|
|
|
const suffix = `${Date.now()}${Math.floor(Math.random() * 1000)}`;
|
|
const fakePort = Number(process.env.MINDSPACE_AGENT_FAKE_PORT ?? 18161);
|
|
const portalPort = Number(process.env.MINDSPACE_AGENT_PORT ?? 18162);
|
|
const baseUrl = `http://127.0.0.1:${portalPort}`;
|
|
const storageRoot = path.join('/tmp', `mindspace-agent-jobs-${suffix}`);
|
|
const users = [
|
|
{
|
|
username: `msaj_owner_${suffix}`,
|
|
email: `msaj-owner-${suffix}@example.test`,
|
|
password: 'MindSpace-Agent-Owner-2026',
|
|
},
|
|
];
|
|
|
|
const eventStreams = new Map();
|
|
const fakeSessions = new Map();
|
|
|
|
function sseWrite(res, data) {
|
|
res.write(`data: ${JSON.stringify(data)}\n\n`);
|
|
}
|
|
|
|
const fakeUpstream = http.createServer(async (req, res) => {
|
|
const url = new URL(req.url ?? '/', `http://${req.headers.host}`);
|
|
const bodyChunks = [];
|
|
for await (const chunk of req) {
|
|
bodyChunks.push(chunk);
|
|
}
|
|
const raw = bodyChunks.length ? Buffer.concat(bodyChunks).toString('utf8') : '';
|
|
const body = raw ? JSON.parse(raw) : null;
|
|
|
|
if (req.method === 'POST' && url.pathname === '/agent/start') {
|
|
const sessionId = `agent-job-session-${suffix}`;
|
|
fakeSessions.set(sessionId, {
|
|
id: sessionId,
|
|
working_dir: body?.working_dir ?? '/tmp',
|
|
extensions: body?.extension_overrides ?? [],
|
|
});
|
|
res.setHeader('Content-Type', 'application/json');
|
|
res.end(JSON.stringify({ id: sessionId, name: 'Agent Job E2E Session' }));
|
|
return;
|
|
}
|
|
|
|
const sessionMatch = url.pathname.match(/^\/sessions\/([^/]+)$/);
|
|
if (req.method === 'GET' && sessionMatch) {
|
|
const session = fakeSessions.get(sessionMatch[1]);
|
|
if (!session) {
|
|
res.statusCode = 404;
|
|
res.end(JSON.stringify({ message: 'session not found' }));
|
|
return;
|
|
}
|
|
res.setHeader('Content-Type', 'application/json');
|
|
res.end(
|
|
JSON.stringify({
|
|
id: session.id,
|
|
working_dir: session.working_dir,
|
|
}),
|
|
);
|
|
return;
|
|
}
|
|
|
|
const extensionMatch = url.pathname.match(/^\/sessions\/([^/]+)\/extensions$/);
|
|
if (req.method === 'GET' && extensionMatch) {
|
|
const session = fakeSessions.get(extensionMatch[1]);
|
|
res.setHeader('Content-Type', 'application/json');
|
|
res.end(JSON.stringify({ extensions: session?.extensions ?? [] }));
|
|
return;
|
|
}
|
|
|
|
if (
|
|
req.method === 'POST' &&
|
|
[
|
|
'/agent/update_working_dir',
|
|
'/agent/update_session',
|
|
'/agent/remove_extension',
|
|
'/agent/add_extension',
|
|
'/agent/restart',
|
|
'/agent/harness_remember',
|
|
'/agent/harness_bootstrap',
|
|
].includes(url.pathname)
|
|
) {
|
|
res.setHeader('Content-Type', 'application/json');
|
|
res.end('{}');
|
|
return;
|
|
}
|
|
|
|
const eventMatch = url.pathname.match(/^\/sessions\/([^/]+)\/events$/);
|
|
if (req.method === 'GET' && eventMatch) {
|
|
const sessionId = eventMatch[1];
|
|
res.writeHead(200, {
|
|
'Content-Type': 'text/event-stream',
|
|
'Cache-Control': 'no-cache',
|
|
Connection: 'keep-alive',
|
|
});
|
|
res.flushHeaders?.();
|
|
sseWrite(res, { type: 'Ping' });
|
|
eventStreams.set(sessionId, res);
|
|
req.on('close', () => {
|
|
if (eventStreams.get(sessionId) === res) {
|
|
eventStreams.delete(sessionId);
|
|
}
|
|
});
|
|
return;
|
|
}
|
|
|
|
const replyMatch = url.pathname.match(/^\/sessions\/([^/]+)\/reply$/);
|
|
if (req.method === 'POST' && replyMatch) {
|
|
const sessionId = replyMatch[1];
|
|
const stream = eventStreams.get(sessionId);
|
|
if (!stream) {
|
|
res.statusCode = 409;
|
|
res.end(JSON.stringify({ message: 'events stream not connected' }));
|
|
return;
|
|
}
|
|
const requestId = body?.request_id;
|
|
const prompt = String(body?.user_message?.content?.[0]?.text ?? '');
|
|
const content = {
|
|
title: prompt.includes('weekly.md') ? 'Agent 生成周报' : 'Agent 页面',
|
|
summary: '根据授权资料生成的草稿',
|
|
content: '# Agent 周报\n\n本周已完成资产分析,并生成了草稿页面。',
|
|
content_format: 'markdown',
|
|
};
|
|
setTimeout(() => {
|
|
sseWrite(stream, {
|
|
type: 'Message',
|
|
request_id: requestId,
|
|
message: {
|
|
id: `assistant-${suffix}`,
|
|
role: 'assistant',
|
|
created: Math.floor(Date.now() / 1000),
|
|
metadata: { userVisible: true, agentVisible: true },
|
|
content: [{ type: 'text', text: JSON.stringify(content) }],
|
|
},
|
|
token_state: {
|
|
inputTokens: 12,
|
|
outputTokens: 20,
|
|
totalTokens: 32,
|
|
accumulatedInputTokens: 12,
|
|
accumulatedOutputTokens: 20,
|
|
accumulatedTotalTokens: 32,
|
|
},
|
|
});
|
|
sseWrite(stream, {
|
|
type: 'Finish',
|
|
request_id: requestId,
|
|
reason: 'completed',
|
|
token_state: {
|
|
inputTokens: 12,
|
|
outputTokens: 20,
|
|
totalTokens: 32,
|
|
accumulatedInputTokens: 12,
|
|
accumulatedOutputTokens: 20,
|
|
accumulatedTotalTokens: 32,
|
|
},
|
|
});
|
|
stream.end();
|
|
eventStreams.delete(sessionId);
|
|
}, 50);
|
|
res.setHeader('Content-Type', 'application/json');
|
|
res.end('{}');
|
|
return;
|
|
}
|
|
|
|
res.statusCode = 404;
|
|
res.setHeader('Content-Type', 'application/json');
|
|
res.end(JSON.stringify({ message: 'not found' }));
|
|
});
|
|
|
|
await new Promise((resolve) => fakeUpstream.listen(fakePort, '127.0.0.1', resolve));
|
|
|
|
const portal = spawn(process.execPath, ['server.mjs'], {
|
|
cwd: path.join(import.meta.dirname, '..'),
|
|
env: {
|
|
...process.env,
|
|
H5_PORT: String(portalPort),
|
|
TKMIND_API_TARGET: `http://127.0.0.1:${fakePort}`,
|
|
MINDSPACE_STORAGE_ROOT: storageRoot,
|
|
MINDSPACE_AGENT_JOBS_ENABLED: 'true',
|
|
},
|
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
});
|
|
|
|
let portalLogs = '';
|
|
portal.stdout.on('data', (chunk) => {
|
|
portalLogs += chunk.toString();
|
|
});
|
|
portal.stderr.on('data', (chunk) => {
|
|
portalLogs += chunk.toString();
|
|
});
|
|
|
|
async function waitForPortal() {
|
|
for (let attempt = 0; attempt < 60; attempt += 1) {
|
|
if (portal.exitCode != null) throw new Error(`Portal 提前退出\n${portalLogs}`);
|
|
try {
|
|
const response = await fetch(`${baseUrl}/auth/status`);
|
|
if (response.ok) return;
|
|
} catch {
|
|
// waiting
|
|
}
|
|
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
}
|
|
throw new Error(`Portal 启动超时\n${portalLogs}`);
|
|
}
|
|
|
|
async function request(pathname, options = {}) {
|
|
const response = await fetch(`${baseUrl}${pathname}`, options);
|
|
const contentType = response.headers.get('content-type') ?? '';
|
|
const body = contentType.includes('application/json')
|
|
? await response.json()
|
|
: await response.text();
|
|
return { response, body };
|
|
}
|
|
|
|
async function registerAndLogin(user) {
|
|
const registration = await request('/auth/register', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ ...user, displayName: user.username }),
|
|
});
|
|
assert.equal(registration.response.status, 200, JSON.stringify(registration.body));
|
|
|
|
const login = await request('/auth/login', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ username: user.username, password: user.password }),
|
|
});
|
|
assert.equal(login.response.status, 200, JSON.stringify(login.body));
|
|
|
|
return {
|
|
id: registration.body.user.id,
|
|
cookie: login.response.headers.get('set-cookie')?.split(';', 1)[0],
|
|
};
|
|
}
|
|
|
|
function authHeaders(cookie) {
|
|
return { Cookie: cookie, 'Content-Type': 'application/json' };
|
|
}
|
|
|
|
async function pollJob(cookie, jobId) {
|
|
let lastJob = null;
|
|
for (let attempt = 0; attempt < 60; attempt += 1) {
|
|
const detail = await request(`/api/mindspace/v1/agent/jobs/${jobId}`, {
|
|
headers: { Cookie: cookie },
|
|
});
|
|
assert.equal(detail.response.status, 200, JSON.stringify(detail.body));
|
|
const job = detail.body.data;
|
|
lastJob = job;
|
|
if (['completed', 'failed', 'cancelled', 'timed_out'].includes(job.status)) {
|
|
return job;
|
|
}
|
|
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
}
|
|
throw new Error(`等待 Agent job 完成超时: ${JSON.stringify(lastJob)}`);
|
|
}
|
|
|
|
const pool = createDbPool();
|
|
|
|
try {
|
|
await waitForPortal();
|
|
const owner = await registerAndLogin(users[0]);
|
|
assert.ok(owner.cookie);
|
|
|
|
const space = await request('/api/mindspace/v1/space', {
|
|
headers: { Cookie: owner.cookie },
|
|
});
|
|
assert.equal(space.response.status, 200, JSON.stringify(space.body));
|
|
const oaCategory = space.body.data.categories.find((category) => category.code === 'oa');
|
|
assert.ok(oaCategory);
|
|
|
|
const source = Buffer.from('# Weekly Notes\n\n- Revenue up\n- Risk controlled\n', 'utf8');
|
|
const createdUpload = await request('/api/mindspace/v1/uploads', {
|
|
method: 'POST',
|
|
headers: authHeaders(owner.cookie),
|
|
body: JSON.stringify({
|
|
category_id: oaCategory.id,
|
|
filename: 'weekly.md',
|
|
size_bytes: source.length,
|
|
declared_mime_type: 'text/markdown',
|
|
}),
|
|
});
|
|
assert.equal(createdUpload.response.status, 201, JSON.stringify(createdUpload.body));
|
|
|
|
const uploadedContent = await request(createdUpload.body.data.uploadUrl, {
|
|
method: 'PUT',
|
|
headers: {
|
|
Cookie: owner.cookie,
|
|
'Content-Type': 'application/octet-stream',
|
|
},
|
|
body: source,
|
|
});
|
|
assert.equal(uploadedContent.response.status, 200, JSON.stringify(uploadedContent.body));
|
|
|
|
const completedUpload = await request(
|
|
`/api/mindspace/v1/uploads/${createdUpload.body.data.id}/complete`,
|
|
{
|
|
method: 'POST',
|
|
headers: authHeaders(owner.cookie),
|
|
body: '{}',
|
|
},
|
|
);
|
|
assert.equal(completedUpload.response.status, 201, JSON.stringify(completedUpload.body));
|
|
const assetId = completedUpload.body.data.id;
|
|
|
|
const createdJob = await request('/api/mindspace/v1/agent/jobs', {
|
|
method: 'POST',
|
|
headers: authHeaders(owner.cookie),
|
|
body: JSON.stringify({
|
|
job_type: 'generate_page',
|
|
instruction: '请根据周报资料生成一个中文周报草稿页面',
|
|
allowed_asset_ids: [assetId],
|
|
output_type: 'page_draft',
|
|
}),
|
|
});
|
|
assert.equal(createdJob.response.status, 201, JSON.stringify(createdJob.body));
|
|
const jobId = createdJob.body.data.id;
|
|
assert.equal(createdJob.body.data.status, 'queued');
|
|
|
|
const started = await request(`/api/mindspace/v1/agent/jobs/${jobId}/run`, {
|
|
method: 'POST',
|
|
headers: authHeaders(owner.cookie),
|
|
body: '{}',
|
|
});
|
|
assert.equal(started.response.status, 202, JSON.stringify(started.body));
|
|
|
|
const finalJob = await pollJob(owner.cookie, jobId);
|
|
assert.equal(finalJob.status, 'completed', JSON.stringify(finalJob));
|
|
assert.ok(finalJob.sessionId, '任务完成后应关联 Agent 会话');
|
|
assert.ok(finalJob.resultPageId, '任务完成后应生成页面草稿');
|
|
|
|
const pageDetail = await request(`/api/mindspace/v1/pages/${finalJob.resultPageId}`, {
|
|
headers: { Cookie: owner.cookie },
|
|
});
|
|
assert.equal(pageDetail.response.status, 200, JSON.stringify(pageDetail.body));
|
|
assert.equal(pageDetail.body.data.title, 'Agent 生成周报');
|
|
assert.match(pageDetail.body.data.content, /Agent 周报/);
|
|
|
|
const draftPages = await request('/api/mindspace/v1/pages?status=draft', {
|
|
headers: { Cookie: owner.cookie },
|
|
});
|
|
assert.equal(draftPages.response.status, 200, JSON.stringify(draftPages.body));
|
|
assert.equal(draftPages.body.data.length, 1);
|
|
|
|
const [billingRows] = await pool.query(
|
|
`SELECT last_input_tokens, last_output_tokens
|
|
FROM h5_session_billing_state
|
|
WHERE agent_session_id = ? AND user_id = ?`,
|
|
[finalJob.sessionId, owner.id],
|
|
);
|
|
assert.equal(Number(billingRows[0]?.last_input_tokens ?? 0), 12, 'Agent 运行后应记录输入 Token');
|
|
assert.equal(Number(billingRows[0]?.last_output_tokens ?? 0), 20, 'Agent 运行后应记录输出 Token');
|
|
|
|
console.log('MindSpace agent jobs E2E passed');
|
|
} finally {
|
|
await pool.query(`DELETE FROM h5_users WHERE username IN (?)`, [[users[0].username]]);
|
|
await pool.end();
|
|
fakeUpstream.close();
|
|
portal.kill('SIGTERM');
|
|
await new Promise((resolve) => portal.once('exit', resolve));
|
|
await fs.rm(storageRoot, { recursive: true, force: true });
|
|
}
|