feat: overhaul task board and cross-conversation workflows
This commit is contained in:
@@ -11,6 +11,24 @@ const SERVER_INFO = {
|
||||
version: '1.0.0',
|
||||
};
|
||||
|
||||
const CCWEB_REPLY_MODES = Object.freeze({
|
||||
ONE_WAY: 'one_way',
|
||||
RETURN_AND_CONTINUE: 'return_and_continue',
|
||||
});
|
||||
const CCWEB_SEND_MESSAGE_DESCRIPTION = '向指定 ccweb 对话发送一条消息,并以“来自某对话”的气泡在目标对话中展示。必须填写 replyMode:仅当来源不需要目标结果时使用 one_way;涉及分析、实现、测试、验收、完成后汇报或来源后续依赖目标结果时,必须使用 return_and_continue。工具调用会立即返回,不阻塞也不等待目标对话完成。';
|
||||
const CODEX_APP_COMMUNICATION_TOOL_NAMES = new Set([
|
||||
'ccweb_list_conversations',
|
||||
'ccweb_set_title',
|
||||
'ccweb_create_conversation',
|
||||
'ccweb_send_message',
|
||||
'ccweb_list_pending_replies',
|
||||
'ccweb_get_pending_reply',
|
||||
]);
|
||||
const HIDDEN_CALLABLE_TOOL_NAMES = new Set([
|
||||
'ccweb_request_reply',
|
||||
'ccweb_task_update',
|
||||
]);
|
||||
|
||||
const TOOLS = [
|
||||
{
|
||||
name: 'ccweb_display_image',
|
||||
@@ -28,10 +46,15 @@ const TOOLS = [
|
||||
},
|
||||
{
|
||||
name: 'ccweb_list_conversations',
|
||||
description: '列出当前 ccweb 中可投递消息的对话。只返回 ID、标题、Agent、运行状态和更新时间,不返回对话正文。',
|
||||
description: '列出当前 ccweb 中可投递消息的对话。默认返回全部对话;scope=children 时只返回当前来源对话通过 ccweb_create_conversation 直接创建的持久子对话。只返回 ID、标题、Agent、运行状态和更新时间,不返回对话正文。',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
scope: {
|
||||
type: 'string',
|
||||
enum: ['all', 'children'],
|
||||
description: '可选。all 返回全部对话;children 只返回当前来源对话直接创建的持久子对话,默认 all。',
|
||||
},
|
||||
agent: {
|
||||
type: 'string',
|
||||
enum: ['claude', 'codex', 'codexapp'],
|
||||
@@ -102,7 +125,7 @@ const TOOLS = [
|
||||
},
|
||||
{
|
||||
name: 'ccweb_send_message',
|
||||
description: '向指定 ccweb 对话发送一条消息,并以“来自某对话”的气泡在目标对话中展示。',
|
||||
description: CCWEB_SEND_MESSAGE_DESCRIPTION,
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
@@ -114,8 +137,13 @@ const TOOLS = [
|
||||
type: 'string',
|
||||
description: '要发送到目标对话的纯文本消息。',
|
||||
},
|
||||
replyMode: {
|
||||
type: 'string',
|
||||
enum: Object.values(CCWEB_REPLY_MODES),
|
||||
description: '回传模式。one_way 表示单向投递且目标输出只留在目标对话;return_and_continue 表示目标完成后自动回传结果并触发来源继续运行。',
|
||||
},
|
||||
},
|
||||
required: ['targetConversationId', 'content'],
|
||||
required: ['targetConversationId', 'content', 'replyMode'],
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
@@ -149,25 +177,6 @@ const TOOLS = [
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'ccweb_request_reply',
|
||||
description: '向指定 ccweb 对话发送一条消息,并在目标对话本轮输出完成后把回复写回当前对话,然后继续触发当前对话运行。',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
targetConversationId: {
|
||||
type: 'string',
|
||||
description: '目标对话 ID。',
|
||||
},
|
||||
content: {
|
||||
type: 'string',
|
||||
description: '要发送到目标对话的纯文本消息。',
|
||||
},
|
||||
},
|
||||
required: ['targetConversationId', 'content'],
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'ccweb_prompt_user',
|
||||
description: '在当前来源 ccweb 对话前台渲染一个多问题表单。工具会立即返回,不等待用户;用户提交后,ccweb 会把问题、选择和答案作为一条普通用户消息发回当前对话。',
|
||||
@@ -278,6 +287,17 @@ function imageMimeFromPath(filePath) {
|
||||
return ({ '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.webp': 'image/webp', '.gif': 'image/gif' })[ext] || 'application/octet-stream';
|
||||
}
|
||||
|
||||
function isCallableToolName(name) {
|
||||
const toolName = String(name || '');
|
||||
return TOOLS.some((tool) => tool.name === toolName) || HIDDEN_CALLABLE_TOOL_NAMES.has(toolName);
|
||||
}
|
||||
|
||||
function codexAppCommunicationTools() {
|
||||
return TOOLS
|
||||
.filter((tool) => CODEX_APP_COMMUNICATION_TOOL_NAMES.has(tool.name))
|
||||
.map((tool) => ({ ...tool, namespace: 'ccweb' }));
|
||||
}
|
||||
|
||||
function prepareImagePayload(args = {}) {
|
||||
const source = String(args.source || '').trim();
|
||||
if (!source) return { ok: false, code: 'missing_source', message: 'source 不能为空。' };
|
||||
@@ -415,6 +435,12 @@ function toolResponse(payload) {
|
||||
};
|
||||
}
|
||||
|
||||
async function listToolsForCurrentSource() {
|
||||
const payload = await callCcweb('ccweb_internal_tools_list', {});
|
||||
if (!payload?.ok || !Array.isArray(payload.tools)) return TOOLS;
|
||||
return [...TOOLS, ...payload.tools];
|
||||
}
|
||||
|
||||
async function handleRequest(message) {
|
||||
const { id, method } = message;
|
||||
const hasId = Object.prototype.hasOwnProperty.call(message, 'id');
|
||||
@@ -435,12 +461,12 @@ async function handleRequest(message) {
|
||||
jsonRpcResult(id, {});
|
||||
break;
|
||||
case 'tools/list':
|
||||
jsonRpcResult(id, { tools: TOOLS });
|
||||
jsonRpcResult(id, { tools: await listToolsForCurrentSource() });
|
||||
break;
|
||||
case 'tools/call': {
|
||||
const name = String(message.params?.name || '');
|
||||
const args = message.params?.arguments || {};
|
||||
if (!TOOLS.some((tool) => tool.name === name)) {
|
||||
if (!isCallableToolName(name)) {
|
||||
jsonRpcResult(id, toolResponse({
|
||||
ok: false,
|
||||
code: 'unknown_tool',
|
||||
@@ -493,7 +519,14 @@ function runStdioServer() {
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { TOOLS, prepareImagePayload, runStdioServer };
|
||||
module.exports = {
|
||||
TOOLS,
|
||||
CCWEB_REPLY_MODES,
|
||||
codexAppCommunicationTools,
|
||||
isCallableToolName,
|
||||
prepareImagePayload,
|
||||
runStdioServer,
|
||||
};
|
||||
|
||||
if (require.main === module) {
|
||||
runStdioServer();
|
||||
|
||||
256
lib/task-board-lifecycle.js
Normal file
256
lib/task-board-lifecycle.js
Normal file
@@ -0,0 +1,256 @@
|
||||
'use strict';
|
||||
|
||||
const LIFECYCLE_EVENT_TYPES = Object.freeze({
|
||||
USER_MESSAGE_RECEIVED: 'user_message_received',
|
||||
TURN_STARTED: 'turn_started',
|
||||
USER_INPUT_REQUESTED: 'user_input_requested',
|
||||
TURN_COMPLETED: 'turn_completed',
|
||||
TURN_FAILED: 'turn_failed',
|
||||
});
|
||||
|
||||
const SUPPORTED_EVENT_TYPES = new Set(Object.values(LIFECYCLE_EVENT_TYPES));
|
||||
const EVENT_TYPE_ALIASES = Object.freeze({
|
||||
user_message: LIFECYCLE_EVENT_TYPES.USER_MESSAGE_RECEIVED,
|
||||
message_received: LIFECYCLE_EVENT_TYPES.USER_MESSAGE_RECEIVED,
|
||||
user_message_received: LIFECYCLE_EVENT_TYPES.USER_MESSAGE_RECEIVED,
|
||||
turn_start: LIFECYCLE_EVENT_TYPES.TURN_STARTED,
|
||||
turn_started: LIFECYCLE_EVENT_TYPES.TURN_STARTED,
|
||||
request_user_input: LIFECYCLE_EVENT_TYPES.USER_INPUT_REQUESTED,
|
||||
user_input_request: LIFECYCLE_EVENT_TYPES.USER_INPUT_REQUESTED,
|
||||
user_input_requested: LIFECYCLE_EVENT_TYPES.USER_INPUT_REQUESTED,
|
||||
item_tool_request_user_input: LIFECYCLE_EVENT_TYPES.USER_INPUT_REQUESTED,
|
||||
item_tool_requestuserinput: LIFECYCLE_EVENT_TYPES.USER_INPUT_REQUESTED,
|
||||
turn_complete: LIFECYCLE_EVENT_TYPES.TURN_COMPLETED,
|
||||
turn_completed: LIFECYCLE_EVENT_TYPES.TURN_COMPLETED,
|
||||
turn_error: LIFECYCLE_EVENT_TYPES.TURN_FAILED,
|
||||
turn_failed: LIFECYCLE_EVENT_TYPES.TURN_FAILED,
|
||||
});
|
||||
|
||||
function isObject(value) {
|
||||
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function cleanText(value, maxLength = 4000) {
|
||||
return typeof value === 'string' ? value.trim().slice(0, maxLength) : '';
|
||||
}
|
||||
|
||||
function normalizeTimestamp(value, now) {
|
||||
if (value instanceof Date && Number.isFinite(value.getTime())) return value.toISOString();
|
||||
if (typeof value === 'number' && Number.isFinite(value)) {
|
||||
const parsed = new Date(value);
|
||||
if (Number.isFinite(parsed.getTime())) return parsed.toISOString();
|
||||
}
|
||||
if (typeof value === 'string' && value.trim()) {
|
||||
const parsed = new Date(value);
|
||||
if (Number.isFinite(parsed.getTime())) return parsed.toISOString();
|
||||
}
|
||||
return new Date(now()).toISOString();
|
||||
}
|
||||
|
||||
function normalizeLifecycleEvent(input, options = {}) {
|
||||
if (!isObject(input)) return null;
|
||||
const now = typeof options.now === 'function' ? options.now : Date.now;
|
||||
const rawType = cleanText(input.type || input.eventType || input.event, 80).toLowerCase();
|
||||
let type = EVENT_TYPE_ALIASES[rawType] || rawType;
|
||||
const outcome = cleanText(input.outcome || input.status, 80).toLowerCase();
|
||||
if (type === LIFECYCLE_EVENT_TYPES.TURN_COMPLETED && /^(failed|failure|error|errored)$/.test(outcome)) {
|
||||
type = LIFECYCLE_EVENT_TYPES.TURN_FAILED;
|
||||
}
|
||||
if (!SUPPORTED_EVENT_TYPES.has(type)) {
|
||||
return { valid: false, reason: 'unsupported_event', type: type || null };
|
||||
}
|
||||
|
||||
const errorValue = input.error;
|
||||
const error = typeof errorValue === 'string'
|
||||
? cleanText(errorValue)
|
||||
: (isObject(errorValue) ? cleanText(errorValue.message) : '');
|
||||
return {
|
||||
valid: true,
|
||||
type,
|
||||
turnId: cleanText(input.turnId || input.turn_id, 128) || null,
|
||||
eventId: cleanText(input.eventId || input.event_id || input.id, 160) || null,
|
||||
occurredAt: normalizeTimestamp(input.occurredAt || input.timestamp, now),
|
||||
outcome,
|
||||
pendingUserInput: input.pendingUserInput === true,
|
||||
structured: input.structured !== false,
|
||||
trackingEnabled: input.trackingEnabled,
|
||||
error: error || null,
|
||||
};
|
||||
}
|
||||
|
||||
function ignoredResult(sessionId, event, reason, extra = {}) {
|
||||
return {
|
||||
ok: true,
|
||||
handled: false,
|
||||
changed: false,
|
||||
ignored: true,
|
||||
reason,
|
||||
sessionId,
|
||||
eventType: event?.type || null,
|
||||
...extra,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeHandleArguments(sessionOrEnvelope, maybeEvent) {
|
||||
if (typeof sessionOrEnvelope === 'string') {
|
||||
return { sessionId: cleanText(sessionOrEnvelope, 128), event: maybeEvent };
|
||||
}
|
||||
if (!isObject(sessionOrEnvelope)) return { sessionId: '', event: maybeEvent };
|
||||
const sessionId = cleanText(
|
||||
sessionOrEnvelope.sessionId || sessionOrEnvelope.conversationId || sessionOrEnvelope.session_id,
|
||||
128,
|
||||
);
|
||||
return {
|
||||
sessionId,
|
||||
event: isObject(sessionOrEnvelope.event) ? sessionOrEnvelope.event : sessionOrEnvelope,
|
||||
};
|
||||
}
|
||||
|
||||
function trackingEnabledFrom(value) {
|
||||
if (typeof value === 'boolean') return value;
|
||||
if (!isObject(value)) return null;
|
||||
if (typeof value.enabled === 'boolean') return value.enabled;
|
||||
if (typeof value.trackingEnabled === 'boolean') return value.trackingEnabled;
|
||||
if (typeof value.taskTracking?.enabled === 'boolean') return value.taskTracking.enabled;
|
||||
if (typeof value.task?.taskTracking?.enabled === 'boolean') return value.task.taskTracking.enabled;
|
||||
return null;
|
||||
}
|
||||
|
||||
function serviceEventFrom(event) {
|
||||
const output = {
|
||||
type: event.type,
|
||||
occurredAt: event.occurredAt,
|
||||
};
|
||||
if (event.turnId) output.turnId = event.turnId;
|
||||
if (event.outcome) output.outcome = event.outcome;
|
||||
if (event.pendingUserInput) output.pendingUserInput = true;
|
||||
if (event.error) output.error = event.error;
|
||||
return output;
|
||||
}
|
||||
|
||||
function eventIdentity(event) {
|
||||
if (event.eventId) return `event:${event.eventId}`;
|
||||
if (event.turnId) return `turn:${event.turnId}:${event.type}`;
|
||||
return '';
|
||||
}
|
||||
|
||||
function createTaskBoardLifecycle(deps = {}, extraOptions = {}) {
|
||||
const directService = isObject(deps) && typeof deps.recordLifecycleEvent === 'function' ? deps : null;
|
||||
const options = directService
|
||||
? (isObject(extraOptions) ? extraOptions : {})
|
||||
: { ...(isObject(deps) ? deps : {}), ...(isObject(extraOptions) ? extraOptions : {}) };
|
||||
const taskBoardService = directService || options.taskBoardService || options.service;
|
||||
if (!taskBoardService || typeof taskBoardService.recordLifecycleEvent !== 'function') {
|
||||
throw new TypeError('createTaskBoardLifecycle 需要注入 TaskBoardService.recordLifecycleEvent');
|
||||
}
|
||||
|
||||
const now = typeof options.now === 'function' ? options.now : Date.now;
|
||||
const trackingChecker = typeof options.isTrackingEnabled === 'function'
|
||||
? options.isTrackingEnabled
|
||||
: null;
|
||||
const logger = options.logger || null;
|
||||
const queues = new Map();
|
||||
const identitiesBySession = new Map();
|
||||
|
||||
function enqueue(sessionId, operation) {
|
||||
const previous = queues.get(sessionId) || Promise.resolve();
|
||||
const next = previous.catch(() => undefined).then(operation);
|
||||
queues.set(sessionId, next);
|
||||
return next.finally(() => {
|
||||
if (queues.get(sessionId) === next) queues.delete(sessionId);
|
||||
});
|
||||
}
|
||||
|
||||
function hasIdentity(sessionId, identity) {
|
||||
return Boolean(identity && identitiesBySession.get(sessionId)?.has(identity));
|
||||
}
|
||||
|
||||
function rememberIdentity(sessionId, identity) {
|
||||
if (!identity) return;
|
||||
let identities = identitiesBySession.get(sessionId);
|
||||
if (!identities) {
|
||||
identities = new Set();
|
||||
identitiesBySession.set(sessionId, identities);
|
||||
}
|
||||
identities.add(identity);
|
||||
while (identities.size > 256) identities.delete(identities.values().next().value);
|
||||
}
|
||||
|
||||
function logFailure(error, sessionId, event) {
|
||||
if (!logger) return;
|
||||
const payload = { sessionId, eventType: event?.type || '', error: cleanText(error?.message || error) };
|
||||
if (typeof logger === 'function') logger('error', 'task_board_lifecycle_failed', payload);
|
||||
else if (typeof logger.error === 'function') logger.error('task_board_lifecycle_failed', payload);
|
||||
}
|
||||
|
||||
async function processEvent(sessionId, input) {
|
||||
const event = normalizeLifecycleEvent(input, { now });
|
||||
if (!event) return ignoredResult(sessionId, null, 'invalid_event');
|
||||
if (!event.valid) return ignoredResult(sessionId, event, event.reason);
|
||||
if (event.trackingEnabled === false) return ignoredResult(sessionId, event, 'task_tracking_disabled');
|
||||
if (event.type === LIFECYCLE_EVENT_TYPES.USER_INPUT_REQUESTED && !event.structured) {
|
||||
return ignoredResult(sessionId, event, 'unstructured_user_input');
|
||||
}
|
||||
if (trackingChecker) {
|
||||
const enabled = trackingEnabledFrom(await trackingChecker(sessionId, event));
|
||||
if (enabled === false) return ignoredResult(sessionId, event, 'task_tracking_disabled');
|
||||
}
|
||||
const identity = eventIdentity(event);
|
||||
if (hasIdentity(sessionId, identity)) {
|
||||
return ignoredResult(sessionId, event, 'duplicate_event');
|
||||
}
|
||||
|
||||
let serviceResult;
|
||||
try {
|
||||
serviceResult = await taskBoardService.recordLifecycleEvent(sessionId, serviceEventFrom(event));
|
||||
} catch (error) {
|
||||
if (error?.code === 'task_tracking_disabled') {
|
||||
return ignoredResult(sessionId, event, 'task_tracking_disabled');
|
||||
}
|
||||
logFailure(error, sessionId, event);
|
||||
throw error;
|
||||
}
|
||||
rememberIdentity(sessionId, identity);
|
||||
return {
|
||||
ok: true,
|
||||
handled: serviceResult?.ignored !== true,
|
||||
changed: serviceResult?.changed === true,
|
||||
ignored: serviceResult?.ignored === true,
|
||||
reason: serviceResult?.reason || null,
|
||||
sessionId,
|
||||
eventType: event.type,
|
||||
...(serviceResult?.task ? { task: serviceResult.task } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function handleEvent(sessionOrEnvelope, maybeEvent) {
|
||||
const { sessionId, event } = normalizeHandleArguments(sessionOrEnvelope, maybeEvent);
|
||||
if (!sessionId) return Promise.resolve(ignoredResult('', null, 'session_id_missing'));
|
||||
return enqueue(sessionId, () => processEvent(sessionId, event));
|
||||
}
|
||||
|
||||
function clearSession(sessionIdValue) {
|
||||
const sessionId = cleanText(sessionIdValue, 128);
|
||||
if (!sessionId) return Promise.resolve(false);
|
||||
identitiesBySession.delete(sessionId);
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
|
||||
const eventHandler = (type) => (sessionId, event = {}) => handleEvent(sessionId, { ...event, type });
|
||||
return Object.freeze({
|
||||
handleEvent,
|
||||
recordLifecycleEvent: handleEvent,
|
||||
clearSession,
|
||||
onUserMessageReceived: eventHandler(LIFECYCLE_EVENT_TYPES.USER_MESSAGE_RECEIVED),
|
||||
onTurnStarted: eventHandler(LIFECYCLE_EVENT_TYPES.TURN_STARTED),
|
||||
onUserInputRequested: eventHandler(LIFECYCLE_EVENT_TYPES.USER_INPUT_REQUESTED),
|
||||
onTurnCompleted: eventHandler(LIFECYCLE_EVENT_TYPES.TURN_COMPLETED),
|
||||
onTurnFailed: eventHandler(LIFECYCLE_EVENT_TYPES.TURN_FAILED),
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
LIFECYCLE_EVENT_TYPES,
|
||||
createTaskBoardLifecycle,
|
||||
normalizeLifecycleEvent,
|
||||
};
|
||||
220
lib/task-board-mcp.js
Normal file
220
lib/task-board-mcp.js
Normal file
@@ -0,0 +1,220 @@
|
||||
'use strict';
|
||||
|
||||
const { TaskBoardError } = require('./task-board-service');
|
||||
|
||||
// 任务工具不能静态注册;缺少来源会话上下文时必须保持为空。
|
||||
const TASK_BOARD_MCP_TOOL_DEFINITIONS = Object.freeze([]);
|
||||
const TASK_BOARD_MCP_TOOLS = TASK_BOARD_MCP_TOOL_DEFINITIONS;
|
||||
const TOOLS = TASK_BOARD_MCP_TOOL_DEFINITIONS;
|
||||
|
||||
function isPlainObject(value) {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) return false;
|
||||
const prototype = Object.getPrototypeOf(value);
|
||||
return prototype === Object.prototype || prototype === null;
|
||||
}
|
||||
|
||||
function assertArgsObject(args, allowedKeys, toolName) {
|
||||
const value = args === undefined || args === null ? {} : args;
|
||||
if (!isPlainObject(value)) {
|
||||
throw new TaskBoardError('task_status_invalid', `${toolName} 参数必须是对象。`);
|
||||
}
|
||||
const unexpected = Object.keys(value).filter((key) => !allowedKeys.has(key));
|
||||
if (unexpected.length > 0) {
|
||||
throw new TaskBoardError('task_status_invalid', `${toolName} 包含不支持的字段。`, {
|
||||
fields: unexpected,
|
||||
});
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function contextSessionId(context, resolver) {
|
||||
if (!isPlainObject(context)) {
|
||||
throw new TaskBoardError('task_session_not_found', 'MCP 调用上下文缺少来源会话。');
|
||||
}
|
||||
let value;
|
||||
if (typeof resolver === 'function') value = resolver(context);
|
||||
if (value === undefined || value === null || value === '') {
|
||||
value = context.sourceSessionId
|
||||
?? context.sessionId
|
||||
?? context.source?.sessionId
|
||||
?? context.mcp?.sourceSessionId;
|
||||
}
|
||||
// 来源只取 MCP 连接上下文,绝不接受模型参数中的 sessionId。
|
||||
if (typeof value !== 'string' || value.length === 0) {
|
||||
throw new TaskBoardError('task_session_not_found', 'MCP 调用上下文缺少来源会话。');
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function actorFromContext(context) {
|
||||
const actorId = isPlainObject(context)
|
||||
? (context.actorId || context.agentId || context.sourceAgentId || 'mcp')
|
||||
: 'mcp';
|
||||
return { source: 'mcp', id: actorId };
|
||||
}
|
||||
|
||||
function errorPayload(error) {
|
||||
if (error instanceof TaskBoardError) {
|
||||
const payload = { ok: false, code: error.code, message: error.message };
|
||||
if (error.details !== undefined) payload.details = error.details;
|
||||
return payload;
|
||||
}
|
||||
return {
|
||||
ok: false,
|
||||
code: 'task_status_invalid',
|
||||
message: '任务状态工具执行失败。',
|
||||
};
|
||||
}
|
||||
|
||||
function serviceFromOptions(serviceOrOptions) {
|
||||
if (serviceOrOptions && typeof serviceOrOptions.getTask === 'function'
|
||||
&& typeof serviceOrOptions.updateStatus === 'function') {
|
||||
return serviceOrOptions;
|
||||
}
|
||||
if (isPlainObject(serviceOrOptions)) {
|
||||
if (serviceOrOptions.service) return serviceOrOptions.service;
|
||||
if (serviceOrOptions.taskBoardService) return serviceOrOptions.taskBoardService;
|
||||
}
|
||||
throw new TypeError('任务看板 MCP 需要 TaskBoardService。');
|
||||
}
|
||||
|
||||
function factoryOptionsFrom(serviceOrOptions, options) {
|
||||
return isPlainObject(serviceOrOptions) && !serviceOrOptions.getTask
|
||||
? { ...serviceOrOptions, ...(isPlainObject(options) ? options : {}) }
|
||||
: (isPlainObject(options) ? options : {});
|
||||
}
|
||||
|
||||
function enabledDefinitions(service) {
|
||||
const snapshot = typeof service.getStatusDefinitionSnapshot === 'function'
|
||||
? service.getStatusDefinitionSnapshot()
|
||||
: { definitions: service.getStatusDefinitions() };
|
||||
return (Array.isArray(snapshot.definitions) ? snapshot.definitions : [])
|
||||
.filter((definition) => definition?.enabled === true);
|
||||
}
|
||||
|
||||
function compactPrompt(value) {
|
||||
return String(value || '').replace(/\s+/g, ' ').trim();
|
||||
}
|
||||
|
||||
function taskUpdateDescription(task, definitions) {
|
||||
const current = task.status || definitions.find((definition) => (
|
||||
definition.id === task.taskTracking.statusId
|
||||
));
|
||||
const currentLabel = compactPrompt(current?.label) || task.taskTracking.statusId;
|
||||
const currentId = task.taskTracking.statusId;
|
||||
const mappings = definitions.map((definition) => (
|
||||
`- ${compactPrompt(definition.label)} / ${definition.id}: ${compactPrompt(definition.prompt)}`
|
||||
));
|
||||
return [
|
||||
`更新当前来源会话的任务看板列。当前状态:${currentLabel} / ${currentId}。`,
|
||||
'仅当当前对话任务状态真实发生变化时调用;单轮开始、结束、运行停止不代表任务状态变化。',
|
||||
'可选列分类映射:',
|
||||
...mappings,
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
function createTaskBoardMcpToolDefinitions(serviceOrOptions, context = {}, options = {}) {
|
||||
const service = serviceFromOptions(serviceOrOptions);
|
||||
const factoryOptions = factoryOptionsFrom(serviceOrOptions, options);
|
||||
const resolver = factoryOptions.sourceSessionIdResolver
|
||||
|| factoryOptions.getSourceSessionId
|
||||
|| null;
|
||||
try {
|
||||
const sessionId = contextSessionId(context, resolver);
|
||||
const task = service.getTask(sessionId);
|
||||
if (task.taskTracking.enabled !== true) return [];
|
||||
const definitions = enabledDefinitions(service);
|
||||
return [{
|
||||
name: 'ccweb_task_update',
|
||||
description: taskUpdateDescription(task, definitions),
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
statusId: {
|
||||
type: 'string',
|
||||
enum: definitions.map((definition) => definition.id),
|
||||
description: '选择一个当前已启用的看板列 ID。',
|
||||
},
|
||||
reason: {
|
||||
type: 'string',
|
||||
maxLength: 2000,
|
||||
description: '可选,简要说明状态变化原因。',
|
||||
},
|
||||
summary: {
|
||||
type: 'string',
|
||||
maxLength: 4000,
|
||||
description: '可选,更新面向看板的任务摘要。',
|
||||
},
|
||||
},
|
||||
required: ['statusId'],
|
||||
additionalProperties: false,
|
||||
},
|
||||
}];
|
||||
} catch (error) {
|
||||
if (error instanceof TaskBoardError
|
||||
&& (error.code === 'task_session_not_found' || error.code === 'task_tracking_disabled')) {
|
||||
return [];
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function statusPayload(task) {
|
||||
return {
|
||||
ok: true,
|
||||
changed: task.changed === true,
|
||||
sessionId: task.sessionId,
|
||||
currentStatus: { ...task.taskTracking },
|
||||
currentStatusDefinition: task.status ? { ...task.status } : null,
|
||||
task,
|
||||
};
|
||||
}
|
||||
|
||||
function createTaskBoardMcpHandlers(serviceOrOptions, options = {}) {
|
||||
const service = serviceFromOptions(serviceOrOptions);
|
||||
const factoryOptions = factoryOptionsFrom(serviceOrOptions, options);
|
||||
const sourceSessionResolver = factoryOptions.sourceSessionIdResolver
|
||||
|| factoryOptions.getSourceSessionId
|
||||
|| null;
|
||||
|
||||
function updateStatus(args = {}, context = {}) {
|
||||
try {
|
||||
const input = assertArgsObject(args, new Set(['statusId', 'reason', 'summary']), 'ccweb_task_update');
|
||||
const sessionId = contextSessionId(context, sourceSessionResolver);
|
||||
const task = service.updateStatus(sessionId, input, actorFromContext(context));
|
||||
return statusPayload(task);
|
||||
} catch (error) {
|
||||
return errorPayload(error);
|
||||
}
|
||||
}
|
||||
|
||||
return Object.freeze({ ccweb_task_update: updateStatus });
|
||||
}
|
||||
|
||||
function createTaskBoardMcpHandler(serviceOrOptions, options = {}) {
|
||||
const handlers = createTaskBoardMcpHandlers(serviceOrOptions, options);
|
||||
return function handleTaskBoardMcpCall(name, args = {}, context = {}) {
|
||||
const handler = handlers[name];
|
||||
if (!handler) {
|
||||
return {
|
||||
ok: false,
|
||||
code: 'unknown_tool',
|
||||
message: `未知任务看板工具: ${String(name || '')}`,
|
||||
};
|
||||
}
|
||||
return handler(args, context);
|
||||
};
|
||||
}
|
||||
|
||||
const createTaskBoardMcpAdapter = createTaskBoardMcpHandler;
|
||||
|
||||
module.exports = {
|
||||
TASK_BOARD_MCP_TOOL_DEFINITIONS,
|
||||
TASK_BOARD_MCP_TOOLS,
|
||||
TOOLS,
|
||||
createTaskBoardMcpToolDefinitions,
|
||||
createTaskBoardMcpHandlers,
|
||||
createTaskBoardMcpHandler,
|
||||
createTaskBoardMcpAdapter,
|
||||
errorPayload,
|
||||
};
|
||||
1037
lib/task-board-service.js
Normal file
1037
lib/task-board-service.js
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user