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

@@ -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 });
}