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
+46
View File
@@ -29,3 +29,49 @@ export function resolvePostAgentRunChatState({
export function shouldPromoteSessionIdToStreaming(chatState) {
return chatState !== 'idle';
}
/**
* Agent-run POST tracks portal request_id; Goose SSE may emit a different chat_request_id.
* Adopt the upstream id so Message/Finish events are not dropped in the UI.
*
* @param {{ activeRequestId?: string | null; chatState?: string; eventType?: string; eventRequestId?: string | null; activeRequestIds?: string[] }} input
* @returns {{ activeRequestId: string | null; promoteStreaming: boolean; allowMissingGrace: boolean }}
*/
export function reconcileSessionEventRequestContext({
activeRequestId = null,
chatState = 'idle',
eventType,
eventRequestId = null,
activeRequestIds = [],
} = {}) {
if (eventType === 'ActiveRequests') {
if (!activeRequestId && activeRequestIds.length > 0) {
return { activeRequestId: activeRequestIds[0], promoteStreaming: true, allowMissingGrace: false };
}
if (activeRequestId && activeRequestIds.includes(activeRequestId)) {
return { activeRequestId, promoteStreaming: false, allowMissingGrace: false };
}
if (activeRequestId && !activeRequestIds.includes(activeRequestId)) {
if (chatState === 'waiting' && activeRequestIds.length > 0) {
return { activeRequestId: activeRequestIds[0], promoteStreaming: true, allowMissingGrace: false };
}
return {
activeRequestId,
promoteStreaming: false,
allowMissingGrace: chatState !== 'waiting',
};
}
return { activeRequestId, promoteStreaming: false, allowMissingGrace: false };
}
if (
eventRequestId &&
(chatState === 'waiting' || chatState === 'streaming') &&
(eventType === 'Message' || eventType === 'Finish') &&
(!activeRequestId || eventRequestId !== activeRequestId)
) {
return { activeRequestId: eventRequestId, promoteStreaming: chatState === 'waiting', allowMissingGrace: false };
}
return { activeRequestId, promoteStreaming: false, allowMissingGrace: false };
}
+22
View File
@@ -1,6 +1,7 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
reconcileSessionEventRequestContext,
resolvePostAgentRunChatState,
shouldPromoteSessionIdToStreaming,
} from './chat-agent-run-gate.mjs';
@@ -29,3 +30,24 @@ test('shouldPromoteSessionIdToStreaming skips re-streaming after Finish', () =>
assert.equal(shouldPromoteSessionIdToStreaming('waiting'), true);
assert.equal(shouldPromoteSessionIdToStreaming('streaming'), true);
});
test('reconcileSessionEventRequestContext adopts Goose request id while agent-run gate waits', () => {
assert.deepEqual(
reconcileSessionEventRequestContext({
activeRequestId: 'portal-req',
chatState: 'waiting',
eventType: 'ActiveRequests',
activeRequestIds: ['goose-req'],
}),
{ activeRequestId: 'goose-req', promoteStreaming: true, allowMissingGrace: false },
);
assert.deepEqual(
reconcileSessionEventRequestContext({
activeRequestId: 'portal-req',
chatState: 'waiting',
eventType: 'Message',
eventRequestId: 'goose-req',
}),
{ activeRequestId: 'goose-req', promoteStreaming: true, allowMissingGrace: false },
);
});
+33
View File
@@ -16,6 +16,7 @@ import { execFileSync } from 'node:child_process';
import mysql from 'mysql2/promise';
import { createScheduleService } from './schedule-service.mjs';
import { resolveScheduleTimestamp } from './schedule-time.mjs';
import { shouldAutoCreateReminderAtStart } from './schedule-service.mjs';
import { renderLongImage } from './mindspace-long-image.mjs';
import { createUserDataSpaceService } from './user-data-space-service.mjs';
import { writePageAccessPolicy, readPageAccessPolicy } from './page-data-policy-store.mjs';
@@ -426,6 +427,11 @@ if (isScheduleConfigured()) {
location: { type: 'string', description: '地点,可选' },
sourceMessageId: { type: 'string', description: '服务号消息 ID;有值时必须原样传入' },
sourceText: { type: 'string', description: '原始用户文本,可选' },
remindAt: { type: 'number', description: '提醒触发时间 Unix 毫秒时间戳,可选(优先使用 remindLocal' },
remindLocal: { type: 'string', description: '本地提醒时间 YYYY-MM-DD HH:mm;需要到点提醒时推荐与 startLocal 同传' },
offsetMinutes: { type: 'number', description: '相对事项时间的提前分钟数,可选' },
channel: { type: 'string', description: '提醒通道,默认 wechat,可选' },
noReminder: { type: 'boolean', description: '明确只记事项、不创建到点提醒时传 true' },
},
required: ['title'],
},
@@ -730,6 +736,33 @@ async function callTool(name, args) {
sourceText: args.sourceText ?? null,
metadata: { source: 'schedule_assistant_skill' },
});
let remindAt = resolveScheduleTimestamp({
epochMs: args.remindAt,
localString: args.remindLocal,
timezone,
fieldName: '提醒时间',
});
if (
remindAt == null
&& shouldAutoCreateReminderAtStart({
title: args.title,
description: args.description,
startAt,
noReminder: Boolean(args.noReminder),
})
) {
remindAt = startAt;
}
if (remindAt != null) {
const reminder = await getScheduleService().createReminder({
userId: PRIVATE_DATA_USER_ID,
itemId: item.id,
remindAt,
offsetMinutes: args.offsetMinutes ?? null,
channel: args.channel ?? 'wechat',
});
return [{ type: 'text', text: JSON.stringify({ item, reminder }, null, 2) }];
}
return [{ type: 'text', text: JSON.stringify(item, null, 2) }];
}
case 'schedule_create_reminder': {
+13
View File
@@ -10,6 +10,19 @@ import {
const DEFAULT_TIMEZONE = 'Asia/Shanghai';
const REMINDER_INTENT_PATTERN = /提醒|闹钟|吃药|叫我/;
export function shouldAutoCreateReminderAtStart({
title = '',
description = '',
startAt = null,
noReminder = false,
} = {}) {
if (noReminder || startAt == null) return false;
const text = `${String(title ?? '')} ${String(description ?? '')}`;
return REMINDER_INTENT_PATTERN.test(text);
}
function nowMs() {
return Date.now();
}
+36 -1
View File
@@ -1,6 +1,41 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createScheduleService } from './schedule-service.mjs';
import { createScheduleService, shouldAutoCreateReminderAtStart } from './schedule-service.mjs';
test('shouldAutoCreateReminderAtStart detects reminder intent from title or description', () => {
assert.equal(
shouldAutoCreateReminderAtStart({
title: '吃药提醒',
description: '今天上午10点吃药',
startAt: 1,
}),
true,
);
assert.equal(
shouldAutoCreateReminderAtStart({
title: '开会',
description: '明天下午三点',
startAt: 1,
}),
false,
);
assert.equal(
shouldAutoCreateReminderAtStart({
title: '吃药提醒',
description: '今天上午10点吃药',
startAt: 1,
noReminder: true,
}),
false,
);
assert.equal(
shouldAutoCreateReminderAtStart({
title: '买菜',
startAt: null,
}),
false,
);
});
test('listUserNotifications accepts mysql JSON columns returned as objects', async () => {
const service = createScheduleService({
+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);
});
+5 -4
View File
@@ -42,10 +42,11 @@ description: 处理待办、提醒、日程类消息;先澄清缺失时间,
3. 创建事项时:
- 纯待办用 `kind: task`
- 明确约会/会议/出发等时间安排可用 `kind: event`
4. 需要提醒时:
- 先创建事项
- 调用 `schedule_create_reminder`
- 只有两个工具都成功,才对用户确认成功
4. 需要提醒时(推荐单步写入,避免漏掉第二步)
- 优先在一次 `schedule_create_item` 中同时传 `startLocal``remindLocal`(到点提醒通常相同)
- 若提醒时间与事项开始时间不同,再单独调用 `schedule_create_reminder`
- 只有事项和提醒都写入成功,才对用户确认成功
- 用户明确说「不提醒 / 只记一下」时传 `noReminder: true`
5. 如果提示里给出了 `sourceMessageId`,调用 `schedule_create_item` 时必须原样传入。
6. 查询时调用 `schedule_list_items`,只按结果回答,不要编造“已经存在”。
+30 -16
View File
@@ -52,6 +52,7 @@ import { buildContextPrefix } from '../utils/mindspaceChatContext';
import { buildUserAddressPrefix } from '../utils/userAddress';
import { buildAutoChatSkillPrefix } from '../../chat-skills.mjs';
import {
reconcileSessionEventRequestContext,
resolvePostAgentRunChatState,
shouldPromoteSessionIdToStreaming,
} from '../../chat-agent-run-gate.mjs';
@@ -989,19 +990,22 @@ export function useTKMindChat(
(event) => {
let rid = activeRequestId.current;
if (event.type === 'ActiveRequests') {
if (!rid && event.request_ids.length > 0) {
// SSE reconnected while agent was running — adopt the active request.
const activeRequestContext = reconcileSessionEventRequestContext({
activeRequestId: rid,
chatState: chatStateRef.current,
eventType: event.type,
activeRequestIds: event.request_ids,
});
if (
activeRequestContext.activeRequestId &&
activeRequestContext.activeRequestId !== rid
) {
clearActiveRequestMissingTimer();
activeRequestId.current = event.request_ids[0];
setChatState('streaming');
activeRequestId.current = activeRequestContext.activeRequestId;
rid = activeRequestContext.activeRequestId;
} else if (rid && event.request_ids.includes(rid)) {
clearActiveRequestMissingTimer();
} else if (rid && !event.request_ids.includes(rid)) {
// While waiting for the agent run gate, keep request context alive so
// Goose streaming events are not dropped before the UI subscribes.
if (chatStateRef.current === 'waiting') {
return;
}
} else if (activeRequestContext.allowMissingGrace) {
// The backend can briefly report no active request between tool phases.
// Confirm the absence before turning the UI idle, otherwise MindSpace
// refreshes the page while tools are still mutating it.
@@ -1016,18 +1020,28 @@ export function useTKMindChat(
}, ACTIVE_REQUEST_MISSING_GRACE_MS);
}
}
if (activeRequestContext.promoteStreaming) {
setChatState('streaming');
}
return;
}
const eventRequestId = getSessionEventRequestId(event);
const eventRequestContext = reconcileSessionEventRequestContext({
activeRequestId: rid,
chatState: chatStateRef.current,
eventType: event.type,
eventRequestId,
});
if (
!rid &&
eventRequestId &&
(chatStateRef.current === 'waiting' || chatStateRef.current === 'streaming') &&
(event.type === 'Message' || event.type === 'Finish')
eventRequestContext.activeRequestId &&
eventRequestContext.activeRequestId !== rid
) {
activeRequestId.current = eventRequestId;
rid = eventRequestId;
clearActiveRequestMissingTimer();
activeRequestId.current = eventRequestContext.activeRequestId;
rid = eventRequestContext.activeRequestId;
if (eventRequestContext.promoteStreaming) {
setChatState('streaming');
}
}
if (
rid ||
+1
View File
@@ -38,6 +38,7 @@ export function buildWechatAgentPrompt(intent, { grantedSkills = [] } = {}) {
'【日程技能要求】这条消息涉及待办、提醒或日程。',
'开始前先加载 `schedule-assistant` skill,并严格按 skill 里的边界执行。',
'写入工具时优先使用 startLocal / endLocal / remindLocalYYYY-MM-DD HH:mm),不要自行估算 Unix 毫秒。',
'需要到点提醒时,优先在一次 schedule_create_item 中同时传 startLocal 与 remindLocal;不要只创建事项就结束。',
'只有在 `schedule_create_item` / `schedule_create_reminder` 等工具成功返回后,才能告诉用户“已经设置好了”。',
intent?.msgId ? `调用 schedule_create_item 时必须传入 sourceMessageId: ${intent.msgId}` : '',
'',