fix(wechat): route scheduled task cancel and spec updates through Goose MCP

Pass cancel requests to Goose like create instead of inbound title matching,
and add scheduled_task_update_spec so format or execution constraints update
taskSpec without triggering page delivery guards.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
john
2026-08-29 09:47:39 +08:00
parent e13df82021
commit a1a921eba6
12 changed files with 265 additions and 36 deletions
+18
View File
@@ -33,6 +33,20 @@ export async function executeScheduledTaskCreateTool(args, {
return workerWarning ? { ...task, workerWarning } : task;
}
export async function executeScheduledTaskUpdateSpecTool(args, {
userId,
scheduledTaskService,
} = {}) {
if (!scheduledTaskService) throw new Error('scheduledTaskService 不可用');
return scheduledTaskService.updateTaskSpec({
userId,
taskId: args.taskId,
taskSpec: args.taskSpec ?? null,
title: args.title ?? null,
mergeTaskSpec: Boolean(args.mergeTaskSpec),
});
}
export async function executeScheduleCreateItemTool(args, {
userId,
scheduleService,
@@ -134,6 +148,10 @@ export async function commitAgentSideEffectTool(toolName, toolArgs, services) {
const task = await executeScheduledTaskCreateTool(toolArgs, services);
return { kind: 'scheduled_task', task };
}
if (name === 'scheduled_task_update_spec') {
const task = await executeScheduledTaskUpdateSpecTool(toolArgs, services);
return { kind: 'scheduled_task', task, updated: true };
}
if (name === 'schedule_create_item') {
const result = await executeScheduleCreateItemTool(toolArgs, services);
return { kind: 'schedule_item', ...result };
+43
View File
@@ -0,0 +1,43 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
commitAgentSideEffectTool,
executeScheduledTaskUpdateSpecTool,
} from './agent-side-effect-tools.mjs';
test('executeScheduledTaskUpdateSpecTool delegates to scheduledTaskService.updateTaskSpec', async () => {
const calls = [];
const task = await executeScheduledTaskUpdateSpecTool({
taskId: 'task-1',
taskSpec: '新规范',
mergeTaskSpec: true,
}, {
userId: 'user-1',
scheduledTaskService: {
async updateTaskSpec(payload) {
calls.push(payload);
return { id: 'task-1', taskSpec: 'merged spec', title: '新闻' };
},
},
});
assert.equal(calls[0].taskId, 'task-1');
assert.equal(calls[0].mergeTaskSpec, true);
assert.equal(task.title, '新闻');
});
test('commitAgentSideEffectTool supports scheduled_task_update_spec', async () => {
const committed = await commitAgentSideEffectTool('scheduled_task_update_spec', {
taskId: 'task-1',
taskSpec: '标准格式说明',
mergeTaskSpec: true,
}, {
userId: 'user-1',
scheduledTaskService: {
async updateTaskSpec() {
return { id: 'task-1', taskSpec: '标准格式说明', status: 'active' };
},
},
});
assert.equal(committed.kind, 'scheduled_task');
assert.equal(committed.updated, true);
});
+1
View File
@@ -469,6 +469,7 @@ export function sandboxMcpTools(capabilities) {
'scheduled_task_create',
'scheduled_task_list',
'scheduled_task_cancel',
'scheduled_task_update_spec',
);
}
return tools;
+3
View File
@@ -398,6 +398,7 @@ test('sandboxMcpTools returns correct tool list based on capabilities', () => {
'scheduled_task_create',
'scheduled_task_list',
'scheduled_task_cancel',
'scheduled_task_update_spec',
]);
const withBrowse = { ...base, code_browse: true };
@@ -424,6 +425,7 @@ test('private_data_space alone exposes private data tools through sandbox MCP',
'scheduled_task_create',
'scheduled_task_list',
'scheduled_task_cancel',
'scheduled_task_update_spec',
]);
const policy = buildAgentExtensionPolicy(caps, {
@@ -458,6 +460,7 @@ test('private_data_space alone exposes private data tools through sandbox MCP',
'scheduled_task_create',
'scheduled_task_list',
'scheduled_task_cancel',
'scheduled_task_update_spec',
]);
});
+38 -3
View File
@@ -21,6 +21,7 @@ import {
executeScheduleCreateItemTool,
executeScheduleCreateReminderTool,
executeScheduledTaskCreateTool,
executeScheduledTaskUpdateSpecTool,
} from './agent-side-effect-tools.mjs';
import { createIntentDraftService } from './intent-draft-service.mjs';
import { requireUserConfirmForSideEffectTool } from './intent-tool-confirm-gate.mjs';
@@ -681,15 +682,42 @@ if (isScheduleConfigured()) {
},
{
name: 'scheduled_task_cancel',
description: '取消当前用户的一个定时自动任务。需提供 taskId 或 titleMatch。',
description:
'取消当前用户的一个定时自动任务。优先传 taskId(先调用 scheduled_task_list 拿到 id 后再取消)。'
+ '只有在列表里已明确对应条目、且 taskId 已知时才能取消;不要猜测 titleMatch。',
inputSchema: {
type: 'object',
properties: {
taskId: { type: 'string', description: '任务 ID,优先使用' },
titleMatch: { type: 'string', description: '按标题模糊匹配最近一条 active 任务' },
taskId: { type: 'string', description: '任务 ID(来自 scheduled_task_list,优先使用' },
titleMatch: {
type: 'string',
description: 'Deprecated fallback:仅当 taskId 不可用且标题关键词来自 list 结果时使用',
},
},
},
},
{
name: 'scheduled_task_update_spec',
description:
'更新已有定时自动任务的 taskSpec(执行说明)或标题。'
+ '用于用户补充格式标准、参考页面、长期执行约束等;先 scheduled_task_list 拿 taskId。',
inputSchema: {
type: 'object',
properties: {
taskId: { type: 'string', description: '任务 ID(来自 scheduled_task_list,必填)' },
taskSpec: {
type: 'string',
description: '新的完整执行说明;与 mergeTaskSpec 二选一',
},
title: { type: 'string', description: '可选:更新任务标题' },
mergeTaskSpec: {
type: 'boolean',
description: '为 true 时将 taskSpec 追加到现有说明末尾(适合补充格式/规范)',
},
},
required: ['taskId'],
},
},
);
}
@@ -1234,6 +1262,13 @@ async function callTool(name, args) {
});
return [{ type: 'text', text: JSON.stringify(task, null, 2) }];
}
case 'scheduled_task_update_spec': {
const task = await executeScheduledTaskUpdateSpecTool(args, {
userId: PRIVATE_DATA_USER_ID,
scheduledTaskService: getScheduledTaskService(),
});
return [{ type: 'text', text: JSON.stringify(task, null, 2) }];
}
default:
throw new Error(`未知工具:${name}`);
}
+46
View File
@@ -218,6 +218,51 @@ export function createScheduledTaskService(pool, { defaultTimezone = DEFAULT_TIM
return rows.map(rowToTask);
};
const updateTaskSpec = async ({
userId,
taskId,
taskSpec = null,
title = null,
mergeTaskSpec = false,
} = {}) => {
if (!userId) throw new Error('缺少用户');
const safeTaskId = String(taskId ?? '').trim();
if (!safeTaskId) throw new Error('缺少 taskId');
const safeTaskSpec = taskSpec == null ? null : String(taskSpec).trim();
const safeTitle = title == null ? null : String(title).trim();
if (!safeTaskSpec && !safeTitle) throw new Error('缺少 taskSpec 或 title');
const [rows] = await pool.query(
`SELECT *
FROM h5_scheduled_tasks
WHERE id = ? AND user_id = ? AND status IN ('active', 'locked', 'failed')
LIMIT 1`,
[safeTaskId, userId],
);
const existing = rows[0];
if (!existing) throw new Error('未找到可更新的定时任务');
let nextSpec = existing.task_spec;
if (safeTaskSpec) {
nextSpec = mergeTaskSpec
? [String(existing.task_spec ?? '').trim(), safeTaskSpec].filter(Boolean).join('\n\n')
: safeTaskSpec;
}
const nextTitle = safeTitle || existing.title;
const ts = clock.now();
await pool.query(
`UPDATE h5_scheduled_tasks
SET task_spec = ?, title = ?, updated_at = ?
WHERE id = ?`,
[nextSpec, nextTitle, ts, safeTaskId],
);
const [updated] = await pool.query(
`SELECT * FROM h5_scheduled_tasks WHERE id = ? LIMIT 1`,
[safeTaskId],
);
return rowToTask(updated[0]);
};
const cancelTask = async ({
userId,
taskId = null,
@@ -385,6 +430,7 @@ export function createScheduledTaskService(pool, { defaultTimezone = DEFAULT_TIM
return {
createTask,
listTasks,
updateTaskSpec,
cancelTask,
listDueTasks,
lockTask,
+64
View File
@@ -275,3 +275,67 @@ test('markTaskSucceeded schedules next run for daily task', async () => {
assert.ok(result.nextRunAt > Date.parse('2026-07-31T02:00:00+08:00'));
assert.equal(updates.length, 1);
});
test('updateTaskSpec replaces or merges taskSpec', async () => {
const activeTask = {
id: 'task-news',
user_id: 'user-1',
title: '每日新闻早报页面(5:30',
task_spec: '按旧格式生成新闻页',
recurrence: 'daily',
hour: 5,
minute: 30,
weekday: null,
timezone: 'Asia/Shanghai',
next_run_at: 1780000000000,
last_run_at: null,
notify_channel: 'both',
status: 'active',
attempts: 0,
last_error: null,
last_result_json: null,
source_channel: 'agent',
source_session_id: null,
source_message_id: null,
source_text: null,
created_at: 1780000000000,
updated_at: 1780000000000,
};
let stored = { ...activeTask };
const service = createScheduledTaskService({
async query(sql, params) {
if (sql.includes('FROM h5_scheduled_tasks') && sql.includes('status IN')) {
return [[stored]];
}
if (sql.includes('UPDATE h5_scheduled_tasks') && sql.includes('task_spec')) {
stored = {
...stored,
task_spec: params[0],
title: params[1],
updated_at: params[2],
};
return [{ affectedRows: 1 }];
}
if (sql.includes('SELECT * FROM h5_scheduled_tasks WHERE id = ? LIMIT 1')) {
return [[stored]];
}
return [[]];
},
});
const merged = await service.updateTaskSpec({
userId: 'user-1',
taskId: 'task-news',
taskSpec: '参照 daily-news-0829.html 作为标准格式',
mergeTaskSpec: true,
});
assert.match(merged.taskSpec, /旧格式/u);
assert.match(merged.taskSpec, /daily-news-0829/u);
const replaced = await service.updateTaskSpec({
userId: 'user-1',
taskId: 'task-news',
taskSpec: '全新执行说明',
});
assert.equal(replaced.taskSpec, '全新执行说明');
});
+15 -11
View File
@@ -10,21 +10,23 @@ description: 处理定时自动任务(Scheduled Automation):澄清执行
## 适用范围
- 创建一次性或循环的定时自动任务(Scheduled Task / Recurring Automation
- 查询、取消当前用户的定时任务
- 查询、取消、**更新执行说明(taskSpec** 当前用户的定时任务
- 澄清缺失的执行时间或任务内容
## 边界
1. **不**处理纯提醒、待办记录、日程查询 —— 那些交给 `schedule-assistant`
2. **不**在当前对话里立即执行长任务;本 skill 只负责**写入定时订阅**
2. **不**在当前对话里立即执行长任务;本 skill 只负责**写入/更新定时订阅**
3. 只能操作当前用户的数据;工具成功返回前,不能说「已经设置好了」。
4. 信息不完整时先追问,禁止臆造时间或任务内容。
5. 用户是在**补充已有任务的长期规范/格式/参考页**(如「以后新闻按 8/29 格式」)时,用 `scheduled_task_update_spec`,**不要**去生成 HTML 页面。
## 可用工具
- `scheduled_task_create`
- `scheduled_task_create`(创建,需用户确认卡片)
- `scheduled_task_list`
- `scheduled_task_cancel`
- `scheduled_task_update_spec`(更新 taskSpec / 标题,直接写入)
## 必填信息(创建前必须齐全)
@@ -43,15 +45,12 @@ description: 处理定时自动任务(Scheduled Automation):澄清执行
## 工作规则
1. 先判断用户是要**创建**、**查询**还是**取消**定时自动任务
1. 先判断用户是要**创建**、**查询**、**取消**还是**更新规范**
2. 缺时间或缺任务内容时,一次只追问最小必要信息。
3. 创建成功后,用用户能理解的话说明:
- 何时执行
- 执行什么
- 是一次性还是循环
- 结果会通过哪个通道推送(默认微信 + 站内)
4. 取消时优先用 `taskId`;用户只说任务名时可传 `titleMatch`
5. 若提示里给出了 `sourceMessageId`,调用 `scheduled_task_create` 时必须原样传入。
3. **取消 / 更新**前必须先 `scheduled_task_list`,用返回的 `taskId` 操作;由你理解用户指哪一条,不要猜 titleMatch。
4. **更新规范**:用户补充格式标准、参考页面 URL、排版约束时,对目标任务调用 `scheduled_task_update_spec``mergeTaskSpec: true` 追加说明。
5. 创建成功后,用用户能理解的话说明何时执行、执行什么、recurrence、推送通道。
6. 若提示里给出了 `sourceMessageId`,调用 `scheduled_task_create` 时必须原样传入。
## 回复要求
@@ -65,3 +64,8 @@ description: 处理定时自动任务(Scheduled Automation):澄清执行
助手:(信息齐全后调用 `scheduled_task_create`
已设置循环任务:每天 06:00(北京时间)自动搜索并生成「今日新闻页」,完成后推送链接。
用户:以后新闻早报都按 8 月 29 号那版格式执行
助手:(`scheduled_task_list` → 对 5:30 新闻任务 `scheduled_task_update_spec` + mergeTaskSpec
已更新定时任务的执行说明,之后到点会按新格式生成。
+4 -17
View File
@@ -4,8 +4,6 @@ import {
parseScheduledTaskIntent,
} from '../../scheduled-task-intent.mjs';
const WEEKDAY_NAMES = ['周日', '周一', '周二', '周三', '周四', '周五', '周六'];
export async function handleWechatScheduledTaskIntent({
intent,
user,
@@ -31,21 +29,10 @@ export async function handleWechatScheduledTaskIntent({
return formatScheduledTaskListReply(tasks);
}
if (taskIntent.action === 'cancel_scheduled_task') {
const titleMatch = String(intent.agentText ?? '')
.replace(/(?:取消|停止|关闭|删除|我的|定时|自动|任务)/gu, ' ')
.trim();
if (!titleMatch) {
return '请告诉我要取消哪一条定时任务,或提供任务名称关键词。';
}
const task = await scheduledTaskService.cancelTask({
userId: user.userId,
titleMatch,
});
return `已取消定时任务:${task.title}`;
}
if (taskIntent.action === 'create_scheduled_task') {
if (
taskIntent.action === 'create_scheduled_task'
|| taskIntent.action === 'cancel_scheduled_task'
) {
return null;
}
} catch (err) {
+17
View File
@@ -36,6 +36,23 @@ test('handleWechatScheduledTaskIntent passes incomplete create requests to goose
assert.equal(reply, null);
});
test('handleWechatScheduledTaskIntent passes cancel requests to goose', async () => {
const reply = await handleWechatScheduledTaskIntent({
intent: {
msgType: 'text',
agentText: '取消定时推送苏州天气任务',
msgId: 'msg-123-1',
},
user: { userId: 'user-123' },
scheduledTaskService: {
async cancelTask() {
throw new Error('should not cancel from inbound regex');
},
},
});
assert.equal(reply, null);
});
test('handleWechatScheduledTaskIntent ignores non automation text', async () => {
const reply = await handleWechatScheduledTaskIntent({
intent: {
+15 -5
View File
@@ -4,7 +4,11 @@ import {
isPageDataIntent,
} from '../../chat-skills.mjs';
import { shouldUseScheduleAssistant } from '../../schedule-intent.mjs';
import { shouldUseScheduledTaskAutomation } from '../../scheduled-task-intent.mjs';
import {
isScheduledTaskIntent,
parseScheduledTaskIntent,
shouldUseScheduledTaskAutomation,
} from '../../scheduled-task-intent.mjs';
import { buildCurrentTimeAgentPrefix } from '../../user-memory-profile.mjs';
import { isPageGenerateText, wantsDocxDownload } from '../intent/patterns.mjs';
import { buildWechatStandaloneImageInstruction } from '../image-generation-policy.mjs';
@@ -82,12 +86,18 @@ export function buildWechatAgentPrompt(intent, {
'',
].filter(Boolean).join('\n')
: '';
const scheduledTaskAutomationHint = shouldUseScheduledTaskAutomation(agentText)
const scheduledTaskIntent = parseScheduledTaskIntent(agentText);
const scheduledTaskAutomationHint = (
shouldUseScheduledTaskAutomation(agentText)
|| isScheduledTaskIntent(scheduledTaskIntent)
)
? [
'【定时自动任务要求】这条消息 Scheduled Automation(到点自动执行并推送结果),不是待办提醒。',
'【定时自动任务要求】这条消息涉及 Scheduled Automation(到点自动执行并推送结果),不是待办提醒。',
'开始前先加载 `scheduled-task-automation` skill,并严格按 skill 边界执行。',
'澄清缺失的执行时间或 taskSpec,再调用 scheduled_task_create / scheduled_task_list / scheduled_task_cancel。',
'禁止在当前对话立即执行长任务;工具成功返回后才能确认已设置。',
'创建:澄清缺失的执行时间或 taskSpec,再调用 scheduled_task_create(需用户确认卡片)。',
'取消/更新规范:必须先 scheduled_task_list 拿 taskId;取消用 scheduled_task_cancel,补充格式/长期约束用 scheduled_task_update_spec(可 mergeTaskSpec。',
'若用户只是在补充已有定时任务的执行规范(不是要做新页面),禁止走 static-page-publish 页面交付链路。',
'禁止在当前对话立即执行长任务;工具成功返回后才能确认已设置/已取消。',
'一次性任务用 runAtLocal;循环任务用 hour/minute/weekday,禁止自行估算 Unix 毫秒。',
intent?.msgId ? `调用 scheduled_task_create 时必须传入 sourceMessageId: ${intent.msgId}` : '',
'',
+1
View File
@@ -66,6 +66,7 @@ export function buildPageGenerateAgentPrompt(
return [
'【微信服务号 · 页面生成任务】',
'这是服务号专用页面生成,不是普通聊天。必须按步骤完成,未完成前禁止告诉用户“已生成/已发布”。',
'【定时任务规范】若用户是在为已有定时自动任务补充长期格式/参考页/执行约束(不是要你此刻写 HTML),改加载 scheduled-task-automation,用 scheduled_task_list + scheduled_task_update_spec;禁止走本页面交付链路。',
'',
docxBlock,
imageBlock,