feat: overhaul task board and cross-conversation workflows

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

View File

@@ -0,0 +1,230 @@
#!/usr/bin/env node
'use strict';
const assert = require('assert/strict');
const fs = require('fs');
const path = require('path');
const REPO_DIR = path.resolve(__dirname, '..');
const SERVER_PATH = path.join(REPO_DIR, 'server.js');
const mcpServer = require(path.join(REPO_DIR, 'lib', 'ccweb-mcp-server.js'));
const serverSource = fs.readFileSync(SERVER_PATH, 'utf8');
function check(label, fn) {
try {
fn();
} catch (err) {
err.message = `${label}: ${err.message}`;
throw err;
}
}
function toolNames(tools) {
return tools.map((tool) => tool && tool.name).filter(Boolean);
}
function findTool(tools, name) {
return tools.find((tool) => tool && tool.name === name);
}
function replyModeValues(value) {
if (Array.isArray(value)) return value;
if (value && typeof value === 'object') return Object.values(value);
return [];
}
function withoutNamespace(tool) {
const clone = { ...tool };
delete clone.namespace;
return clone;
}
function assertExactReplyModeEnum(actual, label) {
assert.deepEqual(
actual,
['one_way', 'return_and_continue'],
`${label} must be exactly ["one_way", "return_and_continue"]`,
);
}
check('public TOOLS expose only the unified send communication tool', () => {
assert.ok(Array.isArray(mcpServer.TOOLS), 'TOOLS must be exported as an array');
const names = toolNames(mcpServer.TOOLS);
assert.equal(
names.filter((name) => name === 'ccweb_send_message').length,
1,
`expected exactly one ccweb_send_message, got: ${names.join(', ')}`,
);
assert.equal(
names.includes('ccweb_request_reply'),
false,
`ccweb_request_reply must be hidden from public TOOLS; got: ${names.join(', ')}`,
);
});
const publicSendTool = findTool(mcpServer.TOOLS, 'ccweb_send_message');
check('ccweb_send_message schema requires replyMode with the exact enum', () => {
assert.ok(publicSendTool, 'ccweb_send_message must exist in public TOOLS');
const schema = publicSendTool.inputSchema || {};
const required = schema.required || [];
assert.ok(required.includes('targetConversationId'), 'required must include targetConversationId');
assert.ok(required.includes('content'), 'required must include content');
assert.ok(required.includes('replyMode'), 'required must include replyMode');
assertExactReplyModeEnum(schema.properties?.replyMode?.enum, 'replyMode enum');
});
check('ccweb_send_message description states strong selection and non-blocking rules', () => {
const description = String(publicSendTool.description || '');
assert.match(description, /one_way/, 'description must mention one_way');
assert.match(description, /return_and_continue/, 'description must mention return_and_continue');
assert.match(description, /立即返回/, 'description must say calls return immediately');
assert.match(description, /(不阻塞|不会阻塞|不等待)/, 'description must state the call does not block/wait');
assert.match(
description,
/(仅当[\s\S]*来源[\s\S]*不需要[\s\S]*结果[\s\S]*one_way|one_way[\s\S]*仅当[\s\S]*来源[\s\S]*不需要[\s\S]*结果)/,
'description must say one_way is only for cases where the source does not need the result',
);
assert.match(
description,
/(分析|实现|测试|验收|完成后汇报|后续依赖)[\s\S]*必须[\s\S]*return_and_continue|return_and_continue[\s\S]*必须[\s\S]*(分析|实现|测试|验收|完成后汇报|后续依赖)/,
'description must say result-dependent work must use return_and_continue',
);
});
check('future compatibility exports are present and keep hidden legacy callable', () => {
assertExactReplyModeEnum(
replyModeValues(mcpServer.CCWEB_REPLY_MODES).sort(),
'CCWEB_REPLY_MODES values',
);
assert.equal(typeof mcpServer.isCallableToolName, 'function', 'isCallableToolName must be exported');
assert.equal(
mcpServer.isCallableToolName('ccweb_send_message'),
true,
'ccweb_send_message must be callable',
);
assert.equal(
mcpServer.isCallableToolName('ccweb_request_reply'),
true,
'hidden legacy ccweb_request_reply must remain callable',
);
assert.equal(
typeof mcpServer.codexAppCommunicationTools,
'function',
'codexAppCommunicationTools must be exported',
);
});
check('Codex App fallback communication tools reuse the public send contract', () => {
const fallbackTools = mcpServer.codexAppCommunicationTools();
assert.ok(Array.isArray(fallbackTools), 'codexAppCommunicationTools() must return an array');
const names = toolNames(fallbackTools);
assert.equal(
names.includes('ccweb_request_reply'),
false,
`fallback tools must not expose ccweb_request_reply; got: ${names.join(', ')}`,
);
assert.equal(
names.includes('ccweb_display_image'),
false,
'fallback tools must not include ccweb_display_image',
);
assert.equal(
names.includes('ccweb_prompt_user'),
false,
'fallback tools must not include ccweb_prompt_user',
);
assert.equal(
names.some((name) => name.startsWith('ccweb_task_')),
false,
`fallback tools must not include task-board tools; got: ${names.join(', ')}`,
);
const fallbackSendTool = findTool(fallbackTools, 'ccweb_send_message');
assert.ok(fallbackSendTool, 'fallback tools must include ccweb_send_message');
assert.deepEqual(
withoutNamespace(fallbackSendTool),
publicSendTool,
'fallback ccweb_send_message must match public definition except namespace',
);
});
check('server.js dynamic tools and shared call gates reuse the shared contract', () => {
assert.match(
serverSource,
/function\s+codexAppCommunicationDynamicTools\s*\([^)]*\)\s*{[\s\S]{0,600}codexAppCommunicationTools\s*\(/,
'codexAppCommunicationDynamicTools() must delegate to codexAppCommunicationTools()',
);
assert.match(
serverSource,
/case ['"]tools\/call['"]\s*:[\s\S]{0,1400}isCallableToolName\s*\(/,
'shared MCP tools/call gate must use isCallableToolName()',
);
assert.match(
serverSource,
/case ['"]ccweb_request_reply['"]\s*:/,
'internal dispatcher must keep the hidden legacy ccweb_request_reply case',
);
});
check('unified runtime persists the original request and maps the legacy reply alias', () => {
const sendStart = serverSource.indexOf('function sendCrossConversationMessage');
const sendEnd = serverSource.indexOf('function requestCrossConversationReply', sendStart);
const sendSource = serverSource.slice(sendStart, sendEnd);
assert.ok(sendStart >= 0 && sendEnd > sendStart, 'sendCrossConversationMessage() source must be present');
assert.match(
sendSource,
/(args\.replyMode|normalizeCrossConversationReplyMode)/,
'ccweb_send_message runtime must read or normalize replyMode',
);
assert.match(
sendSource,
/originalRequest:\s*content/,
'return_and_continue pending state must persist the original request',
);
const legacyStart = serverSource.indexOf('function requestCrossConversationReply');
const legacyEnd = serverSource.indexOf('function listPendingCrossConversationReplies', legacyStart);
const legacySource = serverSource.slice(legacyStart, legacyEnd);
assert.match(
legacySource,
/(RETURN_AND_CONTINUE|return_and_continue)/,
'legacy ccweb_request_reply must map explicitly to return_and_continue',
);
});
check('target runtime prompt explains both reply modes and prevents duplicate return', () => {
const promptStart = serverSource.indexOf('function buildCrossConversationRuntimeText');
const promptEnd = serverSource.indexOf('function buildCrossConversationReplyContent', promptStart);
const promptSource = serverSource.slice(promptStart, promptEnd);
assert.match(promptSource, /one_way/, 'target prompt must identify one_way mode');
assert.match(promptSource, /return_and_continue/, 'target prompt must identify return_and_continue mode');
assert.match(
promptSource,
/(不会自动回传|不会自动写回来源)/,
'one_way target prompt must state that no automatic return occurs',
);
assert.match(
promptSource,
/(自动回传|自动写回来源)/,
'return_and_continue target prompt must state that the system returns the result automatically',
);
assert.match(
promptSource,
/(不要|禁止)[\s\S]*(手工|手动)[\s\S]*(回传|发送)/,
'return_and_continue target prompt must prohibit duplicate manual return',
);
});
check('source auto-run prompt carries correlation context and completion guardrail', () => {
const promptStart = serverSource.indexOf('function buildCrossConversationReplyAutoRunText');
const promptEnd = serverSource.indexOf('function startCrossConversationReplyAutoRun', promptStart);
const promptSource = serverSource.slice(promptStart, promptEnd);
assert.match(promptSource, /requestId/, 'source auto-run prompt must include requestId');
assert.match(promptSource, /targetSession\?\.id|targetSession\.id/, 'source auto-run prompt must include target conversation id');
assert.match(promptSource, /originalRequest/, 'source auto-run prompt must include the original request');
assert.match(promptSource, /已返回不等于已完成/, 'source auto-run prompt must distinguish returned from completed');
assert.match(promptSource, /完整/, 'source auto-run prompt must require a completeness check');
});
console.log('ccweb message reply unit checks passed.');

View File

@@ -0,0 +1,443 @@
'use strict';
const assert = require('node:assert');
const fs = require('node:fs');
const path = require('node:path');
const vm = require('node:vm');
const REPO_DIR = path.resolve(__dirname, '..');
const APP_PATH = path.join(REPO_DIR, 'public', 'app.js');
const STYLE_PATH = path.join(REPO_DIR, 'public', 'style.css');
function extractFunctionSource(source, name) {
const marker = `function ${name}`;
const start = source.indexOf(marker);
assert(start >= 0, `缺少函数 ${name}`);
const paramsOpen = source.indexOf('(', start);
assert(paramsOpen >= 0, `函数 ${name} 缺少参数列表`);
let paramDepth = 0;
let paramsClose = -1;
for (let index = paramsOpen; index < source.length; index += 1) {
const ch = source[index];
if (ch === '(') paramDepth += 1;
if (ch === ')') {
paramDepth -= 1;
if (paramDepth === 0) {
paramsClose = index;
break;
}
}
}
assert(paramsClose >= 0, `函数 ${name} 参数列表未闭合`);
const open = source.indexOf('{', paramsClose);
assert(open >= 0, `函数 ${name} 缺少函数体`);
let depth = 0;
for (let index = open; index < source.length; index += 1) {
const ch = source[index];
if (ch === '{') depth += 1;
if (ch === '}') {
depth -= 1;
if (depth === 0) return source.slice(start, index + 1);
}
}
throw new Error(`函数 ${name} 未闭合`);
}
class FakeClassList {
constructor(element) {
this.element = element;
}
values() {
return String(this.element.className || '').split(/\s+/).filter(Boolean);
}
contains(name) {
return this.values().includes(name);
}
add(...names) {
const next = new Set(this.values());
names.forEach((name) => next.add(String(name)));
this.element.className = Array.from(next).join(' ');
}
remove(...names) {
const removed = new Set(names.map(String));
this.element.className = this.values().filter((name) => !removed.has(name)).join(' ');
}
toggle(name, force) {
const enabled = force === undefined ? !this.contains(name) : !!force;
if (enabled) this.add(name);
else this.remove(name);
return enabled;
}
}
function dataKey(name) {
return name.slice(5).split('-').map((part, index) => (
index === 0 ? part : `${part.slice(0, 1).toUpperCase()}${part.slice(1)}`
)).join('');
}
function attributeValue(element, name) {
if (name === 'class') return element.className;
if (name === 'id') return element.id || null;
if (name === 'disabled') return element.disabled ? '' : null;
if (name.startsWith('data-')) {
const key = dataKey(name);
return Object.prototype.hasOwnProperty.call(element.dataset, key) ? String(element.dataset[key]) : null;
}
return element.attributes.get(name) ?? null;
}
function matchesSelector(element, selector) {
const raw = String(selector || '').trim().replace(/^:scope\s*/, '');
if (!raw) return false;
const tag = raw.match(/^[A-Za-z][A-Za-z0-9-]*/)?.[0];
if (tag && element.tagName !== tag.toUpperCase()) return false;
const classes = Array.from(raw.matchAll(/\.([A-Za-z0-9_-]+)/g), (match) => match[1]);
if (classes.some((name) => !element.classList.contains(name))) return false;
const attrs = Array.from(raw.matchAll(/\[([^\]=]+)(?:="([^"]*)")?\]/g));
for (const [, name, expected] of attrs) {
const actual = attributeValue(element, name);
if (actual === null) return false;
if (expected !== undefined && actual !== expected) return false;
}
return true;
}
class FakeElement {
constructor(tagName) {
this.tagName = String(tagName).toUpperCase();
this.children = [];
this.parentNode = null;
this.dataset = {};
this.attributes = new Map();
this.listeners = new Map();
this.className = '';
this.id = '';
this.type = '';
this.disabled = false;
this._textContent = '';
this.classList = new FakeClassList(this);
}
get textContent() {
return `${this._textContent}${this.children.map((child) => child.textContent).join('')}`;
}
set textContent(value) {
this._textContent = value == null ? '' : String(value);
this.children.forEach((child) => { child.parentNode = null; });
this.children = [];
}
appendChild(child) {
if (child.parentNode) child.remove();
child.parentNode = this;
this.children.push(child);
return child;
}
remove() {
if (!this.parentNode) return;
this.parentNode.children = this.parentNode.children.filter((child) => child !== this);
this.parentNode = null;
}
setAttribute(name, value) {
this.attributes.set(String(name), String(value));
}
getAttribute(name) {
return this.attributes.get(String(name)) ?? null;
}
removeAttribute(name) {
this.attributes.delete(String(name));
}
addEventListener(type, handler) {
const list = this.listeners.get(type) || [];
list.push(handler);
this.listeners.set(type, list);
}
click() {
const event = {
preventDefault() {},
stopPropagation() {},
};
(this.listeners.get('click') || []).forEach((handler) => handler(event));
}
querySelector(selector) {
return this.querySelectorAll(selector)[0] || null;
}
querySelectorAll(selector) {
const selectors = String(selector || '').split(',').map((item) => item.trim()).filter(Boolean);
const results = [];
const visit = (node) => {
node.children.forEach((child) => {
if (selectors.some((item) => matchesSelector(child, item))) results.push(child);
visit(child);
});
};
visit(this);
return results;
}
}
class FakeDocument {
constructor() {
this.root = new FakeElement('div');
}
createElement(tagName) {
return new FakeElement(tagName);
}
querySelector(selector) {
return this.root.querySelector(selector);
}
}
function createHarness() {
const source = fs.readFileSync(APP_PATH, 'utf8');
const document = new FakeDocument();
const messagesDiv = document.root;
const sent = [];
const errors = [];
let outlineUpdates = 0;
let scrollbarUpdates = 0;
const functionNames = [
'codexAppSteerStatusLabel',
'cloneCodexAppSteerAttachments',
'registerCodexAppSteerRecord',
'clearCodexAppSteerRecord',
'commitCodexAppSteerMessage',
'registerCodexAppSteerSessionMessage',
'retryCodexAppSteerMessage',
'deleteCodexAppSteerMessage',
'setCodexAppSteerStatusElement',
'updateCodexAppSteerMessage',
];
const functions = functionNames.map((name) => extractFunctionSource(source, name)).join('\n\n');
const script = `
const userMessageIndex = new Map();
const codexAppSteerRecords = new Map();
let currentSessionId = 'session-a';
let currentMode = 'yolo';
let currentAgent = 'codexapp';
let isGenerating = true;
let currentSessionRunning = true;
let currentSessionMessageCount = 4;
let ws = { readyState: 1, send(payload) { sent.push(JSON.parse(payload)); } };
function cssEscape(value) { return String(value || '').replace(/"/g, '\\\\"'); }
function isCodexAppAgent(agent) { return agent === 'codexapp'; }
function send(data) { if (ws && ws.readyState === 1) ws.send(JSON.stringify(data)); }
function appendError(message) { errors.push(String(message || '')); }
function updateUserOutlinePanel() { outlineUpdates += 1; }
function updateScrollbar() { scrollbarUpdates += 1; }
function scrollToBottom() {}
function markSessionMessageElement(element, index) {
element.dataset.sessionMessage = 'true';
element.dataset.messageIndex = String(index);
}
${functions}
function makeMessage(id) {
const element = document.createElement('div');
element.className = 'msg user';
element.dataset.messageId = id;
const bubble = document.createElement('div');
bubble.className = 'msg-bubble';
element.appendChild(bubble);
messagesDiv.appendChild(element);
userMessageIndex.set(id, { id, element, content: '原文本', timestamp: '2026-08-11T00:00:00.000Z' });
return element;
}
({
makeMessage,
registerCodexAppSteerRecord,
registerCodexAppSteerSessionMessage,
updateCodexAppSteerMessage,
setCodexAppSteerStatusElement,
retryCodexAppSteerMessage,
deleteCodexAppSteerMessage,
records: codexAppSteerRecords,
index: userMessageIndex,
messagesDiv,
sent,
errors,
get outlineUpdates() { return outlineUpdates; },
get scrollbarUpdates() { return scrollbarUpdates; },
get currentSessionMessageCount() { return currentSessionMessageCount; },
setSession(id) { currentSessionId = id; },
setGenerating(value) { isGenerating = value; currentSessionRunning = value; },
setWsReady(value) { ws.readyState = value; },
});
`;
return vm.runInNewContext(script, { document, messagesDiv, sent, errors, outlineUpdates, scrollbarUpdates });
}
function assertFailedActionsContract() {
const harness = createHarness();
const element = harness.makeMessage('retry-target');
harness.registerCodexAppSteerRecord('retry-target', {
element,
text: '原文本',
attachments: [{ id: 'img-a', filename: 'a.png' }],
sessionId: 'session-a',
mode: 'plan',
agent: 'codexapp',
});
assert.strictEqual(harness.updateCodexAppSteerMessage('retry-target', 'failed', '插入失败'), true);
const failedActions = element.querySelector('.codex-steer-actions');
assert(failedActions, 'failed 状态应渲染操作区');
const retry = failedActions.querySelector('.codex-steer-retry');
const remove = failedActions.querySelector('.codex-steer-delete');
assert(retry && remove, 'failed 状态应同时渲染重试和删除按钮');
assert.strictEqual(retry.textContent, '重试');
assert.strictEqual(remove.textContent, '删除');
assert.strictEqual(retry.getAttribute('aria-label'), '重试插入此消息');
assert.strictEqual(remove.getAttribute('aria-label'), '删除失败插入项');
retry.click();
retry.click();
assert.strictEqual(harness.sent.length, 1, '连续快速点击重试只应发送一次');
assert.deepStrictEqual(JSON.parse(JSON.stringify(harness.sent[0])), {
type: 'message',
text: '原文本',
attachments: [{ id: 'img-a', filename: 'a.png' }],
sessionId: 'session-a',
mode: 'plan',
agent: 'codexapp',
clientMessageId: 'retry-target',
});
assert.strictEqual(element.getAttribute('aria-busy'), 'true', 'pending 状态应暴露 aria-busy=true');
assert.strictEqual(element.querySelector('.codex-steer-actions'), null, 'pending 状态不应展示失败操作');
harness.updateCodexAppSteerMessage('retry-target', 'failed', '仍然失败');
assert(element.querySelector('.codex-steer-actions'), '再次失败后应恢复操作');
harness.updateCodexAppSteerMessage('retry-target', 'inserted', '已插入');
assert.strictEqual(element.querySelector('.codex-steer-actions'), null, '成功后不应展示失败操作');
assert.strictEqual(element.dataset.sessionMessage, 'true', '成功后才提交消息索引');
assert.strictEqual(element.dataset.messageIndex, '4', '成功提交应使用当前会话消息序号');
assert.strictEqual(harness.currentSessionMessageCount, 5, '成功提交只应递增一次');
harness.updateCodexAppSteerMessage('retry-target', 'inserted', '已插入');
assert.strictEqual(harness.currentSessionMessageCount, 5, '重复 inserted 不应重复递增');
}
function assertDeleteAndBlockedRetryContract() {
const harness = createHarness();
const first = harness.makeMessage('delete-target');
const second = harness.makeMessage('keep-target');
harness.registerCodexAppSteerRecord('delete-target', {
element: first,
text: '删除目标',
attachments: [{ id: 'same-name', filename: 'same.png' }],
sessionId: 'session-a',
mode: 'yolo',
agent: 'codexapp',
});
harness.registerCodexAppSteerRecord('keep-target', {
element: second,
text: '保留目标',
attachments: [{ id: 'same-name-2', filename: 'same.png' }],
sessionId: 'session-a',
mode: 'yolo',
agent: 'codexapp',
});
harness.updateCodexAppSteerMessage('delete-target', 'failed', '插入失败');
harness.updateCodexAppSteerMessage('keep-target', 'failed', '插入失败');
first.querySelector('.codex-steer-delete').click();
assert.strictEqual(harness.messagesDiv.children.includes(first), false, '删除只移除目标临时项');
assert.strictEqual(harness.messagesDiv.children.includes(second), true, '删除不应影响同名其他项');
assert.strictEqual(harness.index.has('delete-target'), false, '删除应清理用户消息索引');
assert.strictEqual(harness.records.has('delete-target'), false, '删除应清理临时插入记录');
assert.strictEqual(harness.records.has('keep-target'), true, '删除不应清理其他临时记录');
const blocked = harness.makeMessage('blocked-target');
harness.registerCodexAppSteerRecord('blocked-target', {
element: blocked,
text: '断线重试',
attachments: [],
sessionId: 'session-a',
mode: 'yolo',
agent: 'codexapp',
});
harness.updateCodexAppSteerMessage('blocked-target', 'failed', '插入失败');
harness.setWsReady(3);
blocked.querySelector('.codex-steer-retry').click();
assert.strictEqual(harness.sent.length, 0, 'WebSocket 断开时不能误发重试');
assert(blocked.querySelector('.codex-steer-actions'), 'WebSocket 断开时应保持失败态可恢复');
assert.strictEqual(blocked.getAttribute('aria-busy'), 'false', 'WebSocket 断开时不能永久卡 pending');
harness.setWsReady(1);
harness.setSession('other-session');
blocked.querySelector('.codex-steer-retry').click();
assert.strictEqual(harness.sent.length, 0, '会话不匹配时不能误发重试');
assert(blocked.querySelector('.codex-steer-actions'), '会话不匹配时应保持失败态');
harness.setSession('session-a');
harness.setGenerating(false);
blocked.querySelector('.codex-steer-retry').click();
assert.strictEqual(harness.sent.length, 0, 'turn 已结束时不能误发重试');
assert(blocked.querySelector('.codex-steer-actions'), 'turn 已结束时应保持失败态');
}
function assertProgrammaticSessionMessageRetryContract() {
const harness = createHarness();
const element = harness.makeMessage('programmatic-target');
harness.registerCodexAppSteerSessionMessage('session-a', {
id: 'programmatic-target',
role: 'user',
content: '表单答案',
attachments: [{ id: 'img-programmatic', filename: 'programmatic.png' }],
timestamp: '2026-08-11T00:00:01.000Z',
codexAppSteerStatus: 'pending',
codexAppSteerMessage: '引导中...',
}, element);
assert.strictEqual(harness.records.has('programmatic-target'), true, '程序化 session_message 应补建可重试记录');
harness.updateCodexAppSteerMessage('programmatic-target', 'failed', '插入失败');
const retry = element.querySelector('.codex-steer-retry');
assert(retry, '程序化失败消息应展示可用重试按钮');
retry.click();
assert.deepStrictEqual(JSON.parse(JSON.stringify(harness.sent[0])), {
type: 'message',
text: '表单答案',
attachments: [{ id: 'img-programmatic', filename: 'programmatic.png' }],
sessionId: 'session-a',
mode: 'yolo',
agent: 'codexapp',
clientMessageId: 'programmatic-target',
});
harness.updateCodexAppSteerMessage('programmatic-target', 'inserted', '已插入');
assert.strictEqual(element.dataset.sessionMessage, 'true', '程序化临时项成功后才提交消息索引');
assert.strictEqual(element.dataset.messageIndex, '4', '程序化临时项应复用当前持久化消息序号');
assert.strictEqual(harness.currentSessionMessageCount, 5, '程序化临时项成功后只递增一次消息计数');
}
function assertStaticContracts() {
const appSource = fs.readFileSync(APP_PATH, 'utf8');
const styleSource = fs.readFileSync(STYLE_PATH, 'utf8');
assert(appSource.includes('const codexAppSteerRecords = new Map();'), '前端应维护独立临时插入记录');
assert(appSource.includes("clientMessageId: id"), '重试应复用稳定 clientMessageId');
assert(appSource.includes("aria-busy"), 'pending 卡片应暴露 aria-busy');
assert(styleSource.includes('.codex-steer-actions'), '失败操作样式应限定在 codex-steer 区段');
assert(styleSource.includes('.codex-steer-retry'), '应存在重试按钮样式');
assert(styleSource.includes('.codex-steer-delete'), '应存在删除按钮样式');
}
assertStaticContracts();
assertFailedActionsContract();
assertDeleteAndBlockedRetryContract();
assertProgrammaticSessionMessageRetryContract();
console.log('Failed insert card frontend unit checks passed.');

View File

@@ -604,12 +604,38 @@ function assertFrontendSidebarCollapseContract() {
const compactMobileStyleEnd = styleSource.indexOf('/* === Utility === */', compactMobileStyleStart);
const compactMobileStyle = styleSource.slice(compactMobileStyleStart, compactMobileStyleEnd);
assert(
/\.user-outline-panel\s*\{[^}]*left:\s*auto;[^}]*right:\s*0;[^}]*width:\s*min\(320px,\s*calc\(100vw - 20px\)\);/.test(compactMobileStyle),
'Compact-mobile user locator should open left from the second grid column'
/\.user-outline-anchor,\s*\.ccweb-prompt-outline-anchor\s*\{[^}]*position:\s*static;/.test(compactMobileStyle),
'Compact-mobile locator anchors should use the shared chat-controls positioning context'
);
assert(
/\.ccweb-prompt-outline-panel\s*\{[^}]*left:\s*0;[^}]*right:\s*auto;/.test(compactMobileStyle),
'Compact-mobile pending-form locator should open right from the first grid column'
/\.user-outline-panel,\s*\.ccweb-prompt-outline-panel\s*\{[^}]*left:\s*0;[^}]*right:\s*0;[^}]*width:\s*auto;[^}]*bottom:\s*calc\(100% \+ 6px\);/.test(compactMobileStyle),
'Compact-mobile locator panels should span the chat controls width without horizontal overflow'
);
assert(
!/\.user-outline-panel\s*\{[^}]*left:\s*auto;[^}]*right:\s*0;[^}]*width:\s*min\(320px,\s*calc\(100vw - 20px\)\);/.test(compactMobileStyle)
&& !/\.ccweb-prompt-outline-panel\s*\{[^}]*left:\s*0;[^}]*right:\s*auto;/.test(compactMobileStyle),
'Compact-mobile locator panels should not be overridden back to per-button anchor positioning'
);
const compactMobileChatControls = compactMobileStyle.match(/\.chat-controls\s*\{[^}]*\}/)?.[0] || '';
assert(
/display:\s*flex;/.test(compactMobileChatControls) && /flex-wrap:\s*wrap;/.test(compactMobileChatControls),
'Compact-mobile chat controls should keep flex wrapping instead of switching to a two-column grid'
);
assert(
!/grid-template-columns:\s*repeat\(2,\s*minmax\(0,\s*1fr\)\);/.test(compactMobileStyle),
'Compact-mobile chat controls should not force two equal columns'
);
assert(
!/\.mode-select,\s*\.user-outline-btn,\s*\.ccweb-prompt-outline-btn,\s*\.reload-mcp-btn,\s*\.chat-runtime-state\s*\{[^}]*width:\s*100%;/.test(compactMobileStyle),
'Compact-mobile short chat controls should keep content-width sizing'
);
assert(
/\.chat-cwd\s*\{[^}]*width:\s*100%;[^}]*max-width:\s*none;/.test(compactMobileStyle),
'Compact-mobile cwd header control should retain its previous full-width behavior'
);
assert(
/\.ccweb-prompt-outline-anchor\s*\{[^}]*width:\s*auto;/.test(compactMobileStyle),
'Compact-mobile temporary form control should keep content width and wrap without overflow'
);
assert(styleSource.includes('.menu-btn:focus-visible'), 'Sidebar toggle should retain a visible keyboard focus treatment');
assert(
@@ -629,6 +655,96 @@ function assertFrontendSidebarCollapseContract() {
);
}
function assertTaskBoardIntegrationContract() {
const indexSource = fs.readFileSync(PUBLIC_INDEX_PATH, 'utf8');
const frontendSource = fs.readFileSync(PUBLIC_APP_PATH, 'utf8');
const serverSource = fs.readFileSync(SERVER_PATH, 'utf8');
const taskBoardServiceSource = fs.readFileSync(path.join(REPO_DIR, 'lib', 'task-board-service.js'), 'utf8');
const taskBoardMcpSource = fs.readFileSync(path.join(REPO_DIR, 'lib', 'task-board-mcp.js'), 'utf8');
const taskBoardLifecycleSource = fs.readFileSync(path.join(REPO_DIR, 'lib', 'task-board-lifecycle.js'), 'utf8');
const taskBoardFrontendSource = fs.readFileSync(path.join(PUBLIC_DIR, 'task-board.js'), 'utf8');
const taskBoardStyleSource = fs.readFileSync(path.join(PUBLIC_DIR, 'task-board.css'), 'utf8');
const trackingIdCount = (indexSource.match(/id="task-tracking-control"/g) || []).length;
assert(trackingIdCount === 1, 'Task tracking should reuse exactly one DOM mount point');
assert(
/<div class="chat-controls"[^>]*>[\s\S]*?<div id="task-tracking-control" class="task-tracking-control" hidden><\/div>\s*<\/div>\s*<div id="attachment-tray"/.test(indexSource),
'Task tracking should be the trailing control inside the existing chat-controls row'
);
assert(frontendSource.includes("const taskTrackingControl = $('#task-tracking-control');"), 'Task tracking should keep the existing frontend mount id');
assert(frontendSource.includes('window.CcwebTaskBoard.renderTrackingControl(taskTrackingControl,'), 'Task tracking should keep the existing controller mount path');
assert(!extractFunctionSource(frontendSource, 'openTaskBoard').includes('createSession'), 'Task board mount should not restore the retired createSession adapter');
const filtersSource = extractFunctionSource(serverSource, 'taskBoardQueryFilters');
const queryPayloadSource = extractFunctionSource(serverSource, 'taskBoardQueryPayload');
const snapshotSource = extractFunctionSource(serverSource, 'taskTrackingSnapshotForSession');
const wsHandlerSource = extractFunctionSource(serverSource, 'handleTaskBoardWsMessage');
const internalMcpSource = extractFunctionSource(serverSource, 'callInternalMcpTool');
const composerSource = extractFunctionSource(serverSource, 'listComposerSuggestions');
const composerMcpSource = extractFunctionSource(serverSource, 'listComposerMcpItems');
assert(!serverSource.includes('function taskBoardFacets(') && !queryPayloadSource.includes('facets'), 'Task board queries should not expose retired Agent facets');
assert(!filtersSource.includes("'agent'") && !filtersSource.includes('source.agent') && !filtersSource.includes('filters.agent'), 'Task board queries should ignore retired Agent filters');
assert(!filtersSource.includes('source.priority') && !filtersSource.includes('filters.priority'), 'Task board queries should not forward the retired priority filter');
assert(
[snapshotSource, taskBoardServiceSource, taskBoardMcpSource, taskBoardLifecycleSource, taskBoardFrontendSource]
.every((source) => !source.includes('baseStatus')),
'Task board production chain should not retain baseStatus mappings'
);
assert(
[snapshotSource, taskBoardServiceSource, taskBoardMcpSource, taskBoardLifecycleSource, taskBoardFrontendSource]
.every((source) => !source.includes('reportingStatus') && !source.includes('本轮未上报')),
'Task board production chain should not retain per-turn reporting audit semantics'
);
const retiredTaskProgressPattern = /\bprogress\b|百分比|进度条|任务进度/i;
assert(
[
taskBoardServiceSource,
taskBoardMcpSource,
taskBoardLifecycleSource,
taskBoardFrontendSource,
taskBoardStyleSource,
snapshotSource,
wsHandlerSource,
queryPayloadSource,
].every((source) => !retiredTaskProgressPattern.test(source)),
'Task board production chain should not retain percentage progress semantics'
);
assert(
frontendSource.includes('function normalizePlanProgress(value)')
&& frontendSource.includes('function createPlanProgressElementFromProgress(progress, options = {})'),
'Retiring task board percentages must preserve unrelated Plan List progress'
);
assert(
composerMcpSource.includes('taskBoardMcpToolDefinitionsForSession')
&& composerSource.includes('const reservedMcpItems = [...promptUserMcpItems, ...taskBoardMcpItems]')
&& composerSource.includes('mergeComposerSuggestionGroups(reservedMcpItems, commands, otherMcpItems)')
&& !composerMcpSource.includes('ccweb_task_status_list')
&& !composerSource.includes('ccweb_task_status_list'),
'Slash composer should dynamically gate the sole task update tool by the current source session'
);
assert(taskBoardMcpSource.includes('createTaskBoardMcpToolDefinitions'), 'Task MCP should expose a dynamic per-session definition factory');
assert(!taskBoardMcpSource.includes("name: 'ccweb_task_status_list'"), 'Task status_list must be retired from the model tool surface');
assert(!taskBoardMcpSource.includes('expectedVersion') && !taskBoardMcpSource.includes("'version'"), 'Task update MCP must not expose redundant version parameters');
assert(
!taskBoardServiceSource.includes('SOURCE_PRIORITY')
&& !taskBoardServiceSource.includes('higher_priority_source')
&& !taskBoardServiceSource.includes('ignoredReason')
&& !taskBoardMcpSource.includes('higher_priority_source')
&& !taskBoardMcpSource.includes('ignoredReason'),
'Task status writes should use optimistic versions and last valid write, never source priority'
);
assert(
!wsHandlerSource.includes('task.ignored') && !internalMcpSource.includes('result.ignored'),
'Task status WebSocket and MCP responses should not retain ignored-write branches'
);
assert(
serverSource.includes('ensureTaskBoardMcpToolsFresh(client, session, currentThreadId)')
&& serverSource.includes('taskSchema: taskSchemaFingerprint')
&& serverSource.includes("client.reloadMcpServers()"),
'Codex App turns should invalidate cached MCP tools and carry a schema fingerprint fallback'
);
}
function assertFrontendGenerationControlsContract() {
const source = fs.readFileSync(PUBLIC_APP_PATH, 'utf8');
const controlsStart = source.indexOf('function updateGenerationControls()');
@@ -3594,6 +3710,9 @@ function assertServerSessionHistoryRequestIdContract() {
function publicTitleMetadata() { return {}; }
function sessionModelLabel(session) { return session.model; }
function isSessionRunning() { return false; }
function taskTrackingSnapshotForSession() {
return { enabled: false, statusId: 'unassigned', version: 0 };
}
function attachActiveRuntimeToWs() {}
function resolveClaudeSessionLocalMeta() { return null; }
${handleLoadSessionSource}
@@ -4070,6 +4189,19 @@ async function runCodexAppStaleRunningRegression(options = {}) {
), 5000);
assert(/expectedTurnId does not match active turn/.test(mismatchError.message || ''), 'Expected turn mismatch should not trigger stale-turn recovery');
assert(!messages.some((msg) => msg.type === 'system_message' && msg.sessionId === mismatchSession.sessionId && /已自动开始新一轮对话/.test(msg.message || '')), 'Expected turn mismatch should not start a replacement turn');
const mismatchStored = JSON.parse(fs.readFileSync(path.join(sessionsDir, `${mismatchSession.sessionId}.json`), 'utf8'));
assert(
mismatchStored.messages.filter((message) => (
message.role === 'user' && message.id === 'regression-expected-turn-mismatch'
)).length === 0,
'Expected turn mismatch should roll back the persisted steer user message by clientMessageId'
);
assert(
mismatchStored.messages.filter((message) => (
message.role === 'user' && message.content === 'codexapp mismatch follow-up'
)).length === 0,
'Expected turn mismatch should not leave a ghost user message with the steer text'
);
ws.close();
});
}
@@ -4144,6 +4276,11 @@ function assertFrontendAssetVersionContract() {
indexSource.includes('app.js?v=__CC_WEB_FRONTEND_ASSET_VERSION__'),
'Index template should load app.js with the __CC_WEB_FRONTEND_ASSET_VERSION__ placeholder'
);
assert(
indexSource.includes('task-board.js?v=__CC_WEB_FRONTEND_ASSET_VERSION__')
&& indexSource.includes('task-board.css?v=__CC_WEB_FRONTEND_ASSET_VERSION__'),
'Task board assets should share the dynamic frontend asset version placeholder'
);
assert(
indexSource.includes('__CC_WEB_FRONTEND_ASSET_VERSION__') &&
indexSource.includes('window.ccWebFrontendAssetVersion'),
@@ -4155,12 +4292,12 @@ function assertFrontendAssetVersionContract() {
const computeSource = extractFunctionSource(serverSource, 'computeFrontendAssetVersion');
assert(
/crypto\.createHash\(['"]sha256['"]\)/.test(computeSource),
'computeFrontendAssetVersion should hash the app.js bytes with SHA-256'
);
assert(
/fs\.readFileSync\(\s*(?:PUBLIC_APP_PATH|path\.join\(\s*PUBLIC_DIR\s*,\s*['"]app\.js['"]\s*\))/.test(computeSource),
'computeFrontendAssetVersion should read public/app.js, not a hard-coded version string'
'computeFrontendAssetVersion should hash versioned frontend assets with SHA-256'
);
for (const assetName of ['index.html', 'app.js', 'task-board.js', 'task-board.css']) {
assert(computeSource.includes(`'${assetName}'`), `computeFrontendAssetVersion should include ${assetName}`);
}
assert(/fs\.readFileSync\(\s*path\.join\(\s*PUBLIC_DIR\s*,\s*assetName\s*\)\s*\)/.test(computeSource), 'computeFrontendAssetVersion should read each declared asset instead of a hard-coded version string');
assert(
/\.digest\(['"]hex['"]\)/.test(computeSource),
'computeFrontendAssetVersion should return a hex digest value'
@@ -4290,10 +4427,14 @@ async function runFrontendAssetVersionRegression() {
mkdirp(sessionsDir);
mkdirp(logsDir);
const expectedVersion = crypto.createHash('sha256')
.update(fs.readFileSync(PUBLIC_APP_PATH))
.digest('hex')
.slice(0, 16);
const expectedHash = crypto.createHash('sha256');
for (const assetName of ['index.html', 'app.js', 'task-board.js', 'task-board.css']) {
expectedHash.update(assetName);
expectedHash.update('\0');
expectedHash.update(fs.readFileSync(path.join(PUBLIC_DIR, assetName)));
expectedHash.update('\0');
}
const expectedVersion = expectedHash.digest('hex').slice(0, 16);
const port = await getFreePort();
const password = 'FrontendAssetVersion!234';
@@ -4312,14 +4453,19 @@ async function runFrontendAssetVersionRegression() {
const html = await response.text();
const injectedGlobalVersion = html.match(/window\.ccWebFrontendAssetVersion\s*=\s*'([a-f0-9]{16,64})'/)?.[1] || '';
const injectedScriptVersion = html.match(/app\.js\?v=([a-f0-9]{16,64})/)?.[1] || '';
const injectedTaskBoardScriptVersion = html.match(/task-board\.js\?v=([a-f0-9]{16,64})/)?.[1] || '';
const injectedTaskBoardStyleVersion = html.match(/task-board\.css\?v=([a-f0-9]{16,64})/)?.[1] || '';
assert(response.ok, `Frontend asset version index request should succeed, got ${response.status}`);
assert(
injectedGlobalVersion === expectedVersion,
`Served index should expose the computed app.js version to the frontend, got ${JSON.stringify(injectedGlobalVersion)}`
);
assert(
injectedScriptVersion === expectedVersion && !html.includes('__CC_WEB_FRONTEND_ASSET_VERSION__'),
`Served index should replace every frontend asset version placeholder, got ${JSON.stringify(injectedScriptVersion)}`
injectedScriptVersion === expectedVersion
&& injectedTaskBoardScriptVersion === expectedVersion
&& injectedTaskBoardStyleVersion === expectedVersion
&& !html.includes('__CC_WEB_FRONTEND_ASSET_VERSION__'),
`Served index should replace every frontend asset version placeholder, got ${JSON.stringify({ injectedScriptVersion, injectedTaskBoardScriptVersion, injectedTaskBoardStyleVersion })}`
);
const { ws, messages } = await connectWs(port, password);
@@ -5280,11 +5426,14 @@ async function runSessionPreviewMetadataRegression() {
const fullParseBytes = 64 * 1024;
const previewBytes = 16 * 1024;
const sourceSessionId = 'preview-parent-source';
const tailCwd = path.join(homeDir, '上下游协同平台');
const tailSessionId = 'preview-tail-top-level';
const childSessionId = 'preview-mcp-direct-child';
const nullSessionId = 'preview-null-fields';
const boundarySessionId = 'preview-boundary-join-risk';
const tailSessionPath = path.join(sessionsDir, `${tailSessionId}.json`);
const childSessionPath = path.join(sessionsDir, `${childSessionId}.json`);
const nullSessionPath = path.join(sessionsDir, `${nullSessionId}.json`);
const boundarySessionPath = path.join(sessionsDir, `${boundarySessionId}.json`);
@@ -5299,6 +5448,17 @@ async function runSessionPreviewMetadataRegression() {
agent: 'codexapp',
cwd: tailCwd,
}));
fs.writeFileSync(childSessionPath, makeLargeSessionPreviewFixture(childSessionId, {
title: 'Preview MCP Direct Child',
updated: '2026-08-06T00:00:30.000Z',
created: '2026-08-05T23:30:00.000Z',
pinnedAt: null,
titleSource: 'system',
createdFrom: { kind: 'mcp', sourceSessionId },
hasUnread: false,
agent: 'codexapp',
cwd: tailCwd,
}));
fs.writeFileSync(nullSessionPath, makeLargeSessionPreviewFixture(nullSessionId, {
title: null,
updated: '2026-08-06T00:01:00.000Z',
@@ -5315,6 +5475,7 @@ async function runSessionPreviewMetadataRegression() {
makeHeadTailJoinRiskFixture(boundarySessionId, previewBytes)
);
assert(fs.statSync(tailSessionPath).size > fullParseBytes, 'Tail fixture must use oversized preview parsing');
assert(fs.statSync(childSessionPath).size > fullParseBytes, 'MCP child fixture must use oversized preview parsing');
assert(fs.statSync(nullSessionPath).size > fullParseBytes, 'Null fixture must use oversized preview parsing');
assert(fs.statSync(boundarySessionPath).size > fullParseBytes, 'Boundary fixture must use oversized preview parsing');
@@ -5336,7 +5497,7 @@ async function runSessionPreviewMetadataRegression() {
const { ws, messages } = await connectWs(port, password);
const list = await nextMessage(messages, ws, (msg) => (
msg.type === 'session_list' &&
[tailSessionId, nullSessionId, boundarySessionId].every((id) => (
[tailSessionId, childSessionId, nullSessionId, boundarySessionId].every((id) => (
msg.sessions.some((session) => session.id === id)
))
), 5000);
@@ -5359,6 +5520,18 @@ async function runSessionPreviewMetadataRegression() {
assert(boundarySession.cwd === '', `Preview parser should not join head cwd key with tail value, got ${JSON.stringify(boundarySession.cwd)}`);
assert(boundarySession.projectName === '', `Boundary preview should not derive a projectName from a joined value, got ${JSON.stringify(boundarySession.projectName)}`);
const childList = await callInternalMcp(port, 'SessionPreviewMcp!234', {
tool: 'ccweb_list_conversations',
sourceSessionId,
args: { scope: 'children', limit: 10 },
});
assert(childList.status === 200 && childList.body?.ok, 'Preview children scope list should succeed');
const childIds = childList.body.conversations.map((conversation) => conversation.id);
assert(
JSON.stringify(childIds) === JSON.stringify([childSessionId]),
`Preview children scope should return only the direct MCP child, got ${JSON.stringify(childIds)}`
);
ws.close();
});
}
@@ -5438,6 +5611,70 @@ function assertCcwebDisplayImageContract() {
assert(prepareImagePayload({ source: 'relative.png' }).code === 'invalid_local_path', 'Local images should require absolute paths');
}
function assertCcwebListConversationsScopeContract() {
const server = fs.readFileSync(SERVER_PATH, 'utf8');
const {
TOOLS,
codexAppCommunicationTools,
isCallableToolName,
} = require(path.join(REPO_DIR, 'lib', 'ccweb-mcp-server'));
const listTool = TOOLS.find((tool) => tool.name === 'ccweb_list_conversations');
const scopeSchema = listTool?.inputSchema?.properties?.scope;
assert(listTool, 'ccweb MCP should expose ccweb_list_conversations');
assert(
JSON.stringify(scopeSchema?.enum) === JSON.stringify(['all', 'children']),
'ccweb_list_conversations stdio/shared MCP schema should expose all and children scopes'
);
assert(
/默认返回全部对话/.test(listTool.description || '') &&
/scope=children/.test(listTool.description || '') &&
/直接创建/.test(listTool.description || ''),
'ccweb_list_conversations stdio/shared MCP prompt should explain children scope'
);
const toolNames = TOOLS.map((tool) => tool.name);
const sendTool = TOOLS.find((tool) => tool.name === 'ccweb_send_message');
assert(sendTool, 'ccweb MCP should expose the unified ccweb_send_message tool');
assert(!toolNames.includes('ccweb_request_reply'), 'ccweb MCP public tools/list should hide ccweb_request_reply');
assert(
sendTool.inputSchema?.required?.includes('replyMode') &&
JSON.stringify(sendTool.inputSchema?.properties?.replyMode?.enum) === JSON.stringify(['one_way', 'return_and_continue']),
'ccweb_send_message should require the exact one_way/return_and_continue replyMode enum'
);
assert(isCallableToolName('ccweb_request_reply'), 'Hidden legacy ccweb_request_reply should remain callable');
const dynamicTools = codexAppCommunicationTools();
const dynamicListTool = dynamicTools.find((tool) => tool.name === 'ccweb_list_conversations');
const dynamicSendTool = dynamicTools.find((tool) => tool.name === 'ccweb_send_message');
assert(dynamicListTool && dynamicSendTool, 'Codex App compatibility tools should include list and unified send');
assert(!dynamicTools.some((tool) => tool.name === 'ccweb_request_reply'), 'Codex App compatibility tools should hide ccweb_request_reply');
const { namespace: listNamespace, ...dynamicListDefinition } = dynamicListTool;
const { namespace: sendNamespace, ...dynamicSendDefinition } = dynamicSendTool;
assert(listNamespace === 'ccweb' && sendNamespace === 'ccweb', 'Codex App compatibility tools should use the ccweb namespace');
assert(JSON.stringify(dynamicListDefinition) === JSON.stringify(listTool), 'Codex App list tool should reuse the formal MCP definition');
assert(JSON.stringify(dynamicSendDefinition) === JSON.stringify(sendTool), 'Codex App send tool should reuse the formal MCP definition');
const dynamicToolsStart = server.indexOf('function codexAppCommunicationDynamicTools()');
const dynamicToolsEnd = server.indexOf('function handleCodexAppDynamicToolCall', dynamicToolsStart);
const dynamicToolsSource = server.slice(dynamicToolsStart, dynamicToolsEnd);
assert(
/codexAppCommunicationTools\s*\(/.test(dynamicToolsSource),
'Codex App compatibility tools should delegate to the formal shared definitions'
);
const dynamicCallStart = server.indexOf('function handleCodexAppDynamicToolCall');
const dynamicCallEnd = server.indexOf('function normalizeCodexAppUserInputAnswers', dynamicCallStart);
assert(
/ccweb_request_reply/.test(server.slice(dynamicCallStart, dynamicCallEnd)),
'Codex App old dynamic calls should keep accepting the hidden legacy reply tool'
);
const sharedMcpStart = server.indexOf('function handleMcpJsonRpcMessage');
const sharedMcpEnd = server.indexOf('async function handleSharedMcpHttpApi', sharedMcpStart);
assert(
/case ['"]tools\/call['"]:[\s\S]*isCallableToolName\s*\(/.test(server.slice(sharedMcpStart, sharedMcpEnd)),
'Shared MCP tools/call should accept hidden legacy names through the callable gate'
);
}
async function main() {
const targetIndex = process.argv.indexOf('--target');
const regressionTarget = targetIndex >= 0 ? String(process.argv[targetIndex + 1] || '').trim() : String(process.env.CC_WEB_REGRESSION_TARGET || '').trim();
@@ -5527,6 +5764,7 @@ async function main() {
return;
}
if (regressionTarget === 'session-preview-metadata') {
assertCcwebListConversationsScopeContract();
await runSessionPreviewMetadataRegression();
console.log('Session preview metadata regression checks passed.');
return;
@@ -5566,6 +5804,7 @@ async function main() {
assertFrontendGildedThemeContract();
assertFrontendWastelandThemeContract();
assertFrontendSidebarCollapseContract();
assertTaskBoardIntegrationContract();
assertFrontendGenerationControlsContract();
assertFrontendComposerMcpContract();
assertComposerSlashRoutingContract();
@@ -5591,6 +5830,7 @@ async function main() {
assertMultiAgentV2CompatibilityContract();
assertWindowsStartupContract();
assertCcwebDisplayImageContract();
assertCcwebListConversationsScopeContract();
await runSessionPreviewMetadataRegression();
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'cc-web-regression-'));
@@ -6113,6 +6353,81 @@ async function main() {
))
));
const scopeFixtureTimestamp = new Date().toISOString();
const branchFixtureId = 'mcp-children-branch-fixture';
const grandchildFixtureId = 'mcp-children-grandchild-fixture';
const scopeFixtureBase = {
created: scopeFixtureTimestamp,
updated: scopeFixtureTimestamp,
pinnedAt: null,
titleSource: 'system',
agent: 'codex',
permissionMode: 'yolo',
totalCost: 0,
totalUsage: { inputTokens: 0, cachedInputTokens: 0, outputTokens: 0 },
messages: [],
cwd: codexInitCwd,
};
fs.writeFileSync(path.join(sessionsDir, `${branchFixtureId}.json`), JSON.stringify({
...scopeFixtureBase,
id: branchFixtureId,
title: 'Branch Child Fixture',
createdFrom: {
kind: 'branch',
sourceSessionId: codexSession.sessionId,
sourceTitle: 'Regression Source',
sourceMessageIndex: 0,
createdAt: scopeFixtureTimestamp,
},
}, null, 2));
fs.writeFileSync(path.join(sessionsDir, `${grandchildFixtureId}.json`), JSON.stringify({
...scopeFixtureBase,
id: grandchildFixtureId,
title: 'MCP Grandchild Fixture',
createdFrom: {
kind: 'mcp',
sourceSessionId: mcpCreate.body.conversationId,
sourceTitle: 'MCP Created Conversation',
hopCount: 2,
createdAt: scopeFixtureTimestamp,
},
}, null, 2));
const listArgs = { agent: 'codex', limit: 100 };
const mcpListDefaultScope = await callInternalMcp(port, internalMcpToken, {
tool: 'ccweb_list_conversations',
sourceSessionId: codexSession.sessionId,
args: listArgs,
});
const mcpListAllScope = await callInternalMcp(port, internalMcpToken, {
tool: 'ccweb_list_conversations',
sourceSessionId: codexSession.sessionId,
args: { ...listArgs, scope: 'all' },
});
const mcpListInvalidScope = await callInternalMcp(port, internalMcpToken, {
tool: 'ccweb_list_conversations',
sourceSessionId: codexSession.sessionId,
args: { ...listArgs, scope: 'unexpected' },
});
const defaultScopeIds = mcpListDefaultScope.body?.conversations?.map((conversation) => conversation.id) || [];
const allScopeIds = mcpListAllScope.body?.conversations?.map((conversation) => conversation.id) || [];
const invalidScopeIds = mcpListInvalidScope.body?.conversations?.map((conversation) => conversation.id) || [];
assert(mcpListDefaultScope.status === 200 && mcpListAllScope.status === 200, 'Default and all conversation scopes should succeed');
assert(JSON.stringify(defaultScopeIds) === JSON.stringify(allScopeIds), 'Omitted scope should return exactly the same conversations as scope=all');
assert(JSON.stringify(invalidScopeIds) === JSON.stringify(allScopeIds), 'Unexpected scope should conservatively fall back to all');
const mcpListChildrenScope = await callInternalMcp(port, internalMcpToken, {
tool: 'ccweb_list_conversations',
sourceSessionId: codexSession.sessionId,
args: { ...listArgs, scope: 'children' },
});
assert(mcpListChildrenScope.status === 200 && mcpListChildrenScope.body?.ok, 'Children conversation scope should succeed');
const childrenScopeIds = mcpListChildrenScope.body.conversations.map((conversation) => conversation.id);
assert(
JSON.stringify(childrenScopeIds) === JSON.stringify([mcpCreate.body.conversationId]),
`Children scope should include only direct MCP children, got ${JSON.stringify(childrenScopeIds)}`
);
const mcpReplyCreateCwd = path.join(tempRoot, 'mcp-create-reply');
mkdirp(mcpReplyCreateCwd);
const mcpCreateReply = await callInternalMcp(port, internalMcpToken, {
@@ -6159,6 +6474,18 @@ async function main() {
mkdirp(crossTargetCwd);
ws.send(JSON.stringify({ type: 'new_session', agent: 'codex', cwd: crossTargetCwd, mode: 'yolo' }));
const crossTargetSession = await nextMessage(messages, ws, (msg) => msg.type === 'session_info' && msg.agent === 'codex' && msg.cwd === crossTargetCwd);
const invalidReplyMode = await callInternalMcp(port, internalMcpToken, {
tool: 'ccweb_send_message',
sourceSessionId: codexSession.sessionId,
sourceHopCount: 0,
args: {
targetConversationId: crossTargetSession.sessionId,
content: 'invalid reply mode should reject',
replyMode: 'wait_forever',
},
});
assert(invalidReplyMode.status === 400 && invalidReplyMode.body?.code === 'invalid_reply_mode', 'Explicit invalid replyMode should be rejected');
const crossSend = await callInternalMcp(port, internalMcpToken, {
tool: 'ccweb_send_message',
sourceSessionId: codexSession.sessionId,
@@ -6166,9 +6493,11 @@ async function main() {
args: {
targetConversationId: crossTargetSession.sessionId,
content: 'cross hello from mcp',
replyMode: 'one_way',
},
});
assert(crossSend.status === 200 && crossSend.body?.ok, `MCP cross send should succeed: ${JSON.stringify(crossSend.body)}`);
assert(crossSend.body.replyMode === 'one_way' && !crossSend.body.requestId, 'one_way send should return its mode without creating a reply request');
const crossUserBubble = await nextMessage(messages, ws, (msg) => (
msg.type === 'session_message' &&
msg.sessionId === crossTargetSession.sessionId &&
@@ -6182,7 +6511,13 @@ async function main() {
const storedCrossMessage = storedCrossTarget.messages.find((message) => message.crossConversation?.messageId === crossSend.body.messageId);
assert(storedCrossMessage?.content === 'cross hello from mcp', 'Cross message should be persisted in target session');
assert(storedCrossMessage.crossConversation.sourceTitle === storedCrossSource.title, 'Cross message should persist source title');
assert(storedCrossTarget.messages.some((message) => message.role === 'assistant' && /来自/.test(String(message.content || ''))), 'Cross message runtime prompt should include source context for the target agent');
assert(storedCrossMessage.crossConversation.replyMode === 'one_way' && storedCrossMessage.crossConversation.expectsReply !== true, 'one_way target metadata should not expect a reply');
assert(storedCrossTarget.messages.some((message) => (
message.role === 'assistant' &&
/来自/.test(String(message.content || '')) &&
/one_way/.test(String(message.content || '')) &&
/不会自动回传/.test(String(message.content || ''))
)), 'one_way runtime prompt should include source context and state that output will not auto-return');
const hopAllowed = await callInternalMcp(port, internalMcpToken, {
tool: 'ccweb_send_message',
@@ -6194,6 +6529,7 @@ async function main() {
},
});
assert(hopAllowed.status === 200 && hopAllowed.body?.ok, `MCP cross send should not enforce hop limit: ${JSON.stringify(hopAllowed.body)}`);
assert(hopAllowed.body.replyMode === 'one_way' && !hopAllowed.body.requestId, 'Legacy ccweb_send_message without replyMode should remain one_way');
const hopAllowedBubble = await nextMessage(messages, ws, (msg) => (
msg.type === 'session_message' &&
msg.sessionId === crossTargetSession.sessionId &&
@@ -6208,17 +6544,19 @@ async function main() {
ws.send(JSON.stringify({ type: 'new_session', agent: 'codex', cwd: crossReplyTargetCwd, mode: 'yolo' }));
const crossReplyTargetSession = await nextMessage(messages, ws, (msg) => msg.type === 'session_info' && msg.agent === 'codex' && msg.cwd === crossReplyTargetCwd);
const requestReply = await callInternalMcp(port, internalMcpToken, {
tool: 'ccweb_request_reply',
tool: 'ccweb_send_message',
sourceSessionId: codexSession.sessionId,
sourceHopCount: 0,
args: {
targetConversationId: crossReplyTargetSession.sessionId,
content: 'cross reply requested',
replyMode: 'return_and_continue',
},
});
assert(requestReply.status === 200 && requestReply.body?.ok, `MCP request reply should succeed: ${JSON.stringify(requestReply.body)}`);
assert(requestReply.body.requestId && requestReply.body.status === 'waiting', 'MCP request reply should return a waiting request id');
assert(requestReply.body.replyDelivery === 'auto_run' && requestReply.body.sourceAutoRun === true, 'MCP request reply should declare source auto-run delivery');
assert(requestReply.status === 200 && requestReply.body?.ok, `MCP return_and_continue should succeed: ${JSON.stringify(requestReply.body)}`);
assert(requestReply.body.replyMode === 'return_and_continue', 'MCP return_and_continue should echo its normalized reply mode');
assert(requestReply.body.requestId && requestReply.body.status === 'waiting', 'MCP return_and_continue should return a waiting request id');
assert(requestReply.body.replyDelivery === 'auto_run' && requestReply.body.sourceAutoRun === true, 'MCP return_and_continue should declare source auto-run delivery');
const requestReplyTargetBubble = await nextMessage(messages, ws, (msg) => (
msg.type === 'session_message' &&
msg.sessionId === crossReplyTargetSession.sessionId &&
@@ -6226,7 +6564,8 @@ async function main() {
msg.message?.crossConversation?.expectsReply === true &&
msg.message?.content === 'cross reply requested'
));
assert(requestReplyTargetBubble.message.crossConversation.hopCount === 1, 'Request reply target message should persist hop count');
assert(requestReplyTargetBubble.message.crossConversation.hopCount === 1, 'return_and_continue target message should persist hop count');
assert(requestReplyTargetBubble.message.crossConversation.replyMode === 'return_and_continue', 'return_and_continue target metadata should persist reply mode');
await nextMessage(messages, ws, (msg) => msg.type === 'done' && msg.sessionId === crossReplyTargetSession.sessionId);
await waitForJsonCondition(path.join(sessionsDir, `${codexSession.sessionId}.json`), (session) => (
Array.isArray(session.messages) &&
@@ -6240,8 +6579,14 @@ async function main() {
const storedReplyTarget = JSON.parse(fs.readFileSync(path.join(sessionsDir, `${crossReplyTargetSession.sessionId}.json`), 'utf8'));
const storedReplySource = JSON.parse(fs.readFileSync(path.join(sessionsDir, `${codexSession.sessionId}.json`), 'utf8'));
const storedReplyRequestMessage = storedReplyTarget.messages.find((message) => message.crossConversation?.replyRequestId === requestReply.body.requestId);
assert(storedReplyRequestMessage?.crossConversation?.expectsReply === true, 'Request reply target message should persist waiting metadata');
assert(storedReplyTarget.messages.some((message) => message.role === 'assistant' && /cross reply requested/.test(String(message.content || ''))), 'Request reply target should produce an assistant reply');
assert(storedReplyRequestMessage?.crossConversation?.expectsReply === true, 'return_and_continue target message should persist waiting metadata');
assert(storedReplyTarget.messages.some((message) => (
message.role === 'assistant' &&
/cross reply requested/.test(String(message.content || '')) &&
/return_and_continue/.test(String(message.content || '')) &&
/自动回传/.test(String(message.content || '')) &&
/(不要|禁止)[\s\S]*(手工|手动)/.test(String(message.content || ''))
)), 'return_and_continue target prompt should promise automatic return and prevent duplicate manual return');
const storedReplyMessageIndex = storedReplySource.messages.findIndex((message) => message.crossConversation?.replyToRequestId === requestReply.body.requestId);
assert(storedReplyMessageIndex >= 0, 'Request reply should send the target reply back to source session');
const storedReplyMessage = storedReplySource.messages[storedReplyMessageIndex];
@@ -6249,15 +6594,19 @@ async function main() {
assert(storedReplyMessage.crossConversation.reply === true, 'Returned cross message should be marked as a reply');
assert(storedReplyMessage.crossConversation.processed === true, 'Returned cross message should persist a processed marker');
assert(storedReplyMessage.crossConversation.autoRun === true, 'Returned cross message should mark source auto-run');
assert(storedReplyMessage.crossConversation.originalRequest === 'cross reply requested', 'Returned cross message should retain the original request for correlation');
assert(storedReplyMessage.ccwebDisplayOnly === true, 'Returned cross message should be marked display-only');
assert(/线程「/.test(storedReplyMessage.content || '') && /已返回消息/.test(storedReplyMessage.content || ''), 'Returned cross message should include reply heading');
assert(/Codex mock handled/.test(storedReplyMessage.content || ''), 'Returned cross message should include target assistant output');
assert(storedReplySource.messages.slice(storedReplyMessageIndex + 1).some((message) => (
const replyAutoRunAssistant = storedReplySource.messages.slice(storedReplyMessageIndex + 1).find((message) => (
message.role === 'assistant' &&
/Codex mock handled/.test(String(message.content || '')) &&
/cross reply requested/.test(String(message.content || '')) &&
/子对话/.test(String(message.content || ''))
)), 'Returned cross message should trigger the source session to run again');
/cross reply requested/.test(String(message.content || ''))
));
assert(replyAutoRunAssistant, 'Returned cross message should trigger the source session to run again');
assert(new RegExp(requestReply.body.requestId).test(String(replyAutoRunAssistant.content || '')), 'Source auto-run prompt should include requestId');
assert(new RegExp(crossReplyTargetSession.sessionId).test(String(replyAutoRunAssistant.content || '')), 'Source auto-run prompt should include target conversation id');
assert(/子对话/.test(String(replyAutoRunAssistant.content || '')) && /已返回不等于已完成/.test(String(replyAutoRunAssistant.content || '')), 'Source auto-run prompt should identify the child reply and require a completion check');
const busySourceCwd = path.join(tempRoot, 'codex-mcp-busy-source');
mkdirp(busySourceCwd);
@@ -6280,6 +6629,7 @@ async function main() {
},
});
assert(busyRequestReply.status === 200 && busyRequestReply.body?.ok, `MCP busy source request reply should succeed: ${JSON.stringify(busyRequestReply.body)}`);
assert(busyRequestReply.body.replyMode === 'return_and_continue', 'Legacy ccweb_request_reply should map to return_and_continue');
assert(busyRequestReply.body.requestId && busyRequestReply.body.status === 'waiting', 'Busy source request reply should return a waiting request id');
assert(busyRequestReply.body.replyDelivery === 'auto_run' && busyRequestReply.body.sourceAutoRun === true, 'Busy source request reply should declare source auto-run delivery');
await nextMessage(messages, ws, (msg) => msg.type === 'done' && msg.sessionId === busyReplyTargetSession.sessionId);
@@ -6289,6 +6639,7 @@ async function main() {
reply.requestId === busyRequestReply.body.requestId &&
reply.sourceConversationId === busySourceSession.sessionId &&
reply.status === 'ready' &&
reply.originalRequest === 'busy source reply requested' &&
/busy source reply requested/.test(String(reply.replyText || ''))
))
));
@@ -6303,7 +6654,11 @@ async function main() {
assert(busyPendingList.status === 200 && busyPendingList.body?.ok, `MCP pending reply list should succeed: ${JSON.stringify(busyPendingList.body)}`);
assert(busyPendingList.body.waitingOnChildren === true, 'Pending reply list should report waitingOnChildren while ready reply is queued');
assert(busyPendingList.body.readyReplyCount === 1, 'Pending reply list should count ready replies');
assert(busyPendingList.body.replies.some((reply) => reply.requestId === busyRequestReply.body.requestId && reply.status === 'ready'), 'Pending reply list should include the queued ready reply');
assert(busyPendingList.body.replies.some((reply) => (
reply.requestId === busyRequestReply.body.requestId &&
reply.status === 'ready' &&
reply.originalRequest === 'busy source reply requested'
)), 'Pending reply list should include the queued ready reply and original request');
const busyPendingDetail = await callInternalMcp(port, internalMcpToken, {
tool: 'ccweb_get_pending_reply',
@@ -6312,6 +6667,7 @@ async function main() {
});
assert(busyPendingDetail.status === 200 && busyPendingDetail.body?.ok, `MCP pending reply detail should succeed: ${JSON.stringify(busyPendingDetail.body)}`);
assert(busyPendingDetail.body.status === 'ready', 'Pending reply detail should expose ready status');
assert(busyPendingDetail.body.originalRequest === 'busy source reply requested', 'Pending reply detail should expose the original request');
assert(/busy source reply requested/.test(String(busyPendingDetail.body.replyText || '')), 'Pending reply detail should expose target assistant output');
const busyConversationList = await callInternalMcp(port, internalMcpToken, {
@@ -6341,11 +6697,14 @@ async function main() {
assert(storedBusySource.messages[busyReplyIndex].crossConversation?.autoRun === true, 'Queued reply should mark source auto-run');
await nextMessage(messages, ws, (msg) => isSessionCompletionMessage(msg, busySourceSession.sessionId), 8000);
storedBusySource = JSON.parse(fs.readFileSync(path.join(sessionsDir, `${busySourceSession.sessionId}.json`), 'utf8'));
assert(storedBusySource.messages.slice(busyReplyIndex + 1).some((message) => (
const busyAutoRunAssistant = storedBusySource.messages.slice(busyReplyIndex + 1).find((message) => (
message.role === 'assistant' &&
/busy source reply requested/.test(String(message.content || '')) &&
/子对话/.test(String(message.content || ''))
)), 'Busy source should auto-run after the queued child reply is flushed');
/busy source reply requested/.test(String(message.content || ''))
));
assert(busyAutoRunAssistant, 'Busy source should auto-run after the queued child reply is flushed');
assert(new RegExp(busyRequestReply.body.requestId).test(String(busyAutoRunAssistant.content || '')), 'Queued source auto-run should include requestId');
assert(new RegExp(busyReplyTargetSession.sessionId).test(String(busyAutoRunAssistant.content || '')), 'Queued source auto-run should include target conversation id');
assert(/子对话/.test(String(busyAutoRunAssistant.content || '')) && /已返回不等于已完成/.test(String(busyAutoRunAssistant.content || '')), 'Queued source auto-run should require a completion check');
const returnedPendingDetail = await callInternalMcp(port, internalMcpToken, {
tool: 'ccweb_get_pending_reply',
@@ -6354,6 +6713,7 @@ async function main() {
});
assert(returnedPendingDetail.status === 200 && returnedPendingDetail.body?.ok, 'Returned pending reply detail should remain queryable from source history');
assert(returnedPendingDetail.body.status === 'returned' && returnedPendingDetail.body.returned === true, 'Returned pending reply detail should report returned status');
assert(returnedPendingDetail.body.originalRequest === 'busy source reply requested', 'Returned pending reply detail should retain the original request');
ws.send(JSON.stringify({ type: 'load_session', sessionId: busySourceSession.sessionId, requestId: 'reg-load-busy-source' }));
const loadedBusySource = await nextMessage(messages, ws, (msg) => msg.type === 'session_info' && msg.sessionId === busySourceSession.sessionId);
@@ -6725,6 +7085,7 @@ async function main() {
const reloadMcpStatusText = JSON.stringify(reloadMcpResult.mcpStatus);
assert(/CC_WEB_MCP_TOKEN=\[redacted\]/.test(reloadMcpStatusText), 'Codex App MCP reload status should redact token-looking values');
assert(!reloadMcpStatusText.includes('mock-secret-token'), 'Codex App MCP reload status should not leak raw token-looking values');
const baselineMcpReloadCount = Number(reloadMcpResult.result?.reloadCount || 0);
storedCodexApp = JSON.parse(fs.readFileSync(path.join(sessionsDir, `${codexAppSession.sessionId}.json`), 'utf8'));
assert(storedCodexApp.codexAppMcpStartupStatus?.servers?.ccweb?.status === 'ready', 'Codex App MCP startup status should be persisted on the session');
assert(!JSON.stringify(storedCodexApp.codexAppMcpStartupStatus).includes('mock-secret-token'), 'Persisted MCP startup status should not leak raw token-looking values');
@@ -6742,11 +7103,39 @@ async function main() {
await nextMessage(messages, ws, (msg) => msg.type === 'done' && msg.sessionId === codexAppSession.sessionId);
ws.send(JSON.stringify({ type: 'composer_suggestions', requestId: 'reg-codexapp-empty-slash-prompt-user-mcp', trigger: '/', query: '', sessionId: codexAppSession.sessionId, agent: 'codexapp' }));
const codexAppEmptySlashMcpComposer = await nextMessage(messages, ws, (msg) => msg.type === 'composer_suggestions' && msg.requestId === 'reg-codexapp-empty-slash-prompt-user-mcp');
const emptySlashPromptUserIndex = codexAppEmptySlashMcpComposer.items.findIndex((item) => item.kind === 'mcp' && item.server === 'ccweb' && item.name === 'ccweb_prompt_user');
const firstOtherCcwebMcpIndex = codexAppEmptySlashMcpComposer.items.findIndex((item) => item.kind === 'mcp' && item.server === 'ccweb' && item.name !== 'ccweb_prompt_user');
assert(emptySlashPromptUserIndex >= 0, 'Codex App empty slash composer should include ccweb_prompt_user');
assert(firstOtherCcwebMcpIndex < 0 || emptySlashPromptUserIndex < firstOtherCcwebMcpIndex, 'ccweb_prompt_user should be pinned before other ccweb MCP tools');
const codexAppUntrackedComposer = await nextMessage(messages, ws, (msg) => msg.type === 'composer_suggestions' && msg.requestId === 'reg-codexapp-empty-slash-prompt-user-mcp');
assert(codexAppUntrackedComposer.items[0]?.name === 'ccweb_prompt_user', 'Untracked Codex App composer should preserve prompt_user priority');
assert(!codexAppUntrackedComposer.items.some((item) => item.name === 'ccweb_task_status_list'), 'Untracked composer must not expose retired status_list');
assert(!codexAppUntrackedComposer.items.some((item) => item.name === 'ccweb_task_update'), 'Untracked composer must not expose task update');
assert(codexAppUntrackedComposer.items.some((item) => item.kind === 'mcp' && item.itemType === 'server' && item.name === 'reg-app-project'), 'Untracked task MCP regression should retain other project MCP servers');
ws.send(JSON.stringify({
type: 'task_tracking_set',
requestId: 'reg-codexapp-enable-task-tracking',
sessionId: codexAppSession.sessionId,
enabled: true,
expectedVersion: codexAppSession.taskTracking?.version || 0,
}));
const trackedResult = await nextMessage(messages, ws, (msg) => msg.type === 'task_tracking_result' && msg.requestId === 'reg-codexapp-enable-task-tracking');
assert(trackedResult.ok === true && trackedResult.task?.taskTracking?.enabled === true, 'Regression fixture should enable task tracking through the production WebSocket path');
ws.send(JSON.stringify({ type: 'composer_suggestions', requestId: 'reg-codexapp-empty-slash-tracked', trigger: '/', query: '', sessionId: codexAppSession.sessionId, agent: 'codexapp' }));
const codexAppTrackedComposer = await nextMessage(messages, ws, (msg) => msg.type === 'composer_suggestions' && msg.requestId === 'reg-codexapp-empty-slash-tracked');
assert(
codexAppTrackedComposer.items.slice(0, 2).map((item) => item.name).join(',')
=== 'ccweb_prompt_user,ccweb_task_update',
'Tracked empty slash initial viewport should expose prompt_user then the sole dynamic task update tool'
);
assert(!codexAppTrackedComposer.items.some((item) => item.name === 'ccweb_task_status_list'), 'Tracked composer must not expose retired status_list');
assert(codexAppTrackedComposer.items.findIndex((item) => item.name === 'ccweb_task_update') === 1, 'Tracked task update must stay inside the initial visible suggestions');
ws.send(JSON.stringify({ type: 'message', text: 'codexapp task schema refresh', sessionId: codexAppSession.sessionId, mode: 'yolo', agent: 'codexapp' }));
await nextMessage(messages, ws, (msg) => msg.type === 'done' && msg.sessionId === codexAppSession.sessionId);
const reloadAfterTracking = await postAuthedJson(port, token, `/api/sessions/${codexAppSession.sessionId}/reload-mcp`);
assert(
Number(reloadAfterTracking.result?.reloadCount || 0) >= baselineMcpReloadCount + 2,
'Changing tracking should trigger one implicit Codex App MCP reload before the next turn'
);
ws.send(JSON.stringify({ type: 'composer_suggestions', requestId: 'reg-codexapp-prompt-user-mcp', trigger: '/', query: 'prompt_user', sessionId: codexAppSession.sessionId, agent: 'codexapp' }));
const codexAppPromptUserMcpComposer = await nextMessage(messages, ws, (msg) => msg.type === 'composer_suggestions' && msg.requestId === 'reg-codexapp-prompt-user-mcp');
@@ -7117,6 +7506,7 @@ async function main() {
args: {
targetConversationId: codexAppSession.sessionId,
content: 'running codexapp target should reject this',
replyMode: 'one_way',
},
});
assert(codexAppRunningMcp.status === 400 && codexAppRunningMcp.body?.code === 'target_running', 'MCP cross send should reject running Codex App targets');

View File

@@ -0,0 +1,548 @@
'use strict';
const assert = require('node:assert');
const fs = require('node:fs');
const net = require('node:net');
const os = require('node:os');
const path = require('node:path');
const { spawn } = require('node:child_process');
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 GECKODRIVER = '/snap/firefox/current/usr/lib/firefox/geckodriver';
const FIREFOX = '/snap/firefox/current/usr/lib/firefox/firefox';
const PASSWORD = 'TaskBoardBrowser!234';
const INTERNAL_TOKEN = 'TaskBoardBrowserInternal!234';
const SESSION_ID = 'browser-task-existing';
const STATUS_ID = 'waiting-release';
const INITIAL_PROMPT = '当实现和验证已完成,但仍需等待部署环境或发布窗口时选择此列。';
const EDITED_PROMPT = '当实现与验证完成,但仍在等待发布窗口、部署条件或最终放行时选择此列。';
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 = 10_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 waitFor(predicate, message, timeoutMs = 10_000) {
const started = Date.now();
let lastError = null;
while (Date.now() - started < timeoutMs) {
try {
const value = await predicate();
if (value) return value;
} catch (error) {
lastError = error;
}
await sleep(80);
}
throw new Error(`${message}${lastError ? `: ${lastError.message}` : ''}`);
}
async function stopChild(child) {
if (!child || child.exitCode !== null) return;
child.kill('SIGTERM');
await Promise.race([
new Promise((resolve) => child.once('exit', resolve)),
sleep(1_000),
]);
if (child.exitCode === null) child.kill('SIGKILL');
}
class FirefoxDriver {
constructor(port) {
this.port = port;
this.sessionId = '';
}
async request(method, endpoint, body) {
const response = await fetch(`http://127.0.0.1:${this.port}${endpoint}`, {
method,
headers: body === undefined ? {} : { 'content-type': 'application/json' },
body: body === undefined ? undefined : JSON.stringify(body),
});
const payload = await response.json();
if (!response.ok || payload?.value?.error) {
throw new Error(`WebDriver ${method} ${endpoint}: ${JSON.stringify(payload.value || payload)}`);
}
return payload.value;
}
async start() {
const value = await this.request('POST', '/session', {
capabilities: {
alwaysMatch: {
browserName: 'firefox',
acceptInsecureCerts: true,
'moz:firefoxOptions': {
binary: FIREFOX,
args: ['-headless'],
prefs: {
'browser.shell.checkDefaultBrowser': false,
'browser.startup.homepage_override.mstone': 'ignore',
'media.prefers-reduced-motion': 1,
},
},
},
},
});
this.sessionId = value.sessionId;
return value.capabilities;
}
async execute(script, args = []) {
return this.request('POST', `/session/${this.sessionId}/execute/sync`, { script, args });
}
async navigate(url) {
return this.request('POST', `/session/${this.sessionId}/url`, { url });
}
async setWindow(width, height) {
await this.request('POST', `/session/${this.sessionId}/window/rect`, { width, height });
return this.request('GET', `/session/${this.sessionId}/window/rect`);
}
async click(selector) {
const clicked = await this.execute(`
const node = document.querySelector(arguments[0]);
if (!node) return false;
node.click();
return true;
`, [selector]);
assert(clicked, `找不到可点击节点: ${selector}`);
}
async screenshot(targetPath) {
const base64 = await this.request('GET', `/session/${this.sessionId}/screenshot`);
fs.writeFileSync(targetPath, Buffer.from(base64, 'base64'));
return targetPath;
}
async close() {
if (!this.sessionId) return;
try {
await this.request('DELETE', `/session/${this.sessionId}`);
} finally {
this.sessionId = '';
}
}
}
function readJson(filePath) {
return JSON.parse(fs.readFileSync(filePath, 'utf8'));
}
function statusDefinition(configPath) {
if (!fs.existsSync(configPath)) return null;
return readJson(configPath).definitions?.find((definition) => definition.id === STATUS_ID) || null;
}
async function setTheme(driver, theme) {
await driver.execute(`
document.documentElement.dataset.theme = arguments[0];
localStorage.setItem('cc-web-theme', arguments[0]);
return document.documentElement.dataset.theme;
`, [theme]);
await sleep(120);
}
async function auditBoard(driver) {
return driver.execute(`
const shell = document.querySelector('.ccweb-task-board');
const toolbar = document.querySelector('.ccweb-task-board__toolbar');
const lanes = document.querySelector('.ccweb-task-board__lanes');
const workspace = document.querySelector('.ccweb-task-board__workspace');
const column = document.querySelector('.ccweb-task-board__column');
const text = shell?.innerText || '';
const controlsText = document.querySelector('.ccweb-task-board__controls')?.innerText || '';
const styleOf = (node) => node ? getComputedStyle(node).backgroundColor : '';
return {
theme: document.documentElement.dataset.theme,
viewport: { width: innerWidth, height: innerHeight },
shellBackground: styleOf(shell),
toolbarBackground: styleOf(toolbar),
columnBackground: styleOf(column),
controlsText,
hasAgentFilter: /Agent|代理筛选/.test(controlsText),
hasPriorityFilter: /优先级|priority/i.test(controlsText),
hasCreateTask: /新建任务/.test(controlsText),
hasRefreshControl: Array.from(document.querySelectorAll('.ccweb-task-board__controls button')).some((button) => /刷新|refresh/i.test(button.textContent + ' ' + button.title + ' ' + button.getAttribute('aria-label'))),
hasPercentage: /(?:^|\\s)\\d+(?:\\.\\d+)?%/.test(text),
hasProgressElement: !!shell?.querySelector('[class*="progress"], [data-progress]'),
hasRuntimeState: /运行:(?:运行中|未运行|等待子任务)/.test(text),
hasWaitingReleaseLane: !!lanes?.querySelector('[data-status-id="waiting-release"]'),
laneScrollWidth: lanes?.scrollWidth || 0,
laneClientWidth: lanes?.clientWidth || 0,
workspaceScrollWidth: workspace?.scrollWidth || 0,
workspaceClientWidth: workspace?.clientWidth || 0,
};
`);
}
async function main() {
assert(fs.existsSync(GECKODRIVER), `缺少 geckodriver: ${GECKODRIVER}`);
assert(fs.existsSync(FIREFOX), `缺少 Firefox: ${FIREFOX}`);
const runtimeRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'cc-web-task-board-browser-runtime-'));
const evidenceRoot = path.join(os.tmpdir(), `cc-web-task-board-browser-evidence-${Date.now()}`);
const configDir = path.join(runtimeRoot, 'config');
const sessionsDir = path.join(runtimeRoot, 'sessions');
const logsDir = path.join(runtimeRoot, 'logs');
const homeDir = path.join(runtimeRoot, 'home');
for (const directory of [evidenceRoot, configDir, sessionsDir, logsDir, homeDir]) {
fs.mkdirSync(directory, { recursive: true });
}
const now = new Date().toISOString();
const sessionPath = path.join(sessionsDir, `${SESSION_ID}.json`);
fs.writeFileSync(sessionPath, JSON.stringify({
id: SESSION_ID,
title: '真实浏览器任务',
agent: 'codexapp',
cwd: homeDir,
created: now,
updated: now,
messages: [],
taskTracking: {
enabled: true,
statusId: 'in_progress',
source: 'user',
reason: '浏览器隔离验收',
summary: '验证真实新增列链路',
version: 1,
enabledAt: now,
disabledAt: null,
statusUpdatedAt: now,
archivedAt: null,
archivedBy: null,
},
}, null, 2));
const [serverPort, driverPort] = await Promise.all([freePort(), freePort()]);
const server = spawn(process.execPath, [SERVER_PATH], {
cwd: REPO_DIR,
env: {
...process.env,
PORT: String(serverPort),
CC_WEB_PASSWORD: PASSWORD,
CC_WEB_INTERNAL_MCP_TOKEN: INTERNAL_TOKEN,
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'],
});
const geckodriver = spawn(GECKODRIVER, ['--port', String(driverPort), '-b', FIREFOX], {
cwd: runtimeRoot,
stdio: ['ignore', 'pipe', 'pipe'],
});
let serverStderr = '';
let driverStderr = '';
server.stderr.on('data', (chunk) => { serverStderr += chunk.toString(); });
geckodriver.stderr.on('data', (chunk) => { driverStderr += chunk.toString(); });
const driver = new FirefoxDriver(driverPort);
const evidence = { screenshots: {}, geometry: {}, audits: {}, runtime: {} };
try {
await Promise.all([waitForPort(serverPort), waitForPort(driverPort)]);
evidence.runtime.capabilities = await driver.start();
evidence.geometry.desktop = await driver.setWindow(1440, 900);
await driver.navigate(`http://127.0.0.1:${serverPort}/`);
await waitFor(() => driver.execute('return !!document.querySelector("#login-form")'), '登录页未加载');
await driver.execute(`
const input = document.querySelector('#login-password');
input.value = arguments[0];
document.querySelector('#login-form').requestSubmit();
return true;
`, [PASSWORD]);
await waitFor(() => driver.execute('return !document.querySelector("#app")?.hidden && document.querySelector("#login-overlay")?.hidden'), '浏览器登录失败');
await waitFor(() => driver.execute(`return !!document.querySelector('.session-item[data-id="${SESSION_ID}"]')`), '隔离会话未出现在列表');
await driver.click(`.session-item[data-id="${SESSION_ID}"]`);
await waitFor(() => driver.execute(`return document.querySelector('.session-item[data-id="${SESSION_ID}"]')?.classList.contains('active')`), '隔离会话未打开');
await waitFor(() => driver.execute('return !!document.querySelector("#task-tracking-control .ccweb-task-tracking__input")'), '任务跟踪开关未渲染');
await setTheme(driver, 'washi');
await driver.execute(`
const input = document.querySelector('#msg-input');
input.focus();
input.value = '/';
input.setSelectionRange(1, 1);
input.dispatchEvent(new Event('input', { bubbles: true }));
return true;
`);
await waitFor(() => driver.execute(`
const labels = Array.from(document.querySelectorAll('#cmd-menu .cmd-item-cmd')).map((node) => node.textContent.trim());
return labels[0] === 'mcp:ccweb/ccweb_prompt_user'
&& labels[1] === 'mcp:ccweb/ccweb_task_update'
&& !labels.includes('mcp:ccweb/ccweb_task_status_list');
`), 'tracked 空 / 未显示唯一任务 update 工具');
evidence.geometry.trackedSlash = await driver.execute(`
const menu = document.querySelector('#cmd-menu');
const menuRect = menu.getBoundingClientRect();
const items = Array.from(menu.querySelectorAll('.cmd-item')).slice(0, 5).map((item, index) => {
const rect = item.getBoundingClientRect();
return {
index,
label: item.querySelector('.cmd-item-cmd')?.textContent.trim() || '',
top: rect.top,
bottom: rect.bottom,
height: rect.height,
fullyVisible: rect.top >= menuRect.top && rect.bottom <= menuRect.top + menu.clientHeight,
};
});
return {
viewport: { width: innerWidth, height: innerHeight },
menu: { top: menuRect.top, bottom: menuRect.bottom, height: menuRect.height, clientHeight: menu.clientHeight, scrollHeight: menu.scrollHeight },
items,
};
`);
assert.deepEqual(
evidence.geometry.trackedSlash.items.slice(0, 2).map((item) => item.label),
['mcp:ccweb/ccweb_prompt_user', 'mcp:ccweb/ccweb_task_update'],
);
assert(evidence.geometry.trackedSlash.items.slice(0, 2).every((item) => item.fullyVisible), 'tracked 的动态任务工具未出现在菜单初始可视区域');
evidence.screenshots.trackedSlash = await driver.screenshot(path.join(evidenceRoot, 'tracked-empty-slash-washi-1440x900.png'));
await driver.execute(`
const input = document.querySelector('#msg-input');
input.value = '';
input.dispatchEvent(new Event('input', { bubbles: true }));
return true;
`);
await driver.click('#task-tracking-control .ccweb-task-tracking__input');
await waitFor(() => readJson(sessionPath).taskTracking?.enabled === false, '真实开关未将会话移出任务看板');
await waitFor(() => driver.execute(`return document.querySelector('#task-tracking-control .ccweb-task-tracking')?.dataset.enabled === 'false'`), '跟踪开关 UI 未显示未加入');
await driver.execute(`
const input = document.querySelector('#msg-input');
input.focus();
input.value = '/';
input.setSelectionRange(1, 1);
input.dispatchEvent(new Event('input', { bubbles: true }));
return true;
`);
await waitFor(() => driver.execute(`
const labels = Array.from(document.querySelectorAll('#cmd-menu .cmd-item-cmd')).map((node) => node.textContent.trim());
return labels[0] === 'mcp:ccweb/ccweb_prompt_user'
&& !labels.includes('mcp:ccweb/ccweb_task_status_list')
&& !labels.includes('mcp:ccweb/ccweb_task_update');
`), 'untracked 空 / 仍泄漏任务工具');
evidence.geometry.untrackedSlash = await driver.execute(`
const menu = document.querySelector('#cmd-menu');
const menuRect = menu.getBoundingClientRect();
const labels = Array.from(menu.querySelectorAll('.cmd-item-cmd')).map((node) => node.textContent.trim());
return {
viewport: { width: innerWidth, height: innerHeight },
menu: { top: menuRect.top, bottom: menuRect.bottom, height: menuRect.height },
labels,
trackingText: document.querySelector('#task-tracking-control')?.innerText || '',
};
`);
assert.equal(evidence.geometry.untrackedSlash.labels.includes('mcp:ccweb/ccweb_task_update'), false);
evidence.screenshots.untrackedSlash = await driver.screenshot(path.join(evidenceRoot, 'untracked-empty-slash-washi-1440x900.png'));
await driver.execute(`
const input = document.querySelector('#msg-input');
input.value = '';
input.dispatchEvent(new Event('input', { bubbles: true }));
return true;
`);
await driver.click('#task-tracking-control .ccweb-task-tracking__input');
await waitFor(() => readJson(sessionPath).taskTracking?.enabled === true, '真实开关未重新加入任务看板');
await waitFor(() => driver.execute(`return document.querySelector('#task-tracking-control .ccweb-task-tracking')?.dataset.enabled === 'true'`), '跟踪开关 UI 未显示已加入');
assert.equal(readJson(sessionPath).taskTracking.statusId, 'in_progress', '开关跟踪不得改变任务列');
await driver.execute(`
const input = document.querySelector('#msg-input');
input.value = '/mo';
input.setSelectionRange(3, 3);
input.dispatchEvent(new Event('input', { bubbles: true }));
return true;
`);
await waitFor(() => driver.execute(`return Array.from(document.querySelectorAll('#cmd-menu .cmd-item-cmd')).some((node) => node.textContent.trim() === '/model')`), '保留组前置后 /model 不可搜索');
await driver.execute(`
const input = document.querySelector('#msg-input');
input.value = '';
input.dispatchEvent(new Event('input', { bubbles: true }));
return true;
`);
await driver.click('#task-board-open');
await waitFor(() => driver.execute('return !document.querySelector("#task-board-panel")?.hidden && !!document.querySelector(".ccweb-task-board__column")'), '任务看板未打开');
const initialAudit = await auditBoard(driver);
assert.equal(initialAudit.hasAgentFilter, false);
assert.equal(initialAudit.hasPriorityFilter, false);
assert.equal(initialAudit.hasCreateTask, false);
assert.equal(initialAudit.hasRefreshControl, false);
assert.equal(initialAudit.hasPercentage, false);
assert.equal(initialAudit.hasProgressElement, false);
assert.equal(initialAudit.hasRuntimeState, true);
await driver.click('[data-action="manage-statuses"]');
await waitFor(() => driver.execute('return !document.querySelector(".ccweb-task-board__status-layer")?.hidden'), '列管理未打开');
const submittedShape = await driver.execute(`
const form = Array.from(document.querySelectorAll('form[data-form-action="upsert-definition"]'))
.find((candidate) => !candidate.dataset.definitionId);
if (!form) return null;
const values = {
id: 'waiting-release',
label: '等待发布',
color: '#f97316',
order: '38',
enabled: true,
prompt: arguments[0],
};
for (const [name, value] of Object.entries(values)) {
const control = form.querySelector('[name="' + name + '"]');
if (!control) return { missing: name };
if (name === 'enabled') control.checked = value;
else control.value = value;
control.dispatchEvent(new Event('input', { bubbles: true }));
control.dispatchEvent(new Event('change', { bubbles: true }));
}
form.requestSubmit();
return ['id', 'label', 'color', 'order', 'enabled', 'system', 'prompt'];
`, [INITIAL_PROMPT]);
assert.deepEqual(submittedShape, ['id', 'label', 'color', 'order', 'enabled', 'system', 'prompt']);
const statusConfigPath = path.join(configDir, 'task-board-statuses.json');
const persistedDefinition = await waitFor(() => statusDefinition(statusConfigPath), '真实前端新增列未持久化到隔离服务');
assert.equal(persistedDefinition.prompt, INITIAL_PROMPT);
assert.equal(persistedDefinition.description || '', '');
assert.equal(Object.hasOwn(persistedDefinition, 'baseStatus'), false);
assert.equal(Object.hasOwn(persistedDefinition, 'progress'), false);
await waitFor(() => driver.execute('return !!document.querySelector(".ccweb-task-board__column[data-status-id=\\"waiting-release\\"]")'), '新增列未真实渲染');
await waitFor(() => driver.execute('return !!document.querySelector("form[data-definition-id=\\"waiting-release\\"] textarea[name=\\"prompt\\"]")'), '新增列编辑表单未渲染');
await driver.execute(`
const form = document.querySelector('form[data-definition-id="waiting-release"]');
const prompt = form.querySelector('textarea[name="prompt"]');
prompt.value = arguments[0];
prompt.dispatchEvent(new Event('input', { bubbles: true }));
form.requestSubmit();
return true;
`, [EDITED_PROMPT]);
await waitFor(() => statusDefinition(statusConfigPath)?.prompt === EDITED_PROMPT, '分类提示词编辑未持久化');
await driver.execute(`
document.querySelector('form[data-definition-id="waiting-release"]')
?.scrollIntoView({ block: 'center', inline: 'nearest' });
return true;
`);
await sleep(160);
evidence.geometry.waitingReleaseForm = await driver.execute(`
const form = document.querySelector('form[data-definition-id="waiting-release"]');
const rect = form.getBoundingClientRect();
return {
top: rect.top,
bottom: rect.bottom,
left: rect.left,
right: rect.right,
id: form.querySelector('[name="id"]')?.value || '',
label: form.querySelector('[name="label"]')?.value || '',
prompt: form.querySelector('[name="prompt"]')?.value || '',
};
`);
assert.equal(evidence.geometry.waitingReleaseForm.id, STATUS_ID);
assert.equal(evidence.geometry.waitingReleaseForm.prompt, EDITED_PROMPT);
evidence.screenshots.statusManager = await driver.screenshot(path.join(evidenceRoot, 'status-manager-washi-1440x900.png'));
await driver.click('[data-action="close-statuses"]');
await waitFor(() => driver.execute('return document.querySelector(".ccweb-task-board__status-layer")?.hidden'), '列管理未关闭');
await driver.execute(`
const select = document.querySelector('[data-action="set-status"][data-session-id="${SESSION_ID}"]');
if (!select) return false;
select.value = 'waiting-release';
select.dispatchEvent(new Event('change', { bubbles: true }));
return true;
`);
await waitFor(() => readJson(sessionPath).taskTracking?.statusId === STATUS_ID, '自定义列状态未由真实 UI 写入会话');
await waitFor(() => driver.execute(`return document.querySelector('[data-session-id="${SESSION_ID}"]')?.dataset.statusId === 'waiting-release'`), '任务卡片未移动到新增列');
await driver.execute(`
document.querySelector('.ccweb-task-board__column[data-status-id="waiting-release"]')
?.scrollIntoView({ block: 'nearest', inline: 'center' });
return true;
`);
await sleep(160);
evidence.audits.washiDesktop = await auditBoard(driver);
assert.equal(evidence.audits.washiDesktop.hasWaitingReleaseLane, true);
evidence.screenshots.washiDesktop = await driver.screenshot(path.join(evidenceRoot, 'board-washi-1440x900.png'));
await setTheme(driver, 'carbon');
evidence.audits.carbonDesktop = await auditBoard(driver);
assert.notEqual(evidence.audits.washiDesktop.shellBackground, evidence.audits.carbonDesktop.shellBackground, '看板背景未继承 washi/carbon 主题变量');
evidence.screenshots.carbonDesktop = await driver.screenshot(path.join(evidenceRoot, 'board-carbon-1440x900.png'));
await driver.click(`[data-action="set-archive"][data-session-id="${SESSION_ID}"]`);
await waitFor(() => !!readJson(sessionPath).taskTracking?.archivedAt, '归档未写入隔离会话');
await driver.click('[data-action="toggle-archive"]');
await waitFor(() => driver.execute(`return !!document.querySelector('[data-action="set-archive"][data-session-id="${SESSION_ID}"][data-archived="false"]')`), '归档视图未显示任务');
await driver.click(`[data-action="set-archive"][data-session-id="${SESSION_ID}"]`);
await waitFor(() => readJson(sessionPath).taskTracking?.archivedAt === null, '恢复未清除隔离会话归档状态');
await driver.click('[data-action="toggle-archive"]');
evidence.geometry.narrow = await driver.setWindow(500, 900);
assert.equal(evidence.geometry.narrow.width, 500, 'Firefox 未提供要求的 500px 视口');
await setTheme(driver, 'washi');
evidence.audits.washiNarrow = await auditBoard(driver);
assert(evidence.audits.washiNarrow.laneScrollWidth > evidence.audits.washiNarrow.laneClientWidth, '500px 看板 lanes 未形成横向滚动区域');
evidence.screenshots.washiNarrow = await driver.screenshot(path.join(evidenceRoot, 'board-washi-500x900.png'));
await setTheme(driver, 'carbon');
evidence.audits.carbonNarrow = await auditBoard(driver);
evidence.screenshots.carbonNarrow = await driver.screenshot(path.join(evidenceRoot, 'board-carbon-500x900.png'));
evidence.geometry.requested390 = await driver.setWindow(390, 900);
evidence.geometry.covered390 = evidence.geometry.requested390.width === 390;
if (evidence.geometry.covered390) {
evidence.screenshots.washi390 = await driver.screenshot(path.join(evidenceRoot, 'board-carbon-390x900.png'));
}
evidence.persistedDefinition = statusDefinition(statusConfigPath);
evidence.persistedSessionStatus = readJson(sessionPath).taskTracking?.statusId;
fs.writeFileSync(path.join(evidenceRoot, 'evidence.json'), JSON.stringify(evidence, null, 2));
console.log(JSON.stringify({ ok: true, evidenceRoot, ...evidence }, null, 2));
} catch (error) {
error.message += `\nserver stderr:\n${serverStderr.slice(-4_000)}\ngeckodriver stderr:\n${driverStderr.slice(-4_000)}`;
throw error;
} finally {
try { await driver.close(); } catch {}
await stopChild(geckodriver);
await stopChild(server);
fs.rmSync(runtimeRoot, { recursive: true, force: true });
}
}
main().catch((error) => {
console.error(error);
process.exitCode = 1;
});

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -0,0 +1,158 @@
'use strict';
const assert = require('node:assert');
const fs = require('node:fs');
const path = require('node:path');
const { createTaskBoardService } = require('../lib/task-board-service');
const {
LIFECYCLE_EVENT_TYPES,
createTaskBoardLifecycle,
normalizeLifecycleEvent,
} = require('../lib/task-board-lifecycle');
function clone(value) {
return JSON.parse(JSON.stringify(value));
}
function createHarness() {
const session = {
id: 'dynamic-lifecycle',
title: '动态列生命周期',
agent: 'codexapp',
cwd: '/home/cc-web',
created: '2026-08-12T00:00:00.000Z',
updated: '2026-08-12T00:00:00.000Z',
messages: [],
};
const sessions = new Map([[session.id, clone(session)]]);
let statusConfig = null;
let tick = 0;
const service = createTaskBoardService({
loadSession(sessionId) {
return sessions.has(sessionId) ? clone(sessions.get(sessionId)) : null;
},
saveSession(next) {
sessions.set(next.id, clone(next));
return true;
},
listSessions() {
return [...sessions.values()].map(clone);
},
loadStatusDefinitions() {
return statusConfig;
},
saveStatusDefinitions(next) {
statusConfig = clone(next);
return true;
},
now() {
return new Date(Date.UTC(2026, 7, 12, 0, 0, tick++));
},
});
return { service, sessions };
}
async function main() {
assert.deepEqual(Object.values(LIFECYCLE_EVENT_TYPES), [
'user_message_received',
'turn_started',
'user_input_requested',
'turn_completed',
'turn_failed',
]);
assert.equal(normalizeLifecycleEvent({ type: 'turn_start' }).type, 'turn_started');
assert.equal(normalizeLifecycleEvent({ type: 'turn_completed', outcome: 'failed' }).type, 'turn_failed');
assert.equal(normalizeLifecycleEvent({ type: 'user_input_requested', structured: false }).structured, false);
const lifecycleSource = fs.readFileSync(path.join(__dirname, '..', 'lib', 'task-board-lifecycle.js'), 'utf8');
assert.equal(/reportingStatus|本轮未上报|mark_reporting_missing/.test(lifecycleSource), false);
assert.equal(/['"](?:in_progress|waiting_user|blocked|completed)['"]/.test(lifecycleSource), false);
const { service, sessions } = createHarness();
service.upsertStatusDefinition({
id: 'review_ready',
label: '等待评审',
prompt: '当实现与验证已完成,下一步明确需要评审时选择此列。',
color: '#38bdf8',
order: 36,
enabled: true,
expectedVersion: 0,
}, { source: 'user' });
const enabled = service.setTracking('dynamic-lifecycle', true, {
source: 'user',
expectedVersion: 0,
});
const selected = service.updateStatus('dynamic-lifecycle', {
statusId: 'review_ready',
reason: '等待真实评审',
version: enabled.taskTracking.version,
}, { source: 'mcp' });
const lifecycle = createTaskBoardLifecycle(service, {
isTrackingEnabled(sessionId) {
return service.getTask(sessionId).taskTracking.enabled;
},
});
assert.equal(typeof lifecycle.noteTaskStatusUpdate, 'undefined', '生命周期不再维护本轮显式上报审计状态');
let version = selected.taskTracking.version;
const events = [
{ type: 'user_message_received', eventId: 'message-1' },
{ type: 'turn_started', turnId: 'turn-1' },
{ type: 'user_input_requested', turnId: 'turn-1', eventId: 'input-1', structured: true },
{ type: 'turn_failed', turnId: 'turn-1', error: '运行异常' },
{ type: 'turn_completed', turnId: 'turn-2', outcome: 'completed' },
];
for (const event of events) {
const result = await lifecycle.handleEvent('dynamic-lifecycle', event);
const task = service.getTask('dynamic-lifecycle');
assert.equal(task.taskTracking.statusId, 'review_ready', `${event.type} 不得推断或迁移任务列`);
assert.equal(Object.hasOwn(task.taskTracking, 'reportingStatus'), false);
assert.equal(task.taskTracking.version, version, `${event.type} 不得制造无意义状态写入`);
assert.equal(result.ok, true);
}
const duplicate = await lifecycle.handleEvent('dynamic-lifecycle', {
type: 'turn_completed', turnId: 'turn-2', outcome: 'completed',
});
assert.equal(duplicate.ignored, true);
const archived = service.setArchived('dynamic-lifecycle', true, {
source: 'user',
expectedVersion: version,
});
version = archived.taskTracking.version;
assert(archived.taskTracking.archivedAt);
const resumed = await lifecycle.handleEvent('dynamic-lifecycle', {
type: 'user_message_received', eventId: 'message-resume',
});
assert.equal(resumed.changed, true);
const resumedTask = service.getTask('dynamic-lifecycle');
assert.equal(resumedTask.taskTracking.statusId, 'review_ready');
assert.equal(resumedTask.taskTracking.archivedAt, null);
assert.equal(resumedTask.taskTracking.version, version + 1);
assert.equal(Object.hasOwn(sessions.get('dynamic-lifecycle').taskTracking, 'reportingStatus'), false);
const disabled = service.setTracking('dynamic-lifecycle', false, {
source: 'user',
expectedVersion: resumedTask.taskTracking.version,
});
const gated = await lifecycle.handleEvent('dynamic-lifecycle', {
type: 'turn_started', turnId: 'turn-disabled',
});
assert.equal(gated.ignored, true);
assert.equal(gated.reason, 'task_tracking_disabled');
assert.equal(service.getTask('dynamic-lifecycle').taskTracking.statusId, 'review_ready');
assert.equal(service.getTask('dynamic-lifecycle').taskTracking.version, disabled.taskTracking.version);
const unstructured = await lifecycle.handleEvent('dynamic-lifecycle', {
type: 'user_input_requested', structured: false,
});
assert.equal(unstructured.ignored, true);
console.log('Task board lifecycle unit checks passed.');
}
main().catch((error) => {
console.error(error.stack || error.message || error);
process.exitCode = 1;
});

View File

@@ -0,0 +1,465 @@
'use strict';
const assert = require('assert');
const {
MAX_VERSION,
STATUS_ID_PATTERN,
SYSTEM_STATUS_IDS,
TaskBoardError,
createTaskBoardService,
} = require('../lib/task-board-service');
const {
TASK_BOARD_MCP_TOOL_DEFINITIONS,
createTaskBoardMcpToolDefinitions,
createTaskBoardMcpHandler,
createTaskBoardMcpHandlers,
} = require('../lib/task-board-mcp');
function clone(value) {
return JSON.parse(JSON.stringify(value));
}
function sessionFixture(id, overrides = {}) {
return {
id,
title: `任务 ${id}`,
agent: 'codexapp',
cwd: '/home/cc-web',
created: '2026-08-11T00:00:00.000Z',
updated: '2026-08-11T00:00:00.000Z',
messages: [],
...overrides,
};
}
function createHarness() {
const sessions = new Map([
['ordinary-a', sessionFixture('ordinary-a')],
['migration-b', sessionFixture('migration-b')],
['priority-c', sessionFixture('priority-c')],
['lifecycle-d', sessionFixture('lifecycle-d')],
['mcp-e', sessionFixture('mcp-e')],
]);
let statusConfig = null;
let clockTick = 0;
const service = createTaskBoardService({
loadSession(sessionId) {
const session = sessions.get(sessionId);
return session ? clone(session) : null;
},
saveSession(session) {
sessions.set(session.id, clone(session));
return true;
},
listSessions() {
return Array.from(sessions.values(), clone);
},
loadStatusDefinitions() {
return statusConfig;
},
saveStatusDefinitions(nextConfig) {
statusConfig = clone(nextConfig);
return true;
},
now() {
const value = new Date(Date.UTC(2026, 7, 11, 1, 0, clockTick));
clockTick += 1;
return value;
},
});
return {
service,
sessions,
getStatusConfig: () => clone(statusConfig),
};
}
function expectTaskError(fn, code) {
assert.throws(fn, (error) => (
error instanceof TaskBoardError && error.code === code
), `应抛出 ${code}`);
}
async function main() {
const harness = createHarness();
const { service } = harness;
// 普通会话默认关闭,读取不会把默认结构写回会话。
const ordinaryDefault = service.getTask('ordinary-a');
assert.strictEqual(ordinaryDefault.taskTracking.enabled, false);
assert.strictEqual(ordinaryDefault.taskTracking.statusId, 'unassigned');
assert.strictEqual('baseStatus' in ordinaryDefault.taskTracking, false);
assert.strictEqual('progress' in ordinaryDefault.taskTracking, false);
assert.strictEqual(ordinaryDefault.taskTracking.version, 0);
assert.strictEqual(harness.sessions.get('ordinary-a').taskTracking, undefined);
assert.deepStrictEqual(service.listTasks(), []);
assert.deepStrictEqual(service.listTasks({ includeDisabled: true, archived: 'all' }), []);
// 历史百分比字段只在读取时忽略,不返回,也不触发会话改写。
const legacyProgressSession = sessionFixture('legacy-progress-g', {
taskTracking: {
enabled: true,
statusId: 'in_progress',
source: 'mcp',
reason: '历史任务',
summary: '遗留百分比字段',
progress: 64,
reportingStatus: 'reported',
version: 3,
enabledAt: '2026-08-11T00:00:00.000Z',
disabledAt: null,
statusUpdatedAt: '2026-08-11T00:01:00.000Z',
completedAt: null,
archivedAt: null,
archivedBy: null,
},
});
harness.sessions.set(legacyProgressSession.id, clone(legacyProgressSession));
const legacyBeforeRead = clone(harness.sessions.get(legacyProgressSession.id));
const legacyTask = service.getTask(legacyProgressSession.id);
assert.strictEqual(legacyTask.taskTracking.statusId, 'in_progress');
assert.strictEqual('progress' in legacyTask.taskTracking, false);
assert.strictEqual('progress' in service.listTasks({ sessionId: legacyProgressSession.id })[0].taskTracking, false);
assert.deepStrictEqual(harness.sessions.get(legacyProgressSession.id), legacyBeforeRead);
harness.sessions.delete(legacyProgressSession.id);
expectTaskError(() => service.updateStatus('ordinary-a', {
statusId: 'in_progress',
}, { source: 'mcp' }), 'task_tracking_disabled');
expectTaskError(() => service.setTracking('ordinary-a', true, { source: 'mcp' }), 'task_tracking_forbidden');
const enabledA = service.setTracking('ordinary-a', true, {
source: 'user', id: 'user-a', expectedVersion: 0,
});
assert.strictEqual(enabledA.taskTracking.enabled, true);
assert.strictEqual(enabledA.taskTracking.source, 'default');
assert.strictEqual('progress' in enabledA.taskTracking, false);
assert.strictEqual(enabledA.taskTracking.version, 1);
assert.strictEqual(enabledA.agent, 'codexapp', 'Agent 仍应作为只读会话元数据返回');
assert.strictEqual(service.listTasks().length, 1);
assert.strictEqual(service.listTasks({ query: '', archived: false }).length, 1);
expectTaskError(() => service.listTasks({ agent: 'codexapp' }), 'task_status_invalid');
expectTaskError(() => service.listTasks({ baseStatus: 'in_progress' }), 'task_status_invalid');
expectTaskError(() => service.listTasks({ priority: 'high' }), 'task_status_invalid');
harness.sessions.set('preenabled-f', sessionFixture('preenabled-f', { taskTracking: { enabled: true } }));
const initializedF = service.setTracking('preenabled-f', true, { source: 'user', expectedVersion: 0 });
assert.strictEqual(initializedF.taskTracking.enabled, true);
assert.strictEqual(initializedF.taskTracking.version, 1);
assert(initializedF.taskTracking.enabledAt);
assert(initializedF.taskTracking.statusUpdatedAt);
// 每个定义都是独立列;系统列 ID 稳定,所有列都有可编辑分类提示词。
const defaults = service.getStatusDefinitions();
assert.deepStrictEqual(defaults.filter((item) => item.system).map((item) => item.id).sort(), [...SYSTEM_STATUS_IDS].sort());
assert(defaults.every((item) => typeof item.prompt === 'string' && item.prompt.length > 0));
assert(defaults.every((item) => !Object.prototype.hasOwnProperty.call(item, 'baseStatus')));
assert(STATUS_ID_PATTERN.test('waiting-release'));
assert(STATUS_ID_PATTERN.test('Waiting-Release_2'));
assert(STATUS_ID_PATTERN.test(`A${'b'.repeat(79)}`));
assert.strictEqual(STATUS_ID_PATTERN.test(`A${'b'.repeat(80)}`), false);
const updatedSystem = service.upsertStatusDefinition({
id: 'waiting_user',
label: '等待用户',
color: '#F59E0B',
order: 35,
prompt: '当后续推进必须等待用户补充、确认、授权或验收时选择此列。',
version: 0,
}, { source: 'user', id: 'admin' });
assert.strictEqual(updatedSystem.version, 1);
assert.strictEqual(updatedSystem.definition.prompt, '当后续推进必须等待用户补充、确认、授权或验收时选择此列。');
assert.strictEqual('baseStatus' in updatedSystem.definition, false);
assert.strictEqual(updatedSystem.definition.color, '#f59e0b');
expectTaskError(() => service.upsertStatusDefinition({
id: 'waiting_user', baseStatus: 'blocked', version: 1,
}, { source: 'user' }), 'task_status_invalid');
expectTaskError(() => service.removeStatusDefinition('completed', null, { source: 'user' }), 'task_status_invalid');
// 与 Firefox 真实提交完全一致definition 不包含 description。
const frontendDefinition = {
id: 'waiting-release',
label: '等待发布',
color: '#f97316',
order: 38,
enabled: true,
system: false,
prompt: '当实现和验证已完成,但仍需等待部署环境或发布窗口时选择此列。',
};
assert.strictEqual(Object.hasOwn(frontendDefinition, 'description'), false);
const customResult = service.upsertStatusDefinition({
...frontendDefinition,
expectedVersion: 1,
}, { source: 'user', id: 'admin' });
assert.strictEqual(customResult.version, 2);
assert.strictEqual(customResult.definition.system, false);
assert.strictEqual(customResult.definition.id, 'waiting-release');
assert.strictEqual(customResult.definition.prompt, '当实现和验证已完成,但仍需等待部署环境或发布窗口时选择此列。');
assert.strictEqual('baseStatus' in customResult.definition, false);
const queriedCustomDefinition = service.getStatusDefinitions()
.find((item) => item.id === 'waiting-release');
assert(queriedCustomDefinition);
assert.strictEqual(queriedCustomDefinition.prompt, frontendDefinition.prompt);
assert.strictEqual('baseStatus' in queriedCustomDefinition, false);
assert.strictEqual(harness.getStatusConfig().version, 2);
assert.strictEqual(
harness.getStatusConfig().definitions.find((item) => item.id === 'waiting-release').prompt,
'当实现和验证已完成,但仍需等待部署环境或发布窗口时选择此列。',
);
assert(harness.getStatusConfig().definitions.every((item) => !('baseStatus' in item)));
expectTaskError(() => service.upsertStatusDefinition({
id: 'waiting-release', prompt: '', expectedVersion: 2,
}, { source: 'user' }), 'task_status_invalid');
expectTaskError(() => service.upsertStatusDefinition({
id: 'waiting-release', baseStatus: 'blocked', expectedVersion: 2,
}, { source: 'user' }), 'task_status_invalid');
const disabledCustom = service.upsertStatusDefinition({
id: 'paused_external',
label: '外部暂停',
description: '暂时停用的状态。',
prompt: '当任务明确因外部条件暂停时选择此列。',
color: '#a855f7',
order: 39,
enabled: false,
expectedVersion: 2,
}, { source: 'user', id: 'admin' });
assert.strictEqual(disabledCustom.version, 3);
expectTaskError(() => service.updateStatus('ordinary-a', {
statusId: 'paused_external', version: 1,
}, { source: 'mcp' }), 'task_status_invalid');
const customTaskA = service.updateStatus('ordinary-a', {
statusId: 'waiting-release',
reason: '代码已完成',
summary: '等待部署环境',
version: 1,
}, { source: 'mcp', id: 'agent-a' });
assert.strictEqual(customTaskA.taskTracking.statusId, 'waiting-release');
assert.strictEqual('baseStatus' in customTaskA.taskTracking, false);
assert.strictEqual('progress' in customTaskA.taskTracking, false);
assert.strictEqual('baseStatus' in customTaskA.status, false);
assert.strictEqual(customTaskA.taskTracking.source, 'mcp');
assert.strictEqual(customTaskA.taskTracking.version, 2);
const queriedCustomTasks = service.listTasks({ statusId: 'waiting-release' });
assert.deepStrictEqual(queriedCustomTasks.map((task) => task.sessionId), ['ordinary-a']);
assert.strictEqual('progress' in queriedCustomTasks[0].taskTracking, false);
// 停用跟踪仍保留状态;迁移会覆盖启用和停用会话中的引用。
const enabledB = service.setTracking('migration-b', true, { source: 'user', expectedVersion: 0 });
const customTaskB = service.updateStatus('migration-b', {
statusId: 'waiting-release', version: enabledB.taskTracking.version,
}, { source: 'mcp' });
const disabledB = service.setTracking('migration-b', false, {
source: 'user', expectedVersion: customTaskB.taskTracking.version,
});
assert.strictEqual(disabledB.taskTracking.enabled, false);
assert.strictEqual(disabledB.taskTracking.statusId, 'waiting-release');
assert.strictEqual(service.listTasks().some((task) => task.sessionId === 'migration-b'), false);
assert(service.listTasks({ includeDisabled: true, archived: 'all' })
.some((task) => task.sessionId === 'migration-b'));
expectTaskError(() => service.removeStatusDefinition(
'waiting-release', null, { source: 'user', expectedVersion: 3 },
), 'task_status_in_use');
expectTaskError(() => service.removeStatusDefinition(
'waiting-release', 'paused_external', { source: 'user', expectedVersion: 3 },
), 'task_status_invalid');
const migration = service.removeStatusDefinition(
'waiting-release', 'waiting_user', { source: 'user', expectedVersion: 3 },
);
assert.strictEqual(migration.version, 4);
assert.deepStrictEqual(migration.migratedSessionIds.sort(), ['migration-b', 'ordinary-a']);
assert.strictEqual(service.getTask('ordinary-a').taskTracking.statusId, 'waiting_user');
assert.strictEqual(service.getTask('migration-b').taskTracking.statusId, 'waiting_user');
assert.strictEqual(service.getTask('migration-b').taskTracking.enabled, false);
assert.strictEqual(service.getStatusDefinitions().some((item) => item.id === 'waiting-release'), false);
const reviewColumn = service.upsertStatusDefinition({
id: 'review_ready',
label: '等待评审',
description: '实现完成,等待代码或产品评审。',
prompt: '当实现与自测已完成,下一步明确是代码评审或产品评审时选择此列。',
color: '#38bdf8',
order: 36,
enabled: true,
version: 4,
}, { source: 'user', id: 'admin' });
assert.strictEqual(reviewColumn.version, 5);
assert.strictEqual('baseStatus' in reviewColumn.definition, false);
// 用户与来源 MCP 均按最后一次有效写入生效,版本检查独立处理并发冲突。
const enabledC = service.setTracking('priority-c', true, { source: 'user', expectedVersion: 0 });
const userStatus = service.updateStatus('priority-c', {
statusId: 'blocked',
reason: '用户手动标记',
version: enabledC.taskTracking.version,
}, { source: 'user', id: 'owner' });
const mcpStatus = service.updateStatus('priority-c', {
statusId: 'review_ready', version: userStatus.taskTracking.version,
}, { source: 'mcp', id: 'agent-c' });
assert.strictEqual(mcpStatus.changed, true);
assert.strictEqual(Object.hasOwn(mcpStatus, 'ignored'), false);
assert.strictEqual(Object.hasOwn(mcpStatus, 'ignoredReason'), false);
assert.strictEqual(mcpStatus.taskTracking.statusId, 'review_ready');
assert.strictEqual(mcpStatus.taskTracking.version, userStatus.taskTracking.version + 1);
assert.strictEqual(mcpStatus.taskTracking.source, 'mcp');
const ignoredHook = service.recordLifecycleEvent('priority-c', {
type: 'turn_started',
occurredAt: '2026-08-11T02:00:00.000Z',
version: mcpStatus.taskTracking.version,
});
assert.strictEqual(ignoredHook.ignored, true);
assert.strictEqual(ignoredHook.reason, 'no_change');
assert.strictEqual(ignoredHook.task.taskTracking.statusId, 'review_ready');
expectTaskError(() => service.updateStatus('priority-c', {
statusId: 'completed', version: 1,
}, { source: 'user' }), 'task_version_conflict');
// 严格校验状态 ID、颜色、退役字段、文本长度与版本。
expectTaskError(() => service.updateStatus('priority-c', {
statusId: 'Bad.Status', version: mcpStatus.taskTracking.version,
}, { source: 'user' }), 'task_status_invalid');
expectTaskError(() => service.updateStatus('priority-c', {
statusId: 'blocked', progress: 42, version: mcpStatus.taskTracking.version,
}, { source: 'user' }), 'task_status_invalid');
expectTaskError(() => service.updateStatus('priority-c', {
statusId: 'blocked', reason: 'x'.repeat(2001), version: mcpStatus.taskTracking.version,
}, { source: 'user' }), 'task_status_invalid');
expectTaskError(() => service.updateStatus('priority-c', {
statusId: 'blocked', version: MAX_VERSION + 1,
}, { source: 'user' }), 'task_status_invalid');
expectTaskError(() => service.upsertStatusDefinition({
id: 'bad_color',
label: '坏颜色',
description: '',
prompt: '当颜色校验测试命中时选择此列。',
color: 'red',
order: 1,
enabled: true,
version: 5,
}, { source: 'user' }), 'task_status_invalid');
// 归档是软标记Agent 无权归档;新用户消息只恢复归档,不推断任务列。
const enabledD = service.setTracking('lifecycle-d', true, { source: 'user', expectedVersion: 0 });
const completedD = service.updateStatus('lifecycle-d', {
statusId: 'completed',
summary: '已完成',
version: enabledD.taskTracking.version,
}, { source: 'mcp' });
expectTaskError(() => service.setArchived('lifecycle-d', true, {
source: 'mcp', expectedVersion: completedD.taskTracking.version,
}), 'task_archive_forbidden');
const archivedD = service.setArchived('lifecycle-d', true, {
source: 'user', id: 'owner', expectedVersion: completedD.taskTracking.version,
});
assert.strictEqual(archivedD.archived, true);
assert.strictEqual(archivedD.taskTracking.statusId, 'completed');
assert.strictEqual(service.listTasks().some((task) => task.sessionId === 'lifecycle-d'), false);
assert.strictEqual(service.listTasks({ archived: true }).length, 1);
const archivedTurn = service.recordLifecycleEvent('lifecycle-d', {
type: 'turn_started', version: archivedD.taskTracking.version,
});
assert.strictEqual(archivedTurn.reason, 'task_archived');
const reopenedD = service.recordLifecycleEvent('lifecycle-d', {
type: 'user_message_received',
occurredAt: '2026-08-11T03:00:00.000Z',
version: archivedD.taskTracking.version,
});
assert.strictEqual(reopenedD.changed, true);
assert.strictEqual(reopenedD.task.archived, false);
assert.strictEqual(reopenedD.task.taskTracking.statusId, 'completed');
assert.strictEqual('baseStatus' in reopenedD.task.taskTracking, false);
assert.strictEqual(Object.hasOwn(reopenedD.task.taskTracking, 'reportingStatus'), false);
const completedTurn = service.recordLifecycleEvent('lifecycle-d', {
type: 'turn_completed',
occurredAt: '2026-08-11T03:30:00.000Z',
version: reopenedD.task.taskTracking.version,
});
assert.strictEqual(completedTurn.changed, false);
assert.strictEqual(completedTurn.reason, 'no_change');
assert.strictEqual(completedTurn.task.taskTracking.statusId, 'completed');
assert.strictEqual(completedTurn.task.taskTracking.version, reopenedD.task.taskTracking.version);
assert.strictEqual(Object.hasOwn(completedTurn.task.taskTracking, 'reportingStatus'), false);
// MCP 工具按来源会话动态生成;静态工具表不再泄漏任何任务工具。
assert.deepStrictEqual(TASK_BOARD_MCP_TOOL_DEFINITIONS, []);
const mcpHandlers = createTaskBoardMcpHandlers(service);
assert.deepStrictEqual(
createTaskBoardMcpToolDefinitions(service, { sourceSessionId: 'mcp-e' }),
[],
);
const disabledUpdate = await mcpHandlers.ccweb_task_update({
statusId: 'in_progress',
}, { sourceSessionId: 'mcp-e' });
assert.strictEqual(disabledUpdate.ok, false);
assert.strictEqual(disabledUpdate.code, 'task_tracking_disabled');
const enabledE = service.setTracking('mcp-e', true, { source: 'user', expectedVersion: 0 });
const dynamicTools = createTaskBoardMcpToolDefinitions(service, { sourceSessionId: 'mcp-e' });
assert.deepStrictEqual(dynamicTools.map((tool) => tool.name), ['ccweb_task_update']);
const updateToolDefinition = dynamicTools[0];
const enabledDefinitions = service.getStatusDefinitions().filter((definition) => definition.enabled);
assert.deepStrictEqual(
updateToolDefinition.inputSchema.properties.statusId.enum,
enabledDefinitions.map((definition) => definition.id),
);
assert.deepStrictEqual(
Object.keys(updateToolDefinition.inputSchema.properties),
['statusId', 'reason', 'summary'],
);
assert.match(updateToolDefinition.description, /当前状态/);
assert.match(updateToolDefinition.description, /仅当当前对话任务状态真实发生变化时调用/);
assert.match(updateToolDefinition.description, /单轮开始、结束、运行停止不代表任务状态变化/);
for (const definition of enabledDefinitions) {
assert(updateToolDefinition.description.includes(definition.label));
assert(updateToolDefinition.description.includes(definition.id));
assert(updateToolDefinition.description.includes(definition.prompt));
}
assert.strictEqual(updateToolDefinition.description.includes('ccweb_task_status_list'), false);
const retiredMcpProgress = await mcpHandlers.ccweb_task_update({
statusId: 'review_ready',
progress: 10,
}, { sourceSessionId: 'mcp-e', actorId: 'agent-e' });
assert.strictEqual(retiredMcpProgress.ok, false);
assert.strictEqual(retiredMcpProgress.code, 'task_status_invalid');
assert.strictEqual(service.getTask('mcp-e').taskTracking.version, enabledE.taskTracking.version);
const mcpSuccess = await mcpHandlers.ccweb_task_update({
statusId: 'review_ready',
summary: 'MCP 根据列提示词判断为等待评审',
}, { sourceSessionId: 'mcp-e', actorId: 'agent-e' });
assert.strictEqual(mcpSuccess.ok, true);
assert.strictEqual(mcpSuccess.sessionId, 'mcp-e');
assert.strictEqual(mcpSuccess.currentStatus.statusId, 'review_ready');
assert.strictEqual(mcpSuccess.currentStatus.source, 'mcp');
assert.strictEqual(Object.hasOwn(mcpSuccess.currentStatus, 'progress'), false);
assert.strictEqual(Object.hasOwn(mcpSuccess.task.taskTracking, 'progress'), false);
assert.strictEqual(mcpSuccess.currentStatusDefinition.prompt, reviewColumn.definition.prompt);
assert.strictEqual(JSON.stringify(mcpSuccess).includes('baseStatus'), false);
const refreshedTools = createTaskBoardMcpToolDefinitions(service, { sourceSessionId: 'mcp-e' });
assert.match(refreshedTools[0].description, /等待评审[^\n]*review_ready|review_ready[^\n]*等待评审/);
const unknownStatus = await mcpHandlers.ccweb_task_update({
statusId: 'does_not_exist',
}, { sourceSessionId: 'mcp-e' });
assert.strictEqual(unknownStatus.code, 'task_status_unknown');
const missingContext = await mcpHandlers.ccweb_task_update({ statusId: 'in_progress' }, {});
assert.strictEqual(missingContext.code, 'task_session_not_found');
const spoofedContext = await mcpHandlers.ccweb_task_update({
statusId: 'in_progress', sourceSessionId: 'priority-c',
}, { sourceSessionId: 'mcp-e' });
assert.strictEqual(spoofedContext.code, 'task_status_invalid');
const dispatchMcp = createTaskBoardMcpHandler({ taskBoardService: service });
const retiredList = await dispatchMcp('ccweb_task_status_list', {}, { sessionId: 'mcp-e' });
assert.strictEqual(retiredList.code, 'unknown_tool');
const unknownTool = await dispatchMcp('ccweb_task_archive', {}, { sourceSessionId: 'mcp-e' });
assert.strictEqual(unknownTool.code, 'unknown_tool');
console.log('Task board service and MCP unit checks passed.');
}
main().catch((error) => {
console.error(error.stack || error.message);
process.exit(1);
});