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, isTaskDataPlaneRoute } from '../src/server.js'; import { REQUIRED_SCHEMA_VERSION } from '../src/db.js'; import { TaskService, authorizeLifecycleOperationForManualConfirmation, buildImmediateParserPromotionGates, classifyExpiredExecutionResult, classifyExecutionResult, canAccessTask, canExecuteBusinessRoute, canUseTaskDataPlane, 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 excludes administrators and isolates employee owners', () => { const cases = [ { name: 'administrator cannot inspect another assigned task', access: { userId: 'admin', role: 'admin' as const }, assignedUserId: 'user-a', allowed: false }, { name: 'administrator cannot inspect a legacy admin-assigned task', access: { userId: 'admin', role: 'admin' as const }, assignedUserId: 'admin', allowed: false }, { 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(canUseTaskDataPlane('admin'), false); assert.equal(canUseTaskDataPlane('team_lead'), true); assert.equal(canUseTaskDataPlane('user'), true); assert.equal(canUseTaskDataPlane(undefined), false); assert.equal(canViewOperationsDashboard('admin'), false); assert.equal(canViewOperationsDashboard('team_lead'), true); assert.equal(canViewOperationsDashboard('user'), false); }); test('task data-plane HTTP classifier covers every task-bearing surface and excludes management APIs', () => { for (const route of [ '/api/tasks', '/api/tasks/:taskId', '/api/messages', '/api/connections/heartbeat', '/api/events', '/api/parser-decisions/:decisionId/review', '/api/operations-dashboard', '/api/operations-dashboard/tasks/:taskId' ]) assert.equal(isTaskDataPlaneRoute(route), true, route); for (const route of [ '/api/accounts', '/api/channels', '/api/settings/parser-routing', '/api/settings/automation', '/api/audit' ]) assert.equal(isTaskDataPlaneRoute(route), false, route); }); test('task service rejects administrator operations before touching task storage', async () => { const config = loadConfig({ NODE_ENV: 'test', FIELD_ENCRYPTION_KEY: Buffer.alloc(32, 19).toString('base64'), DATABASE_URL: 'postgresql://invalid:invalid@127.0.0.1:1/invalid' }); const service = new TaskService(config); const context = { organizationId: '11111111-1111-4111-8111-111111111111', userId: '22222222-2222-4222-8222-222222222222', requestId: 'admin-task-isolation-test', role: 'admin' as const, source: 'manual' as const }; const operations: Array<[string, () => Promise]> = [ ['create', () => service.createTask(context, '安排用车')], ['message', () => service.ingestMessage(context, { message: '安排用车' })], ['attachment', () => service.attachPassengerRosterAttachment(context, { fileName: 'roster.xlsx', contentType: 'application/octet-stream', content: Buffer.from('x'), source: 'manual' }, { taskId: 'TASK-1' })], ['list', () => service.listTasksPage(context.organizationId, { access: context })], ['read', () => service.getTask(context.organizationId, 'TASK-1', context)], ['input history', () => service.getTaskInputHistory(context, 'TASK-1')], ['artifact', () => service.getTaskArtifact(context.organizationId, 'TASK-1', '33333333-3333-4333-8333-333333333333', context)], ['confirm', () => service.confirmTask(context, 'TASK-1')], ['claim', () => service.claimForBrowser(context, 'TASK-1', 'platform-browser:test')], ['result', () => service.recordExecutionResult(context, 'TASK-1', {}, 'platform-browser:test', '44444444-4444-4444-8444-444444444444')], ['reconcile', () => service.markReconciliationRequired(context, 'TASK-1', 'uncertain', '')], ['archive', () => service.archiveTasks(context, ['TASK-1'])], ['restore', () => service.restoreTask(context, 'TASK-1')], ['delete', () => service.hardDeleteTasks(context, ['TASK-1'])], ['cancel', () => service.cancelTask(context, 'TASK-1')], ['heartbeat', () => service.heartbeat(context, 'platform-browser:test', '0.0.0')], ['AI reparse', () => service.reparseTaskWithAi(context, 'TASK-1', 'test')], ['parser review', () => service.reviewParserDecision(context, '33333333-3333-4333-8333-333333333333', 'equivalent')] ]; for (const [name, operation] of operations) { await assert.rejects(operation, (error: unknown) => ( error instanceof Error && 'code' in error && error.code === 'task_access_forbidden' ), name); } }); 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: [] }), false, 'administrators cannot create manual tasks'); 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('administrator sessions receive 403 before every task data-plane handler', async () => { const config = loadConfig({ NODE_ENV: 'test', FIELD_ENCRYPTION_KEY: Buffer.alloc(32, 20).toString('base64'), DATABASE_URL: 'postgresql://invalid:invalid@127.0.0.1:1/invalid' }); const { app, auth } = await buildServer({ config, startParserLoop: false, parser: { async parse() { return { blockers: ['test parser'] }; }, async checkConnection() { return { ok: false, configured: false }; } } }); (auth as unknown as { getActiveSession: () => Promise }).getActiveSession = async () => ({ id: '33333333-3333-4333-8333-333333333333', csrfTokenHash: Buffer.alloc(32), user: { id: '22222222-2222-4222-8222-222222222222', organizationId: '11111111-1111-4111-8111-111111111111', username: 'admin', role: 'admin', erpAccount: null } }); const requests = [ { method: 'GET', url: '/api/tasks' }, { method: 'POST', url: '/api/tasks', payload: { raw_text: '安排用车' } }, { method: 'POST', url: '/api/messages', payload: { message: '安排用车' } }, { method: 'GET', url: '/api/tasks/TASK-1' }, { method: 'GET', url: '/api/tasks/TASK-1/input-history' }, { method: 'GET', url: '/api/tasks/TASK-1/artifacts/44444444-4444-4444-8444-444444444444' }, { method: 'POST', url: '/api/tasks/TASK-1/confirm', payload: {} }, { method: 'POST', url: '/api/tasks/TASK-1/claim', payload: { connection_id: 'platform-browser:1234567890abcdef' } }, { method: 'POST', url: '/api/tasks/TASK-1/result', payload: { result: {}, connection_id: 'platform-browser:1234567890abcdef', execution_id: '44444444-4444-4444-8444-444444444444' } }, { method: 'POST', url: '/api/tasks/TASK-1/cancel', payload: {} }, { method: 'POST', url: '/api/tasks/bulk-delete', payload: { task_ids: ['TASK-1'] } }, { method: 'POST', url: '/api/tasks/bulk-archive', payload: { task_ids: ['TASK-1'] } }, { method: 'DELETE', url: '/api/tasks/TASK-1' }, { method: 'POST', url: '/api/tasks/TASK-1/archive', payload: {} }, { method: 'POST', url: '/api/tasks/TASK-1/restore', payload: {} }, { method: 'POST', url: '/api/connections/heartbeat', payload: { connection_id: 'platform-browser:1234567890abcdef' } }, { method: 'GET', url: '/api/events' }, { method: 'GET', url: '/api/operations-dashboard' }, { method: 'GET', url: '/api/operations-dashboard/tasks/TASK-1' }, { method: 'POST', url: '/api/tasks/TASK-1/reparse', payload: { engine: 'ai', reason: 'test' } }, { method: 'PUT', url: '/api/parser-decisions/44444444-4444-4444-8444-444444444444/review', payload: { verdict: 'equivalent' } } ]; try { for (const request of requests) { const response = await app.inject(request as any); assert.equal(response.statusCode, 403, `${request.method} ${request.url}`); assert.equal(response.json().error_code, 'task_access_forbidden', `${request.method} ${request.url}`); } } finally { 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 leader summary webhook 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, '023_leader_summary_webhook_delivery'); 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) => ({ ...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('
游客
', 'utf8'); const wordContent = Buffer.from('team'); 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; 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>)[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('游客', '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=20260908-leader-webhook-1/); assert.match(index, /app\.js\?v=20260908-leader-webhook-1/); 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.match(taskService, /指令已拆解,等待任务归属人确认/); assert.doesNotMatch(taskService, /指令已拆解,等待管理员确认/); 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, /if \(!showAuthenticatedApp\(me\.user\)\) return false;[\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|>重交 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, data: Record, actorUserId: string ) => Promise; }; 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) => Record; }; 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 & { external_request: Record }; }; 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) => Record; }; 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 }; 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'/); });