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 { return { defaultBranch: 'main', agentRules: null, ...over } as Project; } function fakeTask(over: Partial = {}): 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, /声明的改动文件范围/); });