feat: 优化任务状态与图片 MCP 提示
This commit is contained in:
@@ -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 });
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user