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

+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\)/);