@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
package/src/index.ts
CHANGED
|
@@ -21,7 +21,6 @@ import type { ExtensionAPI, ExtensionContext, SessionShutdownEvent, SessionStart
|
|
|
21
21
|
import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
22
22
|
import { getLogger, setPiHandle } from "@zhushanwen/pi-extension-logger";
|
|
23
23
|
|
|
24
|
-
import type { AgentRegistry } from "./execution/agent-registry.ts";
|
|
25
24
|
import { bestEffort } from "./execution/best-effort.ts";
|
|
26
25
|
// ═══ execution/ 层(subagents 核心 + 运行时) ═══
|
|
27
26
|
import { getOrCreateChannelRegistry } from "./execution/channel-registry-access.ts";
|
|
@@ -51,7 +50,6 @@ import { registerSubagentTool } from "./interface/subagent-tool.ts";
|
|
|
51
50
|
import { registerSubagentsCommand } from "./interface/subagents.ts";
|
|
52
51
|
import { registerWorkflowTool } from "./interface/tool-workflow.ts";
|
|
53
52
|
import { registerWorkflowScriptTool } from "./interface/tool-workflow-script.ts";
|
|
54
|
-
import { cleanupAllTempFiles as cleanupAllFiles } from "./orchestration/agent-opts-resolver.ts";
|
|
55
53
|
import { JsonlRunStore } from "./orchestration/jsonl-run-store.ts";
|
|
56
54
|
// ═══ orchestration/ 层(workflow engine + infra) ═══
|
|
57
55
|
import type { LauncherDeps } from "./orchestration/launcher.ts";
|
|
@@ -136,8 +134,6 @@ export default function subagentsWorkflowExtension(pi: ExtensionAPI): void {
|
|
|
136
134
|
{
|
|
137
135
|
store: JsonlRunStore;
|
|
138
136
|
runs: Map<string, WorkflowRun>;
|
|
139
|
-
activeTempFiles: Set<string>;
|
|
140
|
-
agentRegistry: AgentRegistry;
|
|
141
137
|
sessionDir: string;
|
|
142
138
|
/** D-008 per-session SAR(需要 ctxModel + subagentService) */
|
|
143
139
|
runner: SubprocessAgentRunner;
|
|
@@ -180,8 +176,6 @@ export default function subagentsWorkflowExtension(pi: ExtensionAPI): void {
|
|
|
180
176
|
state: {
|
|
181
177
|
store: JsonlRunStore;
|
|
182
178
|
runs: Map<string, WorkflowRun>;
|
|
183
|
-
activeTempFiles: Set<string>;
|
|
184
|
-
agentRegistry: AgentRegistry;
|
|
185
179
|
sessionDir: string;
|
|
186
180
|
runner: SubprocessAgentRunner;
|
|
187
181
|
},
|
|
@@ -194,9 +188,6 @@ export default function subagentsWorkflowExtension(pi: ExtensionAPI): void {
|
|
|
194
188
|
runs: state.runs,
|
|
195
189
|
registry,
|
|
196
190
|
onRunDone: (run: WorkflowRun) => notifyDone(pi, run.runId, run, notifiedRunIds, toGuiCtx(sessionCtx)),
|
|
197
|
-
agentRegistry: state.agentRegistry,
|
|
198
|
-
sessionDir: state.sessionDir,
|
|
199
|
-
activeTempFiles: state.activeTempFiles,
|
|
200
191
|
eventBus: pi.events,
|
|
201
192
|
scheduleTimeBudget: (runId: string, budgetTimeMs: number) =>
|
|
202
193
|
scheduleTimeBudget(runId, deps, budgetTimeMs),
|
|
@@ -317,10 +308,10 @@ export default function subagentsWorkflowExtension(pi: ExtensionAPI): void {
|
|
|
317
308
|
});
|
|
318
309
|
const runs = new Map<string, WorkflowRun>();
|
|
319
310
|
|
|
320
|
-
// F-4/D-003:
|
|
321
|
-
//
|
|
322
|
-
//
|
|
323
|
-
|
|
311
|
+
// F-4/D-003: agent 发现走 shared/resource-discovery(ADR-031),modelService
|
|
312
|
+
// 自持 AgentRegistry(subagents/workflow 两域共用同一发现结果)。
|
|
313
|
+
// M2 修正:workflow 域 resolveAgentOpts 不再消费 agentRegistry(agent ref 交
|
|
314
|
+
// resolveIdentity),无需经 state 透传——modelService 是唯一 registry 源。
|
|
324
315
|
|
|
325
316
|
// MF-1: store 健康度跟踪。loadAll 失败 → storeHealthy=false,workflow 域启动时 fail-fast。
|
|
326
317
|
let storeHealthy = true;
|
|
@@ -356,8 +347,6 @@ export default function subagentsWorkflowExtension(pi: ExtensionAPI): void {
|
|
|
356
347
|
sessionState.set(sessionId, {
|
|
357
348
|
store,
|
|
358
349
|
runs,
|
|
359
|
-
activeTempFiles: new Set(),
|
|
360
|
-
agentRegistry,
|
|
361
350
|
sessionDir,
|
|
362
351
|
runner,
|
|
363
352
|
ctx,
|
|
@@ -417,7 +406,6 @@ export default function subagentsWorkflowExtension(pi: ExtensionAPI): void {
|
|
|
417
406
|
await Promise.allSettled(
|
|
418
407
|
running.map((run) => pauseRun(run.runId, makeDeps(state, _ctx))),
|
|
419
408
|
);
|
|
420
|
-
cleanupAllFiles(state.activeTempFiles);
|
|
421
409
|
sessionState.delete(sessionId);
|
|
422
410
|
}
|
|
423
411
|
|
|
@@ -504,15 +492,6 @@ export default function subagentsWorkflowExtension(pi: ExtensionAPI): void {
|
|
|
504
492
|
get onRunDone() {
|
|
505
493
|
return getDeps().onRunDone;
|
|
506
494
|
},
|
|
507
|
-
get agentRegistry() {
|
|
508
|
-
return getDeps().agentRegistry;
|
|
509
|
-
},
|
|
510
|
-
get sessionDir() {
|
|
511
|
-
return getDeps().sessionDir;
|
|
512
|
-
},
|
|
513
|
-
get activeTempFiles() {
|
|
514
|
-
return getDeps().activeTempFiles;
|
|
515
|
-
},
|
|
516
495
|
get eventBus() {
|
|
517
496
|
return getDeps().eventBus;
|
|
518
497
|
},
|
|
@@ -1,15 +1,55 @@
|
|
|
1
1
|
// subagent-list-injector 单测
|
|
2
2
|
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
// resource-discovery
|
|
3
|
+
// 两类覆盖:
|
|
4
|
+
// 1. 纯函数(parseAgentFrontmatter / formatAgentList):P3 正向触发引导语 + 名字约束
|
|
5
|
+
// + XML 结构 + 转义。discoverAllAgents 依赖文件系统 + resource-discovery,属集成层,
|
|
6
|
+
// 此处聚焦可快速回归的格式化契约(TC5 回归保护)。
|
|
7
|
+
// 2. session 级缓存行为(TC1-TC4):mock shared/resource-discovery 的 discoverResources
|
|
8
|
+
// (vi.hoisted 稳定 spy + vi.mock 工厂闭包)+ getCachedFileContent 返回 fixture,
|
|
9
|
+
// mock pi.on 捕获三 handler 手动触发;模块级缓存靠 vi.resetModules + 动态 import 重置。
|
|
6
10
|
|
|
7
|
-
import { describe, expect, it } from "vitest";
|
|
11
|
+
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
8
12
|
|
|
9
|
-
import {
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
14
|
+
|
|
15
|
+
import type { DiscoveredResource } from "../../shared/resource-discovery.ts";
|
|
16
|
+
|
|
17
|
+
// ── 稳定 spy:vi.hoisted 保证 resetModules 后引用不变(vi.mock 工厂闭包捕获同一 fn,
|
|
18
|
+
// 故 fresh import 的注入器拿到的 discoverResources === spies.discoverResources,跨
|
|
19
|
+
// resetModules 调用计数连续,mockClear 控制每用例重置) ──
|
|
20
|
+
const spies = vi.hoisted(() => ({
|
|
21
|
+
discoverResources: vi.fn(),
|
|
22
|
+
getCachedFileContent: vi.fn(),
|
|
23
|
+
}));
|
|
24
|
+
|
|
25
|
+
vi.mock("../../shared/resource-discovery.ts", () => ({
|
|
26
|
+
discoverResources: spies.discoverResources,
|
|
27
|
+
findWorkspaceRoot: () => "/ws",
|
|
28
|
+
getCachedFileContent: spies.getCachedFileContent,
|
|
29
|
+
}));
|
|
30
|
+
|
|
31
|
+
vi.mock("@zhushanwen/pi-extension-logger", () => ({
|
|
32
|
+
getLogger: () => ({
|
|
33
|
+
debug: () => {
|
|
34
|
+
/* no-op */
|
|
35
|
+
},
|
|
36
|
+
info: () => {
|
|
37
|
+
/* no-op */
|
|
38
|
+
},
|
|
39
|
+
warn: () => {
|
|
40
|
+
/* no-op */
|
|
41
|
+
},
|
|
42
|
+
error: () => {
|
|
43
|
+
/* no-op */
|
|
44
|
+
},
|
|
45
|
+
}),
|
|
46
|
+
setPiHandle: () => {
|
|
47
|
+
/* no-op */
|
|
48
|
+
},
|
|
49
|
+
}));
|
|
50
|
+
|
|
51
|
+
// ── 纯函数测试:静态 import(模块级缓存状态不影响纯函数;与下方缓存 describe 隔离) ──
|
|
52
|
+
import { formatAgentList, parseAgentFrontmatter } from "../subagent-list-injector";
|
|
13
53
|
|
|
14
54
|
describe("parseAgentFrontmatter", () => {
|
|
15
55
|
it("解析双引号包裹的 name + description", () => {
|
|
@@ -21,6 +61,7 @@ body`;
|
|
|
21
61
|
expect(parseAgentFrontmatter(md)).toEqual({
|
|
22
62
|
name: "worker",
|
|
23
63
|
description: "编码执行者",
|
|
64
|
+
path: "",
|
|
24
65
|
});
|
|
25
66
|
});
|
|
26
67
|
|
|
@@ -32,6 +73,7 @@ description: '代码审查'
|
|
|
32
73
|
expect(parseAgentFrontmatter(md)).toEqual({
|
|
33
74
|
name: "reviewer",
|
|
34
75
|
description: "代码审查",
|
|
76
|
+
path: "",
|
|
35
77
|
});
|
|
36
78
|
});
|
|
37
79
|
|
|
@@ -54,8 +96,42 @@ describe("formatAgentList", () => {
|
|
|
54
96
|
expect(formatAgentList([])).toBe("");
|
|
55
97
|
});
|
|
56
98
|
|
|
99
|
+
it("TC1: when + examples 注入(原样渲染 + escapeXml + 缺省兼容)", () => {
|
|
100
|
+
const out = formatAgentList([
|
|
101
|
+
{
|
|
102
|
+
name: "reviewer",
|
|
103
|
+
description: "代码审查",
|
|
104
|
+
path: "/agents/reviewer.md",
|
|
105
|
+
when: "用户要求 review 代码",
|
|
106
|
+
examples: [
|
|
107
|
+
{ match: "帮我 review 这段代码", action: "调用 reviewer 对抗式审查", positive: true },
|
|
108
|
+
{ match: "帮我 review 设计文档", action: "不调用(文档审查应选 doc-reviewer)", positive: false },
|
|
109
|
+
],
|
|
110
|
+
},
|
|
111
|
+
{ name: "legacy", description: "未迁移 agent", path: "/agents/legacy.md" },
|
|
112
|
+
]);
|
|
113
|
+
expect(out).toContain("<when>用户要求 review 代码</when>");
|
|
114
|
+
// 正反原样渲染——negative action 含原因文本(评审 M5:渲染器不硬编码)
|
|
115
|
+
expect(out).toContain('"帮我 review 这段代码" → 调用 reviewer 对抗式审查');
|
|
116
|
+
expect(out).toContain('"帮我 review 设计文档" → 不调用(文档审查应选 doc-reviewer)');
|
|
117
|
+
// escapeXml:match 含 < > 被转义
|
|
118
|
+
const xmlOut = formatAgentList([
|
|
119
|
+
{
|
|
120
|
+
name: "x",
|
|
121
|
+
description: "d",
|
|
122
|
+
path: "/agents/x.md",
|
|
123
|
+
examples: [{ match: "处理 <task> 的 diff", action: "调用 x", positive: true }],
|
|
124
|
+
},
|
|
125
|
+
]);
|
|
126
|
+
expect(xmlOut).toContain("<task>");
|
|
127
|
+
// 缺省兼容:无 when/examples 的 agent 不渲染该段
|
|
128
|
+
const legacyOut = formatAgentList([{ name: "legacy", description: "未迁移 agent", path: "/agents/legacy.md" }]);
|
|
129
|
+
expect(legacyOut).not.toContain("<when>");
|
|
130
|
+
expect(legacyOut).not.toContain("<examples>");
|
|
131
|
+
});
|
|
132
|
+
|
|
57
133
|
it("包含 P3 正向触发引导语(何时该 delegate)", () => {
|
|
58
|
-
const out = formatAgentList([{ name: "worker", description: "d" }]);
|
|
134
|
+
const out = formatAgentList([{ name: "worker", description: "d", path: "/agents/worker.md" }]);
|
|
59
135
|
expect(out).toContain("PRIORITY");
|
|
60
136
|
expect(out).toContain("3+ files");
|
|
61
137
|
expect(out).toContain("delegate");
|
|
@@ -63,20 +139,20 @@ describe("formatAgentList", () => {
|
|
|
63
139
|
});
|
|
64
140
|
|
|
65
141
|
it("保留原 'ONLY use agent names from this list' 名字约束", () => {
|
|
66
|
-
const out = formatAgentList([{ name: "worker", description: "d" }]);
|
|
142
|
+
const out = formatAgentList([{ name: "worker", description: "d", path: "/agents/worker.md" }]);
|
|
67
143
|
expect(out).toContain("ONLY use agent names from this list");
|
|
68
144
|
});
|
|
69
145
|
|
|
70
146
|
it("包含 'Do NOT call list to discover' 引导语", () => {
|
|
71
|
-
const out = formatAgentList([{ name: "worker", description: "d" }]);
|
|
147
|
+
const out = formatAgentList([{ name: "worker", description: "d", path: "/agents/worker.md" }]);
|
|
72
148
|
expect(out).toContain("Do NOT call list to discover");
|
|
73
149
|
expect(out).toContain("use list only for running state");
|
|
74
150
|
});
|
|
75
151
|
|
|
76
152
|
it("用 <available_subagents> 标签包裹并列出每个 agent", () => {
|
|
77
153
|
const out = formatAgentList([
|
|
78
|
-
{ name: "worker", description: "does work" },
|
|
79
|
-
{ name: "reviewer", description: "reviews code" },
|
|
154
|
+
{ name: "worker", description: "does work", path: "/agents/worker.md" },
|
|
155
|
+
{ name: "reviewer", description: "reviews code", path: "/agents/reviewer.md" },
|
|
80
156
|
]);
|
|
81
157
|
expect(out).toContain("<available_subagents>");
|
|
82
158
|
expect(out).toContain("</available_subagents>");
|
|
@@ -84,11 +160,187 @@ describe("formatAgentList", () => {
|
|
|
84
160
|
expect(out).toContain("<description>does work</description>");
|
|
85
161
|
expect(out).toContain("<name>reviewer</name>");
|
|
86
162
|
expect(out).toContain("<description>reviews code</description>");
|
|
163
|
+
// S1:每项含 <location> 完整路径(agentRef,模型直接引用)
|
|
164
|
+
expect(out).toContain("<location>/agents/worker.md</location>");
|
|
165
|
+
expect(out).toContain("<location>/agents/reviewer.md</location>");
|
|
87
166
|
});
|
|
88
167
|
|
|
89
168
|
it("转义 XML 特殊字符", () => {
|
|
90
|
-
const out = formatAgentList([{ name: "a&b<c>", description: "\"q\"" }]);
|
|
169
|
+
const out = formatAgentList([{ name: "a&b<c>", description: "\"q\"", path: "/agents/a&b<c>.md" }]);
|
|
91
170
|
expect(out).toContain("<name>a&b<c></name>");
|
|
92
171
|
expect(out).toContain(""q"");
|
|
93
172
|
});
|
|
94
173
|
});
|
|
174
|
+
|
|
175
|
+
// ──────────────────────────────────────────────────────────────
|
|
176
|
+
// session 级缓存行为(TC1-TC4)
|
|
177
|
+
// ──────────────────────────────────────────────────────────────
|
|
178
|
+
|
|
179
|
+
/** before_agent_start handler 的返回结构(取 systemPrompt 断言)。 */
|
|
180
|
+
interface BeforeAgentResult {
|
|
181
|
+
systemPrompt: string;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** 三 handler 捕获引用(setupSubagentListInjector 注册后填充)。 */
|
|
185
|
+
interface CapturedHandlers {
|
|
186
|
+
sessionStart?: (event: unknown, ctx: unknown) => Promise<void> | void;
|
|
187
|
+
beforeAgentStart?: (event: { systemPrompt: string }, ctx: unknown) => Promise<BeforeAgentResult | void> | BeforeAgentResult | void;
|
|
188
|
+
sessionShutdown?: (event: unknown, ctx: unknown) => void;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** 构造 mock pi:捕获三 handler 引用,其余 prop 走 noop(仅 on 被调用)。 */
|
|
192
|
+
function createMockPi(handlers: CapturedHandlers): ExtensionAPI {
|
|
193
|
+
const on = (event: string, handler: (...args: unknown[]) => unknown): void => {
|
|
194
|
+
if (event === "session_start") {
|
|
195
|
+
handlers.sessionStart = handler as CapturedHandlers["sessionStart"];
|
|
196
|
+
} else if (event === "before_agent_start") {
|
|
197
|
+
handlers.beforeAgentStart = handler as CapturedHandlers["beforeAgentStart"];
|
|
198
|
+
} else if (event === "session_shutdown") {
|
|
199
|
+
handlers.sessionShutdown = handler as CapturedHandlers["sessionShutdown"];
|
|
200
|
+
}
|
|
201
|
+
};
|
|
202
|
+
const noop = (): void => {
|
|
203
|
+
/* mock */
|
|
204
|
+
};
|
|
205
|
+
// setupSubagentListInjector 仅调 pi.on;用最小对象 + 双重断言满足 ExtensionAPI 契约
|
|
206
|
+
// (测试 mock 约定,见 crash-recovery.test.ts 的 Proxy 模式)
|
|
207
|
+
return { on, appendEntry: noop, registerTool: noop, registerCommand: noop, registerMessageRenderer: noop, events: { emit: noop, on: noop } } as unknown as ExtensionAPI;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** 最小 ctx mock(注入器只读 ctx.cwd)。 */
|
|
211
|
+
function createMockCtx(): Record<string, unknown> {
|
|
212
|
+
return { cwd: "/ws", mode: "tui" };
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** fixture:单个 worker agent 的 DiscoveredResource。 */
|
|
216
|
+
function agentResource(path: string): DiscoveredResource {
|
|
217
|
+
return { path, source: "project-agents", available: true };
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/** fixture:agent .md frontmatter 内容。 */
|
|
221
|
+
function agentMd(name: string, description: string): string {
|
|
222
|
+
return `---\nname: ${name}\ndescription: "${description}"\n---\nbody`;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
describe("subagent-list-injector session 级缓存", () => {
|
|
226
|
+
let setupSubagentListInjector: typeof import("../subagent-list-injector").setupSubagentListInjector;
|
|
227
|
+
let handlers: CapturedHandlers;
|
|
228
|
+
|
|
229
|
+
beforeEach(async () => {
|
|
230
|
+
// resetModules + 动态 import:拿 fresh 模块实例 → 模块级 agentCache 重置为 null
|
|
231
|
+
vi.resetModules();
|
|
232
|
+
spies.discoverResources.mockReset();
|
|
233
|
+
spies.getCachedFileContent.mockReset();
|
|
234
|
+
// 默认空发现(各 TC 按需覆盖)
|
|
235
|
+
spies.discoverResources.mockResolvedValue([]);
|
|
236
|
+
spies.getCachedFileContent.mockReturnValue(null);
|
|
237
|
+
handlers = {};
|
|
238
|
+
const mod = await import("../subagent-list-injector");
|
|
239
|
+
setupSubagentListInjector = mod.setupSubagentListInjector;
|
|
240
|
+
setupSubagentListInjector(createMockPi(handlers));
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
it("TC1: session_start 发现+缓存后,两次 before_agent_start 命中缓存(discoverResources 只调 1 次)", async () => {
|
|
244
|
+
spies.discoverResources.mockResolvedValue([agentResource("/ws/.agents/agents/worker.md")]);
|
|
245
|
+
spies.getCachedFileContent.mockReturnValue(agentMd("worker", "编码执行者"));
|
|
246
|
+
|
|
247
|
+
// session_start 触发发现+缓存
|
|
248
|
+
await handlers.sessionStart!({ type: "session_start", reason: "new" }, createMockCtx());
|
|
249
|
+
expect(spies.discoverResources).toHaveBeenCalledTimes(1);
|
|
250
|
+
|
|
251
|
+
// 两次 before_agent_start:均读缓存,不再触发 discoverResources
|
|
252
|
+
const r1 = await handlers.beforeAgentStart!({ systemPrompt: "" }, createMockCtx());
|
|
253
|
+
const r2 = await handlers.beforeAgentStart!({ systemPrompt: "" }, createMockCtx());
|
|
254
|
+
expect(spies.discoverResources).toHaveBeenCalledTimes(1);
|
|
255
|
+
|
|
256
|
+
// 两次注入段都含 <available_subagents> 且内容一致(同一缓存)
|
|
257
|
+
expect(r1?.systemPrompt).toContain("<available_subagents>");
|
|
258
|
+
expect(r1?.systemPrompt).toContain("<name>worker</name>");
|
|
259
|
+
expect(r2?.systemPrompt).toBe(r1?.systemPrompt);
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
it("TC2: session_shutdown 清缓存后 before_agent_start miss → fallback 重新发现+缓存", async () => {
|
|
263
|
+
spies.discoverResources.mockResolvedValue([agentResource("/ws/.agents/agents/worker.md")]);
|
|
264
|
+
spies.getCachedFileContent.mockReturnValue(agentMd("worker", "编码"));
|
|
265
|
+
|
|
266
|
+
// session_start 发现+缓存(discoverResources count=1)
|
|
267
|
+
await handlers.sessionStart!({ type: "session_start", reason: "new" }, createMockCtx());
|
|
268
|
+
expect(spies.discoverResources).toHaveBeenCalledTimes(1);
|
|
269
|
+
|
|
270
|
+
// session_shutdown 清缓存
|
|
271
|
+
handlers.sessionShutdown!({ type: "session_shutdown", reason: "quit" }, createMockCtx());
|
|
272
|
+
|
|
273
|
+
// before_agent_start 读到空缓存 → fallback 重新发现(count=2)+ 重新缓存
|
|
274
|
+
const r1 = await handlers.beforeAgentStart!({ systemPrompt: "" }, createMockCtx());
|
|
275
|
+
expect(spies.discoverResources).toHaveBeenCalledTimes(2);
|
|
276
|
+
expect(r1?.systemPrompt).toContain("<name>worker</name>");
|
|
277
|
+
|
|
278
|
+
// 再次 before_agent_start 命中缓存(不再调 discoverResources,count 仍 2)
|
|
279
|
+
const r2 = await handlers.beforeAgentStart!({ systemPrompt: "" }, createMockCtx());
|
|
280
|
+
expect(spies.discoverResources).toHaveBeenCalledTimes(2);
|
|
281
|
+
expect(r2?.systemPrompt).toBe(r1?.systemPrompt);
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
it("TC3: 无 session_start 直接 before_agent_start(miss fallback)→ 发现+缓存", async () => {
|
|
285
|
+
spies.discoverResources.mockResolvedValue([agentResource("/ws/.agents/agents/worker.md")]);
|
|
286
|
+
spies.getCachedFileContent.mockReturnValue(agentMd("worker", "编码"));
|
|
287
|
+
|
|
288
|
+
// 未触发 session_start,直接两次 before_agent_start
|
|
289
|
+
const r1 = await handlers.beforeAgentStart!({ systemPrompt: "" }, createMockCtx());
|
|
290
|
+
const r2 = await handlers.beforeAgentStart!({ systemPrompt: "" }, createMockCtx());
|
|
291
|
+
|
|
292
|
+
// 第一次 fallback 发现+缓存(count=1);第二次命中缓存(count 仍 1)
|
|
293
|
+
expect(spies.discoverResources).toHaveBeenCalledTimes(1);
|
|
294
|
+
expect(r1?.systemPrompt).toContain("<name>worker</name>");
|
|
295
|
+
expect(r2?.systemPrompt).toBe(r1?.systemPrompt);
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
it("TC4: session_start(reload) 覆盖缓存(新资源生效)", async () => {
|
|
299
|
+
// 第一次 session_start(new):发现 worker
|
|
300
|
+
spies.discoverResources.mockResolvedValue([agentResource("/ws/.agents/agents/worker.md")]);
|
|
301
|
+
spies.getCachedFileContent.mockReturnValue(agentMd("worker", "编码"));
|
|
302
|
+
await handlers.sessionStart!({ type: "session_start", reason: "new" }, createMockCtx());
|
|
303
|
+
expect(spies.discoverResources).toHaveBeenCalledTimes(1);
|
|
304
|
+
|
|
305
|
+
// 资源变化:discoverResources 改返回 reviewer,getCachedFileContent 改返回 reviewer 内容
|
|
306
|
+
spies.discoverResources.mockResolvedValue([agentResource("/ws/.agents/agents/reviewer.md")]);
|
|
307
|
+
spies.getCachedFileContent.mockReturnValue(agentMd("reviewer", "审查"));
|
|
308
|
+
|
|
309
|
+
// session_start(reload) 重新发现覆盖缓存(count=2)
|
|
310
|
+
await handlers.sessionStart!({ type: "session_start", reason: "reload" }, createMockCtx());
|
|
311
|
+
expect(spies.discoverResources).toHaveBeenCalledTimes(2);
|
|
312
|
+
|
|
313
|
+
// before_agent_start 命中新缓存:注入段反映 reviewer(非旧 worker)
|
|
314
|
+
const r = await handlers.beforeAgentStart!({ systemPrompt: "" }, createMockCtx());
|
|
315
|
+
expect(spies.discoverResources).toHaveBeenCalledTimes(2);
|
|
316
|
+
expect(r?.systemPrompt).toContain("<name>reviewer</name>");
|
|
317
|
+
expect(r?.systemPrompt).not.toContain("worker");
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
it("session_start 发现空列表缓存后 before_agent_start 命中(不重扫,不注入)", async () => {
|
|
321
|
+
// 空列表是有效缓存态(非 null):命中后不重扫,formatAgentList([]) 返空串不注入
|
|
322
|
+
spies.discoverResources.mockResolvedValue([]);
|
|
323
|
+
await handlers.sessionStart!({ type: "session_start", reason: "new" }, createMockCtx());
|
|
324
|
+
expect(spies.discoverResources).toHaveBeenCalledTimes(1);
|
|
325
|
+
|
|
326
|
+
const r1 = await handlers.beforeAgentStart!({ systemPrompt: "base" }, createMockCtx());
|
|
327
|
+
const r2 = await handlers.beforeAgentStart!({ systemPrompt: "base" }, createMockCtx());
|
|
328
|
+
expect(spies.discoverResources).toHaveBeenCalledTimes(1);
|
|
329
|
+
// 空注入:handler 返 void(无 systemPrompt 字段)
|
|
330
|
+
expect(r1).toBeUndefined();
|
|
331
|
+
expect(r2).toBeUndefined();
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
it("session_start 发现异常不阻断(fail-safe,缓存保持 null,before_agent_start fallback)", async () => {
|
|
335
|
+
spies.discoverResources.mockRejectedValueOnce(new Error("disk io"));
|
|
336
|
+
// session_start 发现抛错:记日志不阻断,缓存保持 null
|
|
337
|
+
await handlers.sessionStart!({ type: "session_start", reason: "new" }, createMockCtx());
|
|
338
|
+
|
|
339
|
+
// before_agent_start 读到 null 缓存 → fallback 重新发现(recover)
|
|
340
|
+
spies.discoverResources.mockResolvedValue([agentResource("/ws/.agents/agents/worker.md")]);
|
|
341
|
+
spies.getCachedFileContent.mockReturnValue(agentMd("worker", "编码"));
|
|
342
|
+
const r = await handlers.beforeAgentStart!({ systemPrompt: "" }, createMockCtx());
|
|
343
|
+
expect(spies.discoverResources).toHaveBeenCalledTimes(2);
|
|
344
|
+
expect(r?.systemPrompt).toContain("<name>worker</name>");
|
|
345
|
+
});
|
|
346
|
+
});
|