@zhushanwen/pi-subagent-workflow 8.14.7 → 9.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.
Files changed (58) hide show
  1. package/README.md +6 -5
  2. package/package.json +11 -7
  3. package/relay/relay.mjs +45 -2
  4. package/skills/workflow-script-format/SKILL.md +2 -56
  5. package/src/host/__tests__/inflight-reporter.test.ts +26 -1
  6. package/src/host/__tests__/pi-host.test.ts +418 -4
  7. package/src/host/inflight-reporter.ts +13 -10
  8. package/src/host/pi-host.ts +205 -17
  9. package/src/index.ts +21 -72
  10. package/src/injectors/__tests__/engine-awareness.test.ts +236 -33
  11. package/src/injectors/__tests__/helpers/injector-test-mocks.ts +15 -7
  12. package/src/injectors/__tests__/workflow-list-injector.test.ts +447 -40
  13. package/src/injectors/resource-list-injector.ts +103 -32
  14. package/src/injectors/subagent-list-injector.ts +4 -28
  15. package/src/injectors/workflow-list-injector.ts +8 -4
  16. package/src/interface/__tests__/capture-tool.ts +56 -0
  17. package/src/interface/__tests__/commands-resume.test.ts +103 -0
  18. package/src/interface/__tests__/tool-prompt-contract.test.ts +361 -0
  19. package/src/interface/__tests__/tool-render.test.ts +105 -2
  20. package/src/interface/__tests__/tool-subagents.test.ts +24 -26
  21. package/src/interface/__tests__/tool-workflow-resume.test.ts +227 -0
  22. package/src/interface/__tests__/tool-workflow-script.test.ts +557 -0
  23. package/src/interface/__tests__/tool-workflow.test.ts +884 -0
  24. package/src/interface/bg-notify-render.ts +18 -9
  25. package/src/interface/command-actions.ts +22 -17
  26. package/src/interface/commands.ts +58 -12
  27. package/src/interface/format.ts +50 -16
  28. package/src/interface/id-preview.ts +4 -4
  29. package/src/interface/subagent-actions.ts +18 -50
  30. package/src/interface/subagent-tool.ts +26 -18
  31. package/src/interface/subagents.ts +5 -11
  32. package/src/interface/tool-render.ts +14 -30
  33. package/src/interface/tool-shared.ts +55 -12
  34. package/src/interface/tool-subagents.ts +21 -11
  35. package/src/interface/tool-workflow-script.ts +52 -72
  36. package/src/interface/tool-workflow.ts +345 -139
  37. package/src/interface/views/WorkflowsView.ts +10 -7
  38. package/src/interface/views/__tests__/{WorkflowsView-signature.test.ts → WorkflowsView.test.ts} +155 -11
  39. package/src/interface/views/detail-content.ts +3 -5
  40. package/src/jsonl-run-store.ts +656 -544
  41. package/src/model-events.ts +35 -0
  42. package/src/session-lifecycle.ts +376 -85
  43. package/src/subagents-events.ts +56 -0
  44. package/src/workflow-events.ts +285 -200
  45. package/src/workflow-notify.ts +456 -0
  46. package/src/injectors/__tests__/engine-section-stability.test.ts +0 -353
  47. package/src/injectors/__tests__/model-list-injector.test.ts +0 -136
  48. package/src/injectors/__tests__/subagent-list-injector.test.ts +0 -376
  49. package/src/interface/__tests__/detectors.test.ts +0 -161
  50. package/src/interface/__tests__/subagent-tool-prompt.test.ts +0 -183
  51. package/src/interface/__tests__/tool-workflow-run-builtin-name.test.ts +0 -219
  52. package/src/interface/__tests__/tool-workflow-script-generate.test.ts +0 -251
  53. package/src/interface/__tests__/tool-workflow-throw-paths.test.ts +0 -215
  54. package/src/interface/__tests__/workflow-state-file-exposure.test.ts +0 -38
  55. package/src/interface/__tests__/workflow-tool-prompt.test.ts +0 -129
  56. package/src/interface/helpers.ts +0 -272
  57. package/src/interface/views/__tests__/detail-content-session-file.test.ts +0 -73
  58. package/src/interface/views/__tests__/record-progress.test.ts +0 -235
