294 lines
11 KiB
JavaScript
294 lines
11 KiB
JavaScript
'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;
|
|
});
|