diff --git a/agent-side-effect-tools.mjs b/agent-side-effect-tools.mjs new file mode 100644 index 0000000..7c5e257 --- /dev/null +++ b/agent-side-effect-tools.mjs @@ -0,0 +1,146 @@ +import { resolveScheduleTimestamp } from './schedule-time.mjs'; +import { shouldAutoCreateReminderAtStart } from './schedule-service.mjs'; +import { + isScheduledTaskWorkerEnabled, + scheduledTaskWorkerDisabledMessage, +} from './scheduled-task-worker-config.mjs'; + +export async function executeScheduledTaskCreateTool(args, { + userId, + scheduledTaskService, + timezone = 'Asia/Shanghai', + env = process.env, +} = {}) { + if (!scheduledTaskService) throw new Error('scheduledTaskService 不可用'); + const task = await scheduledTaskService.createTask({ + userId, + title: args.title ?? null, + taskSpec: args.taskSpec, + recurrence: args.recurrence ?? 'daily', + runAtLocal: args.runAtLocal ?? null, + hour: args.hour ?? null, + minute: args.minute ?? 0, + weekday: args.weekday ?? null, + timezone: args.timezone ?? timezone, + notifyChannel: args.notifyChannel ?? 'both', + sourceChannel: args.sourceChannel ?? 'agent', + sourceMessageId: args.sourceMessageId ?? null, + sourceText: args.sourceText ?? null, + }); + const workerWarning = isScheduledTaskWorkerEnabled(env) + ? null + : scheduledTaskWorkerDisabledMessage(env); + return workerWarning ? { ...task, workerWarning } : task; +} + +export async function executeScheduleCreateItemTool(args, { + userId, + scheduleService, + timezone = 'Asia/Shanghai', +} = {}) { + if (!scheduleService) throw new Error('scheduleService 不可用'); + const tz = args.timezone ?? timezone; + const startAt = resolveScheduleTimestamp({ + epochMs: args.startAt, + localString: args.startLocal, + timezone: tz, + fieldName: '开始时间', + }); + const endAt = resolveScheduleTimestamp({ + epochMs: args.endAt, + localString: args.endLocal, + timezone: tz, + fieldName: '结束时间', + }); + const dueAt = resolveScheduleTimestamp({ + epochMs: args.dueAt, + localString: args.dueLocal, + timezone: tz, + fieldName: '截止时间', + }); + const item = await scheduleService.createItem({ + userId, + kind: args.kind, + title: args.title, + description: args.description ?? null, + startAt, + endAt, + dueAt, + allDay: Boolean(args.allDay), + timezone: tz, + location: args.location ?? null, + sourceChannel: args.sourceChannel ?? 'agent', + sourceMessageId: args.sourceMessageId ?? null, + sourceText: args.sourceText ?? null, + metadata: args.metadata ?? { source: 'agent_tool' }, + }); + 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; + } + if (remindAt != null) { + const reminder = await scheduleService.createReminder({ + userId, + itemId: item.id, + remindAt, + offsetMinutes: args.offsetMinutes ?? null, + channel: args.channel ?? 'wechat', + }); + return { item, reminder }; + } + return { item }; +} + +export async function executeScheduleCreateReminderTool(args, { + userId, + scheduleService, + timezone = 'Asia/Shanghai', +} = {}) { + if (!scheduleService) throw new Error('scheduleService 不可用'); + const tz = args.timezone ?? timezone; + const remindAt = resolveScheduleTimestamp({ + epochMs: args.remindAt, + localString: args.remindLocal, + timezone: tz, + fieldName: '提醒时间', + }); + if (remindAt == null) throw new Error('缺少提醒时间 remindLocal 或 remindAt'); + const reminder = await scheduleService.createReminder({ + userId, + itemId: args.itemId, + remindAt, + offsetMinutes: args.offsetMinutes ?? null, + channel: args.channel ?? 'wechat', + }); + return reminder; +} + +export async function commitAgentSideEffectTool(toolName, toolArgs, services) { + const name = String(toolName ?? '').trim(); + if (name === 'scheduled_task_create') { + const task = await executeScheduledTaskCreateTool(toolArgs, services); + return { kind: 'scheduled_task', task }; + } + if (name === 'schedule_create_item') { + const result = await executeScheduleCreateItemTool(toolArgs, services); + return { kind: 'schedule_item', ...result }; + } + if (name === 'schedule_create_reminder') { + const reminder = await executeScheduleCreateReminderTool(toolArgs, services); + return { kind: 'schedule_reminder', reminder }; + } + throw new Error(`不支持的 agent 副作用工具:${name}`); +} diff --git a/intent-tool-confirm-gate.mjs b/intent-tool-confirm-gate.mjs new file mode 100644 index 0000000..b34fabd --- /dev/null +++ b/intent-tool-confirm-gate.mjs @@ -0,0 +1,114 @@ +import { formatIntentActionCard } from './intent-action-card.mjs'; +import { isIntentTransactionEnabled } from './intent-transaction-config.mjs'; + +export function isSideEffectConfirmGateEnabled(env = process.env) { + return isIntentTransactionEnabled(env); +} + +function buildScheduledTaskConfirmCard(args) { + const title = String(args.title ?? args.taskSpec ?? '').trim() || '定时自动任务'; + const trigger = args.recurrence === 'once' + ? { at: args.runAtLocal ?? null } + : { hour: args.hour, minute: args.minute ?? 0 }; + const frequency = args.recurrence === 'weekly' + ? '每周自动执行' + : args.recurrence === 'once' + ? null + : '每天自动执行'; + return formatIntentActionCard({ + title: title.slice(0, 80), + layer: 'L2', + actionLevel: 2, + trigger, + actions: ['到点自动执行任务', '推送执行结果到微信'], + frequency, + }); +} + +function buildScheduleItemConfirmCard(args) { + const title = String(args.title ?? '').trim() || '待办/日程'; + const remindLocal = args.remindLocal ?? args.startLocal ?? null; + const trigger = remindLocal ? { at: remindLocal } : { hour: null, minute: 0 }; + const actions = args.remindLocal || args.remindAt || args.startLocal || args.startAt + ? ['到点发送微信提醒'] + : ['记录到待办列表']; + return formatIntentActionCard({ + title: title.slice(0, 80), + layer: 'L1', + actionLevel: 1, + trigger, + actions, + }); +} + +function buildScheduleReminderConfirmCard(args) { + const trigger = args.remindLocal ? { at: args.remindLocal } : { hour: null, minute: 0 }; + return formatIntentActionCard({ + title: '日程提醒', + layer: 'L1', + actionLevel: 1, + trigger, + actions: ['到点发送微信提醒'], + }); +} + +export async function requireUserConfirmForSideEffectTool({ + userId, + toolName, + toolArgs, + intentDraftService, + env = process.env, + layer = 'L2', + actionLevel = 2, + title = null, + cardText = null, +} = {}) { + if (!isSideEffectConfirmGateEnabled(env)) { + return { proceed: true }; + } + if (!intentDraftService || !userId) { + return { proceed: true }; + } + + const safeToolName = String(toolName ?? '').trim(); + const args = { ...(toolArgs ?? {}) }; + const resolvedCardText = cardText + ?? (safeToolName === 'scheduled_task_create' + ? buildScheduledTaskConfirmCard(args) + : safeToolName === 'schedule_create_item' + ? buildScheduleItemConfirmCard(args) + : safeToolName === 'schedule_create_reminder' + ? buildScheduleReminderConfirmCard(args) + : formatIntentActionCard({ title: title ?? '待确认操作', layer, actionLevel })); + const resolvedTitle = String(title ?? args.title ?? args.taskSpec ?? resolvedCardText.split('\n')[0] ?? '待确认任务').trim(); + + const draft = await intentDraftService.createDraft({ + userId, + layer, + draftType: 'agent_tool', + actionLevel, + title: resolvedTitle.slice(0, 80), + payload: { + toolName: safeToolName, + toolArgs: args, + sourceChannel: 'agent', + sourceMessageId: args.sourceMessageId ?? null, + sourceText: args.sourceText ?? null, + }, + cardText: resolvedCardText, + sourceChannel: 'agent', + sourceMessageId: args.sourceMessageId ?? null, + sourceText: args.sourceText ?? null, + }); + + return { + proceed: false, + response: { + status: 'needs_user_confirmation', + draftId: draft.id, + cardText: resolvedCardText, + instruction: + '这是需要用户确认的副作用操作。请把 cardText 原样发给用户,并请用户回复「确认」后再写入;在用户确认前不要声称已成功设置。', + }, + }; +} diff --git a/intent-tool-confirm-gate.test.mjs b/intent-tool-confirm-gate.test.mjs new file mode 100644 index 0000000..6fad0f7 --- /dev/null +++ b/intent-tool-confirm-gate.test.mjs @@ -0,0 +1,96 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { commitIntentDraft } from './intent-transaction-commit.mjs'; +import { requireUserConfirmForSideEffectTool } from './intent-tool-confirm-gate.mjs'; + +function createDraftStore() { + let pending = null; + return { + async getPendingDraft(userId) { + return pending?.userId === userId ? pending : null; + }, + async createDraft(payload) { + pending = { + id: 'draft-tool-1', + status: 'draft', + ...payload, + }; + return pending; + }, + async cancelDraft(id, userId) { + if (pending?.id === id && pending.userId === userId) pending = null; + return { id, status: 'cancelled' }; + }, + async markDraftCommitted(id, userId, committedRef) { + pending = { ...pending, id, userId, status: 'committed', committedRef }; + return pending; + }, + }; +} + +test('requireUserConfirmForSideEffectTool creates draft instead of writing', async () => { + const drafts = createDraftStore(); + const gate = await requireUserConfirmForSideEffectTool({ + userId: 'user-1', + toolName: 'scheduled_task_create', + toolArgs: { + taskSpec: '做今日新闻页面', + recurrence: 'daily', + hour: 6, + minute: 0, + }, + intentDraftService: drafts, + env: { H5_INTENT_TRANSACTION_ENABLED: '1' }, + }); + assert.equal(gate.proceed, false); + assert.equal(gate.response.status, 'needs_user_confirmation'); + assert.match(gate.response.cardText, /确认/u); + const pending = await drafts.getPendingDraft('user-1'); + assert.equal(pending.draftType, 'agent_tool'); + assert.equal(pending.payload.toolName, 'scheduled_task_create'); +}); + +test('requireUserConfirmForSideEffectTool proceeds when feature disabled', async () => { + const gate = await requireUserConfirmForSideEffectTool({ + userId: 'user-1', + toolName: 'scheduled_task_create', + toolArgs: { taskSpec: '做新闻页面', hour: 6 }, + intentDraftService: createDraftStore(), + env: { H5_INTENT_TRANSACTION_ENABLED: '0' }, + }); + assert.equal(gate.proceed, true); +}); + +test('commitIntentDraft commits agent_tool scheduled task draft', async () => { + const created = []; + const committed = await commitIntentDraft({ + draft: { + status: 'draft', + draftType: 'agent_tool', + title: '新闻页面', + payload: { + toolName: 'scheduled_task_create', + toolArgs: { + taskSpec: '做今日新闻页面', + recurrence: 'daily', + hour: 6, + minute: 0, + timezone: 'Asia/Shanghai', + }, + }, + }, + userId: 'user-1', + scheduledTaskService: { + async createTask(input) { + created.push(input); + return { id: 'task-1', ...input }; + }, + }, + scheduleService: null, + timezone: 'Asia/Shanghai', + env: { H5_INTENT_TRANSACTION_ENABLED: '1' }, + }); + assert.equal(committed.kind, 'scheduled_task'); + assert.equal(created.length, 1); + assert.equal(created[0].hour, 6); +}); diff --git a/intent-transaction-commit.mjs b/intent-transaction-commit.mjs index 207cdc5..aa16a2a 100644 --- a/intent-transaction-commit.mjs +++ b/intent-transaction-commit.mjs @@ -1,3 +1,4 @@ +import { commitAgentSideEffectTool } from './agent-side-effect-tools.mjs'; import { resolveScheduleTimestamp } from './schedule-time.mjs'; import { buildScheduledTaskCreatePayload } from './scheduled-task-intent.mjs'; import { isUnifiedTasksEnabled } from './task-unified-config.mjs'; @@ -136,5 +137,23 @@ export async function commitIntentDraft({ return committed; } + if (kind === 'agent_tool') { + const committed = await commitAgentSideEffectTool(payload.toolName, payload.toolArgs, { + userId, + scheduleService, + scheduledTaskService, + timezone, + env, + }); + await maybeSyncUnifiedTask({ + taskUnifiedService, + userId, + kind: committed.kind === 'scheduled_task' ? 'scheduled_task' : kind, + committed, + env, + }); + return committed; + } + throw new Error(`不支持的草稿类型:${kind}`); } diff --git a/mindspace-sandbox-mcp.mjs b/mindspace-sandbox-mcp.mjs index 4dec1bc..583f5bf 100644 --- a/mindspace-sandbox-mcp.mjs +++ b/mindspace-sandbox-mcp.mjs @@ -18,11 +18,12 @@ import mysql from 'mysql2/promise'; import { createScheduleService } from './schedule-service.mjs'; import { createScheduledTaskService } from './scheduled-task-service.mjs'; import { - isScheduledTaskWorkerEnabled, - scheduledTaskWorkerDisabledMessage, -} from './scheduled-task-worker-config.mjs'; -import { resolveScheduleTimestamp } from './schedule-time.mjs'; -import { shouldAutoCreateReminderAtStart } from './schedule-service.mjs'; + executeScheduleCreateItemTool, + executeScheduleCreateReminderTool, + executeScheduledTaskCreateTool, +} from './agent-side-effect-tools.mjs'; +import { createIntentDraftService } from './intent-draft-service.mjs'; +import { requireUserConfirmForSideEffectTool } from './intent-tool-confirm-gate.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'; @@ -490,6 +491,7 @@ const ALL_TOOLS = [ let quotaPool = null; let scheduleService = null; let scheduledTaskService = null; +let intentDraftService = null; let userDataSpaceService = null; function isQuotaSyncConfigured() { @@ -549,6 +551,39 @@ function getScheduledTaskService() { return scheduledTaskService; } +function getIntentDraftService() { + if (!PRIVATE_DATA_USER_ID) return null; + const pool = getQuotaPool(); + if (!pool || !isScheduleConfigured()) return null; + if (!intentDraftService) { + intentDraftService = createIntentDraftService(pool); + } + return intentDraftService; +} + +async function gateSideEffectTool(toolName, args, { + layer = 'L2', + actionLevel = 2, + title = null, + cardText = null, +} = {}) { + const gate = await requireUserConfirmForSideEffectTool({ + userId: PRIVATE_DATA_USER_ID, + toolName, + toolArgs: args, + intentDraftService: getIntentDraftService(), + layer, + actionLevel, + title, + cardText, + }); + if (gate.proceed) return null; + return [{ + type: 'text', + text: JSON.stringify(gate.response, null, 2), + }]; +} + if (isScheduleConfigured()) { ALL_TOOLS.push( { @@ -1127,85 +1162,31 @@ async function callTool(name, args) { ]; } case 'schedule_create_item': { - const timezone = args.timezone ?? process.env.H5_DEFAULT_TIMEZONE ?? 'Asia/Shanghai'; - const startAt = resolveScheduleTimestamp({ - epochMs: args.startAt, - localString: args.startLocal, - timezone, - fieldName: '开始时间', - }); - const endAt = resolveScheduleTimestamp({ - epochMs: args.endAt, - localString: args.endLocal, - timezone, - fieldName: '结束时间', - }); - const dueAt = resolveScheduleTimestamp({ - epochMs: args.dueAt, - localString: args.dueLocal, - timezone, - fieldName: '截止时间', - }); - const item = await getScheduleService().createItem({ - userId: PRIVATE_DATA_USER_ID, - kind: args.kind, + const gated = await gateSideEffectTool('schedule_create_item', args, { + layer: 'L1', + actionLevel: 1, title: args.title, - description: args.description ?? null, - startAt, - endAt, - dueAt, - allDay: Boolean(args.allDay), - timezone, - location: args.location ?? null, - sourceChannel: 'agent', - sourceMessageId: args.sourceMessageId ?? null, - sourceText: args.sourceText ?? null, - metadata: { source: 'schedule_assistant_skill' }, }); - let remindAt = resolveScheduleTimestamp({ - epochMs: args.remindAt, - localString: args.remindLocal, + if (gated) return gated; + const timezone = args.timezone ?? process.env.H5_DEFAULT_TIMEZONE ?? 'Asia/Shanghai'; + const result = await executeScheduleCreateItemTool(args, { + userId: PRIVATE_DATA_USER_ID, + scheduleService: getScheduleService(), 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) }]; + return [{ type: 'text', text: JSON.stringify(result, null, 2) }]; } case 'schedule_create_reminder': { - const timezone = args.timezone ?? process.env.H5_DEFAULT_TIMEZONE ?? 'Asia/Shanghai'; - const remindAt = resolveScheduleTimestamp({ - epochMs: args.remindAt, - localString: args.remindLocal, - timezone, - fieldName: '提醒时间', + const gated = await gateSideEffectTool('schedule_create_reminder', args, { + layer: 'L1', + actionLevel: 1, }); - if (remindAt == null) throw new Error('缺少提醒时间 remindLocal 或 remindAt'); - const reminder = await getScheduleService().createReminder({ + if (gated) return gated; + const timezone = args.timezone ?? process.env.H5_DEFAULT_TIMEZONE ?? 'Asia/Shanghai'; + const reminder = await executeScheduleCreateReminderTool(args, { userId: PRIVATE_DATA_USER_ID, - itemId: args.itemId, - remindAt, - offsetMinutes: args.offsetMinutes ?? null, - channel: args.channel ?? 'wechat', + scheduleService: getScheduleService(), + timezone, }); return [{ type: 'text', text: JSON.stringify(reminder, null, 2) }]; } @@ -1220,32 +1201,21 @@ async function callTool(name, args) { return [{ type: 'text', text: JSON.stringify(items, null, 2) }]; } case 'scheduled_task_create': { - const timezone = args.timezone ?? process.env.H5_DEFAULT_TIMEZONE ?? 'Asia/Shanghai'; - const task = await getScheduledTaskService().createTask({ - userId: PRIVATE_DATA_USER_ID, - title: args.title ?? null, - taskSpec: args.taskSpec, - recurrence: args.recurrence ?? 'daily', - runAtLocal: args.runAtLocal ?? null, - hour: args.hour ?? null, - minute: args.minute ?? 0, - weekday: args.weekday ?? null, - timezone, - notifyChannel: args.notifyChannel ?? 'both', - sourceChannel: 'agent', - sourceMessageId: args.sourceMessageId ?? null, - sourceText: args.sourceText ?? null, + const gated = await gateSideEffectTool('scheduled_task_create', args, { + layer: 'L2', + actionLevel: 2, + title: args.title ?? args.taskSpec, + }); + if (gated) return gated; + const timezone = args.timezone ?? process.env.H5_DEFAULT_TIMEZONE ?? 'Asia/Shanghai'; + const task = await executeScheduledTaskCreateTool(args, { + userId: PRIVATE_DATA_USER_ID, + scheduledTaskService: getScheduledTaskService(), + timezone, }); - const workerWarning = isScheduledTaskWorkerEnabled() - ? null - : scheduledTaskWorkerDisabledMessage(); return [{ type: 'text', - text: JSON.stringify( - workerWarning ? { ...task, workerWarning } : task, - null, - 2, - ), + text: JSON.stringify(task, null, 2), }]; } case 'scheduled_task_list': { diff --git a/scripts/verify-intent-transaction-layer.mjs b/scripts/verify-intent-transaction-layer.mjs index 539fe68..d092c71 100644 --- a/scripts/verify-intent-transaction-layer.mjs +++ b/scripts/verify-intent-transaction-layer.mjs @@ -94,22 +94,35 @@ async function main() { const drafts = createDraftStore(); const scheduleService = createScheduleService(); - const card = await handleWechatIntentTransaction({ + const passthrough = await handleWechatIntentTransaction({ intent: { agentText: '下午2点半提醒我开项目计划例会', msgId: 'verify-1', msgType: 'text' }, user: { userId: 'verify-user' }, intentDraftService: drafts, scheduleService, env, }); - if (card?.includes('我准备执行') && card.includes('确认')) { - pass('creates action card for timed reminder'); - } else { - fail('creates action card for timed reminder', card); - } + if (passthrough === null) pass('new messages pass through to goose'); + else fail('new messages pass through to goose', passthrough); + await drafts.createDraft({ + userId: 'verify-user', + layer: 'L1', + draftType: 'timed_reminder', + actionLevel: 1, + title: '项目计划例会', + payload: { + title: '项目计划例会', + remindLocal: '2026-08-28 14:30', + hour: 14, + minute: 30, + recurrence: 'once', + sourceChannel: 'agent', + }, + cardText: '确认卡片', + }); const pending = await drafts.getPendingDraft('verify-user'); - if (pending?.draftType === 'timed_reminder') pass('persists pending draft'); - else fail('persists pending draft', JSON.stringify(pending)); + if (pending?.draftType === 'timed_reminder') pass('pending draft can be committed after tool gate'); + else fail('pending draft can be committed after tool gate', JSON.stringify(pending)); const committed = await handleWechatIntentTransaction({ intent: { agentText: '确认', msgId: 'verify-2', msgType: 'text' }, @@ -131,11 +144,18 @@ async function main() { scheduleService, env, }); - if (slotFill?.includes('补充') && !slotFill.includes('我准备执行')) { - pass('slot fill does not create confirmable draft'); - } else { - fail('slot fill does not create confirmable draft', slotFill); - } + if (slotFill === null) pass('incomplete requests pass through to goose'); + else fail('incomplete requests pass through to goose', slotFill); + + const pagePassthrough = await handleWechatIntentTransaction({ + intent: { agentText: '我想帮我做一个页面,页面上每天自动更新天气', msgId: 'verify-page', msgType: 'text' }, + user: { userId: 'verify-user-page' }, + intentDraftService: createDraftStore(), + scheduleService, + env, + }); + if (pagePassthrough === null) pass('page delivery with auto refresh passes through to goose'); + else fail('page delivery with auto refresh passes through to goose', pagePassthrough); const disabled = await handleWechatIntentTransaction({ intent: { agentText: '下午2点提醒我', msgId: 'verify-4', msgType: 'text' }, diff --git a/wechat/handlers/intent-transaction.mjs b/wechat/handlers/intent-transaction.mjs index 40ec67a..7e9721a 100644 --- a/wechat/handlers/intent-transaction.mjs +++ b/wechat/handlers/intent-transaction.mjs @@ -1,102 +1,13 @@ -import { classifyUserIntent, inferActionLevel } from '../../intent-classifier.mjs'; import { commitIntentDraft } from '../../intent-transaction-commit.mjs'; import { formatDraftCancelledReply, formatDraftCommittedReply, - formatIntentActionCard, parseDraftUserReply, } from '../../intent-action-card.mjs'; -import { formatQueryGuardReply } from '../../intent-query-guard.mjs'; -import { isIntentTransactionEnabled } from '../../intent-transaction-config.mjs'; -import { buildScheduledTaskCreatePayload } from '../../scheduled-task-intent.mjs'; import { formatScheduledTaskCreateReply } from '../../scheduled-task-intent.mjs'; +import { isIntentTransactionEnabled } from '../../intent-transaction-config.mjs'; import { isScheduledTaskWorkerEnabled, scheduledTaskWorkerDisabledMessage } from '../../scheduled-task-worker-config.mjs'; -function buildDraftPayload(classification, { intent, user, timezone }) { - const { kind, detail } = classification; - const base = { - sourceChannel: 'wechat', - sourceMessageId: intent.msgId ?? null, - sourceText: intent.agentText, - timezone, - }; - - if (kind === 'timed_reminder') { - return { - ...base, - title: detail.title, - remindLocal: detail.remindLocal, - hour: detail.hour, - minute: detail.minute, - recurrence: detail.recurrence ?? 'once', - }; - } - if (kind === 'create_todo') { - return { ...base, title: detail.title }; - } - if (kind === 'create_daily_todo_digest') { - return { ...base, hour: detail.hour, minute: detail.minute ?? 0 }; - } - if (kind === 'create_balance_alert') { - return { ...base, thresholdCents: detail.thresholdCents }; - } - if (kind === 'scheduled_task') { - return { - ...base, - intentDetail: detail, - createPayload: buildScheduledTaskCreatePayload(detail, { - userId: user.userId, - sourceChannel: 'wechat', - sourceMessageId: intent.msgId ?? null, - sourceText: intent.agentText, - timezone, - }), - }; - } - return base; -} - -function buildCard(classification, draftPayload, actionLevel, text) { - const { layer, kind, detail, clarify } = classification; - if (kind === 'clarify' || classification.layer === 'ambiguous') { - return formatIntentActionCard({ clarify: ['notify_vs_act'] }); - } - if (clarify?.length) { - return formatIntentActionCard({ clarify }); - } - - const title = detail?.title ?? detail?.taskSpec?.slice?.(0, 80) ?? draftPayload.title ?? '待确认任务'; - const trigger = detail?.remindLocal - ? { at: detail.remindLocal } - : { hour: detail?.hour, minute: detail?.minute ?? 0 }; - const frequency = /(?:每天|每日)/u.test(text) - ? '每天自动执行' - : detail?.recurrence === 'weekly' - ? '每周自动执行' - : detail?.recurrence === 'daily' - ? '每天自动执行' - : null; - - const actions = kind === 'scheduled_task' - ? ['到点自动执行任务', '推送执行结果到微信'] - : kind === 'timed_reminder' - ? ['到点发送微信提醒'] - : kind === 'create_daily_todo_digest' - ? ['到点推送当天待办摘要'] - : kind === 'create_balance_alert' - ? ['余额低于阈值时发送微信提醒'] - : ['记录到待办列表']; - - return formatIntentActionCard({ - title, - layer, - actionLevel, - trigger, - actions, - frequency, - }); -} - export async function handleWechatIntentTransaction({ intent, user, @@ -114,108 +25,50 @@ export async function handleWechatIntentTransaction({ try { const pending = await intentDraftService.getPendingDraft(user.userId); const replyKind = parseDraftUserReply(text); - if (pending && replyKind) { - if (replyKind === 'cancel') { - await intentDraftService.cancelDraft(pending.id, user.userId); - return formatDraftCancelledReply(); - } - if (replyKind === 'modify') { - await intentDraftService.cancelDraft(pending.id, user.userId, { reason: 'user_modify' }); - return '好的,请重新发送完整的安排,例如「下午 2 点半提醒我开项目计划例会」。'; - } - if (replyKind === 'confirm') { - if (pending.layer === 'ambiguous' || pending.draftType === 'clarify') { - return '我还需要确认:你是要「到点提醒」还是「到点自动执行并交付结果」?请补充后再发「确认」。'; - } - const committed = await commitIntentDraft({ - draft: pending, - userId: user.userId, - scheduleService, - scheduledTaskService, - taskUnifiedService, - timezone, - env, - }); - await intentDraftService.markDraftCommitted(pending.id, user.userId, committed); - if (committed.kind === 'scheduled_task') { - const workerWarning = isScheduledTaskWorkerEnabled(env) - ? null - : scheduledTaskWorkerDisabledMessage(env); - let reply = formatScheduledTaskCreateReply(committed.task, { workerWarning }); - reply = `${reply}\n\n(已通过确认卡片写入)`; - return reply; - } - return formatDraftCommittedReply({ title: pending.title, kind: pending.draftType }); - } + if (!pending || !replyKind) { + return null; } - const classification = classifyUserIntent(text, { timezone }); - const { layer, kind, action } = classification; - - if (layer === 'L0') { - return formatQueryGuardReply(text, { + if (replyKind === 'cancel') { + await intentDraftService.cancelDraft(pending.id, user.userId); + return formatDraftCancelledReply(); + } + if (replyKind === 'modify') { + await intentDraftService.cancelDraft(pending.id, user.userId, { reason: 'user_modify' }); + return '好的,请重新描述你的需求。'; + } + if (replyKind === 'confirm') { + if (pending.layer === 'ambiguous' || pending.draftType === 'clarify') { + return '我还需要确认:你是要「到点提醒」还是「到点自动执行并交付结果」?请补充后再发「确认」。'; + } + const committed = await commitIntentDraft({ + draft: pending, + userId: user.userId, scheduleService, scheduledTaskService, taskUnifiedService, - userId: user.userId, timezone, env, }); - } - - if (layer === 'L3' || layer === null || kind === 'agent_schedule' || kind === 'agent_automation') { - return null; - } - - if (kind === 'manage') { - return null; - } - - if (layer === 'ambiguous' || kind === 'clarify') { - if (classification.clarify?.length && kind !== 'clarify') { - return buildCard(classification, {}, inferActionLevel(classification, text), text); + await intentDraftService.markDraftCommitted(pending.id, user.userId, committed); + if (committed.kind === 'scheduled_task') { + const workerWarning = isScheduledTaskWorkerEnabled(env) + ? null + : scheduledTaskWorkerDisabledMessage(env); + let reply = formatScheduledTaskCreateReply(committed.task, { workerWarning }); + reply = `${reply}\n\n(已通过确认卡片写入)`; + return reply; } - await intentDraftService.createDraft({ - userId: user.userId, - layer: 'ambiguous', - draftType: 'clarify', - actionLevel: 2, - title: '待确认意图', - payload: { clarify: ['notify_vs_act'], sourceText: text }, - cardText: buildCard({ layer: 'ambiguous', kind: 'clarify', clarify: ['notify_vs_act'] }, {}, 2, text), - sourceMessageId: intent.msgId ?? null, - sourceText: text, - }); - return buildCard({ layer: 'ambiguous', kind: 'clarify', clarify: ['notify_vs_act'] }, {}, 2, text); + if (committed.kind === 'schedule_item' || committed.kind === 'schedule_reminder') { + return formatDraftCommittedReply({ + title: pending.title, + kind: committed.kind === 'schedule_reminder' ? 'timed_reminder' : 'create_todo', + }); + } + return formatDraftCommittedReply({ title: pending.title, kind: pending.draftType }); } - const draftableKinds = new Set([ - 'timed_reminder', - 'create_todo', - 'create_daily_todo_digest', - 'create_balance_alert', - 'scheduled_task', - ]); - if (!draftableKinds.has(kind)) return null; - if (classification.clarify?.length) { - return buildCard(classification, {}, inferActionLevel(classification, text), text); - } - - const actionLevel = inferActionLevel(classification, text); - const payload = buildDraftPayload(classification, { intent, user, timezone }); - const cardText = buildCard(classification, payload, actionLevel, text); - await intentDraftService.createDraft({ - userId: user.userId, - layer, - draftType: kind, - actionLevel, - title: payload.title ?? payload.createPayload?.title ?? classification.detail?.title ?? '待确认任务', - payload, - cardText, - sourceMessageId: intent.msgId ?? null, - sourceText: text, - }); - return cardText; + return null; } catch (err) { logger.warn?.( '[wechat-intent-transaction] failed:', diff --git a/wechat/handlers/intent-transaction.test.mjs b/wechat/handlers/intent-transaction.test.mjs index b085b84..16c4964 100644 --- a/wechat/handlers/intent-transaction.test.mjs +++ b/wechat/handlers/intent-transaction.test.mjs @@ -2,8 +2,8 @@ import assert from 'node:assert/strict'; import test from 'node:test'; import { handleWechatIntentTransaction } from './intent-transaction.mjs'; -function createDraftStore() { - let pending = null; +function createDraftStore(initial = null) { + let pending = initial; return { async getPendingDraft(userId) { return pending?.userId === userId ? pending : null; @@ -43,9 +43,6 @@ function createScheduleService() { async createReminder(payload) { return { id: 'reminder-1', ...payload }; }, - buildTodoDigestText() { - return '今天有 1 条待办。'; - }, }; } @@ -62,7 +59,7 @@ test('intent transaction returns null when feature disabled', async () => { assert.equal(reply, null); }); -test('intent transaction creates action card for simple timed reminder', async () => { +test('intent transaction passes through new messages to goose', async () => { const drafts = createDraftStore(); const reply = await handleWechatIntentTransaction({ intent: { agentText: '下午2点半提醒我开项目计划例会', msgId: 'm2' }, @@ -71,14 +68,41 @@ test('intent transaction creates action card for simple timed reminder', async ( scheduleService: createScheduleService(), env: enabledEnv, }); - assert.match(reply, /我准备执行/); - assert.match(reply, /确认/); - const pending = await drafts.getPendingDraft('user-1'); - assert.equal(pending.draftType, 'timed_reminder'); + assert.equal(reply, null); + assert.equal(await drafts.getPendingDraft('user-1'), null); }); -test('intent transaction commits draft after user confirms', async () => { - const drafts = createDraftStore(); +test('intent transaction passes through page generation with auto refresh wording', async () => { + const reply = await handleWechatIntentTransaction({ + intent: { + agentText: '我想帮我做一个页面,页面上每天自动更新天气', + msgId: 'm-page', + }, + user: { userId: 'user-1' }, + intentDraftService: createDraftStore(), + scheduleService: createScheduleService(), + env: enabledEnv, + }); + assert.equal(reply, null); +}); + +test('intent transaction commits pending draft after user confirms', async () => { + const drafts = createDraftStore({ + id: 'draft-1', + userId: 'user-1', + layer: 'L1', + draftType: 'timed_reminder', + title: '项目计划例会', + status: 'draft', + payload: { + title: '项目计划例会', + remindLocal: '2026-08-28 14:30', + hour: 14, + minute: 30, + recurrence: 'once', + sourceChannel: 'agent', + }, + }); const scheduleService = createScheduleService(); const calls = []; scheduleService.createItem = async (payload) => { @@ -90,13 +114,6 @@ test('intent transaction commits draft after user confirms', async () => { return { id: 'reminder-1', ...payload }; }; - await handleWechatIntentTransaction({ - intent: { agentText: '下午2点半提醒我开项目计划例会', msgId: 'm3' }, - user: { userId: 'user-1' }, - intentDraftService: drafts, - scheduleService, - env: enabledEnv, - }); const reply = await handleWechatIntentTransaction({ intent: { agentText: '确认', msgId: 'm4' }, user: { userId: 'user-1' }, @@ -108,28 +125,26 @@ test('intent transaction commits draft after user confirms', async () => { assert.equal(calls.length, 2); }); -test('intent transaction asks for slot fill without creating draft', async () => { - const drafts = createDraftStore(); - const reply = await handleWechatIntentTransaction({ - intent: { agentText: '设置提醒', msgId: 'm6' }, - user: { userId: 'user-2' }, - intentDraftService: drafts, - scheduleService: createScheduleService(), - env: enabledEnv, +test('intent transaction cancels pending draft', async () => { + const drafts = createDraftStore({ + id: 'draft-1', + userId: 'user-1', + layer: 'L2', + draftType: 'agent_tool', + title: '定时任务', + status: 'draft', + payload: { + toolName: 'scheduled_task_create', + toolArgs: { taskSpec: '做新闻页面', hour: 6 }, + }, }); - assert.match(reply, /补充/); - assert.equal(await drafts.getPendingDraft('user-2'), null); -}); - -test('intent transaction answers query guard without creating draft', async () => { - const drafts = createDraftStore(); const reply = await handleWechatIntentTransaction({ - intent: { agentText: '看看我的待办', msgId: 'm5' }, + intent: { agentText: '取消', msgId: 'm5' }, user: { userId: 'user-1' }, intentDraftService: drafts, scheduleService: createScheduleService(), env: enabledEnv, }); - assert.match(reply, /待办/); + assert.match(reply, /已取消/); assert.equal(await drafts.getPendingDraft('user-1'), null); }); diff --git a/wechat/handlers/scheduled-task.mjs b/wechat/handlers/scheduled-task.mjs index 0def727..2cab61c 100644 --- a/wechat/handlers/scheduled-task.mjs +++ b/wechat/handlers/scheduled-task.mjs @@ -1,15 +1,8 @@ import { - buildScheduledTaskCreatePayload, - formatScheduledTaskClarification, - formatScheduledTaskCreateReply, formatScheduledTaskListReply, isScheduledTaskIntent, parseScheduledTaskIntent, } from '../../scheduled-task-intent.mjs'; -import { - isScheduledTaskWorkerEnabled, - scheduledTaskWorkerDisabledMessage, -} from '../../scheduled-task-worker-config.mjs'; const WEEKDAY_NAMES = ['周日', '周一', '周二', '周三', '周四', '周五', '周六']; @@ -53,28 +46,7 @@ export async function handleWechatScheduledTaskIntent({ } if (taskIntent.action === 'create_scheduled_task') { - if (taskIntent.needsClarification?.length) { - return formatScheduledTaskClarification(taskIntent); - } - const payload = buildScheduledTaskCreatePayload(taskIntent, { - userId: user.userId, - sourceChannel: 'wechat', - sourceMessageId: intent.msgId || null, - sourceText: intent.agentText, - timezone, - }); - const task = await scheduledTaskService.createTask(payload); - const workerWarning = isScheduledTaskWorkerEnabled(env) - ? null - : scheduledTaskWorkerDisabledMessage(env); - let reply = formatScheduledTaskCreateReply(task, { workerWarning }); - if (task.recurrence === 'weekly' && task.weekday != null) { - reply = reply.replace( - /执行时间:/, - `执行时间:${WEEKDAY_NAMES[Number(task.weekday)] ?? ''} `, - ); - } - return reply; + return null; } } catch (err) { logger.warn?.( diff --git a/wechat/handlers/scheduled-task.test.mjs b/wechat/handlers/scheduled-task.test.mjs index 0233778..548486c 100644 --- a/wechat/handlers/scheduled-task.test.mjs +++ b/wechat/handlers/scheduled-task.test.mjs @@ -2,8 +2,7 @@ import assert from 'node:assert/strict'; import test from 'node:test'; import { handleWechatScheduledTaskIntent } from './scheduled-task.mjs'; -test('handleWechatScheduledTaskIntent creates daily automation task', async () => { - const created = []; +test('handleWechatScheduledTaskIntent passes create requests to goose', async () => { const reply = await handleWechatScheduledTaskIntent({ intent: { msgType: 'text', @@ -12,45 +11,15 @@ test('handleWechatScheduledTaskIntent creates daily automation task', async () = }, user: { userId: 'user-1' }, scheduledTaskService: { - async createTask(input) { - created.push(input); - return { - ...input, - id: 'task-1', - nextRunAt: Date.now() + 3600_000, - }; + async createTask() { + throw new Error('should not create from inbound regex'); }, }, - env: { H5_SCHEDULED_TASK_WORKER_ENABLED: '1' }, }); - - assert.equal(created.length, 1); - assert.equal(created[0].hour, 6); - assert.match(created[0].taskSpec, /今日新闻页面/u); - assert.match(reply, /已设置每天定时任务/u); + assert.equal(reply, null); }); -test('handleWechatScheduledTaskIntent warns when worker disabled', async () => { - const reply = await handleWechatScheduledTaskIntent({ - intent: { - msgType: 'text', - agentText: '每天6点帮我做今日新闻页面', - msgId: 'msg-1', - }, - user: { userId: 'user-1' }, - scheduledTaskService: { - async createTask(input) { - return { ...input, id: 'task-1', nextRunAt: Date.now() + 3600_000 }; - }, - }, - env: { H5_SCHEDULED_TASK_WORKER_ENABLED: '0', H5_REMINDER_WORKER_ENABLED: '0' }, - }); - - assert.match(reply, /⚠️/u); - assert.match(reply, /未开启定时自动执行 Worker/u); -}); - -test('handleWechatScheduledTaskIntent returns clarification for incomplete request', async () => { +test('handleWechatScheduledTaskIntent passes incomplete create requests to goose', async () => { const reply = await handleWechatScheduledTaskIntent({ intent: { msgType: 'text', @@ -60,12 +29,11 @@ test('handleWechatScheduledTaskIntent returns clarification for incomplete reque user: { userId: 'user-1' }, scheduledTaskService: { async createTask() { - throw new Error('should not create'); + throw new Error('should not create from inbound regex'); }, }, }); - - assert.match(reply, /执行时间和任务内容/u); + assert.equal(reply, null); }); test('handleWechatScheduledTaskIntent ignores non automation text', async () => { @@ -84,3 +52,20 @@ test('handleWechatScheduledTaskIntent ignores non automation text', async () => }); assert.equal(reply, null); }); + +test('handleWechatScheduledTaskIntent still lists tasks', async () => { + const reply = await handleWechatScheduledTaskIntent({ + intent: { + msgType: 'text', + agentText: '看看我的定时自动任务', + msgId: 'msg-1', + }, + user: { userId: 'user-1' }, + scheduledTaskService: { + async listTasks() { + return [{ title: '新闻页面', recurrence: 'daily', hour: 6, minute: 0, timezone: 'Asia/Shanghai' }]; + }, + }, + }); + assert.match(reply, /新闻页面/u); +}); diff --git a/wechat/intent/patterns.mjs b/wechat/intent/patterns.mjs index df5952d..a5ef51d 100644 --- a/wechat/intent/patterns.mjs +++ b/wechat/intent/patterns.mjs @@ -4,7 +4,6 @@ import { isWechatPageEditText, isWechatPageRetryText, } from './page-continuation.mjs'; -import { shouldUseScheduledTaskAutomation } from '../../scheduled-task-intent.mjs'; export const PAGE_GENERATE_PATTERN = /(?:生成|创建|做|写|帮我.*(?:生成|创建|做|写)).*(?:html|页面|网页|page|文件)/iu; @@ -39,7 +38,6 @@ export const CONNECTIVITY_TEST_PATTERN = /^(测试\s*\d*|test\s*\d*)[!!。.\s] export function isPageGenerateText(text) { const normalized = String(text ?? '').trim(); if (!normalized) return false; - if (shouldUseScheduledTaskAutomation(normalized)) return false; if (PAGE_GENERATE_NEGATION_PATTERN.test(normalized)) return false; return ( PAGE_GENERATE_PATTERN.test(normalized)