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(); }); 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, /声明的改动文件范围/); });