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:
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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 },
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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': {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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 给 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);
|
||||
});
|
||||
@@ -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
@@ -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 ||
|
||||
|
||||
@@ -38,6 +38,7 @@ export function buildWechatAgentPrompt(intent, { grantedSkills = [] } = {}) {
|
||||
'【日程技能要求】这条消息涉及待办、提醒或日程。',
|
||||
'开始前先加载 `schedule-assistant` skill,并严格按 skill 里的边界执行。',
|
||||
'写入工具时优先使用 startLocal / endLocal / remindLocal(YYYY-MM-DD HH:mm),不要自行估算 Unix 毫秒。',
|
||||
'需要到点提醒时,优先在一次 schedule_create_item 中同时传 startLocal 与 remindLocal;不要只创建事项就结束。',
|
||||
'只有在 `schedule_create_item` / `schedule_create_reminder` 等工具成功返回后,才能告诉用户“已经设置好了”。',
|
||||
intent?.msgId ? `调用 schedule_create_item 时必须传入 sourceMessageId: ${intent.msgId}` : '',
|
||||
'',
|
||||
|
||||
Reference in New Issue
Block a user