fix: sequence AgentBus roster replies

This commit is contained in:
inman committed 2026-08-31 16:52:03 +08:00
1 parent 693aed54aa
commit ffa3340899
8 files changed
+557 -21

No files matched your search

+1 -1
View File
@@ -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` 只属于传输路由,不会再污染业务字段签名。
+29
View File
@@ -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;
}
+48 -8
View File
@@ -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({
+46 -7
View File
@@ -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
+359 -2
View File
@@ -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\)/);