fix: sequence AgentBus roster replies

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

View File

@@ -0,0 +1,60 @@
# Task: Fix AgentBus roster reply lifecycle
## Identity
- Task ID: 20260831-fix-agentbus-roster-replies-4c8e1a72
- Mode: Feature
- Branch: main
- Worktree: /Users/inmanx/Documents/lwltAPI
- Base commit: 693aed54aa40ef5516784bc96d97ba8f5c154b77
- Owner: codex
- Status: Ready for integration
## Scope
- Replace the generic AgentBus acceptance text for an initial roster directive with the existing explicit `.xls/.xlsx` waiting prompt.
- Replace the generic acceptance text for a structured roster attachment with an attachment-specific received/validation prompt.
- Ensure one roster task completion produces one final AgentBus result even though the directive and attachment arrive as separate inbound frames.
- Preserve direct-listener and durable-outbox behavior, add regressions, run all repository gates, and push the bounded fix on `main`.
## Intent And Constraints
- Keep roster routes Program-only and retain attachment correlation, validation, task state, idempotency, privacy, and ERP execution behavior.
- The initial `awaiting_attachment` message is a nonterminal input request and must not later claim the ERP final result; the attachment message that resumes parsing owns that result.
- Internal final-reply ownership metadata must remain server-only and never appear in an outbound AgentBus payload.
- Do not deploy, restart services, mutate Kubernetes, access ERP, read secrets, retry the live task, or send an external message.
## Outcome
- Confirmed the duplicate success was caused by durable finalization selecting every delivered accepted row for the same task. The original roster directive and the later attachment frame therefore each received the same terminal result.
- Added one shared AgentBus delivery contract for the generic acceptance, roster waiting prompt, roster attachment-received prompt, and a server-only final-reply-eligibility marker. The marker is removed before every outbound frame.
- Initial roster directives now persist the explicit waiting prompt and are not eligible to own the later ERP result. Attachment messages persist the attachment-specific prompt and remain eligible for the final result.
- Durable finalization now selects only the newest eligible accepted delivery for a task, preserving backward compatibility for older rows without the marker while preventing duplicate success results.
- Direct listeners now wait until ingestion identifies the reply phase: initial roster instructions return after the waiting prompt, normalized attachments alone wait for the terminal task, and duplicate attachments return their bounded duplicate notice without re-running parsing.
- A combined directive-plus-attachment frame records only the attachment-phase delivery instead of first persisting a misleading waiting prompt.
- Updated the active AgentBus reply contract and control-plane operator documentation. No parser, workbook validation, task state, ERP execution, deployment, or live channel state changed.
## Verification
- Focused AgentBus and roster-intake tests: 25/25 passed.
- `node --run check:repo`: 10/10 passed.
- `node --run check`: passed.
- `node --run test:control-plane`: 143/143 passed.
- `node --run test:legacy`: 256/256 passed.
- `node --run build`: passed.
- `check_project_docs.py`: passed.
- `check_doc_drift.py --task-id 20260831-fix-agentbus-roster-replies-4c8e1a72`: passed.
- `git diff --check`: passed.
## Follow-ups
- Deploy/restart and verify the three visible messages against a real WeChat roster flow only under separate authorization.
## Promotion Candidates
- Target canonical AgentBus/data-flow memory during a later Integration task.
- Proposal: roster AgentBus messaging is stage-specific; the waiting directive does not own the final ERP result, the attachment message does, and each task terminal result is emitted once to the newest eligible inbound frame.
- Evidence: `control-plane/src/agentbus-delivery.ts`, `control-plane/src/task-service.ts`, `control-plane/src/agentbus.ts`, their focused 25/25 regression, and the user-supplied duplicate-success screenshot.
- Future impact: future multi-frame task workflows should explicitly identify which inbound frame owns the terminal result instead of treating every accepted frame as finalizable.
- Semantic conflicts: none; this implements the existing requirement that the first roster message return an attachment request and makes the one-result user experience explicit.
- Human confirmation required: no for this bounded reply correction; deployment and live retry remain separately authorized.

View File

