aps-agent/scripts/pi-runtime.test.mjs

104 lines
4.8 KiB
JavaScript
Raw Permalink Normal View History

/**
* Pi 运行时用例(任务 A3.3)。
*
* 1. 未配置密钥时必须快速失败并给出指引(不是静默挂住)
* 2. 真实跑一轮:需要 APS_PI_INTEGRATION=1 且提供密钥(网络调用,默认跳过)
* —— 密钥有效时断言收到文本;无效时断言错误被归到六分类之一;两者都必须以 turn_end 收尾
*/
import assert from 'node:assert/strict';
import { test } from 'node:test';
import { classifyPiFailure, mapPiEvent } from '@aps/server/agent/pi/runtime';
const integrationEnabled = process.env.APS_PI_INTEGRATION === '1';
const apiKey = process.env.APS_LLM_API_KEY ?? process.env.DEEPSEEK_API_KEY ?? '';
test('未配置模型密钥时快速失败并给出配置指引', async () => {
// 环境变量也算配置来源,这里临时清掉,确保「真的没有密钥」
const saved = process.env.DEEPSEEK_API_KEY;
delete process.env.DEEPSEEK_API_KEY;
try {
const { createPiRuntime } = await import('@aps/server/agent/pi/runtime');
const runtime = createPiRuntime({ provider: 'deepseek', model: 'deepseek-chat', apiKey: '' });
await assert.rejects(
() => runtime.createSession({ sessionId: 'no-key', subject: { userId: 'u', tenantId: 't' }, projectId: null, tools: [] }),
/未配置模型密钥/,
);
} finally {
if (saved !== undefined) process.env.DEEPSEEK_API_KEY = saved;
}
});
test('错误文本按六分类归一', () => {
assert.equal(classifyPiFailure('401: Authentication Fails'), 'forbidden');
assert.equal(classifyPiFailure('429 rate limit exceeded'), 'rate_limited');
assert.equal(classifyPiFailure('connect ETIMEDOUT'), 'unavailable');
assert.equal(classifyPiFailure('something odd'), 'unavailable');
});
test('真实跑一轮:Pi 子进程接入', { skip: !integrationEnabled ? '需要 APS_PI_INTEGRATION=1 与密钥(会真实调用模型)' : false }, async () => {
const { createPiRuntime } = await import('@aps/server/agent/pi/runtime');
const runtime = createPiRuntime({ provider: 'deepseek', model: 'deepseek-chat', apiKey, turnTimeoutMs: 60_000 });
const session = await runtime.createSession({
sessionId: `it-${Date.now()}`,
subject: { userId: 'it', tenantId: 'it' },
projectId: null,
tools: [],
});
const events = [];
try {
for await (const event of session.send('只用一句话回答:1+1 等于几')) {
events.push(event);
if (event.type === 'turn_end') break;
}
} finally {
await session.dispose();
}
const types = events.map((event) => event.type);
assert.ok(types.includes('turn_end'), `必须以 turn_end 收尾,实际:${types.join(',')}`);
const failed = events.find((event) => event.type === 'error');
const text = events.filter((event) => event.type === 'text').map((event) => event.delta).join('');
if (failed) {
assert.ok(
['invalid_argument', 'forbidden', 'conflict', 'rate_limited', 'unavailable', 'unknown_result'].includes(failed.kind),
`错误分类必须合法,实际 ${failed.kind}`,
);
console.log(`(本轮模型调用失败,已按 ${failed.kind} 归一)`);
} else {
assert.ok(text.length > 0 || types.includes('message_end'), `既没有文本也没有错误,事件:${types.join(',')}`);
}
});
test('Pi 事件映射:工具名还原成能力 id,执行号取平台回执里的那个', () => {
const resolve = (toolName) => (toolName === 'fake_echo' ? 'fake.echo' : toolName);
const start = mapPiEvent({ type: 'tool_execution_start', toolCallId: 'call_1', toolName: 'fake_echo', args: { text: '你好' } }, resolve);
assert.deepEqual(start, [{ type: 'tool_call', callId: 'call_1', capabilityId: 'fake.echo', args: { text: '你好' } }]);
const end = mapPiEvent(
{
type: 'tool_execution_end',
toolCallId: 'call_1',
toolName: 'fake_echo',
isError: false,
result: { content: [{ type: 'text', text: '{}' }], details: { executionId: 'exec-9', status: 'succeeded', result: { echo: '你好' } } },
},
resolve,
);
assert.equal(end[0].type, 'tool_result');
assert.equal(end[0].receipt.executionId, 'exec-9', '执行号必须取平台回执里的,而不是 Pi 的 callId');
assert.equal(end[0].receipt.status, 'succeeded');
const failed = mapPiEvent(
{ type: 'tool_execution_end', toolCallId: 'call_2', toolName: 'fake_echo', isError: true, result: { details: { executionId: 'exec-10', status: 'failed', errorKind: 'forbidden' } } },
resolve,
);
assert.equal(failed[0].receipt.status, 'failed');
assert.equal(failed[0].receipt.errorKind, 'forbidden');
const text = mapPiEvent({ type: 'message_update', assistantMessageEvent: { type: 'text_delta', delta: '你好' } }, resolve);
assert.deepEqual(text, [{ type: 'text', delta: '你好' }]);
assert.deepEqual(mapPiEvent({ type: 'agent_settled' }, resolve), [{ type: 'turn_end', reason: 'stop' }]);
});