Files
wangjia 59ce391ecc feat(models+gate): 模型按项目可配置(默认 opus-4.8) + diff 声明外文件硬闸
① 模型可配置(项目维度,默认 opus-4.8)
- 新增 projects.models(JSON,按角色 executor/planner/reviewer/conflict
  覆盖,值可为字符串=全复杂度统一 或 {easy,medium,hard} 分档)
- models.ts 重构 resolveModel:优先级 项目级 models > 旧 project.model
  (仅 executor/planner) > env > 默认 DEFAULT_MODEL(opus-4.8)
- 取消内置 fable/sonnet 分档默认:所有角色默认 opus-4.8(彻底回避 fable-5
  不可用问题,需要时项目级显式配置即可);回退链改 opus→sonnet→fable
- API PATCH /projects 透传 models;sanitizeModels 落库校验

② diff 声明外文件闸(task.scopeFiles)
- 新增 tasks.scope_files(JSON glob/路径数组)
- checks.ts: globToRegExp/matchesAnyGlob + scopeFileGate(改动文件越界=硬闸,
  空声明跳过,git 出错不拦截);pipeline runApproveGates 接入
- planner 拆解新增每子任务 files 字段:prompt 要求 + parseDecompose 解析 +
  ingest 落 scopeFiles,自动填充声明范围
- executor prompt 注入「声明文件范围约束」,让 agent 知边界(gate 才公平)

迁移:projects.models / tasks.scope_files 走 ensureColumn 幂等迁移(旧库补列)
测试:models 默认/配置/优先级、scope glob/gate、planner files 解析、
      store 持久化往返、迁移补列 —— 237 通过

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 07:06:32 +08:00

