Files
memind/scripts/verify-schedule-reminder-create.mjs
john 97d0e8d970 fix(schedule): auto-create reminders when agent only writes schedule items
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>
2026-07-12 08:11:43 +08:00

367 lines
11 KiB
JavaScript
Raw Permalink 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: '今天10点提醒我吃药', msgId: 'msg-agent' },
user: { userId: 'user-1' },
scheduleService,
});
if (agentReply === null) {
pass('服务号一次性提醒', '仍 fall through 给 Agentschedule_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: '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);
});