feat: 优化任务状态与图片 MCP 提示
This commit is contained in:
@@ -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' }));
|
||||
|
||||
293
scripts/task-board-classifier-unit.js
Normal file
293
scripts/task-board-classifier-unit.js
Normal 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;
|
||||
});
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user