#!/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 }); }