feat: 优化任务状态与图片 MCP 提示

This commit is contained in:
shiyue
2026-08-13 00:34:37 +08:00
parent 86231f0d97
commit 214ff4ec88
13 changed files with 1353 additions and 49 deletions

View File

@@ -662,6 +662,7 @@ function assertTaskBoardIntegrationContract() {
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 taskBoardClassifierSource = fs.readFileSync(path.join(REPO_DIR, 'lib', 'task-board-classifier.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;
@@ -682,6 +683,11 @@ function assertTaskBoardIntegrationContract() {
const internalMcpSource = extractFunctionSource(serverSource, 'callInternalMcpTool');
const composerSource = extractFunctionSource(serverSource, 'listComposerSuggestions');
const composerMcpSource = extractFunctionSource(serverSource, 'listComposerMcpItems');
const startCodexAppTurnSource = extractFunctionSource(serverSource, 'startCodexAppTurn');
const handleMessageSource = extractFunctionSource(serverSource, 'handleMessage');
const handleTurnCompleteSource = extractFunctionSource(serverSource, 'handleCodexAppTurnComplete');
const handleSteerSource = extractFunctionSource(serverSource, 'handleCodexAppSteerMessage');
const saveSessionSource = extractFunctionSource(serverSource, 'saveSession');
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');
@@ -738,10 +744,32 @@ function assertTaskBoardIntegrationContract() {
'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'
!serverSource.includes('ensureTaskBoardMcpToolsFresh')
&& !serverSource.includes('taskBoardMcpFingerprintBySession')
&& !startCodexAppTurnSource.includes('reloadMcpServers')
&& !startCodexAppTurnSource.includes('config/mcpServer/reload'),
'Automatic task classification must not reload MCP before Codex App turns'
);
assert(
taskBoardClassifierSource.includes("tools: []")
&& taskBoardClassifierSource.includes("tool_choice: 'none'")
&& taskBoardClassifierSource.includes("type: 'json_schema'")
&& taskBoardClassifierSource.includes('唯一职责是分类')
&& taskBoardClassifierSource.includes('不要执行、继续、检查或验证任务'),
'Task classifier should use a focused no-tool strict JSON request'
);
assert(
handleMessageSource.includes('TASK_STATUS_CLASSIFICATION_EVENTS.USER_MESSAGE_RECEIVED')
&& handleTurnCompleteSource.includes('TASK_STATUS_CLASSIFICATION_EVENTS.TURN_COMPLETED')
&& handleTurnCompleteSource.includes('!completionError')
&& handleTurnCompleteSource.includes('!options.interrupted')
&& handleTurnCompleteSource.includes('!entry.userAborted')
&& handleSteerSource.includes('TASK_STATUS_CLASSIFICATION_EVENTS.USER_MESSAGE_RECEIVED'),
'Codex App classification should run only at accepted user messages and successful turn completion'
);
assert(
saveSessionSource.includes('preserveNewerTaskTrackingForSessionSave(session, targetPath)'),
'Ordinary session persistence must preserve a newer task tracking version written by the classifier'
);
}
@@ -5597,6 +5625,8 @@ function assertCcwebDisplayImageContract() {
const { TOOLS, prepareImagePayload } = require(path.join(REPO_DIR, 'lib', 'ccweb-mcp-server'));
const imageHandler = server.slice(server.indexOf('function createCcwebDisplayImage'), server.indexOf('function findCcwebPromptMessage'));
assert(TOOLS.some((tool) => tool.name === 'ccweb_display_image'), 'ccweb MCP should expose the image display tool');
const displayImageTool = TOOLS.find((tool) => tool.name === 'ccweb_display_image');
assert(displayImageTool.description === '当你需要输出、显示图片时,请调用该 MCP。', 'Image tool description should state only its responsibility');
assert(server.includes("case 'ccweb_display_image':"), 'Internal MCP routing should handle image display calls');
assert(!imageHandler.includes("type: 'session_message'"), 'Image tool should not create a separate assistant message');
assert(frontend.includes('isCcwebDisplayImageTool'), 'Image tool results should render inside the current assistant bubble');
@@ -7133,8 +7163,8 @@ async function main() {
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'
Number(reloadAfterTracking.result?.reloadCount || 0) === baselineMcpReloadCount + 1,
'Task tracking changes must not trigger an implicit MCP reload; only the explicit reload request should count'
);
ws.send(JSON.stringify({ type: 'composer_suggestions', requestId: 'reg-codexapp-prompt-user-mcp', trigger: '/', query: 'prompt_user', sessionId: codexAppSession.sessionId, agent: 'codexapp' }));

View File

@@ -0,0 +1,293 @@
'use strict';
const assert = require('node:assert');
const http = require('node:http');
const {
CLASSIFICATION_EVENT_TYPES,
TaskStatusClassificationError,
buildTaskStatusClassificationPrompt,
createTaskStatusClassifier,
parseTaskStatusClassification,
parseResponsesBody,
requestOpenAIResponses,
} = require('../lib/task-board-classifier');
const DEFINITIONS = Object.freeze([
Object.freeze({
id: 'alpha-lane',
label: '甲列',
prompt: '当已有明确工作正在实际推进,且无需等待外部反馈时选择。',
enabled: true,
order: 10,
}),
Object.freeze({
id: 'beta_lane',
label: '乙列',
prompt: '当主对话已经交付本轮结果,但继续推进必须等待用户输入或验收时选择。',
enabled: true,
order: 20,
}),
Object.freeze({
id: 'disabled-lane',
label: '停用列',
prompt: '不得选择。',
enabled: false,
order: 30,
}),
]);
function taskFixture(overrides = {}) {
return {
sessionId: 'classifier-session',
title: '实现动态任务分类',
taskTracking: {
enabled: true,
statusId: 'beta_lane',
summary: '正在讨论分类方案',
version: 7,
},
status: DEFINITIONS[1],
...overrides,
};
}
function expectClassificationError(fn, code) {
assert.throws(fn, (error) => (
error instanceof TaskStatusClassificationError && error.code === code
));
}
async function main() {
const prompt = buildTaskStatusClassificationPrompt({
eventType: CLASSIFICATION_EVENT_TYPES.USER_MESSAGE_RECEIVED,
task: taskFixture(),
definitions: DEFINITIONS,
userMessage: '方案可以,开始实现。',
});
assert.deepEqual(prompt.schema.properties.statusId.enum, ['alpha-lane', 'beta_lane']);
assert.equal(prompt.schema.additionalProperties, false);
assert.deepEqual(prompt.schema.required, ['statusId', 'reason', 'summary']);
assert.equal(Object.hasOwn(prompt.schema.properties.reason, 'minLength'), false);
assert.equal(Object.hasOwn(prompt.schema.properties.reason, 'maxLength'), false);
assert.match(prompt.developerPrompt, /唯一职责是分类/);
assert.match(prompt.developerPrompt, /不要执行、继续、检查或验证任务/);
assert.match(prompt.developerPrompt, /分类提示词.*唯一状态语义/);
assert.match(prompt.developerPrompt, /不得根据列 ID、列名.*猜测/);
assert.match(prompt.developerPrompt, /用户消息进入/);
assert.match(prompt.developerPrompt, /主对话本轮完成/);
assert.match(prompt.developerPrompt, /请求用户输入.*本轮完成/);
assert.match(prompt.developerPrompt, /运行开始、运行停止.*网络失败或人工中断/);
assert.match(prompt.inputPrompt, /alpha-lane/);
assert.match(prompt.inputPrompt, /beta_lane/);
assert.match(prompt.inputPrompt, /已有明确工作正在实际推进/);
assert.match(prompt.inputPrompt, /方案可以,开始实现/);
assert.doesNotMatch(prompt.inputPrompt, /不得选择/);
assert.deepEqual(parseTaskStatusClassification(
'{"statusId":"alpha-lane","reason":"用户已确认开始实现","summary":"实现动态任务分类"}',
['alpha-lane', 'beta_lane'],
), {
statusId: 'alpha-lane',
reason: '用户已确认开始实现',
summary: '实现动态任务分类',
});
expectClassificationError(() => parseTaskStatusClassification(
'```json\n{"statusId":"alpha-lane","reason":"x","summary":"y"}\n```',
['alpha-lane'],
), 'invalid_json');
expectClassificationError(() => parseTaskStatusClassification(
'{"statusId":"disabled-lane","reason":"x","summary":"y"}',
['alpha-lane', 'beta_lane'],
), 'invalid_status');
expectClassificationError(() => parseTaskStatusClassification(
'{"statusId":"alpha-lane","reason":"x","summary":"y","extra":true}',
['alpha-lane'],
), 'invalid_shape');
assert.deepEqual(parseResponsesBody([
'event: response.output_text.delta',
'data: {"type":"response.output_text.delta","delta":"{\\"statusId\\":\\"alpha-lane\\","}',
'',
'event: response.output_text.delta',
'data: {"type":"response.output_text.delta","delta":"\\"reason\\":\\"x\\",\\"summary\\":\\"y\\"}"}',
'',
'data: [DONE]',
].join('\n')), {
status: 'completed',
output_text: '{"statusId":"alpha-lane","reason":"x","summary":"y"}',
});
assert.deepEqual(parseResponsesBody([
'data: {"type":"response.output_text.delta","delta":"{\\"statusId\\":\\"alpha-lane\\",\\"reason\\":\\"x\\",\\"summary\\":\\"y\\"}"}',
'',
'data: {"type":"response.completed","response":{"status":"completed","output":[]}}',
'',
].join('\n')), {
status: 'completed',
output_text: '{"statusId":"alpha-lane","reason":"x","summary":"y"}',
});
const streamingServer = http.createServer((req, res) => {
req.resume();
res.writeHead(200, { 'Content-Type': 'text/event-stream' });
res.write(`event: response.completed\ndata: ${JSON.stringify({
type: 'response.completed',
response: {
status: 'completed',
output_text: '{"statusId":"alpha-lane","reason":"x","summary":"y"}',
},
})}\n\n`);
setTimeout(() => res.end(), 1200);
});
await new Promise((resolve) => streamingServer.listen(0, '127.0.0.1', resolve));
try {
const startedAt = Date.now();
const streamed = await requestOpenAIResponses({
runtime: {
apiBase: `http://127.0.0.1:${streamingServer.address().port}/v1`,
apiKey: 'test-key',
},
body: { model: 'test-model' },
}, { timeoutMs: 2000 });
assert.equal(streamed.ok, true);
assert.equal(streamed.text, '{"statusId":"alpha-lane","reason":"x","summary":"y"}');
assert(Date.now() - startedAt < 600, 'SSE response.completed 后不应继续等待连接关闭');
} finally {
await new Promise((resolve) => streamingServer.close(resolve));
}
let currentTask = taskFixture();
const requests = [];
const updates = [];
const events = [];
const service = {
getTask() {
return JSON.parse(JSON.stringify(currentTask));
},
getStatusDefinitions() {
return JSON.parse(JSON.stringify(DEFINITIONS));
},
updateStatus(sessionId, update, actor) {
assert.equal(sessionId, currentTask.sessionId);
assert.deepEqual(actor, { source: 'classifier', id: 'task-status-classifier' });
if (update.expectedVersion !== currentTask.taskTracking.version) {
const error = new Error('版本冲突');
error.code = 'task_version_conflict';
throw error;
}
updates.push({ update, actor });
currentTask = taskFixture({
taskTracking: {
...currentTask.taskTracking,
statusId: update.statusId,
reason: update.reason,
summary: update.summary,
source: actor.source,
version: currentTask.taskTracking.version + 1,
},
status: DEFINITIONS.find((item) => item.id === update.statusId),
});
return { ...JSON.parse(JSON.stringify(currentTask)), changed: true };
},
};
const classifier = createTaskStatusClassifier({
taskBoardService: service,
loadSession() {
return { id: currentTask.sessionId, agent: 'codexapp', model: 'gpt-test(high)' };
},
resolveRuntime() {
return {
apiBase: 'https://provider.example/v1',
apiKey: 'secret-not-for-logs',
model: 'gpt-test',
effort: 'high',
providerName: 'fixture',
};
},
async requestStructured(request) {
requests.push(request);
return {
ok: true,
text: '{"statusId":"alpha-lane","reason":"用户已授权实施","summary":"实现动态任务分类"}',
};
},
onTaskChanged(event) {
events.push(event);
},
});
const moved = await classifier.enqueue(currentTask.sessionId, {
eventType: CLASSIFICATION_EVENT_TYPES.USER_MESSAGE_RECEIVED,
eventId: 'message-1',
userMessage: '按这个方案开始实现。',
});
assert.equal(moved.ok, true);
assert.equal(moved.changed, true);
assert.equal(currentTask.taskTracking.statusId, 'alpha-lane');
assert.equal(currentTask.taskTracking.source, 'classifier');
assert.equal(requests.length, 1);
assert.deepEqual(requests[0].body.tools, []);
assert.equal(requests[0].body.stream, true);
assert.equal(requests[0].body.reasoning.effort, 'low');
assert.equal(requests[0].body.text.format.type, 'json_schema');
assert.equal(requests[0].body.text.format.strict, true);
assert.deepEqual(requests[0].body.text.format.schema.properties.statusId.enum, ['alpha-lane', 'beta_lane']);
assert.equal(events.length, 1);
const duplicate = await classifier.enqueue(currentTask.sessionId, {
eventType: CLASSIFICATION_EVENT_TYPES.USER_MESSAGE_RECEIVED,
eventId: 'message-1',
userMessage: '重复消息不应再次调用。',
});
assert.equal(duplicate.skipped, 'duplicate_event');
assert.equal(requests.length, 1);
const invalidClassifier = createTaskStatusClassifier({
taskBoardService: service,
loadSession: () => ({ id: currentTask.sessionId, agent: 'codexapp', model: 'gpt-test' }),
resolveRuntime: () => ({ apiBase: 'https://provider.example/v1', apiKey: 'secret', model: 'gpt-test' }),
requestStructured: async () => ({
ok: true,
text: '```json\n{"statusId":"beta_lane","reason":"等待用户","summary":"等待确认"}\n```',
}),
});
const beforeInvalid = currentTask.taskTracking.version;
const invalid = await invalidClassifier.enqueue(currentTask.sessionId, {
eventType: CLASSIFICATION_EVENT_TYPES.TURN_COMPLETED,
eventId: 'turn-invalid',
assistantResult: '请用户确认后继续。',
});
assert.equal(invalid.ok, false);
assert.equal(invalid.errorCode, 'invalid_json');
assert.equal(currentTask.taskTracking.version, beforeInvalid);
let unavailableCalled = false;
const unavailableClassifier = createTaskStatusClassifier({
taskBoardService: service,
loadSession: () => ({ id: currentTask.sessionId, agent: 'codexapp', model: 'gpt-test' }),
resolveRuntime: () => null,
requestStructured: async () => { unavailableCalled = true; },
});
const unavailable = await unavailableClassifier.enqueue(currentTask.sessionId, {
eventType: CLASSIFICATION_EVENT_TYPES.TURN_COMPLETED,
eventId: 'turn-no-provider',
assistantResult: '已完成本轮。',
});
assert.equal(unavailable.skipped, 'provider_unavailable');
assert.equal(unavailableCalled, false);
currentTask = taskFixture({
taskTracking: { ...taskFixture().taskTracking, enabled: false },
});
const disabled = await classifier.enqueue(currentTask.sessionId, {
eventType: CLASSIFICATION_EVENT_TYPES.USER_MESSAGE_RECEIVED,
eventId: 'message-disabled',
userMessage: '未加入看板时不分类。',
});
assert.equal(disabled.skipped, 'tracking_disabled');
console.log('Task board classifier unit checks passed.');
}
main().catch((error) => {
console.error(error.stack || error);
process.exitCode = 1;
});

View File

@@ -12,6 +12,7 @@ const WebSocket = require('ws');
const REPO_DIR = path.resolve(__dirname, '..');
const SERVER_PATH = path.join(REPO_DIR, 'server.js');
const MOCK_CLAUDE = path.join(REPO_DIR, 'scripts', 'mock-claude.js');
const MOCK_CODEX_APP_SERVER = path.join(REPO_DIR, 'scripts', 'mock-codex-app-server.js');
function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
@@ -57,6 +58,56 @@ async function waitForJson(filePath, predicate, timeoutMs = 5_000) {
throw new Error(`等待会话状态超时: ${path.basename(filePath)}`);
}
async function waitForCondition(label, predicate, timeoutMs = 8_000) {
const started = Date.now();
while (Date.now() - started < timeoutMs) {
if (predicate()) return;
await sleep(25);
}
throw new Error(`等待${label}超时`);
}
function startClassifierProvider(port, requests) {
const server = http.createServer((req, res) => {
let raw = '';
req.setEncoding('utf8');
req.on('data', (chunk) => { raw += chunk; });
req.on('end', () => {
let body = null;
try { body = JSON.parse(raw); } catch {}
requests.push({
method: req.method,
url: req.url,
authorization: req.headers.authorization || '',
body,
});
const inputText = Array.isArray(body?.input)
? body.input.map((item) => String(item?.content || '')).join('\n')
: '';
const statusIds = body?.text?.format?.schema?.properties?.statusId?.enum || [];
const eventType = inputText.includes('"eventType": "turn_completed"')
? 'turn_completed'
: 'user_message_received';
const invalid = inputText.includes('分类返回非标准 JSON');
const statusId = eventType === 'turn_completed' ? 'completed' : 'waiting-release';
const result = {
statusId: statusIds.includes(statusId) ? statusId : statusIds[0],
reason: eventType === 'turn_completed' ? '主轮次已经完成目标与验证。' : '用户要求开始推进并等待发布。',
summary: eventType === 'turn_completed' ? '自动分类集成任务已完成。' : '自动分类集成任务正在推进并等待发布。',
};
const outputText = invalid
? `\`\`\`json\n${JSON.stringify(result)}\n\`\`\``
: JSON.stringify(result);
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ status: 'completed', output_text: outputText }));
});
});
return new Promise((resolve, reject) => {
server.once('error', reject);
server.listen(port, '127.0.0.1', () => resolve(server));
});
}
function connectClient(port, password) {
return new Promise((resolve, reject) => {
const ws = new WebSocket(`ws://127.0.0.1:${port}/ws`);
@@ -218,6 +269,28 @@ async function main() {
const homeDir = path.join(tempRoot, 'home');
[configDir, sessionsDir, logsDir, homeDir].forEach((directory) => fs.mkdirSync(directory, { recursive: true }));
const classifierPort = await freePort();
const classifierRequests = [];
const classifierProvider = await startClassifierProvider(classifierPort, classifierRequests);
const codexHome = path.join(homeDir, '.codex');
fs.mkdirSync(codexHome, { recursive: true });
fs.writeFileSync(path.join(codexHome, 'config.toml'), [
'model_provider = "classifier_test"',
'model = "gpt-classifier-test"',
'model_reasoning_effort = "low"',
'',
'[model_providers.classifier_test]',
'name = "Classifier Test"',
`base_url = "http://127.0.0.1:${classifierPort}/v1"`,
'wire_api = "responses"',
'env_key = "CLASSIFIER_TEST_API_KEY"',
'',
].join('\n'));
fs.writeFileSync(path.join(codexHome, 'auth.json'), JSON.stringify({
CLASSIFIER_TEST_API_KEY: 'classifier-test-key',
tokens: { access_token: 'must-not-be-used' },
}, null, 2));
const ordinaryId = 'ordinary-existing';
const ordinaryPath = path.join(sessionsDir, `${ordinaryId}.json`);
fs.writeFileSync(ordinaryPath, JSON.stringify({
@@ -273,6 +346,7 @@ async function main() {
CC_WEB_LOGS_DIR: logsDir,
HOME: homeDir,
CLAUDE_PATH: MOCK_CLAUDE,
CODEX_PATH: MOCK_CODEX_APP_SERVER,
},
stdio: ['ignore', 'pipe', 'pipe'],
});
@@ -633,6 +707,104 @@ async function main() {
}, 'task_status_definitions_result');
assert.equal(reenabled.ok, true);
primary.ws.send(JSON.stringify({
type: 'new_session',
requestId: 'new-classifier-task',
agent: 'codexapp',
cwd: homeDir,
taskTrackingEnabled: true,
}));
const classifierInfo = await nextMessage(primary, (message) => (
message.type === 'session_info' && message.requestId === 'new-classifier-task'
));
const classifierSessionId = classifierInfo.sessionId;
const classifierRequestStart = classifierRequests.length;
primary.ws.send(JSON.stringify({
type: 'message',
sessionId: classifierSessionId,
agent: 'codexapp',
mode: 'yolo',
clientMessageId: 'classifier-user-event',
text: '开始推进自动分类集成任务,完成后等待发布。',
}));
const classifiedUserEvent = await nextMessage(observer, (message) => (
message.type === 'task_board_event'
&& message.event === 'classifier:user_message_received'
&& message.sessionId === classifierSessionId
), 8_000);
assert.equal(classifiedUserEvent.task.taskTracking.statusId, 'waiting-release');
assert.equal(classifiedUserEvent.task.taskTracking.source, 'classifier');
await nextMessage(primary, (message) => (
message.type === 'done' && message.sessionId === classifierSessionId
), 8_000);
const classifiedCompletionEvent = await nextMessage(observer, (message) => (
message.type === 'task_board_event'
&& message.event === 'classifier:turn_completed'
&& message.sessionId === classifierSessionId
), 8_000);
assert.equal(classifiedCompletionEvent.task.taskTracking.statusId, 'completed');
assert.equal(classifiedCompletionEvent.task.taskTracking.source, 'classifier');
assert.equal(
classifiedCompletionEvent.task.taskTracking.version,
classifierInfo.taskTracking.version + 2,
);
await waitForCondition('两次自动分类请求', () => classifierRequests.length >= classifierRequestStart + 2);
const [userClassificationRequest, completionClassificationRequest] = classifierRequests.slice(
classifierRequestStart,
classifierRequestStart + 2,
);
for (const classifierRequest of [userClassificationRequest, completionClassificationRequest]) {
assert.equal(classifierRequest.method, 'POST');
assert.equal(classifierRequest.url, '/v1/responses');
assert.equal(classifierRequest.authorization, 'Bearer classifier-test-key');
assert.equal(classifierRequest.body.model, 'gpt-classifier-test');
assert.deepEqual(classifierRequest.body.tools, []);
assert.equal(classifierRequest.body.tool_choice, 'none');
assert.equal(classifierRequest.body.store, false);
assert.equal(classifierRequest.body.text.format.type, 'json_schema');
assert.equal(classifierRequest.body.text.format.strict, true);
assert(classifierRequest.body.text.format.schema.properties.statusId.enum.includes('waiting-release'));
const developerPrompt = classifierRequest.body.input.find((item) => item.role === 'developer')?.content || '';
assert(developerPrompt.includes('唯一职责是分类'));
assert(developerPrompt.includes('不要执行、继续、检查或验证任务'));
assert(developerPrompt.includes('分类提示词”是唯一状态语义来源'));
assert(developerPrompt.includes('请求用户输入也是主对话本轮完成'));
const evidencePrompt = classifierRequest.body.input.find((item) => item.role === 'user')?.content || '';
assert(evidencePrompt.includes(editedPrompt));
}
assert(userClassificationRequest.body.input.some((item) => (
item.role === 'user' && item.content.includes('"eventType": "user_message_received"')
)));
assert(completionClassificationRequest.body.input.some((item) => (
item.role === 'user' && item.content.includes('"eventType": "turn_completed"')
)));
const beforeInvalidClassification = JSON.parse(fs.readFileSync(
path.join(sessionsDir, `${classifierSessionId}.json`),
'utf8',
)).taskTracking;
const invalidRequestStart = classifierRequests.length;
primary.ws.send(JSON.stringify({
type: 'message',
sessionId: classifierSessionId,
agent: 'codexapp',
mode: 'yolo',
clientMessageId: 'classifier-invalid-json',
text: '分类返回非标准 JSON但主对话仍应正常完成。',
}));
await nextMessage(primary, (message) => (
message.type === 'done' && message.sessionId === classifierSessionId
), 8_000);
await waitForCondition('非标准 JSON 分类请求', () => classifierRequests.length >= invalidRequestStart + 2);
await sleep(100);
const afterInvalidClassification = JSON.parse(fs.readFileSync(
path.join(sessionsDir, `${classifierSessionId}.json`),
'utf8',
)).taskTracking;
assert.equal(afterInvalidClassification.statusId, beforeInvalidClassification.statusId);
assert.equal(afterInvalidClassification.version, beforeInvalidClassification.version);
assert.equal(afterInvalidClassification.summary, beforeInvalidClassification.summary);
const forgedSource = await callInternalMcp(port, internalToken, {
tool: 'ccweb_task_update',
sourceSessionId: mcpInfo.sessionId,
@@ -721,6 +893,7 @@ async function main() {
child.kill('SIGTERM');
await sleep(200);
if (child.exitCode === null) child.kill('SIGKILL');
await new Promise((resolve) => classifierProvider.close(resolve));
fs.rmSync(tempRoot, { recursive: true, force: true });
}

View File

@@ -39,6 +39,7 @@ function createHarness() {
['priority-c', sessionFixture('priority-c')],
['lifecycle-d', sessionFixture('lifecycle-d')],
['mcp-e', sessionFixture('mcp-e')],
['classifier-h', sessionFixture('classifier-h')],
]);
let statusConfig = null;
let clockTick = 0;
@@ -243,6 +244,27 @@ async function main() {
assert.deepStrictEqual(queriedCustomTasks.map((task) => task.sessionId), ['ordinary-a']);
assert.strictEqual('progress' in queriedCustomTasks[0].taskTracking, false);
// 自动分类器是独立审计来源,仍由 expectedVersion 负责并发门禁。
const classifierEnabled = service.setTracking('classifier-h', true, {
source: 'user', expectedVersion: 0,
});
const classifierUserMove = service.updateStatus('classifier-h', {
statusId: 'waiting_user', expectedVersion: classifierEnabled.taskTracking.version,
}, { source: 'user', id: 'user-h' });
const classifierMove = service.updateStatus('classifier-h', {
statusId: 'waiting-release',
reason: '用户已确认继续推进',
summary: '等待发布前的最终处理',
expectedVersion: classifierUserMove.taskTracking.version,
}, { source: 'classifier', id: 'task-status-classifier' });
assert.strictEqual(classifierMove.changed, true);
assert.strictEqual(classifierMove.taskTracking.statusId, 'waiting-release');
assert.strictEqual(classifierMove.taskTracking.source, 'classifier');
assert.strictEqual(classifierMove.taskTracking.version, classifierUserMove.taskTracking.version + 1);
expectTaskError(() => service.updateStatus('classifier-h', {
statusId: 'completed', expectedVersion: classifierUserMove.taskTracking.version,
}, { source: 'classifier' }), 'task_version_conflict');
// 停用跟踪仍保留状态;迁移会覆盖启用和停用会话中的引用。
const enabledB = service.setTracking('migration-b', true, { source: 'user', expectedVersion: 0 });
const customTaskB = service.updateStatus('migration-b', {
@@ -267,7 +289,8 @@ async function main() {
'waiting-release', 'waiting_user', { source: 'user', expectedVersion: 3 },
);
assert.strictEqual(migration.version, 4);
assert.deepStrictEqual(migration.migratedSessionIds.sort(), ['migration-b', 'ordinary-a']);
assert.deepStrictEqual(migration.migratedSessionIds.sort(), ['classifier-h', 'migration-b', 'ordinary-a']);
assert.strictEqual(service.getTask('classifier-h').taskTracking.statusId, 'waiting_user');
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);