@zhushanwen/pi-subagent-workflow 8.14.7 → 9.0.1
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/README.md +6 -5
- package/package.json +14 -9
- package/relay/relay.mjs +45 -2
- package/skills/workflow-script-format/SKILL.md +2 -56
- package/src/host/__tests__/inflight-reporter.test.ts +26 -1
- package/src/host/__tests__/pi-host.test.ts +418 -4
- package/src/host/inflight-reporter.ts +13 -10
- package/src/host/pi-host.ts +205 -17
- package/src/index.ts +21 -72
- package/src/injectors/__tests__/engine-awareness.test.ts +236 -33
- package/src/injectors/__tests__/helpers/injector-test-mocks.ts +15 -7
- package/src/injectors/__tests__/workflow-list-injector.test.ts +447 -40
- package/src/injectors/resource-list-injector.ts +103 -32
- package/src/injectors/subagent-list-injector.ts +4 -28
- package/src/injectors/workflow-list-injector.ts +8 -4
- package/src/interface/__tests__/capture-tool.ts +56 -0
- package/src/interface/__tests__/commands-resume.test.ts +103 -0
- package/src/interface/__tests__/tool-prompt-contract.test.ts +361 -0
- package/src/interface/__tests__/tool-render.test.ts +105 -2
- package/src/interface/__tests__/tool-subagents.test.ts +24 -26
- package/src/interface/__tests__/tool-workflow-resume.test.ts +227 -0
- package/src/interface/__tests__/tool-workflow-script.test.ts +557 -0
- package/src/interface/__tests__/tool-workflow.test.ts +884 -0
- package/src/interface/bg-notify-render.ts +18 -9
- package/src/interface/command-actions.ts +22 -17
- package/src/interface/commands.ts +58 -12
- package/src/interface/format.ts +50 -16
- package/src/interface/id-preview.ts +4 -4
- package/src/interface/subagent-actions.ts +18 -50
- package/src/interface/subagent-tool.ts +26 -18
- package/src/interface/subagents.ts +5 -11
- package/src/interface/tool-render.ts +14 -30
- package/src/interface/tool-shared.ts +55 -12
- package/src/interface/tool-subagents.ts +21 -11
- package/src/interface/tool-workflow-script.ts +52 -72
- package/src/interface/tool-workflow.ts +345 -139
- package/src/interface/views/WorkflowsView.ts +10 -7
- package/src/interface/views/__tests__/{WorkflowsView-signature.test.ts → WorkflowsView.test.ts} +155 -11
- package/src/interface/views/detail-content.ts +3 -5
- package/src/jsonl-run-store.ts +656 -544
- package/src/model-events.ts +35 -0
- package/src/session-lifecycle.ts +349 -86
- package/src/subagents-events.ts +56 -0
- package/src/workflow-events.ts +285 -200
- package/src/workflow-notify.ts +456 -0
- package/src/injectors/__tests__/engine-section-stability.test.ts +0 -353
- package/src/injectors/__tests__/model-list-injector.test.ts +0 -136
- package/src/injectors/__tests__/subagent-list-injector.test.ts +0 -376
- package/src/interface/__tests__/detectors.test.ts +0 -161
- package/src/interface/__tests__/subagent-tool-prompt.test.ts +0 -183
- package/src/interface/__tests__/tool-workflow-run-builtin-name.test.ts +0 -219
- package/src/interface/__tests__/tool-workflow-script-generate.test.ts +0 -251
- package/src/interface/__tests__/tool-workflow-throw-paths.test.ts +0 -215
- package/src/interface/__tests__/workflow-state-file-exposure.test.ts +0 -38
- package/src/interface/__tests__/workflow-tool-prompt.test.ts +0 -129
- package/src/interface/helpers.ts +0 -272
- package/src/interface/views/__tests__/detail-content-session-file.test.ts +0 -73
- package/src/interface/views/__tests__/record-progress.test.ts +0 -235
|
@@ -1,183 +0,0 @@
|
|
|
1
|
-
// 提示词质量回归:subagent tool description 必须是"行为约束器"而非"功能说明书"。
|
|
2
|
-
//
|
|
3
|
-
// agent(LLM)决策时唯一能看到的 tool 元信息就是 description。审查发现旧版
|
|
4
|
-
// description 把篇幅花在功能说明上,缺乏调用信号(何时委派 vs 自己做)、
|
|
5
|
-
// 能力边界(cannot)、反模式密度(高风险要求 ≥4 条)、以及对 auto-injected
|
|
6
|
-
// completion message 的注入防御。
|
|
7
|
-
//
|
|
8
|
-
// 本测试用源码断言(读 .ts 文件文本)锁定这些约束,防止后续重构把约束措辞
|
|
9
|
-
// 删掉或弱化。读源码而非 import,避免 mock 链(subagent-tool.ts 依赖 pi-ai/
|
|
10
|
-
// typebox/pi-tui/ExtensionAPI 等值导入)。
|
|
11
|
-
|
|
12
|
-
import { readFileSync } from "node:fs";
|
|
13
|
-
import { dirname,join } from "node:path";
|
|
14
|
-
import { fileURLToPath } from "node:url";
|
|
15
|
-
import { createRequire } from "node:module";
|
|
16
|
-
|
|
17
|
-
import { describe, expect, it } from "vitest";
|
|
18
|
-
|
|
19
|
-
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
20
|
-
const SUBAGENT_TOOL_SRC = readFileSync(
|
|
21
|
-
join(__dirname, "../subagent-tool.ts"),
|
|
22
|
-
"utf-8",
|
|
23
|
-
);
|
|
24
|
-
|
|
25
|
-
/** 提取 description: `...` 模板字符串的原始内容。 */
|
|
26
|
-
function extractDescription(src: string): string {
|
|
27
|
-
const m = src.match(/description:\s*`([\s\S]*?)`,/);
|
|
28
|
-
if (!m) throw new Error("description template literal not found");
|
|
29
|
-
return m[1];
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
const DESCRIPTION = extractDescription(SUBAGENT_TOOL_SRC);
|
|
33
|
-
|
|
34
|
-
describe("subagent tool description — 行为约束器(非功能说明书)", () => {
|
|
35
|
-
it("词数 ≤ 850(高风险 description 密度上限)", () => {
|
|
36
|
-
// 高风险 tool 的 description 应聚焦约束而非功能铺陈;过长会稀释信号。
|
|
37
|
-
// 上限演进:400 → 550(补 start/list/cancel 三 action 完整 JSON 正例)→
|
|
38
|
-
// 650(M2-B3 新增 message/close 两 action + conversation 对话模式 + 例子)→
|
|
39
|
-
// 800(递归指导:树形判据 + 自包含 task + 独立验收 + fork 成本警告)→
|
|
40
|
-
// 850(C2 补 sync 批独立判据禁令句——adversarial-review-fixes §3.4:原 description
|
|
41
|
-
// 未定义 independent 且未交叉引用依赖链禁令,依赖任务入批缺判据拦截)。
|
|
42
|
-
// 逼近上限时优先精简正文而非再放宽断言。
|
|
43
|
-
// 每次 action 扩展必然增加必需描述;正例对弱模型首次用对参数的价值 > 节省 description 预算。
|
|
44
|
-
const words = DESCRIPTION.trim().split(/\s+/).filter(Boolean).length;
|
|
45
|
-
expect(words).toBeLessThanOrEqual(850);
|
|
46
|
-
});
|
|
47
|
-
|
|
48
|
-
it("含 'When to delegate' 调用条件段(何时委派 vs 自己做)", () => {
|
|
49
|
-
// 开篇必须给信号驱动的调用条件,而非纯功能说明。
|
|
50
|
-
expect(DESCRIPTION).toMatch(/When to delegate/i);
|
|
51
|
-
});
|
|
52
|
-
|
|
53
|
-
it("Anti-patterns 段含 ≥ 4 条 bullet", () => {
|
|
54
|
-
// 高风险 tool 要求 ≥ 4 条反模式,密度才足以覆盖主要误用路径。
|
|
55
|
-
const apIdx = DESCRIPTION.indexOf("## Anti-patterns");
|
|
56
|
-
expect(apIdx).toBeGreaterThan(-1);
|
|
57
|
-
const afterAp = DESCRIPTION.slice(apIdx);
|
|
58
|
-
// 截到下一个 ## 段
|
|
59
|
-
const nextSection = afterAp.indexOf("##", "## Anti-patterns".length);
|
|
60
|
-
const apSection =
|
|
61
|
-
nextSection > -1 ? afterAp.slice(0, nextSection) : afterAp;
|
|
62
|
-
const bullets = apSection.match(/^- .+/gm) || [];
|
|
63
|
-
expect(bullets.length).toBeGreaterThanOrEqual(4);
|
|
64
|
-
});
|
|
65
|
-
|
|
66
|
-
it("含能力边界段 'You cannot'", () => {
|
|
67
|
-
// 必须显式声明 tool 做不到的事,阻止 LLM 错误假设。
|
|
68
|
-
expect(DESCRIPTION).toMatch(/You cannot/);
|
|
69
|
-
expect(DESCRIPTION.toLowerCase()).toContain("cannot");
|
|
70
|
-
});
|
|
71
|
-
|
|
72
|
-
it("含注入防御:声明 completion message 为不可信数据", () => {
|
|
73
|
-
// completion message 是 auto-injected(F14 注入面),必须告诉 LLM
|
|
74
|
-
// 把它当作不可信数据,校验其中的指令后再执行。
|
|
75
|
-
const lower = DESCRIPTION.toLowerCase();
|
|
76
|
-
expect(
|
|
77
|
-
lower.includes("untrusted") || lower.includes("verify"),
|
|
78
|
-
).toBe(true);
|
|
79
|
-
});
|
|
80
|
-
|
|
81
|
-
it("保留 nested spawning 段(允许 sub-subagent,仅深度限制)", () => {
|
|
82
|
-
// 这段防止 LLM 错误拒绝合法的 nested delegation。
|
|
83
|
-
expect(DESCRIPTION).toMatch(/Nested spawning/);
|
|
84
|
-
expect(DESCRIPTION).toMatch(/Depth: N\/10/);
|
|
85
|
-
});
|
|
86
|
-
|
|
87
|
-
it("递归指导含树形判据 + 自包含 task + 独立验收 + 深度定位", () => {
|
|
88
|
-
// 递归不是「能套就套」,必须有明确使用判据,否则 LLM 会滥用深递归。
|
|
89
|
-
const lower = DESCRIPTION.toLowerCase();
|
|
90
|
-
expect(lower).toContain("tree-shaped"); // 树形判据
|
|
91
|
-
expect(lower).toContain("self-contained"); // task 自包含
|
|
92
|
-
expect(lower).toContain("acceptance criteria"); // 独立验收
|
|
93
|
-
expect(lower).toContain("safety rail"); // 深度定位:10 是护栏非预算
|
|
94
|
-
});
|
|
95
|
-
|
|
96
|
-
it("保留 executionMode sequential 的 CRITICAL 说明", () => {
|
|
97
|
-
// sequential 是关键执行语义,删了会导致 LLM 误以为并行可用。
|
|
98
|
-
expect(DESCRIPTION).toMatch(/CRITICAL/i);
|
|
99
|
-
expect(DESCRIPTION).toMatch(/sequential/);
|
|
100
|
-
expect(DESCRIPTION).toMatch(/SAME message/i);
|
|
101
|
-
});
|
|
102
|
-
|
|
103
|
-
it("Examples 段含平铺 JSON 正例(task/slug 在顶层,无 startParam envelope)", () => {
|
|
104
|
-
// 弱模型信任 schema 结构信号 > 文本信号,原本嵌套 startParam 容器经常被省略。
|
|
105
|
-
// 现已拍平:task/slug 等 13 字段直接放在顶层。description 必须有完整平铺 JSON 正例,
|
|
106
|
-
// 让模型能直接照抄。强约束:startParam envelope 必须从 description 中彻底消失。
|
|
107
|
-
expect(DESCRIPTION).toContain('"action":"start","task"');
|
|
108
|
-
expect(DESCRIPTION).not.toContain('"startParam"');
|
|
109
|
-
});
|
|
110
|
-
|
|
111
|
-
it("cancel 示例 subagentId 用 sa- 连字符前缀(与 subagent-service.ts 实际生成格式一致)", () => {
|
|
112
|
-
// subagent-service.ts:600 生成 `sa-${crypto.randomUUID()}`(连字符)。
|
|
113
|
-
// description 示例必须与实际生成格式一致——弱模型会照抄示例,前缀错(如 sa_ 下划线)
|
|
114
|
-
// 会导致 subagentId 永远匹配不到真实 record。
|
|
115
|
-
expect(DESCRIPTION).toContain('"subagentId":"sa-');
|
|
116
|
-
expect(DESCRIPTION).not.toContain('"sa_');
|
|
117
|
-
});
|
|
118
|
-
|
|
119
|
-
it("Examples 示例 agent 值必须是绝对路径 .md 形态(显式 ref 硬守卫拒绝裸名)", () => {
|
|
120
|
-
// 显式 agent ref 走硬守卫:getRequiredAgentConfig → loadByPath(ref, true),
|
|
121
|
-
// 裸名(normalizeRef 非绝对路径 → null)同步 throw `Invalid agent ref` +
|
|
122
|
-
// <available_subagents> 恢复指引(explicit-agent-ref-guard.test.ts 锁定拒绝路径)。
|
|
123
|
-
// 示例若教裸名(历史漂移:"agent":"coder"),弱模型照抄即必败调用——浪费一轮
|
|
124
|
-
// 并系统性教唆反模式。与 subagentId 格式测试同风格:示例必须与实际契约一致。
|
|
125
|
-
// ① 零裸名:任何 "agent":"<非/>" 形态都禁止
|
|
126
|
-
expect(DESCRIPTION).not.toMatch(/"agent":"(?!\/)[^"]*"/);
|
|
127
|
-
// ② 正例在位且为绝对路径 .md 形态(<available_subagents> 注入的 <location> 形态)
|
|
128
|
-
expect(DESCRIPTION).toMatch(/"agent":"\/[^"]*\.md"/);
|
|
129
|
-
});
|
|
130
|
-
|
|
131
|
-
it("agent 字段 description 不写死枚举,指向 <available_subagents>(通用化防漂移)", () => {
|
|
132
|
-
// 原实现把 9 个内置 agent 名写死在 schema field description(防漏),
|
|
133
|
-
// 但枚举与包内 agents/*.md 存在漂移风险(新增/删除 agent 要手改两处)。
|
|
134
|
-
// 现改为指向每 turn 动态注入的 <available_subagents> 列表——防漏职责由注入段承担,
|
|
135
|
-
// 工具描述只保留通用指引(2026-08 通用化重构)。
|
|
136
|
-
expect(SUBAGENT_TOOL_SRC).toContain("available_subagents");
|
|
137
|
-
// 通用化约束:不写死任何具体 agent 名(名字随 agents/*.md 动态变化)
|
|
138
|
-
expect(SUBAGENT_TOOL_SRC).not.toMatch(/orchestrator|code-reviewer|context-builder|worker/);
|
|
139
|
-
});
|
|
140
|
-
|
|
141
|
-
it("Anti-patterns 段明确 list/cancel 仍 nested(防过度泛化 flatten)", () => {
|
|
142
|
-
// PR 只拍平 start,listParam/cancelParam 仍 nested。description 必须明确这一不对称性,
|
|
143
|
-
// 否则弱模型学了「subagent tool 现在平铺」会过度泛化发 {"action":"list","includeFinished":true}。
|
|
144
|
-
const apIdx = DESCRIPTION.indexOf("## Anti-patterns");
|
|
145
|
-
expect(apIdx).toBeGreaterThan(-1);
|
|
146
|
-
const afterAp = DESCRIPTION.slice(apIdx);
|
|
147
|
-
const nextSection = afterAp.indexOf("##", "## Anti-patterns".length);
|
|
148
|
-
const apSection = nextSection > -1 ? afterAp.slice(0, nextSection) : afterAp;
|
|
149
|
-
expect(apSection).toMatch(/list.*nested.*listParam|listParam.*nested/i);
|
|
150
|
-
});
|
|
151
|
-
});
|
|
152
|
-
|
|
153
|
-
describe("subagent tool runtime handler — 错误文案含纠正正例", () => {
|
|
154
|
-
// 读源码文本断言 startHandler throw 含 Correct 正例,
|
|
155
|
-
// 让弱模型撞错后第二次能直接照抄正确形态。
|
|
156
|
-
// 拍平后:startParam envelope 删除,平铺 task/slug 是合法形态;
|
|
157
|
-
// 平铺检测 guard(hasFlattenedStartFields)已删除,源码不应再含此表达式。
|
|
158
|
-
it("startHandler throw 含 Correct 纠正正例(平铺形态)", () => {
|
|
159
|
-
// [D6②] startHandler 内核(含 Correct 文案)已下沉 core subagent-actions-core,
|
|
160
|
-
// 断言目标跟随文案权威源。锚点用 ./workflows/* 子入口(exports 映射包根真实目录,
|
|
161
|
-
// 随源码走不依赖 dist 构建——relay-env 锚在 require 条件下落 dist/,CI 不构建
|
|
162
|
-
// workspace 包时 resolve 直接 MODULE_NOT_FOUND)。
|
|
163
|
-
const coreRoot = join(
|
|
164
|
-
dirname(
|
|
165
|
-
createRequire(import.meta.url).resolve("@zhushanwen/subagent-core/workflows/README.md"),
|
|
166
|
-
),
|
|
167
|
-
"..",
|
|
168
|
-
);
|
|
169
|
-
const actionsSrc = readFileSync(
|
|
170
|
-
join(coreRoot, "src/execution/assembly/subagent-actions-core.ts"),
|
|
171
|
-
"utf-8",
|
|
172
|
-
);
|
|
173
|
-
// 四处 throw(input 缺失 / task 空白 / slug 空白 / slug 超长)都应含 Correct 正例。
|
|
174
|
-
// 用 occurrences 计数——至少 3 处。
|
|
175
|
-
const occurrences = (actionsSrc.match(/Correct: \{"action":"start"/g) ?? []).length;
|
|
176
|
-
expect(occurrences).toBeGreaterThanOrEqual(3);
|
|
177
|
-
});
|
|
178
|
-
|
|
179
|
-
it("平铺检测 guard(hasFlattenedStartFields)已从 subagent-tool.ts 删除", () => {
|
|
180
|
-
expect(SUBAGENT_TOOL_SRC).not.toContain('params.action === "start" && !params.startParam');
|
|
181
|
-
expect(SUBAGENT_TOOL_SRC).not.toContain("hasFlattenedStartFields");
|
|
182
|
-
});
|
|
183
|
-
});
|
|
@@ -1,219 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* C5③:actionRun 放开内置 workflow 名(convergence D-4 pi 半边)。
|
|
3
|
-
*
|
|
4
|
-
* 解析序:registry.get(name)(内置/已保存 workflow 名,按 tmp>project>user>npm
|
|
5
|
-
* 优先级合并)→ 未命中 registry.getPath(name)(绝对路径 + ~/ 展开)→ 两者都 miss
|
|
6
|
-
* 走原 not_found 报错(文案不变)。严格超集:现有路径用法零变化。
|
|
7
|
-
*
|
|
8
|
-
* 三视角:
|
|
9
|
-
* - 使用者(黑盒):run {"action":"run","name":"chain"} 直接可跑(内置名新能力);
|
|
10
|
-
* 传路径仍可跑(现行为);两者都 miss 的报错与改造前逐字一致。
|
|
11
|
-
* - 构建者(白盒):解析序 get→getPath、get 未命中才穿透到 getPath。
|
|
12
|
-
* - 观察者(真 registry):WorkflowScriptRegistryImpl + fixture 目录经真实
|
|
13
|
-
* discoverWorkflows 按名命中(生产 lookup 链)。
|
|
14
|
-
*
|
|
15
|
-
* mock 策略:lifecycle 深路径 stub(runWorkflow/abortRun 为 vi.fn——不起真 Worker,
|
|
16
|
-
* 只验证 run 启动面的脚本解析与 spec 组装)。框架:vitest(禁 node:test)。
|
|
17
|
-
*/
|
|
18
|
-
import { mkdtempSync, readdirSync, rmSync, writeFileSync, mkdirSync } from "node:fs";
|
|
19
|
-
import { tmpdir } from "node:os";
|
|
20
|
-
import { join } from "node:path";
|
|
21
|
-
|
|
22
|
-
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
23
|
-
|
|
24
|
-
/** 桩化 lifecycle——runWorkflow/abortRun 为 vi.fn,不起真 Worker(只测解析面)。 */
|
|
25
|
-
vi.mock("@zhushanwen/subagent-core/orchestration/lifecycle.ts", () => ({
|
|
26
|
-
runWorkflow: vi.fn(),
|
|
27
|
-
abortRun: vi.fn(),
|
|
28
|
-
}));
|
|
29
|
-
|
|
30
|
-
// 被 mock 的模块——import 路径与被测源文件(tool-workflow.ts 深路径 import)一致
|
|
31
|
-
import { runWorkflow } from "@zhushanwen/subagent-core/orchestration/lifecycle.ts";
|
|
32
|
-
import { actionRun } from "../tool-workflow.ts";
|
|
33
|
-
import { WorkflowScriptRegistryImpl } from "@zhushanwen/subagent-core";
|
|
34
|
-
|
|
35
|
-
// ── fixture:可用 workflow 脚本(@pi-meta 新格式,无参数声明) ──
|
|
36
|
-
|
|
37
|
-
const CHAIN_META = `/* @pi-meta
|
|
38
|
-
name: chain
|
|
39
|
-
description: 内置名测试用三步链
|
|
40
|
-
phases: [a, b]
|
|
41
|
-
*/
|
|
42
|
-
const agent = require("./agent");
|
|
43
|
-
agent("w", { task: $ARGS.task });
|
|
44
|
-
`;
|
|
45
|
-
|
|
46
|
-
/** fake registry 的最小 WorkflowScript stub。 */
|
|
47
|
-
function makeScript(name: string, path: string, parameters?: object) {
|
|
48
|
-
return {
|
|
49
|
-
name,
|
|
50
|
-
path,
|
|
51
|
-
available: true,
|
|
52
|
-
sourceCode: `// ${name}`,
|
|
53
|
-
meta: { description: `${name} workflow`, parameters },
|
|
54
|
-
toExecutable: () => `// ${name}`,
|
|
55
|
-
};
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
/** 最小 deps stub(runWorkflow 已 mock;store 只消费 stateFilePath)。 */
|
|
59
|
-
function makeDeps(registry: Record<string, unknown>): Record<string, unknown> {
|
|
60
|
-
return {
|
|
61
|
-
runs: new Map(),
|
|
62
|
-
store: { stateFilePath: (id: string) => `/tmp/state/${id}.jsonl` },
|
|
63
|
-
registry,
|
|
64
|
-
};
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
beforeEach(() => {
|
|
68
|
-
vi.mocked(runWorkflow).mockReset();
|
|
69
|
-
vi.mocked(runWorkflow).mockResolvedValue("run-id-1");
|
|
70
|
-
});
|
|
71
|
-
|
|
72
|
-
afterEach(() => {
|
|
73
|
-
vi.restoreAllMocks();
|
|
74
|
-
});
|
|
75
|
-
|
|
76
|
-
describe("C5③ run 内置名(fake registry)", () => {
|
|
77
|
-
it("run 传内置名 → registry.get 命中即启动(runWorkflow 收到该脚本,getPath 不被调用)", async () => {
|
|
78
|
-
const chain = makeScript("chain", "/builtin/workflows/chain.js");
|
|
79
|
-
const registry = {
|
|
80
|
-
get: vi.fn().mockResolvedValue(chain),
|
|
81
|
-
getPath: vi.fn().mockResolvedValue(undefined),
|
|
82
|
-
loadAll: vi.fn().mockResolvedValue([chain]),
|
|
83
|
-
};
|
|
84
|
-
const result = await actionRun(
|
|
85
|
-
{ action: "run", name: "chain" } as never,
|
|
86
|
-
makeDeps(registry) as never,
|
|
87
|
-
undefined,
|
|
88
|
-
);
|
|
89
|
-
|
|
90
|
-
expect(registry.get).toHaveBeenCalledWith("chain");
|
|
91
|
-
expect(registry.getPath).not.toHaveBeenCalled();
|
|
92
|
-
expect(vi.mocked(runWorkflow)).toHaveBeenCalledTimes(1);
|
|
93
|
-
// spec 组装:scriptName/scriptPath 来自按名命中的脚本
|
|
94
|
-
const spec = vi.mocked(runWorkflow).mock.calls[0][0] as Record<string, unknown>;
|
|
95
|
-
expect(spec.scriptName).toBe("chain");
|
|
96
|
-
expect(spec.scriptPath).toBe("/builtin/workflows/chain.js");
|
|
97
|
-
expect(result.content[0]?.text).toContain("Started workflow 'chain'");
|
|
98
|
-
expect(result.details).toMatchObject({ action: "run", status: "running", name: "chain" });
|
|
99
|
-
});
|
|
100
|
-
|
|
101
|
-
it("严格超集:get 未命中的路径名 → 穿透 getPath(现有路径用法零变化)", async () => {
|
|
102
|
-
const byPath = makeScript("demo", "/abs/demo.js");
|
|
103
|
-
const registry = {
|
|
104
|
-
get: vi.fn().mockResolvedValue(undefined),
|
|
105
|
-
getPath: vi.fn().mockResolvedValue(byPath),
|
|
106
|
-
loadAll: vi.fn().mockResolvedValue([byPath]),
|
|
107
|
-
};
|
|
108
|
-
await actionRun(
|
|
109
|
-
{ action: "run", name: "/abs/demo.js" } as never,
|
|
110
|
-
makeDeps(registry) as never,
|
|
111
|
-
undefined,
|
|
112
|
-
);
|
|
113
|
-
|
|
114
|
-
expect(registry.get).toHaveBeenCalledWith("/abs/demo.js");
|
|
115
|
-
expect(registry.getPath).toHaveBeenCalledWith("/abs/demo.js");
|
|
116
|
-
expect(vi.mocked(runWorkflow)).toHaveBeenCalledTimes(1);
|
|
117
|
-
const spec = vi.mocked(runWorkflow).mock.calls[0][0] as Record<string, unknown>;
|
|
118
|
-
expect(spec.scriptName).toBe("demo");
|
|
119
|
-
});
|
|
120
|
-
|
|
121
|
-
it("get 命中 available:false 的 stub → 不启动,走 not_found 报错(W4c 口径不回退)", async () => {
|
|
122
|
-
const ghost = { ...makeScript("ghost", "/builtin/ghost.js"), available: false };
|
|
123
|
-
const registry = {
|
|
124
|
-
get: vi.fn().mockResolvedValue(ghost),
|
|
125
|
-
getPath: vi.fn().mockResolvedValue(undefined),
|
|
126
|
-
loadAll: vi.fn().mockResolvedValue([]),
|
|
127
|
-
};
|
|
128
|
-
await expect(
|
|
129
|
-
actionRun(
|
|
130
|
-
{ action: "run", name: "ghost" } as never,
|
|
131
|
-
makeDeps(registry) as never,
|
|
132
|
-
undefined,
|
|
133
|
-
),
|
|
134
|
-
).rejects.toThrow(/Workflow 'ghost' not found\./);
|
|
135
|
-
expect(vi.mocked(runWorkflow)).not.toHaveBeenCalled();
|
|
136
|
-
});
|
|
137
|
-
|
|
138
|
-
it("两者都 miss → 报错含建议清单且逐条附绝对路径 location(按名失败后的自救指引)", async () => {
|
|
139
|
-
const registry = {
|
|
140
|
-
get: vi.fn().mockResolvedValue(undefined),
|
|
141
|
-
getPath: vi.fn().mockResolvedValue(undefined),
|
|
142
|
-
loadAll: vi.fn().mockResolvedValue([makeScript("chain", "/builtin/workflows/chain.js")]),
|
|
143
|
-
};
|
|
144
|
-
const err = await actionRun(
|
|
145
|
-
{ action: "run", name: "no-such" } as never,
|
|
146
|
-
makeDeps(registry) as never,
|
|
147
|
-
undefined,
|
|
148
|
-
).catch((e: unknown) => e as Error);
|
|
149
|
-
// 逐条 location:模型按清单里的名字重试注定再 throw(8.6.0 getPath-only 时代
|
|
150
|
-
// 实测失败形态)——摘要必须直接给出可用的绝对路径,失败一次即自救。
|
|
151
|
-
expect(err.message).toContain(
|
|
152
|
-
"Workflow 'no-such' not found. Available (name — use the absolute location path as 'name' when the bare name is rejected):",
|
|
153
|
-
);
|
|
154
|
-
expect(err.message).toContain(" - chain: chain workflow");
|
|
155
|
-
expect(err.message).toContain(" location: /builtin/workflows/chain.js");
|
|
156
|
-
});
|
|
157
|
-
});
|
|
158
|
-
|
|
159
|
-
describe("C5③ run 内置名(真 registry:WorkflowScriptRegistryImpl + 真实发现链)", () => {
|
|
160
|
-
let fixtureDir: string;
|
|
161
|
-
|
|
162
|
-
beforeEach(() => {
|
|
163
|
-
fixtureDir = mkdtempSync(join(tmpdir(), "c5-run-builtin-"));
|
|
164
|
-
// WorkflowScanConfig 布局:projectDir = <fixture>/ws/.pi/workflows(反推 workspaceRoot)
|
|
165
|
-
const projectDir = join(fixtureDir, "ws", ".pi", "workflows");
|
|
166
|
-
mkdirSync(projectDir, { recursive: true });
|
|
167
|
-
writeFileSync(join(projectDir, "chain.js"), CHAIN_META, "utf-8");
|
|
168
|
-
});
|
|
169
|
-
|
|
170
|
-
afterEach(() => {
|
|
171
|
-
rmSync(fixtureDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 20 });
|
|
172
|
-
});
|
|
173
|
-
|
|
174
|
-
it("get('chain') 经真实 discoverWorkflows 命中 fixture 脚本 → actionRun 按名启动成功", async () => {
|
|
175
|
-
const registry = new WorkflowScriptRegistryImpl({
|
|
176
|
-
projectDir: join(fixtureDir, "ws", ".pi", "workflows"),
|
|
177
|
-
userDir: join(fixtureDir, "user", "workflows"),
|
|
178
|
-
tmpDir: join(fixtureDir, "ws", ".pi", "workflows", ".tmp"),
|
|
179
|
-
npmDirs: [],
|
|
180
|
-
});
|
|
181
|
-
// 前置自检:fixture 布局可被扫描(隔离 config 下 hostRoots 为空、仅 project 根命中)
|
|
182
|
-
const all = await registry.loadAll();
|
|
183
|
-
expect(all.filter((w) => w.available).map((w) => w.name)).toContain("chain");
|
|
184
|
-
|
|
185
|
-
const result = await actionRun(
|
|
186
|
-
{ action: "run", name: "chain" } as never,
|
|
187
|
-
makeDeps(registry as unknown as Record<string, unknown>) as never,
|
|
188
|
-
undefined,
|
|
189
|
-
);
|
|
190
|
-
|
|
191
|
-
expect(vi.mocked(runWorkflow)).toHaveBeenCalledTimes(1);
|
|
192
|
-
const spec = vi.mocked(runWorkflow).mock.calls[0][0] as Record<string, unknown>;
|
|
193
|
-
expect(spec.scriptName).toBe("chain");
|
|
194
|
-
expect(String(spec.scriptPath)).toContain("chain.js");
|
|
195
|
-
expect(result.content[0]?.text).toContain("Started workflow 'chain'");
|
|
196
|
-
});
|
|
197
|
-
|
|
198
|
-
it("真 registry 下未知名(无路径形态)→ not_found(含 fixture 内可用清单)", async () => {
|
|
199
|
-
const registry = new WorkflowScriptRegistryImpl({
|
|
200
|
-
projectDir: join(fixtureDir, "ws", ".pi", "workflows"),
|
|
201
|
-
userDir: join(fixtureDir, "user", "workflows"),
|
|
202
|
-
tmpDir: join(fixtureDir, "ws", ".pi", "workflows", ".tmp"),
|
|
203
|
-
npmDirs: [],
|
|
204
|
-
});
|
|
205
|
-
await expect(
|
|
206
|
-
actionRun(
|
|
207
|
-
{ action: "run", name: "not-a-workflow" } as never,
|
|
208
|
-
makeDeps(registry as unknown as Record<string, unknown>) as never,
|
|
209
|
-
undefined,
|
|
210
|
-
),
|
|
211
|
-
).rejects.toThrow(/Workflow 'not-a-workflow' not found\./);
|
|
212
|
-
expect(vi.mocked(runWorkflow)).not.toHaveBeenCalled();
|
|
213
|
-
});
|
|
214
|
-
|
|
215
|
-
it("fixture 卫生断言:fixture 目录无其他 .js 泄漏(避免 discoverWorkflows 误扫)", () => {
|
|
216
|
-
const projectDir = join(fixtureDir, "ws", ".pi", "workflows");
|
|
217
|
-
expect(readdirSync(projectDir).filter((f) => f.endsWith(".js"))).toEqual(["chain.js"]);
|
|
218
|
-
});
|
|
219
|
-
});
|
|
@@ -1,251 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* actionGenerate 行为测试(m0 wave / TC1-TC7 + [P-generate-roundtrip];C5② 改接 core 管线)
|
|
3
|
-
*
|
|
4
|
-
* C5② 起五道闸校验 + tmp 写盘在 core generateWorkflowScript(barrel import)——本测试
|
|
5
|
-
* 验证宿主契约层(结构化结果 → execute-throw 转换、signal aborted、成功文案)+ 经
|
|
6
|
-
* 真实 core 管线的校验行为回归。
|
|
7
|
-
*
|
|
8
|
-
* mock node:fs(mkdirSync/writeFileSync)避免真实落盘 .pi/workflows/.tmp/(builtin
|
|
9
|
-
* 模块 mock 对 core 管线内的写盘同样生效)。save/delete 走 barrel mock(importActual
|
|
10
|
-
* 展开覆写,其余 barrel 面(含 generateWorkflowScript)保持真实)。
|
|
11
|
-
*
|
|
12
|
-
* 框架:vitest(禁 node:test)。
|
|
13
|
-
*/
|
|
14
|
-
import { mkdirSync, writeFileSync } from "node:fs";
|
|
15
|
-
|
|
16
|
-
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
17
|
-
|
|
18
|
-
import { actionGenerate, type ScriptParams, type TextContent, registerWorkflowScriptTool } from "../tool-workflow-script.ts";
|
|
19
|
-
import { deleteWorkflow, saveWorkflow } from "@zhushanwen/subagent-core";
|
|
20
|
-
|
|
21
|
-
// node:fs 只覆写两个写盘函数、其余保持真实——C5② 后被测链经 barrel 拉起完整 core
|
|
22
|
-
// 依赖图(importActual),core 模块对 existsSync/statSync 等的消费不能被 2 函数工厂截断
|
|
23
|
-
vi.mock("node:fs", async (importOriginal) => ({
|
|
24
|
-
...(await importOriginal<typeof import("node:fs")>()),
|
|
25
|
-
mkdirSync: vi.fn(),
|
|
26
|
-
writeFileSync: vi.fn(),
|
|
27
|
-
}));
|
|
28
|
-
|
|
29
|
-
// C5②:被测模块经 barrel 消费 save/delete/generateWorkflowScript——mock 挂 barrel,
|
|
30
|
-
// importActual 展开保持 generateWorkflowScript 真实(校验管线是被测回归面)
|
|
31
|
-
vi.mock("@zhushanwen/subagent-core", async (importOriginal) => ({
|
|
32
|
-
...(await importOriginal<typeof import("@zhushanwen/subagent-core")>()),
|
|
33
|
-
saveWorkflow: vi.fn(),
|
|
34
|
-
deleteWorkflow: vi.fn(),
|
|
35
|
-
}));
|
|
36
|
-
|
|
37
|
-
const mockedWriteFileSync = vi.mocked(writeFileSync);
|
|
38
|
-
const mockedMkdirSync = vi.mocked(mkdirSync);
|
|
39
|
-
|
|
40
|
-
function gen(script: string, name = "test-wf"): ScriptParams {
|
|
41
|
-
return { action: "generate", name, script } as ScriptParams;
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
/** TextContent.text 在 content[0].text。 */
|
|
45
|
-
function textOf(r: TextContent): string {
|
|
46
|
-
return r.content[0]?.text ?? "";
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
const PI_META_VALID = `/* @pi-meta
|
|
50
|
-
name: test-wf
|
|
51
|
-
description: 合法新格式
|
|
52
|
-
phases: [a, b]
|
|
53
|
-
parameters:
|
|
54
|
-
type: object
|
|
55
|
-
properties:
|
|
56
|
-
task: { type: string }
|
|
57
|
-
required: [task]
|
|
58
|
-
*/
|
|
59
|
-
const agent = require("./agent");
|
|
60
|
-
agent("worker", { task: $ARGS.task });
|
|
61
|
-
`;
|
|
62
|
-
|
|
63
|
-
const PI_META_MALFORMED = `/* @pi-meta
|
|
64
|
-
name: test-wf
|
|
65
|
-
description: bad
|
|
66
|
-
broken: indent
|
|
67
|
-
phases: [a]
|
|
68
|
-
*/
|
|
69
|
-
const agent = require("./agent");
|
|
70
|
-
agent("w");
|
|
71
|
-
`;
|
|
72
|
-
|
|
73
|
-
const PI_META_REGEX_SINGLE_BS = `/* @pi-meta
|
|
74
|
-
name: test-wf
|
|
75
|
-
description: regex
|
|
76
|
-
phases: [A]
|
|
77
|
-
parameters:
|
|
78
|
-
type: object
|
|
79
|
-
patternProperties:
|
|
80
|
-
"^batch\\d+$": { type: string }
|
|
81
|
-
*/
|
|
82
|
-
const agent = require("./agent");
|
|
83
|
-
agent("w");
|
|
84
|
-
`;
|
|
85
|
-
|
|
86
|
-
const LEGACY_CONST_META = `const meta = {
|
|
87
|
-
name: test-wf,
|
|
88
|
-
description: legacy,
|
|
89
|
-
phases: ["a"]
|
|
90
|
-
};
|
|
91
|
-
const agent = require("./agent");
|
|
92
|
-
agent("w");
|
|
93
|
-
`;
|
|
94
|
-
|
|
95
|
-
const NO_META = `const agent = require("./agent");
|
|
96
|
-
agent("w");
|
|
97
|
-
`;
|
|
98
|
-
|
|
99
|
-
const ESM_IMPORT = `/* @pi-meta
|
|
100
|
-
name: x
|
|
101
|
-
description: d
|
|
102
|
-
phases: [a]
|
|
103
|
-
*/
|
|
104
|
-
import { foo } from "bar";
|
|
105
|
-
const agent = require("./agent");
|
|
106
|
-
agent("w");
|
|
107
|
-
`;
|
|
108
|
-
|
|
109
|
-
const NO_AGENT = `/* @pi-meta
|
|
110
|
-
name: x
|
|
111
|
-
description: d
|
|
112
|
-
phases: [a]
|
|
113
|
-
*/
|
|
114
|
-
const x = 1;
|
|
115
|
-
`;
|
|
116
|
-
|
|
117
|
-
describe("actionGenerate (m0: @pi-meta 认可 + round-trip)", () => {
|
|
118
|
-
beforeEach(() => {
|
|
119
|
-
mockedWriteFileSync.mockClear();
|
|
120
|
-
mockedMkdirSync.mockClear();
|
|
121
|
-
});
|
|
122
|
-
afterEach(() => {
|
|
123
|
-
vi.clearAllMocks();
|
|
124
|
-
});
|
|
125
|
-
|
|
126
|
-
/**
|
|
127
|
-
* W4b:generate 校验族错误路径从 return {isError:true} 改为 throw(pi 只对
|
|
128
|
-
* execute throw 置 isError:true,返回值 isError 被 agent-loop 丢弃)。
|
|
129
|
-
* actionGenerate 是同步函数——断言用同步 toThrow。
|
|
130
|
-
*/
|
|
131
|
-
|
|
132
|
-
it("TC1: 合法 @pi-meta → ready + writeFileSync 被调用", () => {
|
|
133
|
-
const r = actionGenerate(gen(PI_META_VALID), undefined);
|
|
134
|
-
expect(r.isError).toBeFalsy();
|
|
135
|
-
expect(textOf(r)).toMatch(/ready|generated|test-wf/i);
|
|
136
|
-
expect(mockedWriteFileSync).toHaveBeenCalledTimes(1);
|
|
137
|
-
});
|
|
138
|
-
|
|
139
|
-
it("TC2: malformed @pi-meta YAML → throw + writeFileSync 未调用 [P-generate-roundtrip]", () => {
|
|
140
|
-
expect(() => actionGenerate(gen(PI_META_MALFORMED), undefined)).toThrow(
|
|
141
|
-
/cannot be parsed/i,
|
|
142
|
-
);
|
|
143
|
-
expect(mockedWriteFileSync).not.toHaveBeenCalled();
|
|
144
|
-
});
|
|
145
|
-
|
|
146
|
-
it("TC3: legacy const meta(过渡期)→ ready + writeFileSync 被调用", () => {
|
|
147
|
-
const r = actionGenerate(gen(LEGACY_CONST_META), undefined);
|
|
148
|
-
expect(r.isError).toBeFalsy();
|
|
149
|
-
expect(mockedWriteFileSync).toHaveBeenCalledTimes(1);
|
|
150
|
-
});
|
|
151
|
-
|
|
152
|
-
it("TC4: 无 meta → throw 提及 @pi-meta 新格式", () => {
|
|
153
|
-
expect(() => actionGenerate(gen(NO_META), undefined)).toThrow(/meta declaration/i);
|
|
154
|
-
expect(mockedWriteFileSync).not.toHaveBeenCalled();
|
|
155
|
-
});
|
|
156
|
-
|
|
157
|
-
it("TC5: @pi-meta 单反斜杠正则 → throw(LLM 高频错)[P-generate-roundtrip]", () => {
|
|
158
|
-
expect(() => actionGenerate(gen(PI_META_REGEX_SINGLE_BS), undefined)).toThrow(
|
|
159
|
-
/escape|cannot be parsed/i,
|
|
160
|
-
);
|
|
161
|
-
expect(mockedWriteFileSync).not.toHaveBeenCalled();
|
|
162
|
-
});
|
|
163
|
-
|
|
164
|
-
it("TC6: ESM import → throw(保留现有行为)", () => {
|
|
165
|
-
expect(() => actionGenerate(gen(ESM_IMPORT), undefined)).toThrow(/ESM|import/i);
|
|
166
|
-
});
|
|
167
|
-
|
|
168
|
-
it("TC7: 无 agent() → throw(保留现有行为)", () => {
|
|
169
|
-
expect(() => actionGenerate(gen(NO_AGENT), undefined)).toThrow(/agent\(\)/i);
|
|
170
|
-
});
|
|
171
|
-
|
|
172
|
-
it("TC8: signal aborted → throw(保留现有行为)", () => {
|
|
173
|
-
const controller = new AbortController();
|
|
174
|
-
controller.abort();
|
|
175
|
-
expect(() => actionGenerate(gen(PI_META_VALID), controller.signal)).toThrow(/abort/i);
|
|
176
|
-
});
|
|
177
|
-
|
|
178
|
-
it("TC9: 缺 name/script 参数 → throw 'generate requires'(防御性,schema 先拦)", () => {
|
|
179
|
-
expect(() => actionGenerate({ action: "generate" } as ScriptParams, undefined)).toThrow(
|
|
180
|
-
"generate requires 'name' and 'script' parameters",
|
|
181
|
-
);
|
|
182
|
-
});
|
|
183
|
-
|
|
184
|
-
it("TC10: ESM export(非 meta)→ throw(W4b 收敛路径)", () => {
|
|
185
|
-
const script = `/* @pi-meta
|
|
186
|
-
name: x
|
|
187
|
-
description: d
|
|
188
|
-
phases: [a]
|
|
189
|
-
*/
|
|
190
|
-
const agent = require("./agent");
|
|
191
|
-
export const foo = 1;
|
|
192
|
-
agent("w");
|
|
193
|
-
`;
|
|
194
|
-
expect(() => actionGenerate(gen(script), undefined)).toThrow(/ESM 'export'/i);
|
|
195
|
-
});
|
|
196
|
-
});
|
|
197
|
-
|
|
198
|
-
describe("actionSave/actionDelete error paths (W4: throw 范式)", () => {
|
|
199
|
-
/**
|
|
200
|
-
* W4:save/delete 失败路径从 return {isError:true} 改为 throw——pi 只对 execute
|
|
201
|
-
* throw 置 isError:true(agent-loop.js:453-483 丢弃返回值里的 isError)。
|
|
202
|
-
* 经 registerWorkflowScriptTool 注册层测(mock workflow-files 的 FS 依赖)。
|
|
203
|
-
*/
|
|
204
|
-
interface CapturedTool {
|
|
205
|
-
execute: (
|
|
206
|
-
toolCallId: string,
|
|
207
|
-
params: Record<string, unknown>,
|
|
208
|
-
signal: AbortSignal | undefined,
|
|
209
|
-
onUpdate: unknown,
|
|
210
|
-
ctx: unknown,
|
|
211
|
-
) => Promise<TextContent>;
|
|
212
|
-
}
|
|
213
|
-
function captureTool(): CapturedTool {
|
|
214
|
-
const tools: CapturedTool[] = [];
|
|
215
|
-
const pi = { registerTool: (t: unknown) => tools.push(t as CapturedTool) };
|
|
216
|
-
// registry 最小 stub:delete 成功路径会调 invalidate(失败路径 throw 前不触达)
|
|
217
|
-
const registry = { invalidate: vi.fn() };
|
|
218
|
-
registerWorkflowScriptTool(pi as never, registry as never, () => false);
|
|
219
|
-
if (!tools[0]) throw new Error("registerWorkflowScriptTool did not register");
|
|
220
|
-
return tools[0];
|
|
221
|
-
}
|
|
222
|
-
const ctx = { mode: "tui" as const, hasUI: true };
|
|
223
|
-
|
|
224
|
-
it("save 失败 → throw 'Save failed: <原因>'(pi catch 后置 isError:true)", async () => {
|
|
225
|
-
vi.mocked(saveWorkflow).mockRejectedValueOnce(new Error("disk full"));
|
|
226
|
-
const tool = captureTool();
|
|
227
|
-
await expect(
|
|
228
|
-
tool.execute("id", { action: "save", name: "tmp-wf" }, undefined, undefined, ctx),
|
|
229
|
-
).rejects.toThrow("Save failed: disk full");
|
|
230
|
-
});
|
|
231
|
-
|
|
232
|
-
it("delete 失败 → throw 'Delete failed: <原因>'", async () => {
|
|
233
|
-
// deleteWorkflow 是同步函数——mock 用同步 throw(mockRejectedValue 不会被
|
|
234
|
-
// actionDelete 的同步 try/catch 捕获)
|
|
235
|
-
vi.mocked(deleteWorkflow).mockImplementationOnce(() => {
|
|
236
|
-
throw new Error("script is running");
|
|
237
|
-
});
|
|
238
|
-
const tool = captureTool();
|
|
239
|
-
await expect(
|
|
240
|
-
tool.execute("id", { action: "delete", name: "tmp-wf" }, undefined, undefined, ctx),
|
|
241
|
-
).rejects.toThrow("Delete failed: script is running");
|
|
242
|
-
});
|
|
243
|
-
|
|
244
|
-
it("save 成功路径不受影响(ok details 正常返回)", async () => {
|
|
245
|
-
vi.mocked(saveWorkflow).mockResolvedValueOnce("saved tmp-wf");
|
|
246
|
-
const tool = captureTool();
|
|
247
|
-
const r = await tool.execute("id", { action: "save", name: "tmp-wf" }, undefined, undefined, ctx);
|
|
248
|
-
expect(r.isError).toBeFalsy();
|
|
249
|
-
expect(r.details).toMatchObject({ action: "save", name: "tmp-wf", ok: true });
|
|
250
|
-
});
|
|
251
|
-
});
|