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
@@ -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