231 lines
9.2 KiB
JavaScript
231 lines
9.2 KiB
JavaScript
#!/usr/bin/env node
|
|
'use strict';
|
|
|
|
const assert = require('assert/strict');
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
|
|
const REPO_DIR = path.resolve(__dirname, '..');
|
|
const SERVER_PATH = path.join(REPO_DIR, 'server.js');
|
|
const mcpServer = require(path.join(REPO_DIR, 'lib', 'ccweb-mcp-server.js'));
|
|
const serverSource = fs.readFileSync(SERVER_PATH, 'utf8');
|
|
|
|
function check(label, fn) {
|
|
try {
|
|
fn();
|
|
} catch (err) {
|
|
err.message = `${label}: ${err.message}`;
|
|
throw err;
|
|
}
|
|
}
|
|
|
|
function toolNames(tools) {
|
|
return tools.map((tool) => tool && tool.name).filter(Boolean);
|
|
}
|
|
|
|
function findTool(tools, name) {
|
|
return tools.find((tool) => tool && tool.name === name);
|
|
}
|
|
|
|
function replyModeValues(value) {
|
|
if (Array.isArray(value)) return value;
|
|
if (value && typeof value === 'object') return Object.values(value);
|
|
return [];
|
|
}
|
|
|
|
function withoutNamespace(tool) {
|
|
const clone = { ...tool };
|
|
delete clone.namespace;
|
|
return clone;
|
|
}
|
|
|
|
function assertExactReplyModeEnum(actual, label) {
|
|
assert.deepEqual(
|
|
actual,
|
|
['one_way', 'return_and_continue'],
|
|
`${label} must be exactly ["one_way", "return_and_continue"]`,
|
|
);
|
|
}
|
|
|
|
check('public TOOLS expose only the unified send communication tool', () => {
|
|
assert.ok(Array.isArray(mcpServer.TOOLS), 'TOOLS must be exported as an array');
|
|
const names = toolNames(mcpServer.TOOLS);
|
|
assert.equal(
|
|
names.filter((name) => name === 'ccweb_send_message').length,
|
|
1,
|
|
`expected exactly one ccweb_send_message, got: ${names.join(', ')}`,
|
|
);
|
|
assert.equal(
|
|
names.includes('ccweb_request_reply'),
|
|
false,
|
|
`ccweb_request_reply must be hidden from public TOOLS; got: ${names.join(', ')}`,
|
|
);
|
|
});
|
|
|
|
const publicSendTool = findTool(mcpServer.TOOLS, 'ccweb_send_message');
|
|
|
|
check('ccweb_send_message schema requires replyMode with the exact enum', () => {
|
|
assert.ok(publicSendTool, 'ccweb_send_message must exist in public TOOLS');
|
|
const schema = publicSendTool.inputSchema || {};
|
|
const required = schema.required || [];
|
|
assert.ok(required.includes('targetConversationId'), 'required must include targetConversationId');
|
|
assert.ok(required.includes('content'), 'required must include content');
|
|
assert.ok(required.includes('replyMode'), 'required must include replyMode');
|
|
assertExactReplyModeEnum(schema.properties?.replyMode?.enum, 'replyMode enum');
|
|
});
|
|
|
|
check('ccweb_send_message description states strong selection and non-blocking rules', () => {
|
|
const description = String(publicSendTool.description || '');
|
|
assert.match(description, /one_way/, 'description must mention one_way');
|
|
assert.match(description, /return_and_continue/, 'description must mention return_and_continue');
|
|
assert.match(description, /立即返回/, 'description must say calls return immediately');
|
|
assert.match(description, /(不阻塞|不会阻塞|不等待)/, 'description must state the call does not block/wait');
|
|
assert.match(
|
|
description,
|
|
/(仅当[\s\S]*来源[\s\S]*不需要[\s\S]*结果[\s\S]*one_way|one_way[\s\S]*仅当[\s\S]*来源[\s\S]*不需要[\s\S]*结果)/,
|
|
'description must say one_way is only for cases where the source does not need the result',
|
|
);
|
|
assert.match(
|
|
description,
|
|
/(分析|实现|测试|验收|完成后汇报|后续依赖)[\s\S]*必须[\s\S]*return_and_continue|return_and_continue[\s\S]*必须[\s\S]*(分析|实现|测试|验收|完成后汇报|后续依赖)/,
|
|
'description must say result-dependent work must use return_and_continue',
|
|
);
|
|
});
|
|
|
|
check('future compatibility exports are present and keep hidden legacy callable', () => {
|
|
assertExactReplyModeEnum(
|
|
replyModeValues(mcpServer.CCWEB_REPLY_MODES).sort(),
|
|
'CCWEB_REPLY_MODES values',
|
|
);
|
|
assert.equal(typeof mcpServer.isCallableToolName, 'function', 'isCallableToolName must be exported');
|
|
assert.equal(
|
|
mcpServer.isCallableToolName('ccweb_send_message'),
|
|
true,
|
|
'ccweb_send_message must be callable',
|
|
);
|
|
assert.equal(
|
|
mcpServer.isCallableToolName('ccweb_request_reply'),
|
|
true,
|
|
'hidden legacy ccweb_request_reply must remain callable',
|
|
);
|
|
assert.equal(
|
|
typeof mcpServer.codexAppCommunicationTools,
|
|
'function',
|
|
'codexAppCommunicationTools must be exported',
|
|
);
|
|
});
|
|
|
|
check('Codex App fallback communication tools reuse the public send contract', () => {
|
|
const fallbackTools = mcpServer.codexAppCommunicationTools();
|
|
assert.ok(Array.isArray(fallbackTools), 'codexAppCommunicationTools() must return an array');
|
|
const names = toolNames(fallbackTools);
|
|
assert.equal(
|
|
names.includes('ccweb_request_reply'),
|
|
false,
|
|
`fallback tools must not expose ccweb_request_reply; got: ${names.join(', ')}`,
|
|
);
|
|
assert.equal(
|
|
names.includes('ccweb_display_image'),
|
|
false,
|
|
'fallback tools must not include ccweb_display_image',
|
|
);
|
|
assert.equal(
|
|
names.includes('ccweb_prompt_user'),
|
|
false,
|
|
'fallback tools must not include ccweb_prompt_user',
|
|
);
|
|
assert.equal(
|
|
names.some((name) => name.startsWith('ccweb_task_')),
|
|
false,
|
|
`fallback tools must not include task-board tools; got: ${names.join(', ')}`,
|
|
);
|
|
|
|
const fallbackSendTool = findTool(fallbackTools, 'ccweb_send_message');
|
|
assert.ok(fallbackSendTool, 'fallback tools must include ccweb_send_message');
|
|
assert.deepEqual(
|
|
withoutNamespace(fallbackSendTool),
|
|
publicSendTool,
|
|
'fallback ccweb_send_message must match public definition except namespace',
|
|
);
|
|
});
|
|
|
|
check('server.js dynamic tools and shared call gates reuse the shared contract', () => {
|
|
assert.match(
|
|
serverSource,
|
|
/function\s+codexAppCommunicationDynamicTools\s*\([^)]*\)\s*{[\s\S]{0,600}codexAppCommunicationTools\s*\(/,
|
|
'codexAppCommunicationDynamicTools() must delegate to codexAppCommunicationTools()',
|
|
);
|
|
assert.match(
|
|
serverSource,
|
|
/case ['"]tools\/call['"]\s*:[\s\S]{0,1400}isCallableToolName\s*\(/,
|
|
'shared MCP tools/call gate must use isCallableToolName()',
|
|
);
|
|
assert.match(
|
|
serverSource,
|
|
/case ['"]ccweb_request_reply['"]\s*:/,
|
|
'internal dispatcher must keep the hidden legacy ccweb_request_reply case',
|
|
);
|
|
});
|
|
|
|
check('unified runtime persists the original request and maps the legacy reply alias', () => {
|
|
const sendStart = serverSource.indexOf('function sendCrossConversationMessage');
|
|
const sendEnd = serverSource.indexOf('function requestCrossConversationReply', sendStart);
|
|
const sendSource = serverSource.slice(sendStart, sendEnd);
|
|
assert.ok(sendStart >= 0 && sendEnd > sendStart, 'sendCrossConversationMessage() source must be present');
|
|
assert.match(
|
|
sendSource,
|
|
/(args\.replyMode|normalizeCrossConversationReplyMode)/,
|
|
'ccweb_send_message runtime must read or normalize replyMode',
|
|
);
|
|
assert.match(
|
|
sendSource,
|
|
/originalRequest:\s*content/,
|
|
'return_and_continue pending state must persist the original request',
|
|
);
|
|
|
|
const legacyStart = serverSource.indexOf('function requestCrossConversationReply');
|
|
const legacyEnd = serverSource.indexOf('function listPendingCrossConversationReplies', legacyStart);
|
|
const legacySource = serverSource.slice(legacyStart, legacyEnd);
|
|
assert.match(
|
|
legacySource,
|
|
/(RETURN_AND_CONTINUE|return_and_continue)/,
|
|
'legacy ccweb_request_reply must map explicitly to return_and_continue',
|
|
);
|
|
});
|
|
|
|
check('target runtime prompt explains both reply modes and prevents duplicate return', () => {
|
|
const promptStart = serverSource.indexOf('function buildCrossConversationRuntimeText');
|
|
const promptEnd = serverSource.indexOf('function buildCrossConversationReplyContent', promptStart);
|
|
const promptSource = serverSource.slice(promptStart, promptEnd);
|
|
assert.match(promptSource, /one_way/, 'target prompt must identify one_way mode');
|
|
assert.match(promptSource, /return_and_continue/, 'target prompt must identify return_and_continue mode');
|
|
assert.match(
|
|
promptSource,
|
|
/(不会自动回传|不会自动写回来源)/,
|
|
'one_way target prompt must state that no automatic return occurs',
|
|
);
|
|
assert.match(
|
|
promptSource,
|
|
/(自动回传|自动写回来源)/,
|
|
'return_and_continue target prompt must state that the system returns the result automatically',
|
|
);
|
|
assert.match(
|
|
promptSource,
|
|
/(不要|禁止)[\s\S]*(手工|手动)[\s\S]*(回传|发送)/,
|
|
'return_and_continue target prompt must prohibit duplicate manual return',
|
|
);
|
|
});
|
|
|
|
check('source auto-run prompt carries correlation context and completion guardrail', () => {
|
|
const promptStart = serverSource.indexOf('function buildCrossConversationReplyAutoRunText');
|
|
const promptEnd = serverSource.indexOf('function startCrossConversationReplyAutoRun', promptStart);
|
|
const promptSource = serverSource.slice(promptStart, promptEnd);
|
|
assert.match(promptSource, /requestId/, 'source auto-run prompt must include requestId');
|
|
assert.match(promptSource, /targetSession\?\.id|targetSession\.id/, 'source auto-run prompt must include target conversation id');
|
|
assert.match(promptSource, /originalRequest/, 'source auto-run prompt must include the original request');
|
|
assert.match(promptSource, /已返回不等于已完成/, 'source auto-run prompt must distinguish returned from completed');
|
|
assert.match(promptSource, /完整/, 'source auto-run prompt must require a completeness check');
|
|
});
|
|
|
|
console.log('ccweb message reply unit checks passed.');
|