feat: add user input history MCP and rebuild release

This commit is contained in:
shiyue
2026-08-18 09:10:25 +08:00
parent 6ed8c7d30b
commit 389690af64
11 changed files with 957 additions and 0 deletions

View File

@@ -0,0 +1,472 @@
#!/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);
});