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>
This commit is contained in:
john
2026-07-12 08:11:43 +08:00
parent 32fb2cdeaf
commit 97d0e8d970
9 changed files with 552 additions and 21 deletions
+366
View File
@@ -0,0 +1,366 @@
#!/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);
});