feat: overhaul task board and cross-conversation workflows

This commit is contained in:
shiyue
2026-08-12 09:41:36 +08:00
parent e7d28935ef
commit 86231f0d97
55 changed files with 11628 additions and 280 deletions

View File

@@ -0,0 +1,465 @@
'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')],
]);
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);
// 停用跟踪仍保留状态;迁移会覆盖启用和停用会话中的引用。
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(), ['migration-b', 'ordinary-a']);
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);
});