2023 lines
86 KiB
JavaScript
2023 lines
86 KiB
JavaScript
import assert from 'node:assert/strict';
|
||
import { createHash } from 'node:crypto';
|
||
import { request as httpRequest } from 'node:http';
|
||
import { createRequire } from 'node:module';
|
||
import { afterEach, describe, it } from 'node:test';
|
||
|
||
import {
|
||
AGENT_PROMPT_VERSION,
|
||
ExternalAgentParser,
|
||
STANDARD_DIRECTIVE_ROUTES,
|
||
buildParserMessage,
|
||
collectSseOutput,
|
||
normalizeParseResult,
|
||
normalizeApiKey,
|
||
normalizeBaseUrl,
|
||
parseJsonObject,
|
||
parseSseBlock,
|
||
validateAgentOperation,
|
||
validateProgramOperation,
|
||
validateStandardOperation
|
||
} from './external-agent-client.mjs';
|
||
import { createBusinessServer } from './server.mjs';
|
||
|
||
const require = createRequire(import.meta.url);
|
||
const operationPlans = require('../chrome-extension/ltjt-order-assistant/operation-plans.js');
|
||
const servers = [];
|
||
|
||
const CANONICAL_TEAM_OPERATION = {
|
||
action: 'team_order_create',
|
||
order_nature: 'formal',
|
||
submit_mode: 'dry_run',
|
||
data: {
|
||
customer: { name: '测试客户', keyword: '测试客户', source_region: '示例来源' },
|
||
product: { name: '遇见老挝', keyword: '遇见老挝', source_region: '示例来源' },
|
||
departure_dates: ['2026-08-11'],
|
||
passenger_counts: { adult: 1 },
|
||
room_counts: { DBL: 1 }
|
||
}
|
||
};
|
||
|
||
function canonicalPassedResult(operation = CANONICAL_TEAM_OPERATION) {
|
||
return { status: 'agent_parse_passed', blockers: [], operation };
|
||
}
|
||
|
||
function canonicalPassedJson(operation = CANONICAL_TEAM_OPERATION) {
|
||
return JSON.stringify(canonicalPassedResult(operation));
|
||
}
|
||
|
||
afterEach(async () => {
|
||
await Promise.all(servers.splice(0).map((server) => new Promise((resolve) => server.close(resolve))));
|
||
});
|
||
|
||
describe('external parser contract', () => {
|
||
it('validates canonical operation JSON locally without opening an Agent session', async () => {
|
||
let fetchCount = 0;
|
||
const parser = new ExternalAgentParser({
|
||
apiKey: '',
|
||
fetchImpl: async () => {
|
||
fetchCount += 1;
|
||
throw new Error('canonical JSON must not reach the network');
|
||
},
|
||
now: () => new Date('2026-07-12T08:00:00.000Z')
|
||
});
|
||
const result = await parser.parse({
|
||
taskId: 'TASK-CANONICAL-LOCAL',
|
||
rawText: JSON.stringify(CANONICAL_TEAM_OPERATION),
|
||
receivedAt: '2026-07-12T08:00:00.000Z'
|
||
});
|
||
|
||
assert.equal(result.status, 'agent_parse_passed');
|
||
assert.equal(result.parse_mode, 'canonical_json');
|
||
assert.equal(result.operation.action, CANONICAL_TEAM_OPERATION.action);
|
||
assert.deepEqual(result.operation.data.departure_dates, CANONICAL_TEAM_OPERATION.data.departure_dates);
|
||
assert.equal(result.operation.source, undefined);
|
||
assert.equal(result.parser_prompt_version, 'canonical-json-v1');
|
||
assert.equal(Object.hasOwn(result.operation.data, 'passenger_list'), false);
|
||
assert.equal(result.session_id, '');
|
||
assert.equal(result.external_request.service, 'canonical_operation_validator');
|
||
assert.equal(result.external_request.transport, 'local');
|
||
assert.equal(result.external_request.stage, 'canonical_validated');
|
||
assert.equal(result.external_request.canonical_json, true);
|
||
assert.equal(fetchCount, 0);
|
||
});
|
||
|
||
it('rejects invalid canonical operation JSON locally without Agent repair', async () => {
|
||
let fetchCount = 0;
|
||
const parser = new ExternalAgentParser({
|
||
apiKey: 'df_open_test',
|
||
fetchImpl: async () => {
|
||
fetchCount += 1;
|
||
throw new Error('invalid canonical JSON must not reach the network');
|
||
},
|
||
now: () => new Date('2026-07-12T08:00:00.000Z')
|
||
});
|
||
const result = await parser.parse({
|
||
taskId: 'TASK-CANONICAL-INVALID',
|
||
rawText: JSON.stringify({
|
||
...CANONICAL_TEAM_OPERATION,
|
||
data: { ...CANONICAL_TEAM_OPERATION.data, unexpected_field: true }
|
||
})
|
||
});
|
||
|
||
assert.equal(result.status, 'agent_parse_blocked');
|
||
assert.equal(result.parse_mode, 'canonical_json');
|
||
assert.equal(result.error_code, 'external_operation_contract_invalid');
|
||
assert.match(result.validation_errors.join('\n'), /operation\.data 包含未声明字段:unexpected_field/);
|
||
assert.equal(result.external_request.service, 'canonical_operation_validator');
|
||
assert.equal(result.external_request.stage, 'canonical_rejected');
|
||
assert.equal(fetchCount, 0);
|
||
});
|
||
|
||
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 firstPassed = canonicalPassedJson();
|
||
const splitAt = Math.floor(firstPassed.length / 2);
|
||
const fragments = [firstPassed.slice(0, splitAt), firstPassed.slice(splitAt)];
|
||
const sse = fragments.map((fragment) => (
|
||
`event: message.delta\ndata: ${JSON.stringify({ content: fragment })}\n\n`
|
||
)).join('') + 'event: run.completed\ndata: {"status":"completed"}\n\n';
|
||
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, undefined);
|
||
assert.equal(result.parser_prompt_version, AGENT_PROMPT_VERSION);
|
||
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, 3);
|
||
const traceStages = result.external_request.timeline.map((item) => item.stage);
|
||
for (const stage of ['not_started', 'creating_session', 'session_created', 'stream_connecting', 'stream_completed', 'result_received', 'contract_validating']) {
|
||
assert.ok(traceStages.includes(stage), `missing trace stage: ${stage}`);
|
||
}
|
||
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');
|
||
const requestDigest = createHash('sha256').update('business-parser:v2\0TASK-001\0' + '1').digest('hex');
|
||
const sessionDigest = createHash('sha256').update('business-parser-session:v2\0TASK-001').digest('hex');
|
||
assert.deepEqual(calls[0].body, {
|
||
external_subject_id: 'business-task:TASK-001',
|
||
idempotency_key: `business-parser-session:v2:${sessionDigest}`,
|
||
metadata: {
|
||
source: 'business_system',
|
||
module: 'input_parser',
|
||
task_id: 'TASK-001',
|
||
turn_no: 1,
|
||
request_digest: requestDigest
|
||
}
|
||
});
|
||
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:v2:${requestDigest}`);
|
||
assert.match(calls[1].body.message, /产品名称:遇见老挝/);
|
||
assert.equal(calls[1].body.metadata.session_id, 'open_sess_test');
|
||
});
|
||
|
||
it('repairs one contract-invalid response in the same session without dropping unknown fields', async () => {
|
||
const calls = [];
|
||
let streamCount = 0;
|
||
const invalidOperation = {
|
||
...CANONICAL_TEAM_OPERATION,
|
||
data: {
|
||
...CANONICAL_TEAM_OPERATION.data,
|
||
unexpected_field: 'must remain rejected'
|
||
}
|
||
};
|
||
const sseFor = (operation) => new Response([
|
||
`event: message.delta\ndata: ${JSON.stringify({ content: canonicalPassedJson(operation) })}`,
|
||
'event: run.completed\ndata: {"status":"completed"}',
|
||
''
|
||
].join('\n\n'), {
|
||
status: 200,
|
||
headers: { 'content-type': 'text/event-stream' }
|
||
});
|
||
const fetchImpl = async (url, options) => {
|
||
const body = options.body ? JSON.parse(options.body) : null;
|
||
calls.push({ url, body });
|
||
if (url.endsWith('/api/open/agent-sessions')) {
|
||
return new Response(JSON.stringify({ session_id: 'open_sess_contract_repair' }), {
|
||
status: 200,
|
||
headers: { 'content-type': 'application/json' }
|
||
});
|
||
}
|
||
streamCount += 1;
|
||
return sseFor(streamCount === 1 ? invalidOperation : CANONICAL_TEAM_OPERATION);
|
||
};
|
||
|
||
const parser = new ExternalAgentParser({
|
||
baseUrl: 'https://superagent.nianxx.cn',
|
||
apiKey: 'df_open_test',
|
||
fetchImpl,
|
||
now: () => new Date('2026-07-12T08:00:00.000Z')
|
||
});
|
||
const result = await parser.parse({
|
||
taskId: 'TASK-CONTRACT-REPAIR',
|
||
rawText: '散拼团新增计划 产品搜索:老挝广东8D',
|
||
receivedAt: '2026-07-12T08:00:00.000Z'
|
||
});
|
||
|
||
assert.equal(result.status, 'agent_parse_passed');
|
||
assert.equal(result.operation.data.product.name, '遇见老挝');
|
||
assert.equal(result.external_request.contract_repair_count, 1);
|
||
assert.equal(result.external_request.event_count, 4);
|
||
assert.ok(result.external_request.timeline.some((item) => item.stage === 'contract_repairing'));
|
||
assert.equal(calls.length, 3);
|
||
assert.match(calls[1].body.message, /只输出一个标准 JSON/);
|
||
assert.match(calls[2].body.idempotency_key, /^business-parser-contract-repair:v1:/);
|
||
assert.match(calls[2].body.message, /上一轮校验提示:operation\.data 包含未声明字段:unexpected_field。/);
|
||
});
|
||
|
||
it('repairs a stale other/filing item question in the same session', async () => {
|
||
const calls = [];
|
||
let streamCount = 0;
|
||
const sseFor = (result) => new Response([
|
||
`event: message.delta\ndata: ${JSON.stringify({ content: JSON.stringify(result) })}`,
|
||
'event: run.completed\ndata: {"status":"completed"}',
|
||
''
|
||
].join('\n\n'), {
|
||
status: 200,
|
||
headers: { 'content-type': 'text/event-stream' }
|
||
});
|
||
const passed = {
|
||
status: 'agent_parse_passed',
|
||
blockers: [],
|
||
operation: {
|
||
action: 'arrangement_other',
|
||
order_nature: 'formal',
|
||
submit_mode: 'dry_run',
|
||
data: {
|
||
existing_refs: { identifier: 'LW-260903-A' },
|
||
arrangement: { mode: 'create', supplier: { name: '美女', keyword: '美女' }, date: '2026-09-03' }
|
||
}
|
||
}
|
||
};
|
||
const stale = {
|
||
status: 'agent_parse_needs_input',
|
||
blockers: [],
|
||
operation: null,
|
||
reply: '请提供项目/备案说明。',
|
||
missing_fields: ['data.arrangement.item'],
|
||
questions: [{ field: 'data.arrangement.item', prompt: '请提供项目/备案说明。' }]
|
||
};
|
||
const fetchImpl = async (url, options) => {
|
||
const body = options.body ? JSON.parse(options.body) : null;
|
||
calls.push({ url, body });
|
||
if (url.endsWith('/api/open/agent-sessions')) {
|
||
return new Response(JSON.stringify({ session_id: 'open_sess_stale_other_repair' }), {
|
||
status: 200,
|
||
headers: { 'content-type': 'application/json' }
|
||
});
|
||
}
|
||
streamCount += 1;
|
||
return sseFor(streamCount === 1 ? stale : passed);
|
||
};
|
||
const parser = new ExternalAgentParser({
|
||
baseUrl: 'https://superagent.nianxx.cn',
|
||
apiKey: 'df_open_test',
|
||
fetchImpl,
|
||
now: () => new Date('2026-08-14T09:52:00.000Z')
|
||
});
|
||
|
||
const result = await parser.parse({
|
||
taskId: 'TASK-STALE-OTHER-REPAIR',
|
||
rawText: '安排其他/备案\n团号:LW-260903-A\n业务日期:09-03\n结算单位搜索:美女',
|
||
receivedAt: '2026-08-14T09:52:00.000Z'
|
||
});
|
||
|
||
assert.equal(result.status, 'agent_parse_passed');
|
||
assert.equal(result.operation.action, 'arrangement_other');
|
||
assert.equal(result.operation.data.arrangement.item, undefined);
|
||
assert.equal(result.external_request.contract_repair_count, 1);
|
||
assert.equal(calls.length, 3);
|
||
assert.match(calls[1].body.message, /项目\/备案说明(data\.arrangement\.item)和数量均为选填/);
|
||
assert.match(calls[2].body.message, /上一轮校验提示:安排其他\/备案的项目\/备案说明和数量均为选填/);
|
||
});
|
||
|
||
it('repairs a stale modification year question in the same session', async () => {
|
||
const calls = [];
|
||
let streamCount = 0;
|
||
const sseFor = (result) => new Response([
|
||
`event: message.delta\ndata: ${JSON.stringify({ content: JSON.stringify(result) })}`,
|
||
'event: run.completed\ndata: {"status":"completed"}',
|
||
''
|
||
].join('\n\n'), {
|
||
status: 200,
|
||
headers: { 'content-type': 'text/event-stream' }
|
||
});
|
||
const stale = {
|
||
status: 'agent_parse_needs_input', blockers: [], operation: null,
|
||
reply: '请提供出发日期的年份。',
|
||
missing_fields: ['data.departure_dates'],
|
||
questions: [{ field: 'data.departure_dates', prompt: '请提供出发日期的完整年份。' }]
|
||
};
|
||
const passed = {
|
||
status: 'agent_parse_passed', blockers: [],
|
||
operation: {
|
||
action: 'order_update_shared_child', order_nature: 'formal', submit_mode: 'dry_run',
|
||
data: {
|
||
existing_refs: { kind: 'shared_child_order' },
|
||
customer: { name: '辽宁康辉', keyword: '辽宁康辉' },
|
||
departure_dates: ['2026-11-15'],
|
||
updates: { actions: [{ target: 'lodging_note', operation: 'append', value: '领队单住' }] }
|
||
}
|
||
}
|
||
};
|
||
const fetchImpl = async (url, options) => {
|
||
const body = options.body ? JSON.parse(options.body) : null;
|
||
calls.push({ url, body });
|
||
if (url.endsWith('/api/open/agent-sessions')) {
|
||
return new Response(JSON.stringify({ session_id: 'open_sess_stale_update_date' }), {
|
||
status: 200,
|
||
headers: { 'content-type': 'application/json' }
|
||
});
|
||
}
|
||
streamCount += 1;
|
||
return sseFor(streamCount === 1 ? stale : passed);
|
||
};
|
||
const parser = new ExternalAgentParser({
|
||
baseUrl: 'https://superagent.nianxx.cn', apiKey: 'df_open_test', fetchImpl,
|
||
now: () => new Date('2026-08-26T01:22:50.000Z')
|
||
});
|
||
const result = await parser.parse({
|
||
taskId: 'TASK-STALE-UPDATE-DATE',
|
||
rawText: '追加散拼子单订房说明\n预订客户:辽宁康辉\n出发日期:11-15\n追加内容:领队单住',
|
||
receivedAt: '2026-08-26T01:22:50.000Z'
|
||
});
|
||
|
||
assert.equal(result.status, 'agent_parse_passed');
|
||
assert.equal(result.operation.data.departure_dates[0], '2026-11-15');
|
||
assert.equal(result.external_request.contract_repair_count, 1);
|
||
assert.equal(calls.length, 3);
|
||
assert.match(calls[2].body.message, /上一轮校验提示:修改类输入中的无年份月日必须按任务当前日期取下一次发生日/);
|
||
});
|
||
|
||
it('repairs the reported stale restore year question in the same session', async () => {
|
||
const calls = [];
|
||
let streamCount = 0;
|
||
const sseFor = (result) => new Response([
|
||
`event: message.delta\ndata: ${JSON.stringify({ content: JSON.stringify(result) })}`,
|
||
'event: run.completed\ndata: {"status":"completed"}',
|
||
''
|
||
].join('\n\n'), {
|
||
status: 200,
|
||
headers: { 'content-type': 'text/event-stream' }
|
||
});
|
||
const stale = {
|
||
status: 'agent_parse_needs_input', blockers: [], operation: null,
|
||
reply: '恢复订单需要完整出发日期(含年份)。',
|
||
missing_fields: ['data.departure_dates'],
|
||
questions: [{ field: 'data.departure_dates', prompt: '当前仅提供 11-05,请补充年份。' }]
|
||
};
|
||
const passed = {
|
||
status: 'agent_parse_passed', blockers: [],
|
||
operation: {
|
||
action: 'order_restore', order_nature: 'formal', submit_mode: 'dry_run',
|
||
data: {
|
||
customer: { name: '衡阳国旅广东', keyword: '衡阳国旅广东' },
|
||
departure_dates: ['2025-11-05'],
|
||
product: { name: '老挝好时光', keyword: '老挝好时光' },
|
||
transition: { to_status: '预订' }
|
||
}
|
||
}
|
||
};
|
||
const fetchImpl = async (url, options) => {
|
||
const body = options.body ? JSON.parse(options.body) : null;
|
||
calls.push({ url, body });
|
||
if (url.endsWith('/api/open/agent-sessions')) {
|
||
return new Response(JSON.stringify({ session_id: 'open_sess_stale_restore_date' }), {
|
||
status: 200,
|
||
headers: { 'content-type': 'application/json' }
|
||
});
|
||
}
|
||
streamCount += 1;
|
||
return sseFor(streamCount === 1 ? stale : passed);
|
||
};
|
||
const parser = new ExternalAgentParser({
|
||
baseUrl: 'https://superagent.nianxx.cn', apiKey: 'df_open_test', fetchImpl,
|
||
now: () => new Date('2026-08-26T05:09:53.000Z')
|
||
});
|
||
const result = await parser.parse({
|
||
taskId: 'TASK-STALE-RESTORE-DATE',
|
||
rawText: '恢复订单\n预订客户:衡阳国旅广东\n出发日期:11-05\n产品名称:老挝好时光\n恢复状态:预订',
|
||
receivedAt: '2026-08-26T05:09:53.000Z'
|
||
});
|
||
|
||
assert.equal(result.status, 'agent_parse_passed');
|
||
assert.equal(result.operation.action, 'order_restore');
|
||
assert.equal(result.operation.data.departure_dates[0], '2026-11-05');
|
||
assert.equal(result.external_request.contract_repair_count, 1);
|
||
assert.equal(calls.length, 3);
|
||
assert.match(calls[2].body.message, /上一轮校验提示:取消或恢复订单的无年份出发日期必须按任务当前日期取下一次发生日/);
|
||
});
|
||
|
||
it('reports the concrete unknown operation.data keys', () => {
|
||
const result = normalizeParseResult({
|
||
status: 'agent_parse_passed',
|
||
blockers: [],
|
||
operation: {
|
||
action: 'shared_plan_create',
|
||
order_nature: 'formal',
|
||
submit_mode: 'dry_run',
|
||
data: {
|
||
product: { name: '老挝广东8D' },
|
||
departure_dates: ['2026-09-03'],
|
||
planned_capacity: 30,
|
||
room_counts: { TWN: 8 },
|
||
unexpected_field: true
|
||
}
|
||
}
|
||
}, { taskId: 'TASK-UNKNOWN-DATA-KEY', receivedAt: '2026-07-12T08:00:00.000Z' });
|
||
|
||
assert.equal(result.status, 'agent_parse_blocked');
|
||
assert.equal(result.error_code, 'external_operation_contract_invalid');
|
||
assert.match(result.validation_errors.join('\n'), /operation\.data 包含未声明字段:unexpected_field。/);
|
||
assert.equal(result.failure_stage, 'operation_contract_validation');
|
||
assert.equal(result.failure_source, 'contract_validator');
|
||
assert.equal(result.agent_returned, true);
|
||
assert.equal(result.no_plugin_dispatch, true);
|
||
assert.equal(result.no_erp_write, true);
|
||
});
|
||
|
||
it('returns NEED_INPUT without an operation and continues the same session on the next turn', async () => {
|
||
const calls = [];
|
||
let streamCount = 0;
|
||
const sseFor = (result) => new Response([
|
||
`event: message.delta\ndata: ${JSON.stringify({ content: JSON.stringify(result) })}`,
|
||
'event: run.completed\ndata: {"status":"completed"}',
|
||
''
|
||
].join('\n\n'), {
|
||
status: 200,
|
||
headers: { 'content-type': 'text/event-stream' }
|
||
});
|
||
const fetchImpl = async (url, options) => {
|
||
const body = options.body ? JSON.parse(options.body) : null;
|
||
calls.push({ url, body });
|
||
if (url.endsWith('/api/open/agent-sessions')) {
|
||
return new Response(JSON.stringify({ session_id: 'open_sess_follow_up' }), {
|
||
status: 200,
|
||
headers: { 'content-type': 'application/json' }
|
||
});
|
||
}
|
||
streamCount += 1;
|
||
return sseFor(streamCount === 1
|
||
? {
|
||
status: 'agent_parse_needs_input',
|
||
blockers: [],
|
||
operation: { action: 'must-be-removed' },
|
||
reply: '请补充线路产品名称后继续处理。',
|
||
missing_fields: ['data.product'],
|
||
questions: [{ field: 'data.product', prompt: '请补充线路产品名称。' }],
|
||
captured_facts: { 'data.departure_dates': ['2026-08-11'] }
|
||
}
|
||
: {
|
||
status: 'agent_parse_passed',
|
||
blockers: [],
|
||
operation: CANONICAL_TEAM_OPERATION
|
||
});
|
||
};
|
||
const parser = new ExternalAgentParser({
|
||
baseUrl: 'https://superagent.nianxx.cn',
|
||
apiKey: 'df_open_test',
|
||
fetchImpl
|
||
});
|
||
|
||
const first = await parser.parse({
|
||
taskId: 'TASK-FOLLOW-UP',
|
||
turnNo: 1,
|
||
rawText: '出发日期:2026-08-11'
|
||
});
|
||
const second = await parser.parse({
|
||
taskId: 'TASK-FOLLOW-UP',
|
||
turnNo: 2,
|
||
sessionId: first.session_id,
|
||
history: [{ turnNo: 1, rawText: '出发日期:2026-08-11' }],
|
||
rawText: '产品名称:遇见老挝'
|
||
});
|
||
|
||
assert.equal(first.status, 'agent_parse_needs_input');
|
||
assert.equal(first.operation, null);
|
||
assert.deepEqual(first.missing_fields, ['data.product']);
|
||
assert.equal(first.questions[0].field, 'data.product');
|
||
assert.equal(first.reply, '请补充线路产品名称后继续处理。');
|
||
assert.equal(second.status, 'agent_parse_passed');
|
||
assert.equal(second.session_id, 'open_sess_follow_up');
|
||
assert.equal(calls.filter((call) => call.url.endsWith('/api/open/agent-sessions')).length, 1);
|
||
assert.equal(calls.length, 3);
|
||
assert.equal(calls[1].body.metadata.turn_no, 1);
|
||
assert.equal(calls[2].body.metadata.turn_no, 2);
|
||
assert.notEqual(calls[1].body.idempotency_key, calls[2].body.idempotency_key);
|
||
assert.match(calls[2].body.message, /产品名称:遇见老挝/);
|
||
});
|
||
|
||
it('rebuilds an expired session once and replays stored turns before the current turn', async () => {
|
||
const calls = [];
|
||
let streamCount = 0;
|
||
const passed = {
|
||
status: 'agent_parse_passed',
|
||
blockers: [],
|
||
operation: CANONICAL_TEAM_OPERATION
|
||
};
|
||
const sseFor = (result) => new Response([
|
||
`event: message.delta\ndata: ${JSON.stringify({ content: JSON.stringify(result) })}`,
|
||
'event: run.completed\ndata: {"status":"completed"}',
|
||
''
|
||
].join('\n\n'), {
|
||
status: 200,
|
||
headers: { 'content-type': 'text/event-stream' }
|
||
});
|
||
const fetchImpl = async (url, options) => {
|
||
const body = options.body ? JSON.parse(options.body) : null;
|
||
calls.push({ url, body });
|
||
if (url.endsWith('/api/open/agent-sessions')) {
|
||
return new Response(JSON.stringify({ session_id: 'open_sess_recovered' }), {
|
||
status: 200,
|
||
headers: { 'content-type': 'application/json' }
|
||
});
|
||
}
|
||
streamCount += 1;
|
||
if (streamCount === 1) return new Response('', { status: 410 });
|
||
return sseFor(passed);
|
||
};
|
||
const parser = new ExternalAgentParser({
|
||
baseUrl: 'https://superagent.nianxx.cn',
|
||
apiKey: 'df_open_test',
|
||
fetchImpl
|
||
});
|
||
|
||
const result = await parser.parse({
|
||
taskId: 'TASK-RECOVERY',
|
||
turnNo: 2,
|
||
sessionId: 'open_sess_expired',
|
||
history: [{ turnNo: 1, rawText: '历史用户输入:出发日期 2026-08-11' }],
|
||
rawText: '补充产品名称:遇见老挝'
|
||
});
|
||
|
||
assert.equal(result.status, 'agent_parse_passed');
|
||
assert.equal(result.session_id, 'open_sess_recovered');
|
||
assert.equal(result.external_request.recovery_count, 1);
|
||
assert.equal(calls.filter((call) => call.url.endsWith('/api/open/agent-sessions')).length, 1);
|
||
assert.equal(calls.filter((call) => call.url.includes('/messages/stream')).length, 3);
|
||
assert.match(calls[2].body.message, /历史用户输入:出发日期 2026-08-11/);
|
||
assert.equal(calls[2].body.metadata.replay, true);
|
||
assert.equal(calls[3].body.metadata.replay, false);
|
||
|
||
let failedRecoveryStreams = 0;
|
||
const failedRecovery = new ExternalAgentParser({
|
||
baseUrl: 'https://superagent.nianxx.cn',
|
||
apiKey: 'df_open_test',
|
||
fetchImpl: async (url) => {
|
||
if (url.endsWith('/api/open/agent-sessions')) {
|
||
return new Response(JSON.stringify({ session_id: 'open_sess_recovery_failed' }), {
|
||
status: 200,
|
||
headers: { 'content-type': 'application/json' }
|
||
});
|
||
}
|
||
failedRecoveryStreams += 1;
|
||
return new Response('', { status: 410 });
|
||
}
|
||
});
|
||
const failedRecoveryResult = await failedRecovery.parse({
|
||
taskId: 'TASK-RECOVERY-FAILED',
|
||
sessionId: 'open_sess_expired',
|
||
history: [{ turnNo: 1, rawText: '历史输入' }],
|
||
rawText: '当前输入'
|
||
});
|
||
assert.equal(failedRecoveryResult.status, 'agent_parse_blocked');
|
||
assert.equal(failedRecoveryResult.error_code, 'session_recovery_failed');
|
||
assert.equal(failedRecoveryResult.external_request.recovery_count, 1);
|
||
assert.equal(failedRecoveryStreams, 2);
|
||
|
||
const exhausted = new ExternalAgentParser({
|
||
baseUrl: 'https://superagent.nianxx.cn',
|
||
apiKey: 'df_open_test',
|
||
fetchImpl: async (url) => url.endsWith('/messages/stream')
|
||
? new Response('', { status: 410 })
|
||
: new Response(JSON.stringify({ session_id: 'must-not-create' }), {
|
||
status: 200,
|
||
headers: { 'content-type': 'application/json' }
|
||
})
|
||
});
|
||
const exhaustedResult = await exhausted.parse({
|
||
taskId: 'TASK-RECOVERY-EXHAUSTED',
|
||
sessionId: 'open_sess_expired',
|
||
recoveryCount: 1,
|
||
rawText: '测试输入'
|
||
});
|
||
assert.equal(exhaustedResult.status, 'agent_parse_blocked');
|
||
assert.equal(exhaustedResult.error_code, 'external_session_expired');
|
||
assert.equal(exhaustedResult.external_status, 410);
|
||
});
|
||
|
||
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('rejects unsafe provider URLs and non-raw API keys', () => {
|
||
assert.equal(normalizeBaseUrl('http://superagent.nianxx.cn'), '');
|
||
assert.equal(normalizeBaseUrl('https://user:pass@superagent.nianxx.cn'), '');
|
||
assert.equal(normalizeBaseUrl('https://superagent.nianxx.cn?token=leak'), '');
|
||
assert.equal(normalizeBaseUrl('https://superagent.nianxx.cn/#fragment'), '');
|
||
assert.equal(normalizeApiKey('Bearer df_open_test'), '');
|
||
assert.equal(normalizeApiKey(' df_open_test'), '');
|
||
assert.equal(normalizeApiKey('df_open_test\n'), '');
|
||
assert.equal(normalizeApiKey('synthetic-raw-key'), 'synthetic-raw-key');
|
||
});
|
||
|
||
it('retries one rate-limited request with the same stable idempotency key', async () => {
|
||
const calls = [];
|
||
const delays = [];
|
||
let sessionAttempts = 0;
|
||
const sse = [
|
||
'event: message.delta',
|
||
`data: ${JSON.stringify({ content: JSON.stringify({
|
||
status: 'agent_parse_passed',
|
||
blockers: [],
|
||
operation: CANONICAL_TEAM_OPERATION
|
||
}) })}`,
|
||
'',
|
||
'event: run.completed',
|
||
'data: {"status":"completed"}',
|
||
'',
|
||
''
|
||
].join('\n');
|
||
const fetchImpl = async (url, options) => {
|
||
calls.push({ url, body: options.body ? JSON.parse(options.body) : null });
|
||
if (url.endsWith('/api/open/agent-sessions')) {
|
||
sessionAttempts += 1;
|
||
if (sessionAttempts === 1) {
|
||
return new Response('', { status: 429, headers: { 'retry-after': '2' } });
|
||
}
|
||
return new Response(JSON.stringify({ session_id: 'open_sess_retry' }), {
|
||
status: 200,
|
||
headers: { 'content-type': 'application/json' }
|
||
});
|
||
}
|
||
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,
|
||
sleepImpl: async (milliseconds) => delays.push(milliseconds)
|
||
});
|
||
|
||
const result = await parser.parse({ taskId: 'TASK-RETRY', rawText: '测试输入' });
|
||
|
||
assert.equal(result.status, 'agent_parse_passed');
|
||
assert.deepEqual(delays, [2_000]);
|
||
assert.equal(calls.length, 3);
|
||
assert.equal(calls[0].body.idempotency_key, calls[1].body.idempotency_key);
|
||
assert.notEqual(calls[1].body.idempotency_key, calls[2].body.idempotency_key);
|
||
});
|
||
|
||
it('uses one parse-wide rate-limit budget across session and stream', async () => {
|
||
let sessionAttempts = 0;
|
||
let streamAttempts = 0;
|
||
const parser = new ExternalAgentParser({
|
||
baseUrl: 'https://superagent.nianxx.cn',
|
||
apiKey: 'df_open_test',
|
||
sleepImpl: async () => {},
|
||
fetchImpl: async (url) => {
|
||
if (url.endsWith('/api/open/agent-sessions')) {
|
||
sessionAttempts += 1;
|
||
return sessionAttempts === 1
|
||
? new Response('', { status: 429 })
|
||
: new Response(JSON.stringify({ session_id: 'open_sess_budget' }), {
|
||
status: 200,
|
||
headers: { 'content-type': 'application/json' }
|
||
});
|
||
}
|
||
streamAttempts += 1;
|
||
return new Response('', { status: 429 });
|
||
}
|
||
});
|
||
|
||
const result = await parser.parse({ taskId: 'TASK-RETRY-BUDGET', rawText: '测试输入' });
|
||
|
||
assert.equal(result.status, 'agent_parse_blocked');
|
||
assert.equal(result.error_code, 'external_rate_limited');
|
||
assert.equal(sessionAttempts, 2);
|
||
assert.equal(streamAttempts, 1);
|
||
});
|
||
|
||
it('redacts arbitrary provider errors, API keys and raw input', async () => {
|
||
const rawInput = 'SYNTHETIC-RAW-INPUT-MUST-NOT-LEAK';
|
||
const parser = new ExternalAgentParser({
|
||
baseUrl: 'https://superagent.nianxx.cn',
|
||
apiKey: 'synthetic-raw-key',
|
||
fetchImpl: async () => {
|
||
throw new Error(`synthetic-raw-key:${rawInput}:upstream-detail`);
|
||
}
|
||
});
|
||
|
||
const result = await parser.parse({ taskId: 'TASK-REDACT', rawText: rawInput });
|
||
const serialized = JSON.stringify(result);
|
||
|
||
assert.equal(result.status, 'agent_parse_blocked');
|
||
assert.doesNotMatch(serialized, /synthetic-raw-key/);
|
||
assert.doesNotMatch(serialized, /SYNTHETIC-RAW-INPUT-MUST-NOT-LEAK/);
|
||
assert.doesNotMatch(serialized, /upstream-detail/);
|
||
});
|
||
|
||
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,
|
||
headers: { 'content-type': 'application/json' }
|
||
});
|
||
}
|
||
return {
|
||
status: 200,
|
||
ok: true,
|
||
body: hangingBody,
|
||
headers: new Headers({ 'content-type': 'text/event-stream' })
|
||
};
|
||
}
|
||
});
|
||
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: ${canonicalPassedJson()}\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, undefined);
|
||
assert.equal(result.parser_prompt_version, AGENT_PROMPT_VERSION);
|
||
assert.equal(Object.hasOwn(result.operation.data, 'attachments'), false);
|
||
assert.equal(Object.hasOwn(result.operation.data, 'passenger_list'), false);
|
||
assert.equal(Object.hasOwn(result.operation.data, 'system_defaults'), false);
|
||
});
|
||
|
||
it('rejects provider-supplied runtime metadata from natural-language results', () => {
|
||
const operation = structuredClone(CANONICAL_TEAM_OPERATION);
|
||
operation.source = { parser_prompt_version: 'provider-controlled-stale-version' };
|
||
const result = normalizeParseResult(canonicalPassedResult(operation), {
|
||
taskId: 'TASK-PROMPT-VERSION',
|
||
receivedAt: '2026-08-12T08:00:00.000Z'
|
||
});
|
||
|
||
assert.equal(result.status, 'agent_parse_blocked');
|
||
assert.equal(result.error_code, 'external_operation_contract_invalid');
|
||
assert.match(result.validation_errors.join('\n'), /operation 包含未声明字段:source/);
|
||
});
|
||
|
||
it('collects assistant chunks from the documented messages event shape', async () => {
|
||
const canonicalJson = canonicalPassedJson();
|
||
const splitAt = Math.floor(canonicalJson.length / 2);
|
||
const events = [
|
||
[{ type: 'human', content: 'ignore this input' }, { type: 'metadata', source: 'run' }],
|
||
[{ type: 'AIMessageChunk', content: canonicalJson.slice(0, splitAt) }],
|
||
[{ type: 'AIMessageChunk', content: canonicalJson.slice(splitAt) }]
|
||
].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('handles UTF-8 byte boundaries and CRLF while requiring a terminal event', async () => {
|
||
const outer = canonicalPassedResult();
|
||
const text = [
|
||
'event: messages',
|
||
`data: ${JSON.stringify([{ type: 'AIMessage', content: JSON.stringify(outer) }])}`,
|
||
'',
|
||
'event: run.completed',
|
||
'data: {"status":"completed"}',
|
||
'',
|
||
''
|
||
].join('\r\n');
|
||
const bytes = new TextEncoder().encode(text);
|
||
const chineseByte = bytes.findIndex((value) => value > 127);
|
||
const body = new ReadableStream({
|
||
start(controller) {
|
||
controller.enqueue(bytes.slice(0, chineseByte + 1));
|
||
controller.enqueue(bytes.slice(chineseByte + 1, chineseByte + 2));
|
||
controller.enqueue(bytes.slice(chineseByte + 2));
|
||
controller.close();
|
||
}
|
||
});
|
||
|
||
const collected = await collectSseOutput(body, { requireTerminal: true });
|
||
|
||
assert.equal(collected.terminal, true);
|
||
assert.deepEqual(collected.result, outer);
|
||
});
|
||
|
||
it('fails closed on oversized, incomplete and conflicting SSE output', async (t) => {
|
||
const parserFor = (stream, responseLimits = {}) => new ExternalAgentParser({
|
||
baseUrl: 'https://superagent.nianxx.cn',
|
||
apiKey: 'df_open_test',
|
||
responseLimits,
|
||
fetchImpl: async (url) => url.endsWith('/api/open/agent-sessions')
|
||
? new Response(JSON.stringify({ session_id: 'open_sess_sse' }), {
|
||
status: 200,
|
||
headers: { 'content-type': 'application/json' }
|
||
})
|
||
: new Response(stream, {
|
||
status: 200,
|
||
headers: { 'content-type': 'text/event-stream' }
|
||
})
|
||
});
|
||
|
||
await t.test('oversized event', async () => {
|
||
const result = await parserFor(
|
||
`event: messages\ndata: ${'x'.repeat(80)}\n\n`,
|
||
{ max_event_bytes: 32, max_total_bytes: 64, max_events: 2 }
|
||
).parse({ taskId: 'TASK-LIMIT', rawText: '测试输入' });
|
||
assert.equal(result.error_code, 'external_malformed_response');
|
||
assert.equal(result.operation, null);
|
||
});
|
||
|
||
await t.test('event count limit', async () => {
|
||
const stream = [
|
||
'event: ping',
|
||
'data: {}',
|
||
'',
|
||
'event: ping',
|
||
'data: {}',
|
||
'',
|
||
'event: run.completed',
|
||
'data: {"status":"completed"}',
|
||
'',
|
||
''
|
||
].join('\n');
|
||
const result = await parserFor(stream, {
|
||
max_event_bytes: 256,
|
||
max_total_bytes: 1_024,
|
||
max_events: 2
|
||
}).parse({ taskId: 'TASK-EVENT-COUNT', rawText: '测试输入' });
|
||
assert.equal(result.error_code, 'external_malformed_response');
|
||
assert.equal(result.operation, null);
|
||
});
|
||
|
||
await t.test('missing terminal event', async () => {
|
||
const outer = JSON.stringify({ status: 'agent_parse_passed', blockers: [], operation: { action: 'team_order_create', data: {} } });
|
||
const result = await parserFor(
|
||
`event: messages\ndata: ${JSON.stringify([{ type: 'AIMessage', content: outer }])}\n\n`
|
||
).parse({ taskId: 'TASK-NO-TERMINAL', rawText: '测试输入' });
|
||
assert.equal(result.error_code, 'external_malformed_response');
|
||
assert.equal(result.operation, null);
|
||
});
|
||
|
||
await t.test('conflicting candidates', async () => {
|
||
const passed = JSON.stringify({ status: 'agent_parse_passed', blockers: [], operation: { action: 'team_order_create', data: {} } });
|
||
const blocked = JSON.stringify({ status: 'agent_parse_blocked', blockers: ['conflict'] });
|
||
const stream = [
|
||
'event: messages',
|
||
`data: ${JSON.stringify([{ type: 'AIMessage', content: passed }])}`,
|
||
'',
|
||
'event: message.delta',
|
||
`data: ${JSON.stringify({ content: blocked })}`,
|
||
'',
|
||
'event: run.completed',
|
||
'data: {"status":"completed"}',
|
||
'',
|
||
''
|
||
].join('\n');
|
||
const result = await parserFor(stream).parse({ taskId: 'TASK-CONFLICT', rawText: '测试输入' });
|
||
assert.equal(result.error_code, 'external_malformed_response');
|
||
assert.equal(result.operation, null);
|
||
});
|
||
|
||
await t.test('wrong session content type', async () => {
|
||
const parser = new ExternalAgentParser({
|
||
baseUrl: 'https://superagent.nianxx.cn',
|
||
apiKey: 'df_open_test',
|
||
fetchImpl: async (url) => url.endsWith('/api/open/agent-sessions')
|
||
? new Response('{"session_id":"open_sess_wrong_type"}', {
|
||
status: 200,
|
||
headers: { 'content-type': 'text/plain' }
|
||
})
|
||
: new Response('')
|
||
});
|
||
const result = await parser.parse({ taskId: 'TASK-WRONG-TYPE', rawText: '测试输入' });
|
||
assert.equal(result.error_code, 'external_malformed_response');
|
||
assert.equal(result.operation, null);
|
||
});
|
||
});
|
||
|
||
it('blocks the currently published flat parser shape instead of adapting it', () => {
|
||
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_blocked');
|
||
assert.equal(result.error_code, 'external_result_contract_mismatch');
|
||
assert.equal(result.operation, null);
|
||
});
|
||
|
||
it('blocks 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_blocked');
|
||
assert.equal(result.error_code, 'external_result_contract_mismatch');
|
||
assert.equal(result.operation, null);
|
||
});
|
||
|
||
it('blocks the malformed formal batch payload that previously reached execution', () => {
|
||
const result = normalizeParseResult({
|
||
status: 'agent_parse_passed',
|
||
blockers: [],
|
||
operation: {
|
||
action: 'team_order_batch_create',
|
||
order_nature: 'formal',
|
||
submit_mode: 'dry_run',
|
||
data: {
|
||
product: '老挝好时光',
|
||
departure_dates: ['2026-08-03', '2026-08-05', '2026-09-30'],
|
||
recurrence: { start_date: '2026-08-01', end_date: '2026-09-30' },
|
||
passenger_counts: { adult: 15, leader: 1 },
|
||
room_counts: { TWN: 8, SGL: 1 },
|
||
customer: '衡阳广东'
|
||
}
|
||
}
|
||
}, { taskId: 'TASK-20260806021139-70Q8b_4', receivedAt: '2026-08-06T02:11:39.000Z' });
|
||
|
||
assert.equal(result.status, 'agent_parse_blocked');
|
||
assert.equal(result.error_code, 'external_operation_contract_invalid');
|
||
assert.equal(result.operation, null);
|
||
assert.match(result.blockers.join('\n'), /operation\.data\.product/);
|
||
assert.match(result.blockers.join('\n'), /operation\.data\.customer/);
|
||
});
|
||
|
||
it('rolls yearless newbooking dates to the next occurrence using the task date', () => {
|
||
const operation = {
|
||
action: 'shared_plan_create',
|
||
order_nature: 'formal',
|
||
submit_mode: 'dry_run',
|
||
data: {
|
||
product: { name: '广东直飞--早对早计划', keyword: '广东直飞--早对早计划' },
|
||
departure_dates: ['2026-01-07', '2026-01-15', '2026-01-26'],
|
||
recurrence: { start_date: '2026-01-01', end_date: '2026-01-30', pattern: 'specified_dates' },
|
||
planned_capacity: 10,
|
||
room_counts: { TWN: 8 }
|
||
}
|
||
};
|
||
const result = normalizeParseResult(canonicalPassedResult(operation), {
|
||
taskId: 'TASK-NEXT-DATE-ROLLOVER',
|
||
receivedAt: '2026-08-18T04:30:00.000Z',
|
||
rawText: '散拼团新增计划\n发团日期:01-01到01-30\n发团周期:01.07/01.15/01.26'
|
||
});
|
||
|
||
assert.equal(result.status, 'agent_parse_passed');
|
||
assert.deepEqual(result.operation.data.departure_dates, ['2027-01-07', '2027-01-15', '2027-01-26']);
|
||
assert.deepEqual(result.operation.data.recurrence, {
|
||
start_date: '2027-01-01',
|
||
end_date: '2027-01-30',
|
||
pattern: 'specified_dates'
|
||
});
|
||
});
|
||
|
||
it('uses the current year for a future yearless newbooking date', () => {
|
||
const operation = {
|
||
action: 'team_order_batch_create',
|
||
order_nature: 'formal',
|
||
submit_mode: 'dry_run',
|
||
data: {
|
||
customer: { name: '测试客户', keyword: '测试客户' },
|
||
product: { name: '测试产品', keyword: '测试产品' },
|
||
departure_dates: ['2026-09-07', '2026-09-15'],
|
||
recurrence: { start_date: '2026-09-01', end_date: '2026-09-30', pattern: 'specified_dates' },
|
||
passenger_counts: { adult: 15, leader: 1 },
|
||
room_counts: { TWN: 8 }
|
||
}
|
||
};
|
||
const result = normalizeParseResult(canonicalPassedResult(operation), {
|
||
taskId: 'TASK-NEXT-DATE-CURRENT-YEAR',
|
||
receivedAt: '2026-08-18T04:30:00.000Z',
|
||
rawText: '独立团批量下单\n发团日期:09-01到09-30\n发团周期:09.07/09.15'
|
||
});
|
||
|
||
assert.equal(result.status, 'agent_parse_passed');
|
||
assert.deepEqual(result.operation.data.departure_dates, ['2026-09-07', '2026-09-15']);
|
||
assert.equal(result.operation.data.recurrence.start_date, '2026-09-01');
|
||
assert.equal(result.operation.data.recurrence.end_date, '2026-09-30');
|
||
});
|
||
|
||
it('does not rewrite explicitly year-qualified newbooking dates', () => {
|
||
const operation = {
|
||
action: 'shared_plan_create',
|
||
order_nature: 'formal',
|
||
submit_mode: 'dry_run',
|
||
data: {
|
||
product: { name: '测试产品', keyword: '测试产品' },
|
||
departure_dates: ['2026-01-07'],
|
||
recurrence: { start_date: '2026-01-01', end_date: '2026-01-30', pattern: 'specified_dates' },
|
||
planned_capacity: 10,
|
||
room_counts: { TWN: 8 }
|
||
}
|
||
};
|
||
const result = normalizeParseResult(canonicalPassedResult(operation), {
|
||
taskId: 'TASK-NEXT-DATE-EXPLICIT',
|
||
receivedAt: '2026-08-18T04:30:00.000Z',
|
||
rawText: '散拼团新增计划\n发团日期:2026-01-01到2026-01-30\n发团周期:2026.01.07'
|
||
});
|
||
|
||
assert.equal(result.status, 'agent_parse_passed');
|
||
assert.equal(result.operation.data.departure_dates[0], '2026-01-07');
|
||
assert.equal(result.operation.data.recurrence.start_date, '2026-01-01');
|
||
});
|
||
|
||
it('rolls yearless modification lookup dates to the next occurrence', () => {
|
||
const operation = {
|
||
action: 'order_update_shared_plan',
|
||
order_nature: 'formal',
|
||
submit_mode: 'dry_run',
|
||
data: {
|
||
existing_refs: { kind: 'shared_plan' },
|
||
departure_dates: ['2026-01-07'],
|
||
updates: { actions: [{ target: 'planned_capacity', operation: 'set', value: 10 }] }
|
||
}
|
||
};
|
||
const result = normalizeParseResult(canonicalPassedResult(operation), {
|
||
taskId: 'TASK-NEXT-DATE-UPDATE-ROLLOVER',
|
||
receivedAt: '2026-08-18T04:30:00.000Z',
|
||
rawText: '修改散拼母团计划\n发团日期:01-07\n计划收客数:10'
|
||
});
|
||
|
||
assert.equal(result.status, 'agent_parse_passed');
|
||
assert.equal(result.operation.data.departure_dates[0], '2027-01-07');
|
||
});
|
||
|
||
it('normalizes the reported shared-child 11-15 lookup without asking for a year', () => {
|
||
const operation = {
|
||
action: 'order_update_shared_child',
|
||
order_nature: 'formal',
|
||
submit_mode: 'dry_run',
|
||
data: {
|
||
existing_refs: { kind: 'shared_child_order' },
|
||
customer: { name: '辽宁康辉', keyword: '辽宁康辉' },
|
||
departure_dates: ['2025-11-15'],
|
||
product: { name: '广东衡阳', keyword: '广东衡阳' },
|
||
leader: { name: '张三', keyword: '张三' },
|
||
updates: { actions: [{ target: 'lodging_note', operation: 'append', value: '领队单住' }] }
|
||
}
|
||
};
|
||
const result = normalizeParseResult(canonicalPassedResult(operation), {
|
||
taskId: 'TASK-NEXT-DATE-SHARED-CHILD',
|
||
receivedAt: '2026-08-26T01:22:50.000Z',
|
||
rawText: '追加散拼子单订房说明\n预订客户:辽宁康辉\n出发日期:11-15\n产品名称:广东衡阳\n领队:张三\n追加内容:领队单住'
|
||
});
|
||
|
||
assert.equal(result.status, 'agent_parse_passed');
|
||
assert.equal(result.operation.data.departure_dates[0], '2026-11-15');
|
||
});
|
||
|
||
it('normalizes modification target dates and preserves explicit years', () => {
|
||
const update = {
|
||
action: 'order_update_independent',
|
||
order_nature: 'formal',
|
||
submit_mode: 'dry_run',
|
||
data: {
|
||
existing_refs: { kind: 'independent_order', identifier: 'LW-260903-A' },
|
||
departure_dates: ['2025-11-15'],
|
||
updates: { actions: [{ target: 'departure_date', operation: 'set', value: '2026-01-07' }] }
|
||
}
|
||
};
|
||
const rolled = normalizeParseResult(canonicalPassedResult(update), {
|
||
taskId: 'TASK-NEXT-DATE-UPDATE-TARGET', receivedAt: '2026-08-18T04:30:00.000Z',
|
||
rawText: '独立团信息修改\n单号:LW-260903-A\n出发日期:2025-11-15\n目标修改:出发日期改为01-07'
|
||
});
|
||
assert.equal(rolled.operation.data.departure_dates[0], '2025-11-15');
|
||
assert.equal(rolled.operation.data.updates.actions[0].value, '2027-01-07');
|
||
|
||
const explicit = normalizeParseResult(canonicalPassedResult({
|
||
...update,
|
||
data: { ...update.data, updates: { actions: [{ target: 'departure_date', operation: 'set', value: '2025-01-07' }] } }
|
||
}), {
|
||
taskId: 'TASK-EXPLICIT-DATE-UPDATE-TARGET', receivedAt: '2026-08-18T04:30:00.000Z',
|
||
rawText: '独立团信息修改\n单号:LW-260903-A\n目标修改:出发日期改为2025-01-07'
|
||
});
|
||
assert.equal(explicit.operation.data.updates.actions[0].value, '2025-01-07');
|
||
});
|
||
|
||
it('normalizes a yearless hotel-change end date', () => {
|
||
const operation = {
|
||
action: 'arrangement_hotel', order_nature: 'formal', submit_mode: 'dry_run',
|
||
data: {
|
||
existing_refs: { identifier: 'LW-260903-A' },
|
||
arrangement: { mode: 'update', changes: { end_date: '2026-01-07', room_count: 9 } }
|
||
}
|
||
};
|
||
const result = normalizeParseResult(canonicalPassedResult(operation), {
|
||
taskId: 'TASK-NEXT-DATE-HOTEL-CHANGE', receivedAt: '2026-08-18T04:30:00.000Z',
|
||
rawText: '安排变更\n团号:LW-260903-A\n目标变更:离店日期改为01-07;房间数改为9间\n安排类型:酒店'
|
||
});
|
||
assert.equal(result.operation.data.arrangement.changes.end_date, '2027-01-07');
|
||
});
|
||
|
||
it('normalizes cancel and restore lookup dates and preserves explicit years', () => {
|
||
const lifecycle = (action, date) => ({
|
||
action, order_nature: 'formal', submit_mode: 'dry_run',
|
||
data: {
|
||
customer: { name: '衡阳国旅广东', keyword: '衡阳国旅广东' },
|
||
departure_dates: [date],
|
||
...(action === 'order_restore' ? { transition: { to_status: '预订' } } : {})
|
||
}
|
||
});
|
||
const restore = normalizeParseResult(canonicalPassedResult(lifecycle('order_restore', '2025-11-05')), {
|
||
taskId: 'TASK-NEXT-DATE-RESTORE', receivedAt: '2026-08-26T05:09:53.000Z',
|
||
rawText: '恢复订单\n预订客户:衡阳国旅广东\n出发日期:11-05\n恢复状态:预订'
|
||
});
|
||
assert.equal(restore.operation.data.departure_dates[0], '2026-11-05');
|
||
|
||
const cancel = normalizeParseResult(canonicalPassedResult(lifecycle('order_cancel', '2026-01-05')), {
|
||
taskId: 'TASK-NEXT-DATE-CANCEL', receivedAt: '2026-08-26T05:09:53.000Z',
|
||
rawText: '取消订单\n预订客户:衡阳国旅广东\n出发日期:01-05'
|
||
});
|
||
assert.equal(cancel.operation.data.departure_dates[0], '2027-01-05');
|
||
|
||
const explicit = normalizeParseResult(canonicalPassedResult(lifecycle('order_restore', '2025-11-05')), {
|
||
taskId: 'TASK-EXPLICIT-DATE-RESTORE', receivedAt: '2026-08-26T05:09:53.000Z',
|
||
rawText: '恢复订单\n预订客户:衡阳国旅广东\n出发日期:2025-11-05'
|
||
});
|
||
assert.equal(explicit.operation.data.departure_dates[0], '2025-11-05');
|
||
});
|
||
|
||
it('rejects a stale year-only restore question so the client performs one contract repair', () => {
|
||
const result = normalizeParseResult({
|
||
status: 'agent_parse_needs_input', blockers: [], operation: null,
|
||
missing_fields: ['data.departure_dates'],
|
||
questions: [{ field: 'data.departure_dates', prompt: '当前仅提供 11-05,请补充年份。' }],
|
||
reply: '请补充年份。'
|
||
}, {
|
||
taskId: 'TASK-STALE-RESTORE-YEAR-QUESTION', receivedAt: '2026-08-26T05:09:53.000Z',
|
||
rawText: '恢复订单\n预订客户:衡阳国旅广东\n出发日期:11-05\n恢复状态:预订'
|
||
});
|
||
assert.equal(result.status, 'agent_parse_blocked');
|
||
assert.equal(result.error_code, 'external_operation_contract_invalid');
|
||
assert.match(result.validation_errors.join('\n'), /不得仅因缺少年份追问/);
|
||
});
|
||
|
||
it('rejects a stale year-only modification question so the client performs one contract repair', () => {
|
||
const result = normalizeParseResult({
|
||
status: 'agent_parse_needs_input', blockers: [], operation: null,
|
||
missing_fields: ['data.departure_dates'],
|
||
questions: [{ field: 'data.departure_dates', prompt: '请提供出发日期的完整年份。' }],
|
||
reply: '请补充年份。'
|
||
}, {
|
||
taskId: 'TASK-STALE-YEAR-QUESTION', receivedAt: '2026-08-26T01:22:50.000Z',
|
||
rawText: '11-15',
|
||
dateContextText: '追加散拼子单订房说明\n预订客户:辽宁康辉\n追加内容:领队单住\n11-15'
|
||
});
|
||
assert.equal(result.status, 'agent_parse_blocked');
|
||
assert.equal(result.error_code, 'external_operation_contract_invalid');
|
||
assert.match(result.validation_errors.join('\n'), /不得仅因缺少年份追问/);
|
||
});
|
||
|
||
it('uses original task history when a continuation only supplies a missing field', () => {
|
||
const operation = {
|
||
action: 'shared_plan_create',
|
||
order_nature: 'formal',
|
||
submit_mode: 'dry_run',
|
||
data: {
|
||
product: { name: '测试产品', keyword: '测试产品' },
|
||
departure_dates: ['2026-01-07'],
|
||
recurrence: { start_date: '2026-01-01', end_date: '2026-01-30', pattern: 'specified_dates' },
|
||
planned_capacity: 10,
|
||
room_counts: { TWN: 8 }
|
||
}
|
||
};
|
||
const result = normalizeParseResult(canonicalPassedResult(operation), {
|
||
taskId: 'TASK-NEXT-DATE-HISTORY',
|
||
receivedAt: '2026-08-18T04:30:00.000Z',
|
||
rawText: '收客数:10',
|
||
dateContextText: '散拼团新增计划\n发团日期:01-01到01-30\n发团周期:01.07\n收客数:10'
|
||
});
|
||
|
||
assert.equal(result.status, 'agent_parse_passed');
|
||
assert.equal(result.operation.data.departure_dates[0], '2027-01-07');
|
||
});
|
||
|
||
it('preserves product/customer source-region context without semantic validation', () => {
|
||
const result = normalizeParseResult({
|
||
status: 'agent_parse_passed',
|
||
blockers: [],
|
||
operation: {
|
||
action: 'team_order_create',
|
||
order_nature: 'formal',
|
||
submit_mode: 'dry_run',
|
||
data: {
|
||
product: { name: '广东来源产品', keyword: '广东来源产品', source_region: '广东' },
|
||
customer: { name: '广西来源客户', keyword: '广西来源客户', source_region: '广西' },
|
||
departure_dates: ['2026-09-03'],
|
||
passenger_counts: { adult: 1 },
|
||
room_counts: { TWN: 1 }
|
||
}
|
||
}
|
||
}, { taskId: 'TASK-SOURCE-REGION', receivedAt: '2026-08-10T07:36:00.000Z' });
|
||
|
||
assert.equal(result.status, 'agent_parse_passed');
|
||
assert.equal(result.error_code, undefined);
|
||
assert.equal(result.operation.data.product.source_region, '广东');
|
||
assert.equal(result.operation.data.customer.source_region, '广西');
|
||
});
|
||
|
||
it('accepts missing source-region metadata without blocking', () => {
|
||
const result = normalizeParseResult({
|
||
status: 'agent_parse_passed',
|
||
blockers: [],
|
||
operation: {
|
||
action: 'team_order_create',
|
||
order_nature: 'formal',
|
||
submit_mode: 'dry_run',
|
||
data: {
|
||
product: { name: '遇见老挝', keyword: '遇见老挝' },
|
||
customer: { name: '老挝联泰人民币', keyword: '老挝联泰人名币' },
|
||
departure_dates: ['2026-09-03'],
|
||
passenger_counts: { adult: 15, leader: 1 },
|
||
room_counts: { TWN: 8, SGL: 1 }
|
||
}
|
||
}
|
||
}, { taskId: 'TASK-SOURCE-REGION-DEFERRED', receivedAt: '2026-08-10T07:41:00.000Z' });
|
||
|
||
assert.equal(result.status, 'agent_parse_passed');
|
||
assert.equal(result.operation.data.product.source_region, undefined);
|
||
assert.equal(result.operation.data.customer.source_region, undefined);
|
||
});
|
||
|
||
it('keeps shared-child guidance at user-visible business fields', () => {
|
||
const message = buildParserMessage({
|
||
rawText: '散拼团单个新增子单',
|
||
taskId: 'TASK-SHARED-CHILD-GUIDANCE',
|
||
receivedAt: '2026-08-11T02:40:00.000Z'
|
||
});
|
||
|
||
assert.match(message, new RegExp(`使用已配置的 ${AGENT_PROMPT_VERSION} 和对应 Skill`));
|
||
assert.match(message, /散拼团单个新增子单/);
|
||
assert.doesNotMatch(message, /独立团批量下单 →|标准指令路由/);
|
||
assert.doesNotMatch(message, /\b(?:tid|ddid|row_id|allowlist)\b|TEST-202609|test_context/);
|
||
});
|
||
|
||
it('keeps the versioned router in the profile and the per-task message minimal', () => {
|
||
const message = buildParserMessage({
|
||
rawText: '独立团信息修改',
|
||
taskId: 'TASK-VERSIONED-ROUTER',
|
||
receivedAt: '2026-08-12T08:05:00.000Z'
|
||
});
|
||
|
||
assert.equal(STANDARD_DIRECTIVE_ROUTES.length, 18);
|
||
assert.match(message, new RegExp(AGENT_PROMPT_VERSION));
|
||
for (const status of ['agent_parse_passed', 'agent_parse_needs_input', 'agent_parse_blocked']) assert.ok(message.includes(status));
|
||
assert.match(message, /只解析用户输入/);
|
||
assert.match(message, /修改类只提取目标值/);
|
||
assert.match(message, /全部目标字段/);
|
||
assert.match(message, /命名引用必须使用对象格式/);
|
||
assert.match(message, /customer、product、leader、resource、supplier/);
|
||
assert.ok(message.length < 1_200, `per-task parser message is too large: ${message.length}`);
|
||
assert.doesNotMatch(message, /标准指令路由|独立团批量下单 →|安排其他\/备案 →/);
|
||
assert.doesNotMatch(message, /TEST-202609|test_context|server_response|native_request|requery|fresh|write_attempted|DoInfo|allowlist/);
|
||
});
|
||
|
||
it('keeps shared-child batch creation Program-only and outside the external Agent router', () => {
|
||
const operation = {
|
||
action: 'shared_child_order_batch_create',
|
||
order_nature: 'formal',
|
||
submit_mode: 'dry_run',
|
||
data: {
|
||
customer: { name: '合成客户', keyword: '合成客户' },
|
||
product: { name: '合成产品', keyword: '合成产品' },
|
||
leader: { name: '测试领队', keyword: '测试领队' },
|
||
recurrence: { start_date: '2026-10-06', end_date: '2026-11-03' },
|
||
passenger_counts: { adult: 20, leader: 1 }
|
||
}
|
||
};
|
||
assert.match(validateAgentOperation(operation).join('\n'), /当前 18 类业务/);
|
||
assert.deepEqual(validateProgramOperation(operation), []);
|
||
assert.deepEqual(validateStandardOperation(operation), []);
|
||
assert.equal(STANDARD_DIRECTIVE_ROUTES.some((route) => route.includes('散拼团多个新增子单')), false);
|
||
});
|
||
|
||
it('pins the optional other/filing fields in the per-task guidance', () => {
|
||
const message = buildParserMessage({
|
||
rawText: '安排其他/备案\n团号:LW-260903-A\n业务日期:09-03\n结算单位搜索:美女',
|
||
taskId: 'TASK-ARRANGEMENT-OTHER-GUIDANCE',
|
||
receivedAt: '2026-08-14T09:49:00.000Z'
|
||
});
|
||
|
||
assert.match(message, /项目\/备案说明(data\.arrangement\.item)和数量均为选填/);
|
||
assert.match(message, /缺少它们不能返回 agent_parse_needs_input/);
|
||
assert.match(message, /省略的项目由后续业务系统联动带入/);
|
||
assert.match(message, new RegExp(AGENT_PROMPT_VERSION));
|
||
});
|
||
|
||
it('accepts a shared-child operation with only the visible parent reference', () => {
|
||
const result = normalizeParseResult({
|
||
status: 'agent_parse_passed',
|
||
blockers: [],
|
||
operation: {
|
||
action: 'shared_child_order_create',
|
||
order_nature: 'formal',
|
||
submit_mode: 'dry_run',
|
||
data: {
|
||
existing_refs: {
|
||
kind: 'shared_plan',
|
||
identifier: 'LW-260916-A',
|
||
parent_group_no: 'LW-260916-A'
|
||
},
|
||
departure_dates: ['2026-09-16'],
|
||
customer: {
|
||
name: 'LW衡阳国旅云南分社(广东市场)',
|
||
keyword: 'LW衡阳国旅云南分社(广东市场)'
|
||
},
|
||
passenger_counts: { adult: 15, leader: 1 },
|
||
special_requests: '领队单住'
|
||
}
|
||
}
|
||
}, { taskId: 'TASK-SHARED-CHILD', receivedAt: '2026-08-11T02:40:00.000Z' });
|
||
|
||
assert.equal(result.status, 'agent_parse_passed');
|
||
assert.equal(result.operation.data.existing_refs.identifier, 'LW-260916-A');
|
||
assert.equal(result.operation.data.existing_refs.tid, undefined);
|
||
assert.equal(result.operation.data.departure_dates.length, 1);
|
||
});
|
||
|
||
it('rejects internal parent identifiers in Agent output', () => {
|
||
const result = normalizeParseResult({
|
||
status: 'agent_parse_passed',
|
||
blockers: [],
|
||
operation: {
|
||
action: 'shared_child_order_create',
|
||
order_nature: 'formal',
|
||
submit_mode: 'dry_run',
|
||
data: {
|
||
existing_refs: {
|
||
kind: 'shared_plan',
|
||
identifier: 'LW-260916-A',
|
||
parent_group_no: 'LW-260916-A',
|
||
tid: '14389'
|
||
},
|
||
departure_dates: ['2026-09-16'],
|
||
customer: { name: '广东客户', keyword: '广东客户' },
|
||
passenger_counts: { adult: 15, leader: 1 }
|
||
}
|
||
}
|
||
}, { taskId: 'TASK-SHARED-CHILD-MARKER', receivedAt: '2026-08-11T02:40:00.000Z' });
|
||
|
||
assert.equal(result.status, 'agent_parse_blocked');
|
||
assert.match(result.validation_errors.join('\n'), /existing_refs 包含未声明字段:tid/);
|
||
});
|
||
|
||
it('accepts multi-date shared-plan child facts without execution metadata', () => {
|
||
const dates = ['2026-09-03', '2026-09-05', '2026-09-30'];
|
||
const result = normalizeParseResult({
|
||
status: 'agent_parse_passed',
|
||
blockers: [],
|
||
operation: {
|
||
action: 'shared_plan_create',
|
||
order_nature: 'formal',
|
||
submit_mode: 'dry_run',
|
||
data: {
|
||
product: { name: '老挝广东8D', keyword: '老挝广东8D', source_region: '广东' },
|
||
departure_dates: dates,
|
||
recurrence: { start_date: '2026-09-01', end_date: '2026-09-30', pattern: 'specified_dates' },
|
||
planned_capacity: 30,
|
||
room_counts: { TWN: 8, SGL: 1 },
|
||
split_order: {
|
||
customer: { name: '南宁国旅', keyword: '南宁国旅', source_region: '广东' },
|
||
passenger_counts: { adult: 15, leader: 1 }
|
||
}
|
||
}
|
||
}
|
||
}, { taskId: 'TASK-SPLIT-LEGACY', receivedAt: '2026-08-06T07:36:00.000Z' });
|
||
|
||
assert.equal(result.status, 'agent_parse_passed');
|
||
assert.equal(result.operation.data.split_order.customer.name, '南宁国旅');
|
||
assert.equal(result.operation.data.split_order.passenger_counts.adult, 15);
|
||
assert.equal(result.operation.source, undefined);
|
||
});
|
||
|
||
it('normalizes legacy flat shared-plan child fields into split_order', () => {
|
||
const result = normalizeParseResult({
|
||
status: 'agent_parse_passed',
|
||
blockers: [],
|
||
operation: {
|
||
action: 'shared_plan_create',
|
||
order_nature: 'formal',
|
||
submit_mode: 'dry_run',
|
||
data: {
|
||
product: { name: '老挝广东8D', keyword: '老挝广东8D' },
|
||
departure_dates: ['2026-09-03'],
|
||
planned_capacity: 30,
|
||
room_counts: { TWN: 8 },
|
||
customer: { name: '南宁国旅', keyword: '南宁国旅', source_region: '广东' }
|
||
}
|
||
}
|
||
}, { taskId: 'TASK-SPLIT-LEGACY', receivedAt: '2026-08-06T07:36:00.000Z' });
|
||
|
||
assert.equal(result.status, 'agent_parse_passed');
|
||
assert.equal(result.operation.data.split_order.customer.name, '南宁国旅');
|
||
assert.equal(Object.hasOwn(result.operation.data, 'customer'), false);
|
||
});
|
||
|
||
it('accepts parser-state lifecycle facts without requiring execution-state fields', () => {
|
||
const operation = {
|
||
action: 'order_cancel',
|
||
order_nature: 'formal',
|
||
submit_mode: 'dry_run',
|
||
data: { existing_refs: { identifier: 'LW-260903-A' } }
|
||
};
|
||
|
||
assert.deepEqual(validateAgentOperation(operation), []);
|
||
const result = normalizeParseResult(canonicalPassedResult(operation), {
|
||
taskId: 'TASK-PARSE-ONLY-CANCEL',
|
||
receivedAt: '2026-08-12T08:00:00.000Z'
|
||
});
|
||
assert.equal(result.status, 'agent_parse_passed');
|
||
assert.equal(result.operation.data.existing_refs.identifier, 'LW-260903-A');
|
||
});
|
||
|
||
it('treats ERP passenger rows as an initial baseline while retaining a 5000-row technical bound', () => {
|
||
const headers = '序号\t姓名\tNAME\t性别\t出生日期\t出生地\t证件类型\t证件号码\t签发地\t签发日\t有效期\t电话\t备注';
|
||
const row = (sequence) => `${sequence}\t合成游客\tSYNTHETIC USER\t男\t1990-01-01\t湖南\t护照\tE90000001\t中国\t2025-01-01\t2035-01-01\t13800000001\t合成示例`;
|
||
const parseState = {
|
||
action: 'passenger_list_import',
|
||
order_nature: 'formal',
|
||
submit_mode: 'dry_run',
|
||
data: {
|
||
existing_refs: { kind: 'independent_order', identifier: 'LW-260903-A' },
|
||
passenger_list: { operation: 'import', row_count: 1, text: `${headers}\n${row(25)}` }
|
||
}
|
||
};
|
||
assert.deepEqual(validateAgentOperation(parseState), []);
|
||
|
||
const executionState = structuredClone(parseState);
|
||
executionState.data.existing_refs = {
|
||
kind: 'independent_order',
|
||
identifier: 'LW-260903-A',
|
||
group_no: 'LW-260903-A',
|
||
tid: '14420',
|
||
ddid: '14482',
|
||
resolved: true,
|
||
resolution_source: 'erp_unique_match',
|
||
expected_passenger_count: 16
|
||
};
|
||
executionState.data.passenger_list.operation = 'first_import';
|
||
assert.deepEqual(validateStandardOperation(executionState), []);
|
||
|
||
const technicalOverflow = structuredClone(parseState);
|
||
technicalOverflow.data.passenger_list.text = `${headers}\n${row(5001)}`;
|
||
assert.match(validateAgentOperation(technicalOverflow).join('\n'), /5000 行技术安全上限/);
|
||
|
||
const executionTechnicalOverflow = structuredClone(executionState);
|
||
executionTechnicalOverflow.data.passenger_list.text = `${headers}\n${row(5001)}`;
|
||
assert.match(validateStandardOperation(executionTechnicalOverflow).join('\n'), /5000 行技术安全上限/);
|
||
});
|
||
|
||
it('accepts hotel arrangement input without a room type and leaves that field for ERP resolution', () => {
|
||
const operation = {
|
||
action: 'arrangement_hotel',
|
||
order_nature: 'formal',
|
||
submit_mode: 'dry_run',
|
||
data: {
|
||
existing_refs: { identifier: 'LW-260903A-A' },
|
||
arrangement: {
|
||
mode: 'create',
|
||
resource: { name: '万荣 龙吟阁', keyword: '万荣 龙吟阁' },
|
||
start_date: '2026-09-03',
|
||
end_date: '2026-09-09',
|
||
room_count: 8
|
||
}
|
||
}
|
||
};
|
||
|
||
assert.deepEqual(validateAgentOperation(operation), []);
|
||
const dispatch = operationPlans.validateDispatchOperation(operation);
|
||
assert.equal(dispatch.ok, true, dispatch.blockers.join('; '));
|
||
assert.equal(dispatch.requiresErpResolution, true);
|
||
assert.equal(operation.data.arrangement.room_type, undefined);
|
||
});
|
||
|
||
it('accepts customer/date business lookup while keeping visible identifiers optional', () => {
|
||
const named = (name) => ({ name, keyword: name });
|
||
const lookup = {
|
||
customer: named('辽宁康辉'),
|
||
departure_dates: ['2026-09-15'],
|
||
product: named('老挝广东8D'),
|
||
leader: named('张三')
|
||
};
|
||
const operations = [
|
||
{
|
||
action: 'shared_child_order_create',
|
||
data: { ...lookup, passenger_counts: { adult: 15, leader: 1 } }
|
||
},
|
||
{
|
||
action: 'order_update_shared_plan',
|
||
data: {
|
||
...lookup,
|
||
existing_refs: { kind: 'shared_plan' },
|
||
updates: { actions: [{ target: 'planned_capacity', operation: 'set', value: 45 }] }
|
||
}
|
||
},
|
||
{ action: 'order_cancel', data: { ...lookup } },
|
||
{ action: 'confirmation_export', data: { ...lookup, confirmation: { type: 'liantai-confirm' } } }
|
||
];
|
||
|
||
for (const candidate of operations) {
|
||
const operation = { order_nature: 'formal', submit_mode: 'dry_run', ...candidate };
|
||
assert.deepEqual(validateAgentOperation(operation), [], candidate.action);
|
||
const dispatch = operationPlans.validateDispatchOperation(operation);
|
||
assert.equal(dispatch.ok, true, `${candidate.action}: ${dispatch.blockers.join('; ')}`);
|
||
assert.equal(dispatch.requiresErpResolution, true);
|
||
}
|
||
|
||
const arrangementWithoutIdentifier = {
|
||
action: 'arrangement_guide',
|
||
order_nature: 'formal',
|
||
submit_mode: 'dry_run',
|
||
data: {
|
||
...lookup,
|
||
existing_refs: { kind: 'independent_order' },
|
||
arrangement: { mode: 'create', resource: named('导游甲') }
|
||
}
|
||
};
|
||
assert.match(validateAgentOperation(arrangementWithoutIdentifier).join('\n'), /identifier|未声明字段/);
|
||
assert.equal(operationPlans.validateDispatchOperation(arrangementWithoutIdentifier).ok, false);
|
||
});
|
||
|
||
it('accepts only visitor-list for a shared mother-plan export scope', () => {
|
||
const named = (name) => ({ name, keyword: name });
|
||
const wholeGroup = {
|
||
action: 'confirmation_export',
|
||
order_nature: 'formal',
|
||
submit_mode: 'dry_run',
|
||
data: {
|
||
existing_refs: { kind: 'shared_plan' },
|
||
customer: named('合成客户'),
|
||
product: named('合成产品'),
|
||
departure_dates: ['2026-11-01'],
|
||
confirmation: { type: 'visitor-list' }
|
||
}
|
||
};
|
||
assert.deepEqual(validateAgentOperation(wholeGroup), []);
|
||
assert.equal(operationPlans.validateDispatchOperation(wholeGroup).ok, true);
|
||
|
||
const wrongType = structuredClone(wholeGroup);
|
||
wrongType.data.confirmation = { type: 'liantai-confirm' };
|
||
assert.match(validateAgentOperation(wrongType).join('\n'), /散拼母团.*整团游客信息/);
|
||
|
||
const mixedTypes = structuredClone(wholeGroup);
|
||
delete mixedTypes.data.confirmation;
|
||
mixedTypes.data.export_types = ['visitor-list', 'guide-confirm'];
|
||
assert.match(validateAgentOperation(mixedTypes).join('\n'), /散拼母团.*整团游客信息/);
|
||
});
|
||
|
||
it('allows a no-customer shared mother-plan capacity update when the departure date is present', () => {
|
||
const operation = {
|
||
action: 'order_update_shared_plan',
|
||
order_nature: 'formal',
|
||
submit_mode: 'dry_run',
|
||
data: {
|
||
existing_refs: { kind: 'shared_plan' },
|
||
departure_dates: ['2026-09-15'],
|
||
updates: { actions: [{ target: 'planned_capacity', operation: 'set', value: 45 }] }
|
||
}
|
||
};
|
||
|
||
assert.deepEqual(validateAgentOperation(operation), []);
|
||
const dispatch = operationPlans.validateDispatchOperation(operation);
|
||
assert.equal(dispatch.ok, true, dispatch.blockers.join('; '));
|
||
assert.equal(dispatch.requiresErpResolution, true);
|
||
|
||
const missingDate = structuredClone(operation);
|
||
delete missingDate.data.departure_dates;
|
||
assert.match(validateAgentOperation(missingDate).join('\n'), /departure_dates|出发日期/);
|
||
assert.equal(operationPlans.validateDispatchOperation(missingDate).ok, false);
|
||
});
|
||
|
||
it('preserves a complete independent passenger and room distribution as multiple update actions', () => {
|
||
const operation = {
|
||
action: 'order_update_independent',
|
||
order_nature: 'formal',
|
||
submit_mode: 'dry_run',
|
||
data: {
|
||
existing_refs: { kind: 'independent_order' },
|
||
customer: { name: '衡阳国旅广东', keyword: '衡阳国旅广东' },
|
||
departure_dates: ['2026-09-03'],
|
||
product: { name: '老挝好时光', keyword: '老挝好时光' },
|
||
updates: { actions: [
|
||
{ target: 'rooms.SGL', operation: 'set', value: 3 },
|
||
{ target: 'rooms.TWN', operation: 'set', value: 2 },
|
||
{ target: 'pax.adult', operation: 'set', value: 8 },
|
||
{ target: 'pax.child_bed', operation: 'set', value: 1 },
|
||
{ target: 'pax.child_no_bed', operation: 'set', value: 0 },
|
||
{ target: 'pax.infant', operation: 'set', value: 0 },
|
||
{ target: 'pax.leader', operation: 'set', value: 1 }
|
||
] }
|
||
}
|
||
};
|
||
assert.deepEqual(validateAgentOperation(operation), []);
|
||
assert.deepEqual(operationPlans.validateDispatchOperation(operation).blockers, []);
|
||
const dispatchWarnings = operationPlans.validateDispatchOperation(operation).warnings.join('\n');
|
||
assert.doesNotMatch(dispatchWarnings, /rooms\.SGL|rooms\.TWN/);
|
||
assert.doesNotMatch(dispatchWarnings, /pax\.adult|pax\.child_bed|pax\.child_no_bed|pax\.leader/);
|
||
assert.match(dispatchWarnings, /pax\.infant/);
|
||
});
|
||
|
||
it('passes Agent and plugin front-gate validation for every supported directive route', () => {
|
||
const named = (name) => ({ name, keyword: name });
|
||
const operation = (action, data) => ({ action, order_nature: 'formal', submit_mode: 'dry_run', data });
|
||
const headers = '序号\t姓名\tNAME\t性别\t出生日期\t出生地\t证件类型\t证件号码\t签发地\t签发日\t有效期\t电话\t备注';
|
||
const row = '1\t测试游客\tTEST USER\t男\t1990-01-01\t湖南\t护照\tE90000001\t中国\t2025-01-01\t2035-01-01\t13800000001\t合成示例';
|
||
const cases = [
|
||
operation('team_order_create', { customer: named('客户甲'), product: named('产品甲'), departure_dates: ['2026-09-03'], passenger_counts: { adult: 15, leader: 1 }, room_counts: { TWN: 8, SGL: 1 } }),
|
||
operation('team_order_batch_create', { customer: named('客户甲'), product: named('产品甲'), departure_dates: ['2026-09-03', '2026-09-05'], recurrence: { start_date: '2026-09-01', end_date: '2026-09-30' }, passenger_counts: { adult: 15, leader: 1 }, room_counts: { TWN: 8, SGL: 1 } }),
|
||
operation('shared_plan_create', { product: named('产品甲'), departure_dates: ['2026-09-07'], planned_capacity: 40, room_counts: { TWN: 8, SGL: 1 } }),
|
||
operation('shared_child_order_create', { existing_refs: { kind: 'shared_plan', identifier: 'LW-260915-A', parent_group_no: 'LW-260915-A' }, customer: named('客户甲'), passenger_counts: { adult: 15, leader: 1 } }),
|
||
operation('passenger_list_import', { existing_refs: { kind: 'independent_order', identifier: 'LW-260903-A' }, passenger_list: { operation: 'import', row_count: 1, text: `${headers}\n${row}` } }),
|
||
operation('passenger_list_import', { existing_refs: { kind: 'shared_child_order', identifier: 'D26091501' }, passenger_list: { operation: 'full_replace', confirmed: true, row_count: 1, text: `${headers}\n${row}` } }),
|
||
operation('arrangement_guide', { existing_refs: { identifier: 'LW-260903-A' }, arrangement: { mode: 'create', resource: named('导游甲') } }),
|
||
operation('arrangement_vehicle', { existing_refs: { identifier: 'LW-260903-A' }, arrangement: { mode: 'create', supplier: named('380'), start_date: '2026-09-03', end_date: '2026-09-15', quantity: 1 } }),
|
||
operation('arrangement_hotel', { existing_refs: { identifier: 'LW-260903-A' }, arrangement: { mode: 'create', resource: named('酒店甲'), room_type: '标准间', start_date: '2026-09-03', end_date: '2026-09-09', room_count: 8 } }),
|
||
operation('arrangement_transport', { existing_refs: { identifier: 'LW-260903-A' }, arrangement: { mode: 'create', supplier: named('航司甲'), item: '团队机票', quantity: 16, date: '2026-09-03' } }),
|
||
operation('arrangement_other', { existing_refs: { identifier: 'LW-260903-A' }, arrangement: { mode: 'create', supplier: named('口岸服务甲'), item: '团队备案服务', quantity: 16, date: '2026-09-03', filing: { entry_port: '磨丁' } } }),
|
||
operation('order_update_shared_plan', { existing_refs: { kind: 'shared_plan', identifier: 'LW-260915-A' }, updates: { actions: [{ target: 'planned_capacity', operation: 'set', value: 45 }] } }),
|
||
operation('order_update_shared_child', { existing_refs: { kind: 'shared_child_order', identifier: 'D26091501' }, updates: { actions: [{ target: 'lodging_note', operation: 'append', value: '领队单住' }] } }),
|
||
operation('order_update_independent', { existing_refs: { kind: 'independent_order', identifier: 'LW-260903-A' }, updates: { actions: [{ target: 'rooms.SGL', operation: 'set', value: 3 }, { target: 'rooms.TWN', operation: 'set', value: 2 }] } }),
|
||
operation('arrangement_hotel', { existing_refs: { identifier: 'LW-260903-A' }, arrangement: { mode: 'update', changes: { end_date: '2026-09-13', room_count: 9 } } }),
|
||
operation('order_cancel', { existing_refs: { identifier: 'LW-260903-A' } }),
|
||
operation('order_restore', { existing_refs: { identifier: 'LW-260903-A' }, transition: { to_status: '预订' } }),
|
||
operation('confirmation_export', { existing_refs: { identifier: 'LW-260903-A' }, confirmation: { type: 'liantai-confirm' } })
|
||
];
|
||
|
||
assert.equal(cases.length, 18);
|
||
cases.forEach((candidate, index) => {
|
||
assert.deepEqual(validateAgentOperation(candidate), [], `Agent route ${index + 1}`);
|
||
const dispatch = operationPlans.validateDispatchOperation(candidate);
|
||
assert.equal(dispatch.ok, true, `plugin front gate route ${index + 1}: ${dispatch.blockers.join('; ')}`);
|
||
});
|
||
|
||
const transportWithoutDescription = operation('arrangement_transport', {
|
||
existing_refs: { identifier: 'LW-260903-A' },
|
||
arrangement: { mode: 'create', supplier: named('老挝航空'), quantity: 16, date: '2026-09-03' }
|
||
});
|
||
assert.deepEqual(validateAgentOperation(transportWithoutDescription), []);
|
||
assert.equal(operationPlans.validateDispatchOperation(transportWithoutDescription).ok, true);
|
||
|
||
const otherWithoutDescriptionOrQuantity = operation('arrangement_other', {
|
||
existing_refs: { identifier: 'LW-260903-A' },
|
||
arrangement: { mode: 'create', supplier: named('美女'), date: '2026-09-03' }
|
||
});
|
||
assert.deepEqual(validateAgentOperation(otherWithoutDescriptionOrQuantity), []);
|
||
assert.equal(operationPlans.validateDispatchOperation(otherWithoutDescriptionOrQuantity).ok, true);
|
||
});
|
||
|
||
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);
|
||
});
|
||
|
||
it('turns a stale other/filing item question into a contract repair instead of asking the user', () => {
|
||
const result = normalizeParseResult({
|
||
status: 'agent_parse_needs_input',
|
||
blockers: [],
|
||
operation: null,
|
||
reply: '请提供项目/备案说明。',
|
||
missing_fields: ['data.arrangement.item'],
|
||
questions: [{ field: 'data.arrangement.item', prompt: '请提供项目/备案说明。' }]
|
||
}, {
|
||
taskId: 'TASK-STALE-ARRANGEMENT-OTHER',
|
||
rawText: '安排其他/备案\n团号:LW-260903-A\n业务日期:09-03\n结算单位搜索:美女',
|
||
receivedAt: '2026-08-14T09:52:00.000Z'
|
||
});
|
||
|
||
assert.equal(result.status, 'agent_parse_blocked');
|
||
assert.equal(result.error_code, 'external_operation_contract_invalid');
|
||
assert.match(result.validation_errors.join('\n'), /项目\/备案说明和数量均为选填/);
|
||
assert.equal(result.no_plugin_dispatch, true);
|
||
assert.equal(result.no_erp_write, true);
|
||
});
|
||
|
||
it('derives a NEED_INPUT reply from validated question prompts when the Agent omitted the top-level reply', () => {
|
||
const result = normalizeParseResult({
|
||
status: 'agent_parse_needs_input',
|
||
blockers: [],
|
||
operation: null,
|
||
missing_fields: ['data.product'],
|
||
questions: [
|
||
{ field: 'data.product', prompt: '请补充线路产品名称。' },
|
||
{ field: 'data.departure_dates', prompt: '请补充出发日期。' }
|
||
],
|
||
captured_facts: {}
|
||
}, { taskId: 'TASK-NEEDS-INPUT-NO-REPLY', receivedAt: '2026-07-12T08:00:00.000Z' });
|
||
|
||
assert.equal(result.status, 'agent_parse_needs_input');
|
||
assert.equal(result.operation, null);
|
||
assert.equal(result.reply, '请补充线路产品名称。;请补充出发日期。');
|
||
assert.deepEqual(result.questions, [
|
||
{ field: 'data.product', prompt: '请补充线路产品名称。' },
|
||
{ field: 'data.departure_dates', prompt: '请补充出发日期。' }
|
||
]);
|
||
});
|
||
|
||
it('still blocks NEED_INPUT when only internal missing fields exist without a displayable question', () => {
|
||
const result = normalizeParseResult({
|
||
status: 'agent_parse_needs_input',
|
||
blockers: [],
|
||
operation: null,
|
||
missing_fields: ['data.product'],
|
||
questions: [],
|
||
captured_facts: {}
|
||
}, { taskId: 'TASK-NEEDS-INPUT-NO-QUESTION', receivedAt: '2026-07-12T08:00:00.000Z' });
|
||
|
||
assert.equal(result.status, 'agent_parse_blocked');
|
||
assert.equal(result.error_code, 'external_needs_input_missing_reply');
|
||
assert.match(result.blockers.join('\n'), /没有可展示的 Agent 回复/);
|
||
});
|
||
});
|
||
|
||
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: false,
|
||
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_reachable, true);
|
||
assert.equal(response.body.ai_authenticated, false);
|
||
assert.equal(response.body.ai_connected, true);
|
||
assert.equal(response.body.ai_connection_basis, 'service_reachability');
|
||
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));
|
||
});
|
||
}
|