feat: overhaul task board and cross-conversation workflows
This commit is contained in:
734
scripts/task-board-integration.js
Normal file
734
scripts/task-board-integration.js
Normal 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;
|
||||
});
|
||||
Reference in New Issue
Block a user