@@ -4,10 +4,11 @@
## 用户侧消息 ## 用户侧消息
用户侧只收到两类消息: 用户侧只收到必要的阶段消息:
1. **受理通知**`已受理,正在处理。` 1. **普通任务受理通知**`已受理,正在处理。`
2. **最终返回通知**:成功返回业务回执;失败返回 ERP 业务反馈或简短错误摘要 2. **名单附件阶段通知**:首次文字指令进入等待时,直接提示需要 `.xls/.xlsx` 名单附件;附件到达后提示`名单附件已收到,正在校验并处理。`,不再重复普通受理文案
3. **最终返回通知**:成功返回业务回执;失败返回 ERP 业务反馈或简短错误摘要。同一任务的同一次最终结果只发送一次。
解析完成、进入全自动化 ERP 执行、页面打开、提交中、写后回查等状态只进入平台生命周期和审计,不发送为用户进度。 解析完成、进入全自动化 ERP 执行、页面打开、提交中、写后回查等状态只进入平台生命周期和审计,不发送为用户进度。
@@ -15,6 +16,8 @@
两项名单业务固定走 Program Parser。首次文字指令到达但没有名单文件时任务进入 `awaiting_attachment`,并返回一条要求发送 `.xls/.xlsx` 的重要消息;该状态不是最终失败,也不会触发 ERP。后续可以在同一 `conversation_id`,或明确携带 `payload.task_id`,发送一个附件而不带正文。 两项名单业务固定走 Program Parser。首次文字指令到达但没有名单文件时任务进入 `awaiting_attachment`,并返回一条要求发送 `.xls/.xlsx` 的重要消息;该状态不是最终失败,也不会触发 ERP。后续可以在同一 `conversation_id`,或明确携带 `payload.task_id`,发送一个附件而不带正文。
首次等待附件的入站消息只拥有等待提示,不拥有后续 ERP 最终回执。通过校验并触发解析的附件消息才是该次任务最终回执的归属消息;若历史或重放场景存在多条可归属消息,耐久回执只选择最新一条。因此“文字指令 + 附件”不会在任务完成后各返回一次相同成功信息。
名单入站 `payload.attachments` 最多一个,使用 `name``content_type``size`、可选 `sha256` 和不含凭据的 HTTPS `url`。当前服务运行在受信内网,控制面允许该 URL 直接使用内网域名、私网 IPv4/IPv6 或 localhost仍拒绝 HTTP、URL 用户名密码、无法解析的地址、超过大小限制、大小或 SHA-256 不一致以及超过两次的重定向。每次 DNS 解析后固定到选定地址发起 HTTPS 请求,重定向目标重新执行同一 URL 与 DNS 流程。附件 URL 和原始字节不写入任务 JSONB 或日志;允许内网目标意味着 AgentBus 渠道及其上游桥接器必须是受信输入源。 名单入站 `payload.attachments` 最多一个,使用 `name``content_type``size`、可选 `sha256` 和不含凭据的 HTTPS `url`。当前服务运行在受信内网,控制面允许该 URL 直接使用内网域名、私网 IPv4/IPv6 或 localhost仍拒绝 HTTP、URL 用户名密码、无法解析的地址、超过大小限制、大小或 SHA-256 不一致以及超过两次的重定向。每次 DNS 解析后固定到选定地址发起 HTTPS 请求,重定向目标重新执行同一 URL 与 DNS 流程。附件 URL 和原始字节不写入任务 JSONB 或日志;允许内网目标意味着 AgentBus 渠道及其上游桥接器必须是受信输入源。
微信传输信封的 `Conversation:` 在帧未显式提供 `conversation_id` 时作为会话键,显式字段优先。`[WeChat attachment: 文件名]` 只是桥接器的文字占位符:没有同时提供 `payload.attachments[]` 时,控制面不得把它作为新业务消息创建任务,也不得假装已收到文件;应保留原任务的 `awaiting_attachment` 状态并提示附件内容尚未传到平台。附件元数据或安全下载校验失败时只返回预定义的安全摘要,不回显 URL、文件内容或名单值。 微信传输信封的 `Conversation:` 在帧未显式提供 `conversation_id` 时作为会话键,显式字段优先。`[WeChat attachment: 文件名]` 只是桥接器的文字占位符:没有同时提供 `payload.attachments[]` 时,控制面不得把它作为新业务消息创建任务,也不得假装已收到文件;应保留原任务的 `awaiting_attachment` 状态并提示附件内容尚未传到平台。附件元数据或安全下载校验失败时只返回预定义的安全摘要,不回显 URL、文件内容或名单值。

View File

