import assert from 'node:assert/strict'; import { readFileSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; import path from 'node:path'; import test from 'node:test'; import Ajv2020 from 'ajv/dist/2020.js'; import { buildParserMessage } from '../LianSyn-platform/external-agent-client.mjs'; const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); const read = (relativePath) => readFileSync(path.join(repoRoot, relativePath), 'utf8').replace(/\r\n/gu, '\n'); const catalog = read('agent设计规范/templates/business-input-templates.md'); const registry = read('agent设计规范/business-behavior-registry.md'); const mainPrompt = read('agent设计规范/agent-prompt.md'); const skillRuntimeFiles = [ 'agent设计规范/skills/lwlt-newbooking/SKILL.md', 'agent设计规范/skills/lwlt-newbooking/agents/openai.yaml', 'agent设计规范/skills/lwlt-newbooking/references/actions-and-fields.md', 'agent设计规范/skills/lwlt-newbooking/references/input-contract.md', 'agent设计规范/skills/lwlt-newbooking/references/normalization-rules.md', 'agent设计规范/skills/lwlt-newbooking/references/output-contract.md', 'agent设计规范/skills/lwlt-updating/SKILL.md', 'agent设计规范/skills/lwlt-updating/agents/openai.yaml', 'agent设计规范/skills/lwlt-updating/references/input-contract.md', 'agent设计规范/skills/lwlt-updating/references/operation-contract.md', 'agent设计规范/skills/lwlt-arrangement/SKILL.md', 'agent设计规范/skills/lwlt-arrangement/agents/openai.yaml', 'agent设计规范/skills/lwlt-arrangement/references/input-contract.md', 'agent设计规范/skills/lwlt-arrangement/references/operation-contract.md', 'agent设计规范/skills/lwlt-confirmation/SKILL.md', 'agent设计规范/skills/lwlt-confirmation/agents/openai.yaml', 'agent设计规范/skills/lwlt-confirmation/references/input-contract.md', 'agent设计规范/skills/lwlt-confirmation/references/output-contract.md', 'agent设计规范/skills/lwlt-lifecycle/SKILL.md', 'agent设计规范/skills/lwlt-lifecycle/agents/openai.yaml', 'agent设计规范/skills/lwlt-lifecycle/references/input-contract.md', 'agent设计规范/skills/lwlt-lifecycle/references/operation-contract.md' ]; const skillRuntimeText = skillRuntimeFiles.map(read).join('\n'); const executableDirectives = [ '独立团单个下单', '独立团批量下单', '散拼团新增计划', '散拼团单个新增子单', '导入独立团名单', '导入散拼子单名单', '安排导游', '安排用车', '安排酒店', '安排大交通', '安排其他/备案', '修改散拼母团计划收客数', '追加散拼子单订房说明', '取消订单', '恢复订单', '导出团队文件' ]; const narrowModificationDirectives = [ '独立团信息修改', '安排变更' ]; const deferredDirectives = [ '真实应收团款处理', '团队文件交付自动化', '团队文件上传' ]; const allDirectives = [ ...executableDirectives.slice(0, 13), ...narrowModificationDirectives, ...executableDirectives.slice(13) ]; const skillContracts = { 'agent设计规范/skills/lwlt-newbooking/references/input-contract.md': [ '独立团单个下单', '独立团批量下单', '散拼团新增计划', '散拼团单个新增子单' ], 'agent设计规范/skills/lwlt-arrangement/references/input-contract.md': [ '安排导游', '安排用车', '安排酒店', '安排大交通', '安排其他/备案', '安排变更' ], 'agent设计规范/skills/lwlt-updating/references/input-contract.md': [ '修改散拼母团计划收客数', '追加散拼子单订房说明', '独立团信息修改' ], 'agent设计规范/skills/lwlt-confirmation/references/input-contract.md': ['导出团队文件'], 'agent设计规范/skills/lwlt-lifecycle/references/input-contract.md': [ '导入独立团名单', '导入散拼子单名单', '取消订单', '恢复订单' ] }; const businessPages = { 'agent设计规范/businesses/team_order_create.md': '独立团单个下单', 'agent设计规范/businesses/team_order_batch_create.md': '独立团批量下单', 'agent设计规范/businesses/shared_plan_create.md': '散拼团新增计划', 'agent设计规范/businesses/shared_child_order_create.md': '散拼团单个新增子单' }; const minimumLabels = { 独立团单个下单: ['发团日期', '预订客户', '产品搜索', '预估人数', '用房数量'], 独立团批量下单: ['发团日期', '预订客户', '产品搜索', '预估人数', '用房数量', '发团周期'], 散拼团新增计划: ['发团日期', '产品搜索', '计划收客数', '用房数量'], 散拼团单个新增子单: ['预订客户', '出发日期', '人数'], 导入独立团名单: ['预订客户', '出发日期'], 导入散拼子单名单: ['预订客户', '出发日期'], 安排导游: ['团号', '导游搜索'], 安排用车: ['团号', '结算单位搜索', '用车日期', '数量'], 安排酒店: ['团号', '入住日期', '离店日期', '酒店搜索', '间数'], 安排大交通: ['团号', '结算单位搜索', '数量', '交通日期'], '安排其他/备案': ['团号', '结算单位搜索', '业务日期'], 修改散拼母团计划收客数: ['出发日期', '目标计划收客数'], 追加散拼子单订房说明: ['预订客户', '出发日期', '追加内容'], 独立团信息修改: ['预订客户', '出发日期', '目标修改'], 安排变更: ['团号', '目标变更'], 取消订单: ['预订客户', '出发日期'], 恢复订单: ['预订客户', '出发日期'], 导出团队文件: ['预订客户', '出发日期', '文件类型'] }; const textBlocks = (document) => [...document.matchAll(/```text\r?\n([\s\S]*?)```/g)].map((match) => match[1].trim()); const headings = [...catalog.matchAll(/^### (\d+)\. (.+)$/gm)]; const operationSections = headings.map((match, index) => { const start = match.index; const end = headings[index + 1]?.index ?? catalog.length; return { number: Number(match[1]), directive: match[2].trim(), body: catalog.slice(start, end) }; }); const sectionByDirective = new Map(operationSections.map((section) => [section.directive, section])); const labeledBlock = (section, label) => { const pattern = new RegExp( `^#### ${label}\\r?\\n\\r?\\n\`\`\`text\\r?\\n([\\s\\S]*?)\`\`\``, 'm' ); return section.body.match(pattern)?.[1].trim() ?? null; }; const fieldLines = (block) => block .split(/\r?\n/) .slice(1) .filter((line) => line.includes(':')); const assertTwoBlockContract = (document, directives, source) => { const blocks = textBlocks(document); for (const directive of directives) { const matches = blocks.filter((block) => block.startsWith(`${directive}\n`)); assert.equal(matches.length, 2, `${source}: ${directive} must have exactly template + example`); const [template, example] = matches; for (const line of fieldLines(template)) { assert.match(line, /(必填|选填):/, `${source}: unmarked template field: ${line}`); } assert.doesNotMatch(example, /(必填|选填)/, `${source}: example contains field rules`); assert.doesNotMatch(example, /<[^>]+>/, `${source}: example contains a placeholder`); } }; test('catalog has exactly 18 ordered operations with adjacent template and example blocks', () => { assert.deepEqual( operationSections.map((section) => section.number), Array.from({ length: 18 }, (_, index) => index + 1) ); assert.deepEqual(operationSections.map((section) => section.directive), allDirectives); for (const section of operationSections) { const template = labeledBlock(section, '模板'); const example = labeledBlock(section, '示例'); assert.ok(template, `${section.directive}: missing template block`); assert.ok(example, `${section.directive}: missing example block`); assert.equal(textBlocks(section.body).length, 2, `${section.directive}: must contain only two text blocks`); assert.equal(template.split(/\r?\n/, 1)[0], section.directive); assert.equal(example.split(/\r?\n/, 1)[0], section.directive); assert.ok(section.body.indexOf('#### 模板') < section.body.indexOf('#### 示例')); } assert.doesNotMatch(catalog, /最小必填速查|## 二、能力状态/); }); test('main prompt remains a compact Skill router', () => { assert.match(mainPrompt, /prompt_version: ltjt-agent-prompt-v1\.8-independent-headcount-categories/); for (const action of [ 'team_order_create', 'team_order_batch_create', 'shared_plan_create', 'shared_child_order_create', 'passenger_list_import', 'arrangement_guide', 'arrangement_vehicle', 'arrangement_hotel', 'arrangement_transport', 'arrangement_other', 'order_update_shared_plan', 'order_update_shared_child', 'order_update_independent', 'order_cancel', 'order_restore', 'confirmation_export' ]) assert.equal(mainPrompt.includes(action), false, `main prompt duplicates Skill action: ${action}`); assert.match(mainPrompt, /`agent_parse_needs_input`/); assert.match(mainPrompt, /`agent_parse_blocked`/); assert.match(mainPrompt, /你是老挝联泰系统操作员。根据用户实际表达识别业务类型,并调用适合的业务能力处理,最后原样返回标准 JSON 结果。/); assert.match(mainPrompt, /不得脱离业务能力自行编造 `operation` 字段或用户事实/); assert.doesNotMatch(mainPrompt, /## 18 类标准路由|## 输出状态|## 提取边界|```json/); assert.ok(mainPrompt.length < 1_200, `main prompt is too large: ${mainPrompt.length}`); assert.doesNotMatch(mainPrompt, /插件|ERP|门禁|TEST-202609|test_context|server_response|native_request|requery|fresh|write_attempted|DoInfo|allowlist|\b(?:tid|ddid|row_id)\b/); const runtimeMessage = buildParserMessage({ rawText: '独立团信息修改\n单号:LW-260903-A\n目标修改:标间数改为9间', taskId: 'TASK-PARSE-ONLY-PROMPT', receivedAt: '2026-08-12T08:00:00.000Z' }); assert.match(runtimeMessage, /只解析用户输入,不查询对象、不执行任务、不判断结果/); assert.match(runtimeMessage, /新建业务、订单\/安排修改以及取消\/恢复订单中,用户只提供月日且原文没有年份时/); assert.match(runtimeMessage, /取消\/恢复的定位出发日期均执行上述规则/); assert.doesNotMatch(runtimeMessage, /标准指令路由/); assert.doesNotMatch(runtimeMessage, /插件|ERP|门禁|TEST-202609|test_context|server_response|native_request|requery|fresh|write_attempted|DoInfo|allowlist|\b(?:tid|ddid|row_id)\b/); }); test('all packaged Skill sources contain only parsing knowledge', () => { // Business-field names such as the ERP leader lookup column are parsing // knowledge. Skills must still stay clear of execution plumbing and // internal/runtime references. assert.doesNotMatch(skillRuntimeText, /插件|浏览器|门禁|TEST-202609|test_context|server_response|native_request|requery|fresh|write_attempted|DoInfo|allowlist|\b(?:tid|ddid|row_id)\b|E2E\d*/); for (const action of [ 'team_order_create', 'team_order_batch_create', 'shared_plan_create', 'shared_child_order_create', 'passenger_list_import', 'arrangement_guide', 'arrangement_vehicle', 'arrangement_hotel', 'arrangement_transport', 'arrangement_other', 'order_update_shared_plan', 'order_update_shared_child', 'order_update_independent', 'order_cancel', 'order_restore', 'confirmation_export' ]) assert.ok(skillRuntimeText.includes(action), `Skill sources missing parser action: ${action}`); }); test('Agent parse Schema accepts business facts and rejects execution metadata', () => { const operationSchema = JSON.parse(read('schemas/agent_operation.schema.json')); const resultSchema = JSON.parse(read('schemas/agent_parse_result.schema.json')); const ajv = new Ajv2020({ allErrors: true, strict: false }); ajv.addFormat('date', /^\d{4}-\d{2}-\d{2}$/); ajv.addSchema(operationSchema); const validate = ajv.compile(resultSchema); const parseOnly = { status: 'agent_parse_passed', blockers: [], operation: { action: 'order_cancel', order_nature: 'formal', submit_mode: 'dry_run', data: { existing_refs: { identifier: 'LW-260903-A' } } } }; assert.equal(validate(parseOnly), true, JSON.stringify(validate.errors)); const multiTargetUpdate = { status: 'agent_parse_passed', blockers: [], 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: 'pax.adult', operation: 'set', value: 8 }, { target: 'pax.child_bed', operation: 'set', value: 1 }, { target: 'pax.leader', operation: 'set', value: 1 } ] } } } }; assert.equal(validate(multiTargetUpdate), true, JSON.stringify(validate.errors)); const businessLookup = structuredClone(parseOnly); businessLookup.operation.data = { customer: { name: '辽宁康辉', keyword: '辽宁康辉' }, departure_dates: ['2026-09-03'], product: { name: '遇见老挝', keyword: '遇见老挝' }, leader: { name: '张三', keyword: '张三' } }; assert.equal(validate(businessLookup), true, JSON.stringify(validate.errors)); const executionMetadata = structuredClone(parseOnly); executionMetadata.operation.source = { test_context: { run_id: 'not-agent-data' } }; assert.equal(validate(executionMetadata), false); assert.match(JSON.stringify(validate.errors), /additionalProperties/); }); test('template fields are marked and examples contain only concrete values', () => { for (const section of operationSections) { const template = labeledBlock(section, '模板'); const example = labeledBlock(section, '示例'); const fields = fieldLines(template); assert.ok(fields.length > 0, `${section.directive}: expected at least one field`); assert.ok(fields.some((line) => line.includes('(必填):')), `${section.directive}: no required field`); for (const line of fields) { assert.match(line, /(必填|选填):/, `${section.directive}: unmarked template field: ${line}`); } assert.doesNotMatch(example, /(必填|选填)/, `${section.directive}: example contains field rules`); assert.doesNotMatch(example, /<[^>]+>/, `${section.directive}: example contains a placeholder`); } }); test('user templates never request ERP internal identifiers and use canonical customer label', () => { const forbiddenLabels = /^(?:母团\s*)?(?:tid|ddid|row_id|行\s*ID|账号|运行编号|测试标记|allowlist)((?:必填|选填)):/im; for (const section of operationSections) { const template = labeledBlock(section, '模板'); assert.doesNotMatch(template, forbiddenLabels); assert.doesNotMatch(template, /^预定客户((?:必填|选填)):/m, 'template must use 预订客户'); } }); test('all current directives are routed to their Skill contract in the same two-block format', () => { for (const directive of allDirectives) { assert.ok(registry.includes(`\`${directive}\``), `registry missing ${directive}`); } for (const [relativePath, directives] of Object.entries(skillContracts)) { assertTwoBlockContract(read(relativePath), directives, relativePath); } }); test('the four detailed business pages use adjacent template and example blocks', () => { for (const [relativePath, directive] of Object.entries(businessPages)) { const document = read(relativePath); const blocks = textBlocks(document).filter((block) => block.startsWith(`${directive}\n`)); assert.equal(blocks.length, 2, `${relativePath}: expected one template and one example`); assert.match(document, /### 1\.1 模板\r?\n/); assert.match(document, /### 1\.2 示例\r?\n/); for (const line of fieldLines(blocks[0])) { assert.match(line, /(必填|选填):/, `${relativePath}: unmarked template field: ${line}`); } assert.doesNotMatch(blocks[1], /(必填|选填)|<[^>]+>/); } }); test('the two desktop-approved examples remain exactly copyable', () => { const independentSingle = [ '独立团单个下单', '发团日期:11-01', '预订客户:老挝联泰人民币', '产品搜索:遇见老挝', '预估人数:15+1', '用房数量:8标1单' ].join('\n'); const sharedPlan = [ '散拼团新增计划', '发团日期:11-01到11-30', '产品搜索:广东衡阳6D', '计划收客数:40', '用房数量:8标1单', '发团周期:11.01/11.15/11.26' ].join('\n'); assert.equal(labeledBlock(sectionByDirective.get('独立团单个下单'), '示例'), independentSingle); assert.equal(labeledBlock(sectionByDirective.get('散拼团新增计划'), '示例'), sharedPlan); }); test('every operation preserves its explicit minimum-required field set', () => { for (const [directive, expectedLabels] of Object.entries(minimumLabels)) { const template = labeledBlock(sectionByDirective.get(directive), '模板'); const actualLabels = fieldLines(template) .filter((line) => line.includes('(必填):')) .map((line) => line.slice(0, line.indexOf('(必填):'))); assert.deepEqual(actualLabels, expectedLabels, `${directive} minimum-required fields changed`); } for (const directive of ['导入独立团名单', '导入散拼子单名单']) { const section = sectionByDirective.get(directive); assert.match(section.body, /名单附件(必填):[\s\S]*`\.xls` 或 `\.xlsx`/); assert.doesNotMatch(labeledBlock(section, '模板'), /名单内容/); } }); test('modification input asks only for targets and export stays minimal', () => { const controlPlaneReadme = read('control-plane/README.md'); const releaseGate = read('agent设计规范/test-fixtures/lwlt-lifecycle/release-gate.md'); assert.doesNotMatch(catalog, /清理测试订单/); assert.doesNotMatch(registry, /\| `清理测试订单` \|/); const forbiddenPreviousValueField = /^(?:修改前[^(]*|原值|当前值|当前安排|当前收结状态)((?:必填|选填)):/m; for (const directive of [ '修改散拼母团计划收客数', '追加散拼子单订房说明', '独立团信息修改', '安排变更' ]) { assert.doesNotMatch(labeledBlock(sectionByDirective.get(directive), '模板'), forbiddenPreviousValueField); } assert.deepEqual(minimumLabels['导出团队文件'], ['预订客户', '出发日期', '文件类型']); assert.doesNotMatch(catalog, /^### \d+\. 读取团队文件源响应$/m); assert.doesNotMatch(catalog, /^读取团队文件源响应$/m); assert.match(catalog, /游客名单返回 `\.xlsx`;其他文件通常返回 PDF/); assert.doesNotMatch(catalog, /before_snapshot|AgentBus|OSS|object key|updates\.actions/); assert.match(controlPlaneReadme, /游客信息保留原始 ERP `\.xls` 作为源归档,同时由平台通过 LibreOffice 生成真实 `\.xlsx`/); assert.match(releaseGate, /系统自行读取当前值并形成 before\/after/); }); test('team-file export uses the business name while keeping the internal action stable', () => { const confirmationContract = read('agent设计规范/skills/lwlt-confirmation/references/input-contract.md'); const platformUi = read('LianSyn-platform/app.js'); const pluginPlanner = read('chrome-extension/ltjt-order-assistant/operation-plans.js'); assert.ok(sectionByDirective.has('导出团队文件')); assert.match(confirmationContract, /# 导出团队文件输入契约/); assert.match(read('agent设计规范/skills/lwlt-confirmation/SKILL.md'), /confirmation_export operation JSON/); assert.ok(registry.includes('| `导出团队文件` | `lwlt-confirmation` | `confirmation_export` |')); assert.match(platformUi, /confirmation_export: '导出团队文件'/); assert.match(pluginPlanner, /confirmation_export:\s*{[\s\S]*?label: '导出团队文件'/); assert.match(catalog, /文件类型(必填):[\s\S]*整团游客信息/); assert.match(catalog, /整团游客信息.*散拼母团/); assert.match(catalog, /游客名单.*独立团.*散拼具体子单/); assert.match(confirmationContract, /`整团游客信息`.*散拼母团/); assert.match(confirmationContract, /`游客名单`.*独立团.*散拼具体子单/); assert.match(pluginPlanner, /validateSharedPlanVisitorExport/); assert.doesNotMatch(platformUi, /确认件源读取\/恢复/); assert.doesNotMatch(pluginPlanner, /label: '确认件源读取\/恢复'/); }); test('independent-order updates preserve broad parser targets while arrangement changes stay narrow', () => { const parser = read('LianSyn-platform/external-agent-client.mjs'); const updatingSkill = read('agent设计规范/skills/lwlt-updating/SKILL.md'); const arrangementSkill = read('agent设计规范/skills/lwlt-arrangement/SKILL.md'); const modificationSection = catalog.slice(catalog.indexOf('## 五、修改'), catalog.indexOf('## 六、取消与恢复')); for (const directive of narrowModificationDirectives) { assert.ok(modificationSection.includes(`### ${sectionByDirective.get(directive).number}. ${directive}`)); assert.ok(registry.includes(`\`${directive}\``)); } assert.match(updatingSkill, /只提取目标值,不询问、不生成当前值、原值或 before\/after/); assert.match(updatingSkill, /保留业务可见目标/); assert.match(arrangementSkill, /安排变更只接受酒店离店日期和\/或目标房间总数/); assert.match(parser, /修改类只提取目标值/); assert.match(parser, /updates\.actions 包含多个动作/); assert.match(parser, /new Set\(\['end_date', 'room_count'\]\)/); assert.match(labeledBlock(sectionByDirective.get('独立团信息修改'), '示例'), /目标修改:人数改为8\+1(占)\+1/); assert.match(updatingSkill, /兼容旧三段 `人数改为8\+1(占)\+1`/); assert.match(updatingSkill, /pax\.child_no_bed=1/); assert.match(sectionByDirective.get('独立团信息修改')?.body ?? '', /未出现的类别保持原值/); assert.match(labeledBlock(sectionByDirective.get('安排变更'), '示例'), /离店日期改为11-05;房间数改为15间/); assert.doesNotMatch(catalog, /需求登记:/); }); test('deferred items 19 to 21 are absent from current user and Skill input contracts', () => { const currentInputSurface = [ catalog, ...Object.keys(skillContracts).map((relativePath) => read(relativePath)) ].join('\n'); for (const directive of deferredDirectives) { assert.doesNotMatch(currentInputSurface, new RegExp(directive)); } assert.doesNotMatch(catalog, /^### (?:19|20|21)\./m); }); test('new business adaptation template enforces the two-block writing convention', () => { const template = read('agent设计规范/templates/business-adaptation-template.md'); assert.match(template, /#### 模板\r?\n/); assert.match(template, /#### 示例\r?\n/); assert.match(template, /模板和示例必须紧邻出现/); assert.match(template, /逐字段标明`(必填)\/(选填)`/); });