Files
cc-web/scripts/ccweb-list-user-inputs-unit.js

473 lines
17 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env node
'use strict';
const assert = require('assert/strict');
const crypto = require('crypto');
const fs = require('fs');
const net = require('net');
const os = require('os');
const path = require('path');
const { spawn } = require('child_process');
const REPO_DIR = path.resolve(__dirname, '..');
const SERVER_PATH = path.join(REPO_DIR, 'server.js');
const { TOOLS } = require(path.join(REPO_DIR, 'lib', 'ccweb-mcp-server'));
const INTERNAL_MCP_TOKEN = 'ListUserInputsUnitMcp!234';
function mkdirp(dir) {
fs.mkdirSync(dir, { recursive: true });
}
function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function getFreePort() {
return new Promise((resolve, reject) => {
const server = net.createServer();
server.on('error', reject);
server.listen(0, '127.0.0.1', () => {
const address = server.address();
const port = address && typeof address === 'object' ? address.port : null;
server.close(() => resolve(port));
});
});
}
async function waitForPort(port, timeoutMs = 10000) {
const started = Date.now();
let lastError = null;
while (Date.now() - started < timeoutMs) {
try {
await new Promise((resolve, reject) => {
const socket = net.createConnection({ host: '127.0.0.1', port });
socket.once('connect', () => {
socket.destroy();
resolve();
});
socket.once('error', reject);
socket.setTimeout(500, () => {
socket.destroy();
reject(new Error('timeout'));
});
});
return;
} catch (err) {
lastError = err;
await sleep(50);
}
}
throw new Error(`Timed out waiting for port ${port}: ${lastError?.message || 'unknown'}`);
}
async function withServer(env, fn) {
const child = spawn(process.execPath, [SERVER_PATH], {
cwd: REPO_DIR,
env: { ...process.env, ...env },
stdio: ['ignore', 'pipe', 'pipe'],
});
let stdout = '';
let stderr = '';
child.stdout.on('data', (chunk) => { stdout += chunk.toString(); });
child.stderr.on('data', (chunk) => { stderr += chunk.toString(); });
try {
await waitForPort(env.PORT, 10000);
await fn({ stdout: () => stdout, stderr: () => stderr });
} finally {
if (child.exitCode === null && !child.signalCode) {
child.kill('SIGTERM');
await sleep(300);
}
if (child.exitCode === null && !child.signalCode) child.kill('SIGKILL');
}
}
async function postJson(port, pathname, body) {
const response = await fetch(`http://127.0.0.1:${port}${pathname}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CC-Web-MCP-Token': INTERNAL_MCP_TOKEN,
},
body: JSON.stringify(body),
});
let payload = null;
try {
payload = await response.json();
} catch {}
return { status: response.status, payload };
}
async function mcpJsonRpc(port, sourceSessionId, message) {
const query = sourceSessionId ? `?sourceSessionId=${encodeURIComponent(sourceSessionId)}` : '';
return postJson(port, `/api/internal/mcp/stream${query}`, message);
}
async function callSharedTool(port, sourceSessionId, args = {}) {
const response = await mcpJsonRpc(port, sourceSessionId, {
jsonrpc: '2.0',
id: crypto.randomUUID(),
method: 'tools/call',
params: {
name: 'ccweb_list_user_inputs',
arguments: args,
},
});
assert.equal(response.status, 200, `tools/call should return HTTP 200, got ${response.status}`);
assert.ok(response.payload?.result, 'tools/call should return a JSON-RPC result');
return response.payload.result.structuredContent;
}
async function directInternalCall(port, sourceSessionId, args = {}) {
const response = await postJson(port, '/api/internal/mcp', {
tool: 'ccweb_list_user_inputs',
args,
sourceSessionId,
});
return response;
}
function writeSession(sessionsDir, session) {
const filePath = path.join(sessionsDir, `${session.id}.json`);
fs.writeFileSync(filePath, JSON.stringify(session, null, 2));
return filePath;
}
function sha256(filePath) {
return crypto.createHash('sha256').update(fs.readFileSync(filePath)).digest('hex');
}
function userMessage(id, content) {
return {
id,
role: 'user',
content,
timestamp: `2026-08-17T00:${String(Number(id.replace(/\D/g, '')) || 0).padStart(2, '0')}:00.000Z`,
};
}
function createFixtures(sessionsDir) {
const sourceMessages = [];
for (let index = 1; index <= 18; index += 1) {
sourceMessages.push(userMessage(`u${String(index).padStart(2, '0')}`, `用户输入 ${String(index).padStart(2, '0')}`));
}
sourceMessages.splice(4, 0,
{ id: 'assistant-guidance', role: 'assistant', content: 'assistant 说明不应进入结果', timestamp: '2026-08-17T00:04:10.000Z' },
{ id: 'tool-result', role: 'tool', content: '工具输出不应进入结果', timestamp: '2026-08-17T00:04:20.000Z' },
{ id: 'blank-user', role: 'user', content: ' \n\t', timestamp: '2026-08-17T00:04:30.000Z' },
{
id: 'assistant-prompt-card',
role: 'assistant',
content: '',
timestamp: '2026-08-17T00:04:40.000Z',
ccwebPrompt: {
id: 'prompt-1',
status: 'pending',
title: '需要确认',
questions: [{ id: 'q1', title: '方向', question: '选择方向' }],
},
},
);
sourceMessages.push(userMessage('submitted-form', '表单答案:\n\n表单需要确认\n\n1. 方向\n答案继续实现最小方案'));
const sourcePath = writeSession(sessionsDir, {
id: 'source-session',
title: '来源会话',
agent: 'codexapp',
created: '2026-08-17T00:00:00.000Z',
updated: '2026-08-17T00:30:00.000Z',
hasUnread: false,
isRunning: false,
messages: sourceMessages,
});
const explicitPath = writeSession(sessionsDir, {
id: 'explicit-session',
title: '显式会话',
agent: 'codexapp',
created: '2026-08-17T01:00:00.000Z',
updated: '2026-08-17T01:10:00.000Z',
hasUnread: true,
isRunning: false,
messages: [
userMessage('explicit-old', '显式旧输入'),
{ id: 'explicit-assistant', role: 'assistant', content: '显式 assistant' },
userMessage('explicit-new', '显式新输入'),
],
});
const unicodePath = writeSession(sessionsDir, {
id: 'unicode-session',
title: 'Unicode 会话',
agent: 'codexapp',
created: '2026-08-17T02:00:00.000Z',
updated: '2026-08-17T02:10:00.000Z',
messages: [
userMessage('unicode-old', 'abcdefghij'),
userMessage('unicode-mid', '一二三四'),
userMessage('unicode-latest', '🙂🙂🙂🙂'),
],
});
const exactSinglePath = writeSession(sessionsDir, {
id: 'exact-single-session',
title: '恰好预算单消息',
agent: 'codexapp',
messages: [userMessage('exact-single', '🙂甲')],
});
const exactMorePath = writeSession(sessionsDir, {
id: 'exact-more-session',
title: '恰好预算仍有更早消息',
agent: 'codexapp',
messages: [
userMessage('exact-old', '旧'),
userMessage('exact-latest', '🙂甲'),
],
});
const emptyPath = writeSession(sessionsDir, {
id: 'empty-session',
title: '无有效用户正文',
agent: 'codexapp',
messages: [
{ id: 'empty-assistant', role: 'assistant', content: '仅 assistant' },
{ id: 'empty-user', role: 'user', content: ' \n\t ' },
],
});
return {
sourcePath,
explicitPath,
unicodePath,
exactSinglePath,
exactMorePath,
emptyPath,
};
}
function assertToolSchema() {
const tool = TOOLS.find((item) => item.name === 'ccweb_list_user_inputs');
assert.ok(tool, '正式 ccweb MCP tools/list 应暴露 ccweb_list_user_inputs');
assert.equal(tool.description, '该工具返回用户输入的近 N 次对话列表。');
const schema = tool.inputSchema;
assert.equal(schema?.type, 'object');
assert.equal(schema.additionalProperties, false, 'schema additionalProperties 必须为 false');
assert.ok(!Array.isArray(schema.required) || !schema.required.includes('conversationId'), 'conversationId 必须可选');
assert.deepEqual(schema.properties?.conversationId?.type, 'string');
assert.equal(schema.properties?.limit?.type, 'integer');
assert.equal(schema.properties?.limit?.minimum, 1);
assert.equal(schema.properties?.limit?.maximum, 50);
assert.match(schema.properties?.limit?.description || '', /默认\s*15|15/);
assert.equal(schema.properties?.maxChars?.type, 'integer');
assert.equal(schema.properties?.maxChars?.minimum, 1);
assert.equal(schema.properties?.maxChars?.maximum, 1000);
assert.match(schema.properties?.maxChars?.description || '', /默认\s*1000|1000/);
}
async function assertServerToolList(port) {
const response = await mcpJsonRpc(port, 'source-session', {
jsonrpc: '2.0',
id: 'tools-list',
method: 'tools/list',
params: {},
});
assert.equal(response.status, 200);
const toolNames = response.payload?.result?.tools?.map((tool) => tool.name) || [];
assert.ok(toolNames.includes('ccweb_list_user_inputs'), '内部 JSON-RPC tools/list 应包含 ccweb_list_user_inputs');
}
async function assertDefaultsAndFiltering(port) {
const payload = await callSharedTool(port, 'source-session', {});
assert.equal(payload.ok, true);
assert.equal(payload.conversationId, 'source-session');
assert.equal(payload.requestedLimit, 15);
assert.equal(payload.maxChars, 1000);
assert.equal(payload.returnedCount, 15);
assert.equal(payload.items.length, 15);
assert.equal(payload.hasMore, true, '超过默认 limit 的更早用户输入应设置 hasMore=true');
assert.deepEqual(payload.items.map((item) => item.messageId), [
'u05', 'u06', 'u07', 'u08', 'u09',
'u10', 'u11', 'u12', 'u13', 'u14',
'u15', 'u16', 'u17', 'u18', 'submitted-form',
]);
const allContent = payload.items.map((item) => item.content).join('\n');
assert.match(allContent, /表单答案/, '提交后的普通 user 消息应进入结果');
assert.doesNotMatch(allContent, /assistant 说明|工具输出/, 'assistant/tool 独有内容不应进入结果');
assert.ok(
!payload.items.some((item) => item.messageId === 'assistant-prompt-card'),
'未提交的 assistant 引导卡不应以 messageId 进入结果'
);
assert.ok(payload.totalChars <= 1000);
for (const item of payload.items) {
assert.equal(item.contentChars, Array.from(item.content).length);
assert.equal(item.truncated, false);
}
}
async function assertExplicitConversation(port) {
const payload = await callSharedTool(port, 'source-session', { conversationId: 'explicit-session', limit: 10 });
assert.equal(payload.ok, true);
assert.equal(payload.conversationId, 'explicit-session');
assert.deepEqual(payload.items.map((item) => item.content), ['显式旧输入', '显式新输入']);
}
async function assertUnicodeBudgetAndTruncation(port) {
const payload = await callSharedTool(port, 'source-session', {
conversationId: 'unicode-session',
limit: 3,
maxChars: 6,
});
assert.equal(payload.ok, true);
assert.equal(payload.totalChars, 6);
assert.equal(payload.returnedCount, 2);
assert.equal(payload.hasMore, true);
assert.deepEqual(payload.items.map((item) => item.messageId), ['unicode-mid', 'unicode-latest']);
assert.equal(payload.items[0].content, '一二');
assert.equal(payload.items[0].contentChars, 2);
assert.equal(payload.items[0].originalChars, 4);
assert.equal(payload.items[0].truncated, true);
assert.equal(payload.items[1].content, '🙂🙂🙂🙂');
assert.equal(payload.items[1].contentChars, 4, 'emoji 应按 Unicode code point 计数,而不是 UTF-16 code unit');
assert.equal(payload.items[1].originalChars, 4);
assert.equal(payload.items[1].truncated, false);
assert.ok(payload.items.reduce((sum, item) => sum + item.contentChars, 0) <= 6);
}
async function assertExactBudgetEmptyAndClamps(port) {
const exactSingle = await callSharedTool(port, 'source-session', {
conversationId: 'exact-single-session',
maxChars: 2,
});
assert.equal(exactSingle.totalChars, 2);
assert.equal(exactSingle.hasMore, false, '恰好用满预算且无更早输入时不应误报 hasMore');
assert.equal(exactSingle.items[0].truncated, false);
const exactMore = await callSharedTool(port, 'source-session', {
conversationId: 'exact-more-session',
maxChars: 2,
});
assert.equal(exactMore.totalChars, 2);
assert.equal(exactMore.returnedCount, 1);
assert.equal(exactMore.items[0].messageId, 'exact-latest');
assert.equal(exactMore.items[0].truncated, false);
assert.equal(exactMore.hasMore, true, '恰好用满预算但仍有更早输入时应标记 hasMore');
const empty = await callSharedTool(port, 'source-session', { conversationId: 'empty-session' });
assert.equal(empty.returnedCount, 0);
assert.equal(empty.totalChars, 0);
assert.equal(empty.hasMore, false);
assert.deepEqual(empty.items, []);
const clamped = await callSharedTool(port, 'source-session', {
conversationId: 'explicit-session',
limit: 999,
maxChars: 9999,
});
assert.equal(clamped.requestedLimit, 50);
assert.equal(clamped.maxChars, 1000);
}
async function assertErrors(port) {
const missing = await callSharedTool(port, '', {});
assert.equal(missing.ok, false);
assert.equal(missing.code, 'missing_source_conversation');
const invalid = await callSharedTool(port, 'source-session', { conversationId: '!!!', limit: 5 });
assert.equal(invalid.ok, false);
assert.equal(invalid.code, 'invalid_conversation_id');
const notFound = await callSharedTool(port, 'source-session', { conversationId: 'missing-session', limit: 5 });
assert.equal(notFound.ok, false);
assert.equal(notFound.code, 'conversation_not_found');
}
async function assertDirectInternalRoute(port) {
const response = await directInternalCall(port, 'source-session', { limit: 1 });
assert.equal(response.status, 200);
assert.equal(response.payload?.ok, true);
assert.equal(response.payload?.conversationId, 'source-session');
assert.equal(response.payload?.returnedCount, 1);
}
async function runTest(name, fn, failures) {
try {
await fn();
console.log(`ok - ${name}`);
} catch (err) {
failures.push({ name, err });
console.error(`not ok - ${name}`);
console.error(` ${err.stack || err.message}`);
}
}
async function main() {
const failures = [];
await runTest('正式 MCP schema', assertToolSchema, failures);
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ccweb-list-user-inputs-'));
try {
const sessionsDir = path.join(tempRoot, 'sessions');
const configDir = path.join(tempRoot, 'config');
const logsDir = path.join(tempRoot, 'logs');
mkdirp(sessionsDir);
mkdirp(configDir);
mkdirp(logsDir);
const fixturePaths = createFixtures(sessionsDir);
const before = Object.fromEntries(Object.entries(fixturePaths).map(([key, filePath]) => {
const stat = fs.statSync(filePath);
return [key, { filePath, hash: sha256(filePath), mtimeMs: stat.mtimeMs }];
}));
const port = await getFreePort();
await withServer({
PORT: String(port),
CC_WEB_PASSWORD: 'ListUserInputsUnit!234',
CC_WEB_INTERNAL_MCP_TOKEN: INTERNAL_MCP_TOKEN,
CC_WEB_CONFIG_DIR: configDir,
CC_WEB_SESSIONS_DIR: sessionsDir,
CC_WEB_LOGS_DIR: logsDir,
CC_WEB_USAGE_STATISTICS: '0',
}, async () => {
await runTest('内部 JSON-RPC tools/list 暴露工具', () => assertServerToolList(port), failures);
await runTest('默认来源会话、最近 15 条、过滤和旧到新排序', () => assertDefaultsAndFiltering(port), failures);
await runTest('显式指定 conversationId 查询目标会话', () => assertExplicitConversation(port), failures);
await runTest('maxChars Unicode code point 预算和截断字段', () => assertUnicodeBudgetAndTruncation(port), failures);
await runTest('恰好预算、空会话和运行时 clamp 边界', () => assertExactBudgetEmptyAndClamps(port), failures);
await runTest('错误码 missing_source_conversation / invalid_conversation_id / conversation_not_found', () => assertErrors(port), failures);
await runTest('旧版内部 /api/internal/mcp 路由分派', () => assertDirectInternalRoute(port), failures);
});
await runTest('查询不修改 fixture 会话文件', () => {
for (const entry of Object.values(before)) {
const stat = fs.statSync(entry.filePath);
assert.equal(sha256(entry.filePath), entry.hash, `${path.basename(entry.filePath)} 内容不应变化`);
assert.equal(stat.mtimeMs, entry.mtimeMs, `${path.basename(entry.filePath)} mtime 不应变化`);
}
}, failures);
} finally {
try {
fs.rmSync(tempRoot, { recursive: true, force: true });
} catch (err) {
failures.push({ name: '清理临时目录', err });
console.error('not ok - 清理临时目录');
console.error(` ${err.stack || err.message}`);
}
}
if (failures.length > 0) {
console.error(`\n${failures.length} test(s) failed.`);
process.exitCode = 1;
return;
}
console.log('\nall ccweb_list_user_inputs focused tests passed');
}
main().catch((err) => {
console.error(err.stack || err.message);
process.exit(1);
});