@@ -93,7 +93,7 @@ Auto 一旦发生 AI fallback任务会永久绑定原 AI 会话。每次解
将 onboarding 文档中的 WebSocket 地址、WebSocket Token、Bot Address 和 Worker Address 写入服务端受保护的环境文件。配置 `AGENTBUS_ENABLED=auto` 时,只要填写 AgentBus 连接字段,控制平面就会自动启动长期监听;保持这些字段为空则不启动监听。 将 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` 只属于传输路由,不会再污染业务字段签名。 对微信来源listener 在调用 `TaskService.ingestMessage()` 前执行上述严格信封解包,因此手工正文与 AgentBus 正文进入同一个业务 route resolver、任务级 mode snapshot 和 parser orchestrator`Conversation` 只属于传输路由,不会再污染业务字段签名。

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;
}

View File

@@ -19,6 +19,11 @@ import {
parseAgentBusInputAttachment parseAgentBusInputAttachment
} from './input-attachment.js'; } from './input-attachment.js';
import { diagnosticDurationMs, diagnosticError } from './diagnostics.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 OPEN_READY_STATE = 1;
const MAX_COMPLETED_TASK_IDS = 2_048; const MAX_COMPLETED_TASK_IDS = 2_048;
@@ -487,7 +492,7 @@ function createDurableDeliveryFrame(
session: AgentBusSession, session: AgentBusSession,
directAttachments: AgentBusAttachmentFrame[] = [] directAttachments: AgentBusAttachmentFrame[] = []
): AgentBusFrame { ): AgentBusFrame {
const payload = { ...jsonObject(delivery.payload) }; const payload = publicAgentBusDeliveryPayload(jsonObject(delivery.payload));
const fallbackAttachments = payloadAttachmentReferences(payload) const fallbackAttachments = payloadAttachmentReferences(payload)
.map((reference) => attachmentMetadataFrame(reference)) .map((reference) => attachmentMetadataFrame(reference))
.filter((attachment): attachment is AgentBusAttachmentFrame => Boolean(attachment)); .filter((attachment): attachment is AgentBusAttachmentFrame => Boolean(attachment));
@@ -581,7 +586,7 @@ export function taskResultText(task: PublicTask): string {
return '资料不完整,请补充必要信息。'; return '资料不完整,请补充必要信息。';
} }
if (status === 'awaiting_confirmation') { if (status === 'awaiting_confirmation') {
return '已受理,正在处理。'; return AGENTBUS_GENERIC_ACCEPTED_TEXT;
} }
if (FAILED_TASK_STATUSES.has(status)) { if (FAILED_TASK_STATUSES.has(status)) {
const failure = task.failure?.failure_message || task.error_summary?.message || task.message; 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 = { const input: TaskMessageInput = {
message, message,
conversationId, conversationId,
taskId: frameTaskId(frame), taskId: frameTaskId(frame),
...(attachments.length ? { attachments } : {}), ...(attachments.length ? { attachments } : {}),
idempotencyKey: this.durableDeliveryEnabled() idempotencyKey: durableDelivery
? `agentbus:${this.channel?.id}:${taskId}` ? `agentbus:${this.channel?.id}:${taskId}`
: `agentbus:${taskId}`, : `agentbus:${taskId}`,
...(this.durableDeliveryEnabled() && this.channel ...(durableDelivery && this.channel
? { ? {
channelId: this.channel.id, channelId: this.channel.id,
agentBusRoute: { agentBusRoute: {
@@ -1001,7 +1007,6 @@ export class AgentBusListener {
} }
: {}) : {})
}; };
if (!this.durableDeliveryEnabled()) this.sendProgress(frame, '已受理,正在处理。');
const context: TaskContext = { const context: TaskContext = {
organizationId: this.organizationId, organizationId: this.organizationId,
userId: '', userId: '',
@@ -1019,6 +1024,36 @@ export class AgentBusListener {
attached: result.attached, attached: result.attached,
created: result.created created: result.created
}, 'AgentBus message ingested into task service'); }, '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' const rejectedAttachment = result.input_attachment?.status === 'rejected'
? result.input_attachment ? result.input_attachment
: null; : null;
@@ -1030,7 +1065,7 @@ export class AgentBusListener {
task_status: result.task.status, task_status: result.task.status,
error_code: rejectedAttachment.error_code || 'roster_workbook_processing_failed' error_code: rejectedAttachment.error_code || 'roster_workbook_processing_failed'
}, 'AgentBus roster attachment rejection reply requested'); }, '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, { await this.tasks.enqueueAgentBusResult.call(this.tasks, this.channel.id, taskId, {
event: 'task.result', event: 'task.result',
status: 'failed', status: 'failed',
@@ -1048,7 +1083,7 @@ export class AgentBusListener {
inbound_frame_id: taskId, inbound_frame_id: taskId,
task_id: result.task.task_id task_id: result.task.task_id
}, 'AgentBus requesting parse queue'); }, 'AgentBus requesting parse queue');
if (this.durableDeliveryEnabled()) { if (durableDelivery) {
await this.scheduleParseQueue().catch((error) => { await this.scheduleParseQueue().catch((error) => {
this.logger.warn({ this.logger.warn({
agentbus_event: 'parse_queue_request_failed', 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) { if (!this.socket || this.socket.readyState !== OPEN_READY_STATE || !this.session) {
this.logger.warn({ this.logger.warn({
agentbus_event: 'progress_skipped', agentbus_event: 'progress_skipped',
@@ -1321,6 +1360,7 @@ export class AgentBusListener {
return; return;
} }
this.logFrame('outbound_progress_sent', frame, 'AgentBus progress frame sent'); this.logFrame('outbound_progress_sent', frame, 'AgentBus progress frame sent');
if (completeInbound) this.rememberCompleted(text(inbound.id));
}); });
} catch (error) { } catch (error) {
this.logger.warn({ this.logger.warn({

View File

@@ -43,6 +43,12 @@ import {
PASSENGER_ROSTER_WORKBOOK_VERSION, PASSENGER_ROSTER_WORKBOOK_VERSION,
type NormalizedPassengerRosterWorkbook type NormalizedPassengerRosterWorkbook
} from './passenger-roster-workbook.js'; } 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 { export interface TaskEvent {
id: number; id: number;
@@ -1056,7 +1062,7 @@ export function initialTaskIntakeState(routeId: BusinessRouteId | null): {
? { ? {
status: 'awaiting_attachment', status: 'awaiting_attachment',
stage: 'intake', stage: 'intake',
message: '已识别名单业务,等待 .xls 或 .xlsx 名单附件;附件到齐前不会开始解析或执行。' message: AGENTBUS_ROSTER_WAITING_TEXT
} }
: { status: 'parse_queued', stage: 'parse', message: '任务已入队,等待解析。' }; : { status: 'parse_queued', stage: 'parse', message: '任务已入队,等待解析。' };
} }
@@ -2107,7 +2113,8 @@ export class TaskService {
channelId: string | undefined, channelId: string | undefined,
taskRowId: string, taskRowId: string,
route: TaskMessageInput['agentBusRoute'] | undefined, route: TaskMessageInput['agentBusRoute'] | undefined,
conversationId: string | null conversationId: string | null,
options: AgentBusAcceptedDeliveryOptions = {}
): Promise<void> { ): Promise<void> {
if (!channelId || !route?.inboundFrameId || !route.inboundFrom) return; if (!channelId || !route?.inboundFrameId || !route.inboundFrom) return;
await client.query( await client.query(
@@ -2123,7 +2130,7 @@ export class TaskService {
route.inboundFrameId, route.inboundFrameId,
route.inboundFrom, route.inboundFrom,
route.conversationId || conversationId, route.conversationId || conversationId,
{ event: 'task.progress', status: 'accepted', text: '已受理,正在处理。' } createAgentBusAcceptedDeliveryPayload(options)
] ]
); );
} }
@@ -2910,7 +2917,11 @@ export class TaskService {
target.channelId || undefined, target.channelId || undefined,
target.rowId, target.rowId,
selection.agentBusRoute, selection.agentBusRoute,
target.conversationId target.conversationId,
{
text: '该名单附件已经接收,无需重复发送。',
finalReplyEligible: false
}
); );
return { return {
row: taskRow, row: taskRow,
@@ -3095,7 +3106,11 @@ export class TaskService {
target.channelId || undefined, target.channelId || undefined,
target.rowId, target.rowId,
selection.agentBusRoute, selection.agentBusRoute,
target.conversationId target.conversationId,
{
text: AGENTBUS_ROSTER_ATTACHMENT_RECEIVED_TEXT,
finalReplyEligible: true
}
); );
return { row: updatedRow, event, inputAttachment }; return { row: updatedRow, event, inputAttachment };
}); });
@@ -3229,11 +3244,16 @@ export class TaskService {
if (!isPassengerRosterRouteId(routeId)) { if (!isPassengerRosterRouteId(routeId)) {
throw new TaskError('attachment_not_supported', '只有补充录入名单业务可以接收 Excel 名单附件。', 409); 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, { return this.attachPassengerRosterAttachment(context, attachment, {
taskId: baseResult.task.task_id, taskId: baseResult.task.task_id,
conversationId, conversationId,
channelId, channelId,
agentBusRoute: input.agentBusRoute,
created: baseResult.created created: baseResult.created
}); });
} }
@@ -3414,7 +3434,13 @@ export class TaskService {
channelId, channelId,
text(taskRow.id), text(taskRow.id),
input.agentBusRoute, input.agentBusRoute,
conversationId conversationId,
intake.status === 'awaiting_attachment'
? {
text: intake.message,
finalReplyEligible: false
}
: undefined
); );
const event = await this.emitEvent(client, taskRow, { const event = await this.emitEvent(client, taskRow, {
status: intake.status, status: intake.status,
@@ -3554,10 +3580,23 @@ export class TaskService {
WHERE d.channel_id = $1 WHERE d.channel_id = $1
AND d.delivery_kind = 'accepted' AND d.delivery_kind = 'accepted'
AND d.delivery_status = 'delivered' AND d.delivery_status = 'delivered'
AND (d.payload ->> '_final_reply_eligible') IS DISTINCT FROM 'false'
AND t.status NOT IN ( AND t.status NOT IN (
'awaiting_attachment', 'parse_queued', 'parse_running', 'awaiting_confirmation', 'awaiting_attachment', 'parse_queued', 'parse_running', 'awaiting_confirmation',
'confirmed', 'queued', 'accepted', 'running' '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 ( AND NOT EXISTS (
SELECT 1 FROM agentbus_deliveries result_delivery SELECT 1 FROM agentbus_deliveries result_delivery
WHERE result_delivery.channel_id = d.channel_id WHERE result_delivery.channel_id = d.channel_id

View File

@@ -21,6 +21,14 @@ import {
mergeRuntimeChannelStatuses, mergeRuntimeChannelStatuses,
type PublicAgentBusChannel type PublicAgentBusChannel
} from '../src/agentbus-channels.js'; } 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 { resolveBusinessRoute } from '../src/business-routes.js';
import type { AgentBusDelivery, PublicTask } from '../src/task-service.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); 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', () => { test('AgentBus auto configuration rejects partial credentials', () => {
assert.throws(() => loadConfig({ assert.throws(() => loadConfig({
NODE_ENV: 'test', NODE_ENV: 'test',
@@ -839,6 +867,10 @@ test('attachment validation rejection returns an immediate failed result without
await waitFor(() => socket.sent.length === 2); await waitFor(() => socket.sent.length === 2);
assert.equal(parseQueueCalls, 0); 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>).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>).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>).status, 'failed');
assert.equal((socket.sent[1].payload as Record<string, unknown>).text, rejectionMessage); 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'); 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', () => { test('listener reload stop does not overwrite the channel status as disabled', () => {
const statuses: string[] = []; const statuses: string[] = [];
const listener = new AgentBusListener({ 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', inbound_from: 'channel:external:user-a',
conversation_id: 'conversation-durable-1', conversation_id: 'conversation-durable-1',
delivery_kind: 'accepted', delivery_kind: 'accepted',
payload: { event: 'task.progress', status: 'accepted', text: '已受理,正在处理。' }, payload: createAgentBusAcceptedDeliveryPayload(),
attempt_count: 1 attempt_count: 1
}; };
const result: AgentBusDelivery = { const result: AgentBusDelivery = {
@@ -1117,6 +1266,203 @@ test('durable channel listener persists route and resends accepted/result delive
listener.stop(); 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) => { test('durable channel persists and sends an attachment rejection result while the task keeps waiting', async (t) => {
const config = testConfig(); const config = testConfig();
const socket = new FakeSocket(); 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', inbound_from: 'channel:wechat:user-1',
conversation_id: 'conversation-attachment-rejected', conversation_id: 'conversation-attachment-rejected',
delivery_kind: 'accepted', delivery_kind: 'accepted',
payload: { event: 'task.progress', status: 'accepted', text: '已受理,正在处理。' }, payload: createAgentBusAcceptedDeliveryPayload({
text: AGENTBUS_ROSTER_ATTACHMENT_RECEIVED_TEXT,
finalReplyEligible: true
}),
attempt_count: 1 attempt_count: 1
}; };
const result: AgentBusDelivery = { 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.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>).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>).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>).status, 'failed');
assert.equal((socket.sent[1].payload as Record<string, unknown>).text, rejectionMessage); assert.equal((socket.sent[1].payload as Record<string, unknown>).text, rejectionMessage);

View File

@@ -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); 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 () => { test('manual API accepts exactly one bounded inline roster attachment', async () => {
const source = await readFile(new URL('../src/server.ts', import.meta.url), 'utf8'); const source = await readFile(new URL('../src/server.ts', import.meta.url), 'utf8');
assert.match(source, /attachments: z\.array\(encodedInputAttachmentSchema\)\.max\(1\)/); assert.match(source, /attachments: z\.array\(encodedInputAttachmentSchema\)\.max\(1\)/);