Files
cc-web/scripts/task-board-integration.js
2026-08-13 00:34:37 +08:00

908 lines
37 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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