909 lines
37 KiB
JavaScript
909 lines
37 KiB
JavaScript
'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,
|
||
CC_WEB_TASK_BOARD_ENABLED: '1',
|
||
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;
|
||
});
|