13572a5416
新建任务时除手选 hard/medium/easy 外,新增 AUTO 档:先以 medium 落库 占位,建任务后异步用 sonnet 起一次轻量分类调用回填三档结果并记录理由, 不阻塞建任务返回;模型不可用/解析失败一律兜底 medium,绝不报错。 - web 看板:复杂度 seg 增加第四档 AUTO(默认选中,cyan 样式),提交 complexity='auto' - src/api/server.ts:POST tasks 接受 'auto' → medium 占位 + 异步 classify 回填(可注入 classify 测试) - src/executor/classify.ts(新增):buildClassifyPrompt/parseClassification/classifyComplexity 复用 cc.ts/models.ts,sonnet 省额度,无工具、限轮限时 - src/executor/models.ts:pickClassifierModel(复用 executor easy 档 + MAESTRO_MODEL_EASY 覆盖) - src/store/store.ts:applyAutoComplexity 回填复杂度+重置状态+理由入产出字段+广播 task.updated - test/classify.test.ts:解析映射/兜底、prompt、回填、建任务集成(13 例) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
189 lines
7.7 KiB
TypeScript
189 lines
7.7 KiB
TypeScript
import { test } from 'node:test';
|
||
import assert from 'node:assert/strict';
|
||
import { buildClassifyPrompt, parseClassification, type ClassifierFn } from '../src/executor/classify.js';
|
||
import { Store } from '../src/store/index.js';
|
||
import { buildServer } from '../src/api/server.js';
|
||
import type { Event } from '../src/model/types.js';
|
||
|
||
function freshStore(): Store {
|
||
return new Store(':memory:');
|
||
}
|
||
|
||
// ---------- parseClassification(映射 + 兜底) ----------
|
||
|
||
test('parseClassification:三档正常映射 + 取理由', () => {
|
||
for (const c of ['hard', 'medium', 'easy'] as const) {
|
||
const r = parseClassification(`COMPLEXITY: ${c}\nREASON: 因为如此`);
|
||
assert.equal(r.complexity, c);
|
||
assert.equal(r.reason, '因为如此');
|
||
}
|
||
});
|
||
|
||
test('parseClassification:大小写/空白宽容', () => {
|
||
const r = parseClassification(' complexity: HARD \nreason: 跨模块 ');
|
||
assert.equal(r.complexity, 'hard');
|
||
assert.equal(r.reason, '跨模块');
|
||
});
|
||
|
||
test('parseClassification:多个 COMPLEXITY 取最后一个', () => {
|
||
const r = parseClassification('COMPLEXITY: easy\n改判\nCOMPLEXITY: hard\nREASON: 复查后升级');
|
||
assert.equal(r.complexity, 'hard');
|
||
});
|
||
|
||
test('parseClassification:噪声/非法 → 兜底 medium', () => {
|
||
for (const noise of ['', '我也不知道', 'COMPLEXITY: trivial\nREASON: 乱写', '随便聊聊']) {
|
||
const r = parseClassification(noise);
|
||
assert.equal(r.complexity, 'medium', `输入「${noise}」应兜底 medium`);
|
||
assert.ok(r.reason.length > 0);
|
||
}
|
||
});
|
||
|
||
test('parseClassification:有档无理由 → 给占位理由', () => {
|
||
const r = parseClassification('COMPLEXITY: easy');
|
||
assert.equal(r.complexity, 'easy');
|
||
assert.ok(r.reason.includes('未给出'));
|
||
});
|
||
|
||
// ---------- buildClassifyPrompt ----------
|
||
|
||
test('buildClassifyPrompt:含三档判据 + 标题/说明 + 输出格式', () => {
|
||
const p = buildClassifyPrompt('改个文案', '把按钮文字改一下');
|
||
assert.match(p, /easy/);
|
||
assert.match(p, /medium/);
|
||
assert.match(p, /hard/);
|
||
assert.match(p, /改个文案/);
|
||
assert.match(p, /把按钮文字改一下/);
|
||
assert.match(p, /COMPLEXITY:/);
|
||
assert.match(p, /REASON:/);
|
||
});
|
||
|
||
test('buildClassifyPrompt:无说明时占位', () => {
|
||
const p = buildClassifyPrompt('只有标题');
|
||
assert.match(p, /说明:(无)/);
|
||
});
|
||
|
||
// ---------- Store.applyAutoComplexity(回填) ----------
|
||
|
||
test('applyAutoComplexity:medium 占位 → 回填 hard,状态重置 analyzing + 理由入 plan', () => {
|
||
const s = freshStore();
|
||
const events: Event[] = [];
|
||
s.subscribe((e) => events.push(e));
|
||
const p = s.createProject({ name: 'a', repoPath: '/tmp/a-' + Math.random() });
|
||
const t = s.createTask({ projectId: p.id, title: 'x', complexity: 'medium' }); // speccing
|
||
assert.equal(t.status, 'speccing');
|
||
|
||
const u = s.applyAutoComplexity(t.id, 'hard', '需要跨模块拆解');
|
||
assert.equal(u.complexity, 'hard');
|
||
assert.equal(u.status, 'analyzing');
|
||
assert.match(u.plan ?? '', /需要跨模块拆解/);
|
||
// 广播了带理由的 task.updated + status.changed
|
||
const upd = events.filter((e) => e.type === 'task.updated' && e.payload.auto === 'classify').at(-1);
|
||
assert.equal(upd?.payload.complexity, 'hard');
|
||
assert.equal(upd?.payload.reason, '需要跨模块拆解');
|
||
assert.ok(events.some((e) => e.type === 'status.changed' && e.payload.to === 'analyzing'));
|
||
s.close();
|
||
});
|
||
|
||
test('applyAutoComplexity:回填 easy → 状态重置 ready + 理由入 operations', () => {
|
||
const s = freshStore();
|
||
const p = s.createProject({ name: 'b', repoPath: '/tmp/b-' + Math.random() });
|
||
const t = s.createTask({ projectId: p.id, title: 'y', complexity: 'medium' });
|
||
const u = s.applyAutoComplexity(t.id, 'easy', '单文件机械改动');
|
||
assert.equal(u.complexity, 'easy');
|
||
assert.equal(u.status, 'ready');
|
||
assert.match(u.operations ?? '', /单文件机械改动/);
|
||
s.close();
|
||
});
|
||
|
||
test('applyAutoComplexity:判定仍是 medium → 不变状态但记录理由 + 广播', () => {
|
||
const s = freshStore();
|
||
const events: Event[] = [];
|
||
s.subscribe((e) => events.push(e));
|
||
const p = s.createProject({ name: 'c', repoPath: '/tmp/c-' + Math.random() });
|
||
const t = s.createTask({ projectId: p.id, title: 'z', complexity: 'medium' });
|
||
const u = s.applyAutoComplexity(t.id, 'medium', '范围清晰、跨几处');
|
||
assert.equal(u.complexity, 'medium');
|
||
assert.equal(u.status, 'speccing'); // 未变
|
||
assert.match(u.spec ?? '', /范围清晰/);
|
||
assert.ok(events.some((e) => e.type === 'task.updated' && e.payload.auto === 'classify'));
|
||
// medium===medium,不应产生 status.changed
|
||
assert.ok(!events.some((e) => e.type === 'status.changed'));
|
||
s.close();
|
||
});
|
||
|
||
// ---------- 建任务集成(complexity='auto') ----------
|
||
|
||
/** 等待一次满足条件的事件(带超时,避免测试挂死) */
|
||
function waitFor(store: Store, pred: (e: Event) => boolean, ms = 2000): Promise<Event> {
|
||
return new Promise((resolve, reject) => {
|
||
const timer = setTimeout(() => { off(); reject(new Error('等待事件超时')); }, ms);
|
||
const off = store.subscribe((e) => {
|
||
if (pred(e)) { clearTimeout(timer); off(); resolve(e); }
|
||
});
|
||
});
|
||
}
|
||
|
||
test('POST tasks complexity=auto:先 medium 落库,模型判定后异步回填 hard', async () => {
|
||
const s = freshStore();
|
||
const p = s.createProject({ name: 'srv', repoPath: '/tmp/srv-' + Math.random() });
|
||
const classify: ClassifierFn = async (title) => {
|
||
assert.equal(title, '拆个大功能');
|
||
return { complexity: 'hard', reason: '跨子系统、有未知项' };
|
||
};
|
||
const app = buildServer({ store: s, classify });
|
||
|
||
const backfilled = waitFor(s, (e) => e.type === 'task.updated' && e.payload.auto === 'classify');
|
||
const res = await app.inject({
|
||
method: 'POST', url: `/api/projects/${p.id}/tasks`,
|
||
payload: { title: '拆个大功能', complexity: 'auto' },
|
||
});
|
||
assert.equal(res.statusCode, 200);
|
||
const created = res.json();
|
||
assert.equal(created.complexity, 'medium'); // 占位
|
||
assert.equal(created.status, 'speccing');
|
||
|
||
await backfilled;
|
||
const after = s.getTask(created.id);
|
||
assert.equal(after?.complexity, 'hard'); // 已回填
|
||
assert.equal(after?.status, 'analyzing');
|
||
assert.match(after?.plan ?? '', /跨子系统/);
|
||
await app.close();
|
||
s.close();
|
||
});
|
||
|
||
test('POST tasks complexity=auto:模型不可用(兜底 medium)仍建成功且为 medium', async () => {
|
||
const s = freshStore();
|
||
const p = s.createProject({ name: 'srv2', repoPath: '/tmp/srv2-' + Math.random() });
|
||
// 模拟 classifyComplexity 内部兜底:返回 medium
|
||
const classify: ClassifierFn = async () => ({ complexity: 'medium', reason: '模型不可用,兜底 medium' });
|
||
const app = buildServer({ store: s, classify });
|
||
|
||
const backfilled = waitFor(s, (e) => e.type === 'task.updated' && e.payload.auto === 'classify');
|
||
const res = await app.inject({
|
||
method: 'POST', url: `/api/projects/${p.id}/tasks`,
|
||
payload: { title: '不确定的任务', complexity: 'auto' },
|
||
});
|
||
assert.equal(res.statusCode, 200);
|
||
assert.equal(res.json().complexity, 'medium');
|
||
|
||
await backfilled;
|
||
const after = s.getTask(res.json().id);
|
||
assert.equal(after?.complexity, 'medium');
|
||
assert.match(after?.spec ?? '', /兜底 medium/);
|
||
await app.close();
|
||
s.close();
|
||
});
|
||
|
||
test('POST tasks:非法 complexity(非 auto/三档)被拒 400', async () => {
|
||
const s = freshStore();
|
||
const p = s.createProject({ name: 'srv3', repoPath: '/tmp/srv3-' + Math.random() });
|
||
const app = buildServer({ store: s, classify: async () => ({ complexity: 'medium', reason: '' }) });
|
||
const res = await app.inject({
|
||
method: 'POST', url: `/api/projects/${p.id}/tasks`,
|
||
payload: { title: 't', complexity: 'trivial' },
|
||
});
|
||
assert.equal(res.statusCode, 400);
|
||
await app.close();
|
||
s.close();
|
||
});
|