#!/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); });