#!/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); });