hololake-system-architecture/product-source/guanghu-knowledge-base/server/persona-agent.test.ts

166 lines
6.8 KiB
TypeScript

import assert from 'node:assert/strict';
import test from 'node:test';
import { createDefaultPersona } from './persona-agent.js';
type MockReply = {
content?: string | null;
tool_calls?: Array<{
id: string;
type: 'function';
function: { name: string; arguments: string };
}>;
};
function reply(message: MockReply): Response {
return new Response(JSON.stringify({ choices: [{ message }] }), {
status: 200,
headers: { 'content-type': 'application/json' },
});
}
function toolCall(id: string, name: string, args: Record<string, unknown> = {}): MockReply {
return {
content: null,
tool_calls: [{
id,
type: 'function',
function: { name, arguments: JSON.stringify(args) },
}],
};
}
function fakeGit(overrides: Record<string, unknown> = {}) {
return {
getRepositoryStatus: async () => ({}),
getChannelState: async () => ({ revision: 0, modules: [{ id: 'HL-MOD-KNOWLEDGE-001', installed: true, mounted: true, order: 0 }] }),
applyChannelPatch: async () => ({ id: 'HL-CHANNEL-RCPT-TEST', after: { revision: 1 } }),
getDoc: async (path: string) => ({
meta: { id: path, title: path, updatedAt: '2026-08-09T00:00:00Z' },
body: '正文',
}),
createDoc: async (path: string) => ({ meta: { id: path } }),
updateDoc: async (path: string) => ({ meta: { id: path } }),
deleteDoc: async () => undefined,
search: async () => [],
getTree: async () => [],
getHistory: async () => [],
...overrides,
} as any;
}
async function withMockModel(
replies: MockReply[],
run: (requests: any[]) => Promise<void>,
): Promise<void> {
const originalFetch = globalThis.fetch;
const previous = {
key: process.env.HOLOLAKE_LLM_KEY,
base: process.env.HOLOLAKE_LLM_BASE,
model: process.env.HOLOLAKE_LLM_MODEL,
};
const requests: any[] = [];
process.env.HOLOLAKE_LLM_KEY = 'local-test-key';
process.env.HOLOLAKE_LLM_BASE = 'http://127.0.0.1:9/v1';
process.env.HOLOLAKE_LLM_MODEL = 'test-model';
globalThis.fetch = (async (_input: string | URL | Request, init?: RequestInit) => {
requests.push(JSON.parse(String(init?.body || '{}')));
const next = replies.shift();
assert.ok(next, 'mock model received more calls than expected');
return reply(next);
}) as typeof fetch;
try {
await run(requests);
assert.equal(replies.length, 0, 'all mock replies should be consumed');
} finally {
globalThis.fetch = originalFetch;
for (const [key, value] of Object.entries(previous)) {
const envName = key === 'key' ? 'HOLOLAKE_LLM_KEY' : key === 'base' ? 'HOLOLAKE_LLM_BASE' : 'HOLOLAKE_LLM_MODEL';
if (value === undefined) delete process.env[envName];
else process.env[envName] = value;
}
}
}
test('read tool protocol keeps assistant tool_calls immediately before tool result', { concurrency: false }, async () => {
await withMockModel([
toolCall('call-list', 'list_documents'),
{ content: '已列出文档。' },
], async requests => {
const agent = createDefaultPersona(fakeGit({ getTree: async () => [{ type: 'file', name: 'INDEX.md', path: 'INDEX.md' }] }));
const result = await agent.chat('列出文档');
assert.equal(result.reply, '已列出文档。');
assert.equal(result.activities[0]?.tool, 'list_documents');
const messages = requests[1].messages;
const assistantIndex = messages.findIndex((message: any) => message.tool_calls?.[0]?.id === 'call-list');
assert.ok(assistantIndex >= 0);
assert.equal(messages[assistantIndex + 1].role, 'tool');
assert.equal(messages[assistantIndex + 1].tool_call_id, 'call-list');
});
});
test('confirmed write receipt never leaks an orphan tool message into the next turn', { concurrency: false }, async () => {
await withMockModel([
toolCall('call-create', 'create_document', { path: 'note.md', title: '笔记', body: '正文' }),
{ content: '创建动作等待确认。' },
{ content: '新的自然语言回合正常。' },
], async requests => {
const agent = createDefaultPersona(fakeGit());
const staged = await agent.chat('创建笔记');
assert.equal(staged.activities[0]?.status, 'pending');
const pending = agent.getPendingActions()[0];
assert.ok(pending);
await agent.confirmAction(pending.id);
const next = await agent.chat('继续');
assert.equal(next.reply, '新的自然语言回合正常。');
const nextTurnMessages = requests[2].messages;
assert.equal(nextTurnMessages.some((message: any) => message.role === 'tool'), false);
});
});
test('channel module changes use the same confirmation gate as document writes', { concurrency: false }, async () => {
let executed = 0;
await withMockModel([
toolCall('call-channel', 'set_channel_module_state', { moduleId: 'HL-MOD-KNOWLEDGE-001', mounted: false }),
{ content: '频道动作等待确认。' },
], async () => {
const agent = createDefaultPersona(fakeGit({
applyChannelPatch: async () => {
executed += 1;
return { id: 'HL-CHANNEL-RCPT-TEST', after: { revision: 1 } };
},
}));
const staged = await agent.chat('收起知识库');
assert.equal(staged.activities[0]?.status, 'pending');
assert.equal(executed, 0);
const pending = agent.getPendingActions()[0];
assert.equal(pending.target, 'HL-MOD-KNOWLEDGE-001');
const receipt = await agent.confirmAction(pending.id);
assert.equal(executed, 1);
assert.match(receipt.output, /HL-CHANNEL-RCPT-TEST/);
});
});
test('persona wake route is resolved and read dynamically without a fixed step count', { concurrency: false }, async () => {
const routePath = 'personas/TCS-0002/WAKE-CURRENT.md';
await withMockModel([
toolCall('call-route', 'find_persona_wake_routes', { query: 'TCS-0002' }),
toolCall('call-read-route', 'read_document', { path: routePath }),
{ content: '已按该人格体当前页面解析出 11 个自定义节点;等待逐项权限核验。' },
], async requests => {
const agent = createDefaultPersona(fakeGit({
search: async (query: string) => query === 'TCS-0002' || query === 'WAKE'
? [{ path: routePath, title: 'TCS-0002 当前唤醒路径', snippet: '人格自定义路线' }]
: [],
getDoc: async () => ({
meta: { id: routePath, title: '当前唤醒路径', updatedAt: '2026-08-09T00:00:00Z' },
body: Array.from({ length: 11 }, (_, index) => `节点 ${index + 1}`).join('\n'),
}),
}));
const result = await agent.chat('唤醒 TCS-0002');
assert.deepEqual(result.toolCalls.map(call => call.name), ['find_persona_wake_routes', 'read_document']);
assert.match(result.reply, /11 个自定义节点/);
const systemPrompt = requests[0].messages[0].content;
assert.match(systemPrompt, /不同人格体的路径和步数不同/);
assert.doesNotMatch(systemPrompt, /固定七步|必须七步|7\s*步/);
});
});