Files
maestro/test/memory-inject.test.ts
wangjia 42f9788b0a feat(executor): 人工驳回意见喂回返工 agent prompt (tsk_BJLguXuWkyq4)
照搬现有 L1「上次失败原因」链路,新增平行的「人工驳回意见」注入链路:

- store.lastRejectReasonOf(taskId, gate?):从 approvals 取最近一次 reject reason,
  支持按闸过滤(exec/spec/plan),无 reject 返回 null
- orchestrator.claimOne:按返工角色对应闸装配 task.lastRejectReason 下发 job
  (executor→exec / planner-spec→spec / planner-decompose→plan)
- runner.lastRejectLines + buildPrompt/buildPlannerPrompt 拼接驳回段
  (紧接失败段之后,2000 字截断);buildConflictPrompt 不注入
- types.Task 新增 lastRejectReason 临时装配字段(仅注入 prompt,不持久化)
- test/memory-inject.test.ts:覆盖 store gate 过滤、三处注入、截断、边界、顺序

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 09:08:26 +08:00

185 lines
8.7 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 { 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();
});
const REJECT_SECTION = '上次被人审驳回的意见';
test('REJECT: 无 lastRejectReason 时三处 prompt 都不含驳回段', () => {
assert.ok(!buildPrompt(fakeTask()).includes(REJECT_SECTION));
assert.ok(!buildPlannerPrompt(fakeTask({ complexity: 'medium' }), 'spec').includes(REJECT_SECTION));
assert.ok(!buildPlannerPrompt(fakeTask({ complexity: 'hard' }), 'decompose').includes(REJECT_SECTION));
});
test('REJECT: 有 lastRejectReason 时 buildPrompt/buildPlannerPrompt(spec/decompose) 都注入驳回意见原文', () => {
const reason = '错误地动了 web/ 老仪表盘,请改到 design/ui_kits/console/';
for (const p of [
buildPrompt(fakeTask({ lastRejectReason: reason })),
buildPlannerPrompt(fakeTask({ complexity: 'medium', lastRejectReason: reason }), 'spec'),
buildPlannerPrompt(fakeTask({ complexity: 'hard', lastRejectReason: reason }), 'decompose'),
]) {
assert.ok(p.includes(REJECT_SECTION), '应含驳回段标题');
assert.ok(p.includes(reason), '应含驳回意见原文');
}
});
test('REJECT: buildConflictPrompt 始终不注入驳回段(守边界)', () => {
const reason = '某条驳回意见';
assert.ok(!buildConflictPrompt(fakeTask({ lastRejectReason: reason }), ['a.ts']).includes(REJECT_SECTION));
});
test('REJECT: 超长驳回意见被截断到 2000 字符', () => {
const reason = 'Y'.repeat(5000);
const p = buildPrompt(fakeTask({ lastRejectReason: reason }));
assert.ok(p.includes('已截断'));
assert.ok(!p.includes('Y'.repeat(2100)));
});
test('REJECT: 顺序——驳回段在角色说明之前;与失败段并存时失败段在前', () => {
// planner 中驳回段排在「你的角色」之前
const sp = buildPlannerPrompt(fakeTask({ complexity: 'medium', lastRejectReason: 'RJ' }), 'spec');
assert.ok(sp.indexOf(REJECT_SECTION) < sp.indexOf('你的角色'), '驳回段在角色说明之前');
// 失败段与驳回段并存:失败在前、驳回在后
const both = buildPrompt(fakeTask({ lastRunError: 'ERR', lastRejectReason: 'RJ' }));
assert.ok(both.indexOf(SECTION) < both.indexOf(REJECT_SECTION), '失败段在驳回段之前');
});
test('lastRejectReasonOf 取最近一次 reject 的 reason,支持 gate 过滤,无 reject → null', () => {
const s = new Store(':memory:');
const p = s.createProject({ name: 'rj', repoPath: '/tmp/rj-' + Math.random() });
const t = s.createTask({ projectId: p.id, title: 'feat', complexity: 'easy' });
assert.equal(s.lastRejectReasonOf(t.id), null, '无 reject → null');
// 推到 exec_review 再 rejectexec reject 落 ready
s.setOperations(t.id, 'do it');
s.transition(t.id, 'queued');
s.transition(t.id, 'executing');
s.transition(t.id, 'exec_review');
const reason = '代码动了越界文件,请收敛范围';
s.decide(t.id, 'reject', 'alice', reason);
assert.equal(s.lastRejectReasonOf(t.id), reason, '不带 gate → 取最近一次');
assert.equal(s.lastRejectReasonOf(t.id, 'exec'), reason, 'exec 闸能取到');
assert.equal(s.lastRejectReasonOf(t.id, 'plan'), null, 'plan 闸取不到该 exec reject');
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, /声明的改动文件范围/);
});