feat: add leader AgentBus task summaries
This commit is contained in:
1 parent
f4664997a8
commit
1a3ab63700
18 files changed
+2158
-19
No files matched your search
@@ -453,6 +453,6 @@ test('account authorization editor uses a scroll-safe open layout without overri
|
||||
assert.match(openLayoutSource, /overflow:\s*visible/);
|
||||
assert.doesNotMatch(styles, /\.account-panel\s*\{\s*grid-template-rows:/);
|
||||
|
||||
assert.match(index, /styles\.css\?v=20260902-account-routing-hard-delete-2/);
|
||||
assert.match(index, /app\.js\?v=20260902-account-routing-hard-delete-2/);
|
||||
assert.match(index, /styles\.css\?v=20260907-leader-summary-1/);
|
||||
assert.match(index, /app\.js\?v=20260907-leader-summary-1/);
|
||||
});
|
||||
@@ -6,6 +6,8 @@ import {
|
||||
AgentBusListener,
|
||||
type AgentBusSocket,
|
||||
type AgentBusTaskGateway,
|
||||
type LeaderNotificationGateway,
|
||||
createLeaderTaskSummaryFrame,
|
||||
createTaskResultFrame,
|
||||
createTaskProgressFrame,
|
||||
extractAgentBusBusinessText,
|
||||
@@ -31,6 +33,7 @@ import {
|
||||
} from '../src/agentbus-delivery.js';
|
||||
import { resolveBusinessRoute } from '../src/business-routes.js';
|
||||
import type { AgentBusDelivery, PublicTask } from '../src/task-service.js';
|
||||
import type { LeaderTaskSummaryDelivery } from '../src/leader-notification-service.js';
|
||||
|
||||
function makeTask(status: string, overrides: Partial<PublicTask> = {}): PublicTask {
|
||||
return {
|
||||
@@ -410,6 +413,142 @@ test('AgentBus protocol helpers preserve reply routing fields', () => {
|
||||
assert.equal(fallbackProgress.conversation_id, 'agentbus:channel:wechat:user-2');
|
||||
});
|
||||
|
||||
test('leader summary frame uses explicit proactive routing and cannot become an inbound task', () => {
|
||||
const delivery: LeaderTaskSummaryDelivery = {
|
||||
id: '11111111-1111-4111-8111-111111111111',
|
||||
channel_id: '22222222-2222-4222-8222-222222222222',
|
||||
task_id: 'TASK-20260907-001',
|
||||
recipient_address: 'channel:wechat:leader-a',
|
||||
recipient_fingerprint: 'abc123def456',
|
||||
conversation_id: 'wechat-conversation-a',
|
||||
conversation_fingerprint: 'def456abc123',
|
||||
payload: {
|
||||
event: 'task.summary',
|
||||
status: 'completed',
|
||||
task_id: 'TASK-20260907-001',
|
||||
text: '【员工任务摘要】\n员工:employee-a'
|
||||
},
|
||||
attempt_count: 1
|
||||
};
|
||||
const frame = createLeaderTaskSummaryFrame(delivery, {
|
||||
id: 'session-leader-1',
|
||||
epoch: 9,
|
||||
address: 'bot:leader-a:listener'
|
||||
});
|
||||
assert.equal(frame.id, `leader-summary-${delivery.id}`);
|
||||
assert.equal(frame.from, 'bot:leader-a:listener');
|
||||
assert.equal(frame.to, delivery.recipient_address);
|
||||
assert.equal(frame.conversation_id, delivery.conversation_id);
|
||||
assert.equal(Object.hasOwn(frame, 'reply_to'), false);
|
||||
assert.deepEqual(frame.payload, delivery.payload);
|
||||
assert.equal(isInboundAgentBusTask(frame), false);
|
||||
});
|
||||
|
||||
test('listener sends employee outbox first, then a redacted-log leader summary batch', async (t) => {
|
||||
const socket = new FakeSocket();
|
||||
const order: string[] = [];
|
||||
const delivered: string[] = [];
|
||||
const released: string[] = [];
|
||||
const logs: Array<Record<string, unknown>> = [];
|
||||
const delivery: LeaderTaskSummaryDelivery = {
|
||||
id: '33333333-3333-4333-8333-333333333333',
|
||||
channel_id: 'channel-leader-a',
|
||||
task_id: 'TASK-PRIVATE-1',
|
||||
recipient_address: 'channel:wechat:private-leader-address',
|
||||
recipient_fingerprint: 'a1b2c3d4e5f6',
|
||||
conversation_id: 'private-wechat-conversation',
|
||||
conversation_fingerprint: 'f6e5d4c3b2a1',
|
||||
payload: {
|
||||
event: 'task.summary',
|
||||
status: 'completed',
|
||||
task_id: 'TASK-PRIVATE-1',
|
||||
text: '【员工任务摘要】\n员工:private-employee'
|
||||
},
|
||||
attempt_count: 1
|
||||
};
|
||||
let claimed = false;
|
||||
const tasks: AgentBusTaskGateway = {
|
||||
events: new EventEmitter(),
|
||||
async ingestMessage() {
|
||||
return { task: makeTask('failed'), attached: false, created: true };
|
||||
},
|
||||
async getTask() {
|
||||
return makeTask('failed');
|
||||
},
|
||||
async listAgentBusFinalizationCandidates() {
|
||||
return [];
|
||||
},
|
||||
async enqueueAgentBusResult() {},
|
||||
async claimAgentBusDeliveries() {
|
||||
order.push('employee');
|
||||
return [];
|
||||
},
|
||||
async markAgentBusDeliveryDelivered() {},
|
||||
async markAgentBusDeliveryFailed() {},
|
||||
async releaseAgentBusDeliveries() {}
|
||||
};
|
||||
const leaderNotifications: LeaderNotificationGateway = {
|
||||
async claimDeliveries() {
|
||||
order.push('leader');
|
||||
if (claimed) return [];
|
||||
claimed = true;
|
||||
return [delivery];
|
||||
},
|
||||
async markDeliveryDelivered(deliveryId) {
|
||||
delivered.push(deliveryId);
|
||||
},
|
||||
async markDeliveryFailed() {},
|
||||
async releaseDeliveries(channelId) {
|
||||
released.push(channelId);
|
||||
}
|
||||
};
|
||||
const listener = new AgentBusListener({
|
||||
config: testConfig(),
|
||||
tasks,
|
||||
leaderNotifications,
|
||||
organizationId: 'org-1',
|
||||
scheduleParseQueue: async () => {},
|
||||
socketFactory: () => socket as unknown as AgentBusSocket,
|
||||
channel: {
|
||||
id: 'channel-leader-a',
|
||||
displayName: '组长 A',
|
||||
wsUrl: 'wss://mesh.nianxx.cn/ws',
|
||||
wsToken: 'leader-channel-token',
|
||||
botAddress: 'bot:leader-a:listener',
|
||||
ownerUserId: 'leader-a',
|
||||
ownerRole: 'team_lead'
|
||||
},
|
||||
logger: {
|
||||
info(metadata) { logs.push(metadata); },
|
||||
warn(metadata) { logs.push(metadata); },
|
||||
error(metadata) { logs.push(metadata); }
|
||||
}
|
||||
});
|
||||
t.after(() => listener.stop());
|
||||
listener.start();
|
||||
socket.readyState = 1;
|
||||
socket.emit('open');
|
||||
socket.emit('message', JSON.stringify({
|
||||
id: 'ready-leader-summary',
|
||||
type: 'event',
|
||||
session_id: 'session-leader-summary',
|
||||
epoch: 1,
|
||||
to: 'bot:leader-a:listener',
|
||||
payload: { event: 'session.ready' }
|
||||
}));
|
||||
|
||||
await waitFor(() => socket.sent.length === 1 && delivered.length === 1);
|
||||
assert.deepEqual(order.slice(0, 2), ['employee', 'leader']);
|
||||
assert.equal(delivered[0], delivery.id);
|
||||
assert.equal(socket.sent[0].reply_to, undefined);
|
||||
assert.equal((socket.sent[0].payload as Record<string, unknown>).event, 'task.summary');
|
||||
const logText = JSON.stringify(logs);
|
||||
assert.doesNotMatch(logText, /private-leader-address|private-wechat-conversation|private-employee/);
|
||||
assert.match(logText, /a1b2c3d4e5f6/);
|
||||
listener.stop();
|
||||
await waitFor(() => released.includes('channel-leader-a'));
|
||||
});
|
||||
|
||||
test('AgentBus result text uses the unified important message and preserves the confirmation gate', () => {
|
||||
const needsInput = makeTask('awaiting_user_input', {
|
||||
input_request: {
|
||||
|
||||
@@ -317,7 +317,7 @@ test('control plane requires the latest durable task-outcome migration before re
|
||||
const { readFile } = await import('node:fs/promises');
|
||||
const db = await readFile(new URL('../src/db.ts', import.meta.url), 'utf8');
|
||||
const server = await readFile(new URL('../src/server.ts', import.meta.url), 'utf8');
|
||||
assert.equal(REQUIRED_SCHEMA_VERSION, '018_agentbus_account_workers');
|
||||
assert.equal(REQUIRED_SCHEMA_VERSION, '020_leader_task_summary_notifications');
|
||||
assert.match(db, /schema_migrations/);
|
||||
assert.match(db, /databaseReadiness/);
|
||||
assert.match(db, /assertDatabaseSchema/);
|
||||
@@ -1231,8 +1231,8 @@ test('operator page has a login gate and uses the durable task API', async () =>
|
||||
const inpage = await readFile(new URL('../../chrome-extension/ltjt-order-assistant/inpage.js', import.meta.url), 'utf8');
|
||||
assert.match(index, /id="loginPanel"/);
|
||||
assert.match(index, /id="workbench"[^>]*hidden/);
|
||||
assert.match(index, /styles\.css\?v=20260902-account-routing-hard-delete-2/);
|
||||
assert.match(index, /app\.js\?v=20260902-account-routing-hard-delete-2/);
|
||||
assert.match(index, /styles\.css\?v=20260907-leader-summary-1/);
|
||||
assert.match(index, /app\.js\?v=20260907-leader-summary-1/);
|
||||
assert.match(index, /id="statusDetailsPopover"/);
|
||||
assert.match(index, /id="statusDetailsRefresh"/);
|
||||
assert.match(app, /apiRequest\(`\/api\/tasks\?\$\{params\.toString\(\)\}`/);
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import test from 'node:test';
|
||||
|
||||
async function source(relativePath: string): Promise<string> {
|
||||
return readFile(new URL(relativePath, import.meta.url), 'utf8');
|
||||
}
|
||||
|
||||
test('migration creates a default-off encrypted outbox separate from employee replies', async () => {
|
||||
const sql = await source('../migrations/020_leader_task_summary_notifications.sql');
|
||||
assert.match(sql, /CREATE TABLE IF NOT EXISTS leader_task_summary_subscriptions/);
|
||||
assert.match(sql, /enabled boolean NOT NULL DEFAULT false/);
|
||||
assert.match(sql, /scope text NOT NULL DEFAULT 'organization'/);
|
||||
assert.match(sql, /CHECK \(include_manual OR include_agentbus\)/);
|
||||
assert.match(sql, /recipient_address_ciphertext text NOT NULL/);
|
||||
assert.match(sql, /conversation_id_ciphertext text NOT NULL/);
|
||||
assert.match(sql, /payload_ciphertext text NOT NULL/);
|
||||
assert.match(sql, /target_verified_at timestamptz/);
|
||||
assert.match(sql, /UNIQUE \(organization_id, leader_user_id\)/);
|
||||
assert.match(sql, /UNIQUE \(subscription_id, subscription_revision, task_id, milestone\)/);
|
||||
assert.doesNotMatch(sql, /INSERT\s+INTO/iu, 'schema migration must not backfill or send historical tasks');
|
||||
assert.doesNotMatch(sql, /REFERENCES\s+agentbus_deliveries/iu);
|
||||
assert.doesNotMatch(sql, /recipient_address\s+text/iu);
|
||||
assert.doesNotMatch(sql, /conversation_id\s+text/iu);
|
||||
assert.doesNotMatch(sql, /payload\s+jsonb/iu);
|
||||
});
|
||||
|
||||
test('projection is organization-scoped, future-only, role-safe and source-selective', async () => {
|
||||
const service = await source('../src/leader-notification-service.ts');
|
||||
assert.match(service, /event\.topic = 'task\.updated'/);
|
||||
assert.match(service, /event\.created_at >= subscription\.starts_at/);
|
||||
assert.match(service, /task\.assigned_user_id <> subscription\.leader_user_id/);
|
||||
assert.match(service, /assignee\.role <> 'admin'/);
|
||||
assert.match(service, /task\.source IN \('manual', 'agentbus'\)/);
|
||||
assert.match(service, /subscription\.include_manual/);
|
||||
assert.match(service, /subscription\.include_agentbus/);
|
||||
assert.match(service, /event\.payload ->> 'archived'.*IS DISTINCT FROM 'true'/s);
|
||||
assert.match(service, /event\.payload ->> 'restored'.*IS DISTINCT FROM 'true'/s);
|
||||
assert.match(service, /subscription\.target_verified_at IS NOT NULL/);
|
||||
assert.match(service, /pg_try_advisory_xact_lock/);
|
||||
assert.match(service, /encryptText\(this\.config, payloadText\)/);
|
||||
assert.match(service, /FOR UPDATE OF delivery SKIP LOCKED/);
|
||||
assert.match(service, /sha256Text\(payloadText\).*payload_fingerprint/s);
|
||||
assert.match(service, /sha256Text\(recipientAddress\).*recipient_address_fingerprint/s);
|
||||
assert.match(service, /sha256Text\(conversationId\).*conversation_id_fingerprint/s);
|
||||
assert.match(service, /delivery_status = 'cancelled'.*订阅设置已变化/s);
|
||||
assert.match(service, /leader_summary_agentbus_disabled/);
|
||||
assert.doesNotMatch(service, /reply_to/);
|
||||
});
|
||||
|
||||
test('HTTP configuration is administrator-only and has no live test-send endpoint', async () => {
|
||||
const server = await source('../src/server.ts');
|
||||
const routeStart = server.indexOf("app.get('/api/settings/leader-summary-subscriptions'");
|
||||
const routeEnd = server.indexOf("app.post('/api/auth/logout'", routeStart);
|
||||
const routes = server.slice(routeStart, routeEnd);
|
||||
assert.ok(routeStart > 0 && routeEnd > routeStart);
|
||||
assert.match(routes, /requireAdminSession/);
|
||||
assert.match(routes, /requireAdminMutationSession/);
|
||||
assert.match(routes, /expectedRevision: body\.expected_revision/);
|
||||
assert.doesNotMatch(server, /leader-summary-subscriptions.*test-send|leader-summary-subscriptions.*test\/send/s);
|
||||
});
|
||||
|
||||
test('AgentBus sends summaries as reserved low-priority proactive events without plaintext frame logging', async () => {
|
||||
const agentbus = await source('../src/agentbus.ts');
|
||||
const ignoreStart = agentbus.indexOf('function inboundFrameIgnoreReason');
|
||||
const ignoreEnd = agentbus.indexOf('export function parseAgentBusFrame', ignoreStart);
|
||||
assert.match(agentbus.slice(ignoreStart, ignoreEnd), /'task\.summary'/);
|
||||
assert.match(agentbus.slice(ignoreStart, ignoreEnd), /reserved_event/);
|
||||
assert.match(agentbus, /id: `leader-summary-\$\{delivery\.id\}`/);
|
||||
assert.match(agentbus, /to: delivery\.recipient_address/);
|
||||
assert.match(agentbus, /conversation_id: delivery\.conversation_id/);
|
||||
const frameStart = agentbus.indexOf('export function createLeaderTaskSummaryFrame');
|
||||
const frameEnd = agentbus.indexOf('export function taskResultStatus', frameStart);
|
||||
assert.doesNotMatch(agentbus.slice(frameStart, frameEnd), /reply_to/);
|
||||
const flushStart = agentbus.indexOf('private async flushOutboundDeliveries');
|
||||
const sendEnd = agentbus.indexOf('private async sendDurableDelivery', flushStart);
|
||||
const leaderDelivery = agentbus.slice(flushStart, sendEnd);
|
||||
assert.match(leaderDelivery, /await this\.flushDurableDeliveries\(\)/);
|
||||
assert.match(leaderDelivery, /await this\.flushLeaderDeliveries\(\)/);
|
||||
assert.match(leaderDelivery, /recipient_fingerprint/);
|
||||
assert.match(leaderDelivery, /conversation_fingerprint/);
|
||||
assert.doesNotMatch(leaderDelivery, /this\.logFrame/);
|
||||
});
|
||||
|
||||
test('administrator UI explains safety boundaries and never asks the API to backfill', async () => {
|
||||
const html = await source('../../LianSyn-platform/index.html');
|
||||
const app = await source('../../LianSyn-platform/app.js');
|
||||
assert.match(html, /组长任务摘要抄送/);
|
||||
assert.match(html, /默认关闭/);
|
||||
assert.match(html, /只处理保存设置后的新结果,不补发历史任务/);
|
||||
assert.match(html, /已经到达微信的消息无法撤回/);
|
||||
assert.match(app, /目标已核对/);
|
||||
assert.match(app, /expected_revision/);
|
||||
assert.match(app, /消息正文不含原始指令、客户\/游客资料或技术错误/);
|
||||
assert.doesNotMatch(app, /leader-summary-subscriptions[^'"\n]*backfill/);
|
||||
});
|
||||
@@ -0,0 +1,75 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import {
|
||||
buildLeaderTaskSummary,
|
||||
formatShanghaiTimestamp,
|
||||
isLeaderTaskSummaryStatus
|
||||
} from '../src/leadership-task-summary.js';
|
||||
|
||||
const base = {
|
||||
taskId: 'TASK-20260907-001',
|
||||
businessRouteId: 'arrangement_hotel_create',
|
||||
assigneeUsername: 'employee-a',
|
||||
createdAt: '2026-09-07T00:05:06.000Z'
|
||||
};
|
||||
|
||||
test('leader summary projects only stable outcomes', () => {
|
||||
assert.equal(buildLeaderTaskSummary({ ...base, status: 'running' }), null);
|
||||
assert.equal(buildLeaderTaskSummary({ ...base, status: 'awaiting_confirmation' }), null);
|
||||
assert.equal(isLeaderTaskSummaryStatus('completed'), true);
|
||||
assert.equal(isLeaderTaskSummaryStatus('execution_uncertain'), true);
|
||||
assert.equal(isLeaderTaskSummaryStatus('running'), false);
|
||||
});
|
||||
|
||||
test('completed leader summary exposes only whitelisted business identifiers', () => {
|
||||
const result = buildLeaderTaskSummary({
|
||||
...base,
|
||||
status: 'completed',
|
||||
successReceipt: {
|
||||
group_numbers: ['LW-260907A-B', '张三', '13800138000'],
|
||||
order_number: 'D12345',
|
||||
customer_name: '绝密客户',
|
||||
passenger_names: ['游客甲', '游客乙'],
|
||||
phone: '13900139000',
|
||||
url: 'https://secret.example/token',
|
||||
technical_error: 'stack trace'
|
||||
}
|
||||
});
|
||||
assert.ok(result);
|
||||
assert.equal(result.milestone, 'final');
|
||||
assert.equal(result.deliveryStatus, 'completed');
|
||||
assert.match(result.messageText, /employee-a/);
|
||||
assert.match(result.messageText, /安排酒店/);
|
||||
assert.match(result.messageText, /LW-260907A-B/);
|
||||
assert.match(result.messageText, /D12345/);
|
||||
assert.match(result.messageText, /2026-09-07 08:05:06/);
|
||||
assert.doesNotMatch(result.messageText, /绝密客户|游客甲|13800138000|13900139000|secret\.example|stack trace|张三/);
|
||||
});
|
||||
|
||||
test('failure, uncertainty, cancellation and dry-run use stable safe wording', () => {
|
||||
const failed = buildLeaderTaskSummary({ ...base, status: 'operation_blocked' });
|
||||
const uncertain = buildLeaderTaskSummary({ ...base, status: 'saved_unverified' });
|
||||
const cancelled = buildLeaderTaskSummary({ ...base, status: 'cancelled' });
|
||||
const dryRun = buildLeaderTaskSummary({ ...base, status: 'dry_run' });
|
||||
assert.equal(failed?.resultText, '本次工作未完成,请在平台查看业务结果。');
|
||||
assert.equal(uncertain?.milestone, 'needs_review');
|
||||
assert.match(uncertain?.messageText || '', /需人工核验|请勿重复提交/);
|
||||
assert.equal(cancelled?.deliveryStatus, 'cancelled');
|
||||
assert.match(dryRun?.resultText || '', /尚未正式提交/);
|
||||
});
|
||||
|
||||
test('a stable result after needs-review produces one result-update milestone', () => {
|
||||
const result = buildLeaderTaskSummary({
|
||||
...base,
|
||||
status: 'completed',
|
||||
hadNeedsReview: true,
|
||||
successReceipt: { group_number: 'LW-260907A-B' }
|
||||
});
|
||||
assert.equal(result?.milestone, 'resolved');
|
||||
assert.match(result?.messageText || '', /员工任务摘要·结果更新/);
|
||||
});
|
||||
|
||||
test('Shanghai timestamp formatting is deterministic and invalid-safe', () => {
|
||||
assert.equal(formatShanghaiTimestamp(base.createdAt), '2026-09-07 08:05:06');
|
||||
assert.equal(formatShanghaiTimestamp('not-a-date'), '时间未记录');
|
||||
});
|
||||
Reference in new issue
Block a user