From 97d0e8d970247d01d999b77204225a5b03b0f016 Mon Sep 17 00:00:00 2001 From: john Date: Sun, 12 Jul 2026 08:11:43 +0800 Subject: [PATCH] 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 --- chat-agent-run-gate.mjs | 46 +++ chat-agent-run-gate.test.mjs | 22 ++ mindspace-sandbox-mcp.mjs | 33 ++ schedule-service.mjs | 13 + schedule-service.test.mjs | 37 +- scripts/verify-schedule-reminder-create.mjs | 366 ++++++++++++++++++++ skills/schedule-assistant/SKILL.md | 9 +- src/hooks/useTKMindChat.ts | 46 ++- wechat/prompts/chat-general.mjs | 1 + 9 files changed, 552 insertions(+), 21 deletions(-) create mode 100644 scripts/verify-schedule-reminder-create.mjs diff --git a/chat-agent-run-gate.mjs b/chat-agent-run-gate.mjs index e942d82..d254091 100644 --- a/chat-agent-run-gate.mjs +++ b/chat-agent-run-gate.mjs @@ -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 }; +} diff --git a/chat-agent-run-gate.test.mjs b/chat-agent-run-gate.test.mjs index 270550b..97b170d 100644 --- a/chat-agent-run-gate.test.mjs +++ b/chat-agent-run-gate.test.mjs @@ -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 }, + ); +}); diff --git a/mindspace-sandbox-mcp.mjs b/mindspace-sandbox-mcp.mjs index 5cc760e..4cef617 100644 --- a/mindspace-sandbox-mcp.mjs +++ b/mindspace-sandbox-mcp.mjs @@ -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': { diff --git a/schedule-service.mjs b/schedule-service.mjs index e31ba59..4a806f1 100644 --- a/schedule-service.mjs +++ b/schedule-service.mjs @@ -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(); } diff --git a/schedule-service.test.mjs b/schedule-service.test.mjs index 8a24c04..e60fbba 100644 --- a/schedule-service.test.mjs +++ b/schedule-service.test.mjs @@ -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({ diff --git a/scripts/verify-schedule-reminder-create.mjs b/scripts/verify-schedule-reminder-create.mjs new file mode 100644 index 0000000..9e784ca --- /dev/null +++ b/scripts/verify-schedule-reminder-create.mjs @@ -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); +}); diff --git a/skills/schedule-assistant/SKILL.md b/skills/schedule-assistant/SKILL.md index a6bd44b..0ab4683 100644 --- a/skills/schedule-assistant/SKILL.md +++ b/skills/schedule-assistant/SKILL.md @@ -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`,只按结果回答,不要编造“已经存在”。 diff --git a/src/hooks/useTKMindChat.ts b/src/hooks/useTKMindChat.ts index 05f2f78..55c18c1 100644 --- a/src/hooks/useTKMindChat.ts +++ b/src/hooks/useTKMindChat.ts @@ -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 || diff --git a/wechat/prompts/chat-general.mjs b/wechat/prompts/chat-general.mjs index ab3c7f1..09bd560 100644 --- a/wechat/prompts/chat-general.mjs +++ b/wechat/prompts/chat-general.mjs @@ -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}` : '', '',