@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
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* meta-parser 测试(v5 §11.1 / TC1-TC10 + 边界 + exec-review 回归)
|
|
3
|
+
*
|
|
4
|
+
* [P-yaml] 探针由 TC5/TC10 承载:验证 eemeli/yaml 真实抛错对象有 linePos 字段且结构为
|
|
5
|
+
* {line,col}(取自 linePos[0])。不 mock parseYaml——必须用真实库验证此运行时断言。
|
|
6
|
+
*
|
|
7
|
+
* exec-review 回归:major-1(星斜杠截断)+ minor-2(kind 串类)+ minor-3(description 必填)
|
|
8
|
+
* + minor-4(detail 非字符串)+ minor-5(parameters 非对象)+ minor-6(空块 vs 缺块)+ minor-7(CRLF)。
|
|
9
|
+
*
|
|
10
|
+
* 框架:vitest(项目规范:禁 node:test,import from "vitest")。
|
|
11
|
+
*/
|
|
12
|
+
import { describe, expect, it } from "vitest";
|
|
13
|
+
|
|
14
|
+
import {
|
|
15
|
+
parseResourceMeta,
|
|
16
|
+
parseResourceMetaDetailed,
|
|
17
|
+
} from "../meta-parser.ts";
|
|
18
|
+
import type { AgentMeta, WorkflowMeta } from "../resource-meta.ts";
|
|
19
|
+
|
|
20
|
+
// ── 固定 content fixtures ────────────────────────────────────
|
|
21
|
+
|
|
22
|
+
const VALID_WORKFLOW = `// 顶部可有任意代码注释
|
|
23
|
+
/* @pi-meta
|
|
24
|
+
name: chain
|
|
25
|
+
description: 通用编排三步链
|
|
26
|
+
when: 用户要顺序编排
|
|
27
|
+
phases: [analyze, transform, synthesize]
|
|
28
|
+
parameters:
|
|
29
|
+
type: object
|
|
30
|
+
properties:
|
|
31
|
+
task: { type: string }
|
|
32
|
+
required: [task]
|
|
33
|
+
usage: |
|
|
34
|
+
## 使用说明
|
|
35
|
+
- task 必填
|
|
36
|
+
*/
|
|
37
|
+
const agent = require("./agent");
|
|
38
|
+
`;
|
|
39
|
+
|
|
40
|
+
const VALID_AGENT = `---
|
|
41
|
+
name: code-review
|
|
42
|
+
description: 审查代码变更
|
|
43
|
+
when: 用户要求 review
|
|
44
|
+
notFor: 非 xyz-agent 项目
|
|
45
|
+
examples:
|
|
46
|
+
- match: "帮我 review 一下"
|
|
47
|
+
action: 调用 code-review
|
|
48
|
+
positive: true
|
|
49
|
+
- match: 今天天气
|
|
50
|
+
action: 不调用
|
|
51
|
+
positive: false
|
|
52
|
+
tools: [read, bash, grep]
|
|
53
|
+
model: claude-3.5
|
|
54
|
+
---
|
|
55
|
+
# code-review agent 正文
|
|
56
|
+
`;
|
|
57
|
+
|
|
58
|
+
const MALFORMED_INDENT = `/* @pi-meta
|
|
59
|
+
name: x
|
|
60
|
+
description: bad
|
|
61
|
+
description: bad indent
|
|
62
|
+
phases: [a]
|
|
63
|
+
*/`;
|
|
64
|
+
|
|
65
|
+
const REGEX_SINGLE_BACKSLASH = `/* @pi-meta
|
|
66
|
+
name: rfl
|
|
67
|
+
description: 测试正则转义
|
|
68
|
+
phases: [Review, Fix]
|
|
69
|
+
parameters:
|
|
70
|
+
type: object
|
|
71
|
+
patternProperties:
|
|
72
|
+
"^batch\\d+$": { type: string }
|
|
73
|
+
*/`;
|
|
74
|
+
|
|
75
|
+
const LEGACY_CONST_META = `const meta = {
|
|
76
|
+
name: chain,
|
|
77
|
+
description: 旧格式,
|
|
78
|
+
phases: ["a"]
|
|
79
|
+
};
|
|
80
|
+
const agent = require("./agent");
|
|
81
|
+
`;
|
|
82
|
+
|
|
83
|
+
const NO_META_BLOCK = `const agent = require("./agent");
|
|
84
|
+
// 纯代码,无 @pi-meta
|
|
85
|
+
`;
|
|
86
|
+
|
|
87
|
+
const EMPTY_PI_META = `/* @pi-meta
|
|
88
|
+
|
|
89
|
+
*/
|
|
90
|
+
const x = 1;
|
|
91
|
+
`;
|
|
92
|
+
|
|
93
|
+
const EMPTY_PHASES = `/* @pi-meta
|
|
94
|
+
name: x
|
|
95
|
+
description: 空 phases
|
|
96
|
+
phases: []
|
|
97
|
+
*/`;
|
|
98
|
+
|
|
99
|
+
// ── parseResourceMeta(IF1,discovery)──────────────────────
|
|
100
|
+
|
|
101
|
+
describe("parseResourceMeta (discovery, fail-safe null)", () => {
|
|
102
|
+
it("TC1: 合法 workflow @pi-meta 返 WorkflowMeta", () => {
|
|
103
|
+
const meta = parseResourceMeta(VALID_WORKFLOW, "workflow");
|
|
104
|
+
expect(meta).not.toBeNull();
|
|
105
|
+
const wf = meta as WorkflowMeta;
|
|
106
|
+
expect(wf.kind).toBe("workflow");
|
|
107
|
+
expect(wf.name).toBe("chain");
|
|
108
|
+
expect(wf.phases).toEqual(["analyze", "transform", "synthesize"]);
|
|
109
|
+
expect(wf.when).toBe("用户要顺序编排");
|
|
110
|
+
expect(wf.parameters).toBeDefined();
|
|
111
|
+
expect(wf.usage).toContain("task 必填");
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it("TC2: 合法 agent frontmatter 返 AgentMeta", () => {
|
|
115
|
+
const meta = parseResourceMeta(VALID_AGENT, "agent");
|
|
116
|
+
expect(meta).not.toBeNull();
|
|
117
|
+
const a = meta as AgentMeta;
|
|
118
|
+
expect(a.kind).toBe("agent");
|
|
119
|
+
expect(a.name).toBe("code-review");
|
|
120
|
+
expect(a.notFor).toBe("非 xyz-agent 项目");
|
|
121
|
+
expect(a.examples).toHaveLength(2);
|
|
122
|
+
expect(a.examples![0]).toEqual({ match: "帮我 review 一下", action: "调用 code-review", positive: true });
|
|
123
|
+
expect(a.examples![1]!.positive).toBe(false);
|
|
124
|
+
expect(a.tools).toEqual(["read", "bash", "grep"]);
|
|
125
|
+
expect(a.model).toBe("claude-3.5");
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it("TC3: malformed YAML(缩进错)返 null,不抛 [P-yaml]", () => {
|
|
129
|
+
expect(() => parseResourceMeta(MALFORMED_INDENT, "workflow")).not.toThrow();
|
|
130
|
+
expect(parseResourceMeta(MALFORMED_INDENT, "workflow")).toBeNull();
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it("TC4: 缺 meta 块返 null", () => {
|
|
134
|
+
expect(parseResourceMeta(NO_META_BLOCK, "workflow")).toBeNull();
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it("TC8(修正): workflow 字段(phases)当 agent kind 解析,typecheckMeta 串类 reject [minor-2]", () => {
|
|
138
|
+
// 用 frontmatter 包裹 workflow 字段,确保 FRONTMATTER_RE 匹配、typecheckMeta 真正被触达
|
|
139
|
+
const content = `---
|
|
140
|
+
name: chainlike
|
|
141
|
+
description: has phases but parsed as agent
|
|
142
|
+
phases: [analyze, transform]
|
|
143
|
+
parameters:
|
|
144
|
+
type: object
|
|
145
|
+
---
|
|
146
|
+
# agent body`;
|
|
147
|
+
expect(parseResourceMeta(content, "agent")).toBeNull();
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it("TC8b: agent 字段(examples)当 workflow kind 解析,串类 reject [minor-2]", () => {
|
|
151
|
+
const content = `/* @pi-meta
|
|
152
|
+
name: x
|
|
153
|
+
description: has examples but workflow
|
|
154
|
+
phases: [A]
|
|
155
|
+
examples:
|
|
156
|
+
- match: m
|
|
157
|
+
action: a
|
|
158
|
+
positive: true
|
|
159
|
+
*/`;
|
|
160
|
+
expect(parseResourceMeta(content, "workflow")).toBeNull();
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it("TC9: legacy const meta(无 @pi-meta 块)返 null(无 adapter)[D1]", () => {
|
|
164
|
+
expect(parseResourceMeta(LEGACY_CONST_META, "workflow")).toBeNull();
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it("TC10: patternProperties 单反斜杠正则致 YAML 解析失败返 null [P-yaml]", () => {
|
|
168
|
+
expect(parseResourceMeta(REGEX_SINGLE_BACKSLASH, "workflow")).toBeNull();
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it("边界:空 @pi-meta 块(内容为空)返 null", () => {
|
|
172
|
+
expect(parseResourceMeta(EMPTY_PI_META, "workflow")).toBeNull();
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it("边界:phases: [] 空数组合法(workflow 可无 phase)", () => {
|
|
176
|
+
const meta = parseResourceMeta(EMPTY_PHASES, "workflow");
|
|
177
|
+
expect(meta).not.toBeNull();
|
|
178
|
+
expect((meta as WorkflowMeta).phases).toEqual([]);
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
// ── exec-review 回归 ──────────────────────────────────────
|
|
182
|
+
|
|
183
|
+
it("major-1: usage 块标量含 */ 不再截断,parameters 不丢失", () => {
|
|
184
|
+
const content = `/* @pi-meta
|
|
185
|
+
name: x
|
|
186
|
+
description: d
|
|
187
|
+
phases: [A]
|
|
188
|
+
when: present
|
|
189
|
+
usage: |
|
|
190
|
+
see */ for details
|
|
191
|
+
parameters:
|
|
192
|
+
type: object
|
|
193
|
+
properties:
|
|
194
|
+
task: { type: string }
|
|
195
|
+
*/`;
|
|
196
|
+
const meta = parseResourceMeta(content, "workflow");
|
|
197
|
+
expect(meta).not.toBeNull();
|
|
198
|
+
const wf = meta as WorkflowMeta;
|
|
199
|
+
expect(wf.parameters).toBeDefined(); // major-1:此前被 */ 截断静默丢失
|
|
200
|
+
expect(wf.when).toBe("present"); // 此前同样丢失
|
|
201
|
+
expect(wf.usage).toContain("see */ for details");
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
it("major-1b: patternProperties 正则含 * 后接 / 不再误截断", () => {
|
|
205
|
+
const content = `/* @pi-meta
|
|
206
|
+
name: re
|
|
207
|
+
description: regex
|
|
208
|
+
phases: [A]
|
|
209
|
+
parameters:
|
|
210
|
+
type: object
|
|
211
|
+
patternProperties:
|
|
212
|
+
"^\\\\d*/\\\\w+$": { type: string }
|
|
213
|
+
*/`;
|
|
214
|
+
const meta = parseResourceMeta(content, "workflow");
|
|
215
|
+
expect(meta).not.toBeNull();
|
|
216
|
+
expect((meta as WorkflowMeta).parameters).toBeDefined();
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
it("minor-3: 缺 description 返 null(必填,不再静默空串)", () => {
|
|
220
|
+
const content = `/* @pi-meta
|
|
221
|
+
name: nodesc
|
|
222
|
+
phases: [A]
|
|
223
|
+
*/`;
|
|
224
|
+
expect(parseResourceMeta(content, "workflow")).toBeNull();
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
it("minor-4: phase detail 非字符串返 null(不再静默丢弃)", () => {
|
|
228
|
+
const content = `/* @pi-meta
|
|
229
|
+
name: x
|
|
230
|
+
description: d
|
|
231
|
+
phases:
|
|
232
|
+
- title: A
|
|
233
|
+
detail: 123
|
|
234
|
+
*/`;
|
|
235
|
+
expect(parseResourceMeta(content, "workflow")).toBeNull();
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
it("minor-5: parameters 是数组返 null(不再静默当 undefined)", () => {
|
|
239
|
+
const content = `/* @pi-meta
|
|
240
|
+
name: x
|
|
241
|
+
description: d
|
|
242
|
+
phases: [A]
|
|
243
|
+
parameters: [a, b]
|
|
244
|
+
*/`;
|
|
245
|
+
expect(parseResourceMeta(content, "workflow")).toBeNull();
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
it("minor-7: CRLF 行尾 frontmatter 正确提取", () => {
|
|
249
|
+
const content = "---\r\nname: a\r\ndescription: d\r\n---\r\n# body";
|
|
250
|
+
const meta = parseResourceMeta(content, "agent");
|
|
251
|
+
expect(meta).not.toBeNull();
|
|
252
|
+
expect((meta as AgentMeta).name).toBe("a");
|
|
253
|
+
});
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
// ── parseResourceMetaDetailed(IF2,generate 闭环)──────────
|
|
257
|
+
|
|
258
|
+
describe("parseResourceMetaDetailed (generate, linePos)", () => {
|
|
259
|
+
it("TC6: 合法返 {ok:true, meta}", () => {
|
|
260
|
+
const r = parseResourceMetaDetailed(VALID_WORKFLOW, "workflow");
|
|
261
|
+
expect(r.ok).toBe(true);
|
|
262
|
+
if (r.ok) expect(r.meta.name).toBe("chain");
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
it("TC5: malformed YAML 返 {ok:false, error, linePos:{line,col}} [P-yaml]", () => {
|
|
266
|
+
const r = parseResourceMetaDetailed(MALFORMED_INDENT, "workflow");
|
|
267
|
+
expect(r.ok).toBe(false);
|
|
268
|
+
if (!r.ok) {
|
|
269
|
+
expect(r.error).toBeTruthy();
|
|
270
|
+
expect(r.linePos).toBeDefined();
|
|
271
|
+
expect(typeof r.linePos!.line).toBe("number");
|
|
272
|
+
expect(typeof r.linePos!.col).toBe("number");
|
|
273
|
+
expect(r.linePos!.line).toBeGreaterThan(0);
|
|
274
|
+
}
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
it("TC7: 缺块返 {ok:false, error} 无 linePos", () => {
|
|
278
|
+
const r = parseResourceMetaDetailed(NO_META_BLOCK, "workflow");
|
|
279
|
+
expect(r.ok).toBe(false);
|
|
280
|
+
if (!r.ok) expect(r.linePos).toBeUndefined();
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
it("TC10: 单反斜杠正则返 {ok:false, error 含 escape, linePos} [P-yaml]", () => {
|
|
284
|
+
const r = parseResourceMetaDetailed(REGEX_SINGLE_BACKSLASH, "workflow");
|
|
285
|
+
expect(r.ok).toBe(false);
|
|
286
|
+
if (!r.ok) {
|
|
287
|
+
expect(r.error).toContain("escape");
|
|
288
|
+
expect(r.linePos).toBeDefined();
|
|
289
|
+
expect(r.linePos!.line).toBeGreaterThan(0);
|
|
290
|
+
}
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
it("minor-6: 空块与缺块错误消息区分(空块不报「未找到」)", () => {
|
|
294
|
+
const empty = parseResourceMetaDetailed(EMPTY_PI_META, "workflow");
|
|
295
|
+
const missing = parseResourceMetaDetailed(NO_META_BLOCK, "workflow");
|
|
296
|
+
expect(empty.ok).toBe(false);
|
|
297
|
+
expect(missing.ok).toBe(false);
|
|
298
|
+
if (!empty.ok && !missing.ok) {
|
|
299
|
+
// 缺块报「未找到 meta 块」;空块报类型校验失败(块在但内容空/非法)
|
|
300
|
+
expect(missing.error).toContain("未找到");
|
|
301
|
+
expect(empty.error).not.toContain("未找到");
|
|
302
|
+
}
|
|
303
|
+
});
|
|
304
|
+
});
|
|
@@ -6,13 +6,23 @@ import * as fs from "node:fs";
|
|
|
6
6
|
import * as os from "node:os";
|
|
7
7
|
import * as path from "node:path";
|
|
8
8
|
|
|
9
|
-
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
9
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
10
|
+
|
|
11
|
+
// 隔离真实用户全局目录:resource-discovery 用 homedir() 推导 user-agents 源
|
|
12
|
+
// (~/.agents/agents/),测试环境可能存在真实 agent 文件(如 tech-design-review.md),
|
|
13
|
+
// 不 mock 会导致「期望空列表/精确列表」用例被环境污染(2026-08 实测 4 个失败)。
|
|
14
|
+
vi.mock("node:os", async (importOriginal) => {
|
|
15
|
+
const actual = await importOriginal<typeof import("node:os")>();
|
|
16
|
+
return { ...actual, homedir: () => "/nonexistent-home-for-tests" };
|
|
17
|
+
});
|
|
10
18
|
|
|
11
19
|
import {
|
|
12
20
|
discoverResources,
|
|
13
21
|
discoverResourcesSync,
|
|
14
22
|
findWorkspaceRoot,
|
|
15
23
|
processPackageSync,
|
|
24
|
+
getCachedFile,
|
|
25
|
+
getCachedFileContent,
|
|
16
26
|
} from "../resource-discovery.ts";
|
|
17
27
|
|
|
18
28
|
// ============================================================
|
|
@@ -79,8 +89,11 @@ describe("discoverResourcesSync", () => {
|
|
|
79
89
|
it("discovers agents from project .pi/agents/", () => {
|
|
80
90
|
writeFile(path.join(ws, ".pi", "agents"), "worker.md", "body");
|
|
81
91
|
const result = discoverResourcesSync({ kind: "agents", workspaceRoot: ws, agentDir });
|
|
82
|
-
|
|
83
|
-
|
|
92
|
+
// 按 source 过滤断言——用户全局目录(~/.agents/agents/)可能有真实 agent 文件,
|
|
93
|
+
// 测试不假设环境为空(2026-08:环境新增 tech-design-review.md 暴露此脆弱性)
|
|
94
|
+
const project = result.filter((r) => r.source === "project-pi");
|
|
95
|
+
expect(project.map((r) => path.basename(r.path))).toEqual(["worker.md"]);
|
|
96
|
+
expect(project[0]?.available).toBe(true);
|
|
84
97
|
});
|
|
85
98
|
|
|
86
99
|
it("discovers workflows from project .pi/workflows/", () => {
|
|
@@ -93,8 +106,9 @@ describe("discoverResourcesSync", () => {
|
|
|
93
106
|
writeFile(path.join(ws, ".pi", "agents"), "worker.md", "pi-body");
|
|
94
107
|
writeFile(path.join(ws, ".agents", "agents"), "worker.md", "agents-body");
|
|
95
108
|
const result = discoverResourcesSync({ kind: "agents", workspaceRoot: ws, agentDir });
|
|
96
|
-
|
|
97
|
-
expect(
|
|
109
|
+
const project = result.filter((r) => r.source === "project-agents");
|
|
110
|
+
expect(project).toHaveLength(1);
|
|
111
|
+
expect(project[0]?.source).toBe("project-agents");
|
|
98
112
|
});
|
|
99
113
|
|
|
100
114
|
it("includes tmp source for workflows when includeTmp=true", () => {
|
|
@@ -121,12 +135,14 @@ describe("discoverResourcesSync", () => {
|
|
|
121
135
|
writeFile(dir, "_skip.md", "ignored");
|
|
122
136
|
writeFile(dir, "trace.chain.md", "ignored");
|
|
123
137
|
const result = discoverResourcesSync({ kind: "agents", workspaceRoot: ws, agentDir });
|
|
124
|
-
|
|
138
|
+
const project = result.filter((r) => r.source === "project-pi");
|
|
139
|
+
expect(project.map((r) => path.basename(r.path))).toEqual(["real.md"]);
|
|
125
140
|
});
|
|
126
141
|
|
|
127
142
|
it("nonexistent directories are silently skipped", () => {
|
|
128
143
|
const result = discoverResourcesSync({ kind: "agents", workspaceRoot: ws, agentDir });
|
|
129
|
-
|
|
144
|
+
// 用户全局目录可能有真实文件——只断言 project 源为空
|
|
145
|
+
expect(result.filter((r) => r.source === "project-pi" || r.source === "project-agents")).toEqual([]);
|
|
130
146
|
});
|
|
131
147
|
});
|
|
132
148
|
|
|
@@ -224,3 +240,147 @@ describe("discoverResources (async)", () => {
|
|
|
224
240
|
expect(result).toHaveLength(2);
|
|
225
241
|
});
|
|
226
242
|
});
|
|
243
|
+
|
|
244
|
+
// ============================================================
|
|
245
|
+
// user-extension-paths (XYZ_EXTENSION_PATHS) — dev-link 扩展发现
|
|
246
|
+
// ============================================================
|
|
247
|
+
|
|
248
|
+
describe("user-extension-paths (XYZ_EXTENSION_PATHS)", () => {
|
|
249
|
+
let ws: string;
|
|
250
|
+
let agentDir: string;
|
|
251
|
+
let savedEnv: string | undefined;
|
|
252
|
+
|
|
253
|
+
beforeEach(() => {
|
|
254
|
+
ws = tmpWorkspace();
|
|
255
|
+
agentDir = path.join(ws, ".fake-agent");
|
|
256
|
+
savedEnv = process.env.XYZ_EXTENSION_PATHS;
|
|
257
|
+
});
|
|
258
|
+
afterEach(() => {
|
|
259
|
+
if (savedEnv === undefined) delete process.env.XYZ_EXTENSION_PATHS;
|
|
260
|
+
else process.env.XYZ_EXTENSION_PATHS = savedEnv;
|
|
261
|
+
fs.rmSync(ws, { recursive: true, force: true });
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
it("discovers agents from XYZ_EXTENSION_PATHS via pi.agents manifest", () => {
|
|
265
|
+
const pkgDir = path.join(ws, "my-ext");
|
|
266
|
+
writePackageJson(pkgDir, { agents: ["./agents"] });
|
|
267
|
+
writeFile(path.join(pkgDir, "agents"), "custom.md", "body");
|
|
268
|
+
process.env.XYZ_EXTENSION_PATHS = pkgDir;
|
|
269
|
+
const result = discoverResourcesSync({ kind: "agents", workspaceRoot: ws, agentDir });
|
|
270
|
+
const ext = result.filter((r) => r.source === "user-extension-paths");
|
|
271
|
+
expect(ext.map((r) => path.basename(r.path))).toEqual(["custom.md"]);
|
|
272
|
+
expect(ext[0]?.source).toBe("user-extension-paths");
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
it("discovers agents via convention dir (no manifest)", () => {
|
|
276
|
+
const pkgDir = path.join(ws, "my-ext");
|
|
277
|
+
writeFile(path.join(pkgDir, "agents"), "conv.md", "body");
|
|
278
|
+
process.env.XYZ_EXTENSION_PATHS = pkgDir;
|
|
279
|
+
const result = discoverResourcesSync({ kind: "agents", workspaceRoot: ws, agentDir });
|
|
280
|
+
const ext = result.filter((r) => r.source === "user-extension-paths");
|
|
281
|
+
expect(ext.map((r) => path.basename(r.path))).toEqual(["conv.md"]);
|
|
282
|
+
expect(ext[0]?.source).toBe("user-extension-paths");
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
it("multiple paths separated by delimiter", () => {
|
|
286
|
+
const pkg1 = path.join(ws, "ext1");
|
|
287
|
+
const pkg2 = path.join(ws, "ext2");
|
|
288
|
+
writeFile(path.join(pkg1, "agents"), "a1.md", "body");
|
|
289
|
+
writeFile(path.join(pkg2, "agents"), "a2.md", "body");
|
|
290
|
+
process.env.XYZ_EXTENSION_PATHS = `${pkg1}${path.delimiter}${pkg2}`;
|
|
291
|
+
const result = discoverResourcesSync({ kind: "agents", workspaceRoot: ws, agentDir });
|
|
292
|
+
const ext = result.filter((r) => r.source === "user-extension-paths");
|
|
293
|
+
expect(ext.map((r) => path.basename(r.path)).sort()).toEqual(["a1.md", "a2.md"]);
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
it("overrides npm on name clash (priority: user-extension-paths > npm)", () => {
|
|
297
|
+
const npmPkg = path.join(agentDir, "npm", "node_modules", "test-pkg");
|
|
298
|
+
writePackageJson(npmPkg, { agents: ["./agents"] });
|
|
299
|
+
writeFile(path.join(npmPkg, "agents"), "shared.md", "npm-body");
|
|
300
|
+
const devPkg = path.join(ws, "dev-ext");
|
|
301
|
+
writePackageJson(devPkg, { agents: ["./agents"] });
|
|
302
|
+
writeFile(path.join(devPkg, "agents"), "shared.md", "dev-body");
|
|
303
|
+
process.env.XYZ_EXTENSION_PATHS = devPkg;
|
|
304
|
+
const result = discoverResourcesSync({ kind: "agents", workspaceRoot: ws, agentDir });
|
|
305
|
+
const shared = result.find((r) => path.basename(r.path) === "shared.md");
|
|
306
|
+
expect(shared?.source).toBe("user-extension-paths");
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
it("project-agents overrides user-extension-paths (project wins)", () => {
|
|
310
|
+
const devPkg = path.join(ws, "dev-ext");
|
|
311
|
+
writePackageJson(devPkg, { agents: ["./agents"] });
|
|
312
|
+
writeFile(path.join(devPkg, "agents"), "x.md", "dev-body");
|
|
313
|
+
writeFile(path.join(ws, ".agents", "agents"), "x.md", "project-body");
|
|
314
|
+
process.env.XYZ_EXTENSION_PATHS = devPkg;
|
|
315
|
+
const result = discoverResourcesSync({ kind: "agents", workspaceRoot: ws, agentDir });
|
|
316
|
+
const x = result.find((r) => path.basename(r.path) === "x.md");
|
|
317
|
+
expect(x?.source).toBe("project-agents");
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
it("empty/unset env → no user-extension-paths source", () => {
|
|
321
|
+
delete process.env.XYZ_EXTENSION_PATHS;
|
|
322
|
+
const result = discoverResourcesSync({ kind: "agents", workspaceRoot: ws, agentDir });
|
|
323
|
+
expect(result.filter((r) => r.source === "user-extension-paths")).toEqual([]);
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
it("async discoverResources also scans user-extension-paths", async () => {
|
|
327
|
+
const pkgDir = path.join(ws, "my-ext");
|
|
328
|
+
writePackageJson(pkgDir, { agents: ["./agents"] });
|
|
329
|
+
writeFile(path.join(pkgDir, "agents"), "async.md", "body");
|
|
330
|
+
process.env.XYZ_EXTENSION_PATHS = pkgDir;
|
|
331
|
+
const result = await discoverResources({ kind: "agents", workspaceRoot: ws, agentDir });
|
|
332
|
+
const ext = result.filter((r) => r.source === "user-extension-paths");
|
|
333
|
+
expect(ext.map((r) => path.basename(r.path))).toEqual(["async.md"]);
|
|
334
|
+
expect(ext[0]?.source).toBe("user-extension-paths");
|
|
335
|
+
});
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
// ── m5 TC4/TC5: 统一 mtime 缓存层(P-cache / P-cache-invalidation) ──
|
|
339
|
+
|
|
340
|
+
describe("m5: 统一 mtime 缓存层", () => {
|
|
341
|
+
it("TC4a: P-cache——文件未变时二次读取命中同一缓存条目(对象引用)", () => {
|
|
342
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "m5-cache-"));
|
|
343
|
+
const f = path.join(dir, "a.md");
|
|
344
|
+
fs.writeFileSync(f, "---\nname: x\ndescription: y\n---\nbody", "utf-8");
|
|
345
|
+
try {
|
|
346
|
+
// ESM 下 node:fs 不可 spy(vitest 限制)——命中断言用对象引用:
|
|
347
|
+
// getCachedFile 命中时返回缓存条目对象本身(非新建)
|
|
348
|
+
const first = getCachedFile(f);
|
|
349
|
+
const second = getCachedFile(f);
|
|
350
|
+
expect(first?.content).toBe("---\nname: x\ndescription: y\n---\nbody");
|
|
351
|
+
expect(second).toBe(first); // 同引用 = 命中缓存(未重 read)
|
|
352
|
+
} finally {
|
|
353
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
354
|
+
}
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
it("TC4b: P-cache——两处消费方(getCachedFile/getCachedFileContent)共享同一缓存条目", () => {
|
|
358
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "m5-cache2-"));
|
|
359
|
+
const f = path.join(dir, "a.md");
|
|
360
|
+
fs.writeFileSync(f, "---\nname: x\ndescription: y\n---\nbody", "utf-8");
|
|
361
|
+
try {
|
|
362
|
+
const a = getCachedFile(f);
|
|
363
|
+
const b = getCachedFileContent(f);
|
|
364
|
+
// 内容一致性(命中语义由 TC4a 的对象引用断言覆盖——此处验证两 API 一致)
|
|
365
|
+
expect(a?.content).toBe(b);
|
|
366
|
+
} finally {
|
|
367
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
368
|
+
}
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
it("TC5: P-cache-invalidation——改内容(mtime 变)后重读新内容;删文件后驱逐", () => {
|
|
372
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "m5-cache3-"));
|
|
373
|
+
const f = path.join(dir, "a.md");
|
|
374
|
+
fs.writeFileSync(f, "v1", "utf-8");
|
|
375
|
+
try {
|
|
376
|
+
expect(getCachedFileContent(f)).toBe("v1");
|
|
377
|
+
fs.writeFileSync(f, "v2", "utf-8");
|
|
378
|
+
expect(getCachedFileContent(f)).toBe("v2"); // mtime 变 → 重读
|
|
379
|
+
// 删除 → 驱逐(不再返回旧内容)
|
|
380
|
+
fs.rmSync(f);
|
|
381
|
+
expect(getCachedFileContent(f)).toBeNull();
|
|
382
|
+
} finally {
|
|
383
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
384
|
+
}
|
|
385
|
+
});
|
|
386
|
+
});
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* resource-meta 类型族测试(v5 §11.1)
|
|
3
|
+
*
|
|
4
|
+
* 编译期 + 运行期判别 ResourceMeta 联合。
|
|
5
|
+
*/
|
|
6
|
+
import { describe, expect, it } from "vitest";
|
|
7
|
+
|
|
8
|
+
import type { AgentMeta, ResourceMeta, WorkflowMeta } from "../resource-meta.ts";
|
|
9
|
+
|
|
10
|
+
describe("ResourceMeta 类型族", () => {
|
|
11
|
+
it("WorkflowMeta 判别 kind='workflow'", () => {
|
|
12
|
+
const wf: WorkflowMeta = {
|
|
13
|
+
kind: "workflow",
|
|
14
|
+
name: "chain",
|
|
15
|
+
description: "test",
|
|
16
|
+
phases: ["a"],
|
|
17
|
+
};
|
|
18
|
+
expect(wf.kind).toBe("workflow");
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it("AgentMeta 判别 kind='agent'", () => {
|
|
22
|
+
const ag: AgentMeta = {
|
|
23
|
+
kind: "agent",
|
|
24
|
+
name: "reviewer",
|
|
25
|
+
description: "review",
|
|
26
|
+
};
|
|
27
|
+
expect(ag.kind).toBe("agent");
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("联合类型 kind 判别 narrowing", () => {
|
|
31
|
+
function isWorkflow(m: ResourceMeta): m is WorkflowMeta {
|
|
32
|
+
return m.kind === "workflow";
|
|
33
|
+
}
|
|
34
|
+
const wf: ResourceMeta = { kind: "workflow", name: "x", description: "", phases: [] };
|
|
35
|
+
const ag: ResourceMeta = { kind: "agent", name: "y", description: "" };
|
|
36
|
+
expect(isWorkflow(wf)).toBe(true);
|
|
37
|
+
expect(isWorkflow(ag)).toBe(false);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("可选字段缺省为 undefined", () => {
|
|
41
|
+
const wf: WorkflowMeta = {
|
|
42
|
+
kind: "workflow",
|
|
43
|
+
name: "x",
|
|
44
|
+
description: "",
|
|
45
|
+
phases: [],
|
|
46
|
+
};
|
|
47
|
+
expect(wf.parameters).toBeUndefined();
|
|
48
|
+
expect(wf.usage).toBeUndefined();
|
|
49
|
+
expect(wf.when).toBeUndefined();
|
|
50
|
+
});
|
|
51
|
+
});
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* agentRef / workflowRef 统一路径解析(S2)。
|
|
3
|
+
*
|
|
4
|
+
* 设计(agent-ref-path-redesign D1):资源引用唯一形态 = 绝对路径。
|
|
5
|
+
* - agentRef → .md 文件绝对路径(注入段 <available_subagents> 的 <location>)
|
|
6
|
+
* - workflowRef → .js 文件绝对路径(注入段 <available_workflows> 的 <location>)
|
|
7
|
+
* - 支持 `~/` 前缀展开(homedir);相对路径不认(注入段给绝对路径,模型照抄零歧义)
|
|
8
|
+
*
|
|
9
|
+
* 名字不再是引用——只是注入段展示标签。执行侧拿到引用后一律 normalizeRef →
|
|
10
|
+
* 文件加载(AgentRegistry.loadByPath / WorkflowScriptRegistry.getPath)。
|
|
11
|
+
*/
|
|
12
|
+
import { homedir } from "node:os";
|
|
13
|
+
import { isAbsolute, join } from "node:path";
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* 归一化资源引用:~ 展开 + 绝对路径校验。
|
|
17
|
+
*
|
|
18
|
+
* @param ref 原始引用(注入段 location / 工具参数值)
|
|
19
|
+
* @param ext 期望扩展名(如 ".md" / ".js"),不匹配返回 null
|
|
20
|
+
* @returns 归一化绝对路径;非法(空/相对路径/扩展名不符)返回 null
|
|
21
|
+
*/
|
|
22
|
+
export function normalizeRef(ref: string, ext?: string): string | null {
|
|
23
|
+
const trimmed = ref.trim();
|
|
24
|
+
if (!trimmed) return null;
|
|
25
|
+
|
|
26
|
+
const expanded = trimmed.startsWith("~/") ? join(homedir(), trimmed.slice(2)) : trimmed;
|
|
27
|
+
if (!isAbsolute(expanded)) return null; // 相对路径无基准(注入段给绝对路径)
|
|
28
|
+
|
|
29
|
+
if (ext !== undefined && !expanded.endsWith(ext)) return null;
|
|
30
|
+
return expanded;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** agentRef 扩展名。 */
|
|
34
|
+
export const AGENT_REF_EXT = ".md";
|
|
35
|
+
/** workflowRef 扩展名。 */
|
|
36
|
+
export const WORKFLOW_REF_EXT = ".js";
|