@zhushanwen/pi-subagent-workflow 7.4.0 → 8.1.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/README.md +15 -0
- package/package.json +7 -4
- package/scripts/rfl.mjs +308 -0
- package/skills/workflow-script-format/SKILL.md +1 -1
- package/src/execution/__tests__/__fixtures__/truncline.snapshot.json +1 -0
- package/src/execution/__tests__/ask-user-transit-e2e.test.ts +10 -4
- package/src/execution/__tests__/before-agent-start-injection.test.ts +132 -0
- package/src/execution/__tests__/bg-notify-render.test.ts +15 -15
- package/src/execution/__tests__/chatmode-first-round-closure-service.test.ts +365 -0
- package/src/execution/__tests__/chatmode-first-round-closure-spawn.test.ts +190 -0
- package/src/execution/__tests__/chatmode-round-notify-real-chain.test.ts +215 -0
- package/src/execution/__tests__/conversation-wiring.test.ts +198 -0
- package/src/execution/__tests__/crash-recovery.test.ts +8 -2
- package/src/execution/__tests__/delivery-methods.test.ts +385 -0
- package/src/execution/__tests__/epipe-fallback.test.ts +241 -0
- package/src/execution/__tests__/execute-and-await-worktree.test.ts +49 -2
- package/src/execution/__tests__/execute-nesting.test.ts +20 -72
- package/src/execution/__tests__/execution-record.test.ts +199 -0
- package/src/execution/__tests__/finalize-record.test.ts +197 -15
- package/src/execution/__tests__/format.test.ts +131 -7
- package/src/execution/__tests__/gc-timer.test.ts +184 -0
- package/src/execution/__tests__/get-record-for-action-restart.test.ts +254 -0
- package/src/execution/__tests__/helpers/spawn-mock.ts +37 -10
- package/src/execution/__tests__/index-session-start-identity.test.ts +371 -0
- package/src/execution/__tests__/index-session-start.test.ts +257 -5
- package/src/execution/__tests__/lifecycle-manager-lock.test.ts +211 -0
- package/src/execution/__tests__/lifecycle-manager.test.ts +337 -0
- package/src/execution/__tests__/lifecycle-predicates.test.ts +116 -0
- package/src/execution/__tests__/list-component.test.ts +59 -5
- package/src/execution/__tests__/list-fields.test.ts +109 -0
- package/src/execution/__tests__/model-resolver.test.ts +38 -1
- package/src/execution/__tests__/nested-visibility-env-propagation.test.ts +287 -0
- package/src/execution/__tests__/nested-visibility.test.ts +325 -0
- package/src/execution/__tests__/notifier-flush.test.ts +209 -7
- package/src/execution/__tests__/one-shot-upgrade.test.ts +205 -0
- package/src/execution/__tests__/parent-child-matrix.test.ts +336 -0
- package/src/execution/__tests__/record-store.test.ts +442 -54
- package/src/execution/__tests__/recursive-visibility-baseline.test.ts +11 -12
- package/src/execution/__tests__/recursive-visibility-env.test.ts +18 -20
- package/src/execution/__tests__/resource-policy.test.ts +109 -0
- package/src/execution/__tests__/run-and-finalize-chatmode.test.ts +267 -0
- package/src/execution/__tests__/run-spawn-chatmode-settled.test.ts +253 -0
- package/src/execution/__tests__/run-spawn-edges.test.ts +18 -25
- package/src/execution/__tests__/run-spawn-integration.test.ts +29 -25
- package/src/execution/__tests__/run-spawn-resume.test.ts +322 -0
- package/src/execution/__tests__/run-spawn-rpc-mode.test.ts +14 -11
- package/src/execution/__tests__/session-pending.test.ts +61 -2
- package/src/execution/__tests__/session-reconstructor.test.ts +4 -4
- package/src/execution/__tests__/session-runner-epipe.test.ts +178 -0
- package/src/execution/__tests__/session-runner-schema-env.test.ts +15 -21
- package/src/execution/__tests__/session-start-reaper.test.ts +10 -8
- package/src/execution/__tests__/spawn-args.test.ts +127 -49
- package/src/execution/__tests__/spawn-worktree-guidance.test.ts +1 -0
- package/src/execution/__tests__/spawned-children.test.ts +92 -0
- package/src/execution/__tests__/status-refactor.test.ts +345 -0
- package/src/execution/__tests__/stdin-writer.test.ts +97 -0
- package/src/execution/__tests__/subagent-service-message-close.test.ts +629 -0
- package/src/execution/__tests__/subagent-service-parent-guard.test.ts +180 -0
- package/src/execution/__tests__/subagent-service.test.ts +49 -11
- package/src/execution/__tests__/timeout-integration.test.ts +27 -13
- package/src/execution/__tests__/tool-action.test.ts +12 -10
- package/src/execution/__tests__/truncline-snapshot.test.ts +81 -0
- package/src/execution/__tests__/turn-limiter-semantics.test.ts +194 -0
- package/src/execution/__tests__/worktree-manager.test.ts +300 -90
- package/src/execution/__tests__/worktree-pid-registration.integration.test.ts +17 -13
- package/src/execution/__tests__/worktree-reconcile.integration.test.ts +181 -0
- package/src/execution/__tests__/worktree-registry.test.ts +72 -34
- package/src/execution/agent-result-mapper.ts +4 -1
- package/src/execution/argv-mirror.ts +21 -2
- package/src/execution/channel-registry-access.ts +3 -1
- package/src/execution/execution-record.ts +126 -9
- package/src/execution/finalize-record.ts +99 -13
- package/src/execution/idle-gc.ts +47 -0
- package/src/execution/lifecycle-manager.ts +491 -0
- package/src/execution/lifecycle-predicates.ts +65 -0
- package/src/execution/manifest-store.ts +61 -16
- package/src/execution/model-resolver.ts +26 -5
- package/src/execution/notifier.ts +69 -12
- package/src/execution/pi-invocation.ts +21 -1
- package/src/execution/record-entry.ts +118 -0
- package/src/execution/record-store.ts +844 -108
- package/src/execution/session-pending.ts +121 -49
- package/src/execution/session-reconstructor.ts +224 -7
- package/src/execution/session-runner.ts +713 -316
- package/src/execution/sessions-index.ts +304 -0
- package/src/execution/stdin-writer.ts +93 -7
- package/src/execution/stream-sink.ts +20 -3
- package/src/execution/subagent-service.ts +917 -138
- package/src/execution/temp-prompt.ts +8 -3
- package/src/execution/turn-limiter.ts +14 -0
- package/src/execution/types.ts +218 -19
- package/src/execution/worktree-manager.ts +449 -59
- package/src/execution/worktree-registry.ts +97 -29
- package/src/index.ts +318 -20
- package/src/injectors/subagent-list-injector.ts +26 -8
- package/src/injectors/workflow-list-injector.ts +25 -8
- package/src/interface/__tests__/subagent-tool-prompt.test.ts +17 -4
- package/src/interface/__tests__/tool-render.test.ts +10 -8
- package/src/interface/__tests__/tool-workflow-script-generate.test.ts +103 -26
- package/src/interface/__tests__/tool-workflow-throw-paths.test.ts +179 -0
- package/src/interface/bg-notify-render.ts +32 -8
- package/src/interface/command-actions.ts +26 -7
- package/src/interface/commands.ts +21 -22
- package/src/interface/format.ts +53 -20
- package/src/interface/gui-mappers.ts +6 -8
- package/src/interface/helpers.ts +170 -10
- package/src/interface/list-component.ts +53 -14
- package/src/interface/subagent-actions.ts +235 -17
- package/src/interface/subagent-tool.ts +81 -16
- package/src/interface/subagents.ts +2 -1
- package/src/interface/tool-render.ts +21 -27
- package/src/interface/tool-workflow-script.ts +29 -33
- package/src/interface/tool-workflow.ts +67 -100
- package/src/interface/views/WorkflowsView.ts +89 -32
- package/src/interface/views/__tests__/WorkflowsView-signature.test.ts +264 -0
- package/src/interface/views/detail-content.ts +1 -1
- package/src/interface/views/format.ts +3 -3
- package/src/orchestration/__tests__/__fixtures__/worker-template.snapshot.txt +333 -0
- package/src/orchestration/__tests__/args-validator.test.ts +1 -1
- package/src/orchestration/__tests__/config-loader.test.ts +38 -0
- package/src/orchestration/__tests__/error-recovery-handlers.test.ts +394 -4
- package/src/orchestration/__tests__/error-recovery-workflow-call.test.ts +4 -4
- package/src/orchestration/__tests__/execute-agent-call.test.ts +144 -1
- package/src/orchestration/__tests__/jsonl-run-store-loadall-sources.test.ts +171 -0
- package/src/orchestration/__tests__/jsonl-run-store-session-file.test.ts +820 -19
- package/src/orchestration/__tests__/launcher-nested-workflow.test.ts +0 -2
- package/src/orchestration/__tests__/lifecycle-runid-injection.test.ts +96 -0
- package/src/orchestration/__tests__/lifecycle.test.ts +332 -149
- package/src/orchestration/__tests__/review-fix-loop-e2e.test.ts +1108 -19
- package/src/orchestration/__tests__/skill-discovery.test.ts +130 -61
- package/src/orchestration/__tests__/test-mocks.ts +197 -0
- package/src/orchestration/__tests__/worker-returnmeta-passthrough.test.ts +164 -0
- package/src/orchestration/__tests__/worker-script-template-snapshot.test.ts +110 -0
- package/src/orchestration/__tests__/workflow-nesting-e2e.test.ts +0 -2
- package/src/orchestration/__tests__/workflow-script-lint-memo.test.ts +110 -0
- package/src/orchestration/__tests__/workflows-e2e.test.ts +38 -40
- package/src/orchestration/agent-opts-resolver.ts +4 -1
- package/src/orchestration/args-validator.ts +2 -2
- package/src/orchestration/config-loader.ts +30 -1
- package/src/orchestration/error-recovery.ts +137 -30
- package/src/orchestration/execute-agent-call.ts +44 -10
- package/src/orchestration/jsonl-run-store.ts +397 -71
- package/src/orchestration/launcher.ts +7 -1
- package/src/orchestration/lifecycle.ts +145 -133
- package/src/orchestration/models/__tests__/trace.test.ts +408 -0
- package/src/orchestration/models/budget.ts +1 -1
- package/src/orchestration/models/run-runtime.ts +15 -17
- package/src/orchestration/models/run-spec.ts +2 -2
- package/src/orchestration/models/run-state.ts +3 -3
- package/src/orchestration/models/trace.ts +95 -15
- package/src/orchestration/models/types.ts +8 -9
- package/src/orchestration/models/workflow-run.ts +50 -71
- package/src/orchestration/models/workflow-script.ts +32 -1
- package/src/orchestration/skill-discovery.ts +30 -0
- package/src/orchestration/worker-handle.ts +1 -1
- package/src/orchestration/worker-host.ts +1 -1
- package/src/orchestration/worker-script-builder.ts +38 -11
- package/src/shared/__tests__/agent-ref.test.ts +34 -0
- package/src/shared/__tests__/resource-discovery-manifest-cache.test.ts +280 -0
- package/src/shared/__tests__/resource-discovery.test.ts +79 -0
- package/src/shared/__tests__/schema-jsonify.test.ts +81 -0
- package/src/shared/agent-ref.ts +22 -1
- package/src/shared/resource-discovery.ts +162 -59
- package/src/shared/schema-jsonify.ts +56 -0
- package/workflows/review-fix-loop-utils.cjs +542 -32
- package/workflows/review-fix-loop.js +462 -109
|
@@ -1,88 +1,157 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* resolveSkillPath
|
|
3
|
-
*
|
|
4
|
-
*
|
|
2
|
+
* skill-discovery — resolveSkillPath 结果缓存(IF8/#14)+ 搜索序测试。
|
|
3
|
+
*
|
|
4
|
+
* 现状该文件此前零测试,本文件为首个测试(design IF8 契约):
|
|
5
|
+
* - hit 不再 existsSync(计数 wrapper 观察,spyOn ESM namespace 不可配置的既有教训)
|
|
6
|
+
* - 未命中 undefined 也缓存(不存在的名字二次调用零 stat)
|
|
7
|
+
* - clearSkillPathCache 后重扫
|
|
8
|
+
* - project 优先于 user/npm 的搜索序
|
|
9
|
+
*
|
|
10
|
+
* [merge 注] main 侧同名文件(mock-fs 版)的 5 用例语义均已覆盖:搜索序三用例
|
|
11
|
+
* 同名等价;「npm 候选路径构造含 agentDir」= 本文件各用例断言 join(homeRoot,
|
|
12
|
+
* ".pi/agent/npm/...") 完整路径 + npm 兜底用例的多包枚举;「全 miss undefined 含
|
|
13
|
+
* readdirSync ENOENT 兜底」= 「未命中 undefined 也缓存」用例(homeRoot 无 npm 目录,
|
|
14
|
+
* 真实 fs 下 ENOENT 由实现 catch)。mock 版被本集成版取代。
|
|
15
|
+
*
|
|
16
|
+
* 隔离:node:fs 的 existsSync 用 vi.mock 包一层计数(委托真实实现);
|
|
17
|
+
* getAgentDir 用 vi.mock 覆盖为临时 home(vitest alias 指向 mocks/pi-coding-agent.ts
|
|
18
|
+
* 的硬编码桩 "/home/user/.pi/agent",桩不读 os.homedir——mock node:os 无法影响它);
|
|
19
|
+
* process.cwd 用 spyOn。
|
|
5
20
|
*/
|
|
6
|
-
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
7
|
-
import
|
|
8
|
-
import
|
|
21
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
22
|
+
import { mkdtempSync, mkdirSync, rmSync } from "node:fs";
|
|
23
|
+
import { tmpdir } from "node:os";
|
|
24
|
+
import { join } from "node:path";
|
|
9
25
|
|
|
10
|
-
vi.
|
|
11
|
-
|
|
12
|
-
|
|
26
|
+
const { existsSyncCalls, osHome } = vi.hoisted(() => ({
|
|
27
|
+
existsSyncCalls: { count: 0 },
|
|
28
|
+
osHome: { dir: "/nonexistent-home-skill-tests" },
|
|
13
29
|
}));
|
|
14
30
|
|
|
15
|
-
vi.mock("
|
|
16
|
-
|
|
17
|
-
|
|
31
|
+
vi.mock("node:fs", async (importOriginal) => {
|
|
32
|
+
const actual = await importOriginal<typeof import("node:fs")>();
|
|
33
|
+
const realExistsSync = actual.existsSync;
|
|
34
|
+
// 计数 wrapper(委托真实实现)——vi.spyOn(fs, "existsSync") 在 ESM 模块
|
|
35
|
+
// 命名空间不可配置(record-store-index.test.ts:419 同款教训),改用 mock 工厂。
|
|
36
|
+
const countingExistsSync = ((p: string) => {
|
|
37
|
+
existsSyncCalls.count++;
|
|
38
|
+
return realExistsSync(p);
|
|
39
|
+
}) as typeof actual.existsSync;
|
|
40
|
+
return { ...actual, existsSync: countingExistsSync };
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
vi.mock("@earendil-works/pi-coding-agent", async (importOriginal) => {
|
|
44
|
+
const actual = await importOriginal<typeof import("@earendil-works/pi-coding-agent")>();
|
|
45
|
+
const { join } = await import("node:path");
|
|
46
|
+
// 桩不读 os.homedir,必须在此把 agentDir 指向 hoisted 临时 home(beforeEach 注入 homeRoot)
|
|
47
|
+
return { ...actual, getAgentDir: () => join(osHome.dir, ".pi", "agent") };
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
import { clearSkillPathCache, resolveSkillPath } from "../skill-discovery.ts";
|
|
18
51
|
|
|
19
|
-
|
|
20
|
-
import { resolveSkillPath } from "../skill-discovery";
|
|
52
|
+
// ── 临时目录工具 ──────────────────────────────────────────────
|
|
21
53
|
|
|
22
|
-
|
|
23
|
-
|
|
54
|
+
let projRoot: string;
|
|
55
|
+
let homeRoot: string;
|
|
24
56
|
|
|
25
57
|
beforeEach(() => {
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
mockedFs.readdirSync.mockReturnValue([]);
|
|
58
|
+
clearSkillPathCache();
|
|
59
|
+
existsSyncCalls.count = 0;
|
|
60
|
+
projRoot = mkdtempSync(join(tmpdir(), "skill-proj-"));
|
|
61
|
+
homeRoot = mkdtempSync(join(tmpdir(), "skill-home-"));
|
|
62
|
+
osHome.dir = homeRoot;
|
|
32
63
|
});
|
|
33
64
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
65
|
+
afterEach(() => {
|
|
66
|
+
vi.restoreAllMocks();
|
|
67
|
+
rmSync(projRoot, { recursive: true, force: true });
|
|
68
|
+
rmSync(homeRoot, { recursive: true, force: true });
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
/** mkdir -p(目录存在即 resolveSkillPath 命中——检查的是目录而非 SKILL.md)。 */
|
|
72
|
+
function mkdirp(dir: string): void {
|
|
73
|
+
mkdirSync(dir, { recursive: true });
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
describe("resolveSkillPath — 搜索序(project > user > npm)", () => {
|
|
77
|
+
it("project 优先:同名 skill 同时存在于 project/user/npm 时返回 project 路径", () => {
|
|
78
|
+
const cwdSpy = vi.spyOn(process, "cwd").mockReturnValue(projRoot);
|
|
79
|
+
const name = "dup-skill";
|
|
80
|
+
mkdirp(join(projRoot, ".agents/skills", name));
|
|
81
|
+
mkdirp(join(homeRoot, ".pi/agent/skills", name));
|
|
82
|
+
mkdirp(join(homeRoot, ".pi/agent/npm/node_modules/some-pkg/skills", name));
|
|
83
|
+
|
|
84
|
+
const resolved = resolveSkillPath(name);
|
|
38
85
|
|
|
39
|
-
expect(
|
|
86
|
+
expect(resolved).toBe(join(projRoot, ".agents/skills", name));
|
|
87
|
+
expect(cwdSpy).toHaveBeenCalled();
|
|
40
88
|
});
|
|
41
89
|
|
|
42
|
-
it("
|
|
43
|
-
|
|
44
|
-
|
|
90
|
+
it("user 次之:仅 user/npm 存在时返回 user 路径", () => {
|
|
91
|
+
vi.spyOn(process, "cwd").mockReturnValue(projRoot);
|
|
92
|
+
const name = "user-skill";
|
|
93
|
+
mkdirp(join(homeRoot, ".pi/agent/skills", name));
|
|
94
|
+
mkdirp(join(homeRoot, ".pi/agent/npm/node_modules/some-pkg/skills", name));
|
|
45
95
|
|
|
46
|
-
expect(resolveSkillPath(
|
|
96
|
+
expect(resolveSkillPath(name)).toBe(join(homeRoot, ".pi/agent/skills", name));
|
|
47
97
|
});
|
|
48
98
|
|
|
49
|
-
it("npm
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
99
|
+
it("npm 兜底:仅 npm 包内存在时返回包内 skills 路径(readdirSync 枚举多包候选)", () => {
|
|
100
|
+
vi.spyOn(process, "cwd").mockReturnValue(projRoot);
|
|
101
|
+
const name = "npm-skill";
|
|
102
|
+
// 两个包并存:断言实现枚举 npm/node_modules 下每个包(S-6 语义,防只查首个包的回退)
|
|
103
|
+
mkdirp(join(homeRoot, ".pi/agent/npm/node_modules/pkg-a/other", "x"));
|
|
104
|
+
mkdirp(join(homeRoot, ".pi/agent/npm/node_modules/some-pkg/skills", name));
|
|
105
|
+
|
|
106
|
+
expect(resolveSkillPath(name)).toBe(
|
|
107
|
+
join(homeRoot, ".pi/agent/npm/node_modules/some-pkg/skills", name),
|
|
56
108
|
);
|
|
57
|
-
|
|
109
|
+
});
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
describe("resolveSkillPath — 结果缓存(IF8/DM3)", () => {
|
|
113
|
+
it("hit 不再 existsSync:二次调用零 stat", () => {
|
|
114
|
+
vi.spyOn(process, "cwd").mockReturnValue(projRoot);
|
|
115
|
+
const name = "cache-hit";
|
|
116
|
+
const expected = join(projRoot, ".agents/skills", name);
|
|
117
|
+
mkdirp(expected);
|
|
58
118
|
|
|
59
|
-
|
|
119
|
+
const first = resolveSkillPath(name);
|
|
120
|
+
const callsAfterFirst = existsSyncCalls.count;
|
|
121
|
+
expect(first).toBe(expected);
|
|
122
|
+
expect(callsAfterFirst).toBeGreaterThan(0);
|
|
123
|
+
|
|
124
|
+
const second = resolveSkillPath(name);
|
|
125
|
+
expect(second).toBe(expected);
|
|
126
|
+
expect(existsSyncCalls.count).toBe(callsAfterFirst); // 零增量
|
|
60
127
|
});
|
|
61
128
|
|
|
62
|
-
it("
|
|
63
|
-
|
|
64
|
-
const
|
|
65
|
-
const probed: string[] = [];
|
|
66
|
-
mockedFs.existsSync.mockImplementation((p) => {
|
|
67
|
-
probed.push(p as string);
|
|
68
|
-
return false;
|
|
69
|
-
});
|
|
70
|
-
mockedFs.readdirSync.mockImplementation((dir) =>
|
|
71
|
-
dir === npmSkillsDir ? ["pkg-a", "pkg-b"] : [],
|
|
72
|
-
);
|
|
129
|
+
it("未命中 undefined 也缓存:不存在的名字二次调用零 stat", () => {
|
|
130
|
+
vi.spyOn(process, "cwd").mockReturnValue(projRoot);
|
|
131
|
+
const name = "no-such-skill-xyz";
|
|
73
132
|
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
expect(
|
|
133
|
+
const first = resolveSkillPath(name);
|
|
134
|
+
expect(first).toBeUndefined();
|
|
135
|
+
const callsAfterFirst = existsSyncCalls.count;
|
|
136
|
+
expect(callsAfterFirst).toBeGreaterThan(0);
|
|
137
|
+
|
|
138
|
+
const second = resolveSkillPath(name);
|
|
139
|
+
expect(second).toBeUndefined();
|
|
140
|
+
expect(existsSyncCalls.count).toBe(callsAfterFirst); // 零增量(未命中也缓存)
|
|
78
141
|
});
|
|
79
142
|
|
|
80
|
-
it("
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
143
|
+
it("clearSkillPathCache 后重扫:计数恢复增长且结果一致", () => {
|
|
144
|
+
vi.spyOn(process, "cwd").mockReturnValue(projRoot);
|
|
145
|
+
const name = "cache-clear";
|
|
146
|
+
const expected = join(projRoot, ".agents/skills", name);
|
|
147
|
+
mkdirp(expected);
|
|
148
|
+
|
|
149
|
+
expect(resolveSkillPath(name)).toBe(expected);
|
|
150
|
+
const callsAfterFirst = existsSyncCalls.count;
|
|
151
|
+
expect(callsAfterFirst).toBeGreaterThan(0);
|
|
85
152
|
|
|
86
|
-
|
|
153
|
+
clearSkillPathCache();
|
|
154
|
+
expect(resolveSkillPath(name)).toBe(expected);
|
|
155
|
+
expect(existsSyncCalls.count).toBeGreaterThan(callsAfterFirst); // 重扫发生
|
|
87
156
|
});
|
|
88
157
|
});
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
// src/orchestration/__tests__/test-mocks.ts
|
|
2
|
+
//
|
|
3
|
+
// Pi ExtensionAPI / ExtensionContext 测试 mock 工厂(PR #173 review S8)。
|
|
4
|
+
//
|
|
5
|
+
// 为什么需要工厂:此前测试用 `as never` 注入残缺 mock(如 `{ appendEntry: vi.fn() }`
|
|
6
|
+
// 直接塞进 `JsonlRunStoreOptions.pi`)。`as never` 是 bottom type 断言,mock 缺字段时
|
|
7
|
+
// 编译期零报错,比 `as unknown as T` 更危险。工厂返回**类型完整**的对象——被 mock
|
|
8
|
+
// 接口(真实 SDK 类型,tsc 按根 tsconfig 排除规则不检查 __tests__,但编辑器 LSP 与
|
|
9
|
+
// 显式 tsc 文件级检查会检查)新增成员时,这里补默认值即暴露,用例侧不再有断言后门。
|
|
10
|
+
//
|
|
11
|
+
// 语义约定:
|
|
12
|
+
// - 默认值贴近真实行为:sessionManager.getEntries 返回浅拷贝(真实 SessionManager
|
|
13
|
+
// 契约);appendEntry 捕获变体追加完整 CustomEntry(真实 pi.appendEntry 即向 session
|
|
14
|
+
// 追加 CustomEntry);isIdle → true、hasPendingMessages → false 等按真实语义取值。
|
|
15
|
+
// - 测试真正消费的成员(jsonl-run-store 只读 ctx.sessionManager.getEntries 与
|
|
16
|
+
// pi.appendEntry)行为真实;其余成员为 no-op spy(vi.fn()),调用不炸但返回
|
|
17
|
+
// undefined——本包测试不消费它们。
|
|
18
|
+
// - modelRegistry / ui.theme 是含 private 字段的 nominal class:真实构造需
|
|
19
|
+
// ModelRuntime.create()(async + 读用户级 models.json)/ loadTheme 读包文件,且
|
|
20
|
+
// vitest 把 @earendil-works/pi-coding-agent alias 到 mocks/(无这些值导出),结构
|
|
21
|
+
// 构造又被 private 字段阻断。二者经 fail-loud Proxy 提供:误用时立即抛错(而非
|
|
22
|
+
// 静默 undefined),把「缺 mock」变成显式失败。这是本文件仅有的两处断言收敛点。
|
|
23
|
+
|
|
24
|
+
import { vi } from "vitest";
|
|
25
|
+
|
|
26
|
+
import type {
|
|
27
|
+
CustomEntry,
|
|
28
|
+
ExtensionAPI,
|
|
29
|
+
ExtensionContext,
|
|
30
|
+
ExtensionUIContext,
|
|
31
|
+
ModelRegistry,
|
|
32
|
+
Theme,
|
|
33
|
+
} from "@earendil-works/pi-coding-agent";
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* 误用即抛错的占位成员:比静默返回 undefined 的 no-op 更早暴露「缺 mock」。
|
|
37
|
+
*
|
|
38
|
+
* T 收窄为 object:调用点(ModelRegistry/Theme)均为对象类型;Proxy 静态类型是 `{}`,
|
|
39
|
+
* 目标是 nominal class(private 字段阻断结构构造),单重 `as T` 断言已足够表达
|
|
40
|
+
* 「结构上无法构造,运行时 fail-loud 兜底」的意图,无需 `as unknown as T` 双重断言。
|
|
41
|
+
*/
|
|
42
|
+
function failLoud<T extends object>(memberPath: string): T {
|
|
43
|
+
return new Proxy(
|
|
44
|
+
{},
|
|
45
|
+
{
|
|
46
|
+
get(_target, prop) {
|
|
47
|
+
throw new Error(
|
|
48
|
+
`[test-mocks] ${memberPath}.${String(prop)} 未提供 mock 实现——` +
|
|
49
|
+
`本测试不应消费该成员;确需消费时经 overrides 显式提供`,
|
|
50
|
+
);
|
|
51
|
+
},
|
|
52
|
+
},
|
|
53
|
+
) as T;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* 构造类型完整的 ExtensionContext。
|
|
58
|
+
*
|
|
59
|
+
* @param entries sessionManager.getEntries 的返回源(浅拷贝返回)。与 mkPi(entries)
|
|
60
|
+
* 共享同一数组时可复现真实 pi 行为:appendEntry 写入 ↔ getEntries 读出。
|
|
61
|
+
* @param overrides 覆写任意 ctx 字段(浅合并,整体替换成员,最后应用)。
|
|
62
|
+
*/
|
|
63
|
+
export function mkCtx(
|
|
64
|
+
entries: CustomEntry[] = [],
|
|
65
|
+
overrides: Partial<ExtensionContext> = {},
|
|
66
|
+
): ExtensionContext {
|
|
67
|
+
const ctx: ExtensionContext = {
|
|
68
|
+
ui: mkUi(),
|
|
69
|
+
// pi 在 xyz-agent 下以 rpc 子进程模式运行;rpc 模式 hasUI 为 true(SDK 契约注释)
|
|
70
|
+
mode: "rpc",
|
|
71
|
+
hasUI: true,
|
|
72
|
+
cwd: "/tmp",
|
|
73
|
+
sessionManager: {
|
|
74
|
+
getCwd: () => "/tmp",
|
|
75
|
+
getSessionDir: () => "/tmp/sessions-test",
|
|
76
|
+
getSessionId: () => "session-test",
|
|
77
|
+
// 真实语义:首条 assistant 消息前 session 文件未落盘,返回 undefined
|
|
78
|
+
getSessionFile: () => undefined,
|
|
79
|
+
getLeafId: () => null,
|
|
80
|
+
getLeafEntry: () => undefined,
|
|
81
|
+
getEntry: () => undefined,
|
|
82
|
+
getLabel: () => undefined,
|
|
83
|
+
getBranch: () => [],
|
|
84
|
+
buildContextEntries: () => [],
|
|
85
|
+
getHeader: () => null,
|
|
86
|
+
// 真实 SessionManager.getEntries 返回浅拷贝
|
|
87
|
+
getEntries: () => [...entries],
|
|
88
|
+
getTree: () => [],
|
|
89
|
+
getSessionName: () => undefined,
|
|
90
|
+
},
|
|
91
|
+
modelRegistry: failLoud<ModelRegistry>("ctx.modelRegistry"),
|
|
92
|
+
// SDK 契约:model / signal 在 agent 未流式输出时合法为 undefined
|
|
93
|
+
model: undefined,
|
|
94
|
+
isIdle: vi.fn(() => true),
|
|
95
|
+
isProjectTrusted: vi.fn(() => true),
|
|
96
|
+
signal: undefined,
|
|
97
|
+
abort: vi.fn(),
|
|
98
|
+
hasPendingMessages: vi.fn(() => false),
|
|
99
|
+
shutdown: vi.fn(),
|
|
100
|
+
getContextUsage: vi.fn(() => undefined),
|
|
101
|
+
compact: vi.fn(),
|
|
102
|
+
getSystemPrompt: vi.fn(() => ""),
|
|
103
|
+
};
|
|
104
|
+
return Object.assign(ctx, overrides);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** ExtensionUIContext 完整 no-op mock(theme 为 fail-loud 占位,见文件头说明)。 */
|
|
108
|
+
function mkUi(): ExtensionUIContext {
|
|
109
|
+
return {
|
|
110
|
+
select: vi.fn(async () => undefined),
|
|
111
|
+
confirm: vi.fn(async () => false),
|
|
112
|
+
input: vi.fn(async () => undefined),
|
|
113
|
+
notify: vi.fn(),
|
|
114
|
+
onTerminalInput: vi.fn(() => () => {}),
|
|
115
|
+
setStatus: vi.fn(),
|
|
116
|
+
setWorkingMessage: vi.fn(),
|
|
117
|
+
setWorkingVisible: vi.fn(),
|
|
118
|
+
setWorkingIndicator: vi.fn(),
|
|
119
|
+
setHiddenThinkingLabel: vi.fn(),
|
|
120
|
+
setWidget: vi.fn(),
|
|
121
|
+
setFooter: vi.fn(),
|
|
122
|
+
setHeader: vi.fn(),
|
|
123
|
+
setTitle: vi.fn(),
|
|
124
|
+
custom: vi.fn(),
|
|
125
|
+
pasteToEditor: vi.fn(),
|
|
126
|
+
setEditorText: vi.fn(),
|
|
127
|
+
getEditorText: vi.fn(() => ""),
|
|
128
|
+
editor: vi.fn(async () => undefined),
|
|
129
|
+
addAutocompleteProvider: vi.fn(),
|
|
130
|
+
setEditorComponent: vi.fn(),
|
|
131
|
+
getEditorComponent: vi.fn(() => undefined),
|
|
132
|
+
theme: failLoud<Theme>("ctx.ui.theme"),
|
|
133
|
+
getAllThemes: vi.fn(() => []),
|
|
134
|
+
getTheme: vi.fn(() => undefined),
|
|
135
|
+
setTheme: vi.fn(() => ({ success: true })),
|
|
136
|
+
getToolsExpanded: vi.fn(() => false),
|
|
137
|
+
setToolsExpanded: vi.fn(),
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* 构造类型完整的 ExtensionAPI。
|
|
143
|
+
*
|
|
144
|
+
* @param entries 给定时,appendEntry 追加完整 CustomEntry 到该数组(真实
|
|
145
|
+
* pi.appendEntry 语义:向 session 追加 custom entry);缺省为纯 spy(可断言
|
|
146
|
+
* 调用次数、无副作用)。
|
|
147
|
+
* @param overrides 覆写任意 pi 字段(浅合并,最后应用)。
|
|
148
|
+
*/
|
|
149
|
+
export function mkPi(
|
|
150
|
+
entries?: CustomEntry[],
|
|
151
|
+
overrides: Partial<ExtensionAPI> = {},
|
|
152
|
+
): ExtensionAPI {
|
|
153
|
+
const appendEntry =
|
|
154
|
+
entries === undefined
|
|
155
|
+
? vi.fn()
|
|
156
|
+
: vi.fn((customType: string, data?: unknown) => {
|
|
157
|
+
entries.push({
|
|
158
|
+
type: "custom",
|
|
159
|
+
customType,
|
|
160
|
+
data,
|
|
161
|
+
id: `entry-${entries.length}`,
|
|
162
|
+
parentId: null,
|
|
163
|
+
timestamp: new Date().toISOString(),
|
|
164
|
+
});
|
|
165
|
+
});
|
|
166
|
+
const pi: ExtensionAPI = {
|
|
167
|
+
on: vi.fn(),
|
|
168
|
+
registerTool: vi.fn(),
|
|
169
|
+
registerCommand: vi.fn(),
|
|
170
|
+
registerShortcut: vi.fn(),
|
|
171
|
+
registerFlag: vi.fn(),
|
|
172
|
+
getFlag: vi.fn(() => undefined),
|
|
173
|
+
registerMessageRenderer: vi.fn(),
|
|
174
|
+
registerEntryRenderer: vi.fn(),
|
|
175
|
+
sendMessage: vi.fn(),
|
|
176
|
+
sendUserMessage: vi.fn(),
|
|
177
|
+
appendEntry,
|
|
178
|
+
setSessionName: vi.fn(),
|
|
179
|
+
getSessionName: vi.fn(() => undefined),
|
|
180
|
+
setLabel: vi.fn(),
|
|
181
|
+
exec: vi.fn(),
|
|
182
|
+
getActiveTools: vi.fn(() => []),
|
|
183
|
+
getAllTools: vi.fn(() => []),
|
|
184
|
+
setActiveTools: vi.fn(),
|
|
185
|
+
getCommands: vi.fn(() => []),
|
|
186
|
+
setModel: vi.fn(async () => true),
|
|
187
|
+
getThinkingLevel: vi.fn(),
|
|
188
|
+
setThinkingLevel: vi.fn(),
|
|
189
|
+
registerProvider: vi.fn(),
|
|
190
|
+
unregisterProvider: vi.fn(),
|
|
191
|
+
events: {
|
|
192
|
+
emit: vi.fn(),
|
|
193
|
+
on: vi.fn(() => () => {}),
|
|
194
|
+
},
|
|
195
|
+
};
|
|
196
|
+
return Object.assign(pi, overrides);
|
|
197
|
+
}
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* rfl 仪表 T1(tier-1 §7.1):returnMeta 透传 usage/durationMs/sessionId。
|
|
3
|
+
*
|
|
4
|
+
* 两个对称点的运行时验证(worker-script-builder 模板字符串断言无法证明
|
|
5
|
+
* 「真实 Worker 线程里 resolve 对象字段齐全」):
|
|
6
|
+
* A1 live resolve——agent-result 消息回发完整 AgentResult,returnMeta:true
|
|
7
|
+
* 时 resolve 对象 = {value, sessionFile, worktreePath, error, usage, durationMs, sessionId}
|
|
8
|
+
* A2 缓存重放——workerData.callCache 命中时重建对象同样含三字段(对称点)
|
|
9
|
+
*
|
|
10
|
+
* 模式与 worker-script-builder-runtime.test.ts 相同:真实 Worker 线程执行
|
|
11
|
+
* buildWorkerScript 产物,主线程模拟 runtime 回发消息。
|
|
12
|
+
*/
|
|
13
|
+
import { Worker } from "node:worker_threads";
|
|
14
|
+
|
|
15
|
+
import { afterEach, describe, expect, it } from "vitest";
|
|
16
|
+
|
|
17
|
+
import { buildWorkerScript } from "../worker-script-builder.ts";
|
|
18
|
+
|
|
19
|
+
const USAGE = { input: 100, output: 50, cacheRead: 700, cacheWrite: 30, cost: 0.012, contextTokens: 850, turns: 3 };
|
|
20
|
+
|
|
21
|
+
const createdWorkers: Worker[] = [];
|
|
22
|
+
|
|
23
|
+
afterEach(() => {
|
|
24
|
+
for (const w of createdWorkers.splice(0)) {
|
|
25
|
+
w.terminate().catch(() => {});
|
|
26
|
+
}
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
interface RunOutcome {
|
|
30
|
+
returnValue?: unknown;
|
|
31
|
+
errorMessage?: string;
|
|
32
|
+
exitCode?: number;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* 起 Worker 跑 userScript;收到首个 agent-call 即回发 agentResult(调用方提供),
|
|
37
|
+
* 等脚本 return。callCache 命中路径不会发出 agent-call(直接从缓存重建返回)。
|
|
38
|
+
*/
|
|
39
|
+
function runWorker(
|
|
40
|
+
userScript: string,
|
|
41
|
+
opts: { agentResult?: Record<string, unknown>; callCache?: Record<number, unknown>; timeoutMs?: number },
|
|
42
|
+
): Promise<RunOutcome> {
|
|
43
|
+
const timeoutMs = opts.timeoutMs ?? 4000;
|
|
44
|
+
return new Promise((resolve, reject) => {
|
|
45
|
+
const worker = new Worker(buildWorkerScript(userScript), {
|
|
46
|
+
eval: true,
|
|
47
|
+
workerData: {
|
|
48
|
+
scriptPath: "test.js",
|
|
49
|
+
args: {},
|
|
50
|
+
workspace: process.cwd(),
|
|
51
|
+
budget: { maxTokens: 0, usedTokens: 0, usedCost: 0 },
|
|
52
|
+
callCache: opts.callCache ?? {},
|
|
53
|
+
},
|
|
54
|
+
});
|
|
55
|
+
createdWorkers.push(worker);
|
|
56
|
+
const out: RunOutcome = {};
|
|
57
|
+
let settled = false;
|
|
58
|
+
const timer = setTimeout(() => {
|
|
59
|
+
worker.terminate().catch(() => {});
|
|
60
|
+
reject(new Error("Worker timed out — likely hung"));
|
|
61
|
+
}, timeoutMs);
|
|
62
|
+
const finish = (r: RunOutcome): void => {
|
|
63
|
+
if (settled) return;
|
|
64
|
+
settled = true;
|
|
65
|
+
clearTimeout(timer);
|
|
66
|
+
resolve(r);
|
|
67
|
+
};
|
|
68
|
+
worker.on("message", (raw: unknown) => {
|
|
69
|
+
const msg = raw as { type?: string; callId?: number };
|
|
70
|
+
if (msg.type === "agent-call" && typeof msg.callId === "number") {
|
|
71
|
+
worker.postMessage({
|
|
72
|
+
type: "agent-result",
|
|
73
|
+
callId: msg.callId,
|
|
74
|
+
result: opts.agentResult ?? { content: "fallback", parsedOutput: { ok: true } },
|
|
75
|
+
cached: false,
|
|
76
|
+
});
|
|
77
|
+
} else if (msg.type === "return") {
|
|
78
|
+
out.returnValue = (raw as { result?: unknown }).result;
|
|
79
|
+
finish(out);
|
|
80
|
+
} else if (msg.type === "error") {
|
|
81
|
+
out.errorMessage = (raw as { error?: string }).error;
|
|
82
|
+
finish(out);
|
|
83
|
+
}
|
|
84
|
+
});
|
|
85
|
+
worker.on("exit", (code: number) => {
|
|
86
|
+
out.exitCode = code;
|
|
87
|
+
if (code !== 0) finish(out);
|
|
88
|
+
});
|
|
89
|
+
worker.on("error", (err: Error) => {
|
|
90
|
+
out.errorMessage = err.message;
|
|
91
|
+
finish(out);
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const META_SCRIPT = "const meta = await agent({ prompt: 'passthrough probe', returnMeta: true }); return meta;";
|
|
97
|
+
|
|
98
|
+
describe("A1 returnMeta live resolve 透传(tier-1 §7.1)", () => {
|
|
99
|
+
it("A1 agent-result 回发完整 AgentResult 时 resolve 对象含 usage/durationMs/sessionId(七字段全集)", async () => {
|
|
100
|
+
const outcome = await runWorker(META_SCRIPT, {
|
|
101
|
+
agentResult: {
|
|
102
|
+
content: "raw text",
|
|
103
|
+
parsedOutput: { ok: true },
|
|
104
|
+
sessionFile: "/tmp/sess.jsonl",
|
|
105
|
+
worktreePath: "/tmp/wt",
|
|
106
|
+
error: undefined,
|
|
107
|
+
usage: USAGE,
|
|
108
|
+
durationMs: 83_000,
|
|
109
|
+
sessionId: "sess-abc-1",
|
|
110
|
+
},
|
|
111
|
+
});
|
|
112
|
+
expect(outcome.errorMessage).toBeUndefined();
|
|
113
|
+
expect(outcome.returnValue).toEqual({
|
|
114
|
+
value: { ok: true },
|
|
115
|
+
sessionFile: "/tmp/sess.jsonl",
|
|
116
|
+
worktreePath: "/tmp/wt",
|
|
117
|
+
error: undefined,
|
|
118
|
+
usage: USAGE,
|
|
119
|
+
durationMs: 83_000,
|
|
120
|
+
sessionId: "sess-abc-1",
|
|
121
|
+
});
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it("A1 旧主线程(AgentResult 无三字段)时 resolve 对象三字段为 undefined(向后兼容不抛错)", async () => {
|
|
125
|
+
const outcome = await runWorker(META_SCRIPT, {
|
|
126
|
+
agentResult: { content: "raw", parsedOutput: { v: 1 }, sessionFile: "/tmp/s.jsonl" },
|
|
127
|
+
});
|
|
128
|
+
expect(outcome.returnValue).toMatchObject({
|
|
129
|
+
value: { v: 1 },
|
|
130
|
+
sessionFile: "/tmp/s.jsonl",
|
|
131
|
+
usage: undefined,
|
|
132
|
+
durationMs: undefined,
|
|
133
|
+
sessionId: undefined,
|
|
134
|
+
});
|
|
135
|
+
});
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
describe("A2 returnMeta 缓存重放重建透传(对称点,tier-1 §7.1)", () => {
|
|
139
|
+
it("A2 workerData.callCache 命中时重建对象含 usage/durationMs/sessionId(不回发 agent-call)", async () => {
|
|
140
|
+
const outcome = await runWorker(META_SCRIPT, {
|
|
141
|
+
callCache: {
|
|
142
|
+
0: {
|
|
143
|
+
content: "cached raw",
|
|
144
|
+
parsedOutput: { cached: true },
|
|
145
|
+
sessionFile: "/tmp/cached.jsonl",
|
|
146
|
+
worktreePath: "/tmp/cwt",
|
|
147
|
+
usage: USAGE,
|
|
148
|
+
durationMs: 42_000,
|
|
149
|
+
sessionId: "sess-cache-9",
|
|
150
|
+
},
|
|
151
|
+
},
|
|
152
|
+
});
|
|
153
|
+
expect(outcome.errorMessage).toBeUndefined();
|
|
154
|
+
expect(outcome.returnValue).toEqual({
|
|
155
|
+
value: { cached: true },
|
|
156
|
+
sessionFile: "/tmp/cached.jsonl",
|
|
157
|
+
worktreePath: "/tmp/cwt",
|
|
158
|
+
error: undefined,
|
|
159
|
+
usage: USAGE,
|
|
160
|
+
durationMs: 42_000,
|
|
161
|
+
sessionId: "sess-cache-9",
|
|
162
|
+
});
|
|
163
|
+
});
|
|
164
|
+
});
|