429 lines
16 KiB
JavaScript
429 lines
16 KiB
JavaScript
import assert from 'node:assert/strict';
|
|
import { request as httpRequest } from 'node:http';
|
|
import { afterEach, describe, it } from 'node:test';
|
|
|
|
import {
|
|
ExternalAgentParser,
|
|
collectSseOutput,
|
|
normalizeParseResult,
|
|
parseJsonObject,
|
|
parseSseBlock
|
|
} from './external-agent-client.mjs';
|
|
import { createBusinessServer } from './server.mjs';
|
|
|
|
const servers = [];
|
|
|
|
afterEach(async () => {
|
|
await Promise.all(servers.splice(0).map((server) => new Promise((resolve) => server.close(resolve))));
|
|
});
|
|
|
|
describe('external parser contract', () => {
|
|
it('creates a task-isolated session and sends the raw input through SSE', async () => {
|
|
const calls = [];
|
|
const fetchImpl = async (url, options) => {
|
|
calls.push({ url, options, body: options.body ? JSON.parse(options.body) : null });
|
|
if (url.endsWith('/api/open/agent-sessions')) {
|
|
return new Response(JSON.stringify({ session_id: 'open_sess_test', status: 'active' }), {
|
|
status: 200,
|
|
headers: { 'content-type': 'application/json' }
|
|
});
|
|
}
|
|
const fragments = [
|
|
'{"status":"agent_parse_passed","blockers":[],"operation":{"action":"team_order_create","data":{"product":{"name":"遇见老挝"}}',
|
|
'}}'
|
|
];
|
|
const sse = fragments.map((fragment) => (
|
|
`event: message.delta\ndata: ${JSON.stringify({ content: fragment })}\n\n`
|
|
)).join('');
|
|
return new Response(sse, {
|
|
status: 200,
|
|
headers: { 'content-type': 'text/event-stream' }
|
|
});
|
|
};
|
|
|
|
const parser = new ExternalAgentParser({
|
|
baseUrl: 'https://superagent.nianxx.cn',
|
|
apiKey: 'df_open_test',
|
|
fetchImpl,
|
|
now: () => new Date('2026-07-12T08:00:00.000Z'),
|
|
csrfToken: 'csrf-test-token'
|
|
});
|
|
const result = await parser.parse({
|
|
taskId: 'TASK-001',
|
|
rawText: '产品名称:遇见老挝',
|
|
receivedAt: '2026-07-12T08:00:00.000Z'
|
|
});
|
|
|
|
assert.equal(result.status, 'agent_parse_passed');
|
|
assert.equal(result.operation.action, 'team_order_create');
|
|
assert.equal(result.operation.data.product.name, '遇见老挝');
|
|
assert.equal(result.operation.submit_mode, 'dry_run');
|
|
assert.equal(result.operation.source.task_id, 'TASK-001');
|
|
assert.equal(result.session_id, 'open_sess_test');
|
|
assert.equal(result.external_request.service, 'external_parse_api');
|
|
assert.equal(result.external_request.transport, 'sse');
|
|
assert.equal(result.external_request.stage, 'stream_completed');
|
|
assert.equal(result.external_request.session_id_present, true);
|
|
assert.equal(result.external_request.event_count, 2);
|
|
assert.equal(calls.length, 2);
|
|
assert.equal(calls[0].options.headers.Authorization, 'Bearer df_open_test');
|
|
assert.equal(calls[0].options.headers['X-CSRF-Token'], 'csrf-test-token');
|
|
assert.equal(calls[0].options.headers.Cookie, 'csrf_token=csrf-test-token');
|
|
assert.deepEqual(calls[0].body, {
|
|
external_subject_id: 'business-task:TASK-001',
|
|
idempotency_key: 'business-parser-session:TASK-001',
|
|
metadata: {
|
|
source: 'business_system',
|
|
module: 'input_parser',
|
|
task_id: 'TASK-001'
|
|
}
|
|
});
|
|
assert.equal(calls[1].url, 'https://superagent.nianxx.cn/api/open/agent-sessions/open_sess_test/messages/stream');
|
|
assert.equal(calls[1].options.headers.Accept, 'text/event-stream');
|
|
assert.equal(calls[1].body.idempotency_key, 'business-parser-message:TASK-001');
|
|
assert.match(calls[1].body.message, /产品名称:遇见老挝/);
|
|
assert.equal(calls[1].body.metadata.session_id, 'open_sess_test');
|
|
});
|
|
|
|
it('returns a blocked result without making a request when the server key is absent', async () => {
|
|
let requestCount = 0;
|
|
const parser = new ExternalAgentParser({
|
|
baseUrl: 'https://superagent.nianxx.cn',
|
|
apiKey: '',
|
|
fetchImpl: async () => {
|
|
requestCount += 1;
|
|
throw new Error('should not be called');
|
|
}
|
|
});
|
|
|
|
const result = await parser.parse({ taskId: 'TASK-002', rawText: '测试输入' });
|
|
|
|
assert.equal(result.status, 'agent_parse_blocked');
|
|
assert.equal(result.error_code, 'external_service_not_configured');
|
|
assert.equal(result.external_request.stage, 'not_configured');
|
|
assert.equal(result.external_request.session_id_present, false);
|
|
assert.equal(requestCount, 0);
|
|
assert.match(result.blockers[0], /DEERFLOW_OPEN_API_KEY/);
|
|
});
|
|
|
|
it('probes the provider health endpoint without creating a session', async () => {
|
|
const calls = [];
|
|
const parser = new ExternalAgentParser({
|
|
baseUrl: 'https://superagent.nianxx.cn',
|
|
apiKey: 'df_open_test',
|
|
fetchImpl: async (url, options) => {
|
|
calls.push({ url, options });
|
|
return new Response(JSON.stringify({ status: 'ok' }), {
|
|
status: 200,
|
|
headers: { 'content-type': 'application/json' }
|
|
});
|
|
}
|
|
});
|
|
|
|
const result = await parser.checkConnection({ timeoutMs: 100 });
|
|
|
|
assert.equal(result.ok, true);
|
|
assert.equal(result.authenticated, null);
|
|
assert.equal(result.authentication_checked, false);
|
|
assert.equal(result.http_status, 200);
|
|
assert.equal(calls.length, 1);
|
|
assert.equal(calls[0].url, 'https://superagent.nianxx.cn/health');
|
|
assert.equal(calls[0].options.method, 'GET');
|
|
assert.equal(calls[0].options.body, undefined);
|
|
assert.equal(calls[0].options.headers.Authorization, 'Bearer df_open_test');
|
|
});
|
|
|
|
it('reports an unhealthy provider endpoint as disconnected', async () => {
|
|
const parser = new ExternalAgentParser({
|
|
baseUrl: 'https://superagent.nianxx.cn',
|
|
apiKey: 'df_open_invalid',
|
|
fetchImpl: async () => new Response(JSON.stringify({ detail: 'unauthorized' }), {
|
|
status: 401,
|
|
headers: { 'content-type': 'application/json' }
|
|
})
|
|
});
|
|
|
|
const result = await parser.checkConnection({ timeoutMs: 100 });
|
|
|
|
assert.equal(result.ok, false);
|
|
assert.equal(result.authenticated, null);
|
|
assert.equal(result.error_code, 'external_health_unhealthy');
|
|
});
|
|
|
|
it('maps external authorization and active-run errors without exposing the key', async () => {
|
|
const parser = new ExternalAgentParser({
|
|
baseUrl: 'https://superagent.nianxx.cn',
|
|
apiKey: 'df_open_secret_value',
|
|
fetchImpl: async () => new Response(JSON.stringify({ detail: 'Missing scope: agent_sessions:message; df_open_secret_value' }), {
|
|
status: 403,
|
|
headers: { 'content-type': 'application/json' }
|
|
})
|
|
});
|
|
|
|
const result = await parser.parse({ taskId: 'TASK-003', rawText: '测试输入' });
|
|
|
|
assert.equal(result.status, 'agent_parse_blocked');
|
|
assert.equal(result.error_code, 'external_forbidden');
|
|
assert.equal(result.external_status, 403);
|
|
assert.match(result.blockers[0], /权限不足/);
|
|
assert.doesNotMatch(JSON.stringify(result), /df_open_secret_value/);
|
|
});
|
|
|
|
it('returns a bounded cancellation result when an SSE body ignores cancel()', async () => {
|
|
const hangingBody = {
|
|
async *[Symbol.asyncIterator]() {
|
|
await new Promise(() => {});
|
|
},
|
|
cancel() {
|
|
return new Promise(() => {});
|
|
}
|
|
};
|
|
const parser = new ExternalAgentParser({
|
|
baseUrl: 'https://superagent.nianxx.cn',
|
|
apiKey: 'df_open_test',
|
|
timeoutMs: 2_000,
|
|
totalTimeoutMs: 2_000,
|
|
fetchImpl: async (url) => {
|
|
if (url.endsWith('/api/open/agent-sessions')) {
|
|
return new Response(JSON.stringify({ session_id: 'open_sess_hanging' }), { status: 200 });
|
|
}
|
|
return { status: 200, ok: true, body: hangingBody };
|
|
}
|
|
});
|
|
const controller = new AbortController();
|
|
const abortTimer = setTimeout(() => controller.abort(), 20);
|
|
const startedAt = Date.now();
|
|
try {
|
|
const result = await parser.parse({ taskId: 'TASK-CANCEL', rawText: '测试输入', signal: controller.signal });
|
|
assert.equal(result.status, 'agent_parse_blocked');
|
|
assert.equal(result.error_code, 'external_cancelled');
|
|
assert.ok(Date.now() - startedAt < 1_500);
|
|
} finally {
|
|
clearTimeout(abortTimer);
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('SSE and result helpers', () => {
|
|
it('parses CRLF SSE blocks and JSON objects wrapped in markdown', () => {
|
|
assert.deepEqual(parseSseBlock('event: run.completed\r\ndata: {"status":"agent_parse_passed"}\r\n'), {
|
|
event: 'run.completed',
|
|
data: { status: 'agent_parse_passed' }
|
|
});
|
|
assert.deepEqual(parseJsonObject('```json\n{"status":"agent_parse_blocked","blockers":[]}\n```'), {
|
|
status: 'agent_parse_blocked',
|
|
blockers: []
|
|
});
|
|
assert.deepEqual(parseSseBlock('event: done\ndata: [DONE]\n\n'), {
|
|
event: 'done',
|
|
data: null,
|
|
done: true
|
|
});
|
|
});
|
|
|
|
it('returns after a terminal SSE event even if the provider keeps the stream open', async () => {
|
|
const body = new ReadableStream({
|
|
start(controller) {
|
|
controller.enqueue(new TextEncoder().encode(
|
|
'event: run.completed\ndata: {"status":"agent_parse_blocked","blockers":["test"]}\n\n'
|
|
));
|
|
}
|
|
});
|
|
let timer;
|
|
try {
|
|
const timeout = new Promise((_, reject) => {
|
|
timer = setTimeout(() => reject(new Error('SSE terminal event was not honored')), 200);
|
|
});
|
|
const collected = await Promise.race([collectSseOutput(body), timeout]);
|
|
assert.equal(collected.eventCount, 1);
|
|
assert.deepEqual(collected.result, { status: 'agent_parse_blocked', blockers: ['test'] });
|
|
} finally {
|
|
clearTimeout(timer);
|
|
await body.cancel().catch(() => {});
|
|
}
|
|
});
|
|
|
|
it('accepts a structured final event and preserves the operation contract', async () => {
|
|
const body = new Response(
|
|
'event: run.completed\ndata: {"status":"agent_parse_passed","blockers":[],"operation":{"action":"team_order_create","data":{}}}\n\n',
|
|
{ headers: { 'content-type': 'text/event-stream' } }
|
|
).body;
|
|
const collected = await collectSseOutput(body);
|
|
const result = normalizeParseResult(collected.result, {
|
|
taskId: 'TASK-004',
|
|
receivedAt: '2026-07-12T08:00:00.000Z',
|
|
sessionId: 'open_sess_test'
|
|
});
|
|
|
|
assert.equal(collected.eventCount, 1);
|
|
assert.equal(result.status, 'agent_parse_passed');
|
|
assert.equal(result.session_id, 'open_sess_test');
|
|
assert.equal(result.operation.source.parser_prompt_version, 'external-profile');
|
|
assert.deepEqual(result.operation.data.attachments, []);
|
|
});
|
|
|
|
it('collects assistant chunks from the documented messages event shape', async () => {
|
|
const events = [
|
|
[{ type: 'human', content: 'ignore this input' }, { type: 'metadata', source: 'run' }],
|
|
[{ type: 'AIMessageChunk', content: '{"status":"agent_parse_passed","operation":' }],
|
|
[{ type: 'AIMessageChunk', content: '{"action":"team_order_create","data":{}}}' }]
|
|
].map((data) => `event: messages\ndata: ${JSON.stringify(data)}\n\n`).join('');
|
|
const collected = await collectSseOutput(new Response(events).body);
|
|
|
|
assert.equal(collected.eventCount, 3);
|
|
assert.equal(parseJsonObject(collected.text).status, 'agent_parse_passed');
|
|
assert.equal(parseJsonObject(collected.text).operation.action, 'team_order_create');
|
|
});
|
|
|
|
it('adapts the currently published flat parser shape into the business operation contract', () => {
|
|
const result = normalizeParseResult({
|
|
order_mode: '团队-单个下单',
|
|
order_nature: '测试',
|
|
product_name: '测试产品',
|
|
departure_date: '2099-01-01',
|
|
adult: 2,
|
|
child_with_bed: 1,
|
|
child_without_bed: 0,
|
|
infant: 0,
|
|
tour_leader: 0,
|
|
op: '测试 OP',
|
|
sales: '测试销售',
|
|
remarks: '仅测试解析',
|
|
test_marker: 'TEST-001'
|
|
}, { taskId: 'TASK-006', receivedAt: '2026-07-12T08:00:00.000Z' });
|
|
|
|
assert.equal(result.status, 'agent_parse_passed');
|
|
assert.equal(result.operation.order_nature, 'test');
|
|
assert.equal(result.operation.data.product.name, '测试产品');
|
|
assert.deepEqual(result.operation.data.departure_dates, ['2099-01-01']);
|
|
assert.equal(result.operation.data.passenger_counts.expected_total, 3);
|
|
assert.equal(result.operation.data.op_user.name, '测试 OP');
|
|
assert.equal(result.operation.data.sales_user.name, '测试销售');
|
|
});
|
|
|
|
it('accepts camelCase flat fields emitted by the external Profile', () => {
|
|
const result = normalizeParseResult({
|
|
action: 'team_order_create',
|
|
orderMode: '团队-单个下单',
|
|
orderNature: '测试',
|
|
productName: '测试产品',
|
|
departureDate: '2099-01-01',
|
|
adults: 1,
|
|
childrenWithBed: 2,
|
|
childrenWithoutBed: 1,
|
|
infants: 0,
|
|
tourLeader: 0,
|
|
operator: '测试 OP',
|
|
salesperson: '测试销售',
|
|
remarks: '仅测试解析'
|
|
}, { taskId: 'TASK-007', receivedAt: '2026-07-12T08:00:00.000Z' });
|
|
|
|
assert.equal(result.status, 'agent_parse_passed');
|
|
assert.equal(result.operation.action, 'team_order_create');
|
|
assert.equal(result.operation.data.passenger_counts.expected_total, 4);
|
|
assert.equal(result.operation.data.op_user.name, '测试 OP');
|
|
assert.equal(result.operation.data.sales_user.name, '测试销售');
|
|
});
|
|
|
|
it('blocks a generic Profile response instead of inventing an operation', () => {
|
|
const result = normalizeParseResult({
|
|
reply: '请补充订单信息',
|
|
intent: 'order_help',
|
|
missingFields: ['product']
|
|
}, { taskId: 'TASK-008', receivedAt: '2026-07-12T08:00:00.000Z' });
|
|
|
|
assert.equal(result.status, 'agent_parse_blocked');
|
|
assert.equal(result.error_code, 'external_result_contract_mismatch');
|
|
assert.deepEqual(result.response_keys, ['reply', 'intent', 'missingFields']);
|
|
assert.equal(result.operation, null);
|
|
});
|
|
});
|
|
|
|
describe('business-system HTTP boundary', () => {
|
|
it('returns the real AI connectivity probe from /api/status', async () => {
|
|
const parser = {
|
|
async checkConnection() {
|
|
return {
|
|
ok: true,
|
|
configured: true,
|
|
reachable: true,
|
|
authenticated: true,
|
|
http_status: 422,
|
|
probe: 'validation_response'
|
|
};
|
|
},
|
|
async parse() {
|
|
throw new Error('not used');
|
|
}
|
|
};
|
|
const server = createBusinessServer({ parser });
|
|
servers.push(server);
|
|
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
|
|
const address = server.address();
|
|
|
|
const response = await requestJson(address.port, 'GET', '/api/status');
|
|
|
|
assert.equal(response.statusCode, 200);
|
|
assert.equal(response.body.ai_configured, true);
|
|
assert.equal(response.body.ai_connected, true);
|
|
assert.equal(response.body.ai_probe.http_status, 422);
|
|
});
|
|
|
|
it('exposes /api/parse and no longer exposes /api/agent routes', async () => {
|
|
const seen = [];
|
|
const parser = {
|
|
async parse(payload) {
|
|
seen.push(payload);
|
|
return { status: 'agent_parse_passed', blockers: [], operation: { action: 'team_order_create', data: {} } };
|
|
}
|
|
};
|
|
const server = createBusinessServer({ parser });
|
|
servers.push(server);
|
|
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
|
|
const address = server.address();
|
|
|
|
const response = await requestJson(address.port, 'POST', '/api/parse', {
|
|
task_id: 'TASK-005',
|
|
raw_text: '测试输入',
|
|
received_at: '2026-07-12T08:00:00.000Z'
|
|
});
|
|
assert.equal(response.statusCode, 200);
|
|
assert.equal(response.body.operation.action, 'team_order_create');
|
|
assert.deepEqual(seen, [{
|
|
rawText: '测试输入',
|
|
taskId: 'TASK-005',
|
|
receivedAt: '2026-07-12T08:00:00.000Z'
|
|
}]);
|
|
|
|
const oldRoute = await requestJson(address.port, 'POST', '/api/agent/parse', {});
|
|
assert.equal(oldRoute.statusCode, 405);
|
|
});
|
|
});
|
|
|
|
function requestJson(port, method, path, body) {
|
|
return new Promise((resolve, reject) => {
|
|
const request = httpRequest({
|
|
host: '127.0.0.1',
|
|
port,
|
|
method,
|
|
path,
|
|
headers: { 'Content-Type': 'application/json' }
|
|
}, (response) => {
|
|
const chunks = [];
|
|
response.on('data', (chunk) => chunks.push(chunk));
|
|
response.on('end', () => {
|
|
const text = Buffer.concat(chunks).toString('utf8');
|
|
let parsed;
|
|
try {
|
|
parsed = text ? JSON.parse(text) : null;
|
|
} catch (error) {
|
|
parsed = text;
|
|
}
|
|
resolve({ statusCode: response.statusCode, body: parsed });
|
|
});
|
|
});
|
|
request.on('error', reject);
|
|
request.end(body === undefined ? undefined : JSON.stringify(body));
|
|
});
|
|
}
|