'use strict'; const assert = require('node:assert'); const fs = require('node:fs'); const http = require('node:http'); const net = require('node:net'); const os = require('node:os'); const path = require('node:path'); const { spawn } = require('node:child_process'); const WebSocket = require('ws'); const REPO_DIR = path.resolve(__dirname, '..'); const SERVER_PATH = path.join(REPO_DIR, 'server.js'); const MOCK_CLAUDE = path.join(REPO_DIR, 'scripts', 'mock-claude.js'); const MOCK_CODEX_APP_SERVER = path.join(REPO_DIR, 'scripts', 'mock-codex-app-server.js'); function sleep(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); } function freePort() { return new Promise((resolve, reject) => { const server = net.createServer(); server.once('error', reject); server.listen(0, '127.0.0.1', () => { const address = server.address(); server.close(() => resolve(address.port)); }); }); } async function waitForPort(port, timeoutMs = 8_000) { const started = Date.now(); while (Date.now() - started < timeoutMs) { const ready = await new Promise((resolve) => { const socket = net.createConnection({ host: '127.0.0.1', port }); socket.once('connect', () => { socket.destroy(); resolve(true); }); socket.once('error', () => resolve(false)); }); if (ready) return; await sleep(50); } throw new Error(`等待测试服务端口 ${port} 超时`); } async function waitForJson(filePath, predicate, timeoutMs = 5_000) { const started = Date.now(); while (Date.now() - started < timeoutMs) { try { const value = JSON.parse(fs.readFileSync(filePath, 'utf8')); if (predicate(value)) return value; } catch {} await sleep(40); } throw new Error(`等待会话状态超时: ${path.basename(filePath)}`); } async function waitForCondition(label, predicate, timeoutMs = 8_000) { const started = Date.now(); while (Date.now() - started < timeoutMs) { if (predicate()) return; await sleep(25); } throw new Error(`等待${label}超时`); } function startClassifierProvider(port, requests) { const server = http.createServer((req, res) => { let raw = ''; req.setEncoding('utf8'); req.on('data', (chunk) => { raw += chunk; }); req.on('end', () => { let body = null; try { body = JSON.parse(raw); } catch {} requests.push({ method: req.method, url: req.url, authorization: req.headers.authorization || '', body, }); const inputText = Array.isArray(body?.input) ? body.input.map((item) => String(item?.content || '')).join('\n') : ''; const statusIds = body?.text?.format?.schema?.properties?.statusId?.enum || []; const eventType = inputText.includes('"eventType": "turn_completed"') ? 'turn_completed' : 'user_message_received'; const invalid = inputText.includes('分类返回非标准 JSON'); const statusId = eventType === 'turn_completed' ? 'completed' : 'waiting-release'; const result = { statusId: statusIds.includes(statusId) ? statusId : statusIds[0], reason: eventType === 'turn_completed' ? '主轮次已经完成目标与验证。' : '用户要求开始推进并等待发布。', summary: eventType === 'turn_completed' ? '自动分类集成任务已完成。' : '自动分类集成任务正在推进并等待发布。', }; const outputText = invalid ? `\`\`\`json\n${JSON.stringify(result)}\n\`\`\`` : JSON.stringify(result); res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ status: 'completed', output_text: outputText })); }); }); return new Promise((resolve, reject) => { server.once('error', reject); server.listen(port, '127.0.0.1', () => resolve(server)); }); } function connectClient(port, password) { return new Promise((resolve, reject) => { const ws = new WebSocket(`ws://127.0.0.1:${port}/ws`); const messages = []; let settled = false; ws.on('open', () => ws.send(JSON.stringify({ type: 'auth', password }))); ws.on('message', (raw) => { const message = JSON.parse(String(raw)); messages.push(message); if (message.type === 'auth_result' && message.success && !settled) { settled = true; resolve({ ws, messages }); } }); ws.once('error', (error) => { if (!settled) reject(error); }); }); } function nextMessage(client, predicate, timeoutMs = 5_000) { return new Promise((resolve, reject) => { const started = Date.now(); const timer = setInterval(() => { const index = client.messages.findIndex(predicate); if (index >= 0) { const [message] = client.messages.splice(index, 1); clearInterval(timer); resolve(message); } else if (Date.now() - started > timeoutMs) { clearInterval(timer); reject(new Error(`等待 WS 消息超时,当前类型: ${client.messages.map((item) => item.type).join(', ')}`)); } }, 25); }); } function request(client, payload, responseType) { client.ws.send(JSON.stringify(payload)); return nextMessage(client, (message) => ( message.type === responseType && message.requestId === payload.requestId )); } function callInternalMcp(port, token, payload) { return new Promise((resolve, reject) => { const body = JSON.stringify(payload); const req = http.request({ hostname: '127.0.0.1', port, path: '/api/internal/mcp', method: 'POST', headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body), 'X-CC-Web-MCP-Token': token, }, }, (res) => { let text = ''; res.setEncoding('utf8'); res.on('data', (chunk) => { text += chunk; }); res.on('end', () => resolve({ status: res.statusCode, body: JSON.parse(text || '{}') })); }); req.once('error', reject); req.end(body); }); } function callSharedMcp(port, token, sourceSessionId, method, params = {}, id = `mcp-${Date.now()}`) { return new Promise((resolve, reject) => { const body = JSON.stringify({ jsonrpc: '2.0', id, method, params }); const req = http.request({ hostname: '127.0.0.1', port, path: `/api/internal/mcp/stream?sourceSessionId=${encodeURIComponent(sourceSessionId)}`, method: 'POST', headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body), 'X-CC-Web-MCP-Token': token, }, }, (res) => { let text = ''; res.setEncoding('utf8'); res.on('data', (chunk) => { text += chunk; }); res.on('end', () => resolve({ status: res.statusCode, body: JSON.parse(text || '{}') })); }); req.once('error', reject); req.end(body); }); } function taskToolNames(toolsListResponse) { return (toolsListResponse.body?.result?.tools || []) .map((tool) => tool.name) .filter((name) => name.startsWith('ccweb_task_')); } function taskUpdateTool(toolsListResponse) { return (toolsListResponse.body?.result?.tools || []) .find((tool) => tool.name === 'ccweb_task_update'); } function callStdioMcpToolsList(port, token, sourceSessionId) { return new Promise((resolve, reject) => { const bridge = spawn(process.execPath, [SERVER_PATH, '--ccweb-mcp-server'], { cwd: REPO_DIR, env: { ...process.env, CC_WEB_MCP_URL: `http://127.0.0.1:${port}/api/internal/mcp`, CC_WEB_MCP_TOKEN: token, CC_WEB_SOURCE_SESSION_ID: sourceSessionId, CC_WEB_CROSS_HOP_COUNT: '0', }, stdio: ['pipe', 'pipe', 'pipe'], }); let buffer = ''; let stderr = ''; const timer = setTimeout(() => finish(new Error(`stdio tools/list 超时: ${stderr}`)), 5_000); let settled = false; function finish(error, value) { if (settled) return; settled = true; clearTimeout(timer); try { bridge.stdin.end(); } catch {} if (bridge.exitCode === null) bridge.kill('SIGTERM'); if (error) reject(error); else resolve(value); } bridge.stderr.on('data', (chunk) => { stderr += chunk.toString(); }); bridge.once('error', finish); bridge.stdout.on('data', (chunk) => { buffer += chunk.toString(); let newline; while ((newline = buffer.indexOf('\n')) >= 0) { const line = buffer.slice(0, newline).trim(); buffer = buffer.slice(newline + 1); if (!line) continue; let message; try { message = JSON.parse(line); } catch { continue; } if (message.id === 'stdio-tools') finish(null, message); } }); bridge.stdin.write(`${JSON.stringify({ jsonrpc: '2.0', id: 'stdio-init', method: 'initialize', params: { protocolVersion: '2024-11-05', capabilities: {}, clientInfo: { name: 'task-board-test', version: '1' } }, })}\n`); bridge.stdin.write(`${JSON.stringify({ jsonrpc: '2.0', id: 'stdio-tools', method: 'tools/list', params: {} })}\n`); }); } async function main() { const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'cc-web-task-board-')); const configDir = path.join(tempRoot, 'config'); const sessionsDir = path.join(tempRoot, 'sessions'); const logsDir = path.join(tempRoot, 'logs'); const homeDir = path.join(tempRoot, 'home'); [configDir, sessionsDir, logsDir, homeDir].forEach((directory) => fs.mkdirSync(directory, { recursive: true })); const classifierPort = await freePort(); const classifierRequests = []; const classifierProvider = await startClassifierProvider(classifierPort, classifierRequests); const codexHome = path.join(homeDir, '.codex'); fs.mkdirSync(codexHome, { recursive: true }); fs.writeFileSync(path.join(codexHome, 'config.toml'), [ 'model_provider = "classifier_test"', 'model = "gpt-classifier-test"', 'model_reasoning_effort = "low"', '', '[model_providers.classifier_test]', 'name = "Classifier Test"', `base_url = "http://127.0.0.1:${classifierPort}/v1"`, 'wire_api = "responses"', 'env_key = "CLASSIFIER_TEST_API_KEY"', '', ].join('\n')); fs.writeFileSync(path.join(codexHome, 'auth.json'), JSON.stringify({ CLASSIFIER_TEST_API_KEY: 'classifier-test-key', tokens: { access_token: 'must-not-be-used' }, }, null, 2)); const ordinaryId = 'ordinary-existing'; const ordinaryPath = path.join(sessionsDir, `${ordinaryId}.json`); fs.writeFileSync(ordinaryPath, JSON.stringify({ id: ordinaryId, title: '普通历史会话', agent: 'claude', cwd: homeDir, created: '2026-08-11T00:00:00.000Z', updated: '2026-08-11T00:00:00.000Z', messages: [], }, null, 2)); const legacyProgressId = 'legacy-progress-existing'; const legacyProgressPath = path.join(sessionsDir, `${legacyProgressId}.json`); fs.writeFileSync(legacyProgressPath, JSON.stringify({ id: legacyProgressId, title: '包含历史百分比的会话', agent: 'codexapp', cwd: homeDir, created: '2026-08-11T00:00:00.000Z', updated: '2026-08-11T00:01:00.000Z', messages: [], taskTracking: { enabled: true, statusId: 'in_progress', source: 'mcp', reason: '历史数据', summary: '读取时忽略旧百分比', progress: 37, reportingStatus: 'reported', version: 4, enabledAt: '2026-08-11T00:00:00.000Z', disabledAt: null, statusUpdatedAt: '2026-08-11T00:01:00.000Z', completedAt: null, archivedAt: null, archivedBy: null, }, }, null, 2)); const port = await freePort(); const password = 'TaskBoard!234'; const internalToken = 'TaskBoardInternal!234'; const child = spawn(process.execPath, [SERVER_PATH], { cwd: REPO_DIR, env: { ...process.env, PORT: String(port), CC_WEB_PASSWORD: password, CC_WEB_INTERNAL_MCP_TOKEN: internalToken, CC_WEB_CONFIG_DIR: configDir, CC_WEB_SESSIONS_DIR: sessionsDir, CC_WEB_LOGS_DIR: logsDir, CC_WEB_TASK_BOARD_ENABLED: '1', HOME: homeDir, CLAUDE_PATH: MOCK_CLAUDE, CODEX_PATH: MOCK_CODEX_APP_SERVER, }, stdio: ['ignore', 'pipe', 'pipe'], }); let stderr = ''; child.stderr.on('data', (chunk) => { stderr += chunk.toString(); }); const clients = []; let dynamicToolsListEvidence = null; try { await waitForPort(port); const primary = await connectClient(port, password); const observer = await connectClient(port, password); clients.push(primary, observer); primary.messages.length = 0; observer.messages.length = 0; const initialQuery = await request(primary, { type: 'task_board_query', requestId: 'query-initial', }, 'task_board_result'); assert.equal(initialQuery.ok, true); assert.equal(initialQuery.tasks.some((task) => task.sessionId === ordinaryId), false); const legacyProgressTask = initialQuery.tasks.find((task) => task.sessionId === legacyProgressId); assert(legacyProgressTask); assert.equal(Object.hasOwn(legacyProgressTask.taskTracking, 'progress'), false); assert(initialQuery.tasks.every((task) => !Object.hasOwn(task.taskTracking, 'progress'))); assert.equal(JSON.parse(fs.readFileSync(legacyProgressPath, 'utf8')).taskTracking.progress, 37); assert(Array.isArray(initialQuery.statusDefinitions)); assert(initialQuery.statusDefinitions.every((definition) => ( typeof definition.prompt === 'string' && !Object.hasOwn(definition, 'baseStatus') ))); assert.equal(Object.hasOwn(initialQuery.facets || {}, 'agents'), false); assert.equal(Object.hasOwn(initialQuery.facets || {}, 'priorities'), false); assert.equal(Object.hasOwn(JSON.parse(fs.readFileSync(ordinaryPath, 'utf8')), 'taskTracking'), false); const untrackedTools = await callSharedMcp( port, internalToken, ordinaryId, 'tools/list', {}, 'tools-untracked', ); assert.equal(untrackedTools.status, 200); assert.deepEqual(taskToolNames(untrackedTools), [], '未加入看板的来源会话不得暴露任务 MCP'); const untrackedStdioTools = await callStdioMcpToolsList(port, internalToken, ordinaryId); assert.deepEqual( untrackedStdioTools.result.tools.map((tool) => tool.name).filter((name) => name.startsWith('ccweb_task_')), [], 'stdio MCP tools/list 也必须按来源会话关闭任务工具', ); const untrackedComposer = await request(primary, { type: 'composer_suggestions', requestId: 'composer-untracked', trigger: '/', query: '', sessionId: ordinaryId, agent: 'codexapp', }, 'composer_suggestions'); assert.equal(untrackedComposer.items.some((item) => item.name === 'ccweb_task_status_list'), false); assert.equal(untrackedComposer.items.some((item) => item.name === 'ccweb_task_update'), false); primary.ws.send(JSON.stringify({ type: 'load_session', requestId: 'load-ordinary', sessionId: ordinaryId, })); const ordinaryInfo = await nextMessage(primary, (message) => ( message.type === 'session_info' && message.requestId === 'load-ordinary' )); assert.equal(ordinaryInfo.taskTracking.enabled, false); assert.equal(Object.hasOwn(ordinaryInfo.taskTracking, 'baseStatus'), false); assert.equal(Object.hasOwn(ordinaryInfo.taskTracking, 'progress'), false); assert.equal(Object.hasOwn(JSON.parse(fs.readFileSync(ordinaryPath, 'utf8')), 'taskTracking'), false); primary.ws.send(JSON.stringify({ type: 'new_session', requestId: 'new-normal', agent: 'claude', cwd: homeDir, })); const normalInfo = await nextMessage(primary, (message) => ( message.type === 'session_info' && message.requestId === 'new-normal' )); assert.equal(normalInfo.taskTracking.enabled, false); assert.equal(Object.hasOwn(normalInfo.taskTracking, 'baseStatus'), false); assert.equal(Object.hasOwn(normalInfo.taskTracking, 'progress'), false); primary.ws.send(JSON.stringify({ type: 'new_session', requestId: 'new-task', agent: 'claude', cwd: homeDir, taskTrackingEnabled: true, })); const taskInfo = await nextMessage(primary, (message) => ( message.type === 'session_info' && message.requestId === 'new-task' )); const taskId = taskInfo.sessionId; assert.equal(taskInfo.taskTracking.enabled, true); assert.equal(Object.hasOwn(taskInfo.taskTracking, 'progress'), false); const trackedToolsBeforeCustom = await callSharedMcp( port, internalToken, taskId, 'tools/list', {}, 'tools-tracked-defaults', ); assert.deepEqual(taskToolNames(trackedToolsBeforeCustom), ['ccweb_task_update']); const trackedUpdateBeforeCustom = taskUpdateTool(trackedToolsBeforeCustom); assert(trackedUpdateBeforeCustom); assert.match(trackedUpdateBeforeCustom.description, /仅当当前对话任务状态真实发生变化时调用/); assert.match(trackedUpdateBeforeCustom.description, /单轮开始、结束、运行停止不代表任务状态变化/); assert.match(trackedUpdateBeforeCustom.description, new RegExp(`当前状态[^\n]*${taskInfo.taskTracking.statusId}`)); assert.deepEqual( trackedUpdateBeforeCustom.inputSchema.properties.statusId.enum, initialQuery.statusDefinitions.filter((definition) => definition.enabled).map((definition) => definition.id), ); for (const definition of initialQuery.statusDefinitions.filter((item) => item.enabled)) { assert(trackedUpdateBeforeCustom.description.includes(definition.label)); assert(trackedUpdateBeforeCustom.description.includes(definition.id)); assert(trackedUpdateBeforeCustom.description.includes(definition.prompt)); } assert.deepEqual( Object.keys(trackedUpdateBeforeCustom.inputSchema.properties), ['statusId', 'reason', 'summary'], '动态 update schema 只保留状态和必要说明参数', ); const trackedComposer = await request(primary, { type: 'composer_suggestions', requestId: 'composer-tracked', trigger: '/', query: '', sessionId: taskId, agent: 'codexapp', }, 'composer_suggestions'); assert.equal(trackedComposer.items[0]?.name, 'ccweb_prompt_user'); assert.equal(trackedComposer.items[1]?.name, 'ccweb_task_update'); assert.equal(trackedComposer.items.some((item) => item.name === 'ccweb_task_status_list'), false); const trackingResult = await request(primary, { type: 'task_tracking_set', requestId: 'tracking-existing', sessionId: ordinaryId, enabled: true, }, 'task_tracking_result'); assert.equal(trackingResult.ok, true); assert.equal(trackingResult.task.taskTracking.enabled, true); const trackingEvent = await nextMessage(observer, (message) => ( message.type === 'task_board_event' && message.sessionId === ordinaryId )); assert.equal(trackingEvent.task.taskTracking.enabled, true); const queryWithoutRetiredFacets = await request(primary, { type: 'task_board_query', requestId: 'query-with-retired-facets', filters: { priority: 'never-match', agent: 'never-match' }, priority: 'also-retired', agent: 'also-retired', }, 'task_board_result'); assert.equal(queryWithoutRetiredFacets.ok, true); assert(queryWithoutRetiredFacets.tasks.some((task) => task.sessionId === ordinaryId)); assert.equal(Object.hasOwn(queryWithoutRetiredFacets.facets || {}, 'agents'), false); assert.equal(Object.hasOwn(queryWithoutRetiredFacets.facets || {}, 'priorities'), false); const waitingReleaseDefinition = { id: 'waiting-release', label: '等待发布', color: '#f97316', order: 38, enabled: true, system: false, prompt: '当实现和验证已完成,但仍需等待部署环境或发布窗口时选择此列。', }; assert.deepEqual( Object.keys(waitingReleaseDefinition), ['id', 'label', 'color', 'order', 'enabled', 'system', 'prompt'], '集成夹具必须与前端新增列请求字段完全一致' ); const upsert = await request(primary, { type: 'task_status_definition_upsert', requestId: 'definition-upsert', expectedVersion: initialQuery.definitionsVersion, definition: waitingReleaseDefinition, }, 'task_status_definitions_result'); assert.equal(upsert.ok, true); const waitingRelease = upsert.statusDefinitions.find((definition) => definition.id === 'waiting-release'); assert(waitingRelease); assert.equal(waitingRelease.prompt, waitingReleaseDefinition.prompt); assert.equal(waitingRelease.description || '', ''); assert.equal(Object.hasOwn(waitingRelease, 'baseStatus'), false); const toolsAfterAdd = await callSharedMcp( port, internalToken, taskId, 'tools/list', {}, 'tools-after-add', ); assert(taskUpdateTool(toolsAfterAdd).inputSchema.properties.statusId.enum.includes('waiting-release')); assert(taskUpdateTool(toolsAfterAdd).description.includes(waitingReleaseDefinition.prompt)); const editedPrompt = '当实现与验证均已完成,但必须等待发布窗口、部署条件或最终放行时选择此列。'; const editedDefinitionPayload = { ...waitingReleaseDefinition, prompt: editedPrompt, }; assert.equal(Object.hasOwn(editedDefinitionPayload, 'description'), false); const edited = await request(primary, { type: 'task_status_definition_upsert', requestId: 'definition-edit-prompt', expectedVersion: upsert.definitionsVersion, definition: editedDefinitionPayload, }, 'task_status_definitions_result'); assert.equal(edited.ok, true); const toolsAfterEdit = await callSharedMcp( port, internalToken, taskId, 'tools/list', {}, 'tools-after-edit', ); assert(taskUpdateTool(toolsAfterEdit).description.includes(editedPrompt)); assert.equal(taskUpdateTool(toolsAfterEdit).description.includes(waitingReleaseDefinition.prompt), false); dynamicToolsListEvidence = { untrackedTaskTools: taskToolNames(untrackedTools), trackedTaskTools: taskToolNames(toolsAfterEdit), update: { description: taskUpdateTool(toolsAfterEdit).description, statusIdEnum: taskUpdateTool(toolsAfterEdit).inputSchema.properties.statusId.enum, properties: Object.keys(taskUpdateTool(toolsAfterEdit).inputSchema.properties), }, }; const trackedStdioTools = await callStdioMcpToolsList(port, internalToken, taskId); const stdioTaskTools = trackedStdioTools.result.tools.filter((tool) => tool.name.startsWith('ccweb_task_')); assert.deepEqual(stdioTaskTools.map((tool) => tool.name), ['ccweb_task_update']); assert(stdioTaskTools[0].description.includes(editedPrompt)); assert(stdioTaskTools[0].inputSchema.properties.statusId.enum.includes('waiting-release')); const statusResult = await request(primary, { type: 'task_status_set', requestId: 'status-set', sessionId: taskId, statusId: 'waiting-release', expectedVersion: taskInfo.taskTracking.version, }, 'task_status_result'); assert.equal(statusResult.ok, true); assert.equal(statusResult.task.taskTracking.statusId, 'waiting-release'); assert.equal(Object.hasOwn(statusResult.task.taskTracking, 'progress'), false); assert.equal(Object.hasOwn(JSON.parse(fs.readFileSync(path.join(sessionsDir, `${taskId}.json`), 'utf8')).taskTracking, 'progress'), false); primary.ws.send(JSON.stringify({ type: 'new_session', requestId: 'new-mcp-task', agent: 'codexapp', cwd: homeDir, taskTrackingEnabled: true, })); const mcpInfo = await nextMessage(primary, (message) => ( message.type === 'session_info' && message.requestId === 'new-mcp-task' )); const manualMcpTaskStatus = await request(primary, { type: 'task_status_set', requestId: 'manual-status-before-mcp', sessionId: mcpInfo.sessionId, statusId: 'blocked', reason: '用户先手动移动卡片', expectedVersion: mcpInfo.taskTracking.version, }, 'task_status_result'); assert.equal(manualMcpTaskStatus.ok, true); assert.equal(manualMcpTaskStatus.task.taskTracking.statusId, 'blocked'); assert.equal(manualMcpTaskStatus.task.taskTracking.source, 'user'); assert.equal( manualMcpTaskStatus.task.taskTracking.version, mcpInfo.taskTracking.version + 1, ); const retiredMcpProgress = await callInternalMcp(port, internalToken, { tool: 'ccweb_task_update', sourceSessionId: mcpInfo.sessionId, args: { statusId: 'waiting-release', progress: 50 }, }); assert.equal(retiredMcpProgress.status, 400); assert.equal(retiredMcpProgress.body.code, 'task_status_invalid'); const mcpUpdate = await callSharedMcp(port, internalToken, mcpInfo.sessionId, 'tools/call', { name: 'ccweb_task_update', arguments: { statusId: 'waiting-release', reason: '无需 status_list 即可选择自定义列' }, }, 'call-update-direct'); assert.equal(mcpUpdate.status, 200); assert.equal(mcpUpdate.body.result.isError, false); const mcpUpdatePayload = mcpUpdate.body.result.structuredContent; assert.equal(mcpUpdatePayload.ok, true); assert.equal(mcpUpdatePayload.changed, true); assert.equal(Object.hasOwn(mcpUpdatePayload, 'ignored'), false); assert.equal(Object.hasOwn(mcpUpdatePayload, 'ignoredReason'), false); assert.equal(mcpUpdatePayload.sessionId, mcpInfo.sessionId); assert.equal(mcpUpdatePayload.currentStatus.statusId, 'waiting-release'); assert.equal(mcpUpdatePayload.currentStatus.source, 'mcp'); assert.equal( mcpUpdatePayload.currentStatus.version, manualMcpTaskStatus.task.taskTracking.version + 1, ); assert.equal(Object.hasOwn(mcpUpdatePayload.currentStatus, 'progress'), false); assert.equal(mcpUpdatePayload.currentStatusDefinition.prompt, editedPrompt); const persistedAfterMcpOverride = JSON.parse(fs.readFileSync( path.join(sessionsDir, `${mcpInfo.sessionId}.json`), 'utf8', )); assert.equal(persistedAfterMcpOverride.taskTracking.statusId, 'waiting-release'); assert.equal(persistedAfterMcpOverride.taskTracking.source, 'mcp'); assert.equal( persistedAfterMcpOverride.taskTracking.version, manualMcpTaskStatus.task.taskTracking.version + 1, ); const retiredStatusListCall = await callSharedMcp(port, internalToken, mcpInfo.sessionId, 'tools/call', { name: 'ccweb_task_status_list', arguments: {}, }, 'call-retired-status-list'); assert.equal(retiredStatusListCall.body.result.isError, true); assert.equal(retiredStatusListCall.body.result.structuredContent.code, 'unknown_tool'); const disabled = await request(primary, { type: 'task_status_definition_upsert', requestId: 'definition-disable', expectedVersion: edited.definitionsVersion, definition: { ...editedDefinitionPayload, enabled: false }, }, 'task_status_definitions_result'); assert.equal(disabled.ok, true); const toolsAfterDisable = await callSharedMcp( port, internalToken, mcpInfo.sessionId, 'tools/list', {}, 'tools-after-disable', ); assert.equal(taskUpdateTool(toolsAfterDisable).inputSchema.properties.statusId.enum.includes('waiting-release'), false); assert.equal(taskUpdateTool(toolsAfterDisable).description.includes(editedPrompt), false); const disabledStdioTools = await callStdioMcpToolsList(port, internalToken, mcpInfo.sessionId); const disabledStdioUpdate = disabledStdioTools.result.tools .find((tool) => tool.name === 'ccweb_task_update'); assert(disabledStdioUpdate); assert.equal(disabledStdioUpdate.inputSchema.properties.statusId.enum.includes('waiting-release'), false); assert.equal(disabledStdioUpdate.description.includes(editedPrompt), false); const reenabled = await request(primary, { type: 'task_status_definition_upsert', requestId: 'definition-reenable', expectedVersion: disabled.definitionsVersion, definition: { ...editedDefinitionPayload, enabled: true }, }, 'task_status_definitions_result'); assert.equal(reenabled.ok, true); primary.ws.send(JSON.stringify({ type: 'new_session', requestId: 'new-classifier-task', agent: 'codexapp', cwd: homeDir, taskTrackingEnabled: true, })); const classifierInfo = await nextMessage(primary, (message) => ( message.type === 'session_info' && message.requestId === 'new-classifier-task' )); const classifierSessionId = classifierInfo.sessionId; const classifierRequestStart = classifierRequests.length; primary.ws.send(JSON.stringify({ type: 'message', sessionId: classifierSessionId, agent: 'codexapp', mode: 'yolo', clientMessageId: 'classifier-user-event', text: '开始推进自动分类集成任务,完成后等待发布。', })); const classifiedUserEvent = await nextMessage(observer, (message) => ( message.type === 'task_board_event' && message.event === 'classifier:user_message_received' && message.sessionId === classifierSessionId ), 8_000); assert.equal(classifiedUserEvent.task.taskTracking.statusId, 'waiting-release'); assert.equal(classifiedUserEvent.task.taskTracking.source, 'classifier'); await nextMessage(primary, (message) => ( message.type === 'done' && message.sessionId === classifierSessionId ), 8_000); const classifiedCompletionEvent = await nextMessage(observer, (message) => ( message.type === 'task_board_event' && message.event === 'classifier:turn_completed' && message.sessionId === classifierSessionId ), 8_000); assert.equal(classifiedCompletionEvent.task.taskTracking.statusId, 'completed'); assert.equal(classifiedCompletionEvent.task.taskTracking.source, 'classifier'); assert.equal( classifiedCompletionEvent.task.taskTracking.version, classifierInfo.taskTracking.version + 2, ); await waitForCondition('两次自动分类请求', () => classifierRequests.length >= classifierRequestStart + 2); const [userClassificationRequest, completionClassificationRequest] = classifierRequests.slice( classifierRequestStart, classifierRequestStart + 2, ); for (const classifierRequest of [userClassificationRequest, completionClassificationRequest]) { assert.equal(classifierRequest.method, 'POST'); assert.equal(classifierRequest.url, '/v1/responses'); assert.equal(classifierRequest.authorization, 'Bearer classifier-test-key'); assert.equal(classifierRequest.body.model, 'gpt-classifier-test'); assert.deepEqual(classifierRequest.body.tools, []); assert.equal(classifierRequest.body.tool_choice, 'none'); assert.equal(classifierRequest.body.store, false); assert.equal(classifierRequest.body.text.format.type, 'json_schema'); assert.equal(classifierRequest.body.text.format.strict, true); assert(classifierRequest.body.text.format.schema.properties.statusId.enum.includes('waiting-release')); const developerPrompt = classifierRequest.body.input.find((item) => item.role === 'developer')?.content || ''; assert(developerPrompt.includes('唯一职责是分类')); assert(developerPrompt.includes('不要执行、继续、检查或验证任务')); assert(developerPrompt.includes('分类提示词”是唯一状态语义来源')); assert(developerPrompt.includes('请求用户输入也是主对话本轮完成')); const evidencePrompt = classifierRequest.body.input.find((item) => item.role === 'user')?.content || ''; assert(evidencePrompt.includes(editedPrompt)); } assert(userClassificationRequest.body.input.some((item) => ( item.role === 'user' && item.content.includes('"eventType": "user_message_received"') ))); assert(completionClassificationRequest.body.input.some((item) => ( item.role === 'user' && item.content.includes('"eventType": "turn_completed"') ))); const beforeInvalidClassification = JSON.parse(fs.readFileSync( path.join(sessionsDir, `${classifierSessionId}.json`), 'utf8', )).taskTracking; const invalidRequestStart = classifierRequests.length; primary.ws.send(JSON.stringify({ type: 'message', sessionId: classifierSessionId, agent: 'codexapp', mode: 'yolo', clientMessageId: 'classifier-invalid-json', text: '分类返回非标准 JSON,但主对话仍应正常完成。', })); await nextMessage(primary, (message) => ( message.type === 'done' && message.sessionId === classifierSessionId ), 8_000); await waitForCondition('非标准 JSON 分类请求', () => classifierRequests.length >= invalidRequestStart + 2); await sleep(100); const afterInvalidClassification = JSON.parse(fs.readFileSync( path.join(sessionsDir, `${classifierSessionId}.json`), 'utf8', )).taskTracking; assert.equal(afterInvalidClassification.statusId, beforeInvalidClassification.statusId); assert.equal(afterInvalidClassification.version, beforeInvalidClassification.version); assert.equal(afterInvalidClassification.summary, beforeInvalidClassification.summary); const forgedSource = await callInternalMcp(port, internalToken, { tool: 'ccweb_task_update', sourceSessionId: mcpInfo.sessionId, args: { statusId: 'completed', sessionId: taskId }, }); assert.equal(forgedSource.status, 400); assert.equal(forgedSource.body.code, 'task_status_invalid'); const forbiddenArchive = await callInternalMcp(port, internalToken, { tool: 'ccweb_task_archive_set', sourceSessionId: mcpInfo.sessionId, args: { archived: true }, }); assert.equal(forbiddenArchive.status, 400); assert.equal(forbiddenArchive.body.code, 'unknown_tool'); const archiveResult = await request(primary, { type: 'task_archive_set', requestId: 'archive-set', sessionId: taskId, archived: true, expectedVersion: statusResult.task.taskTracking.version, }, 'task_archive_result'); assert.equal(archiveResult.ok, true); assert(archiveResult.task.taskTracking.archivedAt); const remove = await request(primary, { type: 'task_status_definition_remove', requestId: 'definition-remove', statusId: 'waiting-release', migrateTo: 'in_progress', expectedVersion: reenabled.definitionsVersion, }, 'task_status_definitions_result'); assert.equal(remove.ok, true); assert.equal(remove.removedId, 'waiting-release'); const badStatus = await request(primary, { type: 'task_status_set', requestId: 'status-error', sessionId: taskId, statusId: 'does_not_exist', }, 'task_status_result'); assert.equal(badStatus.ok, false); assert.equal(badStatus.requestId, 'status-error'); assert.equal(badStatus.code, 'task_status_unknown'); const unarchive = await request(primary, { type: 'task_archive_set', requestId: 'archive-for-resume', sessionId: taskId, archived: false, expectedVersion: remove.tasks.find((task) => task.sessionId === taskId).taskTracking.version, }, 'task_archive_result'); const rearchive = await request(primary, { type: 'task_archive_set', requestId: 'rearchive-for-resume', sessionId: taskId, archived: true, expectedVersion: unarchive.task.taskTracking.version, }, 'task_archive_result'); assert(rearchive.task.taskTracking.archivedAt); primary.ws.send(JSON.stringify({ type: 'message', text: '任务看板归档恢复回归', sessionId: taskId, agent: 'claude', mode: 'yolo', })); await nextMessage(primary, (message) => message.type === 'done' && message.sessionId === taskId, 8_000); const statusBeforeResume = rearchive.task.taskTracking.statusId; const resumed = await waitForJson(path.join(sessionsDir, `${taskId}.json`), (session) => ( session.taskTracking?.archivedAt === null )); assert.equal(resumed.taskTracking.statusId, statusBeforeResume); assert.equal(Object.hasOwn(resumed.taskTracking, 'reportingStatus'), false); assert.equal(Object.hasOwn(resumed.taskTracking, 'baseStatus'), false); assert.equal(Object.hasOwn(resumed.taskTracking, 'progress'), false); assert.equal(JSON.parse(fs.readFileSync(legacyProgressPath, 'utf8')).taskTracking.progress, 37); const statusConfig = JSON.parse(fs.readFileSync(path.join(configDir, 'task-board-statuses.json'), 'utf8')); assert.equal(statusConfig.version, 5); } finally { for (const client of clients) { try { client.ws.close(); } catch {} } child.kill('SIGTERM'); await sleep(200); if (child.exitCode === null) child.kill('SIGKILL'); await new Promise((resolve) => classifierProvider.close(resolve)); fs.rmSync(tempRoot, { recursive: true, force: true }); } console.log(JSON.stringify({ dynamicToolsListEvidence }, null, 2)); console.log('Task board integration checks passed.'); } main().catch((error) => { console.error(error.stack || error.message || error); process.exitCode = 1; });