@zhushanwen/pi-subagent-workflow 4.0.0 → 5.0.0-dev.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/agents/context-builder.md +1 -1
- package/agents/doc-reviewer.md +43 -0
- package/agents/explorer.md +1 -1
- package/agents/oracle.md +1 -1
- package/agents/orchestrator.md +1 -1
- package/agents/planner.md +1 -1
- package/agents/researcher.md +1 -1
- package/agents/reviewer.md +1 -1
- package/package.json +3 -3
- package/skills/workflow-script-format/SKILL.md +42 -0
- package/src/execution/__tests__/agent-registry.test.ts +7 -7
- package/src/interface/__tests__/detectors.test.ts +28 -0
- package/src/interface/tool-workflow.ts +10 -5
- package/src/orchestration/__tests__/review-fix-loop-e2e.test.ts +817 -0
- package/src/orchestration/__tests__/script-lint.test.ts +318 -0
- package/src/orchestration/script-lint.ts +235 -15
- package/workflows/README.md +6 -3
- package/workflows/review-fix-loop-utils.cjs +840 -0
- package/workflows/review-fix-loop.js +580 -289
|
@@ -0,0 +1,817 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* review-fix-loop E2E(真实 worker thread + 场景化 mock LLM runner)
|
|
3
|
+
*
|
|
4
|
+
* §7.2 行为/E2E 测试(设计文档 7.2 三条):
|
|
5
|
+
* 1. R2 prompt 对账段断言 + defer 跨轮传递 mock 剧本(E2E-1)
|
|
6
|
+
* 2. skipCleanAgents 语义 + fixAgent 参数接受(E2E-2)
|
|
7
|
+
* 3. 渲染 gate:非 clean 终止 message 的 [UNRESOLVED] 透出 + ES3 硬校验拦截(E2E-3)
|
|
8
|
+
* 4. M2 回归:全 fixed + 新发现 → reconcile 门控(reconCount)+ 新发现 merge 独立执行(E2E-4)
|
|
9
|
+
* 5. M4 回归:recheckAfterFix=true → 全批重派 + clean agent 走 scoped 分支(E2E-5)
|
|
10
|
+
* 6. F1 回归:doc-reviewer-only 批(reconciliation 恒空)→ merge 重新报告转换 → needs-redesign(E2E-6)
|
|
11
|
+
*
|
|
12
|
+
* 与 workflows-e2e.test.ts 同模式:真实 runAndWait + 真实 worker thread +
|
|
13
|
+
* 唯一 mock 是 deps.runner(AgentRunner)。runner 按调用分流:
|
|
14
|
+
* - schema 含 must_fix_ids → aggregator
|
|
15
|
+
* - schema 含 fixed_count → fix
|
|
16
|
+
* - 其余 → review agent
|
|
17
|
+
* 剧本按调用序返回固定结构化数据;R2 review 会检查 prompt 内容(defer 跨轮传递验证)。
|
|
18
|
+
*
|
|
19
|
+
* 已知限制:parallel 的 review 调用顺序不保证——剧本不依赖具体 agent 顺序
|
|
20
|
+
* (E2E-2 只断言调用总数,R1 中先到者 dirty 后到者 clean 均可)。
|
|
21
|
+
*/
|
|
22
|
+
import { existsSync, mkdtempSync, readdirSync, readFileSync, rmSync } from "node:fs";
|
|
23
|
+
import { tmpdir } from "node:os";
|
|
24
|
+
import { dirname, join } from "node:path";
|
|
25
|
+
import { fileURLToPath } from "node:url";
|
|
26
|
+
|
|
27
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
28
|
+
|
|
29
|
+
import { JsonlRunStore } from "../jsonl-run-store.ts";
|
|
30
|
+
import { type LauncherDeps, runAndWait } from "../launcher.ts";
|
|
31
|
+
import type { LifecycleDeps } from "../models/ports.ts";
|
|
32
|
+
import type { AgentRunner } from "../models/ports.ts";
|
|
33
|
+
import type { AgentResult, AgentUsage } from "../models/types.ts";
|
|
34
|
+
import {
|
|
35
|
+
type WorkflowMeta,
|
|
36
|
+
WorkflowScript,
|
|
37
|
+
type WorkflowSource,
|
|
38
|
+
} from "../models/workflow-script.ts";
|
|
39
|
+
import type { WorkflowScriptRegistry } from "../models/workflow-script-registry.ts";
|
|
40
|
+
import { WorkerHostImpl } from "../worker-host.ts";
|
|
41
|
+
|
|
42
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
43
|
+
const WORKFLOWS_DIR = join(__dirname, "..", "..", "..", "workflows");
|
|
44
|
+
|
|
45
|
+
let sessionDir: string;
|
|
46
|
+
let createdStores: JsonlRunStore[] = [];
|
|
47
|
+
|
|
48
|
+
const MOCK_USAGE: AgentUsage = {
|
|
49
|
+
input: 10,
|
|
50
|
+
output: 5,
|
|
51
|
+
cacheRead: 0,
|
|
52
|
+
cacheWrite: 0,
|
|
53
|
+
cost: 0,
|
|
54
|
+
contextTokens: 15,
|
|
55
|
+
turns: 1,
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
type JsonSchema = {
|
|
59
|
+
type?: string;
|
|
60
|
+
properties?: Record<string, JsonSchema>;
|
|
61
|
+
items?: JsonSchema;
|
|
62
|
+
oneOf?: JsonSchema[];
|
|
63
|
+
required?: string[];
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* 轻量 schema 契约校验(m8):递归校验 parsed 是否符合 opts.schema,防止 mock runner
|
|
68
|
+
* 绕过权威 ajv 校验掩盖「实现与契约脱节」(severity 对象被拒、report_content 丢失等
|
|
69
|
+
* 事故)。支持 type/oneOf/required/properties/items;description 等无关键忽略,
|
|
70
|
+
* 多出的属性不报错。校验失败抛 Error(测试立即失败,message 含 SCHEMA CONTRACT VIOLATION)。
|
|
71
|
+
*/
|
|
72
|
+
function miniValidator(schema: unknown, value: unknown, path: string): void {
|
|
73
|
+
if (!schema || typeof schema !== "object" || Array.isArray(schema)) return; // 无契约不校验
|
|
74
|
+
const s = schema as JsonSchema;
|
|
75
|
+
if (Array.isArray(s.oneOf) && s.oneOf.length > 0) {
|
|
76
|
+
const anyPass = s.oneOf.some((alt) => {
|
|
77
|
+
try {
|
|
78
|
+
miniValidator(alt, value, path);
|
|
79
|
+
return true;
|
|
80
|
+
} catch {
|
|
81
|
+
return false;
|
|
82
|
+
}
|
|
83
|
+
});
|
|
84
|
+
if (!anyPass) throw new Error(`SCHEMA CONTRACT VIOLATION: ${path} (no oneOf branch matched)`);
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
if (s.type) {
|
|
88
|
+
const ok =
|
|
89
|
+
(s.type === "string" && typeof value === "string") ||
|
|
90
|
+
(s.type === "number" && typeof value === "number") ||
|
|
91
|
+
(s.type === "integer" && typeof value === "number" && Number.isInteger(value)) ||
|
|
92
|
+
(s.type === "boolean" && typeof value === "boolean") ||
|
|
93
|
+
(s.type === "object" && value !== null && typeof value === "object" && !Array.isArray(value)) ||
|
|
94
|
+
(s.type === "array" && Array.isArray(value));
|
|
95
|
+
if (!ok) throw new Error(`SCHEMA CONTRACT VIOLATION: ${path} (expected ${s.type})`);
|
|
96
|
+
}
|
|
97
|
+
const rec = value as Record<string, unknown> | null;
|
|
98
|
+
if (Array.isArray(s.required) && rec !== null && typeof rec === "object") {
|
|
99
|
+
for (const key of s.required) {
|
|
100
|
+
if (!(key in rec)) throw new Error(`SCHEMA CONTRACT VIOLATION: ${path}.${key} (missing required property)`);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
if (s.properties && rec !== null && typeof rec === "object" && !Array.isArray(rec)) {
|
|
104
|
+
for (const [key, sub] of Object.entries(s.properties)) {
|
|
105
|
+
if (rec[key] !== undefined) miniValidator(sub, rec[key], `${path}.${key}`);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
if (s.items && Array.isArray(value)) {
|
|
109
|
+
value.forEach((item, i) => miniValidator(s.items, item, `${path}[${i}]`));
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** 按 schema 形状识别调用阶段(review / aggregator / fix)。 */
|
|
114
|
+
function classifyCall(opts: { schema?: unknown }): "review" | "aggregate" | "fix" {
|
|
115
|
+
const props = (opts.schema as JsonSchema | undefined)?.properties ?? {};
|
|
116
|
+
if ("must_fix_ids" in props) return "aggregate";
|
|
117
|
+
if ("fixed_count" in props) return "fix";
|
|
118
|
+
return "review";
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* 对 worker 返回的不可信 scriptResult 做最小运行时形状校验(S-16):
|
|
123
|
+
* workflow 脚本是 JS(无 TS 类型),scriptResult 形状不受静态约束,直接类型断言
|
|
124
|
+
* 会在结构漂移时掩盖真实形状。guard 校验 terminated 为 string(所有终止路径必含),
|
|
125
|
+
* 再返回带可选字段的 view;其余字段由断言侧校验存在性。
|
|
126
|
+
*/
|
|
127
|
+
function assertScriptOutcome(scriptResult: unknown): {
|
|
128
|
+
terminated: string;
|
|
129
|
+
totalFixed?: number;
|
|
130
|
+
message?: string;
|
|
131
|
+
runDir?: string;
|
|
132
|
+
} {
|
|
133
|
+
const raw = scriptResult as Record<string, unknown> | null | undefined;
|
|
134
|
+
if (raw === null || typeof raw !== "object") {
|
|
135
|
+
throw new Error(`scriptResult 缺失或非对象(worker 输出形状漂移): ${JSON.stringify(scriptResult)}`);
|
|
136
|
+
}
|
|
137
|
+
if (typeof raw.terminated !== "string") {
|
|
138
|
+
throw new Error(`scriptResult.terminated 非 string(worker 输出形状漂移): ${JSON.stringify(raw.terminated)}`);
|
|
139
|
+
}
|
|
140
|
+
return raw as { terminated: string; totalFixed?: number; message?: string; runDir?: string };
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
interface Scenario {
|
|
144
|
+
/** review 调用序 → 返回数据生成器;R2+ 回调收到 prompt 文本(可用于对账/传递断言)。 */
|
|
145
|
+
review: Array<(prompt: string) => Record<string, unknown>>;
|
|
146
|
+
aggregate: () => Record<string, unknown>;
|
|
147
|
+
fix: () => Record<string, unknown>;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* 场景化 mock runner:记录每次调用的 prompt 与分类,按剧本返回。
|
|
152
|
+
* R2 review 调用(剧本元素)若返回含 must_fix: 9 表示"断言失败"信号(测试可见)。
|
|
153
|
+
*/
|
|
154
|
+
function makeScenarioRunner(scenario: Scenario) {
|
|
155
|
+
const reviewCalls: Array<{ prompt: string; result: Record<string, unknown> }> = [];
|
|
156
|
+
const calls: Array<{ kind: "review" | "aggregate" | "fix"; prompt: string; agent?: string }> = [];
|
|
157
|
+
const run = vi.fn(async (opts: { prompt?: string; schema?: unknown; agent?: string }): Promise<AgentResult> => {
|
|
158
|
+
const kind = classifyCall(opts);
|
|
159
|
+
const prompt = opts.prompt ?? "";
|
|
160
|
+
// m5:记录 agent 字段(review/fix 派发验证)。内置名(reviewer/doc-reviewer)走
|
|
161
|
+
// def.name,自定义 .md agent 为 undefined——只断言 fix 调用的(fixAgent 派发)。
|
|
162
|
+
calls.push({ kind, prompt, agent: opts.agent });
|
|
163
|
+
let parsed: unknown = null;
|
|
164
|
+
if (kind === "review") {
|
|
165
|
+
const idx = reviewCalls.length;
|
|
166
|
+
const gen = scenario.review[Math.min(idx, scenario.review.length - 1)];
|
|
167
|
+
const result = gen(prompt);
|
|
168
|
+
reviewCalls.push({ prompt, result });
|
|
169
|
+
parsed = result;
|
|
170
|
+
} else if (kind === "aggregate") {
|
|
171
|
+
parsed = scenario.aggregate();
|
|
172
|
+
} else {
|
|
173
|
+
parsed = scenario.fix();
|
|
174
|
+
}
|
|
175
|
+
// m8:schema 契约校验——mock 返回的 parsedOutput 必须符合 workflow 声明的权威 schema
|
|
176
|
+
// (防止未来 schema 收紧时 E2E 仍绿)。校验失败抛错让测试立即失败。
|
|
177
|
+
miniValidator(opts.schema, parsed, "parsedOutput");
|
|
178
|
+
return {
|
|
179
|
+
content: "mock",
|
|
180
|
+
parsedOutput: parsed,
|
|
181
|
+
usage: MOCK_USAGE,
|
|
182
|
+
durationMs: 1,
|
|
183
|
+
error: undefined,
|
|
184
|
+
};
|
|
185
|
+
});
|
|
186
|
+
return {
|
|
187
|
+
run,
|
|
188
|
+
stats: () => ({
|
|
189
|
+
reviewCalls,
|
|
190
|
+
kinds: calls.map((c) => c.kind),
|
|
191
|
+
prompts: calls.map((c) => c.prompt),
|
|
192
|
+
agents: calls.map((c) => c.agent),
|
|
193
|
+
}),
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// ── registry(与 workflows-e2e.test.ts 同模式:读文件构造 WorkflowScript) ──
|
|
198
|
+
|
|
199
|
+
function extractMeta(source: string, fallbackName: string): WorkflowMeta {
|
|
200
|
+
const metaPattern = /(?:export\s+)?const\s+meta\s*=\s*(\{[^]*?\});?\s*$/m;
|
|
201
|
+
const match = metaPattern.exec(source);
|
|
202
|
+
if (match) {
|
|
203
|
+
try {
|
|
204
|
+
const fn = new Function(`return (${match[1]});`);
|
|
205
|
+
const obj = fn();
|
|
206
|
+
if (obj && typeof obj === "object" && typeof obj.name === "string") {
|
|
207
|
+
return {
|
|
208
|
+
name: obj.name,
|
|
209
|
+
description: typeof obj.description === "string" ? obj.description : "",
|
|
210
|
+
phases: Array.isArray(obj.phases) ? obj.phases : [],
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
} catch {
|
|
214
|
+
// 提取失败 → fallback name(非测试关注点)
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
return { name: fallbackName, description: "", phases: [] };
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function loadWorkflowsFromDir(dir: string): Map<string, WorkflowScript> {
|
|
221
|
+
const scripts = new Map<string, WorkflowScript>();
|
|
222
|
+
for (const file of readdirSync(dir)) {
|
|
223
|
+
if (!file.endsWith(".js")) continue;
|
|
224
|
+
const fullPath = join(dir, file);
|
|
225
|
+
const sourceCode = readFileSync(fullPath, "utf-8");
|
|
226
|
+
const stem = file.replace(/\.js$/, "");
|
|
227
|
+
const meta = extractMeta(sourceCode, stem);
|
|
228
|
+
const source: WorkflowSource = "saved";
|
|
229
|
+
scripts.set(
|
|
230
|
+
meta.name,
|
|
231
|
+
new WorkflowScript({
|
|
232
|
+
name: meta.name,
|
|
233
|
+
source,
|
|
234
|
+
path: fullPath,
|
|
235
|
+
sourceCode,
|
|
236
|
+
meta,
|
|
237
|
+
available: true,
|
|
238
|
+
}),
|
|
239
|
+
);
|
|
240
|
+
}
|
|
241
|
+
return scripts;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function makeRegistry(scripts: Map<string, WorkflowScript>): WorkflowScriptRegistry {
|
|
245
|
+
return {
|
|
246
|
+
get: async (name: string) => scripts.get(name),
|
|
247
|
+
loadAll: async () => Array.from(scripts.values()),
|
|
248
|
+
invalidate: () => {},
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function makeDeps(runner: AgentRunner): LauncherDeps {
|
|
253
|
+
const scripts = loadWorkflowsFromDir(WORKFLOWS_DIR);
|
|
254
|
+
const registry = makeRegistry(scripts);
|
|
255
|
+
const store = new JsonlRunStore({ sessionDir });
|
|
256
|
+
createdStores.push(store);
|
|
257
|
+
const base: LifecycleDeps = {
|
|
258
|
+
store,
|
|
259
|
+
workerHost: new WorkerHostImpl(),
|
|
260
|
+
runner,
|
|
261
|
+
runs: new Map(),
|
|
262
|
+
};
|
|
263
|
+
return { ...base, registry };
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
beforeEach(() => {
|
|
267
|
+
sessionDir = mkdtempSync(join(tmpdir(), "rfl-e2e-"));
|
|
268
|
+
createdStores = [];
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
afterEach(() => {
|
|
272
|
+
try {
|
|
273
|
+
rmSync(sessionDir, { recursive: true, force: true });
|
|
274
|
+
} catch {
|
|
275
|
+
// 临时目录清理失败不影响测试结论
|
|
276
|
+
}
|
|
277
|
+
sessionDir = "";
|
|
278
|
+
createdStores = [];
|
|
279
|
+
vi.restoreAllMocks();
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
const RUN_TIMEOUT_MS = 60_000;
|
|
283
|
+
const RUN_ID = () => "rfl-e2e-" + Date.now() + "-" + Math.floor(Math.random() * 1e6);
|
|
284
|
+
|
|
285
|
+
describe("review-fix-loop E2E(真实 worker + 场景化 mock runner)", () => {
|
|
286
|
+
it("sanity: chain 经本文件基础设施可跑(helper 自检)", async () => {
|
|
287
|
+
// 返回超集对象同时满足 chain 三段 schema(analyze/transform/synthesize 均被分类为 review)
|
|
288
|
+
const runner = makeScenarioRunner({
|
|
289
|
+
review: [() => ({ insights: "i", keyPoints: [], plan: "p", actions: [], summary: "s", recommendation: "r" })],
|
|
290
|
+
aggregate: () => ({}),
|
|
291
|
+
fix: () => ({}),
|
|
292
|
+
});
|
|
293
|
+
const deps = makeDeps(runner);
|
|
294
|
+
const result = await runAndWait("chain", { task: "x" }, deps, undefined, RUN_TIMEOUT_MS);
|
|
295
|
+
expect(result.reason).toBe("completed");
|
|
296
|
+
});
|
|
297
|
+
it(
|
|
298
|
+
"E2E-1:defer 跨轮传递 + R2 prompt 对账段 + clean 终止(§7.2 1/3)",
|
|
299
|
+
async () => {
|
|
300
|
+
const runner = makeScenarioRunner({
|
|
301
|
+
review: [
|
|
302
|
+
// R1:1 must-fix + 1 suggestion
|
|
303
|
+
() => ({ report_file: "/tmp/r1-reviewer.md", must_fix: 1, suggestion: 1, reconciliation: [] }),
|
|
304
|
+
// R2:known-remaining 必须含 S-1(fix 阶段 deferred 写入 → 同步 knownRemaining);
|
|
305
|
+
// 对账 MF-1 已修。若 prompt 缺 S-1 → must_fix=9 使测试失败可见。
|
|
306
|
+
(prompt) => {
|
|
307
|
+
if (!prompt.includes("S-1")) {
|
|
308
|
+
return { report_file: "/tmp/r2-reviewer.md", must_fix: 9, suggestion: 0, reconciliation: [] };
|
|
309
|
+
}
|
|
310
|
+
return {
|
|
311
|
+
report_file: "/tmp/r2-reviewer.md", must_fix: 0, suggestion: 0,
|
|
312
|
+
reconciliation: [{ prev_id: "MF-1", status: "fixed", evidence: "read confirmed" }],
|
|
313
|
+
};
|
|
314
|
+
},
|
|
315
|
+
],
|
|
316
|
+
aggregate: () => ({
|
|
317
|
+
report_file: "/tmp/agg.md", must_fix: 1, suggestion: 1,
|
|
318
|
+
must_fix_ids: [{ id: "MF-1", severity: "major" }], fixes_caution: [],
|
|
319
|
+
}),
|
|
320
|
+
fix: () => ({
|
|
321
|
+
fixed_count: 1,
|
|
322
|
+
fixes: [{ issue_id: "MF-1", description: "mock fix", self_check: "grep: 1 hit; synced", affected_files: ["src/a.ts"] }],
|
|
323
|
+
deferred: [{ issue_id: "S-1", severity: "minor", reason: "needs new mechanism across modules, high cost" }],
|
|
324
|
+
}),
|
|
325
|
+
});
|
|
326
|
+
const deps = makeDeps(runner);
|
|
327
|
+
|
|
328
|
+
const result = await runAndWait(
|
|
329
|
+
"review-fix-loop",
|
|
330
|
+
{ targetType: "file", target: "README.md", agents: "reviewer", _runId: RUN_ID() },
|
|
331
|
+
deps,
|
|
332
|
+
undefined,
|
|
333
|
+
RUN_TIMEOUT_MS,
|
|
334
|
+
);
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
expect(result.reason).toBe("completed");
|
|
338
|
+
expect(result.error).toBeUndefined();
|
|
339
|
+
const outcome = assertScriptOutcome(result.scriptResult);
|
|
340
|
+
expect(outcome.terminated).toBe("clean");
|
|
341
|
+
expect(outcome.totalFixed).toBe(1);
|
|
342
|
+
expect(outcome.message).toContain("All batches clean");
|
|
343
|
+
|
|
344
|
+
// prompt 内容断言:R1 全量深挖(Round 1)→ fix 分流段 → R2 对账段 + known-remaining
|
|
345
|
+
const { prompts, kinds, reviewCalls } = runner.stats();
|
|
346
|
+
const reviewPrompts = prompts.filter((_, i) => kinds[i] === "review");
|
|
347
|
+
expect(reviewPrompts[0]).toContain("Round 1");
|
|
348
|
+
expect(reviewPrompts[1]).toContain("RECONCILE PREVIOUS ROUND");
|
|
349
|
+
expect(reviewPrompts[1]).toContain("S-1"); // 5.3-4 deferred 跨轮继承
|
|
350
|
+
// m6:aggregator prompt 裁决段(5.4 ADJUDICATION)——裁决证据/降级保真/采信抽查
|
|
351
|
+
const aggPrompt = prompts[kinds.indexOf("aggregate")];
|
|
352
|
+
expect(aggPrompt).toContain("ADJUDICATION");
|
|
353
|
+
// m6:fix prompt 分流文案(trivial 直接修 / involved 标记 deferred)+ 自检要求
|
|
354
|
+
const fixPrompt = prompts[kinds.indexOf("fix")];
|
|
355
|
+
expect(fixPrompt).toContain("Fix scope");
|
|
356
|
+
expect(fixPrompt).toContain("fix trivial ones");
|
|
357
|
+
expect(fixPrompt).toContain("mark involved ones as deferred");
|
|
358
|
+
expect(fixPrompt).toContain("self_check in each fixes[] entry MUST include");
|
|
359
|
+
expect(fixPrompt).toContain("grep command + hit count + sync action");
|
|
360
|
+
expect(reviewCalls.length).toBe(2);
|
|
361
|
+
},
|
|
362
|
+
RUN_TIMEOUT_MS,
|
|
363
|
+
);
|
|
364
|
+
|
|
365
|
+
it(
|
|
366
|
+
"E2E-2:skipCleanAgents 语义(clean agent 下轮跳过)+ fixAgent 参数接受(§7.2 2/3)",
|
|
367
|
+
async () => {
|
|
368
|
+
const runner = makeScenarioRunner({
|
|
369
|
+
review: [
|
|
370
|
+
// R1 两个 agent(顺序不定):reviewer → dirty(2);doc-reviewer → clean(0)。
|
|
371
|
+
// doc-reviewer 走 schema-only 真实形态(M3 回归):无 write 工具 → report_file="" +
|
|
372
|
+
// report_content 返回正文,由 workflow 落盘。按 prompt 内的报告路径区分 agent
|
|
373
|
+
// (顺序无关:两 generator 对同一 agent 返回同一结果)。
|
|
374
|
+
(prompt) => prompt.includes("doc-reviewer.md")
|
|
375
|
+
? { report_content: "# doc-reviewer report\nPass 1 完成", report_file: "", must_fix: 0, suggestion: 0, reconciliation: [] }
|
|
376
|
+
: { report_file: "/tmp/r1a.md", must_fix: 2, suggestion: 0, reconciliation: [] },
|
|
377
|
+
(prompt) => prompt.includes("doc-reviewer.md")
|
|
378
|
+
? { report_content: "# doc-reviewer report\nPass 1 完成", report_file: "", must_fix: 0, suggestion: 0, reconciliation: [] }
|
|
379
|
+
: { report_file: "/tmp/r1b.md", must_fix: 2, suggestion: 0, reconciliation: [] },
|
|
380
|
+
// R2:仅 dirty agent 被重派(clean 被 skipCleanAgents 过滤);返回 clean → 终止。
|
|
381
|
+
// 若 skip 失效,会出现第 4 次 review 调用(断言总数=3 拦截)。
|
|
382
|
+
() => ({
|
|
383
|
+
report_file: "/tmp/r2.md", must_fix: 0, suggestion: 0,
|
|
384
|
+
reconciliation: [{ prev_id: "MF-1", status: "fixed", evidence: "read" }, { prev_id: "MF-2", status: "fixed", evidence: "read" }],
|
|
385
|
+
}),
|
|
386
|
+
],
|
|
387
|
+
aggregate: () => ({
|
|
388
|
+
report_file: "/tmp/agg.md", must_fix: 2, suggestion: 0,
|
|
389
|
+
must_fix_ids: [{ id: "MF-1", severity: "major" }, { id: "MF-2", severity: "major" }], fixes_caution: [],
|
|
390
|
+
}),
|
|
391
|
+
fix: () => ({
|
|
392
|
+
fixed_count: 2,
|
|
393
|
+
fixes: [
|
|
394
|
+
{ issue_id: "MF-1", description: "fix1", self_check: "grep: 1 hit", affected_files: [] },
|
|
395
|
+
{ issue_id: "MF-2", description: "fix2", self_check: "grep: 1 hit", affected_files: [] },
|
|
396
|
+
],
|
|
397
|
+
deferred: [],
|
|
398
|
+
}),
|
|
399
|
+
});
|
|
400
|
+
const deps = makeDeps(runner);
|
|
401
|
+
|
|
402
|
+
const result = await runAndWait(
|
|
403
|
+
"review-fix-loop",
|
|
404
|
+
{ targetType: "file", target: "README.md", agents: "reviewer,doc-reviewer", fixAgent: "reviewer", _runId: RUN_ID() },
|
|
405
|
+
deps,
|
|
406
|
+
undefined,
|
|
407
|
+
RUN_TIMEOUT_MS,
|
|
408
|
+
);
|
|
409
|
+
|
|
410
|
+
expect(result.reason).toBe("completed");
|
|
411
|
+
expect(result.error).toBeUndefined();
|
|
412
|
+
const outcome = assertScriptOutcome(result.scriptResult);
|
|
413
|
+
expect(outcome.terminated).toBe("clean");
|
|
414
|
+
expect(outcome.totalFixed).toBe(2);
|
|
415
|
+
|
|
416
|
+
// skipCleanAgents:R1 两 review + R2 一 review = 3;skip 失效则为 4
|
|
417
|
+
const { kinds, reviewCalls, agents } = runner.stats();
|
|
418
|
+
expect(reviewCalls.length).toBe(3);
|
|
419
|
+
expect(kinds.filter((k) => k === "fix").length).toBe(1);
|
|
420
|
+
expect(kinds.filter((k) => k === "aggregate").length).toBe(1);
|
|
421
|
+
// m5:fixAgent 派发验证——fix 调用带 agent: "reviewer"(review 调用只记录不断言:
|
|
422
|
+
// 内置名走 def.name,自定义 .md agent 为 undefined)
|
|
423
|
+
const fixIdx = kinds.indexOf("fix");
|
|
424
|
+
expect(agents[fixIdx]).toBe("reviewer");
|
|
425
|
+
// M3 直接证据:doc-reviewer(schema-only,report_file="")报告经 report_content
|
|
426
|
+
// 落盘到 <runDir>/batch-1/round-1/doc-reviewer.md(def.report 文件名 = 内置名剥离
|
|
427
|
+
// review- 前缀,resolveAgentDefs 默认分支)。修复前 normalizeReviewResult 丢弃
|
|
428
|
+
// report_content → 落盘内容为空文件,本断言失败。
|
|
429
|
+
const docReportPath = join(outcome.runDir, "batch-1", "round-1", "doc-reviewer.md");
|
|
430
|
+
expect(existsSync(docReportPath)).toBe(true);
|
|
431
|
+
expect(readFileSync(docReportPath, "utf-8")).toContain("doc-reviewer report");
|
|
432
|
+
},
|
|
433
|
+
RUN_TIMEOUT_MS,
|
|
434
|
+
);
|
|
435
|
+
|
|
436
|
+
it(
|
|
437
|
+
"E2E-3:ES3 硬校验拦截(deferred critical → fix-failure)+ [UNRESOLVED] 渲染 gate(§7.2 3/3)",
|
|
438
|
+
async () => {
|
|
439
|
+
const runner = makeScenarioRunner({
|
|
440
|
+
review: [
|
|
441
|
+
() => ({ report_file: "/tmp/r1.md", must_fix: 1, suggestion: 0, reconciliation: [] }),
|
|
442
|
+
],
|
|
443
|
+
aggregate: () => ({
|
|
444
|
+
report_file: "/tmp/agg.md", must_fix: 1, suggestion: 0,
|
|
445
|
+
must_fix_ids: [{ id: "MF-1", severity: "critical" }], fixes_caution: [],
|
|
446
|
+
}),
|
|
447
|
+
// 红线违反:must-fix 被 defer 且标 critical
|
|
448
|
+
fix: () => ({
|
|
449
|
+
fixed_count: 0,
|
|
450
|
+
fixes: [],
|
|
451
|
+
deferred: [{ issue_id: "MF-1", severity: "critical", reason: "cannot fix in this round" }],
|
|
452
|
+
}),
|
|
453
|
+
});
|
|
454
|
+
const deps = makeDeps(runner);
|
|
455
|
+
|
|
456
|
+
const result = await runAndWait(
|
|
457
|
+
"review-fix-loop",
|
|
458
|
+
{ targetType: "file", target: "README.md", agents: "reviewer", _runId: RUN_ID() },
|
|
459
|
+
deps,
|
|
460
|
+
undefined,
|
|
461
|
+
RUN_TIMEOUT_MS,
|
|
462
|
+
);
|
|
463
|
+
|
|
464
|
+
expect(result.reason).toBe("completed"); // 结构化终止而非抛错
|
|
465
|
+
expect(result.error).toBeUndefined();
|
|
466
|
+
const outcome = assertScriptOutcome(result.scriptResult);
|
|
467
|
+
expect(outcome.terminated).toBe("fix-failure");
|
|
468
|
+
// 渲染 gate(5.9):非 clean 终止 message 带 [UNRESOLVED] 前缀 + 残留原因
|
|
469
|
+
expect(outcome.message).toContain("[UNRESOLVED]");
|
|
470
|
+
expect(outcome.message).toContain("must-fix 不得 defer");
|
|
471
|
+
expect(outcome.message).toContain("MF-1");
|
|
472
|
+
},
|
|
473
|
+
RUN_TIMEOUT_MS,
|
|
474
|
+
);
|
|
475
|
+
|
|
476
|
+
it(
|
|
477
|
+
"E2E-4:全 fixed + 新发现(M2:reconcile 门控 reconCount + 新发现 merge 独立执行)",
|
|
478
|
+
async () => {
|
|
479
|
+
// M2 回归场景:R2 所有 prev ID 声明 fixed(reconSeen 空)但仍有新发现 MF-2。
|
|
480
|
+
// 修复前:reconcile 分支整体跳过 → MF-1 停留 fix-attempted(永不转 fixed)、
|
|
481
|
+
// MF-2(新发现 merge 在分支内)不创建 → 残留清单含 MF-1 而非 MF-2。
|
|
482
|
+
// 修复后:reconCount>0 触发 reconcileIssues → MF-1 转 fixed;新发现 merge 独立
|
|
483
|
+
// 于 reconcile 执行 → MF-2 创建 → 残留仅 MF-2(max-rounds 终止)。
|
|
484
|
+
let aggRound = 0;
|
|
485
|
+
let fixRound = 0;
|
|
486
|
+
const runner = makeScenarioRunner({
|
|
487
|
+
review: [
|
|
488
|
+
// R1:1 must-fix
|
|
489
|
+
() => ({ report_file: "/tmp/r1-reviewer.md", must_fix: 1, suggestion: 0, reconciliation: [] }),
|
|
490
|
+
// R2:MF-1 声明 fixed(全 fixed → reconSeen 空)+ 1 新发现;走 R2+ 分支
|
|
491
|
+
(prompt) => {
|
|
492
|
+
if (!prompt.includes("RECONCILE PREVIOUS ROUND")) {
|
|
493
|
+
return { report_file: "/tmp/r2-reviewer.md", must_fix: 9, suggestion: 0, reconciliation: [] };
|
|
494
|
+
}
|
|
495
|
+
return {
|
|
496
|
+
report_file: "/tmp/r2-reviewer.md", must_fix: 1, suggestion: 0,
|
|
497
|
+
reconciliation: [{ prev_id: "MF-1", status: "fixed", evidence: "read confirmed" }],
|
|
498
|
+
};
|
|
499
|
+
},
|
|
500
|
+
],
|
|
501
|
+
aggregate: () => {
|
|
502
|
+
aggRound++;
|
|
503
|
+
return {
|
|
504
|
+
report_file: "/tmp/agg.md", must_fix: 1, suggestion: 0,
|
|
505
|
+
// R1 只含 MF-1;R2 中 MF-1 已被 reconciliation 声明 fixed,must_fix 只剩新发现 MF-2
|
|
506
|
+
must_fix_ids: aggRound === 1 ? [{ id: "MF-1", severity: "major" }] : [{ id: "MF-2", severity: "major" }],
|
|
507
|
+
fixes_caution: [],
|
|
508
|
+
};
|
|
509
|
+
},
|
|
510
|
+
fix: () => {
|
|
511
|
+
fixRound++;
|
|
512
|
+
return {
|
|
513
|
+
fixed_count: 1,
|
|
514
|
+
fixes: [{
|
|
515
|
+
issue_id: fixRound === 1 ? "MF-1" : "MF-2",
|
|
516
|
+
description: "mock fix",
|
|
517
|
+
self_check: "grep: 1 hit; synced",
|
|
518
|
+
affected_files: [],
|
|
519
|
+
}],
|
|
520
|
+
deferred: [],
|
|
521
|
+
};
|
|
522
|
+
},
|
|
523
|
+
});
|
|
524
|
+
const deps = makeDeps(runner);
|
|
525
|
+
|
|
526
|
+
const result = await runAndWait(
|
|
527
|
+
"review-fix-loop",
|
|
528
|
+
{ targetType: "file", target: "README.md", agents: "reviewer", maxRounds: 2, _runId: RUN_ID() },
|
|
529
|
+
deps,
|
|
530
|
+
undefined,
|
|
531
|
+
RUN_TIMEOUT_MS,
|
|
532
|
+
);
|
|
533
|
+
|
|
534
|
+
expect(result.reason).toBe("completed");
|
|
535
|
+
expect(result.error).toBeUndefined();
|
|
536
|
+
const outcome = assertScriptOutcome(result.scriptResult);
|
|
537
|
+
// R2 后仍有 must-fix(MF-2 新发现)且达到 maxRounds → max-rounds 终止
|
|
538
|
+
expect(outcome.terminated).toBe("max-rounds");
|
|
539
|
+
expect(outcome.totalFixed).toBe(2);
|
|
540
|
+
// M2 直接可观测证据:MF-1 已被 reconcile 转 fixed → 不进残留清单;MF-2 在残留中
|
|
541
|
+
// (修复前:MF-1 停留 fix-attempted 会出现在残留里、MF-2 不创建 → 本断言失败)
|
|
542
|
+
expect(outcome.message).toContain("MF-2");
|
|
543
|
+
expect(outcome.message).not.toContain("MF-1");
|
|
544
|
+
|
|
545
|
+
// 两轮 review 各 1 次调用(maxRounds=2 未超轮);aggregator/fix 各 2 次
|
|
546
|
+
const { reviewCalls, kinds } = runner.stats();
|
|
547
|
+
expect(reviewCalls.length).toBe(2);
|
|
548
|
+
expect(kinds.filter((k) => k === "aggregate").length).toBe(2);
|
|
549
|
+
expect(kinds.filter((k) => k === "fix").length).toBe(2);
|
|
550
|
+
},
|
|
551
|
+
RUN_TIMEOUT_MS,
|
|
552
|
+
);
|
|
553
|
+
|
|
554
|
+
it(
|
|
555
|
+
"E2E-5:recheckAfterFix=true → 全批重派 + clean agent 走 scoped 分支(M4 回归)",
|
|
556
|
+
async () => {
|
|
557
|
+
// R1:reviewer dirty + doc-reviewer clean → fix 后 R2 全批重派(强回归模式),
|
|
558
|
+
// doc-reviewer(上轮 clean)走 scoped 限定分支(modifiedFiles ∪ affectedFiles)。
|
|
559
|
+
// M4 修复目标:scoped 分支的 lastModifiedFiles 在批内可读(state.lastModifiedFiles
|
|
560
|
+
// 即时字段)——修复前读 state.batches(批内未 push)恒空。
|
|
561
|
+
const runner = makeScenarioRunner({
|
|
562
|
+
review: [
|
|
563
|
+
// R1 两 agent(parallel 顺序不定):按 prompt 内报告路径区分
|
|
564
|
+
(prompt) => prompt.includes("doc-reviewer.md")
|
|
565
|
+
? { report_content: "# doc-reviewer report", report_file: "", must_fix: 0, suggestion: 0, reconciliation: [] }
|
|
566
|
+
: { report_file: "/tmp/r1a.md", must_fix: 2, suggestion: 0, reconciliation: [] },
|
|
567
|
+
(prompt) => prompt.includes("doc-reviewer.md")
|
|
568
|
+
? { report_content: "# doc-reviewer report", report_file: "", must_fix: 0, suggestion: 0, reconciliation: [] }
|
|
569
|
+
: { report_file: "/tmp/r1b.md", must_fix: 2, suggestion: 0, reconciliation: [] },
|
|
570
|
+
// R2 两个调用(reviewer 全量 R2+ / doc-reviewer scoped):全部 clean
|
|
571
|
+
() => ({ report_file: "/tmp/r2a.md", must_fix: 0, suggestion: 0, reconciliation: [{ prev_id: "MF-1", status: "fixed", evidence: "read" }, { prev_id: "MF-2", status: "fixed", evidence: "read" }] }),
|
|
572
|
+
() => ({ report_file: "/tmp/r2b.md", must_fix: 0, suggestion: 0, reconciliation: [] }),
|
|
573
|
+
],
|
|
574
|
+
aggregate: () => ({
|
|
575
|
+
report_file: "/tmp/agg.md", must_fix: 2, suggestion: 0,
|
|
576
|
+
must_fix_ids: [{ id: "MF-1", severity: "major" }, { id: "MF-2", severity: "major" }], fixes_caution: [],
|
|
577
|
+
}),
|
|
578
|
+
fix: () => ({
|
|
579
|
+
fixed_count: 2,
|
|
580
|
+
fixes: [
|
|
581
|
+
{ issue_id: "MF-1", description: "fix1", self_check: "grep: 1 hit", affected_files: ["src/x.ts"] },
|
|
582
|
+
{ issue_id: "MF-2", description: "fix2", self_check: "grep: 1 hit", affected_files: [] },
|
|
583
|
+
],
|
|
584
|
+
deferred: [],
|
|
585
|
+
}),
|
|
586
|
+
});
|
|
587
|
+
const deps = makeDeps(runner);
|
|
588
|
+
|
|
589
|
+
const result = await runAndWait(
|
|
590
|
+
"review-fix-loop",
|
|
591
|
+
{ targetType: "file", target: "README.md", agents: "reviewer,doc-reviewer", recheckAfterFix: true, _runId: RUN_ID() },
|
|
592
|
+
deps,
|
|
593
|
+
undefined,
|
|
594
|
+
RUN_TIMEOUT_MS,
|
|
595
|
+
);
|
|
596
|
+
|
|
597
|
+
expect(result.reason).toBe("completed");
|
|
598
|
+
expect(result.error).toBeUndefined();
|
|
599
|
+
const outcome = assertScriptOutcome(result.scriptResult);
|
|
600
|
+
expect(outcome.terminated).toBe("clean");
|
|
601
|
+
expect(outcome.totalFixed).toBe(2);
|
|
602
|
+
|
|
603
|
+
// R1 两 review + R2 两 review(全批重派)= 4;skipCleanAgents 被 recheckAfterFix 覆盖
|
|
604
|
+
const { prompts, kinds, reviewCalls } = runner.stats();
|
|
605
|
+
expect(reviewCalls.length).toBe(4);
|
|
606
|
+
// scoped 分支被触发:prompt 含 "Scoped recheck"(clean agent 限定重审)
|
|
607
|
+
const scopedPrompt = prompts.filter((p) => p.includes("Scoped recheck"));
|
|
608
|
+
expect(scopedPrompt.length).toBe(1);
|
|
609
|
+
// M4 数据通路:affectedFiles(fix 自检标注)进 scoped prompt
|
|
610
|
+
expect(scopedPrompt[0]).toContain("src/x.ts");
|
|
611
|
+
// scoped prompt 含 modifiedFiles 结构行(git 实测内容取决于测试环境工作区,
|
|
612
|
+
// 只断言结构存在——M4 修复的是数据源可读性)
|
|
613
|
+
expect(scopedPrompt[0]).toContain("Modified files:");
|
|
614
|
+
// R2+ 全量分支同时被触发(reviewer)
|
|
615
|
+
expect(prompts.some((p) => p.includes("RECONCILE PREVIOUS ROUND"))).toBe(true);
|
|
616
|
+
},
|
|
617
|
+
RUN_TIMEOUT_MS,
|
|
618
|
+
);
|
|
619
|
+
|
|
620
|
+
it(
|
|
621
|
+
"E2E-6:doc-reviewer-only 批(reconciliation 恒空)→ 重新报告转 regressed → needs-redesign(F1 回归)",
|
|
622
|
+
async () => {
|
|
623
|
+
// F1 场景:全部 agent 为 doc-reviewer(§5.8 推荐配置),reconciliation 恒空 →
|
|
624
|
+
// reconCount 恒 0。MF-1 连续 3 轮被重新报告(must_fix_ids 含 MF-1):
|
|
625
|
+
// 修复前:merge 跳过已存在 ID → fixAttempts 恒 0 → needs-redesign 不可达;
|
|
626
|
+
// newFindings 恒 0 → R3 触发 converged(streak 2)——提前终止掩盖未修复问题。
|
|
627
|
+
// 修复后:merge 把 fix-attempted 转 regressed + fixAttempts+1 → R3 时
|
|
628
|
+
// fixAttempts=2 → needs-redesign 终止(在 converged 之前,顺序正确)。
|
|
629
|
+
let aggRound = 0;
|
|
630
|
+
let fixRound = 0;
|
|
631
|
+
const runner = makeScenarioRunner({
|
|
632
|
+
review: [
|
|
633
|
+
// R1
|
|
634
|
+
() => ({ report_file: "/tmp/r1-dr.md", must_fix: 1, suggestion: 0, reconciliation: [] }),
|
|
635
|
+
// R2:MF-1 重新报告(未修复)
|
|
636
|
+
() => ({ report_file: "/tmp/r2-dr.md", must_fix: 1, suggestion: 0, reconciliation: [] }),
|
|
637
|
+
// R3:MF-1 再次报告(第 2 次修复失败)
|
|
638
|
+
() => ({ report_file: "/tmp/r3-dr.md", must_fix: 1, suggestion: 0, reconciliation: [] }),
|
|
639
|
+
],
|
|
640
|
+
aggregate: () => {
|
|
641
|
+
aggRound++;
|
|
642
|
+
return {
|
|
643
|
+
report_file: "/tmp/agg.md", must_fix: 1, suggestion: 0,
|
|
644
|
+
must_fix_ids: [{ id: "MF-1", severity: "major" }], fixes_caution: [],
|
|
645
|
+
};
|
|
646
|
+
},
|
|
647
|
+
fix: () => {
|
|
648
|
+
fixRound++;
|
|
649
|
+
return {
|
|
650
|
+
fixed_count: 1,
|
|
651
|
+
fixes: [{ issue_id: "MF-1", description: "fix " + fixRound, self_check: "grep: 1 hit; synced", affected_files: [] }],
|
|
652
|
+
deferred: [],
|
|
653
|
+
};
|
|
654
|
+
},
|
|
655
|
+
});
|
|
656
|
+
const deps = makeDeps(runner);
|
|
657
|
+
|
|
658
|
+
const result = await runAndWait(
|
|
659
|
+
"review-fix-loop",
|
|
660
|
+
{ targetType: "file", target: "README.md", agents: "doc-reviewer", _runId: RUN_ID() },
|
|
661
|
+
deps,
|
|
662
|
+
undefined,
|
|
663
|
+
RUN_TIMEOUT_MS,
|
|
664
|
+
);
|
|
665
|
+
|
|
666
|
+
expect(result.reason).toBe("completed");
|
|
667
|
+
expect(result.error).toBeUndefined();
|
|
668
|
+
const outcome = assertScriptOutcome(result.scriptResult);
|
|
669
|
+
// F1 判别:修复前此处是 converged(提前终止掩盖未修复);修复后 needs-redesign
|
|
670
|
+
expect(outcome.terminated).toBe("needs-redesign");
|
|
671
|
+
expect(outcome.message).toContain("MF-1");
|
|
672
|
+
expect(outcome.message).toContain("2 次修复仍未收敛");
|
|
673
|
+
|
|
674
|
+
const { reviewCalls } = runner.stats();
|
|
675
|
+
expect(reviewCalls.length).toBe(3); // R1/R2/R3 各一次,R3 终止不再续轮
|
|
676
|
+
},
|
|
677
|
+
RUN_TIMEOUT_MS,
|
|
678
|
+
);
|
|
679
|
+
|
|
680
|
+
it(
|
|
681
|
+
"E2E-7:fixed 条目复发 → 不收敛 → 继续修复 → needs-redesign(MF-2 回归)",
|
|
682
|
+
async () => {
|
|
683
|
+
// MF-2 场景(reconciliation 驱动):R1 报 MF-1 → R2 确认 fixed + 新发现 MF-2 →
|
|
684
|
+
// R3 MF-1 复发(reconciliation not-fixed)。修复前:fixed 条目复发不转换(停留
|
|
685
|
+
// fixed)→ R3 newFindings=0 收敛 streak 达 2 → terminated=converged 提前终止而
|
|
686
|
+
// must-fix 仍活跃;finalMessage「残留: 无 deferred」掩盖 MF-1。
|
|
687
|
+
// 修复后:R3 reconcile 把 MF-1 转 regressed(fixAttempts+1)→ 收敛门槛
|
|
688
|
+
// (无 open/regressed 活跃条目)拦截 → 继续 R4 → MF-1 第 2 次 regressed
|
|
689
|
+
// (fixAttempts=2)→ needs-redesign 终止(在 converged 之前,顺序正确)。
|
|
690
|
+
let aggRound = 0;
|
|
691
|
+
let fixRound = 0;
|
|
692
|
+
const runner = makeScenarioRunner({
|
|
693
|
+
review: [
|
|
694
|
+
// R1:MF-1 首次发现
|
|
695
|
+
() => ({ report_file: "/tmp/r1.md", must_fix: 1, suggestion: 0, reconciliation: [] }),
|
|
696
|
+
// R2:MF-1 确认 fixed + 新发现 MF-2
|
|
697
|
+
() => ({ report_file: "/tmp/r2.md", must_fix: 1, suggestion: 0, reconciliation: [{ prev_id: "MF-1", status: "fixed", evidence: "read confirmed" }] }),
|
|
698
|
+
// R3:MF-1 复发(修复前此处不转换 → converged 提前终止)+ MF-2 未修
|
|
699
|
+
() => ({ report_file: "/tmp/r3.md", must_fix: 2, suggestion: 0, reconciliation: [{ prev_id: "MF-1", status: "not-fixed", evidence: "still wrong" }, { prev_id: "MF-2", status: "not-fixed", evidence: "still wrong" }] }),
|
|
700
|
+
// R4:MF-1 再次复发(第 2 次 regressed → needs-redesign);MF-2 已修(转 fixed)
|
|
701
|
+
// ——MF-2 不再累计 openStreak,避免其先触达 stuckThreshold 抢先终止
|
|
702
|
+
() => ({ report_file: "/tmp/r4.md", must_fix: 1, suggestion: 0, reconciliation: [{ prev_id: "MF-1", status: "not-fixed", evidence: "still wrong" }, { prev_id: "MF-2", status: "fixed", evidence: "read confirmed" }] }),
|
|
703
|
+
],
|
|
704
|
+
aggregate: () => {
|
|
705
|
+
aggRound++;
|
|
706
|
+
if (aggRound === 1) return { report_file: "/tmp/agg.md", must_fix: 1, suggestion: 0, must_fix_ids: [{ id: "MF-1", severity: "major" }], fixes_caution: [] };
|
|
707
|
+
if (aggRound === 2) return { report_file: "/tmp/agg.md", must_fix: 1, suggestion: 0, must_fix_ids: [{ id: "MF-2", severity: "major" }], fixes_caution: [] };
|
|
708
|
+
if (aggRound === 3) return { report_file: "/tmp/agg.md", must_fix: 2, suggestion: 0, must_fix_ids: [{ id: "MF-1", severity: "major" }, { id: "MF-2", severity: "major" }], fixes_caution: [] };
|
|
709
|
+
return { report_file: "/tmp/agg.md", must_fix: 1, suggestion: 0, must_fix_ids: [{ id: "MF-1", severity: "major" }], fixes_caution: [] };
|
|
710
|
+
},
|
|
711
|
+
fix: () => {
|
|
712
|
+
fixRound++;
|
|
713
|
+
if (fixRound === 1) {
|
|
714
|
+
return { fixed_count: 1, fixes: [{ issue_id: "MF-1", description: "fix1", self_check: "grep: 1 hit; synced", affected_files: [] }], deferred: [] };
|
|
715
|
+
}
|
|
716
|
+
if (fixRound === 2) {
|
|
717
|
+
return { fixed_count: 1, fixes: [{ issue_id: "MF-2", description: "fix2", self_check: "grep: 1 hit; synced", affected_files: [] }], deferred: [] };
|
|
718
|
+
}
|
|
719
|
+
if (fixRound === 3) {
|
|
720
|
+
return {
|
|
721
|
+
fixed_count: 2,
|
|
722
|
+
fixes: [
|
|
723
|
+
{ issue_id: "MF-1", description: "fix3", self_check: "grep: 1 hit; synced", affected_files: [] },
|
|
724
|
+
{ issue_id: "MF-2", description: "fix4", self_check: "grep: 1 hit; synced", affected_files: [] },
|
|
725
|
+
],
|
|
726
|
+
deferred: [],
|
|
727
|
+
};
|
|
728
|
+
}
|
|
729
|
+
return { fixed_count: 1, fixes: [{ issue_id: "MF-1", description: "fix5", self_check: "grep: 1 hit; synced", affected_files: [] }], deferred: [] };
|
|
730
|
+
},
|
|
731
|
+
});
|
|
732
|
+
const deps = makeDeps(runner);
|
|
733
|
+
|
|
734
|
+
const result = await runAndWait(
|
|
735
|
+
"review-fix-loop",
|
|
736
|
+
{ targetType: "file", target: "README.md", agents: "reviewer", maxRounds: 4, _runId: RUN_ID() },
|
|
737
|
+
deps,
|
|
738
|
+
undefined,
|
|
739
|
+
RUN_TIMEOUT_MS,
|
|
740
|
+
);
|
|
741
|
+
|
|
742
|
+
expect(result.reason).toBe("completed");
|
|
743
|
+
expect(result.error).toBeUndefined();
|
|
744
|
+
const outcome = assertScriptOutcome(result.scriptResult);
|
|
745
|
+
// 修复前此处是 converged(fixed 停留 + 收敛 streak 2 → 提前终止掩盖活跃 must-fix);
|
|
746
|
+
// 修复后 fixed 复发转 regressed → 收敛门槛拦截 → R4 needs-redesign
|
|
747
|
+
expect(outcome.terminated).toBe("needs-redesign");
|
|
748
|
+
expect(outcome.message).toContain("MF-1");
|
|
749
|
+
expect(outcome.message).toContain("2 次修复仍未收敛");
|
|
750
|
+
|
|
751
|
+
const { reviewCalls } = runner.stats();
|
|
752
|
+
expect(reviewCalls.length).toBe(4); // R1/R2/R3/R4——修复前 R3 即 converged(3 次)
|
|
753
|
+
},
|
|
754
|
+
RUN_TIMEOUT_MS,
|
|
755
|
+
);
|
|
756
|
+
|
|
757
|
+
it(
|
|
758
|
+
"fail-fast:未知参数名(batchl 拼错)→ workflow 失败且 error 含未知参数提示(S-19)",
|
|
759
|
+
async () => {
|
|
760
|
+
const runner = makeScenarioRunner({
|
|
761
|
+
review: [() => ({ report_file: "/tmp/r1.md", must_fix: 0, suggestion: 0, reconciliation: [] })],
|
|
762
|
+
aggregate: () => ({ report_file: "/tmp/agg.md", must_fix: 0, suggestion: 0, must_fix_ids: [], fixes_caution: [] }),
|
|
763
|
+
fix: () => ({ fixed_count: 0, fixes: [], deferred: [] }),
|
|
764
|
+
});
|
|
765
|
+
const deps = makeDeps(runner);
|
|
766
|
+
|
|
767
|
+
const result = await runAndWait(
|
|
768
|
+
"review-fix-loop",
|
|
769
|
+
{ targetType: "file", target: "README.md", agents: "reviewer", batchl: "fallow-scan", _runId: RUN_ID() },
|
|
770
|
+
deps,
|
|
771
|
+
undefined,
|
|
772
|
+
RUN_TIMEOUT_MS,
|
|
773
|
+
);
|
|
774
|
+
|
|
775
|
+
// 脚本顶层白名单校验 fail() 抛错 → worker type:"error" → 重试超限 → reason=failed
|
|
776
|
+
expect(result.reason).toBe("failed");
|
|
777
|
+
expect(result.error).toContain("未知参数: batchl");
|
|
778
|
+
// 校验发生在任何 agent 调用之前(参数校验在脚本最顶部)
|
|
779
|
+
expect(runner.stats().kinds).toEqual([]);
|
|
780
|
+
},
|
|
781
|
+
RUN_TIMEOUT_MS,
|
|
782
|
+
);
|
|
783
|
+
|
|
784
|
+
it(
|
|
785
|
+
"fail-fast:targetType 非法枚举 / target 空串 → workflow 失败且 error 含必填提示(S-19)",
|
|
786
|
+
async () => {
|
|
787
|
+
const deps = makeDeps(makeScenarioRunner({
|
|
788
|
+
review: [() => ({ report_file: "/tmp/r1.md", must_fix: 0, suggestion: 0, reconciliation: [] })],
|
|
789
|
+
aggregate: () => ({ report_file: "/tmp/agg.md", must_fix: 0, suggestion: 0, must_fix_ids: [], fixes_caution: [] }),
|
|
790
|
+
fix: () => ({ fixed_count: 0, fixes: [], deferred: [] }),
|
|
791
|
+
}));
|
|
792
|
+
|
|
793
|
+
// targetType 非法枚举
|
|
794
|
+
const r1 = await runAndWait(
|
|
795
|
+
"review-fix-loop",
|
|
796
|
+
{ targetType: "nope", target: "README.md", agents: "reviewer", _runId: RUN_ID() },
|
|
797
|
+
deps,
|
|
798
|
+
undefined,
|
|
799
|
+
RUN_TIMEOUT_MS,
|
|
800
|
+
);
|
|
801
|
+
expect(r1.reason).toBe("failed");
|
|
802
|
+
expect(r1.error).toContain("targetType 必填且必须是枚举之一");
|
|
803
|
+
|
|
804
|
+
// target 空串(trim 后为空 → fail)
|
|
805
|
+
const r2 = await runAndWait(
|
|
806
|
+
"review-fix-loop",
|
|
807
|
+
{ targetType: "file", target: " ", agents: "reviewer", _runId: RUN_ID() },
|
|
808
|
+
deps,
|
|
809
|
+
undefined,
|
|
810
|
+
RUN_TIMEOUT_MS,
|
|
811
|
+
);
|
|
812
|
+
expect(r2.reason).toBe("failed");
|
|
813
|
+
expect(r2.error).toContain("target 必填");
|
|
814
|
+
},
|
|
815
|
+
RUN_TIMEOUT_MS,
|
|
816
|
+
);
|
|
817
|
+
});
|