'use strict'; const assert = require('assert'); const { MAX_VERSION, STATUS_ID_PATTERN, SYSTEM_STATUS_IDS, TaskBoardError, createTaskBoardService, } = require('../lib/task-board-service'); const { TASK_BOARD_MCP_TOOL_DEFINITIONS, createTaskBoardMcpToolDefinitions, createTaskBoardMcpHandler, createTaskBoardMcpHandlers, } = require('../lib/task-board-mcp'); function clone(value) { return JSON.parse(JSON.stringify(value)); } function sessionFixture(id, overrides = {}) { return { id, title: `任务 ${id}`, agent: 'codexapp', cwd: '/home/cc-web', created: '2026-08-11T00:00:00.000Z', updated: '2026-08-11T00:00:00.000Z', messages: [], ...overrides, }; } function createHarness() { const sessions = new Map([ ['ordinary-a', sessionFixture('ordinary-a')], ['migration-b', sessionFixture('migration-b')], ['priority-c', sessionFixture('priority-c')], ['lifecycle-d', sessionFixture('lifecycle-d')], ['mcp-e', sessionFixture('mcp-e')], ['classifier-h', sessionFixture('classifier-h')], ]); let statusConfig = null; let clockTick = 0; const service = createTaskBoardService({ loadSession(sessionId) { const session = sessions.get(sessionId); return session ? clone(session) : null; }, saveSession(session) { sessions.set(session.id, clone(session)); return true; }, listSessions() { return Array.from(sessions.values(), clone); }, loadStatusDefinitions() { return statusConfig; }, saveStatusDefinitions(nextConfig) { statusConfig = clone(nextConfig); return true; }, now() { const value = new Date(Date.UTC(2026, 7, 11, 1, 0, clockTick)); clockTick += 1; return value; }, }); return { service, sessions, getStatusConfig: () => clone(statusConfig), }; } function expectTaskError(fn, code) { assert.throws(fn, (error) => ( error instanceof TaskBoardError && error.code === code ), `应抛出 ${code}`); } async function main() { const harness = createHarness(); const { service } = harness; // 普通会话默认关闭,读取不会把默认结构写回会话。 const ordinaryDefault = service.getTask('ordinary-a'); assert.strictEqual(ordinaryDefault.taskTracking.enabled, false); assert.strictEqual(ordinaryDefault.taskTracking.statusId, 'unassigned'); assert.strictEqual('baseStatus' in ordinaryDefault.taskTracking, false); assert.strictEqual('progress' in ordinaryDefault.taskTracking, false); assert.strictEqual(ordinaryDefault.taskTracking.version, 0); assert.strictEqual(harness.sessions.get('ordinary-a').taskTracking, undefined); assert.deepStrictEqual(service.listTasks(), []); assert.deepStrictEqual(service.listTasks({ includeDisabled: true, archived: 'all' }), []); // 历史百分比字段只在读取时忽略,不返回,也不触发会话改写。 const legacyProgressSession = sessionFixture('legacy-progress-g', { taskTracking: { enabled: true, statusId: 'in_progress', source: 'mcp', reason: '历史任务', summary: '遗留百分比字段', progress: 64, reportingStatus: 'reported', version: 3, enabledAt: '2026-08-11T00:00:00.000Z', disabledAt: null, statusUpdatedAt: '2026-08-11T00:01:00.000Z', completedAt: null, archivedAt: null, archivedBy: null, }, }); harness.sessions.set(legacyProgressSession.id, clone(legacyProgressSession)); const legacyBeforeRead = clone(harness.sessions.get(legacyProgressSession.id)); const legacyTask = service.getTask(legacyProgressSession.id); assert.strictEqual(legacyTask.taskTracking.statusId, 'in_progress'); assert.strictEqual('progress' in legacyTask.taskTracking, false); assert.strictEqual('progress' in service.listTasks({ sessionId: legacyProgressSession.id })[0].taskTracking, false); assert.deepStrictEqual(harness.sessions.get(legacyProgressSession.id), legacyBeforeRead); harness.sessions.delete(legacyProgressSession.id); expectTaskError(() => service.updateStatus('ordinary-a', { statusId: 'in_progress', }, { source: 'mcp' }), 'task_tracking_disabled'); expectTaskError(() => service.setTracking('ordinary-a', true, { source: 'mcp' }), 'task_tracking_forbidden'); const enabledA = service.setTracking('ordinary-a', true, { source: 'user', id: 'user-a', expectedVersion: 0, }); assert.strictEqual(enabledA.taskTracking.enabled, true); assert.strictEqual(enabledA.taskTracking.source, 'default'); assert.strictEqual('progress' in enabledA.taskTracking, false); assert.strictEqual(enabledA.taskTracking.version, 1); assert.strictEqual(enabledA.agent, 'codexapp', 'Agent 仍应作为只读会话元数据返回'); assert.strictEqual(service.listTasks().length, 1); assert.strictEqual(service.listTasks({ query: '', archived: false }).length, 1); expectTaskError(() => service.listTasks({ agent: 'codexapp' }), 'task_status_invalid'); expectTaskError(() => service.listTasks({ baseStatus: 'in_progress' }), 'task_status_invalid'); expectTaskError(() => service.listTasks({ priority: 'high' }), 'task_status_invalid'); harness.sessions.set('preenabled-f', sessionFixture('preenabled-f', { taskTracking: { enabled: true } })); const initializedF = service.setTracking('preenabled-f', true, { source: 'user', expectedVersion: 0 }); assert.strictEqual(initializedF.taskTracking.enabled, true); assert.strictEqual(initializedF.taskTracking.version, 1); assert(initializedF.taskTracking.enabledAt); assert(initializedF.taskTracking.statusUpdatedAt); // 每个定义都是独立列;系统列 ID 稳定,所有列都有可编辑分类提示词。 const defaults = service.getStatusDefinitions(); assert.deepStrictEqual(defaults.filter((item) => item.system).map((item) => item.id).sort(), [...SYSTEM_STATUS_IDS].sort()); assert(defaults.every((item) => typeof item.prompt === 'string' && item.prompt.length > 0)); assert(defaults.every((item) => !Object.prototype.hasOwnProperty.call(item, 'baseStatus'))); assert(STATUS_ID_PATTERN.test('waiting-release')); assert(STATUS_ID_PATTERN.test('Waiting-Release_2')); assert(STATUS_ID_PATTERN.test(`A${'b'.repeat(79)}`)); assert.strictEqual(STATUS_ID_PATTERN.test(`A${'b'.repeat(80)}`), false); const updatedSystem = service.upsertStatusDefinition({ id: 'waiting_user', label: '等待用户', color: '#F59E0B', order: 35, prompt: '当后续推进必须等待用户补充、确认、授权或验收时选择此列。', version: 0, }, { source: 'user', id: 'admin' }); assert.strictEqual(updatedSystem.version, 1); assert.strictEqual(updatedSystem.definition.prompt, '当后续推进必须等待用户补充、确认、授权或验收时选择此列。'); assert.strictEqual('baseStatus' in updatedSystem.definition, false); assert.strictEqual(updatedSystem.definition.color, '#f59e0b'); expectTaskError(() => service.upsertStatusDefinition({ id: 'waiting_user', baseStatus: 'blocked', version: 1, }, { source: 'user' }), 'task_status_invalid'); expectTaskError(() => service.removeStatusDefinition('completed', null, { source: 'user' }), 'task_status_invalid'); // 与 Firefox 真实提交完全一致:definition 不包含 description。 const frontendDefinition = { id: 'waiting-release', label: '等待发布', color: '#f97316', order: 38, enabled: true, system: false, prompt: '当实现和验证已完成,但仍需等待部署环境或发布窗口时选择此列。', }; assert.strictEqual(Object.hasOwn(frontendDefinition, 'description'), false); const customResult = service.upsertStatusDefinition({ ...frontendDefinition, expectedVersion: 1, }, { source: 'user', id: 'admin' }); assert.strictEqual(customResult.version, 2); assert.strictEqual(customResult.definition.system, false); assert.strictEqual(customResult.definition.id, 'waiting-release'); assert.strictEqual(customResult.definition.prompt, '当实现和验证已完成,但仍需等待部署环境或发布窗口时选择此列。'); assert.strictEqual('baseStatus' in customResult.definition, false); const queriedCustomDefinition = service.getStatusDefinitions() .find((item) => item.id === 'waiting-release'); assert(queriedCustomDefinition); assert.strictEqual(queriedCustomDefinition.prompt, frontendDefinition.prompt); assert.strictEqual('baseStatus' in queriedCustomDefinition, false); assert.strictEqual(harness.getStatusConfig().version, 2); assert.strictEqual( harness.getStatusConfig().definitions.find((item) => item.id === 'waiting-release').prompt, '当实现和验证已完成,但仍需等待部署环境或发布窗口时选择此列。', ); assert(harness.getStatusConfig().definitions.every((item) => !('baseStatus' in item))); expectTaskError(() => service.upsertStatusDefinition({ id: 'waiting-release', prompt: '', expectedVersion: 2, }, { source: 'user' }), 'task_status_invalid'); expectTaskError(() => service.upsertStatusDefinition({ id: 'waiting-release', baseStatus: 'blocked', expectedVersion: 2, }, { source: 'user' }), 'task_status_invalid'); const disabledCustom = service.upsertStatusDefinition({ id: 'paused_external', label: '外部暂停', description: '暂时停用的状态。', prompt: '当任务明确因外部条件暂停时选择此列。', color: '#a855f7', order: 39, enabled: false, expectedVersion: 2, }, { source: 'user', id: 'admin' }); assert.strictEqual(disabledCustom.version, 3); expectTaskError(() => service.updateStatus('ordinary-a', { statusId: 'paused_external', version: 1, }, { source: 'mcp' }), 'task_status_invalid'); const customTaskA = service.updateStatus('ordinary-a', { statusId: 'waiting-release', reason: '代码已完成', summary: '等待部署环境', version: 1, }, { source: 'mcp', id: 'agent-a' }); assert.strictEqual(customTaskA.taskTracking.statusId, 'waiting-release'); assert.strictEqual('baseStatus' in customTaskA.taskTracking, false); assert.strictEqual('progress' in customTaskA.taskTracking, false); assert.strictEqual('baseStatus' in customTaskA.status, false); assert.strictEqual(customTaskA.taskTracking.source, 'mcp'); assert.strictEqual(customTaskA.taskTracking.version, 2); const queriedCustomTasks = service.listTasks({ statusId: 'waiting-release' }); assert.deepStrictEqual(queriedCustomTasks.map((task) => task.sessionId), ['ordinary-a']); assert.strictEqual('progress' in queriedCustomTasks[0].taskTracking, false); // 自动分类器是独立审计来源,仍由 expectedVersion 负责并发门禁。 const classifierEnabled = service.setTracking('classifier-h', true, { source: 'user', expectedVersion: 0, }); const classifierUserMove = service.updateStatus('classifier-h', { statusId: 'waiting_user', expectedVersion: classifierEnabled.taskTracking.version, }, { source: 'user', id: 'user-h' }); const classifierMove = service.updateStatus('classifier-h', { statusId: 'waiting-release', reason: '用户已确认继续推进', summary: '等待发布前的最终处理', expectedVersion: classifierUserMove.taskTracking.version, }, { source: 'classifier', id: 'task-status-classifier' }); assert.strictEqual(classifierMove.changed, true); assert.strictEqual(classifierMove.taskTracking.statusId, 'waiting-release'); assert.strictEqual(classifierMove.taskTracking.source, 'classifier'); assert.strictEqual(classifierMove.taskTracking.version, classifierUserMove.taskTracking.version + 1); expectTaskError(() => service.updateStatus('classifier-h', { statusId: 'completed', expectedVersion: classifierUserMove.taskTracking.version, }, { source: 'classifier' }), 'task_version_conflict'); // 停用跟踪仍保留状态;迁移会覆盖启用和停用会话中的引用。 const enabledB = service.setTracking('migration-b', true, { source: 'user', expectedVersion: 0 }); const customTaskB = service.updateStatus('migration-b', { statusId: 'waiting-release', version: enabledB.taskTracking.version, }, { source: 'mcp' }); const disabledB = service.setTracking('migration-b', false, { source: 'user', expectedVersion: customTaskB.taskTracking.version, }); assert.strictEqual(disabledB.taskTracking.enabled, false); assert.strictEqual(disabledB.taskTracking.statusId, 'waiting-release'); assert.strictEqual(service.listTasks().some((task) => task.sessionId === 'migration-b'), false); assert(service.listTasks({ includeDisabled: true, archived: 'all' }) .some((task) => task.sessionId === 'migration-b')); expectTaskError(() => service.removeStatusDefinition( 'waiting-release', null, { source: 'user', expectedVersion: 3 }, ), 'task_status_in_use'); expectTaskError(() => service.removeStatusDefinition( 'waiting-release', 'paused_external', { source: 'user', expectedVersion: 3 }, ), 'task_status_invalid'); const migration = service.removeStatusDefinition( 'waiting-release', 'waiting_user', { source: 'user', expectedVersion: 3 }, ); assert.strictEqual(migration.version, 4); assert.deepStrictEqual(migration.migratedSessionIds.sort(), ['classifier-h', 'migration-b', 'ordinary-a']); assert.strictEqual(service.getTask('classifier-h').taskTracking.statusId, 'waiting_user'); assert.strictEqual(service.getTask('ordinary-a').taskTracking.statusId, 'waiting_user'); assert.strictEqual(service.getTask('migration-b').taskTracking.statusId, 'waiting_user'); assert.strictEqual(service.getTask('migration-b').taskTracking.enabled, false); assert.strictEqual(service.getStatusDefinitions().some((item) => item.id === 'waiting-release'), false); const reviewColumn = service.upsertStatusDefinition({ id: 'review_ready', label: '等待评审', description: '实现完成,等待代码或产品评审。', prompt: '当实现与自测已完成,下一步明确是代码评审或产品评审时选择此列。', color: '#38bdf8', order: 36, enabled: true, version: 4, }, { source: 'user', id: 'admin' }); assert.strictEqual(reviewColumn.version, 5); assert.strictEqual('baseStatus' in reviewColumn.definition, false); // 用户与来源 MCP 均按最后一次有效写入生效,版本检查独立处理并发冲突。 const enabledC = service.setTracking('priority-c', true, { source: 'user', expectedVersion: 0 }); const userStatus = service.updateStatus('priority-c', { statusId: 'blocked', reason: '用户手动标记', version: enabledC.taskTracking.version, }, { source: 'user', id: 'owner' }); const mcpStatus = service.updateStatus('priority-c', { statusId: 'review_ready', version: userStatus.taskTracking.version, }, { source: 'mcp', id: 'agent-c' }); assert.strictEqual(mcpStatus.changed, true); assert.strictEqual(Object.hasOwn(mcpStatus, 'ignored'), false); assert.strictEqual(Object.hasOwn(mcpStatus, 'ignoredReason'), false); assert.strictEqual(mcpStatus.taskTracking.statusId, 'review_ready'); assert.strictEqual(mcpStatus.taskTracking.version, userStatus.taskTracking.version + 1); assert.strictEqual(mcpStatus.taskTracking.source, 'mcp'); const ignoredHook = service.recordLifecycleEvent('priority-c', { type: 'turn_started', occurredAt: '2026-08-11T02:00:00.000Z', version: mcpStatus.taskTracking.version, }); assert.strictEqual(ignoredHook.ignored, true); assert.strictEqual(ignoredHook.reason, 'no_change'); assert.strictEqual(ignoredHook.task.taskTracking.statusId, 'review_ready'); expectTaskError(() => service.updateStatus('priority-c', { statusId: 'completed', version: 1, }, { source: 'user' }), 'task_version_conflict'); // 严格校验状态 ID、颜色、退役字段、文本长度与版本。 expectTaskError(() => service.updateStatus('priority-c', { statusId: 'Bad.Status', version: mcpStatus.taskTracking.version, }, { source: 'user' }), 'task_status_invalid'); expectTaskError(() => service.updateStatus('priority-c', { statusId: 'blocked', progress: 42, version: mcpStatus.taskTracking.version, }, { source: 'user' }), 'task_status_invalid'); expectTaskError(() => service.updateStatus('priority-c', { statusId: 'blocked', reason: 'x'.repeat(2001), version: mcpStatus.taskTracking.version, }, { source: 'user' }), 'task_status_invalid'); expectTaskError(() => service.updateStatus('priority-c', { statusId: 'blocked', version: MAX_VERSION + 1, }, { source: 'user' }), 'task_status_invalid'); expectTaskError(() => service.upsertStatusDefinition({ id: 'bad_color', label: '坏颜色', description: '', prompt: '当颜色校验测试命中时选择此列。', color: 'red', order: 1, enabled: true, version: 5, }, { source: 'user' }), 'task_status_invalid'); // 归档是软标记,Agent 无权归档;新用户消息只恢复归档,不推断任务列。 const enabledD = service.setTracking('lifecycle-d', true, { source: 'user', expectedVersion: 0 }); const completedD = service.updateStatus('lifecycle-d', { statusId: 'completed', summary: '已完成', version: enabledD.taskTracking.version, }, { source: 'mcp' }); expectTaskError(() => service.setArchived('lifecycle-d', true, { source: 'mcp', expectedVersion: completedD.taskTracking.version, }), 'task_archive_forbidden'); const archivedD = service.setArchived('lifecycle-d', true, { source: 'user', id: 'owner', expectedVersion: completedD.taskTracking.version, }); assert.strictEqual(archivedD.archived, true); assert.strictEqual(archivedD.taskTracking.statusId, 'completed'); assert.strictEqual(service.listTasks().some((task) => task.sessionId === 'lifecycle-d'), false); assert.strictEqual(service.listTasks({ archived: true }).length, 1); const archivedTurn = service.recordLifecycleEvent('lifecycle-d', { type: 'turn_started', version: archivedD.taskTracking.version, }); assert.strictEqual(archivedTurn.reason, 'task_archived'); const reopenedD = service.recordLifecycleEvent('lifecycle-d', { type: 'user_message_received', occurredAt: '2026-08-11T03:00:00.000Z', version: archivedD.taskTracking.version, }); assert.strictEqual(reopenedD.changed, true); assert.strictEqual(reopenedD.task.archived, false); assert.strictEqual(reopenedD.task.taskTracking.statusId, 'completed'); assert.strictEqual('baseStatus' in reopenedD.task.taskTracking, false); assert.strictEqual(Object.hasOwn(reopenedD.task.taskTracking, 'reportingStatus'), false); const completedTurn = service.recordLifecycleEvent('lifecycle-d', { type: 'turn_completed', occurredAt: '2026-08-11T03:30:00.000Z', version: reopenedD.task.taskTracking.version, }); assert.strictEqual(completedTurn.changed, false); assert.strictEqual(completedTurn.reason, 'no_change'); assert.strictEqual(completedTurn.task.taskTracking.statusId, 'completed'); assert.strictEqual(completedTurn.task.taskTracking.version, reopenedD.task.taskTracking.version); assert.strictEqual(Object.hasOwn(completedTurn.task.taskTracking, 'reportingStatus'), false); // MCP 工具按来源会话动态生成;静态工具表不再泄漏任何任务工具。 assert.deepStrictEqual(TASK_BOARD_MCP_TOOL_DEFINITIONS, []); const mcpHandlers = createTaskBoardMcpHandlers(service); assert.deepStrictEqual( createTaskBoardMcpToolDefinitions(service, { sourceSessionId: 'mcp-e' }), [], ); const disabledUpdate = await mcpHandlers.ccweb_task_update({ statusId: 'in_progress', }, { sourceSessionId: 'mcp-e' }); assert.strictEqual(disabledUpdate.ok, false); assert.strictEqual(disabledUpdate.code, 'task_tracking_disabled'); const enabledE = service.setTracking('mcp-e', true, { source: 'user', expectedVersion: 0 }); const dynamicTools = createTaskBoardMcpToolDefinitions(service, { sourceSessionId: 'mcp-e' }); assert.deepStrictEqual(dynamicTools.map((tool) => tool.name), ['ccweb_task_update']); const updateToolDefinition = dynamicTools[0]; const enabledDefinitions = service.getStatusDefinitions().filter((definition) => definition.enabled); assert.deepStrictEqual( updateToolDefinition.inputSchema.properties.statusId.enum, enabledDefinitions.map((definition) => definition.id), ); assert.deepStrictEqual( Object.keys(updateToolDefinition.inputSchema.properties), ['statusId', 'reason', 'summary'], ); assert.match(updateToolDefinition.description, /当前状态/); assert.match(updateToolDefinition.description, /仅当当前对话任务状态真实发生变化时调用/); assert.match(updateToolDefinition.description, /单轮开始、结束、运行停止不代表任务状态变化/); for (const definition of enabledDefinitions) { assert(updateToolDefinition.description.includes(definition.label)); assert(updateToolDefinition.description.includes(definition.id)); assert(updateToolDefinition.description.includes(definition.prompt)); } assert.strictEqual(updateToolDefinition.description.includes('ccweb_task_status_list'), false); const retiredMcpProgress = await mcpHandlers.ccweb_task_update({ statusId: 'review_ready', progress: 10, }, { sourceSessionId: 'mcp-e', actorId: 'agent-e' }); assert.strictEqual(retiredMcpProgress.ok, false); assert.strictEqual(retiredMcpProgress.code, 'task_status_invalid'); assert.strictEqual(service.getTask('mcp-e').taskTracking.version, enabledE.taskTracking.version); const mcpSuccess = await mcpHandlers.ccweb_task_update({ statusId: 'review_ready', summary: 'MCP 根据列提示词判断为等待评审', }, { sourceSessionId: 'mcp-e', actorId: 'agent-e' }); assert.strictEqual(mcpSuccess.ok, true); assert.strictEqual(mcpSuccess.sessionId, 'mcp-e'); assert.strictEqual(mcpSuccess.currentStatus.statusId, 'review_ready'); assert.strictEqual(mcpSuccess.currentStatus.source, 'mcp'); assert.strictEqual(Object.hasOwn(mcpSuccess.currentStatus, 'progress'), false); assert.strictEqual(Object.hasOwn(mcpSuccess.task.taskTracking, 'progress'), false); assert.strictEqual(mcpSuccess.currentStatusDefinition.prompt, reviewColumn.definition.prompt); assert.strictEqual(JSON.stringify(mcpSuccess).includes('baseStatus'), false); const refreshedTools = createTaskBoardMcpToolDefinitions(service, { sourceSessionId: 'mcp-e' }); assert.match(refreshedTools[0].description, /等待评审[^\n]*review_ready|review_ready[^\n]*等待评审/); const unknownStatus = await mcpHandlers.ccweb_task_update({ statusId: 'does_not_exist', }, { sourceSessionId: 'mcp-e' }); assert.strictEqual(unknownStatus.code, 'task_status_unknown'); const missingContext = await mcpHandlers.ccweb_task_update({ statusId: 'in_progress' }, {}); assert.strictEqual(missingContext.code, 'task_session_not_found'); const spoofedContext = await mcpHandlers.ccweb_task_update({ statusId: 'in_progress', sourceSessionId: 'priority-c', }, { sourceSessionId: 'mcp-e' }); assert.strictEqual(spoofedContext.code, 'task_status_invalid'); const dispatchMcp = createTaskBoardMcpHandler({ taskBoardService: service }); const retiredList = await dispatchMcp('ccweb_task_status_list', {}, { sessionId: 'mcp-e' }); assert.strictEqual(retiredList.code, 'unknown_tool'); const unknownTool = await dispatchMcp('ccweb_task_archive', {}, { sourceSessionId: 'mcp-e' }); assert.strictEqual(unknownTool.code, 'unknown_tool'); console.log('Task board service and MCP unit checks passed.'); } main().catch((error) => { console.error(error.stack || error.message); process.exit(1); });