@zhushanwen/pi-subagent-workflow 8.1.0 → 8.2.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/package.json +12 -8
- package/skills/workflow-script-format/SKILL.md +1 -1
- package/src/execution/__tests__/__fixtures__/notifier-golden-snapshots.json +53 -0
- package/src/execution/__tests__/channel-registry-handshake.test.ts +1 -1
- package/src/execution/__tests__/dialog-queue.test.ts +1 -1
- package/src/execution/__tests__/host-mode.test.ts +1 -1
- package/src/execution/__tests__/notifier-flush.test.ts +193 -7
- package/src/execution/__tests__/notifier-golden-snapshot.test.ts +261 -0
- package/src/execution/__tests__/spawn-event-adapter-rpc.test.ts +1 -1
- package/src/execution/__tests__/status-refactor.test.ts +5 -5
- package/src/execution/__tests__/ui-channels.test.ts +1 -1
- package/src/execution/__tests__/ui-interaction-model.test.ts +1 -1
- package/src/execution/__tests__/ui-request-observability.test.ts +1 -1
- package/src/execution/notifier.ts +182 -222
- package/src/execution/session-runner.ts +1 -1
- package/src/execution/subagent-service.ts +11 -4
- package/src/index.ts +3 -0
- package/src/injectors/__tests__/helpers/injector-test-mocks.ts +104 -0
- package/src/injectors/__tests__/model-list-injector.test.ts +139 -0
- package/src/injectors/__tests__/subagent-list-injector.test.ts +59 -69
- package/src/injectors/__tests__/workflow-list-injector.test.ts +57 -67
- package/src/injectors/model-list-injector.ts +138 -0
- package/src/injectors/subagent-list-injector.ts +33 -24
- package/src/injectors/workflow-list-injector.ts +16 -25
- package/src/orchestration/__tests__/workflows-e2e.test.ts +2 -2
- package/src/orchestration/config-loader.ts +5 -1
- package/src/orchestration/models/types.ts +1 -1
- package/src/shared/__tests__/resource-discovery.test.ts +79 -0
- package/src/shared/resource-discovery.ts +38 -0
- package/src/shared/xml-injection.ts +35 -0
- package/workflows/review-fix-loop-utils.cjs +1 -1
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
// injector 测试共享 mock 基建(subagent/workflow 两份测试逐字重复样板的提取,
|
|
2
|
+
// fallow 测试 duplication 条目消除)。提取的只是构造样板——断言全部留在各自测试文件。
|
|
3
|
+
//
|
|
4
|
+
// 使用方式(测试文件内,spies 必须留在文件里——vi.hoisted 保证 resetModules 后引用不变):
|
|
5
|
+
// const spies = vi.hoisted(() => ({ discoverResources: vi.fn(), getCachedFileContent: vi.fn() }));
|
|
6
|
+
// vi.mock("../../shared/resource-discovery.ts", () => createDiscoveryModuleMock(spies));
|
|
7
|
+
// vi.mock("@zhushanwen/pi-extension-logger", createLoggerModuleMock);
|
|
8
|
+
//
|
|
9
|
+
// vi.mock 工厂引用本 helper 的 import 绑定是安全的:工厂惰性执行(被 mock 模块首次被
|
|
10
|
+
// import 时),此时本 helper(静态 import、非被 mock 模块)已完成加载。
|
|
11
|
+
import type { Mock } from "vitest";
|
|
12
|
+
|
|
13
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
14
|
+
|
|
15
|
+
/** injector 测试共用的 resource-discovery mock spies(调用计数跨 resetModules 连续)。 */
|
|
16
|
+
export interface DiscoverySpies {
|
|
17
|
+
discoverResources: Mock;
|
|
18
|
+
getCachedFileContent: Mock;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* resource-discovery 模块 mock 工厂:discoverResources/getCachedFileContent 直通
|
|
23
|
+
* spies,findWorkspaceRoot 固定 "/ws",getCachedParsed passthrough(真实实现走
|
|
24
|
+
* mtime 缓存需真文件,测试里委托 getCachedFileContent 的 mock 返回值——保持
|
|
25
|
+
* 「content → parse」语义)。
|
|
26
|
+
*/
|
|
27
|
+
export function createDiscoveryModuleMock(spies: DiscoverySpies) {
|
|
28
|
+
return {
|
|
29
|
+
discoverResources: spies.discoverResources,
|
|
30
|
+
findWorkspaceRoot: () => "/ws",
|
|
31
|
+
getCachedFileContent: spies.getCachedFileContent,
|
|
32
|
+
getCachedParsed: (path: string, parse: (c: string) => unknown) =>
|
|
33
|
+
parse(spies.getCachedFileContent(path) ?? ""),
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** pi-extension-logger 模块 mock 工厂:全级别 no-op(测试不断言日志)。 */
|
|
38
|
+
export function createLoggerModuleMock() {
|
|
39
|
+
return {
|
|
40
|
+
getLogger: () => ({
|
|
41
|
+
debug: () => {
|
|
42
|
+
/* no-op */
|
|
43
|
+
},
|
|
44
|
+
info: () => {
|
|
45
|
+
/* no-op */
|
|
46
|
+
},
|
|
47
|
+
warn: () => {
|
|
48
|
+
/* no-op */
|
|
49
|
+
},
|
|
50
|
+
error: () => {
|
|
51
|
+
/* no-op */
|
|
52
|
+
},
|
|
53
|
+
}),
|
|
54
|
+
setPiHandle: () => {
|
|
55
|
+
/* no-op */
|
|
56
|
+
},
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** before_agent_start handler 的返回结构(取 systemPrompt 断言)。 */
|
|
61
|
+
export interface BeforeAgentResult {
|
|
62
|
+
systemPrompt: string;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** 三 handler 捕获引用(setup*Injector 注册后填充)。 */
|
|
66
|
+
export interface CapturedHandlers {
|
|
67
|
+
sessionStart?: (event: unknown, ctx: unknown) => Promise<void> | void;
|
|
68
|
+
beforeAgentStart?: (
|
|
69
|
+
event: { systemPrompt: string },
|
|
70
|
+
ctx: unknown,
|
|
71
|
+
) => Promise<BeforeAgentResult | void> | BeforeAgentResult | void;
|
|
72
|
+
sessionShutdown?: (event: unknown, ctx: unknown) => void;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** 构造 mock pi:捕获三 handler 引用,其余 prop 走 noop(仅 on 被调用)。 */
|
|
76
|
+
export function createMockPi(handlers: CapturedHandlers): ExtensionAPI {
|
|
77
|
+
const on = (event: string, handler: (...args: unknown[]) => unknown): void => {
|
|
78
|
+
if (event === "session_start") {
|
|
79
|
+
handlers.sessionStart = handler as CapturedHandlers["sessionStart"];
|
|
80
|
+
} else if (event === "before_agent_start") {
|
|
81
|
+
handlers.beforeAgentStart = handler as CapturedHandlers["beforeAgentStart"];
|
|
82
|
+
} else if (event === "session_shutdown") {
|
|
83
|
+
handlers.sessionShutdown = handler as CapturedHandlers["sessionShutdown"];
|
|
84
|
+
}
|
|
85
|
+
};
|
|
86
|
+
const noop = (): void => {
|
|
87
|
+
/* mock */
|
|
88
|
+
};
|
|
89
|
+
// setup*Injector 仅调 pi.on;用最小对象 + 双重断言满足 ExtensionAPI 契约
|
|
90
|
+
// (测试 mock 约定,见 crash-recovery.test.ts 的 Proxy 模式)
|
|
91
|
+
return {
|
|
92
|
+
on,
|
|
93
|
+
appendEntry: noop,
|
|
94
|
+
registerTool: noop,
|
|
95
|
+
registerCommand: noop,
|
|
96
|
+
registerMessageRenderer: noop,
|
|
97
|
+
events: { emit: noop, on: noop },
|
|
98
|
+
} as unknown as ExtensionAPI;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** 最小 ctx mock(注入器只读 ctx.cwd)。 */
|
|
102
|
+
export function createMockCtx(): Record<string, unknown> {
|
|
103
|
+
return { cwd: "/ws", mode: "tui" };
|
|
104
|
+
}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
// model-list-injector 单测
|
|
2
|
+
//
|
|
3
|
+
// 覆盖(与 workflow-list-injector.test.ts 对称,但本 injector 无文件发现/模块级
|
|
4
|
+
// 缓存,纯函数比重更大):
|
|
5
|
+
// 1. formatModelList:排序稳定性(registry 返回顺序不保证,输出必须字节稳定)、
|
|
6
|
+
// caps 推导(reasoning/vision)、空 caps 省略、XML 转义、空列表不注入
|
|
7
|
+
// 2. setupModelListInjector handler:注入 append、空列表返回 undefined、
|
|
8
|
+
// registry 异常不阻断(fail-safe)
|
|
9
|
+
|
|
10
|
+
import { describe, expect, it, vi } from "vitest";
|
|
11
|
+
|
|
12
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
13
|
+
|
|
14
|
+
import type { ModelEntry } from "../model-list-injector";
|
|
15
|
+
|
|
16
|
+
// ── 测试数据工厂 ────────────────────────────────────────
|
|
17
|
+
|
|
18
|
+
function entry(overrides: Partial<ModelEntry> = {}): ModelEntry {
|
|
19
|
+
return {
|
|
20
|
+
provider: "zai-coding-cn",
|
|
21
|
+
id: "glm-5.2",
|
|
22
|
+
name: "GLM 5.2",
|
|
23
|
+
reasoning: true,
|
|
24
|
+
input: ["text"],
|
|
25
|
+
contextWindow: 200_000,
|
|
26
|
+
...overrides,
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// ── 纯函数:formatModelList ─────────────────────────────
|
|
31
|
+
|
|
32
|
+
describe("formatModelList", () => {
|
|
33
|
+
it("空列表返回空串(不注入)", async () => {
|
|
34
|
+
const { formatModelList } = await import("../model-list-injector");
|
|
35
|
+
expect(formatModelList([])).toBe("");
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("渲染 provider/modelId + name + caps + contextWindow", async () => {
|
|
39
|
+
const { formatModelList } = await import("../model-list-injector");
|
|
40
|
+
const out = formatModelList([
|
|
41
|
+
entry({ provider: "p1", id: "m1", name: "Model One" }),
|
|
42
|
+
]);
|
|
43
|
+
expect(out).toContain("<available_provider_models>");
|
|
44
|
+
expect(out).toContain("<model><id>p1/m1</id><name>Model One</name><caps>reasoning</caps><contextWindow>200000</contextWindow></model>");
|
|
45
|
+
expect(out).toContain("</available_provider_models>");
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("input 含 image 时 caps 加 vision;无能力时省略 caps 段", async () => {
|
|
49
|
+
const { formatModelList } = await import("../model-list-injector");
|
|
50
|
+
const out = formatModelList([
|
|
51
|
+
entry({ id: "vision-m", input: ["text", "image"] }),
|
|
52
|
+
entry({ id: "plain-m", reasoning: false }),
|
|
53
|
+
]);
|
|
54
|
+
expect(out).toContain("<id>zai-coding-cn/vision-m</id>");
|
|
55
|
+
expect(out).toContain("<caps>reasoning,vision</caps>");
|
|
56
|
+
// plain-m:reasoning=false 且无 image → 无 caps 段
|
|
57
|
+
expect(out).toMatch(/<id>zai-coding-cn\/plain-m<\/id><name>[^<]*<\/name><contextWindow>/);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("按 (provider, id) 排序——输入乱序输出仍字节稳定(KV cache 前提)", async () => {
|
|
61
|
+
const { formatModelList } = await import("../model-list-injector");
|
|
62
|
+
const models = [
|
|
63
|
+
entry({ provider: "b-prov", id: "z-model" }),
|
|
64
|
+
entry({ provider: "a-prov", id: "y-model" }),
|
|
65
|
+
entry({ provider: "a-prov", id: "x-model" }),
|
|
66
|
+
];
|
|
67
|
+
const out1 = formatModelList([...models]);
|
|
68
|
+
const out2 = formatModelList([...models].reverse());
|
|
69
|
+
expect(out1).toBe(out2);
|
|
70
|
+
// 排序断言:a-prov/x-model 在最前,b-prov 在最后
|
|
71
|
+
const ids = [...out1.matchAll(/<id>([^<]+)<\/id>/g)].map((m) => m[1]);
|
|
72
|
+
expect(ids).toEqual(["a-prov/x-model", "a-prov/y-model", "b-prov/z-model"]);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("排序为码点序而非 locale 序(跨环境字节一致契约,禁 localeCompare)", async () => {
|
|
76
|
+
const { formatModelList } = await import("../model-list-injector");
|
|
77
|
+
// 判别样本:码点序 "B"(0x42) < "a"(0x61),而多数 locale 的 localeCompare
|
|
78
|
+
// 会把 "a-model" 排在 "B-model" 前——本断言在 localeCompare 实现下必挂
|
|
79
|
+
const out = formatModelList([
|
|
80
|
+
entry({ provider: "p", id: "a-model" }),
|
|
81
|
+
entry({ provider: "p", id: "B-model" }),
|
|
82
|
+
]);
|
|
83
|
+
const ids = [...out.matchAll(/<id>([^<]+)<\/id>/g)].map((m) => m[1]);
|
|
84
|
+
expect(ids).toEqual(["p/B-model", "p/a-model"]);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("name 含 XML 特殊字符时转义(防注入段结构破坏)", async () => {
|
|
88
|
+
const { formatModelList } = await import("../model-list-injector");
|
|
89
|
+
const out = formatModelList([
|
|
90
|
+
entry({ provider: "p", id: "m", name: `A<&>"'B` }),
|
|
91
|
+
]);
|
|
92
|
+
expect(out).toContain("<name>A<&>"'B</name>");
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
// ── handler 行为:setupModelListInjector ────────────────
|
|
97
|
+
|
|
98
|
+
describe("setupModelListInjector", () => {
|
|
99
|
+
type Handler = (event: unknown, ctx: unknown) => Promise<unknown>;
|
|
100
|
+
|
|
101
|
+
async function setupWithRegistry(models: ModelEntry[], fail = false): Promise<Handler> {
|
|
102
|
+
const mod = await import("../model-list-injector");
|
|
103
|
+
const handlers: Record<string, Handler> = {};
|
|
104
|
+
const pi = {
|
|
105
|
+
on: (name: string, fn: Handler) => {
|
|
106
|
+
handlers[name] = fn;
|
|
107
|
+
},
|
|
108
|
+
} as unknown as ExtensionAPI;
|
|
109
|
+
mod.setupModelListInjector(pi);
|
|
110
|
+
const registry = fail
|
|
111
|
+
? { getAvailable: vi.fn(() => { throw new Error("registry boom"); }) }
|
|
112
|
+
: { getAvailable: vi.fn(() => models) };
|
|
113
|
+
const handler = handlers["before_agent_start"];
|
|
114
|
+
if (!handler) throw new Error("before_agent_start handler not registered");
|
|
115
|
+
return (event: unknown) => handler(event, { modelRegistry: registry });
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
it("注册 before_agent_start handler,注入 append 到 systemPrompt 尾部", async () => {
|
|
119
|
+
const handler = await setupWithRegistry([entry()]);
|
|
120
|
+
const result = (await handler({ systemPrompt: "BASE" }, {})) as {
|
|
121
|
+
systemPrompt: string;
|
|
122
|
+
};
|
|
123
|
+
expect(result.systemPrompt.startsWith("BASE")).toBe(true);
|
|
124
|
+
expect(result.systemPrompt).toContain("<available_provider_models>");
|
|
125
|
+
expect(result.systemPrompt).toContain("zai-coding-cn/glm-5.2");
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it("空模型列表返回 undefined(不返回 systemPrompt,不干预链)", async () => {
|
|
129
|
+
const handler = await setupWithRegistry([]);
|
|
130
|
+
const result = await handler({ systemPrompt: "BASE" }, {});
|
|
131
|
+
expect(result).toBeUndefined();
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it("registry 异常被吞掉(fail-safe,不阻断 agent turn)", async () => {
|
|
135
|
+
const handler = await setupWithRegistry([], true);
|
|
136
|
+
const result = await handler({ systemPrompt: "BASE" }, {});
|
|
137
|
+
expect(result).toBeUndefined();
|
|
138
|
+
});
|
|
139
|
+
});
|
|
@@ -10,43 +10,20 @@
|
|
|
10
10
|
|
|
11
11
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
12
12
|
|
|
13
|
-
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
14
|
-
|
|
15
13
|
import type { DiscoveredResource } from "../../shared/resource-discovery.ts";
|
|
14
|
+
// 共享 mock 基建(vi.mock 工厂 / mock pi / mock ctx):helpers/injector-test-mocks.ts
|
|
15
|
+
import { createDiscoveryModuleMock, createLoggerModuleMock, createMockCtx, createMockPi, type CapturedHandlers } from "./helpers/injector-test-mocks.ts";
|
|
16
16
|
|
|
17
17
|
// ── 稳定 spy:vi.hoisted 保证 resetModules 后引用不变(vi.mock 工厂闭包捕获同一 fn,
|
|
18
18
|
// 故 fresh import 的注入器拿到的 discoverResources === spies.discoverResources,跨
|
|
19
19
|
// resetModules 调用计数连续,mockClear 控制每用例重置) ──
|
|
20
|
-
const spies = vi.hoisted(() => ({
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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
|
-
}));
|
|
20
|
+
const spies = vi.hoisted(() => ({ discoverResources: vi.fn(), getCachedFileContent: vi.fn() }));
|
|
21
|
+
|
|
22
|
+
vi.mock("../../shared/resource-discovery.ts", () => createDiscoveryModuleMock(spies));
|
|
23
|
+
|
|
24
|
+
// 工厂必须写成箭头惰性形式:vi.mock 被提升到 import 之前执行,直接传
|
|
25
|
+
// createLoggerModuleMock 引用会在提升位置立即求值 import 绑定 → TDZ ReferenceError
|
|
26
|
+
vi.mock("@zhushanwen/pi-extension-logger", () => createLoggerModuleMock());
|
|
50
27
|
|
|
51
28
|
// ── 纯函数测试:静态 import(模块级缓存状态不影响纯函数;与下方缓存 describe 隔离) ──
|
|
52
29
|
import { formatAgentList, parseAgentFrontmatter } from "../subagent-list-injector";
|
|
@@ -173,45 +150,9 @@ describe("formatAgentList", () => {
|
|
|
173
150
|
});
|
|
174
151
|
|
|
175
152
|
// ──────────────────────────────────────────────────────────────
|
|
176
|
-
// session 级缓存行为(TC1-TC4)
|
|
153
|
+
// session 级缓存行为(TC1-TC4;mock pi/ctx 构造在 helpers/injector-test-mocks.ts)
|
|
177
154
|
// ──────────────────────────────────────────────────────────────
|
|
178
155
|
|
|
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
156
|
/** fixture:单个 worker agent 的 DiscoveredResource。 */
|
|
216
157
|
function agentResource(path: string): DiscoveredResource {
|
|
217
158
|
return { path, source: "project-agents", available: true };
|
|
@@ -344,3 +285,52 @@ describe("subagent-list-injector session 级缓存", () => {
|
|
|
344
285
|
expect(r?.systemPrompt).toContain("<name>worker</name>");
|
|
345
286
|
});
|
|
346
287
|
});
|
|
288
|
+
|
|
289
|
+
// ──────────────────────────────────────────────────────────────
|
|
290
|
+
// KV-cache 顺序契约:输出按 name 码点序,重建(两次发现)逐字节一致
|
|
291
|
+
// ──────────────────────────────────────────────────────────────
|
|
292
|
+
|
|
293
|
+
describe("discoverAllAgents 顺序契约(KV-cache)", () => {
|
|
294
|
+
it("输出按 name 码点序排序,与发现层返回顺序(readdir 枚举序)无关", async () => {
|
|
295
|
+
const byPath: Record<string, string> = {
|
|
296
|
+
"/ws/.agents/agents/zeta.md": agentMd("zeta", "z"),
|
|
297
|
+
"/ws/.agents/agents/worker.md": agentMd("worker", "w"),
|
|
298
|
+
"/ws/.agents/agents/alpha.md": agentMd("alpha", "a"),
|
|
299
|
+
};
|
|
300
|
+
// 刻意以非字母序返回(模拟 readdir 无契约枚举序)
|
|
301
|
+
spies.discoverResources.mockResolvedValue([
|
|
302
|
+
agentResource("/ws/.agents/agents/zeta.md"),
|
|
303
|
+
agentResource("/ws/.agents/agents/worker.md"),
|
|
304
|
+
agentResource("/ws/.agents/agents/alpha.md"),
|
|
305
|
+
]);
|
|
306
|
+
spies.getCachedFileContent.mockImplementation((p: string) => byPath[p] ?? null);
|
|
307
|
+
|
|
308
|
+
const { discoverAllAgents } = await import("../subagent-list-injector");
|
|
309
|
+
const agents = await discoverAllAgents("/ws", "/agent");
|
|
310
|
+
expect(agents.map((a) => a.name)).toEqual(["alpha", "worker", "zeta"]);
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
it("重建(两次发现)输出与渲染结果逐字节一致——目录不变时 session_start/fallback/resume 任意重建等价", async () => {
|
|
314
|
+
const byPath: Record<string, string> = {
|
|
315
|
+
"/ws/.agents/agents/b.md": agentMd("beta", "b"),
|
|
316
|
+
"/ws/.agents/agents/a.md": agentMd("alpha", "a"),
|
|
317
|
+
};
|
|
318
|
+
// 两次发现返回顺序不同(模拟跨进程 readdir 漂移)
|
|
319
|
+
spies.discoverResources
|
|
320
|
+
.mockResolvedValueOnce([
|
|
321
|
+
agentResource("/ws/.agents/agents/b.md"),
|
|
322
|
+
agentResource("/ws/.agents/agents/a.md"),
|
|
323
|
+
])
|
|
324
|
+
.mockResolvedValueOnce([
|
|
325
|
+
agentResource("/ws/.agents/agents/a.md"),
|
|
326
|
+
agentResource("/ws/.agents/agents/b.md"),
|
|
327
|
+
]);
|
|
328
|
+
spies.getCachedFileContent.mockImplementation((p: string) => byPath[p] ?? null);
|
|
329
|
+
|
|
330
|
+
const { discoverAllAgents, formatAgentList } = await import("../subagent-list-injector");
|
|
331
|
+
const first = await discoverAllAgents("/ws", "/agent");
|
|
332
|
+
const second = await discoverAllAgents("/ws", "/agent");
|
|
333
|
+
expect(second).toEqual(first);
|
|
334
|
+
expect(formatAgentList(second)).toBe(formatAgentList(first));
|
|
335
|
+
});
|
|
336
|
+
});
|
|
@@ -10,41 +10,17 @@
|
|
|
10
10
|
|
|
11
11
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
12
12
|
|
|
13
|
-
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
14
|
-
|
|
15
13
|
import type { DiscoveredResource } from "../../shared/resource-discovery.ts";
|
|
14
|
+
// 共享 mock 基建(vi.mock 工厂 / mock pi / mock ctx):helpers/injector-test-mocks.ts
|
|
15
|
+
import { createDiscoveryModuleMock, createLoggerModuleMock, createMockCtx, createMockPi, type CapturedHandlers } from "./helpers/injector-test-mocks.ts";
|
|
16
16
|
|
|
17
17
|
// ── 稳定 spy(vi.hoisted 保证 resetModules 后引用不变,见 subagent 测试同款注释) ──
|
|
18
|
-
const spies = vi.hoisted(() => ({
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
vi.mock("
|
|
24
|
-
discoverResources: spies.discoverResources,
|
|
25
|
-
findWorkspaceRoot: () => "/ws",
|
|
26
|
-
getCachedFileContent: spies.getCachedFileContent,
|
|
27
|
-
}));
|
|
28
|
-
|
|
29
|
-
vi.mock("@zhushanwen/pi-extension-logger", () => ({
|
|
30
|
-
getLogger: () => ({
|
|
31
|
-
debug: () => {
|
|
32
|
-
/* no-op */
|
|
33
|
-
},
|
|
34
|
-
info: () => {
|
|
35
|
-
/* no-op */
|
|
36
|
-
},
|
|
37
|
-
warn: () => {
|
|
38
|
-
/* no-op */
|
|
39
|
-
},
|
|
40
|
-
error: () => {
|
|
41
|
-
/* no-op */
|
|
42
|
-
},
|
|
43
|
-
}),
|
|
44
|
-
setPiHandle: () => {
|
|
45
|
-
/* no-op */
|
|
46
|
-
},
|
|
47
|
-
}));
|
|
18
|
+
const spies = vi.hoisted(() => ({ discoverResources: vi.fn(), getCachedFileContent: vi.fn() }));
|
|
19
|
+
|
|
20
|
+
vi.mock("../../shared/resource-discovery.ts", () => createDiscoveryModuleMock(spies));
|
|
21
|
+
|
|
22
|
+
// 工厂必须写成箭头惰性形式(vi.mock 提升后直接传引用会 TDZ,见 helper 文件头注释)
|
|
23
|
+
vi.mock("@zhushanwen/pi-extension-logger", () => createLoggerModuleMock());
|
|
48
24
|
|
|
49
25
|
// ── 纯函数测试:静态 import(模块级缓存状态不影响纯函数) ──
|
|
50
26
|
import {
|
|
@@ -178,43 +154,10 @@ describe("formatWorkflowList", () => {
|
|
|
178
154
|
});
|
|
179
155
|
|
|
180
156
|
// ──────────────────────────────────────────────────────────────
|
|
181
|
-
// session 级缓存行为(TC1-TC4,与 subagent
|
|
157
|
+
// session 级缓存行为(TC1-TC4,与 subagent 对称;mock pi/ctx 构造在
|
|
158
|
+
// helpers/injector-test-mocks.ts)
|
|
182
159
|
// ──────────────────────────────────────────────────────────────
|
|
183
160
|
|
|
184
|
-
/** before_agent_start handler 的返回结构(取 systemPrompt 断言)。 */
|
|
185
|
-
interface BeforeAgentResult {
|
|
186
|
-
systemPrompt: string;
|
|
187
|
-
}
|
|
188
|
-
|
|
189
|
-
/** 三 handler 捕获引用(setupWorkflowListInjector 注册后填充)。 */
|
|
190
|
-
interface CapturedHandlers {
|
|
191
|
-
sessionStart?: (event: unknown, ctx: unknown) => Promise<void> | void;
|
|
192
|
-
beforeAgentStart?: (event: { systemPrompt: string }, ctx: unknown) => Promise<BeforeAgentResult | void> | BeforeAgentResult | void;
|
|
193
|
-
sessionShutdown?: (event: unknown, ctx: unknown) => void;
|
|
194
|
-
}
|
|
195
|
-
|
|
196
|
-
/** 构造 mock pi:捕获三 handler 引用,其余 prop 走 noop(仅 on 被调用)。 */
|
|
197
|
-
function createMockPi(handlers: CapturedHandlers): ExtensionAPI {
|
|
198
|
-
const on = (event: string, handler: (...args: unknown[]) => unknown): void => {
|
|
199
|
-
if (event === "session_start") {
|
|
200
|
-
handlers.sessionStart = handler as CapturedHandlers["sessionStart"];
|
|
201
|
-
} else if (event === "before_agent_start") {
|
|
202
|
-
handlers.beforeAgentStart = handler as CapturedHandlers["beforeAgentStart"];
|
|
203
|
-
} else if (event === "session_shutdown") {
|
|
204
|
-
handlers.sessionShutdown = handler as CapturedHandlers["sessionShutdown"];
|
|
205
|
-
}
|
|
206
|
-
};
|
|
207
|
-
const noop = (): void => {
|
|
208
|
-
/* mock */
|
|
209
|
-
};
|
|
210
|
-
return { on, appendEntry: noop, registerTool: noop, registerCommand: noop, registerMessageRenderer: noop, events: { emit: noop, on: noop } } as unknown as ExtensionAPI;
|
|
211
|
-
}
|
|
212
|
-
|
|
213
|
-
/** 最小 ctx mock(注入器只读 ctx.cwd)。 */
|
|
214
|
-
function createMockCtx(): Record<string, unknown> {
|
|
215
|
-
return { cwd: "/ws", mode: "tui" };
|
|
216
|
-
}
|
|
217
|
-
|
|
218
161
|
/** fixture:单个 workflow 的 DiscoveredResource。 */
|
|
219
162
|
function workflowResource(path: string): DiscoveredResource {
|
|
220
163
|
return { path, source: "project-pi-tmp", available: true };
|
|
@@ -330,3 +273,50 @@ describe("workflow-list-injector session 级缓存", () => {
|
|
|
330
273
|
expect(r?.systemPrompt).toContain("<name>chain</name>");
|
|
331
274
|
});
|
|
332
275
|
});
|
|
276
|
+
|
|
277
|
+
// ──────────────────────────────────────────────────────────────
|
|
278
|
+
// KV-cache 顺序契约:输出按 name 码点序,重建(两次发现)逐字节一致
|
|
279
|
+
// ──────────────────────────────────────────────────────────────
|
|
280
|
+
|
|
281
|
+
describe("discoverAllWorkflows 顺序契约(KV-cache)", () => {
|
|
282
|
+
it("输出按 name 码点序排序,与发现层返回顺序(readdir 枚举序)无关", async () => {
|
|
283
|
+
const byPath: Record<string, string> = {
|
|
284
|
+
"/ws/.pi/workflows/zeta.js": workflowJs("zeta", "z"),
|
|
285
|
+
"/ws/.pi/workflows/chain.js": workflowJs("chain", "c"),
|
|
286
|
+
"/ws/.pi/workflows/alpha.js": workflowJs("alpha", "a"),
|
|
287
|
+
};
|
|
288
|
+
spies.discoverResources.mockResolvedValue([
|
|
289
|
+
workflowResource("/ws/.pi/workflows/zeta.js"),
|
|
290
|
+
workflowResource("/ws/.pi/workflows/chain.js"),
|
|
291
|
+
workflowResource("/ws/.pi/workflows/alpha.js"),
|
|
292
|
+
]);
|
|
293
|
+
spies.getCachedFileContent.mockImplementation((p: string) => byPath[p] ?? null);
|
|
294
|
+
|
|
295
|
+
const { discoverAllWorkflows } = await import("../workflow-list-injector");
|
|
296
|
+
const workflows = await discoverAllWorkflows("/ws", "/agent");
|
|
297
|
+
expect(workflows.map((w) => w.name)).toEqual(["alpha", "chain", "zeta"]);
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
it("重建(两次发现顺序不同)输出与渲染结果逐字节一致", async () => {
|
|
301
|
+
const byPath: Record<string, string> = {
|
|
302
|
+
"/ws/.pi/workflows/b.js": workflowJs("beta", "b"),
|
|
303
|
+
"/ws/.pi/workflows/a.js": workflowJs("alpha", "a"),
|
|
304
|
+
};
|
|
305
|
+
spies.discoverResources
|
|
306
|
+
.mockResolvedValueOnce([
|
|
307
|
+
workflowResource("/ws/.pi/workflows/b.js"),
|
|
308
|
+
workflowResource("/ws/.pi/workflows/a.js"),
|
|
309
|
+
])
|
|
310
|
+
.mockResolvedValueOnce([
|
|
311
|
+
workflowResource("/ws/.pi/workflows/a.js"),
|
|
312
|
+
workflowResource("/ws/.pi/workflows/b.js"),
|
|
313
|
+
]);
|
|
314
|
+
spies.getCachedFileContent.mockImplementation((p: string) => byPath[p] ?? null);
|
|
315
|
+
|
|
316
|
+
const { discoverAllWorkflows, formatWorkflowList } = await import("../workflow-list-injector");
|
|
317
|
+
const first = await discoverAllWorkflows("/ws", "/agent");
|
|
318
|
+
const second = await discoverAllWorkflows("/ws", "/agent");
|
|
319
|
+
expect(second).toEqual(first);
|
|
320
|
+
expect(formatWorkflowList(second)).toBe(formatWorkflowList(first));
|
|
321
|
+
});
|
|
322
|
+
});
|