@zhushanwen/pi-subagent-workflow 5.0.2 → 7.0.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/{reviewer.md → code-reviewer.md} +20 -3
- package/agents/context-builder.md +5 -0
- package/agents/doc-reviewer.md +9 -2
- package/agents/explorer.md +5 -0
- package/agents/general-purpose.md +5 -0
- package/agents/oracle.md +20 -4
- package/agents/orchestrator.md +6 -1
- package/agents/planner.md +5 -0
- package/agents/researcher.md +5 -0
- package/agents/worker.md +5 -0
- package/package.json +6 -4
- package/src/execution/__tests__/agent-registry.test.ts +189 -119
- package/src/execution/__tests__/crash-recovery.test.ts +0 -1
- package/src/execution/__tests__/execute-options-mapper.test.ts +4 -4
- package/src/execution/__tests__/index-session-start.test.ts +0 -1
- package/src/execution/__tests__/model-resolver.test.ts +20 -0
- package/src/execution/__tests__/session-start-reaper.test.ts +0 -2
- package/src/execution/__tests__/subprocess-agent-runner.test.ts +1 -1
- package/src/execution/agent-registry.ts +92 -169
- package/src/execution/execute-options-mapper.ts +2 -2
- package/src/execution/model-config-service.ts +13 -34
- package/src/execution/model-resolver.ts +5 -3
- package/src/execution/subagent-service.ts +9 -6
- package/src/execution/subprocess-agent-runner.ts +3 -2
- package/src/index.ts +4 -25
- package/src/injectors/__tests__/subagent-list-injector.test.ts +266 -14
- package/src/injectors/__tests__/workflow-list-injector.test.ts +236 -32
- package/src/injectors/subagent-list-injector.ts +99 -48
- package/src/injectors/workflow-list-injector.ts +65 -50
- package/src/interface/__tests__/detectors.test.ts +100 -43
- package/src/interface/__tests__/subagent-tool-prompt.test.ts +8 -12
- package/src/interface/__tests__/tool-workflow-script-generate.test.ts +163 -0
- package/src/interface/__tests__/workflow-tool-prompt.test.ts +55 -9
- package/src/interface/subagent-tool.ts +7 -4
- package/src/interface/tool-workflow-script.ts +27 -10
- package/src/interface/tool-workflow.ts +174 -81
- package/src/orchestration/__tests__/args-validator.test.ts +143 -0
- package/src/orchestration/__tests__/config-loader.test.ts +124 -40
- package/src/orchestration/__tests__/launcher-nested-workflow.test.ts +33 -2
- package/src/orchestration/__tests__/lifecycle.test.ts +59 -2
- package/src/orchestration/__tests__/review-fix-loop-e2e.test.ts +116 -51
- package/src/orchestration/__tests__/script-lint.test.ts +167 -1
- package/src/orchestration/__tests__/worker-host.test.ts +120 -0
- package/src/orchestration/__tests__/worker-script-builder-runtime.test.ts +69 -0
- package/src/orchestration/__tests__/worker-script-builder.test.ts +51 -1
- package/src/orchestration/__tests__/workflow-nesting-e2e.test.ts +2 -2
- package/src/orchestration/__tests__/workflows-e2e.test.ts +177 -24
- package/src/orchestration/agent-opts-resolver.ts +51 -101
- package/src/orchestration/args-validator.ts +127 -0
- package/src/orchestration/config-loader.ts +63 -94
- package/src/orchestration/error-recovery.ts +6 -9
- package/src/orchestration/launcher.ts +77 -41
- package/src/orchestration/lifecycle.ts +7 -0
- package/src/orchestration/models/ports.ts +0 -14
- package/src/orchestration/models/run-spec.ts +24 -1
- package/src/orchestration/models/types.ts +15 -8
- package/src/orchestration/models/workflow-script-registry.ts +3 -0
- package/src/orchestration/models/workflow-script.ts +10 -14
- package/src/orchestration/script-lint.ts +159 -0
- package/src/orchestration/worker-host.ts +5 -0
- package/src/orchestration/worker-script-builder.ts +15 -4
- package/src/orchestration/workflow-script-registry-impl.ts +34 -29
- package/src/shared/__tests__/meta-parser.test.ts +304 -0
- package/src/shared/__tests__/resource-discovery.test.ts +167 -7
- package/src/shared/__tests__/resource-meta.test.ts +51 -0
- package/src/shared/agent-ref.ts +36 -0
- package/src/shared/meta-parser.ts +257 -0
- package/src/shared/resource-discovery.ts +88 -2
- package/src/shared/resource-meta.ts +60 -0
- package/workflows/README.md +2 -2
- package/workflows/_shared/agent-refs.cjs +40 -0
- package/workflows/chain.js +30 -6
- package/workflows/map-reduce.js +33 -5
- package/workflows/parallel.js +34 -7
- package/workflows/review-fix-loop-utils.cjs +23 -103
- package/workflows/review-fix-loop.js +121 -58
- package/workflows/scatter-gather.js +30 -6
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
* 已知限制:parallel 的 review 调用顺序不保证——剧本不依赖具体 agent 顺序
|
|
20
20
|
* (E2E-2 只断言调用总数,R1 中先到者 dirty 后到者 clean 均可)。
|
|
21
21
|
*/
|
|
22
|
-
import { existsSync, mkdtempSync, readdirSync, readFileSync, rmSync } from "node:fs";
|
|
22
|
+
import { existsSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
23
23
|
import { tmpdir } from "node:os";
|
|
24
24
|
import { dirname, join } from "node:path";
|
|
25
25
|
import { fileURLToPath } from "node:url";
|
|
@@ -27,6 +27,8 @@ import { fileURLToPath } from "node:url";
|
|
|
27
27
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
28
28
|
|
|
29
29
|
import { JsonlRunStore } from "../jsonl-run-store.ts";
|
|
30
|
+
import { parseResourceMeta } from "../../shared/meta-parser.ts";
|
|
31
|
+
import { normalizeRef } from "../../shared/agent-ref.ts";
|
|
30
32
|
import { type LauncherDeps, runAndWait } from "../launcher.ts";
|
|
31
33
|
import type { LifecycleDeps } from "../models/ports.ts";
|
|
32
34
|
import type { AgentRunner } from "../models/ports.ts";
|
|
@@ -41,6 +43,16 @@ import { WorkerHostImpl } from "../worker-host.ts";
|
|
|
41
43
|
|
|
42
44
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
43
45
|
const WORKFLOWS_DIR = join(__dirname, "..", "..", "..", "workflows");
|
|
46
|
+
const wf = (name: string): string => join(WORKFLOWS_DIR, name + ".js");
|
|
47
|
+
|
|
48
|
+
// agentMd 创建真实临时 fixture .md(e2e 自包含,不依赖包内 agents/ 清单)。
|
|
49
|
+
// R3 启动期 stat 校验要求路径真实存在;fixtureDir 在 beforeEach 创建。
|
|
50
|
+
let fixtureDir: string;
|
|
51
|
+
const agentMd = (name: string): string => {
|
|
52
|
+
const p = join(fixtureDir, name + ".md");
|
|
53
|
+
writeFileSync(p, `---\nname: ${name}\ndescription: "${name} fixture"\n---\nbody`, "utf-8");
|
|
54
|
+
return p;
|
|
55
|
+
};
|
|
44
56
|
|
|
45
57
|
let sessionDir: string;
|
|
46
58
|
let createdStores: JsonlRunStore[] = [];
|
|
@@ -197,24 +209,11 @@ function makeScenarioRunner(scenario: Scenario) {
|
|
|
197
209
|
// ── registry(与 workflows-e2e.test.ts 同模式:读文件构造 WorkflowScript) ──
|
|
198
210
|
|
|
199
211
|
function extractMeta(source: string, fallbackName: string): WorkflowMeta {
|
|
200
|
-
const
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
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: [] };
|
|
212
|
+
// m2 exec-review MINOR-1:旧 const meta regex + new Function 随 m2 迁移已失效,
|
|
213
|
+
// 改调 IF1 parseResourceMeta(与 workflows-e2e.test 一致)。
|
|
214
|
+
const meta = parseResourceMeta(source, "workflow");
|
|
215
|
+
if (meta && meta.kind === "workflow") return meta;
|
|
216
|
+
return { kind: "workflow", name: fallbackName, description: "", phases: [] };
|
|
218
217
|
}
|
|
219
218
|
|
|
220
219
|
function loadWorkflowsFromDir(dir: string): Map<string, WorkflowScript> {
|
|
@@ -244,6 +243,29 @@ function loadWorkflowsFromDir(dir: string): Map<string, WorkflowScript> {
|
|
|
244
243
|
function makeRegistry(scripts: Map<string, WorkflowScript>): WorkflowScriptRegistry {
|
|
245
244
|
return {
|
|
246
245
|
get: async (name: string) => scripts.get(name),
|
|
246
|
+
// S2:按路径加载(任意路径 .js)——路径未预扫则直接读文件
|
|
247
|
+
getPath: async (ref: string) => {
|
|
248
|
+
const normalized = normalizeRef(ref, ".js");
|
|
249
|
+
if (normalized === null) return undefined;
|
|
250
|
+
for (const script of scripts.values()) {
|
|
251
|
+
if (script.path === normalized) return script;
|
|
252
|
+
}
|
|
253
|
+
try {
|
|
254
|
+
const sourceCode = readFileSync(normalized, "utf-8");
|
|
255
|
+
const stem = basename(normalized, ".js");
|
|
256
|
+
const meta = extractMeta(sourceCode, stem);
|
|
257
|
+
return new WorkflowScript({
|
|
258
|
+
name: meta.name,
|
|
259
|
+
source: "saved",
|
|
260
|
+
path: normalized,
|
|
261
|
+
sourceCode,
|
|
262
|
+
meta,
|
|
263
|
+
available: true,
|
|
264
|
+
});
|
|
265
|
+
} catch {
|
|
266
|
+
return undefined;
|
|
267
|
+
}
|
|
268
|
+
},
|
|
247
269
|
loadAll: async () => Array.from(scripts.values()),
|
|
248
270
|
invalidate: () => {},
|
|
249
271
|
};
|
|
@@ -265,16 +287,19 @@ function makeDeps(runner: AgentRunner): LauncherDeps {
|
|
|
265
287
|
|
|
266
288
|
beforeEach(() => {
|
|
267
289
|
sessionDir = mkdtempSync(join(tmpdir(), "rfl-e2e-"));
|
|
290
|
+
fixtureDir = mkdtempSync(join(tmpdir(), "rfl-e2e-agents-"));
|
|
268
291
|
createdStores = [];
|
|
269
292
|
});
|
|
270
293
|
|
|
271
294
|
afterEach(() => {
|
|
272
295
|
try {
|
|
273
296
|
rmSync(sessionDir, { recursive: true, force: true });
|
|
297
|
+
rmSync(fixtureDir, { recursive: true, force: true });
|
|
274
298
|
} catch {
|
|
275
299
|
// 临时目录清理失败不影响测试结论
|
|
276
300
|
}
|
|
277
301
|
sessionDir = "";
|
|
302
|
+
fixtureDir = "";
|
|
278
303
|
createdStores = [];
|
|
279
304
|
vi.restoreAllMocks();
|
|
280
305
|
});
|
|
@@ -291,7 +316,7 @@ describe("review-fix-loop E2E(真实 worker + 场景化 mock runner)", () =>
|
|
|
291
316
|
fix: () => ({}),
|
|
292
317
|
});
|
|
293
318
|
const deps = makeDeps(runner);
|
|
294
|
-
const result = await runAndWait("chain", { task: "x" }, deps, undefined, RUN_TIMEOUT_MS);
|
|
319
|
+
const result = await runAndWait(wf("chain"), { task: "x" }, deps, undefined, RUN_TIMEOUT_MS);
|
|
295
320
|
expect(result.reason).toBe("completed");
|
|
296
321
|
});
|
|
297
322
|
it(
|
|
@@ -326,8 +351,8 @@ describe("review-fix-loop E2E(真实 worker + 场景化 mock runner)", () =>
|
|
|
326
351
|
const deps = makeDeps(runner);
|
|
327
352
|
|
|
328
353
|
const result = await runAndWait(
|
|
329
|
-
"review-fix-loop",
|
|
330
|
-
{ targetType: "file", target: "README.md", agents: "reviewer", _runId: RUN_ID() },
|
|
354
|
+
wf("review-fix-loop"),
|
|
355
|
+
{ targetType: "file", target: "README.md", agents: agentMd("reviewer"), _runId: RUN_ID() },
|
|
331
356
|
deps,
|
|
332
357
|
undefined,
|
|
333
358
|
RUN_TIMEOUT_MS,
|
|
@@ -400,8 +425,8 @@ describe("review-fix-loop E2E(真实 worker + 场景化 mock runner)", () =>
|
|
|
400
425
|
const deps = makeDeps(runner);
|
|
401
426
|
|
|
402
427
|
const result = await runAndWait(
|
|
403
|
-
"review-fix-loop",
|
|
404
|
-
{ targetType: "file", target: "README.md", agents: "reviewer,doc-reviewer", fixAgent: "reviewer", _runId: RUN_ID() },
|
|
428
|
+
wf("review-fix-loop"),
|
|
429
|
+
{ targetType: "file", target: "README.md", agents: agentMd("reviewer") + "," + agentMd("doc-reviewer"), fixAgent: agentMd("reviewer"), _runId: RUN_ID() },
|
|
405
430
|
deps,
|
|
406
431
|
undefined,
|
|
407
432
|
RUN_TIMEOUT_MS,
|
|
@@ -418,13 +443,12 @@ describe("review-fix-loop E2E(真实 worker + 场景化 mock runner)", () =>
|
|
|
418
443
|
expect(reviewCalls.length).toBe(3);
|
|
419
444
|
expect(kinds.filter((k) => k === "fix").length).toBe(1);
|
|
420
445
|
expect(kinds.filter((k) => k === "aggregate").length).toBe(1);
|
|
421
|
-
//
|
|
422
|
-
// 内置名走 def.name,自定义 .md agent 为 undefined)
|
|
446
|
+
// S4:fixAgent 派发验证——fix 调用带 agentRef 路径(<available_subagents> location 语义)
|
|
423
447
|
const fixIdx = kinds.indexOf("fix");
|
|
424
|
-
expect(agents[fixIdx]).toBe("reviewer");
|
|
448
|
+
expect(agents[fixIdx]).toBe(agentMd("reviewer"));
|
|
425
449
|
// M3 直接证据:doc-reviewer(schema-only,report_file="")报告经 report_content
|
|
426
|
-
// 落盘到 <runDir>/batch-1/round-1/doc-reviewer.md(def.report 文件名 =
|
|
427
|
-
//
|
|
450
|
+
// 落盘到 <runDir>/batch-1/round-1/doc-reviewer.md(def.report 文件名 = 路径 basename,
|
|
451
|
+
// resolveAgentDefs 路径解析)。修复前 normalizeReviewResult 丢弃
|
|
428
452
|
// report_content → 落盘内容为空文件,本断言失败。
|
|
429
453
|
const docReportPath = join(outcome.runDir, "batch-1", "round-1", "doc-reviewer.md");
|
|
430
454
|
expect(existsSync(docReportPath)).toBe(true);
|
|
@@ -454,8 +478,8 @@ describe("review-fix-loop E2E(真实 worker + 场景化 mock runner)", () =>
|
|
|
454
478
|
const deps = makeDeps(runner);
|
|
455
479
|
|
|
456
480
|
const result = await runAndWait(
|
|
457
|
-
"review-fix-loop",
|
|
458
|
-
{ targetType: "file", target: "README.md", agents: "reviewer", _runId: RUN_ID() },
|
|
481
|
+
wf("review-fix-loop"),
|
|
482
|
+
{ targetType: "file", target: "README.md", agents: agentMd("reviewer"), _runId: RUN_ID() },
|
|
459
483
|
deps,
|
|
460
484
|
undefined,
|
|
461
485
|
RUN_TIMEOUT_MS,
|
|
@@ -524,8 +548,8 @@ describe("review-fix-loop E2E(真实 worker + 场景化 mock runner)", () =>
|
|
|
524
548
|
const deps = makeDeps(runner);
|
|
525
549
|
|
|
526
550
|
const result = await runAndWait(
|
|
527
|
-
"review-fix-loop",
|
|
528
|
-
{ targetType: "file", target: "README.md", agents: "reviewer", maxRounds: 2, _runId: RUN_ID() },
|
|
551
|
+
wf("review-fix-loop"),
|
|
552
|
+
{ targetType: "file", target: "README.md", agents: agentMd("reviewer"), maxRounds: 2, _runId: RUN_ID() },
|
|
529
553
|
deps,
|
|
530
554
|
undefined,
|
|
531
555
|
RUN_TIMEOUT_MS,
|
|
@@ -587,8 +611,8 @@ describe("review-fix-loop E2E(真实 worker + 场景化 mock runner)", () =>
|
|
|
587
611
|
const deps = makeDeps(runner);
|
|
588
612
|
|
|
589
613
|
const result = await runAndWait(
|
|
590
|
-
"review-fix-loop",
|
|
591
|
-
{ targetType: "file", target: "README.md", agents: "reviewer,doc-reviewer", recheckAfterFix: true, _runId: RUN_ID() },
|
|
614
|
+
wf("review-fix-loop"),
|
|
615
|
+
{ targetType: "file", target: "README.md", agents: agentMd("reviewer") + "," + agentMd("doc-reviewer"), recheckAfterFix: true, _runId: RUN_ID() },
|
|
592
616
|
deps,
|
|
593
617
|
undefined,
|
|
594
618
|
RUN_TIMEOUT_MS,
|
|
@@ -656,8 +680,8 @@ describe("review-fix-loop E2E(真实 worker + 场景化 mock runner)", () =>
|
|
|
656
680
|
const deps = makeDeps(runner);
|
|
657
681
|
|
|
658
682
|
const result = await runAndWait(
|
|
659
|
-
"review-fix-loop",
|
|
660
|
-
{ targetType: "file", target: "README.md", agents: "doc-reviewer", _runId: RUN_ID() },
|
|
683
|
+
wf("review-fix-loop"),
|
|
684
|
+
{ targetType: "file", target: "README.md", agents: agentMd("doc-reviewer"), _runId: RUN_ID() },
|
|
661
685
|
deps,
|
|
662
686
|
undefined,
|
|
663
687
|
RUN_TIMEOUT_MS,
|
|
@@ -732,8 +756,8 @@ describe("review-fix-loop E2E(真实 worker + 场景化 mock runner)", () =>
|
|
|
732
756
|
const deps = makeDeps(runner);
|
|
733
757
|
|
|
734
758
|
const result = await runAndWait(
|
|
735
|
-
"review-fix-loop",
|
|
736
|
-
{ targetType: "file", target: "README.md", agents: "reviewer", maxRounds: 4, _runId: RUN_ID() },
|
|
759
|
+
wf("review-fix-loop"),
|
|
760
|
+
{ targetType: "file", target: "README.md", agents: agentMd("reviewer"), maxRounds: 4, _runId: RUN_ID() },
|
|
737
761
|
deps,
|
|
738
762
|
undefined,
|
|
739
763
|
RUN_TIMEOUT_MS,
|
|
@@ -765,8 +789,8 @@ describe("review-fix-loop E2E(真实 worker + 场景化 mock runner)", () =>
|
|
|
765
789
|
const deps = makeDeps(runner);
|
|
766
790
|
|
|
767
791
|
const result = await runAndWait(
|
|
768
|
-
"review-fix-loop",
|
|
769
|
-
{ targetType: "file", target: "README.md", agents: "reviewer", batchl: "fallow-scan", _runId: RUN_ID() },
|
|
792
|
+
wf("review-fix-loop"),
|
|
793
|
+
{ targetType: "file", target: "README.md", agents: agentMd("reviewer"), batchl: "fallow-scan", _runId: RUN_ID() },
|
|
770
794
|
deps,
|
|
771
795
|
undefined,
|
|
772
796
|
RUN_TIMEOUT_MS,
|
|
@@ -790,28 +814,69 @@ describe("review-fix-loop E2E(真实 worker + 场景化 mock runner)", () =>
|
|
|
790
814
|
fix: () => ({ fixed_count: 0, fixes: [], deferred: [] }),
|
|
791
815
|
}));
|
|
792
816
|
|
|
793
|
-
// targetType
|
|
817
|
+
// targetType 非法枚举(m3 TC14:chokepoint 先拦 → invalid_args + ajv 文案 + info 指引)
|
|
794
818
|
const r1 = await runAndWait(
|
|
795
|
-
"review-fix-loop",
|
|
796
|
-
{ targetType: "nope", target: "README.md", agents: "reviewer", _runId: RUN_ID() },
|
|
819
|
+
wf("review-fix-loop"),
|
|
820
|
+
{ targetType: "nope", target: "README.md", agents: agentMd("reviewer"), _runId: RUN_ID() },
|
|
797
821
|
deps,
|
|
798
822
|
undefined,
|
|
799
823
|
RUN_TIMEOUT_MS,
|
|
800
824
|
);
|
|
801
|
-
expect(r1.reason).toBe("
|
|
802
|
-
expect(r1.error).toContain("
|
|
825
|
+
expect(r1.reason).toBe("invalid_args");
|
|
826
|
+
expect(r1.error).toContain("Invalid args for workflow 'review-fix-loop'");
|
|
827
|
+
expect(r1.error).toContain("targetType");
|
|
828
|
+
expect(r1.error).toContain("Read the workflow script file");
|
|
803
829
|
|
|
804
|
-
// target 空串(
|
|
830
|
+
// target 空串(m3 required 空串复查先拦 → invalid_args;脚本 !target 成不可达死代码)
|
|
805
831
|
const r2 = await runAndWait(
|
|
806
|
-
"review-fix-loop",
|
|
807
|
-
{ targetType: "file", target: " ", agents: "reviewer", _runId: RUN_ID() },
|
|
832
|
+
wf("review-fix-loop"),
|
|
833
|
+
{ targetType: "file", target: " ", agents: agentMd("reviewer"), _runId: RUN_ID() },
|
|
808
834
|
deps,
|
|
809
835
|
undefined,
|
|
810
836
|
RUN_TIMEOUT_MS,
|
|
811
837
|
);
|
|
812
|
-
expect(r2.reason).toBe("
|
|
813
|
-
|
|
838
|
+
expect(r2.reason).toBe("invalid_args");
|
|
839
|
+
// schema 驱动(minLength:1 + pattern '\\S'):chokepoint 不发明约束
|
|
840
|
+
expect(r2.error).toContain("target");
|
|
841
|
+
expect(r2.error).toContain("Read the workflow script file");
|
|
814
842
|
},
|
|
815
843
|
RUN_TIMEOUT_MS,
|
|
816
844
|
);
|
|
817
845
|
});
|
|
846
|
+
|
|
847
|
+
describe("startup fail-fast (ADR-0003 D6)", () => {
|
|
848
|
+
const emptyScenario = {
|
|
849
|
+
review: [() => ({ issues: [], must_fix: [], stuck: false })],
|
|
850
|
+
aggregate: () => ({ issues: [], must_fix: [], stuck: false }),
|
|
851
|
+
fix: () => ({ fixed_count: 0, fixes: [], deferred: [] }),
|
|
852
|
+
};
|
|
853
|
+
|
|
854
|
+
it("TC1: batchN 不存在路径 → 启动期 fail-fast,未调 agent", async () => {
|
|
855
|
+
const runner = makeScenarioRunner(emptyScenario);
|
|
856
|
+
const deps = makeDeps(runner);
|
|
857
|
+
const result = await runAndWait(
|
|
858
|
+
wf("review-fix-loop"),
|
|
859
|
+
{ targetType: "file", target: "README.md", agents: "/nonexistent/missing.md", _runId: RUN_ID() },
|
|
860
|
+
deps, undefined, RUN_TIMEOUT_MS,
|
|
861
|
+
);
|
|
862
|
+
expect(result.reason).not.toBe("completed");
|
|
863
|
+
expect(String(result.error ?? "")).toContain("Agent file not found");
|
|
864
|
+
expect(String(result.error ?? "")).toContain("/nonexistent/missing.md");
|
|
865
|
+
// fail-fast 在 round 前:agent mock 未被调用
|
|
866
|
+
expect(runner.stats().kinds.length).toBe(0);
|
|
867
|
+
}, RUN_TIMEOUT_MS);
|
|
868
|
+
|
|
869
|
+
it("TC2: fixAgent 不存在路径 → 启动期 fail-fast", async () => {
|
|
870
|
+
const runner = makeScenarioRunner(emptyScenario);
|
|
871
|
+
const deps = makeDeps(runner);
|
|
872
|
+
const result = await runAndWait(
|
|
873
|
+
wf("review-fix-loop"),
|
|
874
|
+
{ targetType: "file", target: "README.md", agents: agentMd("reviewer"), fixAgent: "/nonexistent/fix.md", _runId: RUN_ID() },
|
|
875
|
+
deps, undefined, RUN_TIMEOUT_MS,
|
|
876
|
+
);
|
|
877
|
+
expect(result.reason).not.toBe("completed");
|
|
878
|
+
expect(String(result.error ?? "")).toContain("Agent file not found");
|
|
879
|
+
expect(String(result.error ?? "")).toContain("/nonexistent/fix.md");
|
|
880
|
+
expect(runner.stats().kinds.length).toBe(0);
|
|
881
|
+
}, RUN_TIMEOUT_MS);
|
|
882
|
+
});
|
|
@@ -12,7 +12,13 @@
|
|
|
12
12
|
*/
|
|
13
13
|
import { describe, it, expect } from "vitest";
|
|
14
14
|
|
|
15
|
-
import {
|
|
15
|
+
import { readFileSync } from "node:fs";
|
|
16
|
+
import { join } from "node:path";
|
|
17
|
+
|
|
18
|
+
import { lintAgentMeta, lintScript } from "../script-lint.ts";
|
|
19
|
+
import { parseResourceMeta } from "../../shared/meta-parser.ts";
|
|
20
|
+
|
|
21
|
+
const WORKFLOWS_DIR = join(__dirname, "../../../workflows");
|
|
16
22
|
|
|
17
23
|
/** 取所有 error 级 finding。 */
|
|
18
24
|
function errors(findings: LintFinding[]): LintFinding[] {
|
|
@@ -663,3 +669,163 @@ describe("声明 phases 与 phase() 调用一致性 — warning", () => {
|
|
|
663
669
|
expect(warnings(result.findings)).toHaveLength(0);
|
|
664
670
|
});
|
|
665
671
|
});
|
|
672
|
+
|
|
673
|
+
// ── m4 W1-W5:meta 质量(SSOT lint) ─────────────────────────
|
|
674
|
+
|
|
675
|
+
describe("m4 W1-W5: meta 质量 lint", () => {
|
|
676
|
+
const wf = (meta: string, body = 'await agent({ prompt: "x" });') =>
|
|
677
|
+
`/* @pi-meta\n${meta}\n*/\n${body}`;
|
|
678
|
+
|
|
679
|
+
it("TC5 W1: 已声明参数名 :/= 形态 → error;非参数 prose 不误报", () => {
|
|
680
|
+
// 参数名集合 = properties keys + patternProperties word 前缀
|
|
681
|
+
const bad = lintScript(
|
|
682
|
+
wf(
|
|
683
|
+
'name: w\ndescription: "targetType: git-diff 用法"\nphases: [a]\nparameters:\n type: object\n properties:\n targetType: { type: string }\n required: [targetType]',
|
|
684
|
+
),
|
|
685
|
+
);
|
|
686
|
+
expect(errors(bad.findings).some((f) => f.message.includes("targetType"))).toBe(true);
|
|
687
|
+
|
|
688
|
+
// note:/a=b 非参数 → 不误报(评审探针实测形态匹配误报面)
|
|
689
|
+
const good = lintScript(
|
|
690
|
+
wf(
|
|
691
|
+
'name: w\ndescription: "note: fix agent 与 batchN 互斥,见 a=b 说明"\nphases: [a]\nparameters:\n type: object\n properties:\n targetType: { type: string }\n required: [targetType]',
|
|
692
|
+
),
|
|
693
|
+
);
|
|
694
|
+
expect(errors(good.findings).length).toBe(0);
|
|
695
|
+
});
|
|
696
|
+
|
|
697
|
+
it("TC5b W1: patternProperties word 前缀参数名(batch)也检查", () => {
|
|
698
|
+
const bad = lintScript(
|
|
699
|
+
wf(
|
|
700
|
+
'name: w\ndescription: "batch=high 模式"\nphases: [a]\nparameters:\n type: object\n patternProperties:\n "^batch\\\\d+$": { type: string }',
|
|
701
|
+
),
|
|
702
|
+
);
|
|
703
|
+
expect(errors(bad.findings).some((f) => f.message.includes("batch"))).toBe(true);
|
|
704
|
+
});
|
|
705
|
+
|
|
706
|
+
it("TC6 W2: >200 字符 → error;200 边界通过", () => {
|
|
707
|
+
const over = lintScript(
|
|
708
|
+
wf('name: w\ndescription: "' + "x".repeat(201) + '"\nphases: [a]'),
|
|
709
|
+
);
|
|
710
|
+
expect(errors(over.findings).some((f) => f.message.includes("200"))).toBe(true);
|
|
711
|
+
const at = lintScript(
|
|
712
|
+
wf('name: w\ndescription: "' + "x".repeat(200) + '"\nphases: [a]'),
|
|
713
|
+
);
|
|
714
|
+
expect(errors(at.findings).length).toBe(0);
|
|
715
|
+
});
|
|
716
|
+
|
|
717
|
+
it("TC7 W3: 括号内句号/缩写剥离后不报;括号外多句报", () => {
|
|
718
|
+
// 括号内 e.g. + 句号 → 剥离后单句无 finding
|
|
719
|
+
const paren = lintScript(
|
|
720
|
+
wf('name: w\ndescription: "多批串行循环(批内并行 review,e.g. 每批 3 视角)"\nphases: [a]'),
|
|
721
|
+
);
|
|
722
|
+
expect(errors(paren.findings).length).toBe(0);
|
|
723
|
+
// 括号外两个句号 → error
|
|
724
|
+
const multi = lintScript(
|
|
725
|
+
wf('name: w\ndescription: "第一句。第二句"\nphases: [a]'),
|
|
726
|
+
);
|
|
727
|
+
expect(errors(multi.findings).some((f) => f.message.includes("单句"))).toBe(true);
|
|
728
|
+
});
|
|
729
|
+
|
|
730
|
+
it("TC8 W4: lintAgentMeta positive 四形态", () => {
|
|
731
|
+
const meta = (examples: unknown) =>
|
|
732
|
+
({ kind: "agent", name: "a", description: "d", examples }) as never;
|
|
733
|
+
// 正反各一 → 无 finding
|
|
734
|
+
expect(
|
|
735
|
+
lintAgentMeta(
|
|
736
|
+
meta([{ match: "review 代码", action: "reviewer", positive: true }, { match: "天气", action: "x", positive: false }]),
|
|
737
|
+
).length,
|
|
738
|
+
).toBe(0);
|
|
739
|
+
// 全正向 → error
|
|
740
|
+
expect(
|
|
741
|
+
lintAgentMeta(
|
|
742
|
+
meta([{ match: "a", action: "x", positive: true }, { match: "b", action: "y", positive: true }]),
|
|
743
|
+
).length,
|
|
744
|
+
).toBe(1);
|
|
745
|
+
// [] → error
|
|
746
|
+
expect(lintAgentMeta(meta([])).length).toBe(1);
|
|
747
|
+
// 缺失 → 无 finding(WQ1:未迁移 agent 不报错)
|
|
748
|
+
expect(lintAgentMeta(meta(undefined)).length).toBe(0);
|
|
749
|
+
});
|
|
750
|
+
|
|
751
|
+
it("TC8a: W4 EXAMPLES_MAX 边界——3/4 条合法,5 条 → finding", () => {
|
|
752
|
+
const meta = (examples: unknown) =>
|
|
753
|
+
({ kind: "agent", name: "a", description: "d", examples }) as never;
|
|
754
|
+
const mk = (n: number) =>
|
|
755
|
+
Array.from({ length: n }, (_, i) => ({
|
|
756
|
+
match: `m${i}`,
|
|
757
|
+
action: "x",
|
|
758
|
+
positive: i % 2 === 0,
|
|
759
|
+
}));
|
|
760
|
+
expect(lintAgentMeta(meta(mk(3))).length).toBe(0); // 3 条合法
|
|
761
|
+
expect(lintAgentMeta(meta(mk(4))).length).toBe(0); // 4 条合法(上限)
|
|
762
|
+
expect(lintAgentMeta(meta(mk(5))).length).toBe(1); // 5 条超上限
|
|
763
|
+
});
|
|
764
|
+
|
|
765
|
+
it("TC8b: W4 EXAMPLES_MAX——examples 超上限(5 条)→ finding", () => {
|
|
766
|
+
const meta = (examples: unknown) =>
|
|
767
|
+
({ kind: "agent", name: "a", description: "d", examples }) as never;
|
|
768
|
+
const five = [
|
|
769
|
+
{ match: "a", action: "x", positive: true },
|
|
770
|
+
{ match: "b", action: "x", positive: false },
|
|
771
|
+
{ match: "c", action: "x", positive: true },
|
|
772
|
+
{ match: "d", action: "x", positive: false },
|
|
773
|
+
{ match: "e", action: "x", positive: true },
|
|
774
|
+
];
|
|
775
|
+
const findings = lintAgentMeta(meta(five));
|
|
776
|
+
expect(findings.length).toBe(1);
|
|
777
|
+
expect(findings[0]!.message).toContain("上限");
|
|
778
|
+
});
|
|
779
|
+
|
|
780
|
+
it("TC9: 5 内置真实 workflow 过 W1-W5(lintScript valid 无 meta 质量 error)", () => {
|
|
781
|
+
const files = ["chain", "parallel", "scatter-gather", "map-reduce", "review-fix-loop"];
|
|
782
|
+
for (const f of files) {
|
|
783
|
+
const src = readFileSync(join(WORKFLOWS_DIR, `${f}.js`), "utf-8");
|
|
784
|
+
// F11:先断言 meta 真实解析成功——防 parse 失败时 W1-W3 整体跳过真空通过
|
|
785
|
+
const meta = parseResourceMeta(src, "workflow");
|
|
786
|
+
expect(meta, `${f} meta 解析`).not.toBeNull();
|
|
787
|
+
const result = lintScript(src);
|
|
788
|
+
const qualityErrors = errors(result.findings).filter((x) => x.message.includes("meta."));
|
|
789
|
+
expect(qualityErrors, `${f} meta 质量`).toEqual([]);
|
|
790
|
+
}
|
|
791
|
+
});
|
|
792
|
+
|
|
793
|
+
it("F2 回归: 参数名含未配对 [ 不崩溃(RegExp 注入)", () => {
|
|
794
|
+
const src = wf(
|
|
795
|
+
'name: w\ndescription: "x[ 参数说明"\nphases: [a]\nparameters:\n type: object\n properties:\n "x[": { type: string }\n required: ["x["]',
|
|
796
|
+
);
|
|
797
|
+
expect(() => lintScript(src)).not.toThrow();
|
|
798
|
+
});
|
|
799
|
+
|
|
800
|
+
it("F6 回归: 子串词缀不误报(task 不命中 subtask:)", () => {
|
|
801
|
+
const src = wf(
|
|
802
|
+
'name: w\ndescription: "subtask: 需要分解"\nphases: [a]\nparameters:\n type: object\n properties:\n task: { type: string }\n required: [task]',
|
|
803
|
+
);
|
|
804
|
+
const metaErrors = errors(lintScript(src).findings).filter((x) => x.message.includes("meta."));
|
|
805
|
+
expect(metaErrors).toEqual([]);
|
|
806
|
+
});
|
|
807
|
+
|
|
808
|
+
it("F6b 回归: 元字符参数名不误报(a.b 不命中 aXb:)", () => {
|
|
809
|
+
const src = wf(
|
|
810
|
+
'name: w\ndescription: "aXb: 任意字符"\nphases: [a]\nparameters:\n type: object\n properties:\n "a.b": { type: string }\n required: ["a.b"]',
|
|
811
|
+
);
|
|
812
|
+
const metaErrors = errors(lintScript(src).findings).filter((x) => x.message.includes("meta."));
|
|
813
|
+
expect(metaErrors).toEqual([]);
|
|
814
|
+
});
|
|
815
|
+
|
|
816
|
+
it("F7 回归: description 空串时 when/notFor 仍受检查", () => {
|
|
817
|
+
const src = wf(
|
|
818
|
+
'name: w\ndescription: ""\nwhen: "第一句。第二句"\nphases: [a]',
|
|
819
|
+
);
|
|
820
|
+
const metaErrors = errors(lintScript(src).findings).filter((x) => x.message.includes("meta."));
|
|
821
|
+
expect(metaErrors.length).toBeGreaterThan(0); // when 非单句被拦
|
|
822
|
+
});
|
|
823
|
+
|
|
824
|
+
it("F8 回归: 括号外 e.g. 缩写不误报", () => {
|
|
825
|
+
const src = wf(
|
|
826
|
+
'name: w\ndescription: "Run when user wants review, e.g. iterative fix loop"\nphases: [a]',
|
|
827
|
+
);
|
|
828
|
+
const metaErrors = errors(lintScript(src).findings).filter((x) => x.message.includes("meta."));
|
|
829
|
+
expect(metaErrors).toEqual([]);
|
|
830
|
+
});
|
|
831
|
+
});
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WorkerHostImpl — workerData 映射测试。
|
|
3
|
+
*
|
|
4
|
+
* Option B(run-level model/thinkingLevel)的映射点在 WorkerHostImpl.start:
|
|
5
|
+
* RunSpec.model/thinkingLevel → workerData.model/thinkingLevel → worker 内
|
|
6
|
+
* $MODEL/$THINKING_LEVEL globals(worker-script-builder 注入)→ agent() fallback。
|
|
7
|
+
*
|
|
8
|
+
* 本测试 mock node:worker_threads.Worker,捕获构造 options.workerData,
|
|
9
|
+
* 直接断言映射(lifecycle.test.ts 的 workerHost.start 是 mock,看不到 workerData)。
|
|
10
|
+
*/
|
|
11
|
+
import { describe, expect, it, vi } from "vitest";
|
|
12
|
+
|
|
13
|
+
const { workerMock } = vi.hoisted(() => ({
|
|
14
|
+
workerMock: vi.fn(),
|
|
15
|
+
}));
|
|
16
|
+
|
|
17
|
+
vi.mock("node:worker_threads", () => ({
|
|
18
|
+
Worker: workerMock,
|
|
19
|
+
}));
|
|
20
|
+
|
|
21
|
+
import { WorkerHostImpl } from "../worker-host.ts";
|
|
22
|
+
import type { RunSpec } from "../models/run-spec.ts";
|
|
23
|
+
import type { WorkerHandlers } from "../models/ports.ts";
|
|
24
|
+
|
|
25
|
+
interface WorkerOptions {
|
|
26
|
+
eval?: boolean;
|
|
27
|
+
workerData?: Record<string, unknown>;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** mock Worker 实例(WorkerHandle 依赖 on/postMessage/terminate)。 */
|
|
31
|
+
function fakeWorkerInstance() {
|
|
32
|
+
return { on: vi.fn(), postMessage: vi.fn(), terminate: vi.fn() };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function makeSpec(overrides: Partial<RunSpec> = {}): RunSpec {
|
|
36
|
+
return {
|
|
37
|
+
scriptSource: "execute() {}",
|
|
38
|
+
args: { autoCommit: true },
|
|
39
|
+
scriptName: "test-wf",
|
|
40
|
+
scriptPath: "/fake/test.js",
|
|
41
|
+
description: "test desc",
|
|
42
|
+
...overrides,
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function makeHandlers(): WorkerHandlers {
|
|
47
|
+
return {
|
|
48
|
+
onMessage: vi.fn(),
|
|
49
|
+
onError: vi.fn(),
|
|
50
|
+
onExit: vi.fn(),
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
describe("WorkerHostImpl.start — workerData 映射", () => {
|
|
55
|
+
it("Option B: RunSpec.model/thinkingLevel 映射进 workerData(其余字段不破坏)", () => {
|
|
56
|
+
const spec = makeSpec({
|
|
57
|
+
model: "anthropic/claude-3.5-sonnet",
|
|
58
|
+
thinkingLevel: "high",
|
|
59
|
+
budgetTokens: 5000,
|
|
60
|
+
});
|
|
61
|
+
let captured: WorkerOptions | undefined;
|
|
62
|
+
workerMock.mockImplementation(function (
|
|
63
|
+
this: unknown,
|
|
64
|
+
_code: string,
|
|
65
|
+
options: WorkerOptions,
|
|
66
|
+
) {
|
|
67
|
+
captured = options;
|
|
68
|
+
return fakeWorkerInstance();
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
new WorkerHostImpl().start(spec, spec.args, makeHandlers());
|
|
72
|
+
|
|
73
|
+
expect(workerMock).toHaveBeenCalledTimes(1);
|
|
74
|
+
expect(captured).toBeDefined();
|
|
75
|
+
const wd = captured!.workerData;
|
|
76
|
+
expect(wd).toBeDefined();
|
|
77
|
+
// Option B 核心:run-level override 经 workerData 进入 worker globals
|
|
78
|
+
expect(wd!.model).toBe("anthropic/claude-3.5-sonnet");
|
|
79
|
+
expect(wd!.thinkingLevel).toBe("high");
|
|
80
|
+
// workerData 契约其余字段(防未来重构破坏 worker 启动链路)
|
|
81
|
+
expect(wd!.scriptPath).toBe("/fake/test.js");
|
|
82
|
+
expect(wd!.args).toEqual({ autoCommit: true });
|
|
83
|
+
expect(wd!.meta).toEqual({ name: "test-wf", description: "test desc" });
|
|
84
|
+
expect(wd!.budget).toEqual({ maxTokens: 5000, usedTokens: 0, usedCost: 0 });
|
|
85
|
+
expect(captured!.eval).toBe(true);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("model/thinkingLevel 未设:workerData 字段为 undefined(继承主 agent 语义,不污染)", () => {
|
|
89
|
+
let captured: WorkerOptions | undefined;
|
|
90
|
+
workerMock.mockImplementation(function (
|
|
91
|
+
this: unknown,
|
|
92
|
+
_code: string,
|
|
93
|
+
options: WorkerOptions,
|
|
94
|
+
) {
|
|
95
|
+
captured = options;
|
|
96
|
+
return fakeWorkerInstance();
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
new WorkerHostImpl().start(makeSpec(), {}, makeHandlers());
|
|
100
|
+
|
|
101
|
+
expect(captured!.workerData!.model).toBeUndefined();
|
|
102
|
+
expect(captured!.workerData!.thinkingLevel).toBeUndefined();
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it("handlers 绑定:message/error/exit 三类事件注册到 worker", () => {
|
|
106
|
+
const instance = fakeWorkerInstance();
|
|
107
|
+
workerMock.mockImplementation(function (this: unknown) {
|
|
108
|
+
return instance;
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
const handle = new WorkerHostImpl().start(makeSpec(), {}, makeHandlers());
|
|
112
|
+
|
|
113
|
+
expect(instance.on).toHaveBeenCalledTimes(3);
|
|
114
|
+
expect(instance.on).toHaveBeenCalledWith("message", expect.any(Function));
|
|
115
|
+
expect(instance.on).toHaveBeenCalledWith("error", expect.any(Function));
|
|
116
|
+
expect(instance.on).toHaveBeenCalledWith("exit", expect.any(Function));
|
|
117
|
+
// handle 持底层 worker(RunRuntime 直接访问 ref/href)
|
|
118
|
+
expect(handle.raw).toBe(instance);
|
|
119
|
+
});
|
|
120
|
+
});
|