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:
@@ -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 };
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -469,6 +469,7 @@ export function sandboxMcpTools(capabilities) {
|
||||
'scheduled_task_create',
|
||||
'scheduled_task_list',
|
||||
'scheduled_task_cancel',
|
||||
'scheduled_task_update_spec',
|
||||
);
|
||||
}
|
||||
return tools;
|
||||
|
||||
@@ -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',
|
||||
]);
|
||||
});
|
||||
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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, '全新执行说明');
|
||||
});
|
||||
|
||||
@@ -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,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) {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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}` : '',
|
||||
'',
|
||||
|
||||
@@ -66,6 +66,7 @@ export function buildPageGenerateAgentPrompt(
|
||||
return [
|
||||
'【微信服务号 · 页面生成任务】',
|
||||
'这是服务号专用页面生成,不是普通聊天。必须按步骤完成,未完成前禁止告诉用户“已生成/已发布”。',
|
||||
'【定时任务规范】若用户是在为已有定时自动任务补充长期格式/参考页/执行约束(不是要你此刻写 HTML),改加载 scheduled-task-automation,用 scheduled_task_list + scheduled_task_update_spec;禁止走本页面交付链路。',
|
||||
'',
|
||||
docxBlock,
|
||||
imageBlock,
|
||||
|
||||
Reference in New Issue
Block a user