133 lines
6.3 KiB
TypeScript
Raw Permalink 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 { pickModel, resolveModel, isModelError, pickFallbackModel, MODEL_FALLBACK_CHAIN, DEFAULT_MODEL } from '../src/executor/models.js';
import type { Project, Task, ProjectModels } from '../src/model/types.js';
import type { Complexity } from '../src/model/complexity.js';
function fakeTask(complexity: Complexity): Task {
return { complexity } as Task;
}
function fakeProject(over: Partial<Project> = {}): Project {
return { model: null, models: null, ...over } as Project;
}
/** 临时设置 env,跑完恢复(避免污染其他用例) */
function withEnv(vars: Record<string, string>, fn: () => void): void {
const saved = new Map<string, string | undefined>();
for (const [k, v] of Object.entries(vars)) {
saved.set(k, process.env[k]);
process.env[k] = v;
}
try {
fn();
} finally {
for (const [k, v] of saved) {
if (v === undefined) delete process.env[k];
else process.env[k] = v;
}
}
}
const ENV_KEYS = [
'MAESTRO_MODEL_EASY', 'MAESTRO_MODEL_MEDIUM', 'MAESTRO_MODEL_HARD',
'MAESTRO_MODEL_REVIEW_EASY', 'MAESTRO_MODEL_REVIEW_MEDIUM', 'MAESTRO_MODEL_REVIEW_HARD',
'MAESTRO_MODEL_PLAN_EASY', 'MAESTRO_MODEL_PLAN_MEDIUM', 'MAESTRO_MODEL_PLAN_HARD',
];
const clearEnv = (): Record<string, string> => Object.fromEntries(ENV_KEYS.map((k) => [k, '']));
test('默认档:所有角色 × 所有复杂度统一 opus-4.8(无 project/env 配置)', () => {
withEnv(clearEnv(), () => {
const p = fakeProject();
assert.equal(DEFAULT_MODEL, 'claude-opus-4-8');
for (const role of ['executor', 'planner', 'reviewer', 'conflict'] as const) {
for (const c of ['easy', 'medium', 'hard'] as Complexity[]) {
assert.equal(pickModel(fakeTask(c), p, role), 'claude-opus-4-8', `${role}/${c}`);
}
}
});
});
test('project.models:按角色覆盖默认(字符串=全复杂度统一)', () => {
withEnv(clearEnv(), () => {
const models: ProjectModels = { reviewer: 'claude-fable-5', conflict: 'claude-fable-5' };
const p = fakeProject({ models });
// reviewer/conflict 被项目级 models 覆盖成 fable-5(含三档)
for (const c of ['easy', 'medium', 'hard'] as Complexity[]) {
assert.equal(pickModel(fakeTask(c), p, 'reviewer'), 'claude-fable-5');
assert.equal(pickModel(fakeTask(c), p, 'conflict'), 'claude-fable-5');
}
// executor 未配 → 仍默认 opus
assert.equal(pickModel(fakeTask('easy'), p, 'executor'), 'claude-opus-4-8');
});
});
test('project.models:按复杂度分档(对象形式),缺档回落默认', () => {
withEnv(clearEnv(), () => {
const models: ProjectModels = { executor: { easy: 'claude-sonnet-4-6', hard: 'claude-fable-5' } };
const p = fakeProject({ models });
assert.equal(pickModel(fakeTask('easy'), p, 'executor'), 'claude-sonnet-4-6');
assert.equal(pickModel(fakeTask('hard'), p, 'executor'), 'claude-fable-5');
// medium 未在对象里 → 回落默认 opus
assert.equal(pickModel(fakeTask('medium'), p, 'executor'), 'claude-opus-4-8');
});
});
test('优先级:project.models > 旧 project.model > env > 默认', () => {
withEnv({ ...clearEnv(), MAESTRO_MODEL_MEDIUM: 'claude-haiku-4-5' }, () => {
// models.executor 覆盖一切
const p1 = fakeProject({ model: 'claude-opus-4-6', models: { executor: 'claude-sonnet-4-6' } });
assert.equal(pickModel(fakeTask('medium'), p1, 'executor'), 'claude-sonnet-4-6');
// 无 models → 旧 project.model 覆盖 env
const p2 = fakeProject({ model: 'claude-opus-4-6' });
assert.equal(pickModel(fakeTask('medium'), p2, 'executor'), 'claude-opus-4-6');
// 无 models / 无 project.model → env 覆盖默认
const p3 = fakeProject();
assert.equal(pickModel(fakeTask('medium'), p3, 'executor'), 'claude-haiku-4-5');
});
});
test('旧 project.model 不覆盖 reviewer/conflict(保自审独立性),但 project.models 可以', () => {
withEnv(clearEnv(), () => {
const p = fakeProject({ model: 'claude-opus-4-6' });
// project.model 只作用 executor/planner
assert.equal(pickModel(fakeTask('hard'), p, 'executor'), 'claude-opus-4-6');
assert.equal(pickModel(fakeTask('hard'), p, 'planner'), 'claude-opus-4-6');
// reviewer/conflict 不受 project.model 影响 → 仍默认 opus
assert.equal(pickModel(fakeTask('hard'), p, 'reviewer'), 'claude-opus-4-8');
assert.equal(pickModel(fakeTask('hard'), p, 'conflict'), 'claude-opus-4-8');
// 但显式 project.models.reviewer 可以覆盖
const p2 = fakeProject({ model: 'claude-opus-4-6', models: { reviewer: 'claude-sonnet-4-6' } });
assert.equal(pickModel(fakeTask('hard'), p2, 'reviewer'), 'claude-sonnet-4-6');
});
});
test('resolveModelenv 空白字符串视为未设置', () => {
withEnv({ ...clearEnv(), MAESTRO_MODEL_MEDIUM: ' ' }, () => {
assert.equal(resolveModel(fakeProject(), 'executor', 'medium'), 'claude-opus-4-8');
});
});
test('isModelError:模型不可用类错误才触发回退', () => {
// 应触发
assert.ok(isModelError('model claude-fable-5 not_found'));
assert.ok(isModelError('Invalid model: claude-x'));
assert.ok(isModelError('permission denied for model claude-fable-5'));
assert.ok(isModelError('404 model does not exist'));
assert.ok(isModelError('The requested model is unavailable'));
assert.ok(isModelError('unknown model "claude-fable-5"'));
// Claude Code 的「未开通某档模型」报错(线上 fable-5 实测)
assert.ok(isModelError("There's an issue with the selected model (claude-fable-5)"));
// 不应触发
assert.ok(!isModelError('执行超时'));
assert.ok(!isModelError('network connection refused'));
assert.ok(!isModelError('not found: /tmp/some/file')); // 没提到 model
assert.ok(!isModelError('model produced empty output')); // 提到 model 但不是可用性错误
});
test('pickFallbackModel:回退链取下一档(opus→sonnet→fable→opusopus 置链首=默认档优先)', () => {
assert.deepEqual([...MODEL_FALLBACK_CHAIN], ['claude-opus-4-8', 'claude-sonnet-4-6', 'claude-fable-5']);
assert.equal(pickFallbackModel('claude-opus-4-8'), 'claude-sonnet-4-6');
assert.equal(pickFallbackModel('claude-sonnet-4-6'), 'claude-fable-5');
assert.equal(pickFallbackModel('claude-fable-5'), 'claude-opus-4-8');
});