Files
maestro/test/memory-inject.test.ts
T
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

124 lines
5.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 { buildPrompt, buildPlannerPrompt, buildConflictPrompt } from '../src/executor/runner.js';
import { Store } from '../src/store/index.js';
import type { Task, Project } from '../src/model/types.js';
function fakeProject(over: Partial<Project> = {}): Project {
return { defaultBranch: 'main', agentRules: null, ...over } as Project;
}
function fakeTask(over: Partial<Task> = {}): Task {
return {
id: 'tsk_x', title: '改个东西', complexity: 'medium', depth: 1,
operations: '把 A 改成 B', spec: null, plan: null, deps: [],
...over,
} as Task;
}
const SECTION = '上次失败原因';
test('L1: 无 lastRunError 时三处 prompt 都不含「上次失败原因」段', () => {
const t = fakeTask();
assert.ok(!buildPrompt(t).includes(SECTION));
assert.ok(!buildPlannerPrompt(fakeTask({ complexity: 'medium' }), 'spec').includes(SECTION));
assert.ok(!buildPlannerPrompt(fakeTask({ complexity: 'hard' }), 'decompose').includes(SECTION));
assert.ok(!buildConflictPrompt(t, ['a.ts']).includes(SECTION));
});
test('L1: 有 lastRunError 时三处 prompt 注入失败原因', () => {
const err = 'verify 失败:npm test 退出码 1\nFAIL src/foo.test.ts';
const t = fakeTask({ lastRunError: err });
for (const p of [
buildPrompt(t),
buildPlannerPrompt(fakeTask({ complexity: 'medium', lastRunError: err }), 'spec'),
buildPlannerPrompt(fakeTask({ complexity: 'hard', lastRunError: err }), 'decompose'),
buildConflictPrompt(t, ['a.ts']),
]) {
assert.ok(p.includes(SECTION), '应含失败段标题');
assert.ok(p.includes('npm test 退出码 1'), '应含具体错误');
}
});
test('L1: 超长错误被截断到 2000 字符', () => {
const err = 'X'.repeat(5000);
const p = buildPrompt(fakeTask({ lastRunError: err }));
assert.ok(p.includes('已截断'));
assert.ok(!p.includes('X'.repeat(2100)));
});
test('L2: project.agentRules 注入 executor/planner/conflict/,未设则不注入', () => {
const t = fakeTask();
const withRules = fakeProject({ agentRules: '本项目禁止改 schema.sql' });
assert.ok(buildPrompt(t, withRules).includes('项目规范'));
assert.ok(buildPrompt(t, withRules).includes('禁止改 schema.sql'));
assert.ok(buildPlannerPrompt(fakeTask({ complexity: 'hard' }), 'decompose', withRules).includes('禁止改 schema.sql'));
assert.ok(buildConflictPrompt(t, ['a.ts'], withRules).includes('禁止改 schema.sql'));
// 未设规范 → 不出现「项目规范」标题
assert.ok(!buildPrompt(t, fakeProject()).includes('项目规范'));
assert.ok(!buildPrompt(t).includes('项目规范')); // 连 project 都没传
});
test('L4: globalRules 注入且排在项目规范之前', () => {
const t = fakeTask();
const p = buildPrompt(t, fakeProject({ agentRules: 'PROJ_RULE' }), 'GLOBAL_RULE');
assert.ok(p.includes('全局执行规范'));
assert.ok(p.includes('GLOBAL_RULE'));
assert.ok(p.indexOf('GLOBAL_RULE') < p.indexOf('PROJ_RULE'), '全局规范在项目规范之前');
// 全局规范在任务内容之前(注入顺序:L4→L2→任务内容)
assert.ok(p.indexOf('GLOBAL_RULE') < p.indexOf('任务内容'));
});
test('L3: context 注入父任务意图 + 兄弟状态,无 context 不注入', () => {
const t = fakeTask({
context: {
parentPlan: '把登录拆成前后端',
siblings: [{ title: '前端表单', status: 'done', complexity: 'easy' }],
},
});
const p = buildPrompt(t);
assert.ok(p.includes('拆解背景'));
assert.ok(p.includes('把登录拆成前后端'));
assert.ok(p.includes('前端表单'));
assert.ok(p.indexOf('拆解背景') < p.indexOf('任务内容'), '背景在任务内容之前');
assert.ok(!buildPrompt(fakeTask()).includes('拆解背景'));
});
test('taskContextOf 取父 plan + 兄弟概览(排除自身)', () => {
const s = new Store(':memory:');
const pj = s.createProject({ name: 'l3', repoPath: '/tmp/l3-' + Math.random() });
const parent = s.createTask({ projectId: pj.id, title: '大', complexity: 'hard' });
s.setPlan(parent.id, '拆成 a 和 b');
const a = s.createTask({ projectId: pj.id, parentId: parent.id, title: 'a', complexity: 'easy' });
s.createTask({ projectId: pj.id, parentId: parent.id, title: 'b', complexity: 'easy' });
assert.equal(s.taskContextOf(parent.id), null, '根任务无 parent → null');
const ctxA = s.taskContextOf(a.id)!;
assert.equal(ctxA.parentPlan, '拆成 a 和 b');
assert.deepEqual(ctxA.siblings!.map((x) => x.title), ['b'], '兄弟里不含自身 a');
s.close();
});
test('lastRunErrorOf 取最近一次失败 run 的错误(任意 kind', () => {
const s = new Store(':memory:');
const p = s.createProject({ name: 'l1', repoPath: '/tmp/l1-' + Math.random() });
const t = s.createTask({ projectId: p.id, title: 'feat', complexity: 'easy' });
assert.equal(s.lastRunErrorOf(t.id), null, '无失败 run → null');
s.setOperations(t.id, 'do it');
s.transition(t.id, 'queued');
s.transition(t.id, 'executing');
const run = s.startRun(t.id, 'executor', { worktree: '/tmp/w', branch: 'b' });
s.failTaskAttempt(t.id, run.id, '第一次失败:编译错误');
assert.equal(s.lastRunErrorOf(t.id), '第一次失败:编译错误');
s.close();
});
test('buildPrompttask.scopeFiles 非空 → 注入声明文件范围约束;为空 → 不注入', () => {
const withScope = buildPrompt(fakeTask({ scopeFiles: ['src/foo/**', 'lib/a.ts'] }));
assert.match(withScope, /声明的改动文件范围/);
assert.match(withScope, /src\/foo\/\*\*/);
assert.match(withScope, /越界会被硬闸拦截/);
const noScope = buildPrompt(fakeTask({ scopeFiles: null }));
assert.doesNotMatch(noScope, /声明的改动文件范围/);
});