Files
LWLT-AIBOT/control-plane/test/control-plane.test.ts
inman 81a0cdac8e Revert "merge: integrate extension auto-update"
This reverts commit 322475860a, reversing
changes made to f52d9d7413.
2026-09-03 16:45:14 +08:00

1654 lines
76 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import assert from 'node:assert/strict';
import { chmod, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import test from 'node:test';
import { loadConfig } from '../src/config.js';
import { decryptBytes, decryptText, encryptBytes, encryptText, hashToken, sameTokenHash, sha256Bytes } from '../src/crypto.js';
import { aiServiceConnected, buildServer } from '../src/server.js';
import { REQUIRED_SCHEMA_VERSION } from '../src/db.js';
import {
TaskService,
authorizeLifecycleOperationForManualConfirmation,
buildImmediateParserPromotionGates,
classifyExpiredExecutionResult,
classifyExecutionResult,
canAccessTask,
canExecuteBusinessRoute,
canViewOperationsDashboard,
executionLifecycleFacts,
failureSummary,
executionStatusImmutable,
executionUpdateAllowed,
hasPrewriteNoErpEvidence,
hasLifecycleTestContext,
isTaskOwnerRestricted,
isLifecycleOperation,
parseLifecycleFacts,
prepareParsedOperationForConfirmation,
prepareExecutionArtifacts,
shouldAutomaticallyConfirm,
successReceiptFromResult
} from '../src/task-service.js';
import { detectBusinessDirective, isNewDirectiveMessage } from '../src/message-routing.js';
import { DatabaseArtifactStore, OssArtifactStore } from '../src/artifact-store.js';
test('message routing starts a new session for a business directive, not for a supplement', () => {
assert.equal(detectBusinessDirective('安排用车\n团号LW-260903A-A'), '安排用车');
assert.equal(detectBusinessDirective('\uFEFF 安排 用车:\n团号LW-260903A-A'), '安排用车');
assert.equal(detectBusinessDirective('散拼团拼单\n预订客户辽宁康辉'), '散拼团拼单');
assert.equal(detectBusinessDirective('补充车型/项目说明:旅游大巴'), null);
assert.equal(detectBusinessDirective('请继续处理,安排用车'), null);
assert.equal(detectBusinessDirective('AgentBus 转发\n安排用车\n团号LW-260903A-A'), '安排用车');
assert.equal(detectBusinessDirective(`发团日期09-03\n预订客户老挝联泰人名币\n产品搜索遇见老挝\n预估人数15+1\n用房数量8标1单`), null);
assert.equal(isNewDirectiveMessage('导入散拼子单名单\n预订客户辽宁康辉'), true);
assert.equal(isNewDirectiveMessage('数量改为 2'), false);
});
test('task access contract isolates users and team leads while preserving administrator and worker access', () => {
const cases = [
{ name: 'administrator can inspect another assigned task', access: { userId: 'admin', role: 'admin' as const }, assignedUserId: 'user-a', allowed: true },
{ name: 'trusted worker can inspect an unassigned task', access: { userId: '', role: undefined }, assignedUserId: null, allowed: true },
{ name: 'team lead sees own manual task', access: { userId: 'lead-a', role: 'team_lead' as const }, assignedUserId: 'lead-a', allowed: true },
{ name: 'team lead cannot use the normal task path for another task', access: { userId: 'lead-a', role: 'team_lead' as const }, assignedUserId: 'user-b', allowed: false },
{ name: 'team lead sees own AgentBus work', access: { userId: 'lead-a', role: 'team_lead' as const }, assignedUserId: 'lead-a', allowed: true },
{ name: 'ordinary user sees own manual task', access: { userId: 'user-a', role: 'user' as const }, assignedUserId: 'user-a', allowed: true },
{ name: 'ordinary user cannot see another task', access: { userId: 'user-a', role: 'user' as const }, assignedUserId: 'user-b', allowed: false },
{ name: 'ordinary user sees own AgentBus task', access: { userId: 'user-a', role: 'user' as const }, assignedUserId: 'user-a', allowed: true },
{ name: 'ordinary user without an actor cannot see a task', access: { userId: '', role: 'user' as const }, assignedUserId: '', allowed: false }
];
for (const item of cases) {
assert.equal(canAccessTask(item.access, { assignedUserId: item.assignedUserId }), item.allowed, item.name);
}
assert.equal(isTaskOwnerRestricted('admin'), false);
assert.equal(isTaskOwnerRestricted('team_lead'), true);
assert.equal(isTaskOwnerRestricted('user'), true);
assert.equal(canViewOperationsDashboard('admin'), true);
assert.equal(canViewOperationsDashboard('team_lead'), true);
assert.equal(canViewOperationsDashboard('user'), false);
});
test('business route authorization is an explicit allowlist for team leads and ordinary users', () => {
const routeId = 'arrangement_hotel_create' as const;
assert.equal(canExecuteBusinessRoute({
role: 'admin', source: 'manual', routeId: null, authorizedRouteIds: []
}), true, 'administrators retain all registered and unclassified manual intake');
assert.equal(canExecuteBusinessRoute({
role: 'team_lead', source: 'manual', routeId, authorizedRouteIds: [routeId]
}), true, 'team lead can use a granted route');
assert.equal(canExecuteBusinessRoute({
role: 'team_lead', source: 'manual', routeId, authorizedRouteIds: []
}), false, 'team lead cannot use an ungranted route');
assert.equal(canExecuteBusinessRoute({
role: 'user', source: 'manual', routeId, authorizedRouteIds: [routeId]
}), true, 'ordinary user can use a granted route');
assert.equal(canExecuteBusinessRoute({
role: 'user', source: 'manual', routeId: null, authorizedRouteIds: [routeId]
}), false, 'unclassified manual input fails closed for non-administrators');
assert.equal(canExecuteBusinessRoute({
role: 'user', source: 'agentbus', routeId, authorizedRouteIds: [routeId]
}), true, 'bound AgentBus intake uses the employee route allowlist');
assert.equal(canExecuteBusinessRoute({
role: undefined, source: 'agentbus', routeId, authorizedRouteIds: [routeId]
}), false, 'unbound AgentBus intake fails closed');
assert.equal(canExecuteBusinessRoute({
role: 'admin', source: 'agentbus', routeId, authorizedRouteIds: [routeId]
}), false, 'administrators cannot be AgentBus execution owners');
});
test('field encryption round-trips without storing plaintext', () => {
const config = loadConfig({
NODE_ENV: 'test',
FIELD_ENCRYPTION_KEY: Buffer.alloc(32, 7).toString('base64')
});
const plaintext = '含有客户信息的测试指令';
const encrypted = encryptText(config, plaintext);
assert.notEqual(encrypted, plaintext);
assert.equal(decryptText(config, encrypted), plaintext);
});
test('field encryption round-trips binary task attachments', () => {
const config = loadConfig({
NODE_ENV: 'test',
FIELD_ENCRYPTION_KEY: Buffer.alloc(32, 17).toString('base64')
});
const content = Buffer.from([0, 1, 2, 127, 128, 255]);
const encrypted = encryptBytes(config, content);
assert.equal(encrypted.includes(content.toString('base64')), false);
assert.deepEqual(decryptBytes(config, encrypted), content);
});
test('database artifact store sends encrypted bytes to the persistence boundary', async () => {
const config = loadConfig({
NODE_ENV: 'test',
FIELD_ENCRYPTION_KEY: Buffer.alloc(32, 18).toString('base64')
});
const content = Buffer.from([0, 1, 2, 127, 128, 255]);
let queryValues: unknown[] = [];
const store = new DatabaseArtifactStore(config);
const artifact = await store.put({
async query(_sql: string, values: unknown[]) {
queryValues = values;
return {
rowCount: 1,
rows: [{
id: '11111111-1111-4111-8111-111111111111',
organization_id: '22222222-2222-4222-8222-222222222222',
task_id: '33333333-3333-4333-8333-333333333333',
execution_id: '44444444-4444-4444-8444-444444444444',
artifact_index: 0,
artifact_type: 'liantai-confirm',
file_name: 'team.doc',
content_type: 'application/msword',
byte_size: content.byteLength,
sha256: 'a'.repeat(64),
storage_backend: 'database',
created_at: '2026-08-16T00:00:00.000Z'
}]
};
}
} as any, {
organizationId: '22222222-2222-4222-8222-222222222222',
taskRowId: '33333333-3333-4333-8333-333333333333',
taskId: 'TASK-TEST',
executionId: '44444444-4444-4444-8444-444444444444',
artifactIndex: 0,
type: 'liantai-confirm',
fileName: 'team.doc',
contentType: 'application/msword',
byteSize: content.byteLength,
sha256: 'a'.repeat(64),
content
});
assert.equal(artifact.storage_backend, 'database');
assert.equal(typeof queryValues[9], 'string');
assert.notEqual(queryValues[9], content.toString('base64'));
assert.deepEqual(decryptBytes(config, queryValues[9] as string), content);
});
test('production OSS configuration requires provider credentials and derives the region', () => {
assert.throws(
() => loadConfig({ NODE_ENV: 'test', ARTIFACT_STORAGE_BACKEND: 'oss' }),
/OSS artifact storage is enabled but missing configuration/
);
const config = loadConfig({
NODE_ENV: 'test',
ARTIFACT_STORAGE_BACKEND: 'oss',
OSS_ACCESS_KEY_ID: 'test-access-key',
OSS_ACCESS_KEY_SECRET: 'test-access-secret',
OSS_ENDPOINT: 'oss-cn-guangzhou.aliyuncs.com',
OSS_BUCKET_NAME: 'test-bucket'
});
assert.equal(config.ossRegion, 'cn-guangzhou');
assert.equal(config.DATA_RETENTION_ENABLED, false);
});
test('OSS artifact store writes metadata with an idempotent UUID execution path and cleans captured keys', async () => {
const config = loadConfig({
NODE_ENV: 'test',
FIELD_ENCRYPTION_KEY: Buffer.alloc(32, 19).toString('base64'),
ARTIFACT_STORAGE_BACKEND: 'oss',
OSS_ACCESS_KEY_ID: 'test-access-key',
OSS_ACCESS_KEY_SECRET: 'test-access-secret',
OSS_ENDPOINT: 'oss-cn-guangzhou.aliyuncs.com',
OSS_BUCKET_NAME: 'test-bucket'
});
const calls: Array<{ operation: string; key: string }> = [];
const client = {
async putObject(key: string) { calls.push({ operation: 'put', key }); },
async deleteObject(key: string) { calls.push({ operation: 'delete', key }); },
publicUrl(key: string) { return `https://test-bucket.oss-cn-guangzhou.aliyuncs.com/${key}`; }
};
const store = new OssArtifactStore(config, client);
const artifact = await store.put({
async query(sql: string, values: unknown[]) {
assert.match(sql, /storage_backend, storage_key, content_ciphertext/);
assert.equal(values[2], '44444444-4444-4444-8444-444444444444');
assert.equal(values[9], 'liansyn-platform/attachments/44444444-4444-4444-8444-444444444444/0');
return {
rowCount: 1,
rows: [{
id: '11111111-1111-4111-8111-111111111111',
organization_id: '22222222-2222-4222-8222-222222222222',
task_id: '33333333-3333-4333-8333-333333333333',
execution_id: '44444444-4444-4444-8444-444444444444',
artifact_index: 0,
artifact_type: 'liantai-confirm',
file_name: 'team.doc',
content_type: 'application/msword',
byte_size: 3,
sha256: 'a'.repeat(64),
storage_backend: 'oss',
storage_key: 'liansyn-platform/attachments/44444444-4444-4444-8444-444444444444/0',
created_at: '2026-08-16T00:00:00.000Z'
}]
};
}
} as any, {
organizationId: '22222222-2222-4222-8222-222222222222',
taskRowId: '33333333-3333-4333-8333-333333333333',
taskId: 'TASK-TEST',
executionId: '44444444-4444-4444-8444-444444444444',
artifactIndex: 0,
type: 'liantai-confirm',
fileName: 'team.doc',
contentType: 'application/msword',
byteSize: 3,
sha256: 'a'.repeat(64),
content: Buffer.from('abc')
});
assert.equal(artifact.storage_backend, 'oss');
assert.equal(artifact.public_url, 'https://test-bucket.oss-cn-guangzhou.aliyuncs.com/liansyn-platform/attachments/44444444-4444-4444-8444-444444444444/0');
assert.deepEqual(calls, [{ operation: 'put', key: 'liansyn-platform/attachments/44444444-4444-4444-8444-444444444444/0' }]);
await store.cleanup([artifact]);
assert.deepEqual(calls, [
{ operation: 'put', key: 'liansyn-platform/attachments/44444444-4444-4444-8444-444444444444/0' },
{ operation: 'delete', key: 'liansyn-platform/attachments/44444444-4444-4444-8444-444444444444/0' }
]);
});
test('token hashes compare safely and do not expose the original token', () => {
const token = 'temporary-session-token';
const hash = hashToken(token);
assert.equal(hash.toString('utf8').includes(token), false);
assert.equal(sameTokenHash(hash, hashToken(token)), true);
assert.equal(sameTokenHash(hash, hashToken(`${token}-other`)), false);
});
test('organization automation applies uniformly to every valid task source', () => {
const eligible = {
needsInput: false,
blocked: false,
source: 'agentbus',
automationEnabled: true
};
assert.equal(shouldAutomaticallyConfirm({ ...eligible, source: 'agentbus' }), true);
assert.equal(shouldAutomaticallyConfirm({ ...eligible, source: 'manual' }), true);
assert.equal(shouldAutomaticallyConfirm({ ...eligible, needsInput: true }), false);
assert.equal(shouldAutomaticallyConfirm({ ...eligible, blocked: true }), false);
assert.equal(shouldAutomaticallyConfirm({ ...eligible, automationEnabled: false }), false);
});
test('control plane exposes a live health endpoint without a database connection', async () => {
const config = loadConfig({
NODE_ENV: 'test',
FIELD_ENCRYPTION_KEY: Buffer.alloc(32, 8).toString('base64'),
DATABASE_URL: 'postgresql://invalid:invalid@127.0.0.1:1/invalid'
});
const { app } = await buildServer({
config,
startParserLoop: false,
parser: {
async parse() {
return { blockers: ['test parser'] };
},
async checkConnection() {
return { ok: false, configured: false };
}
}
});
const response = await app.inject({ method: 'GET', url: '/health/live' });
assert.equal(response.statusCode, 200);
assert.deepEqual(response.json(), { ok: true, service: 'ltjt-control-plane' });
await app.close();
});
test('AI primary connection state follows service reachability, not historical authentication evidence', () => {
assert.equal(aiServiceConnected(true, {
configured: true,
reachable: true,
authenticated: false,
last_auth_failure_code: 'external_unauthorized'
}), true);
assert.equal(aiServiceConnected(false, { configured: true, reachable: true }), false);
assert.equal(aiServiceConnected(true, { configured: false, reachable: true }), false);
assert.equal(aiServiceConnected(true, { configured: true, reachable: false }), false);
});
test('migration contains the durable state tables and safety fields', async () => {
const { readFile } = await import('node:fs/promises');
const sql = await readFile(new URL('../migrations/001_initial.sql', import.meta.url), 'utf8');
for (const marker of ['organizations', 'users', 'sessions', 'tasks', 'task_events', 'task_attempts', 'idempotency_keys', 'audit_events', 'browser_connections', 'outbox_events', 'execution_result']) {
assert.match(sql, new RegExp(`\\b${marker}\\b`));
}
});
test('control plane requires the latest durable task-outcome migration before readiness', async () => {
const { readFile } = await import('node:fs/promises');
const db = await readFile(new URL('../src/db.ts', import.meta.url), 'utf8');
const server = await readFile(new URL('../src/server.ts', import.meta.url), 'utf8');
assert.equal(REQUIRED_SCHEMA_VERSION, '018_agentbus_account_workers');
assert.match(db, /schema_migrations/);
assert.match(db, /databaseReadiness/);
assert.match(db, /assertDatabaseSchema/);
assert.match(db, /pool\.on\('error'/);
assert.match(db, /transaction rollback failed/);
assert.match(server, /await assertDatabaseSchema\(config\)/);
assert.match(server, /await app\.close\(\)\.catch/);
assert.match(server, /required_migration/);
});
test('ERP execution results fail closed after a possible write', () => {
assert.deepEqual(classifyExecutionResult({ status: 'running' }), {
rawStatus: 'running',
status: 'running',
terminal: false,
uncertain: false
});
assert.deepEqual(classifyExecutionResult({
status: 'running',
stage: 'verification',
write_attempted: true,
execution_phase: 'submitted'
}), {
rawStatus: 'running',
status: 'running',
terminal: false,
uncertain: false
});
assert.equal(classifyExecutionResult({ status: 'completed', write_attempted: true }).status, 'reconciliation_pending');
assert.equal(classifyExecutionResult({
status: 'completed',
write_attempted: true,
erp_receipt: { group_numbers: ['LW-260907A-B'] }
}).status, 'completed');
assert.equal(classifyExecutionResult({ status: 'blocked', no_erp_write: true }).status, 'blocked');
assert.equal(classifyExecutionResult({ status: 'blocked', write_attempted: true }).status, 'reconciliation_pending');
assert.equal(classifyExecutionResult({ status: 'saved_unverified' }).status, 'reconciliation_pending');
assert.equal(classifyExecutionResult({ status: 'unexpected_plugin_state' }).status, 'reconciliation_pending');
assert.equal(executionUpdateAllowed('queued'), true);
assert.equal(executionUpdateAllowed('confirmed'), false);
assert.equal(executionStatusImmutable('completed'), true);
assert.equal(executionStatusImmutable('reconciliation_pending'), true);
});
test('successful ERP executor results expose consistent lifecycle boundary facts', () => {
assert.deepEqual(executionLifecycleFacts({
status: 'completed',
execution_phase: 'submitted',
write_attempted: true,
no_erp_write: false,
erp_receipt: {
group_numbers: ['LW-260907A-B', 'LW-260915A-A']
},
verification: {
status: 'parent_group_found',
group_numbers: ['LW-260907A-B', 'LW-260915A-A']
}
}, 'completed', true), {
agent_returned: true,
plugin_dispatch_started: true,
erp_write_started: true,
no_plugin_dispatch: false,
no_erp_write: false,
write_attempted: true,
reconciliation_resolved: true
});
});
test('reconciliation can only be downgraded to no-write with explicit page evidence', () => {
const storedMisclassification = {
status: 'execution_uncertain',
write_attempted: true,
execution_phase: 'write_started',
report: {
status: 'live_submit_blocked',
submit_safety: { live_submit_attempted: false },
native_request: null,
server_response: null,
requery: null,
side_effects: []
}
};
assert.equal(hasPrewriteNoErpEvidence(storedMisclassification), true);
assert.equal(hasPrewriteNoErpEvidence({
...storedMisclassification,
report: {
...storedMisclassification.report,
submit_safety: { live_submit_attempted: true }
}
}), false);
assert.equal(hasPrewriteNoErpEvidence({
...storedMisclassification,
report: {
...storedMisclassification.report,
ajax_records: [{ prevented_from_network: false, network_request_attempted: true }]
}
}), false);
assert.equal(hasPrewriteNoErpEvidence({
...storedMisclassification,
report: { ...storedMisclassification.report, side_effects: { unknown: true } }
}), false);
});
test('success receipts require verifiable ERP evidence', () => {
assert.deepEqual(successReceiptFromResult({
status: 'completed',
erp_receipt: { group_numbers: ['LW-260907A-B', 'LW-260915A-A'] },
verification: { status: 'team_batch_verification_completed' }
}), {
group_numbers: ['LW-260907A-B', 'LW-260915A-A'],
group_number: 'LW-260907A-B',
verification_status: 'team_batch_verification_completed'
});
assert.equal(successReceiptFromResult({ status: 'completed', message: '执行器报告完成,但没有团号。' }), null);
});
test('multi-date split-order probes require complete child facts and no manual review', () => {
const perDate = ['2026-09-02', '2026-09-15', '2026-09-28'].map((date, index) => ({
date,
facts_determined: true,
outcome: 'concrete_shared_children_with_requested_facts',
parent_group_no: `LW-${date.replaceAll('-', '').slice(2)}TEST-202609-E2E3-A`,
parent_tid: String(15001 + index),
customer_requested: true,
customer_persisted_on_native_list: true,
passenger_counts_requested: true,
passenger_counts_persisted_on_native_list: true,
child_count: 1,
child_refs: [{ tid: String(15001 + index), ddid: String(16001 + index), child_order_no: `D${16001 + index}` }]
}));
const groupNumbers = perDate.map((row) => row.parent_group_no);
const result: any = {
status: 'completed',
report: {
status: 'split_parent_completed',
manual_review_required: false,
blockers: [],
erp_receipt: {
group_numbers: groupNumbers,
split_order_probe: {
status: 'all_dates_facts_determined',
per_date: perDate,
manual_review_required: false
}
},
verification: {
status: 'parent_groups_and_split_order_facts_determined',
group_numbers: groupNumbers,
split_order_probe: { facts_status: 'all_dates_facts_determined', per_date: perDate }
}
}
};
const receipt = successReceiptFromResult(result);
assert.deepEqual(receipt?.group_numbers, groupNumbers);
assert.equal(receipt?.verification_status, 'split_parent_completed');
const parentOnly = structuredClone(result);
parentOnly.report.manual_review_required = true;
parentOnly.report.erp_receipt.split_order_probe.manual_review_required = true;
parentOnly.report.erp_receipt.split_order_probe.per_date[0].outcome = 'parent_row_facts_without_concrete_child';
parentOnly.report.erp_receipt.split_order_probe.per_date[0].child_count = 0;
parentOnly.report.erp_receipt.split_order_probe.per_date[0].child_refs = [];
assert.equal(successReceiptFromResult(parentOnly), null);
const incomplete = structuredClone(result);
incomplete.report.verification.split_order_probe.facts_status = 'partial_dates_facts_determined';
assert.equal(successReceiptFromResult(incomplete), null);
});
test('unified lifecycle response plus fresh requery is accepted as completion evidence', () => {
const result = {
status: 'completed',
write_attempted: true,
no_erp_write: false,
summary: { action: 'arrangement_hotel' },
report: {
status: 'lifecycle_completed',
resolved_refs: {
kind: 'independent_order', identifier: 'LW-260912-TEST-202609-R0511',
tid: '17100', ddid: '17101'
},
server_response: { completed: true, http_status: 200 },
requery: { matched: true },
blockers: [],
manual_review_required: false
}
};
assert.deepEqual(successReceiptFromResult(result), {
status: 'verified',
success: true,
identifier: 'LW-260912-TEST-202609-R0511',
target_kind: 'independent_order',
action: 'arrangement_hotel',
server_response_completed: true,
requery_matched: true,
source_only: false,
group_number: 'LW-260912-TEST-202609-R0511',
verification_status: 'lifecycle_completed'
});
assert.equal(classifyExecutionResult(result).status, 'completed');
const missingRequery = structuredClone(result) as any;
missingRequery.report.requery.matched = false;
assert.equal(successReceiptFromResult(missingRequery), null);
assert.equal(classifyExecutionResult(missingRequery).status, 'reconciliation_pending');
const reviewRequired = structuredClone(result) as any;
reviewRequired.report.manual_review_required = true;
assert.equal(successReceiptFromResult(reviewRequired), null);
});
test('passenger list explicit server success does not require a post-save row requery', () => {
const result = {
status: 'completed',
write_attempted: true,
no_erp_write: false,
summary: { action: 'passenger_list_import' },
report: {
status: 'lifecycle_completed',
resolved_refs: {
kind: 'shared_child_order', identifier: 'D12345',
tid: '17100', ddid: '12345'
},
server_response: { completed: true, http_status: 200 },
requery: {
matched: true,
required: false,
skipped: true,
completion_policy: 'passenger_list_explicit_server_success'
},
blockers: [],
manual_review_required: false
}
};
assert.deepEqual(successReceiptFromResult(result), {
status: 'verified',
success: true,
identifier: 'D12345',
target_kind: 'shared_child_order',
action: 'passenger_list_import',
server_response_completed: true,
requery_matched: false,
requery_required: false,
completion_policy: 'passenger_list_explicit_server_success',
source_only: false,
order_number: 'D12345',
verification_status: 'lifecycle_completed'
});
assert.equal(classifyExecutionResult(result).status, 'completed');
const wrongAction = structuredClone(result) as any;
wrongAction.summary.action = 'arrangement_hotel';
assert.equal(successReceiptFromResult(wrongAction), null);
assert.equal(classifyExecutionResult(wrongAction).status, 'reconciliation_pending');
});
test('backend-attached export requires frozen hashes and explicit no-delivery evidence before it becomes a success receipt', () => {
const artifacts = Array.from({ length: 10 }, (_, index) => ({
type: `source-${index + 1}`,
ok: true,
http_status: 200,
bytes: 128 + index,
content_type: 'application/msword',
sha256: String(index + 1).padStart(64, 'a').slice(-64),
login_timeout: false,
permission_error: false,
downloaded: false,
backend_stored: true,
artifact_id: '11111111-1111-4111-8111-111111111111',
converted: false,
scheduled: false,
sent: false
}));
const result = {
status: 'completed',
no_erp_write: true,
write_attempted: false,
summary: { action: 'confirmation_export' },
report: {
status: 'export_source_completed',
resolved_refs: {
kind: 'shared_child_order', child_order_no: 'D17102', tid: '17100', ddid: '17102'
},
server_response: { completed: true, http_status: 200, artifact_count: 10 },
requery: { matched: true, hashes_frozen: true },
artifacts,
no_erp_write: true,
write_attempted: false,
downloaded: false,
backend_stored: true,
converted: false,
scheduled: false,
sent: false,
backend_storage: 'database',
blockers: [],
manual_review_required: false
}
};
assert.deepEqual(successReceiptFromResult(result), {
status: 'verified',
success: true,
identifier: 'D17102',
target_kind: 'shared_child_order',
action: 'confirmation_export',
server_response_completed: true,
requery_matched: true,
source_only: true,
order_number: 'D17102',
artifact_count: 10,
hashes_frozen: true,
verification_status: 'export_source_completed'
});
assert.equal(classifyExecutionResult(result).status, 'completed');
const converted = structuredClone(result) as any;
converted.report.converted = true;
converted.report.conversion_status = 'pdf';
converted.report.artifacts = converted.report.artifacts.map((artifact: Record<string, unknown>) => ({
...artifact,
file_name: 'source.pdf',
content_type: 'application/pdf',
converted: true,
conversion_status: 'converted'
}));
assert.equal(successReceiptFromResult(converted)?.artifact_count, 10);
const unfrozen = structuredClone(result) as any;
unfrozen.report.requery.hashes_frozen = false;
assert.equal(successReceiptFromResult(unfrozen), null);
const scheduled = structuredClone(result) as any;
scheduled.report.scheduled = true;
assert.equal(successReceiptFromResult(scheduled), null);
const scheduledArtifact = structuredClone(result) as any;
scheduledArtifact.report.artifacts[0].scheduled = true;
assert.equal(successReceiptFromResult(scheduledArtifact), null);
const mismatchedCount = structuredClone(result) as any;
mismatchedCount.report.server_response.artifact_count = 9;
assert.equal(successReceiptFromResult(mismatchedCount), null);
});
test('confirmation export archives visitor XLS, delivers real XLSX, and converts other types to PDF', {
skip: process.platform === 'win32' ? 'Unix fake converter fixture is not executable through Windows execFile.' : false
}, async () => {
const temporaryDirectory = await mkdtemp(join(tmpdir(), 'ltjt-selective-conversion-test-'));
const converterPath = join(temporaryDirectory, 'fake-soffice.sh');
await writeFile(converterPath, [
'#!/bin/sh',
'outdir=""',
'convert_to=""',
'next_is_outdir=0',
'next_is_convert_to=0',
'for arg in "$@"; do',
' if [ "$next_is_convert_to" = "1" ]; then convert_to="$arg"; next_is_convert_to=0; continue; fi',
' if [ "$next_is_outdir" = "1" ]; then outdir="$arg"; next_is_outdir=0; continue; fi',
' if [ "$arg" = "--convert-to" ]; then next_is_convert_to=1; continue; fi',
' if [ "$arg" = "--outdir" ]; then next_is_outdir=1; continue; fi',
'done',
'case "$convert_to" in',
' xlsx:*) printf "PK\\003\\004fake-xlsx" > "$outdir/Visitor.xlsx" ;;',
' pdf:*) printf "%s" "%PDF-1.7\\nconverted" > "$outdir/team.pdf" ;;',
' *) exit 41 ;;',
'esac'
].join('\n'), { mode: 0o700 });
await chmod(converterPath, 0o700);
try {
const config = loadConfig({
NODE_ENV: 'test',
FIELD_ENCRYPTION_KEY: Buffer.alloc(32, 26).toString('base64'),
DOCUMENT_CONVERTER_PATH: converterPath
});
const visitorContent = Buffer.from('<!doctype html><html><body><table><tr><td>游客</td></tr></table></body></html>', 'utf8');
const wordContent = Buffer.from('<html><body>team</body></html>');
const sourceArtifact = (type: string, fileName: string, contentType: string, content: Buffer) => ({
type,
ok: true,
http_status: 200,
content_type: contentType,
content_disposition: `attachment; filename="${fileName}"`,
bytes: content.byteLength,
sha256: sha256Bytes(content),
content_base64: content.toString('base64'),
downloaded: false,
converted: false,
scheduled: false,
sent: false
});
const prepared = await prepareExecutionArtifacts(config, {
report: {
status: 'export_source_completed',
artifacts: [
sourceArtifact('visitor-list', 'Visitor.xls', 'application/vnd.ms-excel', visitorContent),
sourceArtifact('liantai-confirm', 'team.doc', 'application/msword', wordContent)
]
}
});
assert.equal(prepared.failureCode, '');
assert.equal(prepared.artifacts.length, 3);
assert.deepEqual(prepared.artifacts[0].content, visitorContent);
assert.equal(prepared.artifacts[0].fileName, 'Visitor.xls');
assert.equal(prepared.artifacts[0].contentType, 'application/vnd.ms-excel');
assert.equal(prepared.artifacts[0].source.converted, false);
assert.equal(prepared.artifacts[0].source.conversion_status, 'source_native');
assert.equal(prepared.artifacts[0].source.artifact_role, 'source_archive');
assert.equal(prepared.artifacts[0].source.agentbus_visible, false);
assert.equal(prepared.artifacts[1].fileName, 'Visitor.xlsx');
assert.equal(prepared.artifacts[1].contentType, 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
assert.equal(prepared.artifacts[1].source.converted, true);
assert.equal(prepared.artifacts[1].source.conversion_status, 'converted_xlsx');
assert.equal(prepared.artifacts[1].source.artifact_role, 'mobile_delivery');
assert.equal(prepared.artifacts[1].source.agentbus_visible, true);
assert.deepEqual(prepared.artifacts[1].content.subarray(0, 4), Buffer.from([0x50, 0x4b, 0x03, 0x04]));
assert.equal(prepared.artifacts[2].fileName, 'team.pdf');
assert.equal(prepared.artifacts[2].contentType, 'application/pdf');
assert.equal(prepared.artifacts[2].source.converted, true);
assert.equal(prepared.artifacts[2].source.conversion_status, 'converted');
const sanitizedReport = prepared.sanitizedResult.report as Record<string, unknown>;
assert.equal(sanitizedReport.converted, false);
assert.equal(sanitizedReport.conversion_status, 'mixed');
assert.equal(sanitizedReport.native_source_count, 1);
assert.equal(sanitizedReport.converted_count, 2);
assert.equal(sanitizedReport.pdf_converted_count, 1);
assert.equal(sanitizedReport.xlsx_count, 1);
assert.equal(sanitizedReport.source_artifact_count, 2);
assert.equal(sanitizedReport.derived_artifact_count, 1);
assert.equal(sanitizedReport.delivered_artifact_count, 3);
assert.match(String(prepared.sanitizedResult.message), /移动端兼容 XLSX/);
assert.equal((sanitizedReport.artifacts as Array<Record<string, unknown>>)[0].content_base64, undefined);
} finally {
await rm(temporaryDirectory, { recursive: true, force: true });
}
});
test('visitor XLSX conversion failure blocks export delivery instead of returning raw XLS', async () => {
const config = loadConfig({
NODE_ENV: 'test',
FIELD_ENCRYPTION_KEY: Buffer.alloc(32, 27).toString('base64'),
DOCUMENT_CONVERTER_PATH: '/path/that/does/not/exist'
});
const content = Buffer.from('<html><body>游客</body></html>', 'utf8');
const prepared = await prepareExecutionArtifacts(config, {
report: {
status: 'export_source_completed',
artifacts: [{
type: 'visitor-list',
ok: true,
http_status: 200,
content_type: 'application/vnd.ms-excel',
content_disposition: 'attachment; filename="Visitor.xls"',
bytes: content.byteLength,
sha256: sha256Bytes(content),
content_base64: content.toString('base64')
}]
}
});
assert.match(prepared.failureCode, /^visitor_xlsx_conversion_failed:/);
assert.deepEqual(prepared.artifacts, []);
});
test('pre-write ERP blockers remain explicit after plugin dispatch', () => {
assert.deepEqual(executionLifecycleFacts({
status: 'blocked',
no_erp_write: true,
write_attempted: false,
blockers: ['product lookup keyword expected exactly one candidate, found 0']
}, 'blocked', true), {
agent_returned: true,
plugin_dispatch_started: true,
erp_write_started: false,
no_plugin_dispatch: false,
no_erp_write: true,
write_attempted: false,
reconciliation_resolved: false
});
});
test('write-capable running progress is not mislabeled as write-started', () => {
assert.deepEqual(executionLifecycleFacts({
status: 'running',
stage: 'browser_execution',
no_erp_write: false,
execution_phase: 'preflight',
write_attempted: false
}, 'running', true), {
agent_returned: true,
plugin_dispatch_started: true,
erp_write_started: false,
no_plugin_dispatch: false,
no_erp_write: false,
write_attempted: false,
reconciliation_resolved: false
});
});
test('successful parse events show Agent return and explicit pre-plugin no-write boundaries', () => {
assert.deepEqual(parseLifecycleFacts({
status: 'agent_parse_passed',
operation: { action: 'shared_plan_create' }
}, null), {
agent_returned: true,
plugin_dispatch_started: false,
erp_write_started: false,
no_plugin_dispatch: true,
no_erp_write: true
});
});
test('lifecycle live-write authority is removed after parsing and injected only by manual confirmation', () => {
const operation = {
action: 'order_update_shared_child',
order_nature: 'formal',
submit_mode: 'dry_run',
source: {
test_context: {
run_id: 'REPLAY-0511',
marker: 'TEST-202609',
account: '测试ai员工账号',
native_baseline_id: 'C09-native-baseline',
allow_live_write: true,
live_window: '2026-09',
target_dates: ['2026-09-16'],
created_refs: [{
kind: 'shared_child_order', parent_group_no: 'LW-260916-TEST-202609-R0511', child_order_no: 'D16001',
tid: '16000', ddid: '16001', marker: 'TEST-202609'
}]
}
},
data: {
existing_refs: {
kind: 'shared_child_order', parent_group_no: 'LW-260916-TEST-202609-R0511', child_order_no: 'D16001',
tid: '16000', ddid: '16001', departure_date: '2026-09-16', owner_account: '测试ai员工账号', marker: 'TEST-202609'
},
updates: { actions: [{ target: 'lodging_note', operation: 'append', value: 'TEST-202609 replay' }] }
}
};
assert.equal(isLifecycleOperation(operation), true);
const prepared = prepareParsedOperationForConfirmation(operation)!;
assert.equal(((prepared.source as any).test_context as any).allow_live_write, false);
assert.equal((operation.source.test_context as any).allow_live_write, true, 'input must not be mutated');
const authorized = authorizeLifecycleOperationForManualConfirmation(prepared);
assert.equal(((authorized.source as any).test_context as any).allow_live_write, true);
assert.equal(((authorized.source as any).test_context as any).operator, 'control-plane-admin-confirmation');
const missingCreatedRef = structuredClone(prepared) as any;
missingCreatedRef.source.test_context.created_refs = [];
assert.throws(
() => authorizeLifecycleOperationForManualConfirmation(missingCreatedRef),
/created_refs 未精确包含当前对象/
);
const outsideWindow = structuredClone(prepared) as any;
outsideWindow.data.existing_refs.departure_date = '2026-10-01';
outsideWindow.source.test_context.target_dates = ['2026-10-01'];
assert.throws(
() => authorizeLifecycleOperationForManualConfirmation(outsideWindow),
/2026 年 9 月/
);
assert.equal(isLifecycleOperation({ action: 'team_order_create' }), false);
});
test('normal lifecycle confirmation keeps platform metadata outside the business operation', () => {
const operation = {
action: 'order_cancel',
order_nature: 'formal',
submit_mode: 'dry_run',
data: { existing_refs: { identifier: 'LW-EXAMPLE-001' } }
};
assert.equal(isLifecycleOperation(operation), true, 'the action remains a lifecycle action');
assert.equal(hasLifecycleTestContext(operation), false);
const prepared = prepareParsedOperationForConfirmation(operation)!;
assert.deepEqual(prepared, operation);
assert.equal(Object.hasOwn(prepared, 'source'), false);
assert.deepEqual(authorizeLifecycleOperationForManualConfirmation(prepared), operation);
});
test('lifecycle delete authorization rechecks the frozen evidence and allowlist at confirmation time', () => {
const refs = {
kind: 'independent_order', identifier: 'LW-260912-TEST-202609-R0511', tid: '17000', ddid: '17001',
departure_date: '2026-09-12', owner_account: '测试ai员工账号', marker: 'TEST-202609'
};
const operation = prepareParsedOperationForConfirmation({
action: 'order_delete',
order_nature: 'formal',
submit_mode: 'dry_run',
source: { test_context: {
run_id: 'REPLAY-0511', marker: 'TEST-202609', account: '测试ai员工账号', native_baseline_id: 'baseline',
allow_live_write: true, live_window: '2026-09', target_dates: ['2026-09-12'], allowlist_id: 'ALLOW-0511',
created_refs: [{ kind: refs.kind, identifier: refs.identifier, tid: refs.tid, ddid: refs.ddid, marker: refs.marker }]
} },
data: {
existing_refs: refs,
delete_guard: {
marker: 'TEST-202609', allowlist_id: 'ALLOW-0511', account: '测试ai员工账号', run_id: 'REPLAY-0511',
created_refs: [{ kind: refs.kind, identifier: refs.identifier, tid: refs.tid, ddid: refs.ddid, marker: refs.marker }],
post_delete_requery: true, export_evidence_frozen: true, delete_authorized: true
}
}
})!;
assert.equal((authorizeLifecycleOperationForManualConfirmation(operation).source as any).test_context.allow_live_write, true);
(operation.data as any).delete_guard.export_evidence_frozen = false;
assert.throws(() => authorizeLifecycleOperationForManualConfirmation(operation), /证据冻结/);
});
test('uncertain ERP writes have a reconciliation-specific failure code', () => {
const failure = failureSummary({
status: 'execution_uncertain',
blockers: ['ERP response was HTTP 500 after write attempt'],
write_attempted: true,
no_erp_write: false
}, '', 'reconciliation_pending', 'browser_execution');
assert.equal(failure?.error_code, 'erp_result_uncertain');
assert.match(failure?.failure_message || '', /ERP 写入结果不确定/);
});
test('successful parse lifecycle facts are not promoted to a task failure', () => {
const failure = failureSummary({
status: 'agent_parse_passed',
operation: { action: 'team_order_create' },
blockers: [],
agent_returned: true,
plugin_dispatch_started: false,
erp_write_started: false,
no_plugin_dispatch: true,
no_erp_write: true
}, '', 'completed', 'verification');
assert.equal(failure, null);
});
test('expired plugin results fail safely and preserve reconciliation when the write boundary is unknown', () => {
assert.deepEqual(classifyExpiredExecutionResult({
status: 'running',
execution_phase: 'browser_execution'
}), {
taskStatus: 'failed',
resultStatus: 'reconciliation_pending',
attemptStatus: 'reconciliation_pending',
writeAttempted: true,
noErpWrite: false,
reconciliationRequired: true
});
assert.deepEqual(classifyExpiredExecutionResult({
status: 'running',
no_erp_write: true,
write_attempted: false
}), {
taskStatus: 'failed',
resultStatus: 'failed',
attemptStatus: 'failed',
writeAttempted: false,
noErpWrite: true,
reconciliationRequired: false
});
assert.equal(classifyExecutionResult({ status: 'failed', no_erp_write: true }).status, 'failed');
});
test('ERP business-rule rejection preserves the native message for the user', () => {
const message = '此团还有【应收团款】账,不能取消!';
const failure = failureSummary({
status: 'blocked',
error_code: 'erp_business_rule_blocked',
failure_stage: 'lifecycle_live_submit',
failure_source: 'erp',
failure_message: message,
message,
blockers: [`erp_business_rule_blocked:${message}`],
write_attempted: true,
no_erp_write: false
});
assert.equal(failure?.error_code, 'erp_business_rule_blocked');
assert.equal(failure?.failure_stage, 'lifecycle_live_submit');
assert.equal(failure?.failure_source, 'erp');
assert.equal(failure?.failure_message, message);
});
test('passenger capacity rejection preserves the business message and retains technical evidence', () => {
const message = '本次名单共 25 人,超过该独立团在 ERP 中最多可录入的 16 人。请调整名单人数后重新提交。';
const blockers = [
'passenger_native_target_row_count_not_reached:16:25',
'passenger_native_target_row_missing:17',
'passenger_dom_target_row_count_too_small:16:25'
];
const failure = failureSummary({
status: 'blocked',
error_code: 'erp_passenger_count_limit_exceeded',
failure_stage: 'lifecycle_live',
failure_source: 'erp',
failure_message: message,
message,
blockers,
write_attempted: false,
no_erp_write: true
});
assert.equal(failure?.error_code, 'erp_passenger_count_limit_exceeded');
assert.equal(failure?.failure_stage, 'lifecycle_live');
assert.equal(failure?.failure_source, 'erp');
assert.equal(failure?.failure_message, message);
assert.deepEqual(failure?.validation_errors, blockers);
});
test('nested ERP business feedback is promoted when the executor omitted failure metadata', () => {
const message = '此团还有【应收团款】账,不能取消!';
const failure = failureSummary({
status: 'blocked',
report: {
status: 'browser_execution_blocked',
business_error: { code: 'erp_business_rule_blocked', message },
server_response: { business_blocked: true, response_message: message }
},
write_attempted: true,
no_erp_write: false
});
assert.equal(failure?.error_code, 'erp_business_rule_blocked');
assert.equal(failure?.failure_source, 'erp');
assert.equal(failure?.failure_message, message);
});
test('ERP migration enforces one execution attempt per task', async () => {
const { readFile } = await import('node:fs/promises');
const sql = await readFile(new URL('../migrations/004_erp_execution_idempotency.sql', import.meta.url), 'utf8');
assert.match(sql, /UNIQUE INDEX[\s\S]+ON task_attempts \(task_id\)[\s\S]+phase = 'erp'/i);
assert.match(sql, /tasks_erp_lease_idx/);
});
test('task session migration binds one encrypted conversation to each task', async () => {
const { readFile } = await import('node:fs/promises');
const sql = await readFile(new URL('../migrations/005_agent_task_sessions.sql', import.meta.url), 'utf8');
assert.match(sql, /CREATE TABLE IF NOT EXISTS agent_sessions/);
assert.match(sql, /task_id uuid NOT NULL UNIQUE REFERENCES tasks/);
assert.match(sql, /external_session_id_ciphertext/);
assert.match(sql, /CREATE TABLE IF NOT EXISTS agent_session_messages/);
assert.match(sql, /UNIQUE \(agent_session_id, turn_no, role\)/);
assert.match(sql, /agent_session_messages_idempotency_idx/);
assert.match(sql, /status IN \('queued', 'sending', 'sent', 'replayed', 'failed'\)/);
});
test('task outcome migration stores independently timestamped success and error summaries', async () => {
const { readFile } = await import('node:fs/promises');
const sql = await readFile(new URL('../migrations/006_task_outcomes.sql', import.meta.url), 'utf8');
for (const marker of ['success_receipt', 'success_receipt_at', 'error_summary', 'error_summary_at']) {
assert.match(sql, new RegExp(`\\b${marker}\\b`));
}
});
test('task source migration distinguishes manual and AgentBus tasks', async () => {
const { readFile } = await import('node:fs/promises');
const sql = await readFile(new URL('../migrations/007_task_source.sql', import.meta.url), 'utf8');
assert.match(sql, /ADD COLUMN IF NOT EXISTS source text NOT NULL DEFAULT 'manual'/);
assert.match(sql, /CHECK \(source IN \('manual', 'agentbus'\)\)/);
assert.match(sql, /tasks_source_idx/);
});
test('organization automation migration persists the switch and task confirmation mode', async () => {
const { readFile } = await import('node:fs/promises');
const sql = await readFile(new URL('../migrations/009_organization_automation.sql', import.meta.url), 'utf8');
assert.match(sql, /organizations[\s\S]+automation_enabled boolean NOT NULL DEFAULT false/);
assert.match(sql, /tasks[\s\S]+confirmation_mode text NOT NULL DEFAULT 'manual'/);
assert.match(sql, /confirmation_mode IN \('manual', 'automatic'\)/);
assert.match(sql, /tasks_confirmation_mode_idx/);
});
test('task query optimization migration adds bounded list and event indexes', async () => {
const { readFile } = await import('node:fs/promises');
const sql = await readFile(new URL('../migrations/010_task_query_optimization.sql', import.meta.url), 'utf8');
for (const marker of [
'tasks_visible_created_idx',
'tasks_active_status_created_idx',
'tasks_active_handoff_created_idx',
'task_events_org_task_idx'
]) {
assert.match(sql, new RegExp(`CREATE INDEX IF NOT EXISTS ${marker}`));
}
});
test('task artifact migration keeps encrypted bytes scoped to the owning task', async () => {
const { readFile } = await import('node:fs/promises');
const sql = await readFile(new URL('../migrations/011_task_artifacts.sql', import.meta.url), 'utf8');
assert.match(sql, /CREATE TABLE IF NOT EXISTS task_artifacts/);
assert.match(sql, /organization_id uuid NOT NULL REFERENCES organizations\(id\) ON DELETE CASCADE/);
assert.match(sql, /task_id uuid NOT NULL REFERENCES tasks\(id\) ON DELETE CASCADE/);
assert.match(sql, /storage_backend text NOT NULL DEFAULT 'database'/);
assert.match(sql, /storage_key text/);
assert.match(sql, /content_ciphertext text/);
assert.match(sql, /storage_backend = 'database' AND content_ciphertext IS NOT NULL/);
assert.match(sql, /storage_backend = 'oss' AND storage_key IS NOT NULL/);
assert.match(sql, /UNIQUE \(task_id, execution_id, artifact_index\)/);
assert.match(sql, /task_artifacts_org_task_idx/);
});
test('AgentBus channel migration scopes encrypted keys and durable replies', async () => {
const { readFile } = await import('node:fs/promises');
const sql = await readFile(new URL('../migrations/012_agentbus_user_channels.sql', import.meta.url), 'utf8');
assert.match(sql, /CREATE TABLE IF NOT EXISTS user_channels/);
assert.match(sql, /agentbus_ws_token_ciphertext text NOT NULL/);
assert.ok(sql.includes('ADD COLUMN IF NOT EXISTS channel_id uuid REFERENCES user_channels'));
assert.ok(sql.includes('CREATE TABLE IF NOT EXISTS agentbus_deliveries'));
assert.ok(sql.includes('UNIQUE (channel_id, inbound_frame_id, delivery_kind)'));
assert.ok(sql.includes("delivery_status IN ('pending', 'sending', 'delivered', 'failed')"));
const server = await readFile(new URL('../src/server.ts', import.meta.url), 'utf8');
assert.ok(server.includes("app.get('/api/channels'"));
assert.ok(server.includes("app.post('/api/channels'"));
assert.ok(server.includes("app.delete('/api/channels/:channelId'"));
assert.ok(server.includes('rotate-key'));
assert.ok(server.includes("app.get('/channels'"));
const channels = await readFile(new URL('../src/agentbus-channels.ts', import.meta.url), 'utf8');
assert.match(channels, /async delete\(/);
assert.match(channels, /DELETE FROM user_channels/);
assert.match(channels, /agentbus_channel\.deleted/);
assert.match(channels, /channel_managed_by_environment/);
assert.match(channels, /deletable:/);
assert.match(channels, /currentExternalUserRef === LEGACY_CHANNEL_REF/);
assert.match(channels, /LEGACY_CHANNEL_REF/);
assert.match(channels, /WHERE NOT EXISTS/);
assert.match(channels, /FOR UPDATE/);
assert.match(channels, /enabled = false/);
assert.match(channels, /检测到重复的兼容渠道,已停用/);
assert.match(channels, /canonical_legacy/);
const agentBus = await readFile(new URL('../src/agentbus.ts', import.meta.url), 'utf8');
assert.match(agentBus, /stop\(persistStatus = true\)/);
assert.match(channels, /listener\.stop\(false\)/);
assert.match(channels, /reloadRequested = true/);
assert.match(channels, /while \(this\.started && this\.reloadRequested\)/);
assert.match(channels, /runtimeStatusWrites/);
assert.match(channels, /previous[\s\S]+\.catch\(\(\) => undefined\)/);
});
test('business parser migration snapshots modes and encrypts dual candidates', async () => {
const { readFile } = await import('node:fs/promises');
const sql = await readFile(new URL('../migrations/013_business_parser_modes.sql', import.meta.url), 'utf8');
assert.match(sql, /CREATE TABLE IF NOT EXISTS business_parser_settings/);
assert.match(sql, /mode IN \('ai', 'shadow', 'auto', 'program'\)/);
assert.match(sql, /ADD COLUMN IF NOT EXISTS parser_config_revision/);
assert.match(sql, /ADD COLUMN IF NOT EXISTS parser_engine_affinity/);
assert.match(sql, /parser_engine_affinity = 'ai'/);
assert.match(sql, /CREATE TABLE IF NOT EXISTS parse_decisions/);
assert.match(sql, /program_result_ciphertext text/);
assert.match(sql, /ai_result_ciphertext text/);
assert.match(sql, /UNIQUE \(task_id, attempt_no\)/);
const taskService = await readFile(new URL('../src/task-service.ts', import.meta.url), 'utf8');
assert.match(taskService, /pg_advisory_xact_lock/);
assert.match(taskService, /parserFieldDifferences/);
assert.match(taskService, /parser_program_result_ciphertext/);
assert.match(taskService, /parser_unreviewed_difference_count/);
assert.match(taskService, /t\.parser_mode, t\.parser_config_revision/);
assert.match(taskService, /display_order: displayIndex \+ 1/);
assert.match(taskService, /buildImmediateParserPromotionGates/);
assert.equal([...taskService.matchAll(/resolveParserModeSnapshot\(client, context\.organizationId,/g)].length, 2);
assert.doesNotMatch(taskService, /shadow_continuous_days|shadow_real_tasks|auto_continuous_days|program_authoritative_tasks|ai_fallback_rate_percent/);
const server = await readFile(new URL('../src/server.ts', import.meta.url), 'utf8');
for (const route of [
'/api/settings/parser-routing',
'/api/settings/parser-routing/:routeId',
'/api/settings/parser-routing/emergency-ai',
'/api/tasks/:taskId/reparse',
'/api/parser-decisions/:decisionId/review'
]) assert.ok(server.includes(route), route);
});
test('parser promotion uses immediate correctness checks without time, volume, or fallback-rate waits', () => {
const ready = buildImmediateParserPromotionGates({
programVersion: 'ltjt-program-parser-test',
otherRoutesInAuto: 0,
unreviewedDifferences: 0,
programWrong: 0,
programContractErrors: 0,
programRuntimeErrors: 0,
downstreamFailures: 0
});
assert.equal(ready.shadow.eligible, true);
assert.equal(ready.auto.eligible, true);
assert.equal(ready.program.eligible, true);
const codes = Object.values(ready).flatMap((gate) => gate.requirements.map((item) => item.code));
assert.deepEqual(codes.filter((code) => /days|tasks|rate/.test(code)), []);
const blocked = buildImmediateParserPromotionGates({
programVersion: 'ltjt-program-parser-test',
otherRoutesInAuto: 1,
unreviewedDifferences: 1,
programWrong: 1,
programContractErrors: 1,
programRuntimeErrors: 1,
downstreamFailures: 1
});
assert.equal(blocked.shadow.eligible, true);
assert.equal(blocked.auto.eligible, false);
assert.equal(blocked.program.eligible, false);
assert.deepEqual(blocked.shadow.requirements.map((item) => item.code), ['program_implemented']);
assert.equal(Object.values(blocked).flatMap((gate) => gate.requirements).some((item) => item.code === 'release_sequence_ready'), false);
});
test('persistent-session migration removes automatic expiry for active sessions', async () => {
const { readFile } = await import('node:fs/promises');
const sql = await readFile(new URL('../migrations/008_persistent_sessions.sql', import.meta.url), 'utf8');
assert.match(sql, /ALTER COLUMN expires_at DROP NOT NULL/);
assert.match(sql, /ALTER COLUMN idle_expires_at DROP NOT NULL/);
assert.match(sql, /SET revoked_at = now\(\)[\s\S]+expires_at <= now\(\)/);
assert.match(sql, /WHERE revoked_at IS NULL/);
const auth = await readFile(new URL('../src/auth.ts', import.meta.url), 'utf8');
assert.doesNotMatch(auth, /s\.expires_at > now\(\)/);
assert.doesNotMatch(auth, /s\.idle_expires_at > now\(\)/);
assert.match(auth, /PERSISTENT_SESSION_COOKIE_MAX_AGE_SECONDS|expires_at, idle_expires_at/);
});
test('operator page has a login gate and uses the durable task API', async () => {
const { readFile } = await import('node:fs/promises');
const index = await readFile(new URL('../../LianSyn-platform/index.html', import.meta.url), 'utf8');
const app = await readFile(new URL('../../LianSyn-platform/app.js', import.meta.url), 'utf8');
const styles = await readFile(new URL('../../LianSyn-platform/styles.css', import.meta.url), 'utf8');
const taskService = await readFile(new URL('../src/task-service.ts', import.meta.url), 'utf8');
const server = await readFile(new URL('../src/server.ts', import.meta.url), 'utf8');
const bridge = await readFile(new URL('../../chrome-extension/ltjt-order-assistant/business-bridge.js', import.meta.url), 'utf8');
const background = await readFile(new URL('../../chrome-extension/ltjt-order-assistant/background.js', import.meta.url), 'utf8');
const inpage = await readFile(new URL('../../chrome-extension/ltjt-order-assistant/inpage.js', import.meta.url), 'utf8');
assert.match(index, /id="loginPanel"/);
assert.match(index, /id="workbench"[^>]*hidden/);
assert.match(index, /styles\.css\?v=20260902-account-routing-hard-delete-2/);
assert.match(index, /app\.js\?v=20260902-account-routing-hard-delete-2/);
assert.match(index, /id="statusDetailsPopover"/);
assert.match(index, /id="statusDetailsRefresh"/);
assert.match(app, /apiRequest\(`\/api\/tasks\?\$\{params\.toString\(\)\}`/);
assert.match(app, /HOME_TASK_LIMIT = 10/);
assert.match(app, /HISTORY_TASK_PAGE_SIZE/);
assert.match(app, /historyFilters/);
assert.match(app, /historyPagination/);
assert.match(index, /id="historyBatchActions"/);
assert.match(index, /id="historySelectAll"/);
assert.match(index, /id="historyArchiveSelectedButton"/);
assert.match(index, /id="historyDeleteSelectedButton"/);
assert.match(app, /IS_HISTORY_PAGE = CURRENT_PAGE === '\/history'/);
assert.match(index, /href="\/history"/);
assert.match(server, /app\.get\('\/history'/);
assert.match(app, /IS_CHANNELS_PAGE = CURRENT_PAGE === '\/channels'/);
assert.match(index, /id="channelsPage"[^>]*hidden/);
assert.match(index, /href="\/channels"/);
assert.match(server, /app\.get\('\/channels'/);
assert.match(app, /IS_PARSER_ROUTING_PAGE = CURRENT_PAGE === '\/parser-routing'/);
assert.match(app, /route\.display_order/);
assert.match(index, /id="parserRoutingPage"[^>]*hidden/);
assert.match(index, /href="\/parser-routing"/);
assert.match(server, /app\.get\('\/parser-routing'/);
assert.match(styles, /html\.parser-routing-page-active/);
assert.match(styles, /grid-template-rows: auto auto auto auto/);
assert.match(styles, /\.detail-panel\s*\{[^}]*grid-template-rows: auto auto auto;[^}]*overflow-y: auto;/s);
assert.match(styles, /\.task-detail\s*\{[^}]*height: auto;[^}]*overflow: visible;/s);
assert.match(styles, /\.task-detail > \.task-lifecycle-panel\s*\{[^}]*flex: 0 0 auto;/s);
assert.match(index, /全部切回 AI/);
assert.match(app, /changeParserRoutingMode/);
assert.match(app, /renameChannel\(channelId\)/);
assert.match(app, /channelAction = 'rename'/);
assert.match(app, /deleteChannel\(channelId\)/);
assert.match(app, /channelAction = 'delete'/);
assert.match(app, /apiRequest\(`\/api\/channels\/\$\{encodeURIComponent\(channelId\)\}`,[\s\S]+method: 'DELETE'/);
assert.match(app, /持久化 AgentBus 回执记录(包括未发送回执)会被移除/);
assert.match(server, /search: z\.string\(\)\.max\(200\)\.optional\(\)/);
assert.match(server, /offset: z\.coerce\.number\(\)\.int\(\)\.min\(0\)/);
assert.match(server, /include_total/);
assert.match(taskService, /async listTasksPage\(/);
assert.match(taskService, /async maintainStaleReconciliationTasks\(\)/);
assert.match(taskService, /SELECT t\.organization_id, t\.task_id[\s\S]+ORDER BY a\.finished_at/);
assert.doesNotMatch(taskService, /SELECT DISTINCT t\.organization_id, t\.task_id[\s\S]+ORDER BY a\.finished_at/);
assert.match(taskService, /a\.status IN \('accepted', 'running'\)/);
assert.match(taskService, /t\.lease_expires_at IS NOT NULL/);
assert.match(server, /staleReconciliations/);
assert.doesNotMatch(taskService, /COUNT\(\*\) OVER\(\)/);
assert.match(taskService, /SELECT COUNT\(\*\)::int AS total_count/);
assert.match(taskService, /publicTaskSummary/);
assert.match(taskService, /t\.status IN \('queued', 'accepted', 'running'\)[\s\S]+t\.handoff_status IN \('accepted', 'running'\)/);
assert.match(app, /MANUAL_RECONCILIATION_STATUSES/);
assert.match(app, /allowReconciliation/);
assert.match(app, /failedWithReconciliation/);
assert.match(app, /awaiting_user_input/);
assert.match(index, /id="rawInstruction"/);
assert.match(app, /function isAwaitingUserInput\(task\)/);
assert.match(app, /function sendTaskReply\(task, textarea, button, statusNode\)/);
assert.match(app, /apiRequest\('\/api\/messages'/);
assert.match(app, /task_id: taskId/);
assert.match(app, /taskReplyDrafts/);
assert.match(app, /id = 'inputRequestPanel'/);
assert.match(app, /id = 'sendTaskReplyButton'/);
assert.match(app, /id = 'taskReplyInput'/);
assert.match(app, /function renderTaskImportantMessage\(task\)/);
assert.match(app, /需返回\/交互用户的重要消息/);
assert.match(app, /receipt\.reply_attachments/);
assert.match(app, /important_message\?\.attachments/);
assert.match(app, /task-artifact-download/);
assert.match(styles, /\.task-input-request/);
assert.match(styles, /\.task-important-message-panel/);
assert.match(app, /\['awaiting_confirmation', 'agent_parse_passed'\]\.includes\(status\)/);
assert.match(taskService, /operation \? operationSummary\(operation\) : null/);
assert.match(index, /确认并提交到 ERP 插件/);
assert.match(app, /const MANUAL_HANDOFF_LABEL = '确认并提交到 ERP 插件'/);
assert.match(app, /async function confirmAndSubmitToErpPlugin/);
assert.doesNotMatch(app, /textContent = canStart \? '开始执行' : '确认执行'/);
assert.match(app, /fetchWithTimeout\('\/api\/auth\/login'/);
assert.match(app, /showAuthChecking\(\);[\s\S]*pingAi\(\)\.catch/);
assert.match(app, /cache: options\.cache \|\| 'no-store'/);
assert.match(app, /showAuthenticatedApp\(me\.user\);[\s\S]*任务同步失败/);
assert.match(app, /async function toggleStatusDetails/);
assert.match(app, /已连接,有告警/);
assert.match(app, /历史验证失败(不影响链路状态)/);
assert.match(app, /event\.key === 'Escape'/);
assert.match(app, /The backend is authoritative/);
assert.match(app, /syncRequested/);
assert.match(app, /api\/events\?since=/);
assert.match(taskService, /events: PublicTaskEvent\[\]/);
assert.match(taskService, /payload: redactTransportMetadata/);
assert.match(taskService, /failure_stage/);
assert.match(taskService, /success_receipt/);
assert.match(taskService, /error_summary/);
assert.match(taskService, /loadPublicTaskEvents/);
assert.match(taskService, /e\.task_id = ANY\(\$2::uuid\[\]\)/);
assert.match(app, /ensureTaskDetails/);
assert.match(app, /pollInProgress/);
assert.match(app, /fetchWithTimeout/);
assert.match(app, /Array\.isArray\(task\?\.events\)/);
assert.match(app, /任务生命周期/);
assert.match(app, /成功回执/);
assert.match(app, /订单号:/);
assert.doesNotMatch(app, /task-outcome-json/);
assert.match(app, /错误摘要/);
assert.match(app, /task-important-message-card/);
assert.match(app, /lifecycleScrollPositions/);
assert.doesNotMatch(app, /lifecycle\.scrollTop = lifecycle\.scrollHeight/);
assert.match(app, /taskState\.textContent = task \? taskStatusLabel\(task\)/);
assert.match(app, /source: 'task_error_summary'/);
assert.match(app, /data-history-task-select/);
assert.match(app, /data-history-task-delete/);
assert.match(app, /const historySelectedTaskIds = new Set\(\)/);
assert.match(app, /async function deleteHistorySelectedTasks\(\)/);
assert.match(app, /apiRequest\('\/api\/tasks\/bulk-archive'/);
assert.match(app, /function renderHistoryBatchActions\(visibleTasks/);
assert.match(app, /historySelectedTaskIds\.clear\(\)/);
assert.match(app, /recorded_at/);
assert.match(app, /技术详情/);
assert.match(styles, /\.task-important-message-card/);
assert.match(styles, /\.task-source-tag/);
assert.match(app, /function taskSourceInfo\(task\)/);
assert.match(app, /AgentBus/);
assert.match(app, /人工输入/);
assert.match(index, /id="automationToggleButton"/);
assert.match(index, /所有来源的新任务解析通过后/);
assert.match(app, /apiRequest\('\/api\/settings\/automation'/);
assert.match(app, /所有来源的新任务解析通过后将自动进入 ERP/);
assert.match(app, /automationSettingsSyncInFlight/);
assert.match(app, /eventStream\.addEventListener\('open',[\s\S]+syncAutomationSettings\(\{ background: true \}\)/);
assert.match(app, /async function refreshBackgroundState\(\)[\s\S]+syncAutomationSettings\(\{ background: true \}\)/);
assert.match(app, /window\.addEventListener\('focus',[\s\S]+refreshBackgroundState\(\)/);
assert.match(app, /async function autoDispatchReadyTasks\(\{ force = false \} = \{\}\)/);
assert.match(app, /if \(!force && retryAt > Date\.now\(\)\) continue/);
assert.match(app, /const bridgeReadyForDispatch = !wasBridgeConnected/);
assert.match(app, /autoDispatchReadyTasks\(\{ force: bridgeReadyForDispatch \}\)/);
const autoDispatchSource = app.slice(
app.indexOf('async function autoDispatchReadyTasks'),
app.indexOf('function taskStateClass')
);
assert.doesNotMatch(autoDispatchSource, /status: 'waiting_extension'/);
assert.match(app, /confirmation_mode === 'automatic'/);
assert.match(app, /function renderOperationReview\(task\)/);
assert.match(app, /if \(!operation \|\| operation\.action === 'confirmation_export'\) return null/);
assert.doesNotMatch(app, /task-artifact-info/);
assert.doesNotMatch(app, /formatArtifactBytes/);
assert.match(app, /生命周期测试写入审核/);
assert.match(app, /审核并授权本次测试写入/);
assert.match(app, /任何来源的任务都不因生命周期类型转为人工确认/);
assert.match(server, /api\/settings\/automation/);
assert.match(taskService, /automation_enabled/);
assert.match(taskService, /task\.auto_confirmed/);
assert.match(taskService, /prepareParsedOperationForConfirmation/);
assert.match(taskService, /authorizeLifecycleOperationForManualConfirmation/);
assert.match(taskService, /shouldAutomaticallyConfirm\(\{/);
assert.doesNotMatch(taskService, /text\(source\) === 'agentbus'/);
assert.doesNotMatch(taskService, /!isLifecycleOperation\(operation\)/);
assert.match(styles, /\.task-operation-review-card/);
assert.match(styles, /\.task-important-message-card\.state-ok/);
assert.match(app, /operation_contract_validation/);
assert.match(app, /api\/tasks\/\$\{encodeURIComponent\(taskId\)\}\/archive/);
assert.match(app, /api\/tasks\/\$\{encodeURIComponent\(taskId\)\}\/restore/);
assert.match(app, /const taskArchiveStates = new Map\(\)/);
assert.match(app, /const taskDeleteStates = new Map\(\)/);
assert.match(app, /taskDeleteStates\.get\(task\.task_id\)/);
assert.match(app, /taskDeleteStates\.set\(taskId, 'deleting'\)/);
assert.match(app, /deleteButton\.textContent = '强制删除中…'/);
assert.match(app, /sendToExtension\('DELETE_TASK'/);
assert.match(app, /method: 'DELETE'/);
assert.match(app, /此操作不受“正在处理”或“等待 ERP 执行”状态限制/);
assert.match(taskService, /async archiveTask\(/);
assert.match(taskService, /async archiveTasks\(/);
assert.match(taskService, /async restoreTask\(/);
assert.match(taskService, /async hardDeleteTask\(/);
assert.match(taskService, /async hardDeleteTasks\(/);
assert.match(taskService, /archived_at = now\(\), archived_by = \$1/);
assert.match(taskService, /SET archived_at = NULL, archived_by = NULL, archive_reason = NULL/);
assert.match(taskService, /const missingTaskIds = normalizedTaskIds\.filter/);
assert.match(taskService, /task\.hard_deleted/);
assert.match(taskService, /this\.artifactStore\.cleanup\(outcome\.artifacts\)/);
assert.doesNotMatch(taskService, /DELETE FROM audit_events/);
assert.match(taskService, /DELETE FROM tasks/);
assert.match(taskService, /erp-account-queue/);
assert.match(taskService, /t\.assigned_user_id = \$2/);
assert.match(taskService, /AND assigned_user_id = \$2\s+AND status = 'confirmed'/);
assert.match(server, /taskBulkDeleteSchema/);
assert.match(server, /taskBulkArchiveSchema/);
assert.match(server, /\.max\(100\)/);
assert.match(server, /app\.post\('\/api\/tasks\/bulk-delete'/);
assert.match(server, /app\.post\('\/api\/tasks\/bulk-archive'/);
assert.match(server, /app\.delete\('\/api\/tasks\/:taskId'/);
assert.match(server, /app\.post\('\/api\/tasks\/:taskId\/archive'/);
assert.match(server, /app\.post\('\/api\/tasks\/:taskId\/restore'/);
assert.match(server, /tasks\.hardDeleteTasks/);
assert.match(server, /tasks\.hardDeleteTask/);
assert.match(bridge, /status: 'deleted'/);
assert.match(background, /LTJT_HARD_DELETE_TASK/);
assert.doesNotMatch(app, /task-json-output|taskResponseJson/);
assert.doesNotMatch(styles, /\.task-json-output/);
assert.doesNotMatch(app, /appendTaskLogEntry|task\.logs/);
assert.match(app, /title: '系统解析阶段'/);
assert.match(app, /title: '系统写入处理'/);
assert.match(app, /task-stage-card is-\$\{state\.mode\}/);
assert.match(styles, /\.task-stage-card\.is-pending/);
assert.match(styles, /\.task-stage-card\.is-needs/);
assert.match(styles, /\.task-stage-card\.is-processing/);
assert.match(styles, /\.task-stage-card \{[\s\S]*display: flex;[\s\S]*justify-content: space-between;/);
assert.match(styles, /\.workbench-grid \{[\s\S]*min-width: 0;[\s\S]*overflow: hidden;/);
assert.match(styles, /overflow-x: hidden/);
assert.match(styles, /\.task-card-actions/);
assert.match(styles, /\.task-card-delete/);
assert.match(styles, /\.history-batch-actions/);
assert.match(app, /currentOption = stage\.options\.find/);
assert.match(app, /task-stage-status is-current/);
assert.doesNotMatch(app, /for \(const option of stage\.options\)/);
assert.doesNotMatch(app, /task-stage-index|task-stage-current/);
assert.ok(app.indexOf('/claim') < app.indexOf("sendToExtension('CREATE_TASK'"), 'server claim must precede extension dispatch');
assert.doesNotMatch(app, /syncPendingTasks|>重交</);
assert.match(app, /runtimeTasks\(\)\.filter\(\(task\) => taskAssignedToCurrentAccount\(task\) && isTaskPollable\(task\)\)/);
assert.match(app, /execution_id: executionId/);
assert.doesNotMatch(bridge, /async function upsertBusinessTask/);
assert.match(bridge, /task: currentBusinessTask/);
assert.match(background, /businessTaskExecutions:[\s\S]+businessTasks: tasks/);
assert.match(background, /runAutoTaskWithTimeout/);
assert.match(background, /LTJT_CLEANUP_RUNTIME_STATE/);
assert.match(background, /currentTaskAt/);
assert.match(inpage, /deferred_dialog_callbacks_suppressed/);
assert.doesNotMatch(inpage, /callback\.call\(window\)/);
});
test('system task events persist a null actor instead of an invalid empty UUID', async () => {
const config = loadConfig({
NODE_ENV: 'test',
FIELD_ENCRYPTION_KEY: Buffer.alloc(32, 9).toString('base64')
});
const calls: Array<{ sql: string; params: unknown[] }> = [];
const client = {
async query(sql: string, params: unknown[] = []) {
calls.push({ sql, params });
if (sql.includes('INSERT INTO task_events')) {
return {
rows: [{
id: '1',
organization_id: '00000000-0000-0000-0000-000000000001',
task_id: '00000000-0000-0000-0000-000000000002',
status: 'parse_failed',
stage: 'parse',
message: 'blocked',
payload: {},
created_at: new Date('2026-07-13T00:00:00.000Z')
}]
};
}
return { rows: [] };
}
};
const service = new TaskService(config) as unknown as {
emitEvent: (
client: unknown,
row: Record<string, unknown>,
data: Record<string, unknown>,
actorUserId: string
) => Promise<unknown>;
};
await service.emitEvent(client, {
id: '00000000-0000-0000-0000-000000000002',
organization_id: '00000000-0000-0000-0000-000000000001',
task_id: 'TASK-SYSTEM-EVENT'
}, {
status: 'parse_failed',
stage: 'parse',
message: 'blocked'
}, '');
assert.equal(calls[0].params[6], null);
});
test('public task wrappers keep session IDs out of operation and transport metadata', () => {
const config = loadConfig({
NODE_ENV: 'test',
FIELD_ENCRYPTION_KEY: Buffer.alloc(32, 10).toString('base64')
});
const service = new TaskService(config) as unknown as {
publicTask: (row: Record<string, unknown>) => Record<string, unknown>;
};
const parseResponse = {
status: 'agent_parse_needs_input',
blockers: [],
operation: null,
reply: '请补充线路产品名称后继续处理。',
missing_fields: ['data.product'],
questions: [{ field: 'data.product', prompt: '请补充线路产品名称。' }],
captured_facts: { action: 'team_order_create' },
session_id: 'external-session-must-not-be-public',
external_request: {
session_id: 'external-session-must-not-be-public',
idempotency_key: 'transport-key-must-not-be-public'
}
};
const task = service.publicTask({
organization_id: 'org-1',
task_id: 'TASK-INPUT',
source: 'agentbus',
original_text_ciphertext: encryptText(config, '用户补充前的原始指令'),
operation_ciphertext: encryptText(config, JSON.stringify({ action: 'team_order_create', session_id: 'operation-secret' })),
parse_response_ciphertext: encryptText(config, JSON.stringify(parseResponse)),
execution_result_ciphertext: null,
summary: {},
status: 'awaiting_user_input',
stage: 'parse',
message: '资料不完整,等待用户补充。',
error: '',
handoff_status: '',
confirmed_at: null,
created_at: '2026-08-05T00:00:00.000Z',
updated_at: '2026-08-05T00:00:00.000Z',
last_event_id: null,
agent_session_status: 'active',
agent_session_turn: 1,
agent_session_recovery_count: 0,
external_session_id_present: true
}) as {
source: string;
agent_session: { session_id_present: boolean };
input_request: { missing_fields: string[] };
important_message: { kind: string; text: string; input_request?: { missing_fields: string[] } };
operation: null;
parse_response: Record<string, unknown> & { external_request: Record<string, unknown> };
};
assert.equal(task.agent_session.session_id_present, true);
assert.equal(task.source, 'agentbus');
assert.deepEqual(task.input_request.missing_fields, ['data.product']);
assert.equal(task.important_message.kind, 'awaiting_user_input');
assert.equal(task.important_message.text, '请补充线路产品名称后继续处理。');
assert.deepEqual(task.important_message.input_request?.missing_fields, ['data.product']);
assert.equal(task.operation, null);
assert.equal(task.parse_response.session_id, undefined);
assert.equal(task.parse_response.external_request.session_id, undefined);
assert.equal(task.parse_response.external_request.idempotency_key, undefined);
});
test('public task exposes persisted outcome timestamps and keeps the latest error summary', () => {
const config = loadConfig({
NODE_ENV: 'test',
FIELD_ENCRYPTION_KEY: Buffer.alloc(32, 11).toString('base64')
});
const service = new TaskService(config) as unknown as {
publicTask: (row: Record<string, unknown>) => Record<string, unknown>;
};
const task = service.publicTask({
organization_id: 'org-1',
task_id: 'TASK-OUTCOME',
original_text_ciphertext: encryptText(config, '成功回执测试'),
operation_ciphertext: null,
parse_response_ciphertext: null,
execution_result_ciphertext: encryptText(config, JSON.stringify({
status: 'completed',
erp_receipt: { group_numbers: ['LW-260907A-B'] }
})),
success_receipt: { group_numbers: ['LW-260907A-B'], group_number: 'LW-260907A-B' },
success_receipt_at: '2026-08-06T01:02:03.000Z',
error_summary: {
error_code: 'previous_error',
stage: 'preflight',
source: 'plugin_executor',
message: '此前一次预检失败。',
validation_errors: ['字段缺失']
},
error_summary_at: '2026-08-06T01:01:03.000Z',
summary: {},
status: 'completed',
stage: 'verification',
message: '任务已完成。',
error: '',
handoff_status: 'completed',
confirmed_at: null,
created_at: '2026-08-06T01:00:00.000Z',
updated_at: '2026-08-06T01:02:03.000Z',
last_event_id: null
});
const successReceipt = task.success_receipt as { recorded_at: string; receipt: Record<string, unknown> };
assert.equal(successReceipt.recorded_at, '2026-08-06T01:02:03.000Z');
assert.deepEqual(successReceipt.receipt.group_numbers, ['LW-260907A-B']);
assert.equal(successReceipt.receipt.group_number, 'LW-260907A-B');
assert.equal(successReceipt.receipt.reply, undefined);
assert.deepEqual(task.error_summary, {
recorded_at: '2026-08-06T01:01:03.000Z',
error_code: 'previous_error',
stage: 'preflight',
source: 'plugin_executor',
message: '此前一次预检失败。',
validation_errors: ['字段缺失']
});
const importantMessage = task.important_message as {
kind: string;
text: string;
success_receipt: unknown;
};
assert.equal(importantMessage.kind, 'success');
assert.match(importantMessage.text, /LW-260907A-B/);
assert.deepEqual(importantMessage.success_receipt, task.success_receipt);
assert.equal(task.source, 'manual');
assert.equal(task.failure, null);
});
test('platform only synthesizes an error timeline entry for a currently failed task', async () => {
const app = await readFile(new URL('../../LianSyn-platform/app.js', import.meta.url), 'utf8');
const failureInfo = app.slice(
app.indexOf('function taskFailureInfo'),
app.indexOf('function failureStageLabel')
);
const lifecycleEntries = app.slice(
app.indexOf('function taskLifecycleEntries'),
app.indexOf('function lifecycleLevel')
);
assert.match(failureInfo, /isFailureStatus\(status\)/);
assert.match(lifecycleEntries, /isFailureStatus\(status\)/);
assert.match(lifecycleEntries, /source: 'task_error_summary'/);
});