@@ -0,0 +1,227 @@
1
+ /**
2
+ * workflow tool resume action 契约(workflow-run-resume-revision U3 / D14)。
3
+ *
4
+ * 覆盖面:
5
+ * - D14 args 校验(场景 24 单元层):传入 args 与 run-created 帧 argsSummary 的
6
+ * 逐字段深度比对(排除 _runId)——不一致明确拒绝且差异字段可见;一致幂等通过;
7
+ * 不传默认沿用历史(D14 层不拦)。场景级(真链路)用例由 u4b 落地。
8
+ * - argsSummary 截断形态的保守拒绝(全文 args 不进 record,截断摘要不可比对)。
9
+ * - record 流无 run-created 帧 → D14 层透传(资格判据归 core resumeRun 权威文案)。
10
+ * - resumeRun 接线:budgetTimeMs(time 形参透传)/ host / runId;成功文案与 details。
11
+ * - diffResumeArgs 纯逻辑(差异三形态 + 嵌套 + 数组 + _runId 排除)。
12
+ *
13
+ * mock 策略:resume-run 深路径 stub(resumeRun 为 vi.fn——不起锁/IO,只测入口
14
+ * 面与校验层);record 流用 mkdtemp 真文件(D14 读取面读真实路径)。范式对齐
15
+ * tool-workflow.test.ts(lifecycle 深路径 mock + captureTool 注册层黑盒)。
16
+ */
17
+ import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
18
+ import { tmpdir } from "node:os";
19
+ import { join } from "node:path";
20
+
21
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
22
+
23
+ // 被 mock 的模块——import 路径与被测源文件的等值实例(vi.mock hoist 后 barrel
24
+ // re-export 与深路径指向同一 mock 实例,对齐 tool-workflow.test.ts 先例)
25
+ import { resumeRun } from "@zhushanwen/subagent-core/orchestration/resume-run.ts";
26
+
27
+ import { actionResume, diffResumeArgs } from "../tool-workflow.ts";
28
+
29
+ /** 桩化 resume-run——resumeRun 为 vi.fn(不起锁/record IO,只测入口校验与接线面)。 */
30
+ vi.mock("@zhushanwen/subagent-core/orchestration/resume-run.ts", () => ({
31
+ resumeRun: vi.fn(),
32
+ }));
33
+
34
+ // ── fixture ──────────────────────────────────────────────────
35
+
36
+ let tmpDir: string;
37
+
38
+ beforeEach(() => {
39
+ vi.mocked(resumeRun).mockReset();
40
+ vi.mocked(resumeRun).mockResolvedValue("wf-test");
41
+ tmpDir = mkdtempSync(join(tmpdir(), "u3-resume-"));
42
+ });
43
+
44
+ afterEach(() => {
45
+ rmSync(tmpDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 20 });
46
+ vi.restoreAllMocks();
47
+ });
48
+
49
+ /** 写 record 流(每行一个 JSON 对象)并返回路径——D14 读取面的真实文件。 */
50
+ function writeRecordStream(lines: unknown[]): string {
51
+ const path = join(tmpDir, "wf-test.record.jsonl");
52
+ writeFileSync(path, lines.map((l) => JSON.stringify(l)).join("\n") + "\n", "utf8");
53
+ return path;
54
+ }
55
+
56
+ /** run-created 帧(args 含 _runId——对齐 lifecycle runWorkflow 注入后落账形态)。 */
57
+ function runCreatedFrame(argsSummary: string) {
58
+ return {
59
+ type: "run-created",
60
+ seq: 1,
61
+ ts: 1_700_000_000_000,
62
+ runId: "wf-test",
63
+ workflowName: "demo",
64
+ argsSummary,
65
+ scriptSource: "// demo",
66
+ };
67
+ }
68
+
69
+ /** 最小 deps stub(actionResume 只触 store.stateFilePath 与 resumeRun(deps))。 */
70
+ function makeDeps(stateFilePath: string): unknown {
71
+ return {
72
+ runs: new Map(),
73
+ store: { stateFilePath: () => stateFilePath },
74
+ registry: { get: vi.fn(), getPath: vi.fn(), loadAll: vi.fn() },
75
+ };
76
+ }
77
+
78
+ // ── diffResumeArgs 纯逻辑 ────────────────────────────────────
79
+
80
+ describe("diffResumeArgs(D14 深度比对)", () => {
81
+ it("值不等 → 差异字段可见(场景 24 前半:{a:1} 历史以 {a:2} 发)", () => {
82
+ expect(diffResumeArgs({ a: 2 }, { a: 1, _runId: "wf-test" })).toEqual([
83
+ "args.a: resume 2 vs original 1",
84
+ ]);
85
+ });
86
+
87
+ it("一致 → 空(场景 24 后半:{a:1} 幂等通过)", () => {
88
+ expect(diffResumeArgs({ a: 1 }, { a: 1, _runId: "wf-test" })).toEqual([]);
89
+ });
90
+
91
+ it("_runId 双侧排除(incoming 侧携带同样不比)", () => {
92
+ expect(diffResumeArgs({ a: 1, _runId: "other" }, { a: 1, _runId: "wf-test" })).toEqual([]);
93
+ });
94
+
95
+ it("仅 resume 侧有 → unexpected 形态", () => {
96
+ expect(diffResumeArgs({ a: 1, b: 2 }, { a: 1 })).toEqual([
97
+ "args.b: resume args only (not in original run)",
98
+ ]);
99
+ });
100
+
101
+ it("仅原 run 侧有 → missing 形态", () => {
102
+ expect(diffResumeArgs({ a: 1 }, { a: 1, b: "x" })).toEqual([
103
+ "args.b: original run only (missing from resume args)",
104
+ ]);
105
+ });
106
+
107
+ it("嵌套 plain object 递归(路径 a.b 形态)", () => {
108
+ expect(
109
+ diffResumeArgs({ cfg: { deep: 1, same: "s" } }, { cfg: { deep: 2, same: "s" } }),
110
+ ).toEqual(["args.cfg.deep: resume 1 vs original 2"]);
111
+ });
112
+
113
+ it("数组按值整体比(不递归元素)", () => {
114
+ expect(diffResumeArgs({ items: [1, 2] }, { items: [1, 3] })).toEqual([
115
+ 'args.items: resume [1,2] vs original [1,3]',
116
+ ]);
117
+ expect(diffResumeArgs({ items: [1, 2] }, { items: [1, 2] })).toEqual([]);
118
+ });
119
+ });
120
+
121
+ // ── actionResume:入口校验与接线 ─────────────────────────────
122
+
123
+ describe("actionResume", () => {
124
+ it("runId 缺失 → throw 用法指引(不触 resumeRun)", async () => {
125
+ await expect(
126
+ actionResume({ action: "resume" } as never, makeDeps("/nonexistent") as never),
127
+ ).rejects.toThrow(/resume requires 'runId'/);
128
+ expect(vi.mocked(resumeRun)).not.toHaveBeenCalled();
129
+ });
130
+
131
+ it("args 与历史不一致 → 拒绝且差异字段可见,不触 resumeRun(场景 24 前半)", async () => {
132
+ const recordPath = writeRecordStream([runCreatedFrame('{"a":1,"_runId":"wf-test"}')]);
133
+ const err = await actionResume(
134
+ { action: "resume", runId: "wf-test", args: { a: 2 } } as never,
135
+ makeDeps(recordPath) as never,
136
+ ).catch((e: unknown) => e as Error);
137
+ expect(err).toBeInstanceOf(Error);
138
+ expect(err.message).toContain("differ from the original run");
139
+ expect(err.message).toContain("args.a: resume 2 vs original 1");
140
+ expect(err.message).toContain("start a new run");
141
+ expect(vi.mocked(resumeRun)).not.toHaveBeenCalled();
142
+ });
143
+
144
+ it("args 与历史一致 → 幂等通过(场景 24 后半),resumeRun 接线生效", async () => {
145
+ const recordPath = writeRecordStream([runCreatedFrame('{"a":1,"_runId":"wf-test"}')]);
146
+ const result = (await actionResume(
147
+ { action: "resume", runId: "wf-test", args: { a: 1 } } as never,
148
+ makeDeps(recordPath) as never,
149
+ )) as { content: Array<{ text: string }>; details: Record<string, unknown> };
150
+ expect(vi.mocked(resumeRun)).toHaveBeenCalledTimes(1);
151
+ const [calledRunId, calledDeps, options] = vi.mocked(resumeRun).mock.calls[0]!;
152
+ expect(calledRunId).toBe("wf-test");
153
+ expect(calledDeps).toBeDefined();
154
+ expect((options as { budgetTimeMs?: number }).budgetTimeMs).toBeUndefined();
155
+ expect(typeof (options as { host?: string }).host).toBe("string");
156
+ expect(result.content[0]!.text).toContain("Resuming workflow run wf-test");
157
+ expect(result.details).toMatchObject({ action: "resume", runId: "wf-test", status: "running" });
158
+ });
159
+
160
+ it("args 缺省 → 默认沿用历史(D14 层不拦,不读 record)", async () => {
161
+ await actionResume(
162
+ { action: "resume", runId: "wf-test" } as never,
163
+ makeDeps(join(tmpDir, "missing.record.jsonl")) as never,
164
+ );
165
+ expect(vi.mocked(resumeRun)).toHaveBeenCalledTimes(1);
166
+ });
167
+
168
+ it("record 流无 run-created 帧 → D14 层透传(资格判据归 resumeRun 权威文案)", async () => {
169
+ const recordPath = writeRecordStream([
170
+ { type: "phase-started", seq: 1, ts: 1, phase: "p1" },
171
+ ]);
172
+ await actionResume(
173
+ { action: "resume", runId: "wf-test", args: { a: 1 } } as never,
174
+ makeDeps(recordPath) as never,
175
+ );
176
+ expect(vi.mocked(resumeRun)).toHaveBeenCalledTimes(1);
177
+ });
178
+
179
+ it("argsSummary 截断 → 保守拒绝(无法逐字段比对;引导免 args resume 或开新 run)", async () => {
180
+ const long = "x".repeat(300);
181
+ const recordPath = writeRecordStream([runCreatedFrame(`{"payload":"${long}","_runId":"wf-test"}…`)]);
182
+ const err = await actionResume(
183
+ { action: "resume", runId: "wf-test", args: { payload: long } } as never,
184
+ makeDeps(recordPath) as never,
185
+ ).catch((e: unknown) => e as Error);
186
+ expect(err).toBeInstanceOf(Error);
187
+ expect(err.message).toContain("exceed the record's args summary limit");
188
+ expect(vi.mocked(resumeRun)).not.toHaveBeenCalled();
189
+ });
190
+
191
+ it("argsSummary 被篡改为非法 JSON → 拒绝含恢复指引(不含裸 SyntaxError;U21 与 malformed 分支同构)", async () => {
192
+ // 字段篡改形态:未截断摘要字符串是非法 JSON(JSON.stringify 只把它当字符串值
193
+ // 序列化,读取面 parse 必炸)——catch 后抛结构化拒绝文案,原生 SyntaxError
194
+ // 不透出。
195
+ const recordPath = writeRecordStream([runCreatedFrame("{broken json")]);
196
+ const err = await actionResume(
197
+ { action: "resume", runId: "wf-test", args: { a: 1 } } as never,
198
+ makeDeps(recordPath) as never,
199
+ ).catch((e: unknown) => e as Error);
200
+ expect(err).toBeInstanceOf(Error);
201
+ expect(err.message).toContain("Resume rejected: original args of run wf-test are malformed");
202
+ expect(err.message).toContain("Recovery: inspect the record file");
203
+ expect(err.message).toContain("start a new run");
204
+ expect(err.message).not.toContain("SyntaxError");
205
+ expect(vi.mocked(resumeRun)).not.toHaveBeenCalled();
206
+ });
207
+
208
+ it("time 形参透传 budgetTimeMs(u2 偏差②:预算经 D14 同款通道传入)", async () => {
209
+ const recordPath = writeRecordStream([runCreatedFrame('{"a":1}')]);
210
+ await actionResume(
211
+ { action: "resume", runId: "wf-test", args: { a: 1 }, time: 120_000 } as never,
212
+ makeDeps(recordPath) as never,
213
+ );
214
+ const options = vi.mocked(resumeRun).mock.calls[0]![2] as { budgetTimeMs?: number };
215
+ expect(options.budgetTimeMs).toBe(120_000);
216
+ });
217
+
218
+ it("time 负值 → 入口护栏拒绝(与 run action 同源 assertEntryTimeBudget)", async () => {
219
+ await expect(
220
+ actionResume(
221
+ { action: "resume", runId: "wf-test", time: -1 } as never,
222
+ makeDeps("/nonexistent") as never,
223
+ ),
224
+ ).rejects.toThrow(/time/);
225
+ expect(vi.mocked(resumeRun)).not.toHaveBeenCalled();
226
+ });
227
+ });