import { readFileSync } from 'node:fs'; import test from 'node:test'; import assert from 'node:assert/strict'; import { parseProgramInput } from '../src/program-parser.js'; import { classifyExecutionResult, successReceiptFromResult } from '../src/task-service.js'; import { hotelBatch, hotelBatchMatchesOperation, verifiedHotelBatchItems } from '../src/hotel-batch.js'; import { partialBatchProgressEvents } from '../src/team-progress.js'; import { arrangementEventFromSuccess } from '../src/arrangement-ledger.js'; import { buildCustomerSuccessReceipt, replyTextFromReceipt } from '../src/reply-contract.js'; const block = (day: number, group = 'LW-SAMPLE') => `团号:${group}\n入住日期:2026-09-${day}\n离店日期:2026-09-${day + 1}\n酒店搜索:示例酒店${day}TWN\n间数:1`; const parse = (rawText: string) => parseProgramInput({ rawText, receivedAt: '2026-09-24T00:00:00Z' }); type Json = Record; async function fixture() { const parsed = await parse(`安排酒店\n${block(26)}\n${block(27)}\n${block(28)}`); assert.equal(parsed.status, 'agent_parse_passed', parsed.reply as string); const operation = parsed.operation as Json; const items = operation.data.arrangements.map((arrangement: Json, index: number) => ({ index, arrangement, status: 'completed', result: { status: 'completed', write_attempted: true, no_erp_write: false, report: { status: 'lifecycle_completed', server_response: { completed: true }, resolved_refs: { kind: 'independent_order', identifier: 'LW-SAMPLE', tid: '1' }, requery: { matched: true, subpage: { arrangement_target: { row_id: String(index + 10), quantity_value: '1' }, checks: [{ field: 'riqi0', actual: arrangement.start_date, matched: true }, { field: 'riqis0', actual: arrangement.end_date, matched: true }] } } } } })); return { operation, result: { status: 'completed', write_attempted: true, no_erp_write: false, hotel_batch: { version: 1, group_number: 'LW-SAMPLE', items } } as Json }; } test('hotel input splits five repeated group blocks and preserves multi-night stays and ordinary input', async () => { const result = await parse(`安排酒店\n${[25,26,27,28,29].map(day => block(day)).join('\n')}`); assert.equal(result.status, 'agent_parse_passed'); assert.equal((result.operation?.data as Json).arrangements.length, 5); const single = await parse(`安排酒店\n${block(26).replace('2026-09-27', '2026-09-29')}`); assert.equal((single.operation?.data as Json).arrangement.end_date, '2026-09-29'); assert.equal((single.operation?.data as Json).arrangements, undefined); }); test('the entire batch blocks on cross-team, missing field, duplicate, mixed action, invalid date and oversized input', async () => { for (const raw of [ `${block(26)}\n${block(27, 'OTHER')}`, `${block(26)}\n${block(27).replace('间数:1','')}`, `${block(26)}\n${block(26)}`, `${block(26)}\n安排用车\n${block(27)}`, `${block(26)}\n${block(27).replace('2026-09-28','2026-09-26')}`, Array.from({ length:11 },(_, i)=>block(i + 10)).join('\n') ]) { const result = await parse(`安排酒店\n${raw}`); assert.notEqual(result.status, 'agent_parse_passed', raw); assert.equal(result.operation, null); assert.equal(result.no_erp_write, true); } }); test('all rows require bound, distinct write/requery evidence before whole-batch success', async () => { const { operation, result } = await fixture(); assert.equal(classifyExecutionResult(result).status, 'completed'); assert.ok(hotelBatchMatchesOperation(operation, result)); const reply = replyTextFromReceipt(buildCustomerSuccessReceipt(successReceiptFromResult(result)!, result, operation)); assert.match(reply, /酒店批量安排成功/); assert.match(reply, /成功 3 条/); assert.match(reply, /2026\/09\/28/); for (const mutate of [ (r: Json) => { r.hotel_batch.items[1].result.report.server_response.completed = false; }, (r: Json) => { r.hotel_batch.items[1].result.report.requery.subpage.arrangement_target.row_id = '10'; }, (r: Json) => { r.hotel_batch.items[1].result.report.requery.subpage.checks[0].actual = '2026-01-01'; } ]) { const bad = structuredClone(result); mutate(bad); assert.equal(classifyExecutionResult(bad).status, 'reconciliation_pending'); } const bad = structuredClone(result); bad.hotel_batch.items[1].arrangement.resource.keyword = 'different'; assert.equal(hotelBatchMatchesOperation(operation, bad), false); }); test('partial stop retains distinct successful ledger events and uncertainty never counts as success', async () => { const { operation, result } = await fixture(); result.status = 'blocked'; const last = result.hotel_batch.items[2]; last.status = 'failed'; last.result = { status: 'blocked', no_erp_write: true, write_attempted: false, message: '未匹配酒店' }; assert.equal(classifyExecutionResult(result).status, 'blocked'); assert.equal(successReceiptFromResult(result), null); const input = { taskId:'TASK', executionId:'EXEC', operation, result, receipt:null, effectiveAt:'2026-09-24T00:00:00Z', recordedAt:'2026-09-24T00:00:00Z' }; const events = partialBatchProgressEvents(input); assert.equal(events.length, 2); assert.notEqual(events[0].execution_id, events[1].execution_id); const arrangements = verifiedHotelBatchItems(operation, result).map(item => arrangementEventFromSuccess({ ...input, executionId:`EXEC:hotel:${item.index + 1}`, operation:{ action:'arrangement_hotel', data:{ existing_refs:operation.data.existing_refs, arrangement:item.arrangement } }, result:item.result, receipt:successReceiptFromResult(item.result) })); assert.ok(arrangements.every(Boolean)); assert.notEqual(arrangements[0]?.record_key, arrangements[1]?.record_key); last.status = 'uncertain'; last.result = { status:'execution_uncertain', write_attempted:true, no_erp_write:false }; result.status = 'execution_uncertain'; assert.equal(classifyExecutionResult(result).status, 'reconciliation_pending'); assert.equal(partialBatchProgressEvents(input).length, 2); assert.match(hotelBatch.resultText(result.hotel_batch), /待核验 1 条/); }); test('published operator batch example parses to three complete ordered stays', async () => { const catalog = readFileSync('agent设计规范/templates/business-input-templates.md','utf8'); const section = catalog.split('#### 同团批量安排示例')[1].split('### 11.')[0]; const rawText = section.match(/```text\n([\s\S]*?)```/)![1]; const parsed = await parse(rawText); assert.equal(parsed.status, 'agent_parse_passed'); const arrangements = (parsed.operation!.data as Json).arrangements; assert.equal(arrangements.length,3); assert.equal(arrangements[2].start_date,'2026-09-28'); assert.equal(arrangements[2].end_date,'2026-09-30'); });