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,734 @@
'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');
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)}`);
}
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 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,
HOME: homeDir,
CLAUDE_PATH: MOCK_CLAUDE,
},
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);
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');
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;
});