@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.
Files changed (77) hide show
  1. package/agents/{reviewer.md → code-reviewer.md} +20 -3
  2. package/agents/context-builder.md +5 -0
  3. package/agents/doc-reviewer.md +9 -2
  4. package/agents/explorer.md +5 -0
  5. package/agents/general-purpose.md +5 -0
  6. package/agents/oracle.md +20 -4
  7. package/agents/orchestrator.md +6 -1
  8. package/agents/planner.md +5 -0
  9. package/agents/researcher.md +5 -0
  10. package/agents/worker.md +5 -0
  11. package/package.json +6 -4
  12. package/src/execution/__tests__/agent-registry.test.ts +189 -119
  13. package/src/execution/__tests__/crash-recovery.test.ts +0 -1
  14. package/src/execution/__tests__/execute-options-mapper.test.ts +4 -4
  15. package/src/execution/__tests__/index-session-start.test.ts +0 -1
  16. package/src/execution/__tests__/model-resolver.test.ts +20 -0
  17. package/src/execution/__tests__/session-start-reaper.test.ts +0 -2
  18. package/src/execution/__tests__/subprocess-agent-runner.test.ts +1 -1
  19. package/src/execution/agent-registry.ts +92 -169
  20. package/src/execution/execute-options-mapper.ts +2 -2
  21. package/src/execution/model-config-service.ts +13 -34
  22. package/src/execution/model-resolver.ts +5 -3
  23. package/src/execution/subagent-service.ts +9 -6
  24. package/src/execution/subprocess-agent-runner.ts +3 -2
  25. package/src/index.ts +4 -25
  26. package/src/injectors/__tests__/subagent-list-injector.test.ts +266 -14
  27. package/src/injectors/__tests__/workflow-list-injector.test.ts +236 -32
  28. package/src/injectors/subagent-list-injector.ts +99 -48
  29. package/src/injectors/workflow-list-injector.ts +65 -50
  30. package/src/interface/__tests__/detectors.test.ts +100 -43
  31. package/src/interface/__tests__/subagent-tool-prompt.test.ts +8 -12
  32. package/src/interface/__tests__/tool-workflow-script-generate.test.ts +163 -0
  33. package/src/interface/__tests__/workflow-tool-prompt.test.ts +55 -9
  34. package/src/interface/subagent-tool.ts +7 -4
  35. package/src/interface/tool-workflow-script.ts +27 -10
  36. package/src/interface/tool-workflow.ts +174 -81
  37. package/src/orchestration/__tests__/args-validator.test.ts +143 -0
  38. package/src/orchestration/__tests__/config-loader.test.ts +124 -40
  39. package/src/orchestration/__tests__/launcher-nested-workflow.test.ts +33 -2
  40. package/src/orchestration/__tests__/lifecycle.test.ts +59 -2
  41. package/src/orchestration/__tests__/review-fix-loop-e2e.test.ts +116 -51
  42. package/src/orchestration/__tests__/script-lint.test.ts +167 -1
  43. package/src/orchestration/__tests__/worker-host.test.ts +120 -0
  44. package/src/orchestration/__tests__/worker-script-builder-runtime.test.ts +69 -0
  45. package/src/orchestration/__tests__/worker-script-builder.test.ts +51 -1
  46. package/src/orchestration/__tests__/workflow-nesting-e2e.test.ts +2 -2
  47. package/src/orchestration/__tests__/workflows-e2e.test.ts +177 -24
  48. package/src/orchestration/agent-opts-resolver.ts +51 -101
  49. package/src/orchestration/args-validator.ts +127 -0
  50. package/src/orchestration/config-loader.ts +63 -94
  51. package/src/orchestration/error-recovery.ts +6 -9
  52. package/src/orchestration/launcher.ts +77 -41
  53. package/src/orchestration/lifecycle.ts +7 -0
  54. package/src/orchestration/models/ports.ts +0 -14
  55. package/src/orchestration/models/run-spec.ts +24 -1
  56. package/src/orchestration/models/types.ts +15 -8
  57. package/src/orchestration/models/workflow-script-registry.ts +3 -0
  58. package/src/orchestration/models/workflow-script.ts +10 -14
  59. package/src/orchestration/script-lint.ts +159 -0
  60. package/src/orchestration/worker-host.ts +5 -0
  61. package/src/orchestration/worker-script-builder.ts +15 -4
  62. package/src/orchestration/workflow-script-registry-impl.ts +34 -29
  63. package/src/shared/__tests__/meta-parser.test.ts +304 -0
  64. package/src/shared/__tests__/resource-discovery.test.ts +167 -7
  65. package/src/shared/__tests__/resource-meta.test.ts +51 -0
  66. package/src/shared/agent-ref.ts +36 -0
  67. package/src/shared/meta-parser.ts +257 -0
  68. package/src/shared/resource-discovery.ts +88 -2
  69. package/src/shared/resource-meta.ts +60 -0
  70. package/workflows/README.md +2 -2
  71. package/workflows/_shared/agent-refs.cjs +40 -0
  72. package/workflows/chain.js +30 -6
  73. package/workflows/map-reduce.js +33 -5
  74. package/workflows/parallel.js +34 -7
  75. package/workflows/review-fix-loop-utils.cjs +23 -103
  76. package/workflows/review-fix-loop.js +121 -58
  77. 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 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: [] };
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
- // m5:fixAgent 派发验证——fix 调用带 agent: "reviewer"(review 调用只记录不断言:
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
- // review- 前缀,resolveAgentDefs 默认分支)。修复前 normalizeReviewResult 丢弃
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("failed");
802
- expect(r1.error).toContain("targetType 必填且必须是枚举之一");
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 空串(trim 后为空fail)
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("failed");
813
- expect(r2.error).toContain("target 必填");
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 { lintScript, type LintFinding } from "../script-lint.ts";
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
+ });