fix: sequence AgentBus roster replies
This commit is contained in:
1 parent
693aed54aa
commit
ffa3340899
8 files changed
+557
-21
No files matched your search
@@ -93,7 +93,7 @@ Auto 一旦发生 AI fallback,任务会永久绑定原 AI 会话。每次解
|
||||
|
||||
将 onboarding 文档中的 WebSocket 地址、WebSocket Token、Bot Address 和 Worker Address 写入服务端受保护的环境文件。配置 `AGENTBUS_ENABLED=auto` 时,只要填写 AgentBus 连接字段,控制平面就会自动启动长期监听;保持这些字段为空则不启动监听。
|
||||
|
||||
每个启用渠道会连接 `AGENTBUS_WS_URL?ready=1`,使用该渠道自己的 `Authorization: Bearer <channel-agentbus-key>`,等待 `session.ready` 后接收普通 `event` 消息。每条入站消息只发送一次持久化受理通知(`task.progress`,`status=accepted`)和一次持久化最终 `task.result`;不再发送解析完成、等待确认或进入 ERP 的中间进度。`GET /health/ready` 和 `GET /api/status` 的 `agentbus.channels` 字段可用于确认每个 listener 与 session 是否建立。
|
||||
每个启用渠道会连接 `AGENTBUS_WS_URL?ready=1`,使用该渠道自己的 `Authorization: Bearer <channel-agentbus-key>`,等待 `session.ready` 后接收普通 `event` 消息。普通任务发送一次持久化受理通知(`task.progress`,`status=accepted`)并在完成时返回一次 `task.result`。名单任务的首次文字指令改为返回明确的等待附件提示,附件入站改为返回“名单附件已收到,正在校验并处理”;最终结果只归属触发解析的最新附件消息,因此不会因文字与附件两条入站帧重复发送成功回执。解析完成、等待确认或进入 ERP 等内部进度不外发。`GET /health/ready` 和 `GET /api/status` 的 `agentbus.channels` 字段可用于确认每个 listener 与 session 是否建立。
|
||||
|
||||
对微信来源,listener 在调用 `TaskService.ingestMessage()` 前执行上述严格信封解包,因此手工正文与 AgentBus 正文进入同一个业务 route resolver、任务级 mode snapshot 和 parser orchestrator;`Conversation` 只属于传输路由,不会再污染业务字段签名。
|
||||
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
export const AGENTBUS_GENERIC_ACCEPTED_TEXT = '已受理,正在处理。';
|
||||
export const AGENTBUS_ROSTER_WAITING_TEXT = '已识别名单业务,等待 .xls 或 .xlsx 名单附件;附件到齐前不会开始解析或执行。';
|
||||
export const AGENTBUS_ROSTER_ATTACHMENT_RECEIVED_TEXT = '名单附件已收到,正在校验并处理。';
|
||||
export const AGENTBUS_FINAL_REPLY_ELIGIBLE_FIELD = '_final_reply_eligible';
|
||||
|
||||
export interface AgentBusAcceptedDeliveryOptions {
|
||||
text?: string;
|
||||
finalReplyEligible?: boolean;
|
||||
}
|
||||
|
||||
export function createAgentBusAcceptedDeliveryPayload(
|
||||
options: AgentBusAcceptedDeliveryOptions = {}
|
||||
): Record<string, unknown> {
|
||||
const acceptedText = String(options.text || '').trim() || AGENTBUS_GENERIC_ACCEPTED_TEXT;
|
||||
return {
|
||||
event: 'task.progress',
|
||||
status: 'accepted',
|
||||
text: acceptedText,
|
||||
[AGENTBUS_FINAL_REPLY_ELIGIBLE_FIELD]: options.finalReplyEligible !== false
|
||||
};
|
||||
}
|
||||
|
||||
export function publicAgentBusDeliveryPayload(
|
||||
value: Record<string, unknown>
|
||||
): Record<string, unknown> {
|
||||
const payload = { ...value };
|
||||
delete payload[AGENTBUS_FINAL_REPLY_ELIGIBLE_FIELD];
|
||||
return payload;
|
||||
}
|
||||
@@ -19,6 +19,11 @@ import {
|
||||
parseAgentBusInputAttachment
|
||||
} from './input-attachment.js';
|
||||
import { diagnosticDurationMs, diagnosticError } from './diagnostics.js';
|
||||
import {
|
||||
AGENTBUS_GENERIC_ACCEPTED_TEXT,
|
||||
AGENTBUS_ROSTER_ATTACHMENT_RECEIVED_TEXT,
|
||||
publicAgentBusDeliveryPayload
|
||||
} from './agentbus-delivery.js';
|
||||
|
||||
const OPEN_READY_STATE = 1;
|
||||
const MAX_COMPLETED_TASK_IDS = 2_048;
|
||||
@@ -487,7 +492,7 @@ function createDurableDeliveryFrame(
|
||||
session: AgentBusSession,
|
||||
directAttachments: AgentBusAttachmentFrame[] = []
|
||||
): AgentBusFrame {
|
||||
const payload = { ...jsonObject(delivery.payload) };
|
||||
const payload = publicAgentBusDeliveryPayload(jsonObject(delivery.payload));
|
||||
const fallbackAttachments = payloadAttachmentReferences(payload)
|
||||
.map((reference) => attachmentMetadataFrame(reference))
|
||||
.filter((attachment): attachment is AgentBusAttachmentFrame => Boolean(attachment));
|
||||
@@ -581,7 +586,7 @@ export function taskResultText(task: PublicTask): string {
|
||||
return '资料不完整,请补充必要信息。';
|
||||
}
|
||||
if (status === 'awaiting_confirmation') {
|
||||
return '已受理,正在处理。';
|
||||
return AGENTBUS_GENERIC_ACCEPTED_TEXT;
|
||||
}
|
||||
if (FAILED_TASK_STATUSES.has(status)) {
|
||||
const failure = task.failure?.failure_message || task.error_summary?.message || task.message;
|
||||
@@ -982,15 +987,16 @@ export class AgentBusListener {
|
||||
)
|
||||
));
|
||||
}
|
||||
const durableDelivery = this.durableDeliveryEnabled();
|
||||
const input: TaskMessageInput = {
|
||||
message,
|
||||
conversationId,
|
||||
taskId: frameTaskId(frame),
|
||||
...(attachments.length ? { attachments } : {}),
|
||||
idempotencyKey: this.durableDeliveryEnabled()
|
||||
idempotencyKey: durableDelivery
|
||||
? `agentbus:${this.channel?.id}:${taskId}`
|
||||
: `agentbus:${taskId}`,
|
||||
...(this.durableDeliveryEnabled() && this.channel
|
||||
...(durableDelivery && this.channel
|
||||
? {
|
||||
channelId: this.channel.id,
|
||||
agentBusRoute: {
|
||||
@@ -1001,7 +1007,6 @@ export class AgentBusListener {
|
||||
}
|
||||
: {})
|
||||
};
|
||||
if (!this.durableDeliveryEnabled()) this.sendProgress(frame, '已受理,正在处理。');
|
||||
const context: TaskContext = {
|
||||
organizationId: this.organizationId,
|
||||
userId: '',
|
||||
@@ -1019,6 +1024,36 @@ export class AgentBusListener {
|
||||
attached: result.attached,
|
||||
created: result.created
|
||||
}, 'AgentBus message ingested into task service');
|
||||
const attachmentResult = result.input_attachment || null;
|
||||
const waitingForRosterAttachment = !attachmentResult && result.task.status === 'awaiting_attachment';
|
||||
const progressText = attachmentResult?.status === 'duplicate'
|
||||
? attachmentResult.message
|
||||
: attachmentResult
|
||||
? AGENTBUS_ROSTER_ATTACHMENT_RECEIVED_TEXT
|
||||
: waitingForRosterAttachment
|
||||
? taskResultText(result.task)
|
||||
: AGENTBUS_GENERIC_ACCEPTED_TEXT;
|
||||
if (!durableDelivery) {
|
||||
this.sendProgress(
|
||||
frame,
|
||||
progressText,
|
||||
waitingForRosterAttachment || attachmentResult?.status === 'duplicate'
|
||||
);
|
||||
}
|
||||
if (waitingForRosterAttachment || attachmentResult?.status === 'duplicate') {
|
||||
this.logger.info({
|
||||
agentbus_event: waitingForRosterAttachment
|
||||
? 'roster_attachment_waiting_reply_ready'
|
||||
: 'attachment_duplicate_reply_ready',
|
||||
inbound_frame_id: taskId,
|
||||
task_id: result.task.task_id,
|
||||
task_status: result.task.status
|
||||
}, waitingForRosterAttachment
|
||||
? 'AgentBus roster attachment waiting reply ready'
|
||||
: 'AgentBus duplicate attachment reply ready');
|
||||
if (durableDelivery) await this.flushDurableDeliveries();
|
||||
return;
|
||||
}
|
||||
const rejectedAttachment = result.input_attachment?.status === 'rejected'
|
||||
? result.input_attachment
|
||||
: null;
|
||||
@@ -1030,7 +1065,7 @@ export class AgentBusListener {
|
||||
task_status: result.task.status,
|
||||
error_code: rejectedAttachment.error_code || 'roster_workbook_processing_failed'
|
||||
}, 'AgentBus roster attachment rejection reply requested');
|
||||
if (this.durableDeliveryEnabled() && this.channel && this.tasks.enqueueAgentBusResult) {
|
||||
if (durableDelivery && this.channel && this.tasks.enqueueAgentBusResult) {
|
||||
await this.tasks.enqueueAgentBusResult.call(this.tasks, this.channel.id, taskId, {
|
||||
event: 'task.result',
|
||||
status: 'failed',
|
||||
@@ -1048,7 +1083,7 @@ export class AgentBusListener {
|
||||
inbound_frame_id: taskId,
|
||||
task_id: result.task.task_id
|
||||
}, 'AgentBus requesting parse queue');
|
||||
if (this.durableDeliveryEnabled()) {
|
||||
if (durableDelivery) {
|
||||
await this.scheduleParseQueue().catch((error) => {
|
||||
this.logger.warn({
|
||||
agentbus_event: 'parse_queue_request_failed',
|
||||
@@ -1298,7 +1333,11 @@ export class AgentBusListener {
|
||||
});
|
||||
}
|
||||
|
||||
private sendProgress(inbound: AgentBusFrame, progressText: string): void {
|
||||
private sendProgress(
|
||||
inbound: AgentBusFrame,
|
||||
progressText: string,
|
||||
completeInbound = false
|
||||
): void {
|
||||
if (!this.socket || this.socket.readyState !== OPEN_READY_STATE || !this.session) {
|
||||
this.logger.warn({
|
||||
agentbus_event: 'progress_skipped',
|
||||
@@ -1321,6 +1360,7 @@ export class AgentBusListener {
|
||||
return;
|
||||
}
|
||||
this.logFrame('outbound_progress_sent', frame, 'AgentBus progress frame sent');
|
||||
if (completeInbound) this.rememberCompleted(text(inbound.id));
|
||||
});
|
||||
} catch (error) {
|
||||
this.logger.warn({
|
||||
|
||||
@@ -43,6 +43,12 @@ import {
|
||||
PASSENGER_ROSTER_WORKBOOK_VERSION,
|
||||
type NormalizedPassengerRosterWorkbook
|
||||
} from './passenger-roster-workbook.js';
|
||||
import {
|
||||
AGENTBUS_ROSTER_ATTACHMENT_RECEIVED_TEXT,
|
||||
AGENTBUS_ROSTER_WAITING_TEXT,
|
||||
createAgentBusAcceptedDeliveryPayload,
|
||||
type AgentBusAcceptedDeliveryOptions
|
||||
} from './agentbus-delivery.js';
|
||||
|
||||
export interface TaskEvent {
|
||||
id: number;
|
||||
@@ -1056,7 +1062,7 @@ export function initialTaskIntakeState(routeId: BusinessRouteId | null): {
|
||||
? {
|
||||
status: 'awaiting_attachment',
|
||||
stage: 'intake',
|
||||
message: '已识别名单业务,等待 .xls 或 .xlsx 名单附件;附件到齐前不会开始解析或执行。'
|
||||
message: AGENTBUS_ROSTER_WAITING_TEXT
|
||||
}
|
||||
: { status: 'parse_queued', stage: 'parse', message: '任务已入队,等待解析。' };
|
||||
}
|
||||
@@ -2107,7 +2113,8 @@ export class TaskService {
|
||||
channelId: string | undefined,
|
||||
taskRowId: string,
|
||||
route: TaskMessageInput['agentBusRoute'] | undefined,
|
||||
conversationId: string | null
|
||||
conversationId: string | null,
|
||||
options: AgentBusAcceptedDeliveryOptions = {}
|
||||
): Promise<void> {
|
||||
if (!channelId || !route?.inboundFrameId || !route.inboundFrom) return;
|
||||
await client.query(
|
||||
@@ -2123,7 +2130,7 @@ export class TaskService {
|
||||
route.inboundFrameId,
|
||||
route.inboundFrom,
|
||||
route.conversationId || conversationId,
|
||||
{ event: 'task.progress', status: 'accepted', text: '已受理,正在处理。' }
|
||||
createAgentBusAcceptedDeliveryPayload(options)
|
||||
]
|
||||
);
|
||||
}
|
||||
@@ -2910,7 +2917,11 @@ export class TaskService {
|
||||
target.channelId || undefined,
|
||||
target.rowId,
|
||||
selection.agentBusRoute,
|
||||
target.conversationId
|
||||
target.conversationId,
|
||||
{
|
||||
text: '该名单附件已经接收,无需重复发送。',
|
||||
finalReplyEligible: false
|
||||
}
|
||||
);
|
||||
return {
|
||||
row: taskRow,
|
||||
@@ -3095,7 +3106,11 @@ export class TaskService {
|
||||
target.channelId || undefined,
|
||||
target.rowId,
|
||||
selection.agentBusRoute,
|
||||
target.conversationId
|
||||
target.conversationId,
|
||||
{
|
||||
text: AGENTBUS_ROSTER_ATTACHMENT_RECEIVED_TEXT,
|
||||
finalReplyEligible: true
|
||||
}
|
||||
);
|
||||
return { row: updatedRow, event, inputAttachment };
|
||||
});
|
||||
@@ -3229,11 +3244,16 @@ export class TaskService {
|
||||
if (!isPassengerRosterRouteId(routeId)) {
|
||||
throw new TaskError('attachment_not_supported', '只有补充录入名单业务可以接收 Excel 名单附件。', 409);
|
||||
}
|
||||
const baseResult = await this.ingestMessage(context, { ...input, attachments: undefined });
|
||||
const baseResult = await this.ingestMessage(context, {
|
||||
...input,
|
||||
attachments: undefined,
|
||||
agentBusRoute: undefined
|
||||
});
|
||||
return this.attachPassengerRosterAttachment(context, attachment, {
|
||||
taskId: baseResult.task.task_id,
|
||||
conversationId,
|
||||
channelId,
|
||||
agentBusRoute: input.agentBusRoute,
|
||||
created: baseResult.created
|
||||
});
|
||||
}
|
||||
@@ -3414,7 +3434,13 @@ export class TaskService {
|
||||
channelId,
|
||||
text(taskRow.id),
|
||||
input.agentBusRoute,
|
||||
conversationId
|
||||
conversationId,
|
||||
intake.status === 'awaiting_attachment'
|
||||
? {
|
||||
text: intake.message,
|
||||
finalReplyEligible: false
|
||||
}
|
||||
: undefined
|
||||
);
|
||||
const event = await this.emitEvent(client, taskRow, {
|
||||
status: intake.status,
|
||||
@@ -3554,10 +3580,23 @@ export class TaskService {
|
||||
WHERE d.channel_id = $1
|
||||
AND d.delivery_kind = 'accepted'
|
||||
AND d.delivery_status = 'delivered'
|
||||
AND (d.payload ->> '_final_reply_eligible') IS DISTINCT FROM 'false'
|
||||
AND t.status NOT IN (
|
||||
'awaiting_attachment', 'parse_queued', 'parse_running', 'awaiting_confirmation',
|
||||
'confirmed', 'queued', 'accepted', 'running'
|
||||
)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM agentbus_deliveries newer_delivery
|
||||
WHERE newer_delivery.organization_id = d.organization_id
|
||||
AND newer_delivery.channel_id = d.channel_id
|
||||
AND newer_delivery.task_id = d.task_id
|
||||
AND newer_delivery.delivery_kind = 'accepted'
|
||||
AND (newer_delivery.payload ->> '_final_reply_eligible') IS DISTINCT FROM 'false'
|
||||
AND (
|
||||
newer_delivery.created_at > d.created_at
|
||||
OR (newer_delivery.created_at = d.created_at AND newer_delivery.id > d.id)
|
||||
)
|
||||
)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM agentbus_deliveries result_delivery
|
||||
WHERE result_delivery.channel_id = d.channel_id
|
||||
|
||||
@@ -21,6 +21,14 @@ import {
|
||||
mergeRuntimeChannelStatuses,
|
||||
type PublicAgentBusChannel
|
||||
} from '../src/agentbus-channels.js';
|
||||
import {
|
||||
AGENTBUS_FINAL_REPLY_ELIGIBLE_FIELD,
|
||||
AGENTBUS_GENERIC_ACCEPTED_TEXT,
|
||||
AGENTBUS_ROSTER_ATTACHMENT_RECEIVED_TEXT,
|
||||
AGENTBUS_ROSTER_WAITING_TEXT,
|
||||
createAgentBusAcceptedDeliveryPayload,
|
||||
publicAgentBusDeliveryPayload
|
||||
} from '../src/agentbus-delivery.js';
|
||||
import { resolveBusinessRoute } from '../src/business-routes.js';
|
||||
import type { AgentBusDelivery, PublicTask } from '../src/task-service.js';
|
||||
|
||||
@@ -159,6 +167,26 @@ test('AgentBus configuration stays disabled until connection fields are supplied
|
||||
assert.equal(config.AGENTBUS_LOG_PAYLOADS, false);
|
||||
});
|
||||
|
||||
test('AgentBus accepted delivery payloads keep final ownership metadata server-only', () => {
|
||||
const waitingPayload = createAgentBusAcceptedDeliveryPayload({
|
||||
text: AGENTBUS_ROSTER_WAITING_TEXT,
|
||||
finalReplyEligible: false
|
||||
});
|
||||
assert.equal(waitingPayload.text, AGENTBUS_ROSTER_WAITING_TEXT);
|
||||
assert.equal(waitingPayload[AGENTBUS_FINAL_REPLY_ELIGIBLE_FIELD], false);
|
||||
assert.deepEqual(publicAgentBusDeliveryPayload(waitingPayload), {
|
||||
event: 'task.progress',
|
||||
status: 'accepted',
|
||||
text: AGENTBUS_ROSTER_WAITING_TEXT
|
||||
});
|
||||
assert.deepEqual(createAgentBusAcceptedDeliveryPayload(), {
|
||||
event: 'task.progress',
|
||||
status: 'accepted',
|
||||
text: AGENTBUS_GENERIC_ACCEPTED_TEXT,
|
||||
[AGENTBUS_FINAL_REPLY_ELIGIBLE_FIELD]: true
|
||||
});
|
||||
});
|
||||
|
||||
test('AgentBus auto configuration rejects partial credentials', () => {
|
||||
assert.throws(() => loadConfig({
|
||||
NODE_ENV: 'test',
|
||||
@@ -839,6 +867,10 @@ test('attachment validation rejection returns an immediate failed result without
|
||||
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[0].payload as Record<string, unknown>).text,
|
||||
AGENTBUS_ROSTER_ATTACHMENT_RECEIVED_TEXT
|
||||
);
|
||||
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);
|
||||
@@ -846,6 +878,123 @@ test('attachment validation rejection returns an immediate failed result without
|
||||
assert.equal(socket.sent[1].conversation_id, 'conversation-attachment-rejected');
|
||||
});
|
||||
|
||||
test('roster direct listener sends waiting, attachment-received, and one final reply across two frames', async (t) => {
|
||||
const socket = new FakeSocket();
|
||||
const events = new EventEmitter();
|
||||
const taskId = 'TASK-ROSTER-REPLY-LIFECYCLE';
|
||||
const finalText = '名单导入成功 子单号:D14541 本次导入:25人';
|
||||
let parseQueueCalls = 0;
|
||||
let ingestCalls = 0;
|
||||
let currentTask = makeTask('awaiting_attachment', {
|
||||
task_id: taskId,
|
||||
source: 'agentbus',
|
||||
message: AGENTBUS_ROSTER_WAITING_TEXT
|
||||
});
|
||||
const tasks: AgentBusTaskGateway = {
|
||||
events,
|
||||
async ingestMessage() {
|
||||
ingestCalls += 1;
|
||||
if (ingestCalls === 1) {
|
||||
return { task: currentTask, attached: false, created: true };
|
||||
}
|
||||
currentTask = makeTask('parse_queued', {
|
||||
task_id: taskId,
|
||||
source: 'agentbus',
|
||||
message: '名单附件已校验,共 25 行;任务已入队,等待程序解析。'
|
||||
});
|
||||
return {
|
||||
task: currentTask,
|
||||
attached: true,
|
||||
created: false,
|
||||
input_attachment: {
|
||||
status: 'normalized',
|
||||
row_count: 25,
|
||||
message: '名单附件已校验,共 25 行,开始程序解析。'
|
||||
}
|
||||
};
|
||||
},
|
||||
async getTask() {
|
||||
return currentTask;
|
||||
}
|
||||
};
|
||||
const listener = new AgentBusListener({
|
||||
config: testConfig(),
|
||||
tasks,
|
||||
organizationId: 'org-1',
|
||||
scheduleParseQueue: async () => {
|
||||
parseQueueCalls += 1;
|
||||
currentTask = makeTask('completed', {
|
||||
task_id: taskId,
|
||||
source: 'agentbus',
|
||||
message: finalText,
|
||||
important_message: {
|
||||
kind: 'success',
|
||||
text: finalText,
|
||||
recorded_at: new Date().toISOString()
|
||||
}
|
||||
});
|
||||
events.emit('task', {
|
||||
id: 1,
|
||||
organization_id: 'org-1',
|
||||
task_id: taskId,
|
||||
status: 'completed',
|
||||
stage: 'browser_execution',
|
||||
message: finalText,
|
||||
payload: {},
|
||||
created_at: new Date().toISOString()
|
||||
});
|
||||
},
|
||||
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-roster-lifecycle',
|
||||
type: 'event',
|
||||
session_id: 'session-roster-lifecycle',
|
||||
epoch: 1,
|
||||
to: 'bot:test:listener',
|
||||
payload: { event: 'session.ready' }
|
||||
}));
|
||||
socket.emit('message', JSON.stringify({
|
||||
id: 'roster-directive-frame',
|
||||
type: 'event',
|
||||
from: 'channel:wechat:user-1',
|
||||
conversation_id: 'conversation-roster-lifecycle',
|
||||
payload: { text: '导入散拼子单名单' }
|
||||
}));
|
||||
|
||||
await waitFor(() => socket.sent.length === 1);
|
||||
assert.equal(parseQueueCalls, 0);
|
||||
assert.equal((socket.sent[0].payload as Record<string, unknown>).event, 'task.progress');
|
||||
assert.equal((socket.sent[0].payload as Record<string, unknown>).text, AGENTBUS_ROSTER_WAITING_TEXT);
|
||||
|
||||
socket.emit('message', JSON.stringify({
|
||||
id: 'roster-attachment-frame',
|
||||
type: 'event',
|
||||
from: 'channel:wechat:user-1',
|
||||
conversation_id: 'conversation-roster-lifecycle',
|
||||
payload: { text: '名单附件补充消息' }
|
||||
}));
|
||||
|
||||
await waitFor(() => socket.sent.length === 3);
|
||||
assert.equal(parseQueueCalls, 1);
|
||||
assert.equal((socket.sent[1].payload as Record<string, unknown>).event, 'task.progress');
|
||||
assert.equal(
|
||||
(socket.sent[1].payload as Record<string, unknown>).text,
|
||||
AGENTBUS_ROSTER_ATTACHMENT_RECEIVED_TEXT
|
||||
);
|
||||
const finalReplies = socket.sent.filter((frame) => {
|
||||
const payload = frame.payload as Record<string, unknown>;
|
||||
return payload.event === 'task.result' && payload.text === finalText;
|
||||
});
|
||||
assert.equal(finalReplies.length, 1);
|
||||
assert.equal(finalReplies[0].reply_to, 'roster-attachment-frame');
|
||||
});
|
||||
|
||||
test('listener reload stop does not overwrite the channel status as disabled', () => {
|
||||
const statuses: string[] = [];
|
||||
const listener = new AgentBusListener({
|
||||
@@ -948,7 +1097,7 @@ test('durable channel listener persists route and resends accepted/result delive
|
||||
inbound_from: 'channel:external:user-a',
|
||||
conversation_id: 'conversation-durable-1',
|
||||
delivery_kind: 'accepted',
|
||||
payload: { event: 'task.progress', status: 'accepted', text: '已受理,正在处理。' },
|
||||
payload: createAgentBusAcceptedDeliveryPayload(),
|
||||
attempt_count: 1
|
||||
};
|
||||
const result: AgentBusDelivery = {
|
||||
@@ -1117,6 +1266,203 @@ test('durable channel listener persists route and resends accepted/result delive
|
||||
listener.stop();
|
||||
});
|
||||
|
||||
test('durable roster lifecycle assigns the final result only to the attachment frame', async (t) => {
|
||||
const socket = new FakeSocket();
|
||||
const events = new EventEmitter();
|
||||
const publicTaskId = 'TASK-DURABLE-ROSTER-LIFECYCLE';
|
||||
const taskRowId = 'task-row-durable-roster-lifecycle';
|
||||
const finalText = '名单导入成功 子单号:D14541 本次导入:25人';
|
||||
const deliveries: AgentBusDelivery[] = [];
|
||||
const deliveryStatuses = new Map<string, 'pending' | 'sending' | 'delivered'>();
|
||||
let deliverySequence = 0;
|
||||
let parseQueueCalls = 0;
|
||||
let currentTask = makeTask('awaiting_attachment', {
|
||||
task_id: publicTaskId,
|
||||
source: 'agentbus',
|
||||
channel_id: 'channel-1',
|
||||
channel_name: '外部用户 A',
|
||||
message: AGENTBUS_ROSTER_WAITING_TEXT
|
||||
});
|
||||
const addDelivery = (
|
||||
kind: AgentBusDelivery['delivery_kind'],
|
||||
inboundFrameId: string,
|
||||
payload: Record<string, unknown>
|
||||
) => {
|
||||
deliverySequence += 1;
|
||||
const delivery: AgentBusDelivery = {
|
||||
id: `delivery-roster-${deliverySequence}`,
|
||||
channel_id: 'channel-1',
|
||||
task_id: taskRowId,
|
||||
inbound_frame_id: inboundFrameId,
|
||||
inbound_from: 'channel:wechat:user-1',
|
||||
conversation_id: 'conversation-durable-roster-lifecycle',
|
||||
delivery_kind: kind,
|
||||
payload,
|
||||
attempt_count: 1
|
||||
};
|
||||
deliveries.push(delivery);
|
||||
deliveryStatuses.set(delivery.id, 'pending');
|
||||
return delivery;
|
||||
};
|
||||
let ingestCalls = 0;
|
||||
const tasks: AgentBusTaskGateway = {
|
||||
events,
|
||||
async ingestMessage(_context, input) {
|
||||
ingestCalls += 1;
|
||||
const inboundFrameId = String(input.agentBusRoute?.inboundFrameId || '');
|
||||
if (ingestCalls === 1) {
|
||||
addDelivery('accepted', inboundFrameId, createAgentBusAcceptedDeliveryPayload({
|
||||
text: AGENTBUS_ROSTER_WAITING_TEXT,
|
||||
finalReplyEligible: false
|
||||
}));
|
||||
return { task: currentTask, attached: false, created: true };
|
||||
}
|
||||
addDelivery('accepted', inboundFrameId, createAgentBusAcceptedDeliveryPayload({
|
||||
text: AGENTBUS_ROSTER_ATTACHMENT_RECEIVED_TEXT,
|
||||
finalReplyEligible: true
|
||||
}));
|
||||
currentTask = makeTask('parse_queued', {
|
||||
task_id: publicTaskId,
|
||||
source: 'agentbus',
|
||||
channel_id: 'channel-1',
|
||||
channel_name: '外部用户 A'
|
||||
});
|
||||
return {
|
||||
task: currentTask,
|
||||
attached: true,
|
||||
created: false,
|
||||
input_attachment: {
|
||||
status: 'normalized',
|
||||
row_count: 25,
|
||||
message: '名单附件已校验,共 25 行,开始程序解析。'
|
||||
}
|
||||
};
|
||||
},
|
||||
async getTask() {
|
||||
return currentTask;
|
||||
},
|
||||
async listAgentBusFinalizationCandidates() {
|
||||
if (currentTask.status !== 'completed') return [];
|
||||
const eligibleAccepted = deliveries.filter((delivery) => (
|
||||
delivery.delivery_kind === 'accepted'
|
||||
&& deliveryStatuses.get(delivery.id) === 'delivered'
|
||||
&& delivery.payload[AGENTBUS_FINAL_REPLY_ELIGIBLE_FIELD] !== false
|
||||
));
|
||||
const latest = eligibleAccepted.at(-1);
|
||||
if (!latest) return [];
|
||||
const alreadyHasResult = deliveries.some((delivery) => (
|
||||
delivery.delivery_kind === 'result'
|
||||
&& delivery.inbound_frame_id === latest.inbound_frame_id
|
||||
));
|
||||
return alreadyHasResult ? [] : [{
|
||||
channel_id: 'channel-1',
|
||||
task_id: publicTaskId,
|
||||
inbound_frame_id: latest.inbound_frame_id
|
||||
}];
|
||||
},
|
||||
async enqueueAgentBusResult(_channelId, inboundFrameId, payload) {
|
||||
addDelivery('result', inboundFrameId, payload);
|
||||
},
|
||||
async claimAgentBusDeliveries() {
|
||||
const pending = deliveries.filter((delivery) => deliveryStatuses.get(delivery.id) === 'pending');
|
||||
for (const delivery of pending) deliveryStatuses.set(delivery.id, 'sending');
|
||||
return pending;
|
||||
},
|
||||
async markAgentBusDeliveryDelivered(deliveryId) {
|
||||
deliveryStatuses.set(deliveryId, 'delivered');
|
||||
},
|
||||
async markAgentBusDeliveryFailed() {},
|
||||
async releaseAgentBusDeliveries() {}
|
||||
};
|
||||
const listener = new AgentBusListener({
|
||||
config: testConfig(),
|
||||
tasks,
|
||||
organizationId: 'org-1',
|
||||
scheduleParseQueue: async () => {
|
||||
parseQueueCalls += 1;
|
||||
currentTask = makeTask('completed', {
|
||||
task_id: publicTaskId,
|
||||
source: 'agentbus',
|
||||
channel_id: 'channel-1',
|
||||
channel_name: '外部用户 A',
|
||||
message: finalText,
|
||||
important_message: {
|
||||
kind: 'success',
|
||||
text: finalText,
|
||||
recorded_at: new Date().toISOString()
|
||||
}
|
||||
});
|
||||
},
|
||||
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'
|
||||
}
|
||||
});
|
||||
t.after(() => listener.stop());
|
||||
|
||||
listener.start();
|
||||
socket.readyState = 1;
|
||||
socket.emit('open');
|
||||
socket.emit('message', JSON.stringify({
|
||||
id: 'ready-durable-roster-lifecycle',
|
||||
type: 'event',
|
||||
session_id: 'session-durable-roster-lifecycle',
|
||||
epoch: 1,
|
||||
to: 'bot:channel-a:listener',
|
||||
payload: { event: 'session.ready' }
|
||||
}));
|
||||
socket.emit('message', JSON.stringify({
|
||||
id: 'durable-roster-directive-frame',
|
||||
type: 'event',
|
||||
from: 'channel:wechat:user-1',
|
||||
conversation_id: 'conversation-durable-roster-lifecycle',
|
||||
payload: { text: '导入散拼子单名单' }
|
||||
}));
|
||||
|
||||
await waitFor(() => socket.sent.length === 1, 3_000);
|
||||
assert.equal(parseQueueCalls, 0);
|
||||
assert.equal((socket.sent[0].payload as Record<string, unknown>).text, AGENTBUS_ROSTER_WAITING_TEXT);
|
||||
|
||||
socket.emit('message', JSON.stringify({
|
||||
id: 'durable-roster-attachment-frame',
|
||||
type: 'event',
|
||||
from: 'channel:wechat:user-1',
|
||||
conversation_id: 'conversation-durable-roster-lifecycle',
|
||||
payload: { text: '名单附件补充消息' }
|
||||
}));
|
||||
|
||||
await waitFor(() => socket.sent.length === 3, 4_000);
|
||||
assert.equal(parseQueueCalls, 1);
|
||||
assert.deepEqual(socket.sent.map((frame) => {
|
||||
const payload = frame.payload as Record<string, unknown>;
|
||||
return { event: payload.event, text: payload.text, reply_to: frame.reply_to };
|
||||
}), [
|
||||
{
|
||||
event: 'task.progress',
|
||||
text: AGENTBUS_ROSTER_WAITING_TEXT,
|
||||
reply_to: 'durable-roster-directive-frame'
|
||||
},
|
||||
{
|
||||
event: 'task.progress',
|
||||
text: AGENTBUS_ROSTER_ATTACHMENT_RECEIVED_TEXT,
|
||||
reply_to: 'durable-roster-attachment-frame'
|
||||
},
|
||||
{
|
||||
event: 'task.result',
|
||||
text: finalText,
|
||||
reply_to: 'durable-roster-attachment-frame'
|
||||
}
|
||||
]);
|
||||
assert.equal(
|
||||
socket.sent.some((frame) => AGENTBUS_FINAL_REPLY_ELIGIBLE_FIELD in (frame.payload as Record<string, unknown>)),
|
||||
false
|
||||
);
|
||||
});
|
||||
|
||||
test('durable channel persists and sends an attachment rejection result while the task keeps waiting', async (t) => {
|
||||
const config = testConfig();
|
||||
const socket = new FakeSocket();
|
||||
@@ -1136,7 +1482,10 @@ test('durable channel persists and sends an attachment rejection result while th
|
||||
inbound_from: 'channel:wechat:user-1',
|
||||
conversation_id: 'conversation-attachment-rejected',
|
||||
delivery_kind: 'accepted',
|
||||
payload: { event: 'task.progress', status: 'accepted', text: '已受理,正在处理。' },
|
||||
payload: createAgentBusAcceptedDeliveryPayload({
|
||||
text: AGENTBUS_ROSTER_ATTACHMENT_RECEIVED_TEXT,
|
||||
finalReplyEligible: true
|
||||
}),
|
||||
attempt_count: 1
|
||||
};
|
||||
const result: AgentBusDelivery = {
|
||||
@@ -1253,6 +1602,14 @@ test('durable channel persists and sends an attachment rejection result while th
|
||||
});
|
||||
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>).text,
|
||||
AGENTBUS_ROSTER_ATTACHMENT_RECEIVED_TEXT
|
||||
);
|
||||
assert.equal(
|
||||
(socket.sent[0].payload as Record<string, unknown>)[AGENTBUS_FINAL_REPLY_ELIGIBLE_FIELD],
|
||||
undefined
|
||||
);
|
||||
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);
|
||||
|
||||
@@ -56,6 +56,14 @@ test('a newly delivered message revalidates previously rejected attachment bytes
|
||||
assert.match(source, /i\.idempotency_key = \$2[\s\S]*?status: 'duplicate' as const/u);
|
||||
});
|
||||
|
||||
test('durable roster replies distinguish waiting and attachment phases and select one final owner', async () => {
|
||||
const source = await readFile(new URL('../src/task-service.ts', import.meta.url), 'utf8');
|
||||
assert.match(source, /text: intake\.message,[\s\S]*?finalReplyEligible: false/u);
|
||||
assert.match(source, /text: AGENTBUS_ROSTER_ATTACHMENT_RECEIVED_TEXT,[\s\S]*?finalReplyEligible: true/u);
|
||||
assert.match(source, /agentBusRoute: undefined[\s\S]*?agentBusRoute: input\.agentBusRoute/u);
|
||||
assert.match(source, /d\.payload ->> '_final_reply_eligible'[\s\S]*?newer_delivery\.created_at > d\.created_at/u);
|
||||
});
|
||||
|
||||
test('manual API accepts exactly one bounded inline roster attachment', async () => {
|
||||
const source = await readFile(new URL('../src/server.ts', import.meta.url), 'utf8');
|
||||
assert.match(source, /attachments: z\.array\(encodedInputAttachmentSchema\)\.max\(1\)/);
|
||||
|
||||
Reference in new issue
Block a user