Files
maestro/test/classify.test.ts
T
wangjia 13572a5416 feat(complexity): 新增 AUTO·由模型判断 复杂度档位 (tsk_zacmwH3chZpG)
新建任务时除手选 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>
2026-06-13 10:46:37 +08:00

189 lines
7.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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('applyAutoComplexitymedium 占位 → 回填 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();
});