97d0e8d970
When schedule_create_item includes a start time and reminder intent, create the matching h5_schedule_reminders row in the same tool call so 10:00 pushes are not lost. Also reconcile Goose SSE request ids with the portal agent-run gate to keep MindSpace chat streaming reliable. Co-authored-by: Cursor <cursoragent@cursor.com>
367 lines
11 KiB
JavaScript
367 lines
11 KiB
JavaScript
#!/usr/bin/env node
|
||
/**
|
||
* 多场景验证:日程事项 + 到点提醒创建链路
|
||
* - Agent MCP 自动补提醒
|
||
* - 服务号快捷路径不受影响
|
||
* - 提醒 worker 可读 pending 记录
|
||
*/
|
||
import assert from 'node:assert/strict';
|
||
import crypto from 'node:crypto';
|
||
import fs from 'node:fs';
|
||
import mysql from 'mysql2/promise';
|
||
import { handleWechatScheduleIntent } from '../wechat/handlers/schedule.mjs';
|
||
import { parseScheduleIntent, shouldUseScheduleAssistant } from '../schedule-intent.mjs';
|
||
import {
|
||
createScheduleService,
|
||
shouldAutoCreateReminderAtStart,
|
||
} from '../schedule-service.mjs';
|
||
import { resolveScheduleTimestamp } from '../schedule-time.mjs';
|
||
|
||
const tz = 'Asia/Shanghai';
|
||
let passed = 0;
|
||
let failed = 0;
|
||
|
||
function pass(label, detail = '') {
|
||
passed += 1;
|
||
console.log(`✔ ${label}${detail ? `: ${detail}` : ''}`);
|
||
}
|
||
|
||
function fail(label, detail = '') {
|
||
failed += 1;
|
||
console.error(`✘ ${label}${detail ? `: ${detail}` : ''}`);
|
||
}
|
||
|
||
function loadDatabaseUrl() {
|
||
if (process.env.DATABASE_URL) return process.env.DATABASE_URL;
|
||
const envPath = new URL('../.env', import.meta.url);
|
||
if (!fs.existsSync(envPath)) return null;
|
||
const envText = fs.readFileSync(envPath, 'utf8');
|
||
const dbLine = envText.match(/^DATABASE_URL=(.+)$/m)?.[1]?.trim().replace(/^["']|["']$/g, '');
|
||
return dbLine || null;
|
||
}
|
||
|
||
async function simulateCreateItemWithReminder({
|
||
scheduleService,
|
||
userId,
|
||
args,
|
||
}) {
|
||
const startAt = resolveScheduleTimestamp({
|
||
epochMs: args.startAt,
|
||
localString: args.startLocal,
|
||
timezone: tz,
|
||
fieldName: '开始时间',
|
||
});
|
||
const item = await scheduleService.createItem({
|
||
userId,
|
||
kind: args.kind ?? 'task',
|
||
title: args.title,
|
||
description: args.description ?? null,
|
||
startAt,
|
||
timezone: tz,
|
||
sourceChannel: 'agent',
|
||
metadata: { source: 'verify_schedule_reminder_create' },
|
||
});
|
||
|
||
let remindAt = resolveScheduleTimestamp({
|
||
epochMs: args.remindAt,
|
||
localString: args.remindLocal,
|
||
timezone: tz,
|
||
fieldName: '提醒时间',
|
||
});
|
||
if (
|
||
remindAt == null
|
||
&& shouldAutoCreateReminderAtStart({
|
||
title: args.title,
|
||
description: args.description,
|
||
startAt,
|
||
noReminder: Boolean(args.noReminder),
|
||
})
|
||
) {
|
||
remindAt = startAt;
|
||
}
|
||
|
||
let reminder = null;
|
||
if (remindAt != null) {
|
||
reminder = await scheduleService.createReminder({
|
||
userId,
|
||
itemId: item.id,
|
||
remindAt,
|
||
channel: args.channel ?? 'wechat',
|
||
});
|
||
}
|
||
return { item, reminder, startAt, remindAt };
|
||
}
|
||
|
||
async function cleanupTestRows(pool, userId, marker = 'verify_schedule_reminder_create') {
|
||
const [items] = await pool.query(
|
||
`SELECT id FROM h5_schedule_items
|
||
WHERE user_id = ? AND JSON_UNQUOTE(JSON_EXTRACT(metadata_json, '$.source')) = ?`,
|
||
[userId, marker],
|
||
);
|
||
const itemIds = items.map((row) => row.id);
|
||
if (itemIds.length) {
|
||
await pool.query(
|
||
`DELETE FROM h5_schedule_reminders WHERE item_id IN (${itemIds.map(() => '?').join(', ')})`,
|
||
itemIds,
|
||
);
|
||
await pool.query(
|
||
`DELETE FROM h5_schedule_items WHERE id IN (${itemIds.map(() => '?').join(', ')})`,
|
||
itemIds,
|
||
);
|
||
}
|
||
}
|
||
|
||
function testAutoRemindMatrix() {
|
||
const cases = [
|
||
{
|
||
label: '吃药提醒 + 10点 → 自动补提醒',
|
||
input: { title: '吃药提醒', description: '今天上午10点吃药', startAt: 1 },
|
||
expected: true,
|
||
},
|
||
{
|
||
label: '开会无提醒词 → 不自动补',
|
||
input: { title: '开会', description: '明天下午三点', startAt: 1 },
|
||
expected: false,
|
||
},
|
||
{
|
||
label: 'noReminder=true → 不自动补',
|
||
input: { title: '吃药提醒', description: '今天上午10点吃药', startAt: 1, noReminder: true },
|
||
expected: false,
|
||
},
|
||
{
|
||
label: '无 startAt → 不自动补',
|
||
input: { title: '吃药提醒', startAt: null },
|
||
expected: false,
|
||
},
|
||
];
|
||
for (const item of cases) {
|
||
const actual = shouldAutoCreateReminderAtStart(item.input);
|
||
if (actual === item.expected) pass(item.label);
|
||
else fail(item.label, `expected ${item.expected}, got ${actual}`);
|
||
}
|
||
}
|
||
|
||
async function testWechatHandlerIsolation() {
|
||
const calls = [];
|
||
const scheduleService = {
|
||
async createItem(payload) {
|
||
calls.push(['createItem', payload.title]);
|
||
return { id: 'item-1', ...payload };
|
||
},
|
||
async createDailyTodoDigest(payload) {
|
||
calls.push(['createDailyTodoDigest', payload.hour, payload.minute]);
|
||
return { id: 'digest-1', hour: payload.hour, minute: payload.minute };
|
||
},
|
||
async createBalanceLowAlert(payload) {
|
||
calls.push(['createBalanceLowAlert', payload.thresholdCents]);
|
||
return { id: 'balance-1', thresholdCents: payload.thresholdCents };
|
||
},
|
||
async buildTodoDigestText() {
|
||
return '今天有 1 条待办。';
|
||
},
|
||
};
|
||
|
||
const todoReply = await handleWechatScheduleIntent({
|
||
intent: { agentText: '帮我记一下 跟进合同', msgId: 'msg-todo' },
|
||
user: { userId: 'user-1' },
|
||
scheduleService,
|
||
});
|
||
if (todoReply?.includes('未设置提醒') && calls.length === 1 && calls[0][0] === 'createItem') {
|
||
pass('服务号快捷待办', '仍只 createItem,不创建到点提醒');
|
||
} else {
|
||
fail('服务号快捷待办', JSON.stringify({ todoReply, calls }));
|
||
}
|
||
|
||
const digestReply = await handleWechatScheduleIntent({
|
||
intent: { agentText: '每天早上7点把当天待办发给我', msgId: 'msg-digest' },
|
||
user: { userId: 'user-1' },
|
||
scheduleService,
|
||
});
|
||
if (digestReply?.includes('已设置') && calls.some((c) => c[0] === 'createDailyTodoDigest')) {
|
||
pass('服务号每日待办摘要', '仍走 digest 订阅,不受 MCP 改动影响');
|
||
} else {
|
||
fail('服务号每日待办摘要', JSON.stringify({ digestReply, calls }));
|
||
}
|
||
|
||
const agentReply = await handleWechatScheduleIntent({
|
||
intent: { agentText: '今天10点提醒我吃药', msgId: 'msg-agent' },
|
||
user: { userId: 'user-1' },
|
||
scheduleService,
|
||
});
|
||
if (agentReply === null) {
|
||
pass('服务号一次性提醒', '仍 fall through 给 Agent(schedule_agent)');
|
||
} else {
|
||
fail('服务号一次性提醒', `expected null, got ${agentReply}`);
|
||
}
|
||
|
||
const intent = parseScheduleIntent('今天10点提醒我吃药');
|
||
if (shouldUseScheduleAssistant('今天10点提醒我吃药')) {
|
||
pass('意图路由', `一次性提醒走 Agent 路径(parseScheduleIntent=${intent.action},由 prompt 加载 schedule-assistant)`);
|
||
} else {
|
||
fail('意图路由', JSON.stringify(intent));
|
||
}
|
||
}
|
||
|
||
async function testDatabaseScenarios(pool, userId) {
|
||
const scheduleService = createScheduleService(pool, { defaultTimezone: tz });
|
||
await cleanupTestRows(pool, userId);
|
||
|
||
const scenarios = [
|
||
{
|
||
label: 'Agent:吃药提醒仅 create_item → 自动补 reminder',
|
||
args: {
|
||
title: '吃药提醒',
|
||
description: '今天上午11点吃药',
|
||
startLocal: '2026-07-12 11:00',
|
||
},
|
||
expectReminder: true,
|
||
},
|
||
{
|
||
label: 'Agent:显式 remindLocal → 创建 reminder',
|
||
args: {
|
||
title: '喝水',
|
||
description: '今天下午3点喝水',
|
||
startLocal: '2026-07-12 15:00',
|
||
remindLocal: '2026-07-12 15:00',
|
||
},
|
||
expectReminder: true,
|
||
},
|
||
{
|
||
label: 'Agent:开会无提醒词 → 不自动补 reminder',
|
||
args: {
|
||
title: '开会',
|
||
description: '明天下午三点项目会',
|
||
startLocal: '2026-07-13 15:00',
|
||
},
|
||
expectReminder: false,
|
||
},
|
||
{
|
||
label: 'Agent:noReminder=true → 不创建 reminder',
|
||
args: {
|
||
title: '吃药提醒',
|
||
description: '先记一下',
|
||
startLocal: '2026-07-12 12:00',
|
||
noReminder: true,
|
||
},
|
||
expectReminder: false,
|
||
},
|
||
];
|
||
|
||
for (const scenario of scenarios) {
|
||
const result = await simulateCreateItemWithReminder({
|
||
scheduleService,
|
||
userId,
|
||
args: scenario.args,
|
||
});
|
||
const hasReminder = Boolean(result.reminder);
|
||
if (hasReminder === scenario.expectReminder) {
|
||
pass(
|
||
scenario.label,
|
||
hasReminder
|
||
? `reminder ${new Date(result.remindAt).toLocaleString('zh-CN', { timeZone: tz })}`
|
||
: '仅事项',
|
||
);
|
||
} else {
|
||
fail(scenario.label, `expectReminder=${scenario.expectReminder}, actual=${hasReminder}`);
|
||
}
|
||
}
|
||
|
||
const due = await scheduleService.listDueReminders({
|
||
now: resolveScheduleTimestamp({
|
||
localString: '2026-07-12 11:05',
|
||
timezone: tz,
|
||
fieldName: '扫描时间',
|
||
}),
|
||
limit: 20,
|
||
});
|
||
const testDue = due.filter((row) => row.userId === userId);
|
||
if (testDue.length >= 1) {
|
||
pass('提醒 worker 扫描', `listDueReminders 可读 ${testDue.length} 条测试提醒`);
|
||
} else {
|
||
fail('提醒 worker 扫描', '未读到 pending 测试提醒');
|
||
}
|
||
|
||
await cleanupTestRows(pool, userId);
|
||
}
|
||
|
||
async function testLiveAgentIfRequested() {
|
||
if (!['1', 'true', 'yes', 'on'].includes(String(process.env.VERIFY_SCHEDULE_LIVE_AGENT ?? '').toLowerCase())) {
|
||
pass('Live Agent(跳过)', '设置 VERIFY_SCHEDULE_LIVE_AGENT=1 可启用');
|
||
return;
|
||
}
|
||
|
||
const baseUrl = process.env.H5_PORT ? `http://127.0.0.1:${process.env.H5_PORT}` : 'http://127.0.0.1:8081';
|
||
const { loginViaApi, createAgentRun, waitForRunTerminal, createReporter } = await import('./scenario-test-lib.mjs');
|
||
const reporter = createReporter();
|
||
let auth;
|
||
try {
|
||
auth = await loginViaApi(
|
||
baseUrl,
|
||
{ username: 'john8', password: process.env.JOHN8_PASSWORD ?? '888888' },
|
||
reporter,
|
||
);
|
||
} catch (err) {
|
||
pass('Live Agent john8(跳过)', `登录失败:${err.message}`);
|
||
return;
|
||
}
|
||
const message = '今天11点30分提醒我吃药';
|
||
const run = await createAgentRun(baseUrl, auth.cookie, { message });
|
||
const finalRun = await waitForRunTerminal(baseUrl, auth.cookie, run.runId, 180_000);
|
||
if (finalRun.status !== 'succeeded') {
|
||
fail('Live Agent john8', `run status=${finalRun.status}`);
|
||
return;
|
||
}
|
||
|
||
const dbUrl = loadDatabaseUrl();
|
||
const pool = mysql.createPool(dbUrl);
|
||
const [rows] = await pool.query(
|
||
`SELECT i.title, r.remind_at, r.status
|
||
FROM h5_schedule_items i
|
||
LEFT JOIN h5_schedule_reminders r ON r.item_id = i.id
|
||
WHERE i.user_id = ? AND i.deleted_at IS NULL AND i.title LIKE '%吃药%'
|
||
ORDER BY i.created_at DESC LIMIT 3`,
|
||
[auth.user?.id],
|
||
);
|
||
await pool.end();
|
||
|
||
const withReminder = rows.filter((row) => row.remind_at != null);
|
||
if (withReminder.length >= 1) {
|
||
pass('Live Agent john8', `最新吃药事项含 reminder,status=${withReminder[0].status}`);
|
||
} else {
|
||
fail('Live Agent john8', `items=${rows.length}, reminders=0`);
|
||
}
|
||
}
|
||
|
||
async function main() {
|
||
console.log('=== 日程提醒多场景验证 ===\n');
|
||
|
||
testAutoRemindMatrix();
|
||
await testWechatHandlerIsolation();
|
||
|
||
const dbUrl = loadDatabaseUrl();
|
||
if (!dbUrl) {
|
||
fail('数据库场景', '未配置 DATABASE_URL');
|
||
} else {
|
||
const pool = mysql.createPool(dbUrl);
|
||
const [users] = await pool.query('SELECT id FROM h5_users WHERE username = ? LIMIT 1', ['john8']);
|
||
if (!users[0]) {
|
||
fail('数据库场景', 'john8 用户不存在');
|
||
} else {
|
||
await testDatabaseScenarios(pool, users[0].id);
|
||
}
|
||
await pool.end();
|
||
}
|
||
|
||
await testLiveAgentIfRequested();
|
||
|
||
console.log('\n=== 汇总 ===');
|
||
console.log(`通过: ${passed}`);
|
||
console.log(`失败: ${failed}`);
|
||
process.exit(failed > 0 ? 1 : 0);
|
||
}
|
||
|
||
main().catch((err) => {
|
||
console.error(err);
|
||
process.exit(1);
|
||
});
|