447 lines
14 KiB
JavaScript
447 lines
14 KiB
JavaScript
|
|
import crypto from 'node:crypto';
|
|||
|
|
import { buildChatSkillPrompt, CHAT_SKILL_DEFINITIONS } from './chat-skills.mjs';
|
|||
|
|
|
|||
|
|
const DEFAULT_SKILL = 'service-integration-smoke';
|
|||
|
|
const RUN_TIMEOUT_MS = 25000;
|
|||
|
|
const POLL_INTERVAL_MS = 500;
|
|||
|
|
const USER_COOKIE = 'tkmind_user_session';
|
|||
|
|
|
|||
|
|
function normalizeJson(value) {
|
|||
|
|
if (!value) return null;
|
|||
|
|
if (typeof value === 'object') return value;
|
|||
|
|
if (typeof value !== 'string') return null;
|
|||
|
|
try {
|
|||
|
|
return JSON.parse(value);
|
|||
|
|
} catch {
|
|||
|
|
return null;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function trimText(value, max = 600) {
|
|||
|
|
const text = String(value ?? '').replace(/\s+/g, ' ').trim();
|
|||
|
|
if (!text) return '';
|
|||
|
|
return text.length > max ? `${text.slice(0, max)}...` : text;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function extractMessageText(message) {
|
|||
|
|
const content = message?.content;
|
|||
|
|
if (typeof content === 'string') return content.trim();
|
|||
|
|
if (!Array.isArray(content)) return String(message?.text ?? '').trim();
|
|||
|
|
return content
|
|||
|
|
.map((item) => {
|
|||
|
|
if (typeof item === 'string') return item;
|
|||
|
|
if (item?.type === 'text') return item.text ?? '';
|
|||
|
|
return '';
|
|||
|
|
})
|
|||
|
|
.join('\n')
|
|||
|
|
.trim();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function stepResult(step) {
|
|||
|
|
return {
|
|||
|
|
key: step.key,
|
|||
|
|
label: step.label,
|
|||
|
|
status: step.status,
|
|||
|
|
message: step.message,
|
|||
|
|
details: step.details ?? null,
|
|||
|
|
};
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function makeIssue(stepKey, severity, title, detail) {
|
|||
|
|
return {
|
|||
|
|
stepKey,
|
|||
|
|
severity,
|
|||
|
|
title,
|
|||
|
|
detail: trimText(detail, 1000),
|
|||
|
|
};
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function cookieHeader(token) {
|
|||
|
|
return `${USER_COOKIE}=${encodeURIComponent(token)}`;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function skillPrompt(skillName) {
|
|||
|
|
const definition = CHAT_SKILL_DEFINITIONS.find((item) => item.skillName === skillName);
|
|||
|
|
if (definition?.promptKey) {
|
|||
|
|
return buildChatSkillPrompt(definition.promptKey, skillName);
|
|||
|
|
}
|
|||
|
|
return `请使用 ${skillName} 技能:`;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function portalJson(fetchImpl, url, { token, method = 'GET', body } = {}) {
|
|||
|
|
const response = await fetchImpl(url, {
|
|||
|
|
method,
|
|||
|
|
headers: {
|
|||
|
|
...(body ? { 'Content-Type': 'application/json' } : {}),
|
|||
|
|
Cookie: cookieHeader(token),
|
|||
|
|
},
|
|||
|
|
body: body ? JSON.stringify(body) : undefined,
|
|||
|
|
});
|
|||
|
|
const text = await response.text().catch(() => '');
|
|||
|
|
let payload = null;
|
|||
|
|
try {
|
|||
|
|
payload = text ? JSON.parse(text) : null;
|
|||
|
|
} catch {
|
|||
|
|
payload = text;
|
|||
|
|
}
|
|||
|
|
return { ok: response.ok, status: response.status, payload };
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
export function createAdminSystemTestService({
|
|||
|
|
pool,
|
|||
|
|
userAuth,
|
|||
|
|
portalBaseUrl = `http://127.0.0.1:${process.env.H5_PORT ?? 8081}`,
|
|||
|
|
fetchImpl = globalThis.fetch,
|
|||
|
|
} = {}) {
|
|||
|
|
if (!pool || !userAuth || !fetchImpl) {
|
|||
|
|
throw new Error('admin system test service requires pool, userAuth, and fetch');
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function getRunById(runId) {
|
|||
|
|
const [rows] = await pool.query(
|
|||
|
|
`SELECT id, user_id, request_id, status, attempts, agent_session_id, error_message, completed_at
|
|||
|
|
FROM h5_agent_runs
|
|||
|
|
WHERE id = ?
|
|||
|
|
LIMIT 1`,
|
|||
|
|
[runId],
|
|||
|
|
);
|
|||
|
|
return rows[0] ?? null;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function getRunEvents(runId) {
|
|||
|
|
const [rows] = await pool.query(
|
|||
|
|
`SELECT event_type, data_json, created_at
|
|||
|
|
FROM h5_agent_run_events
|
|||
|
|
WHERE run_id = ?
|
|||
|
|
ORDER BY created_at ASC`,
|
|||
|
|
[runId],
|
|||
|
|
);
|
|||
|
|
return rows.map((row) => ({
|
|||
|
|
eventType: row.event_type,
|
|||
|
|
data: normalizeJson(row.data_json) ?? row.data_json ?? null,
|
|||
|
|
createdAt: Number(row.created_at ?? 0) || null,
|
|||
|
|
}));
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function getSessionPreview(sessionId) {
|
|||
|
|
if (!sessionId) return null;
|
|||
|
|
const [rows] = await pool.query(
|
|||
|
|
`SELECT messages_json
|
|||
|
|
FROM h5_session_snapshots
|
|||
|
|
WHERE agent_session_id = ?
|
|||
|
|
LIMIT 1`,
|
|||
|
|
[sessionId],
|
|||
|
|
);
|
|||
|
|
const parsed = normalizeJson(rows[0]?.messages_json);
|
|||
|
|
if (!Array.isArray(parsed)) return null;
|
|||
|
|
const assistant = parsed.filter((message) => message?.role === 'assistant');
|
|||
|
|
const latestAssistant = assistant.length ? assistant.at(-1) : null;
|
|||
|
|
return {
|
|||
|
|
messageCount: parsed.length,
|
|||
|
|
assistantPreview: trimText(extractMessageText(latestAssistant), 800),
|
|||
|
|
};
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function waitForTerminalRun(runId, timeoutMs = RUN_TIMEOUT_MS) {
|
|||
|
|
const startedAt = Date.now();
|
|||
|
|
while (Date.now() - startedAt < timeoutMs) {
|
|||
|
|
const run = await getRunById(runId);
|
|||
|
|
if (!run) {
|
|||
|
|
return { run: null, events: [], preview: null };
|
|||
|
|
}
|
|||
|
|
if (['succeeded', 'failed'].includes(run.status)) {
|
|||
|
|
const [events, preview] = await Promise.all([
|
|||
|
|
getRunEvents(runId),
|
|||
|
|
getSessionPreview(run.agent_session_id),
|
|||
|
|
]);
|
|||
|
|
return { run, events, preview };
|
|||
|
|
}
|
|||
|
|
await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS));
|
|||
|
|
}
|
|||
|
|
const run = await getRunById(runId);
|
|||
|
|
const [events, preview] = await Promise.all([
|
|||
|
|
getRunEvents(runId),
|
|||
|
|
getSessionPreview(run?.agent_session_id ?? null),
|
|||
|
|
]);
|
|||
|
|
return { run, events, preview, timedOut: true };
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function cleanupSession(token, sessionId) {
|
|||
|
|
if (!sessionId) return;
|
|||
|
|
try {
|
|||
|
|
await fetchImpl(`${portalBaseUrl}/api/sessions/${encodeURIComponent(sessionId)}`, {
|
|||
|
|
method: 'DELETE',
|
|||
|
|
headers: { Cookie: cookieHeader(token) },
|
|||
|
|
});
|
|||
|
|
} catch {
|
|||
|
|
// best effort
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function runChatProbe(token, text, {
|
|||
|
|
stepKey,
|
|||
|
|
label,
|
|||
|
|
expectDirectChat = false,
|
|||
|
|
expectMemory = false,
|
|||
|
|
} = {}) {
|
|||
|
|
const requestId = crypto.randomUUID();
|
|||
|
|
const submit = await portalJson(fetchImpl, `${portalBaseUrl}/api/agent/runs`, {
|
|||
|
|
token,
|
|||
|
|
method: 'POST',
|
|||
|
|
body: {
|
|||
|
|
request_id: requestId,
|
|||
|
|
user_message: {
|
|||
|
|
id: `admin-system-test-${stepKey}`,
|
|||
|
|
role: 'user',
|
|||
|
|
created: Math.floor(Date.now() / 1000),
|
|||
|
|
content: [{ type: 'text', text }],
|
|||
|
|
metadata: { userVisible: true, agentVisible: true },
|
|||
|
|
},
|
|||
|
|
},
|
|||
|
|
});
|
|||
|
|
if (!submit.ok || !submit.payload?.run?.id) {
|
|||
|
|
return {
|
|||
|
|
step: stepResult({
|
|||
|
|
key: stepKey,
|
|||
|
|
label,
|
|||
|
|
status: 'failed',
|
|||
|
|
message: `提交测试消息失败(HTTP ${submit.status})`,
|
|||
|
|
details: submit.payload,
|
|||
|
|
}),
|
|||
|
|
issues: [makeIssue(stepKey, 'error', '测试消息提交失败', JSON.stringify(submit.payload ?? {}))],
|
|||
|
|
};
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const runId = submit.payload.run.id;
|
|||
|
|
const result = await waitForTerminalRun(runId);
|
|||
|
|
const issues = [];
|
|||
|
|
const routeEvent = result.events.find((event) => event.eventType === 'intent_routed');
|
|||
|
|
const directEvent = result.events.find((event) => event.eventType === 'direct_chat_completed');
|
|||
|
|
const directFailed = result.events.find((event) => event.eventType === 'direct_chat_failed');
|
|||
|
|
const route = routeEvent?.data?.route ?? null;
|
|||
|
|
const memory = routeEvent?.data?.memory ?? null;
|
|||
|
|
const missingDirectChat = expectDirectChat && (!directEvent || route !== 'direct_chat');
|
|||
|
|
const missingMemory = expectMemory && (!memory || Number(memory.itemsUsed ?? 0) <= 0);
|
|||
|
|
const status = result.timedOut || result.run?.status !== 'succeeded' || missingDirectChat
|
|||
|
|
? 'failed'
|
|||
|
|
: missingMemory
|
|||
|
|
? 'warning'
|
|||
|
|
: 'passed';
|
|||
|
|
|
|||
|
|
if (expectDirectChat && route !== 'direct_chat') {
|
|||
|
|
issues.push(makeIssue(stepKey, 'error', '未走直连聊天', `实际 route=${route ?? 'unknown'}`));
|
|||
|
|
}
|
|||
|
|
if (expectDirectChat && !directEvent) {
|
|||
|
|
issues.push(makeIssue(stepKey, 'error', '直连聊天未完成', JSON.stringify(directFailed?.data ?? result.run ?? {})));
|
|||
|
|
}
|
|||
|
|
if (expectMemory && (!memory || Number(memory.itemsUsed ?? 0) <= 0)) {
|
|||
|
|
issues.push(makeIssue(
|
|||
|
|
stepKey,
|
|||
|
|
'warning',
|
|||
|
|
'Memory V2 未命中可用记忆',
|
|||
|
|
JSON.stringify(memory ?? routeEvent?.data ?? {}),
|
|||
|
|
));
|
|||
|
|
}
|
|||
|
|
if (result.run?.status !== 'succeeded') {
|
|||
|
|
issues.push(makeIssue(stepKey, 'error', '聊天任务未成功结束', JSON.stringify(result.run ?? {})));
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
await cleanupSession(token, result.run?.agent_session_id ?? null);
|
|||
|
|
|
|||
|
|
return {
|
|||
|
|
step: stepResult({
|
|||
|
|
key: stepKey,
|
|||
|
|
label,
|
|||
|
|
status,
|
|||
|
|
message: status === 'passed'
|
|||
|
|
? '验证通过'
|
|||
|
|
: status === 'warning'
|
|||
|
|
? '验证完成,但存在待处理问题'
|
|||
|
|
: '验证失败',
|
|||
|
|
details: {
|
|||
|
|
runId,
|
|||
|
|
route,
|
|||
|
|
runStatus: result.run?.status ?? null,
|
|||
|
|
sessionId: result.run?.agent_session_id ?? null,
|
|||
|
|
memory,
|
|||
|
|
assistantPreview: result.preview?.assistantPreview ?? '',
|
|||
|
|
directChatFailed: directFailed?.data ?? null,
|
|||
|
|
timedOut: Boolean(result.timedOut),
|
|||
|
|
},
|
|||
|
|
}),
|
|||
|
|
issues,
|
|||
|
|
};
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function runSkillProbe(token, skillName) {
|
|||
|
|
const prompt = `${skillPrompt(skillName)}请基于当前账号做一次轻量联调,只返回“通过项 / 失败项 / 待确认项”,不要生成页面。`;
|
|||
|
|
const result = await runChatProbe(token, prompt, {
|
|||
|
|
stepKey: 'skill_probe',
|
|||
|
|
label: 'Skill 验证',
|
|||
|
|
expectDirectChat: false,
|
|||
|
|
expectMemory: false,
|
|||
|
|
});
|
|||
|
|
const preview = String(result.step.details?.assistantPreview ?? '');
|
|||
|
|
if (!preview || !/(通过项|失败项|待确认项)/.test(preview)) {
|
|||
|
|
result.step.status = result.step.status === 'failed' ? 'failed' : 'warning';
|
|||
|
|
result.step.message = 'Skill 已执行,但输出不符合预期格式';
|
|||
|
|
result.issues.push(makeIssue(
|
|||
|
|
'skill_probe',
|
|||
|
|
'warning',
|
|||
|
|
'Skill 输出不完整',
|
|||
|
|
preview || JSON.stringify(result.step.details ?? {}),
|
|||
|
|
));
|
|||
|
|
}
|
|||
|
|
return result;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function runSkillValidation({ username, password, skillName = DEFAULT_SKILL } = {}) {
|
|||
|
|
const issues = [];
|
|||
|
|
const steps = [];
|
|||
|
|
const startedAt = Date.now();
|
|||
|
|
const normalizedSkill = String(skillName || DEFAULT_SKILL).trim() || DEFAULT_SKILL;
|
|||
|
|
|
|||
|
|
const loginResult = await userAuth.login({
|
|||
|
|
username: String(username ?? '').trim(),
|
|||
|
|
password: String(password ?? ''),
|
|||
|
|
ip: 'admin-system-test',
|
|||
|
|
now: startedAt,
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
if (!loginResult?.ok) {
|
|||
|
|
return {
|
|||
|
|
ok: false,
|
|||
|
|
startedAt,
|
|||
|
|
finishedAt: Date.now(),
|
|||
|
|
portalBaseUrl,
|
|||
|
|
selectedSkill: normalizedSkill,
|
|||
|
|
account: { username: String(username ?? '').trim() },
|
|||
|
|
steps: [
|
|||
|
|
stepResult({
|
|||
|
|
key: 'login',
|
|||
|
|
label: '账号登录',
|
|||
|
|
status: 'failed',
|
|||
|
|
message: loginResult?.message ?? '登录失败',
|
|||
|
|
}),
|
|||
|
|
],
|
|||
|
|
issues: [makeIssue('login', 'error', '账号密码校验失败', loginResult?.message ?? '登录失败')],
|
|||
|
|
summary: { passed: 0, warnings: 0, failed: 1 },
|
|||
|
|
};
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const token = loginResult.token;
|
|||
|
|
const user = loginResult.user;
|
|||
|
|
steps.push(stepResult({
|
|||
|
|
key: 'login',
|
|||
|
|
label: '账号登录',
|
|||
|
|
status: 'passed',
|
|||
|
|
message: '登录成功',
|
|||
|
|
details: { userId: user.id, username: user.username, displayName: user.displayName ?? null },
|
|||
|
|
}));
|
|||
|
|
|
|||
|
|
try {
|
|||
|
|
const authStatus = await portalJson(fetchImpl, `${portalBaseUrl}/auth/status`, { token });
|
|||
|
|
if (!authStatus.ok || !authStatus.payload?.authenticated) {
|
|||
|
|
steps.push(stepResult({
|
|||
|
|
key: 'auth_status',
|
|||
|
|
label: '会话状态',
|
|||
|
|
status: 'failed',
|
|||
|
|
message: `auth/status 异常(HTTP ${authStatus.status})`,
|
|||
|
|
details: authStatus.payload,
|
|||
|
|
}));
|
|||
|
|
issues.push(makeIssue('auth_status', 'error', '用户会话未生效', JSON.stringify(authStatus.payload ?? {})));
|
|||
|
|
} else {
|
|||
|
|
const grantedSkills = Array.isArray(authStatus.payload.grantedSkills)
|
|||
|
|
? authStatus.payload.grantedSkills
|
|||
|
|
: [];
|
|||
|
|
const hasSelectedSkill = grantedSkills.includes(normalizedSkill);
|
|||
|
|
steps.push(stepResult({
|
|||
|
|
key: 'auth_status',
|
|||
|
|
label: '会话状态',
|
|||
|
|
status: 'passed',
|
|||
|
|
message: 'auth/status 返回正常',
|
|||
|
|
details: {
|
|||
|
|
userId: authStatus.payload.user?.id ?? null,
|
|||
|
|
grantedSkills,
|
|||
|
|
capabilities: authStatus.payload.capabilities ?? null,
|
|||
|
|
},
|
|||
|
|
}));
|
|||
|
|
steps.push(stepResult({
|
|||
|
|
key: 'skill_grant',
|
|||
|
|
label: 'Skill 授权',
|
|||
|
|
status: hasSelectedSkill ? 'passed' : 'warning',
|
|||
|
|
message: hasSelectedSkill
|
|||
|
|
? `账号已授予 ${normalizedSkill}`
|
|||
|
|
: `账号未授予 ${normalizedSkill}`,
|
|||
|
|
details: { selectedSkill: normalizedSkill, grantedSkills },
|
|||
|
|
}));
|
|||
|
|
if (!hasSelectedSkill) {
|
|||
|
|
issues.push(makeIssue(
|
|||
|
|
'skill_grant',
|
|||
|
|
'warning',
|
|||
|
|
'账号未授予目标 skill',
|
|||
|
|
`selected=${normalizedSkill}; granted=${grantedSkills.join(', ')}`,
|
|||
|
|
));
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const greetingProbe = await runChatProbe(token, 'hi', {
|
|||
|
|
stepKey: 'direct_chat',
|
|||
|
|
label: '直连聊天验证',
|
|||
|
|
expectDirectChat: true,
|
|||
|
|
});
|
|||
|
|
steps.push(greetingProbe.step);
|
|||
|
|
issues.push(...greetingProbe.issues);
|
|||
|
|
|
|||
|
|
const memoryProbe = await runChatProbe(token, '你记得我什么?', {
|
|||
|
|
stepKey: 'memory_read',
|
|||
|
|
label: 'Memory V2 读取验证',
|
|||
|
|
expectDirectChat: true,
|
|||
|
|
expectMemory: true,
|
|||
|
|
});
|
|||
|
|
steps.push(memoryProbe.step);
|
|||
|
|
issues.push(...memoryProbe.issues);
|
|||
|
|
|
|||
|
|
const skillProbe = await runSkillProbe(token, normalizedSkill);
|
|||
|
|
steps.push(skillProbe.step);
|
|||
|
|
issues.push(...skillProbe.issues);
|
|||
|
|
} finally {
|
|||
|
|
try {
|
|||
|
|
await fetchImpl(`${portalBaseUrl}/auth/logout`, {
|
|||
|
|
method: 'POST',
|
|||
|
|
headers: { Cookie: cookieHeader(token) },
|
|||
|
|
});
|
|||
|
|
} catch {
|
|||
|
|
// best effort
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const summary = steps.reduce((acc, step) => {
|
|||
|
|
if (step.status === 'passed') acc.passed += 1;
|
|||
|
|
else if (step.status === 'warning') acc.warnings += 1;
|
|||
|
|
else acc.failed += 1;
|
|||
|
|
return acc;
|
|||
|
|
}, { passed: 0, warnings: 0, failed: 0 });
|
|||
|
|
|
|||
|
|
return {
|
|||
|
|
ok: summary.failed === 0,
|
|||
|
|
startedAt,
|
|||
|
|
finishedAt: Date.now(),
|
|||
|
|
portalBaseUrl,
|
|||
|
|
selectedSkill: normalizedSkill,
|
|||
|
|
account: {
|
|||
|
|
userId: user.id,
|
|||
|
|
username: user.username,
|
|||
|
|
displayName: user.displayName ?? null,
|
|||
|
|
},
|
|||
|
|
summary,
|
|||
|
|
steps,
|
|||
|
|
issues,
|
|||
|
|
};
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return {
|
|||
|
|
runSkillValidation,
|
|||
|
|
};
|
|||
|
|
}
|