42f9788b0a
照搬现有 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>
185 lines
8.7 KiB
TypeScript
185 lines
8.7 KiB
TypeScript
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 再 reject(exec 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('buildPrompt:task.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, /声明的改动文件范围/);
|
||
});
|