Files
LWLT-AIBOT/control-plane/test/agentbus.test.ts
2026-08-31 16:13:28 +08:00

1264 lines
45 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 { EventEmitter } from 'node:events';
import test from 'node:test';
import { loadConfig } from '../src/config.js';
import {
AgentBusListener,
type AgentBusSocket,
type AgentBusTaskGateway,
createTaskResultFrame,
createTaskProgressFrame,
extractAgentBusBusinessText,
isInboundAgentBusTask,
parseAgentBusFrame,
sessionFromReadyFrame,
taskAttachmentReferences,
taskResultStatus,
taskResultText
} from '../src/agentbus.js';
import {
AgentBusManager,
mergeRuntimeChannelStatuses,
type PublicAgentBusChannel
} from '../src/agentbus-channels.js';
import { resolveBusinessRoute } from '../src/business-routes.js';
import type { AgentBusDelivery, PublicTask } from '../src/task-service.js';
function makeTask(status: string, overrides: Partial<PublicTask> = {}): PublicTask {
return {
organization_id: 'org-1',
task_id: 'TASK-1',
source: 'manual',
channel_id: null,
channel_name: null,
raw_text: '测试指令',
conversation_id: 'conversation-1',
agent_session: {
status: 'active',
turn_no: 1,
recovery_count: 0,
session_id_present: false
},
parser: {
route_id: null,
input_contract_version: null,
configured_mode: 'ai',
config_revision: 0,
authoritative_engine: null,
engine_affinity: 'ai',
version: null,
fallback_reason: null,
comparison_status: null,
decision_id: null,
review_status: null,
diff_paths: [],
field_differences: [],
unreviewed_difference_count: 0,
shadow_differences: [],
reparse_count: 0
},
input_request: null,
important_message: null,
operation: null,
parse_response: null,
result: null,
summary: null,
status,
stage: 'parse',
message: '任务状态已更新。',
error: '',
failure: null,
success_receipt: null,
error_summary: null,
handoff_status: '',
confirmation_mode: 'manual',
confirmed_at: null,
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
last_event_id: null,
events: [],
...overrides
};
}
class FakeSocket {
readyState = 0;
readonly sent: Record<string, unknown>[] = [];
private readonly handlers = new Map<string, Array<(...args: any[]) => void>>();
on(event: string, listener: (...args: any[]) => void): this {
const listeners = this.handlers.get(event) || [];
listeners.push(listener);
this.handlers.set(event, listeners);
return this;
}
emit(event: string, ...args: any[]): void {
for (const listener of this.handlers.get(event) || []) listener(...args);
}
send(data: string, callback?: (error?: Error) => void): void {
this.sent.push(JSON.parse(data) as Record<string, unknown>);
callback?.();
}
close(): void {
this.readyState = 3;
this.emit('close', 1000, Buffer.alloc(0));
}
}
async function waitFor(predicate: () => boolean, timeoutMs = 1_000): Promise<void> {
const deadline = Date.now() + timeoutMs;
while (!predicate() && Date.now() < deadline) {
await new Promise((resolve) => setTimeout(resolve, 5));
}
}
function testConfig() {
return loadConfig({
NODE_ENV: 'test',
FIELD_ENCRYPTION_KEY: Buffer.alloc(32, 4).toString('base64'),
AGENTBUS_ENABLED: 'true',
AGENTBUS_WS_URL: 'wss://mesh.nianxx.cn/ws',
AGENTBUS_WS_TOKEN: 'test-ws-token',
AGENTBUS_BOT_ADDRESS: 'bot:test:listener',
AGENTBUS_WS_RECONNECT_DELAY: '1ms',
AGENTBUS_TASK_TIMEOUT_MS: '1000',
AGENTBUS_LOG_PAYLOADS: 'true'
});
}
function publicChannelFixture(overrides: Partial<PublicAgentBusChannel> = {}): PublicAgentBusChannel {
return {
id: 'channel-1',
organization_id: 'org-1',
channel_type: 'agentbus',
display_name: '外部用户 A',
external_user_ref: 'external-user-a',
agentbus_bot_address: 'bot:test:listener',
enabled: true,
status: 'disabled',
key_configured: true,
deletable: true,
last_connected_at: null,
last_error: null,
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
...overrides
};
}
test('AgentBus configuration stays disabled until connection fields are supplied', () => {
const config = loadConfig({
NODE_ENV: 'test',
FIELD_ENCRYPTION_KEY: Buffer.alloc(32, 5).toString('base64')
});
assert.equal(config.agentBusEnabled, false);
assert.equal(config.AGENTBUS_WS_RECONNECT_DELAY, 5_000);
assert.equal(config.AGENTBUS_LOG_PAYLOADS, false);
});
test('AgentBus auto configuration rejects partial credentials', () => {
assert.throws(() => loadConfig({
NODE_ENV: 'test',
FIELD_ENCRYPTION_KEY: Buffer.alloc(32, 6).toString('base64'),
AGENTBUS_WS_TOKEN: 'partial-token'
}), /AGENTBUS_WS_URL/);
});
test('database-managed channels may use the global WebSocket URL without a global key', () => {
const config = loadConfig({
NODE_ENV: 'test',
FIELD_ENCRYPTION_KEY: Buffer.alloc(32, 7).toString('base64'),
AGENTBUS_ENABLED: 'true',
AGENTBUS_WS_URL: 'wss://mesh.nianxx.cn/ws'
});
assert.equal(config.agentBusEnabled, true);
assert.equal(config.AGENTBUS_WS_TOKEN, undefined);
});
test('WeChat transport envelope exposes only its business text to the global parser', () => {
const businessText = [
'独立团单个下单',
'发团日期2026-11-02',
'预订客户:示例客户',
'产品搜索:示例产品',
'预估人数15+1',
'用房数量TWN 8SGL 1'
].join('\n');
const wrapped = [
'New WeChat message',
'Conversation: conversation-example-1',
`Text: ${businessText}`
].join('\n');
const extracted = extractAgentBusBusinessText(wrapped);
assert.equal(extracted, businessText);
assert.equal(resolveBusinessRoute(extracted).routeId, 'team_order_create');
const envelopeConversation = parseAgentBusFrame(JSON.stringify({
id: 'wechat-envelope-conversation',
type: 'event',
from: 'channel:wechat:user-1',
payload: { text: wrapped }
}));
assert.equal(envelopeConversation?.conversation_id, 'conversation-example-1');
const explicitConversation = parseAgentBusFrame(JSON.stringify({
id: 'wechat-explicit-conversation',
type: 'event',
from: 'channel:wechat:user-1',
conversation_id: 'conversation-explicit-1',
payload: { text: wrapped }
}));
assert.equal(explicitConversation?.conversation_id, 'conversation-explicit-1');
const missingConversation = `New WeChat message\nConversation:\nText: ${businessText}`;
const wrongTextLabel = `New WeChat message\nConversation: conversation-example-1\nBody: ${businessText}`;
const emptyBody = 'New WeChat message\nConversation: conversation-example-1\nText: ';
assert.equal(extractAgentBusBusinessText(missingConversation), missingConversation);
assert.equal(extractAgentBusBusinessText(wrongTextLabel), wrongTextLabel);
assert.equal(extractAgentBusBusinessText(emptyBody), emptyBody.trim());
});
test('channel directory prefers the current listener over stale persisted disabled status', () => {
const channel = publicChannelFixture({ status: 'disabled', last_error: '旧进程状态' });
const connected = mergeRuntimeChannelStatuses([channel], [{
channel_id: channel.id,
enabled: true,
connected: true,
session_ready: true
}]);
assert.equal(connected[0].status, 'connected');
assert.equal(connected[0].last_error, null);
const connecting = mergeRuntimeChannelStatuses([channel], [{
channel_id: channel.id,
enabled: true,
connected: false,
session_ready: false
}]);
assert.equal(connecting[0].status, 'connecting');
const disabled = mergeRuntimeChannelStatuses(
[publicChannelFixture({ enabled: false, status: 'disabled' })],
[{ channel_id: channel.id, enabled: true, connected: true, session_ready: true }]
);
assert.equal(disabled[0].status, 'disabled');
});
test('AgentBus protocol helpers preserve reply routing fields', () => {
const inbound = parseAgentBusFrame(JSON.stringify({
id: 'channel-event-1',
type: 'event',
from: 'channel:wechat:user-1',
conversation_id: 'conversation-1',
payload: { text: '你好', reply_policy: { progress: true } }
}));
assert.ok(inbound);
assert.equal(isInboundAgentBusTask(inbound), true);
const session = { id: 'session-1', epoch: 3, address: 'bot:test:listener' };
const ready = sessionFromReadyFrame(parseAgentBusFrame(JSON.stringify({
id: 'ready-1',
type: 'event',
session_id: session.id,
epoch: session.epoch,
to: session.address,
payload: { event: 'session.ready' }
}))!);
assert.deepEqual(ready, session);
const progress = createTaskProgressFrame(inbound, session, '处理中', 'progress-1');
assert.equal(progress.reply_to, 'channel-event-1');
assert.equal(progress.conversation_id, 'conversation-1');
assert.equal((progress.payload as Record<string, unknown>).event, 'task.progress');
const result = createTaskResultFrame(inbound, session, 'completed', '已完成', 'result-1');
assert.equal(result.from, session.address);
assert.equal(result.to, inbound.from);
assert.equal(result.session_id, session.id);
assert.equal(result.epoch, session.epoch);
assert.equal(result.reply_to, inbound.id);
assert.equal((result.payload as Record<string, unknown>).event, 'task.result');
const important = {
kind: 'awaiting_user_input' as const,
text: '请补充房型分配。',
recorded_at: new Date().toISOString()
};
const resultWithImportantMessage = createTaskResultFrame(
inbound,
session,
'completed',
important.text,
'result-important-1',
important
);
assert.deepEqual((resultWithImportantMessage.payload as Record<string, unknown>).important_message, {
kind: 'awaiting_user_input',
text: '请补充房型分配。'
});
const detailedSuccess = {
kind: 'success' as const,
text: '创建完成。\n团号LW-260907A-B',
recorded_at: new Date().toISOString(),
success_receipt: {
recorded_at: new Date().toISOString(),
receipt: {
group_number: 'LW-260907A-B',
verification_status: 'split_parent_completed'
}
}
};
const channelResult = createTaskResultFrame(
inbound,
session,
'completed',
'创建完成。\n团号LW-260907A-B',
'result-channel-summary-1',
detailedSuccess
);
assert.deepEqual((channelResult.payload as Record<string, unknown>).important_message, {
kind: 'success',
text: '创建完成。\n团号LW-260907A-B'
});
assert.doesNotMatch(JSON.stringify(channelResult.payload), /success_receipt|verification_status|split_parent_completed/);
const directFileResult = createTaskResultFrame(
inbound,
session,
'completed',
'团队文件已准备',
'result-file-1',
detailedSuccess,
[{
id: 'artifact-direct-1',
name: 'team.docx',
content_type: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
size: 3,
inline: false,
url: 'https://files.example.test/team.docx',
sha256: 'a'.repeat(64)
}]
);
assert.deepEqual((directFileResult.payload as Record<string, unknown>).attachments, [{
id: 'artifact-direct-1',
name: 'team.docx',
content_type: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
size: 3,
inline: false,
url: 'https://files.example.test/team.docx',
sha256: 'a'.repeat(64)
}]);
const insecureFileResult = createTaskResultFrame(
inbound,
session,
'completed',
'团队文件已准备',
'result-insecure-file-1',
{
kind: 'success',
text: '团队文件已准备',
recorded_at: new Date().toISOString(),
attachments: [{ name: 'team.docx', download_url: 'http://127.0.0.1:8786/file.docx' }]
}
);
assert.equal((insecureFileResult.payload as Record<string, unknown>).attachments, undefined);
const senderOnly = parseAgentBusFrame(JSON.stringify({
id: 'channel-event-without-conversation-1',
type: 'event',
from: 'channel:wechat:user-2',
payload: { text: '补充信息' }
}));
assert.ok(senderOnly);
const fallbackProgress = createTaskProgressFrame(senderOnly, session, '处理中', 'progress-fallback-1');
assert.equal(fallbackProgress.conversation_id, 'agentbus:channel:wechat:user-2');
});
test('AgentBus result text uses the unified important message and preserves the confirmation gate', () => {
const needsInput = makeTask('awaiting_user_input', {
input_request: {
missing_fields: ['data.room_counts'],
questions: [{ field: 'data.room_counts', prompt: '请补充房型分配。' }],
captured_facts: {}
},
important_message: {
kind: 'awaiting_user_input',
text: '请补充房型分配,并说明是否一单一房。',
recorded_at: new Date().toISOString(),
input_request: {
missing_fields: ['data.room_counts'],
questions: [{ field: 'data.room_counts', prompt: '请补充房型分配。' }],
captured_facts: {}
}
}
});
assert.match(taskResultText(needsInput), /是否一单一房/);
assert.equal(taskResultStatus(needsInput), 'completed');
const completed = makeTask('completed', {
message: '散拼-创建母团计划已提交并完成回查。',
important_message: {
kind: 'success',
text: '散拼-创建母团计划已提交并完成回查。\n团号LW-260907A-B',
recorded_at: new Date().toISOString(),
success_receipt: {
recorded_at: new Date().toISOString(),
receipt: {
group_numbers: ['LW-260907A-B', 'LW-260915A-A'],
room_counts: { SGL: 1, TWN: 8 },
verification_status: 'split_parent_completed'
}
}
}
});
assert.equal(
taskResultText(completed),
'散拼-创建母团计划已提交并完成回查。\n团号LW-260907A-B'
);
assert.doesNotMatch(taskResultText(completed), /最终回执|verification_status|split_parent_completed/);
const awaitingConfirmation = makeTask('awaiting_confirmation');
assert.equal(taskResultText(awaitingConfirmation), '已受理,正在处理。');
assert.equal(taskResultStatus(awaitingConfirmation), 'completed');
const failed = makeTask('parse_failed', {
failure: {
error_code: 'blocked',
failure_stage: 'parse',
failure_source: 'agent',
failure_message: '资料不完整',
agent_returned: true,
plugin_dispatch_started: false,
erp_write_started: false,
no_plugin_dispatch: true,
no_erp_write: true,
validation_errors: []
}
});
assert.equal(taskResultStatus(failed), 'failed');
assert.match(taskResultText(failed), /资料不完整/);
const blocked = makeTask('blocked', {
important_message: {
kind: 'error',
text: '插件执行阻断:客户候选未唯一解析,已阻断 ERP 写入。GetProduct ajax request was not observed; product_after_effect: expected exactly one existing LTJT option, found 0; Required fields still blank: customer, product.',
recorded_at: new Date().toISOString()
}
});
assert.equal(
taskResultText(blocked),
'插件执行阻断:客户候选未唯一解析,已阻断 ERP 写入。'
);
assert.doesNotMatch(taskResultText(blocked), /GetProduct|product_after_effect|Required fields/);
const erpFailure = makeTask('blocked', {
error_summary: {
recorded_at: new Date().toISOString(),
error_code: 'erp_business_rule_blocked',
stage: 'lifecycle_live_submit',
source: 'erp',
message: '此团还有【应收团款】账,不能取消!',
validation_errors: []
},
important_message: {
kind: 'error',
text: '此团还有【应收团款】账,不能取消!',
recorded_at: new Date().toISOString(),
error_summary: {
recorded_at: new Date().toISOString(),
error_code: 'erp_business_rule_blocked',
stage: 'lifecycle_live_submit',
source: 'erp',
message: '此团还有【应收团款】账,不能取消!',
validation_errors: []
}
}
});
assert.equal(taskResultStatus(erpFailure), 'failed');
assert.equal(taskResultText(erpFailure), '此团还有【应收团款】账,不能取消!');
const uncertainWrite = makeTask('reconciliation_pending', {
important_message: {
kind: 'error',
text: 'ERP 写入结果不确定write_after_requery_mismatch',
recorded_at: new Date().toISOString()
}
});
assert.equal(taskResultStatus(uncertainWrite), 'failed');
assert.equal(
taskResultText(uncertainWrite),
'ERP 写入已发起,但系统尚未确认最终结果。请勿重复提交同一任务,等待管理员只读核验。'
);
});
test('AgentBus attachment references omit archived visitor XLS and keep only XLSX delivery', () => {
const task = makeTask('completed', {
result: {
report: {
status: 'export_source_completed',
artifacts: [
{
artifact_id: 'artifact-visitor-xls',
file_name: 'Visitor.xls',
content_type: 'application/vnd.ms-excel',
bytes: 9377,
storage_backend: 'oss',
download_url: 'https://files.example.test/Visitor.xls',
agentbus_visible: false
},
{
artifact_id: 'artifact-visitor-xlsx',
file_name: 'Visitor.xlsx',
content_type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
bytes: 5211,
storage_backend: 'oss',
download_url: 'https://files.example.test/Visitor.xlsx',
agentbus_visible: true
}
]
}
}
});
assert.deepEqual(taskAttachmentReferences(task), [{
artifact_id: 'artifact-visitor-xlsx',
name: 'Visitor.xlsx',
content_type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
size: 5211,
url: 'https://files.example.test/Visitor.xlsx',
storage_backend: 'oss'
}]);
});
test('AgentBus listener connects with the documented Authorization header and returns one final result', async (t) => {
const config = testConfig();
const socket = new FakeSocket();
let capturedUrl = '';
let capturedHeaders: Record<string, string> = {};
let currentTask = makeTask('parse_queued');
const events = new EventEmitter();
const received: Array<Record<string, unknown>> = [];
let capturedContext: Record<string, unknown> = {};
const logs: Array<{ level: string; metadata: Record<string, unknown>; message?: string }> = [];
const tasks: AgentBusTaskGateway = {
events,
async ingestMessage(context, input) {
capturedContext = context as unknown as Record<string, unknown>;
received.push(input as unknown as Record<string, unknown>);
return { task: currentTask, attached: false, created: true };
},
async getTask() {
return currentTask;
}
};
const listener = new AgentBusListener({
config,
tasks,
organizationId: 'org-1',
scheduleParseQueue: async () => {
currentTask = makeTask('awaiting_confirmation');
events.emit('task', {
id: 1,
organization_id: 'org-1',
task_id: currentTask.task_id,
status: currentTask.status,
stage: 'parse',
message: currentTask.message,
payload: {},
created_at: new Date().toISOString()
});
await new Promise((resolve) => setTimeout(resolve, 10));
currentTask = makeTask('confirmed', {
confirmation_mode: 'automatic',
confirmed_at: new Date().toISOString(),
handoff_status: 'awaiting_handoff',
message: '指令解析完成,已进入全自动化 ERP 执行。'
});
events.emit('task', {
id: 2,
organization_id: 'org-1',
task_id: currentTask.task_id,
status: currentTask.status,
stage: 'automation',
message: currentTask.message,
payload: {},
created_at: new Date().toISOString()
});
await new Promise((resolve) => setTimeout(resolve, 10));
currentTask = makeTask('completed', {
message: '散拼-创建母团计划已提交并完成回查。',
important_message: {
kind: 'success',
text: '散拼-创建母团计划已提交并完成回查。\n团号LW-260907A-B',
recorded_at: new Date().toISOString(),
success_receipt: {
recorded_at: new Date().toISOString(),
receipt: {
group_number: 'LW-260907A-B',
verification_status: 'split_parent_completed'
}
}
}
});
events.emit('task', {
id: 3,
organization_id: 'org-1',
task_id: currentTask.task_id,
status: currentTask.status,
stage: 'browser_execution',
message: currentTask.message,
payload: {},
created_at: new Date().toISOString()
});
},
socketFactory: (url, options) => {
capturedUrl = url;
capturedHeaders = options.headers;
return socket as unknown as AgentBusSocket;
},
logger: {
info(metadata, message) {
logs.push({ level: 'info', metadata, message });
},
warn(metadata, message) {
logs.push({ level: 'warn', metadata, message });
},
error(metadata, message) {
logs.push({ level: 'error', metadata, message });
}
}
});
t.after(() => listener.stop());
listener.start();
assert.match(capturedUrl, /[?&]ready=1/);
assert.equal(capturedHeaders.Authorization, 'Bearer test-ws-token');
socket.readyState = 1;
socket.emit('open');
socket.emit('message', JSON.stringify({
id: 'ready-1',
type: 'event',
session_id: 'session-1',
epoch: 1,
to: 'bot:test:listener',
payload: { event: 'session.ready' }
}));
socket.emit('message', JSON.stringify({
id: 'ignored-frame-1',
type: 'message',
from: 'channel:wechat:user-1',
payload: { text: '格式不符合监听契约' }
}));
const wrappedBusinessText = [
'New WeChat message',
'Conversation: conversation-wechat-1',
'Text: 独立团单个下单',
'发团日期2026-11-02',
'预订客户:示例客户',
'产品搜索:示例产品',
'预估人数15+1',
'用房数量TWN 8SGL 1'
].join('\n');
socket.emit('message', JSON.stringify({
id: 'channel-event-1',
type: 'event',
from: 'channel:wechat:user-1',
payload: { text: wrappedBusinessText, reply_policy: { progress: true } }
}));
await waitFor(() => socket.sent.length === 2);
assert.equal(received.length, 1);
assert.equal(capturedContext.source, 'agentbus');
assert.equal(received[0].message, wrappedBusinessText.split('\n').slice(2).join('\n').replace(/^Text:\s*/, ''));
assert.equal(received[0].conversationId, 'conversation-wechat-1');
assert.equal(received[0].idempotencyKey, 'agentbus:channel-event-1');
assert.equal(socket.sent.length, 2);
assert.equal((socket.sent[0].payload as Record<string, unknown>).event, 'task.progress');
assert.equal((socket.sent[0].payload as Record<string, unknown>).status, 'accepted');
assert.equal((socket.sent[0].payload as Record<string, unknown>).text, '已受理,正在处理。');
assert.equal((socket.sent[1].payload as Record<string, unknown>).event, 'task.result');
assert.equal(socket.sent[1].reply_to, 'channel-event-1');
assert.match(String((socket.sent[1].payload as Record<string, unknown>).text), /散拼-创建母团计划已提交并完成回查/);
assert.match(String((socket.sent[1].payload as Record<string, unknown>).text), /LW-260907A-B/);
assert.doesNotMatch(String((socket.sent[1].payload as Record<string, unknown>).text), /最终回执|verification_status|split_parent_completed/);
assert.deepEqual((socket.sent[1].payload as Record<string, unknown>).important_message, {
kind: 'success',
text: '散拼-创建母团计划已提交并完成回查。\n团号LW-260907A-B'
});
assert.ok(logs.some((entry) => entry.metadata.agentbus_event === 'listener_starting'));
assert.ok(logs.some((entry) => entry.metadata.agentbus_event === 'session_ready'));
assert.ok(logs.some((entry) => entry.metadata.agentbus_event === 'frame_ignored' && entry.metadata.ignore_reason === 'unexpected_type:message'));
assert.ok(logs.some((entry) => entry.metadata.agentbus_event === 'frame_received' && entry.metadata.frame_id === 'channel-event-1'));
assert.ok(logs.some((entry) => entry.metadata.agentbus_event === 'frame_received' && entry.metadata.text_preview === wrappedBusinessText));
assert.ok(logs.some((entry) => entry.metadata.agentbus_event === 'task_processing_started'
&& entry.metadata.transport_envelope_unwrapped === true));
assert.ok(logs.some((entry) => entry.metadata.agentbus_event === 'task_ingested' && entry.metadata.task_id === 'TASK-1'));
assert.ok(logs.some((entry) => entry.metadata.agentbus_event === 'outbound_progress_sent'));
assert.ok(logs.some((entry) => entry.metadata.agentbus_event === 'outbound_result_sent'));
assert.doesNotMatch(JSON.stringify(logs), /test-ws-token/);
});
test('WeChat attachment placeholder without file metadata fails closed before task ingestion', async (t) => {
const socket = new FakeSocket();
const events = new EventEmitter();
const logs: Array<{ level: string; metadata: Record<string, unknown>; message?: string }> = [];
let ingestCalls = 0;
const tasks: AgentBusTaskGateway = {
events,
async ingestMessage() {
ingestCalls += 1;
return { task: makeTask('parse_queued'), attached: false, created: true };
},
async getTask() {
return makeTask('failed');
}
};
const listener = new AgentBusListener({
config: testConfig(),
tasks,
organizationId: 'org-1',
scheduleParseQueue: async () => {},
socketFactory: () => socket as unknown as AgentBusSocket,
logger: {
info(metadata, message) {
logs.push({ level: 'info', metadata, message });
},
warn(metadata, message) {
logs.push({ level: 'warn', metadata, message });
},
error(metadata, message) {
logs.push({ level: 'error', metadata, message });
}
}
});
t.after(() => listener.stop());
listener.start();
socket.readyState = 1;
socket.emit('open');
socket.emit('message', JSON.stringify({
id: 'ready-attachment-placeholder',
type: 'event',
session_id: 'session-attachment-placeholder',
epoch: 1,
to: 'bot:test:listener',
payload: { event: 'session.ready' }
}));
socket.emit('message', JSON.stringify({
id: 'wechat-attachment-placeholder',
type: 'event',
from: 'channel:wechat:user-1',
payload: {
text: [
'New WeChat message',
'Conversation: thread:conversation-attachment-1',
'Text: [WeChat attachment: synthetic-roster.xlsx]'
].join('\n')
}
}));
await waitFor(() => socket.sent.length === 1);
assert.equal(ingestCalls, 0);
assert.equal(socket.sent.length, 1);
assert.equal(socket.sent[0].conversation_id, 'thread:conversation-attachment-1');
assert.equal((socket.sent[0].payload as Record<string, unknown>).event, 'task.result');
assert.equal((socket.sent[0].payload as Record<string, unknown>).status, 'failed');
assert.equal(
(socket.sent[0].payload as Record<string, unknown>).text,
'附件内容未传到平台,原任务仍在等待附件。请检查微信桥接器的文件转发后重新发送。'
);
assert.ok(logs.some((entry) => entry.metadata.agentbus_event === 'task_processing_failed'
&& entry.metadata.error_code === 'roster_attachment_metadata_missing'
&& /^[a-f0-9]{24}$/u.test(String(entry.metadata.error_fingerprint))));
assert.ok(logs.some((entry) => entry.metadata.agentbus_event === 'task_processing_started'
&& entry.metadata.attachment_count === 0
&& entry.metadata.attachment_placeholder === true));
assert.ok(logs.some((entry) => entry.metadata.agentbus_event === 'attachment_metadata_missing'));
assert.ok(logs.some((entry) => entry.metadata.agentbus_event === 'outbound_result_sent'));
});
test('attachment validation rejection returns an immediate failed result without scheduling parsing', async (t) => {
const socket = new FakeSocket();
const events = new EventEmitter();
const rejectionMessage = '名单附件校验未通过【roster_workbook_conversion_failed】请按模板修正后重新发送。';
let parseQueueCalls = 0;
const tasks: AgentBusTaskGateway = {
events,
async ingestMessage() {
return {
task: makeTask('awaiting_attachment', {
task_id: 'TASK-ATTACHMENT-REJECTED',
source: 'agentbus',
message: rejectionMessage
}),
attached: true,
created: false,
input_attachment: {
status: 'rejected',
error_code: 'roster_workbook_conversion_failed',
message: rejectionMessage
}
};
},
async getTask() {
return makeTask('awaiting_attachment');
}
};
const listener = new AgentBusListener({
config: testConfig(),
tasks,
organizationId: 'org-1',
scheduleParseQueue: async () => {
parseQueueCalls += 1;
},
socketFactory: () => socket as unknown as AgentBusSocket
});
t.after(() => listener.stop());
listener.start();
socket.readyState = 1;
socket.emit('open');
socket.emit('message', JSON.stringify({
id: 'ready-attachment-rejected',
type: 'event',
session_id: 'session-attachment-rejected',
epoch: 1,
to: 'bot:test:listener',
payload: { event: 'session.ready' }
}));
socket.emit('message', JSON.stringify({
id: 'wechat-attachment-rejected',
type: 'event',
from: 'channel:wechat:user-1',
conversation_id: 'conversation-attachment-rejected',
payload: { text: '名单附件补充消息' }
}));
await waitFor(() => socket.sent.length === 2);
assert.equal(parseQueueCalls, 0);
assert.equal((socket.sent[0].payload as Record<string, unknown>).event, 'task.progress');
assert.equal((socket.sent[1].payload as Record<string, unknown>).event, 'task.result');
assert.equal((socket.sent[1].payload as Record<string, unknown>).status, 'failed');
assert.equal((socket.sent[1].payload as Record<string, unknown>).text, rejectionMessage);
assert.equal(socket.sent[1].reply_to, 'wechat-attachment-rejected');
assert.equal(socket.sent[1].conversation_id, 'conversation-attachment-rejected');
});
test('listener reload stop does not overwrite the channel status as disabled', () => {
const statuses: string[] = [];
const listener = new AgentBusListener({
config: testConfig(),
tasks: {} as AgentBusTaskGateway,
organizationId: 'org-1',
scheduleParseQueue: async () => {},
onStatusChange: (status) => statuses.push(status)
});
listener.stop(false);
assert.deepEqual(statuses, []);
listener.stop();
assert.deepEqual(statuses, ['disabled']);
});
test('channel manager replays a reload requested while another reload is in flight', async () => {
const manager = new AgentBusManager({
config: testConfig(),
tasks: {} as AgentBusTaskGateway,
organizationId: 'org-1',
scheduleParseQueue: async () => {}
});
const service = manager.channelService as unknown as {
ensureLegacyChannel: (organizationId: string) => Promise<void>;
listEnabledSecrets: (organizationId: string) => Promise<[]>;
};
let listCalls = 0;
let releaseFirstList: (() => void) | undefined;
service.ensureLegacyChannel = async () => {};
service.listEnabledSecrets = async () => {
listCalls += 1;
if (listCalls === 1) {
await new Promise<void>((resolve) => {
releaseFirstList = resolve;
});
}
return [];
};
const initialReload = manager.start();
while (!releaseFirstList) await new Promise<void>((resolve) => setImmediate(resolve));
const mutationReload = manager.reload();
releaseFirstList();
await Promise.all([initialReload, mutationReload]);
assert.equal(listCalls, 2);
await manager.stop();
});
test('listener shutdown carries its session epoch for stale-writer protection', () => {
const socket = new FakeSocket();
const changes: Array<{ status: string; epoch: number | null }> = [];
const listener = new AgentBusListener({
config: testConfig(),
tasks: {} as AgentBusTaskGateway,
organizationId: 'org-1',
scheduleParseQueue: async () => {},
socketFactory: () => socket as unknown as AgentBusSocket,
onStatusChange: (status, _error, epoch) => changes.push({ status, epoch })
});
listener.start();
socket.readyState = 1;
socket.emit('message', JSON.stringify({
id: 'ready-epoch-1',
type: 'event',
session_id: 'session-epoch-1',
epoch: 7,
to: 'bot:test:listener',
payload: { event: 'session.ready' }
}));
listener.stop();
assert.deepEqual(changes.at(-1), { status: 'disabled', epoch: 7 });
});
test('durable channel listener persists route and resends accepted/result deliveries', async () => {
const config = testConfig();
const socket = new FakeSocket();
const events = new EventEmitter();
let currentTask = makeTask('parse_queued', {
task_id: 'TASK-DURABLE',
channel_id: 'channel-1',
channel_name: '外部用户 A'
});
let acceptedCreated = false;
let resultEnqueued = false;
const deliveryState = {
acceptedDelivered: false,
resultDelivered: false,
failures: [] as string[]
};
let resultPayload: Record<string, unknown> = {};
const accepted: AgentBusDelivery = {
id: 'delivery-accepted',
channel_id: 'channel-1',
task_id: 'task-row-1',
inbound_frame_id: 'channel-durable-1',
inbound_from: 'channel:external:user-a',
conversation_id: 'conversation-durable-1',
delivery_kind: 'accepted',
payload: { event: 'task.progress', status: 'accepted', text: '已受理,正在处理。' },
attempt_count: 1
};
const result: AgentBusDelivery = {
...accepted,
id: 'delivery-result',
delivery_kind: 'result',
payload: resultPayload
};
let capturedHeaders: Record<string, string> = {};
let receivedContext: Record<string, unknown> = {};
let receivedInput: Record<string, unknown> = {};
const tasks: AgentBusTaskGateway & { deliveryState: typeof deliveryState } = {
deliveryState,
events,
async ingestMessage(context, input) {
acceptedCreated = true;
receivedContext = context as unknown as Record<string, unknown>;
receivedInput = input as unknown as Record<string, unknown>;
return { task: currentTask, attached: false, created: true };
},
async getTask() {
return currentTask;
},
async listAgentBusFinalizationCandidates() {
if (acceptedCreated && deliveryState.acceptedDelivered && !resultEnqueued && currentTask.status === 'completed') {
return [{ channel_id: 'channel-1', task_id: currentTask.task_id, inbound_frame_id: accepted.inbound_frame_id }];
}
return [];
},
async enqueueAgentBusResult(_channelId, _inboundFrameId, payload) {
resultPayload = payload;
result.payload = resultPayload;
resultEnqueued = true;
},
async claimAgentBusDeliveries() {
if (acceptedCreated && !deliveryState.acceptedDelivered) return [accepted];
if (resultEnqueued && !deliveryState.resultDelivered) return [result];
return [];
},
async markAgentBusDeliveryDelivered(this: { deliveryState: typeof deliveryState }, deliveryId) {
if (deliveryId === accepted.id) this.deliveryState.acceptedDelivered = true;
if (deliveryId === result.id) this.deliveryState.resultDelivered = true;
},
async markAgentBusDeliveryFailed(this: { deliveryState: typeof deliveryState }, _deliveryId, errorMessage) {
this.deliveryState.failures.push(errorMessage);
},
async releaseAgentBusDeliveries() {}
};
const listener = new AgentBusListener({
config,
tasks,
organizationId: 'org-1',
scheduleParseQueue: async () => {
currentTask = makeTask('completed', {
task_id: 'TASK-DURABLE',
channel_id: 'channel-1',
channel_name: '外部用户 A',
message: '任务已完成。',
result: {
report: {
status: 'export_source_completed',
artifacts: [
{
artifact_id: 'artifact-durable-source-xls',
file_name: 'Visitor.xls',
content_type: 'application/vnd.ms-excel',
bytes: 3,
sha256: 'b'.repeat(64),
storage_backend: 'oss',
download_url: 'https://files.example.test/Visitor.xls',
agentbus_visible: false
},
{
artifact_id: 'artifact-durable-1',
file_name: 'Visitor.xlsx',
content_type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
bytes: 4,
sha256: 'a'.repeat(64),
storage_backend: 'oss',
download_url: 'https://files.example.test/Visitor.xlsx',
agentbus_visible: true
}
]
}
},
important_message: {
kind: 'success',
text: '任务已完成。',
recorded_at: new Date().toISOString()
}
});
},
socketFactory: (_url, options) => {
capturedHeaders = options.headers;
return socket as unknown as AgentBusSocket;
},
channel: {
id: 'channel-1',
displayName: '外部用户 A',
wsUrl: 'wss://mesh.nianxx.cn/ws',
wsToken: 'channel-ws-token',
botAddress: 'bot:channel-a:listener'
}
});
listener.start();
assert.equal(capturedHeaders.Authorization, 'Bearer channel-ws-token');
socket.readyState = 1;
socket.emit('open');
socket.emit('message', JSON.stringify({
id: 'ready-durable-1',
type: 'event',
session_id: 'session-durable-1',
epoch: 4,
to: 'bot:channel-a:listener',
payload: { event: 'session.ready' }
}));
socket.emit('message', JSON.stringify({
id: 'channel-durable-1',
type: 'event',
from: 'channel:external:user-a',
conversation_id: 'conversation-durable-1',
payload: { text: '创建一个新团' }
}));
const deadline = Date.now() + 3_000;
while (Date.now() < deadline && !deliveryState.resultDelivered) {
await new Promise((resolve) => setTimeout(resolve, 50));
}
assert.equal(receivedContext.channelId, 'channel-1');
assert.equal(receivedInput.channelId, 'channel-1');
assert.deepEqual(receivedInput.agentBusRoute, {
inboundFrameId: 'channel-durable-1',
inboundFrom: 'channel:external:user-a',
conversationId: 'conversation-durable-1'
});
assert.deepEqual(resultPayload.attachments, [{
artifact_id: 'artifact-durable-1',
name: 'Visitor.xlsx',
content_type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
size: 4,
url: 'https://files.example.test/Visitor.xlsx',
sha256: 'a'.repeat(64),
storage_backend: 'oss'
}]);
assert.doesNotMatch(JSON.stringify(resultPayload), /content_base64|download_url/);
assert.equal(socket.sent.length, 2);
assert.equal(socket.sent[0].id, 'bot-accepted-delivery-accepted');
assert.equal((socket.sent[0].payload as Record<string, unknown>).event, 'task.progress');
assert.equal(socket.sent[1].id, 'bot-result-delivery-result');
assert.equal(socket.sent[1].reply_to, 'channel-durable-1');
assert.equal((socket.sent[1].payload as Record<string, unknown>).event, 'task.result');
assert.equal((socket.sent[1].payload as Record<string, unknown>).status, 'completed');
assert.deepEqual((socket.sent[1].payload as Record<string, unknown>).attachments, [{
id: 'artifact-durable-1',
name: 'Visitor.xlsx',
content_type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
size: 4,
inline: false,
url: 'https://files.example.test/Visitor.xlsx',
sha256: 'a'.repeat(64)
}]);
assert.equal(deliveryState.acceptedDelivered, true);
assert.equal(deliveryState.resultDelivered, true);
assert.deepEqual(deliveryState.failures, []);
listener.stop();
});
test('durable channel persists and sends an attachment rejection result while the task keeps waiting', async (t) => {
const config = testConfig();
const socket = new FakeSocket();
const events = new EventEmitter();
const rejectionMessage = '名单附件校验未通过【roster_workbook_conversion_failed】请按模板修正后重新发送。';
let acceptedCreated = false;
let resultEnqueued = false;
let acceptedDelivered = false;
let resultDelivered = false;
let parseQueueCalls = 0;
let resultPayload: Record<string, unknown> = {};
const accepted: AgentBusDelivery = {
id: 'delivery-attachment-rejected-accepted',
channel_id: 'channel-1',
task_id: 'task-row-attachment-rejected',
inbound_frame_id: 'channel-attachment-rejected',
inbound_from: 'channel:wechat:user-1',
conversation_id: 'conversation-attachment-rejected',
delivery_kind: 'accepted',
payload: { event: 'task.progress', status: 'accepted', text: '已受理,正在处理。' },
attempt_count: 1
};
const result: AgentBusDelivery = {
...accepted,
id: 'delivery-attachment-rejected-result',
delivery_kind: 'result',
payload: resultPayload
};
const logs: Array<{ level: string; metadata: Record<string, unknown>; message?: string }> = [];
const tasks: AgentBusTaskGateway = {
events,
async ingestMessage() {
acceptedCreated = true;
return {
task: makeTask('awaiting_attachment', {
task_id: 'TASK-ATTACHMENT-REJECTED',
source: 'agentbus',
channel_id: 'channel-1',
channel_name: '外部用户 A',
message: rejectionMessage
}),
attached: true,
created: false,
input_attachment: {
status: 'rejected',
error_code: 'roster_workbook_conversion_failed',
message: rejectionMessage
}
};
},
async getTask() {
return makeTask('awaiting_attachment');
},
async listAgentBusFinalizationCandidates() {
return [];
},
async enqueueAgentBusResult(channelId, inboundFrameId, payload) {
assert.equal(channelId, 'channel-1');
assert.equal(inboundFrameId, 'channel-attachment-rejected');
resultPayload = payload;
result.payload = resultPayload;
resultEnqueued = true;
},
async claimAgentBusDeliveries() {
const deliveries: AgentBusDelivery[] = [];
if (acceptedCreated && !acceptedDelivered) deliveries.push(accepted);
if (resultEnqueued && !resultDelivered) deliveries.push(result);
return deliveries;
},
async markAgentBusDeliveryDelivered(deliveryId) {
if (deliveryId === accepted.id) acceptedDelivered = true;
if (deliveryId === result.id) resultDelivered = true;
},
async markAgentBusDeliveryFailed() {},
async releaseAgentBusDeliveries() {}
};
const listener = new AgentBusListener({
config,
tasks,
organizationId: 'org-1',
scheduleParseQueue: async () => {
parseQueueCalls += 1;
},
socketFactory: () => socket as unknown as AgentBusSocket,
channel: {
id: 'channel-1',
displayName: '外部用户 A',
wsUrl: 'wss://mesh.nianxx.cn/ws',
wsToken: 'channel-ws-token',
botAddress: 'bot:channel-a:listener'
},
logger: {
info(metadata, message) {
logs.push({ level: 'info', metadata, message });
},
warn(metadata, message) {
logs.push({ level: 'warn', metadata, message });
},
error(metadata, message) {
logs.push({ level: 'error', metadata, message });
}
}
});
t.after(() => listener.stop());
listener.start();
socket.readyState = 1;
socket.emit('open');
socket.emit('message', JSON.stringify({
id: 'ready-durable-attachment-rejected',
type: 'event',
session_id: 'session-durable-attachment-rejected',
epoch: 4,
to: 'bot:channel-a:listener',
payload: { event: 'session.ready' }
}));
socket.emit('message', JSON.stringify({
id: 'channel-attachment-rejected',
type: 'event',
from: 'channel:wechat:user-1',
conversation_id: 'conversation-attachment-rejected',
payload: { text: '名单附件补充消息' }
}));
await waitFor(() => resultDelivered, 3_000);
assert.equal(acceptedDelivered, true);
assert.equal(resultDelivered, true);
assert.equal(parseQueueCalls, 0);
assert.deepEqual(resultPayload, {
event: 'task.result',
status: 'failed',
task_id: 'TASK-ATTACHMENT-REJECTED',
text: rejectionMessage
});
assert.equal(socket.sent.length, 2);
assert.equal((socket.sent[0].payload as Record<string, unknown>).event, 'task.progress');
assert.equal((socket.sent[1].payload as Record<string, unknown>).event, 'task.result');
assert.equal((socket.sent[1].payload as Record<string, unknown>).status, 'failed');
assert.equal((socket.sent[1].payload as Record<string, unknown>).text, rejectionMessage);
assert.equal(socket.sent[1].reply_to, 'channel-attachment-rejected');
assert.equal(socket.sent[1].conversation_id, 'conversation-attachment-rejected');
assert.ok(logs.some((entry) => entry.metadata.agentbus_event === 'attachment_rejection_reply_requested'
&& entry.metadata.error_code === 'roster_workbook_conversion_failed'));
});