Files
memind/scripts/verify-schedule-reminder-create.mjs
T
john d58dc2a251 feat(wechat): add Intent Transaction Layer with unified task schema
Introduce Draft → Confirm → Commit flow for WeChat schedule intents behind
feature flags, plus h5_tasks dual-write/read aggregation and rollout scripts
so reminders and automations get explicit user confirmation before persisting.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-24 15:59:17 +08:00

388 lines
12 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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: '明天早上六点去跑步,五点半提醒我', msgId: 'msg-agent' },
user: { userId: 'user-1' },
scheduleService,
});
if (agentReply === null) {
pass('服务号复杂提醒', '仍 fall through 给 Agent(多时间点)');
} else {
fail('服务号复杂提醒', `expected null, got ${agentReply}`);
}
const directReply = await handleWechatScheduleIntent({
intent: { agentText: '今天10点提醒我吃药', msgId: 'msg-direct' },
user: { userId: 'user-1' },
scheduleService: {
...scheduleService,
async createItem(payload) {
calls.push(['createItem', payload.title]);
return { id: 'item-direct', ...payload };
},
async createReminder(payload) {
calls.push(['createReminder', payload.itemId]);
return { id: 'reminder-direct', ...payload };
},
},
});
if (directReply?.includes('已设置提醒') && calls.some((entry) => entry[0] === 'createReminder')) {
pass('服务号简单提醒', '规则路径直接 createItem + createReminder');
} else {
fail('服务号简单提醒', JSON.stringify({ directReply, calls }));
}
const intent = parseScheduleIntent('今天10点提醒我吃药');
if (intent.action === 'create_timed_reminder') {
pass('意图路由', '简单一次性提醒走规则 preflightcreate_timed_reminder');
} 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: 'AgentnoReminder=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', `最新吃药事项含 reminderstatus=${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);
});