166 lines
6.8 KiB
TypeScript
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*步/);
|
|
});
|
|
});
|