Files
cc-web/scripts/ccweb-message-reply-unit.js

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.');