@zhushanwen/pi-subagent-workflow 8.14.7 → 9.0.1

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 +14 -9
  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 +349 -86
  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,884 @@
1
+ /**
2
+ * workflow tool 行为契约合一(2026-09-27 测试审计组 6:六源并一文件)。
3
+ *
4
+ * 来源与分工:
5
+ * - actionStatus 输出形态 + GUI attach(原 tool-workflow-status.test.ts)——
6
+ * status 列表行格式是 LLM 判断 run 状态与 runId 引用的唯一信息源,锁
7
+ * result.content 实际文本与 details.runs 投影。
8
+ * - W4b 错误路径 throw 语义(原 tool-workflow-throw-paths.test.ts)——pi 只对
9
+ * execute throw 置 isError:true(返回值里的 isError 被 agent-loop 丢弃),故
10
+ * 错误路径必须 throw。其中 4 条 entry-guard 用例(slug 上限 / time 上限 /
11
+ * tokens·time 负值)是 core shared/entry-guards.ts 当前唯一行为证明。
12
+ * - D8 创建期模型拒单(原 tool-workflow-model-rejection.test.ts)。
13
+ * - D4-1 按名解析退役 + run/abort 文案全文锚定(原 tool-workflow-run-builtin-name.test.ts)。
14
+ * - TC3i 宿主 reservedKeys 接线锚(原 detectors.test.ts——TC3a-h 的行为等值断言
15
+ * 在 core orchestration/__tests__/args-meta.test.ts,宿主差异面只留此条)。
16
+ * - run details.stateFile 暴露(原 workflow-state-file-exposure.test.ts 第 3 条,
17
+ * grep 断言改行为级等值)。
18
+ *
19
+ * 结构约束:
20
+ * - fake timers 只在 actionStatus describe 内启用(done 冻结语义需钉死墙钟),
21
+ * 不泄漏到 run/abort 路径。
22
+ * - D8 describe 首条必须在任何 setCatalogService 调用之前执行(声明序 = 执行序):
23
+ * setModelConfigService 无复位面,单例 set 后「目录缺席」防御分支不可再达。
24
+ *
25
+ * mock 策略:lifecycle 深路径 stub(runWorkflow/abortRun 为 vi.fn——不起真
26
+ * Worker,只测启动面与入口校验)。范式:captureTool 注册层黑盒。框架:vitest。
27
+ */
28
+ import { mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync, mkdirSync } from "node:fs";
29
+ import { tmpdir } from "node:os";
30
+ import { dirname, join } from "node:path";
31
+ import { fileURLToPath } from "node:url";
32
+
33
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
34
+
35
+ import {
36
+ Budget,
37
+ SLUG_MAX_LENGTH,
38
+ Trace,
39
+ WorkflowRun,
40
+ WorkflowScriptRegistryImpl,
41
+ parseResourceMeta,
42
+ setModelConfigService,
43
+ } from "@zhushanwen/subagent-core";
44
+ import type { DoneReason } from "@zhushanwen/subagent-core";
45
+ import {
46
+ argKeysFromMeta,
47
+ findFlattenedArgKeys,
48
+ } from "@zhushanwen/subagent-core/orchestration/args-meta.ts";
49
+ // 被 mock 的模块——import 路径与被测源文件的等值实例(vi.mock hoist 后 barrel
50
+ // re-export 与深路径指向同一 mock 实例)
51
+ import { runWorkflow, abortRun } from "@zhushanwen/subagent-core/orchestration/lifecycle.ts";
52
+
53
+ import { actionRun, registerWorkflowTool, TOOL_TOP_LEVEL } from "../tool-workflow.ts";
54
+ import { REENTRY_BUSY_MESSAGE, type ReentryGuardRef } from "../reentry-guard.ts";
55
+ import { captureTool, type CapturedTool } from "./capture-tool.ts";
56
+
57
+ /** 桩化 lifecycle——runWorkflow/abortRun 为 vi.fn(不起真 Worker,只测启动面/入口校验)。 */
58
+ vi.mock("@zhushanwen/subagent-core/orchestration/lifecycle.ts", () => ({
59
+ runWorkflow: vi.fn(),
60
+ abortRun: vi.fn(),
61
+ }));
62
+
63
+ // 顶层 mock 生命周期(合并自原 run-builtin-name / model-rejection 两文件的
64
+ // 同构 beforeEach/afterEach;abort 用例另在 stubAbortTransition 内重置 abortRun)
65
+ beforeEach(() => {
66
+ vi.mocked(runWorkflow).mockReset();
67
+ vi.mocked(runWorkflow).mockResolvedValue("run-id-1");
68
+ });
69
+
70
+ afterEach(() => {
71
+ vi.restoreAllMocks();
72
+ });
73
+
74
+ // ── 生产源码文本(KNOWN_ARG_KEYS 退役守卫用——读源码非 import,同提示词测试模式)──
75
+
76
+ const __dirname = dirname(fileURLToPath(import.meta.url));
77
+ const TOOL_WORKFLOW_SRC = readFileSync(join(__dirname, "../tool-workflow.ts"), "utf-8");
78
+
79
+ // ── 共享 fixture(合并前各文件同构拷贝收敛单点)──
80
+
81
+ /** fake registry / 真 registry 通用的最小 WorkflowScript stub。 */
82
+ function makeScript(name: string, path: string, parameters?: object) {
83
+ return {
84
+ name,
85
+ path,
86
+ available: true,
87
+ sourceCode: `// ${name}`,
88
+ meta: { description: `${name} workflow`, parameters },
89
+ toExecutable: () => `// ${name}`,
90
+ };
91
+ }
92
+
93
+ /** registry vi.fn 齐面快捷构造(缺省全 miss;overrides 覆盖单键)。 */
94
+ function stubRegistry(overrides: Record<string, unknown> = {}): Record<string, unknown> {
95
+ return {
96
+ get: vi.fn().mockResolvedValue(undefined),
97
+ getPath: vi.fn().mockResolvedValue(undefined),
98
+ loadAll: vi.fn().mockResolvedValue([]),
99
+ ...overrides,
100
+ };
101
+ }
102
+
103
+ /** 最小 deps stub(runWorkflow 已 mock;store 只消费 stateFilePath)。 */
104
+ function makeDeps(registry: Record<string, unknown>): Record<string, unknown> {
105
+ return {
106
+ runs: new Map(),
107
+ store: { stateFilePath: (id: string) => `/tmp/state/${id}.jsonl` },
108
+ registry,
109
+ };
110
+ }
111
+
112
+ // ══════════════════════════════════════════════════════════════
113
+ // actionStatus 黑盒输出(原 tool-workflow-status.test.ts)
114
+ // ══════════════════════════════════════════════════════════════
115
+
116
+ type StatusResult = {
117
+ content: Array<{ type: string; text: string }>;
118
+ details:
119
+ | {
120
+ action: string;
121
+ runs: Array<Record<string, unknown>>;
122
+ }
123
+ | undefined;
124
+ isError?: boolean;
125
+ };
126
+
127
+ // 测试夹具描述符(非生产契约):黑盒 capture 的结构收窄——actionStatus 未导出,
128
+ // 经注册层 execute 唯一入口取回后按此类型读取输出;单"实现"是 capture 对象本身。
129
+ interface StatusToolView extends CapturedTool { // oe-exempt:20260927:wip:test fixture descriptor, not a production contract awaiting variants
130
+ execute: (
131
+ toolCallId: string,
132
+ params: Record<string, unknown>,
133
+ signal: AbortSignal | undefined,
134
+ onUpdate: unknown,
135
+ ctx: unknown,
136
+ ) => Promise<StatusResult>;
137
+ }
138
+
139
+ /** 注册层黑盒 capture(actionStatus/actionAbort 未导出,经 execute 唯一入口;
140
+ * fake pi 捕获单点见 capture-tool.ts,此处只留差异面:runs Map + 泛型 view)。 */
141
+ function captureRegisteredTool<T extends CapturedTool>(runs: Map<string, WorkflowRun>): T {
142
+ const deps = {
143
+ runs,
144
+ // status/abort 路径只触 store.stateFilePath(RunSummary 宿主扩展字段)
145
+ store: { stateFilePath: (runId: string) => `/state/${runId}.jsonl` },
146
+ registry: { get: vi.fn(), getPath: vi.fn(), loadAll: vi.fn(), invalidate: vi.fn() },
147
+ };
148
+ const guard: ReentryGuardRef = { isProcessing: false };
149
+ return captureTool<T>(
150
+ (pi) => registerWorkflowTool(pi as never, deps as never, guard),
151
+ "workflow",
152
+ );
153
+ }
154
+
155
+ /** 真实 WorkflowRun 聚合根(reconstruct 工厂——status/abort 路径只读投影面)。 */
156
+ function makeRun(opts: {
157
+ runId: string;
158
+ scriptName: string;
159
+ status: "running" | "done";
160
+ reason?: DoneReason;
161
+ error?: string;
162
+ startedAt: string;
163
+ completedAt?: string;
164
+ }): WorkflowRun {
165
+ return WorkflowRun.reconstruct(
166
+ opts.runId,
167
+ {
168
+ scriptSource: "// stub source",
169
+ args: {},
170
+ scriptName: opts.scriptName,
171
+ scriptPath: `/abs/${opts.scriptName}.js`,
172
+ },
173
+ {
174
+ status: opts.status,
175
+ reason: opts.reason,
176
+ budget: new Budget(),
177
+ calls: new Map(),
178
+ trace: new Trace(),
179
+ errorLogs: [],
180
+ error: opts.error,
181
+ },
182
+ { startedAt: opts.startedAt, completedAt: opts.completedAt },
183
+ );
184
+ }
185
+
186
+ describe("actionStatus 输出形态(LLM 可见文本锁)", () => {
187
+ // ── 墙钟 fixture(fake timers 钉死 Date.now,elapsed 全确定性;
188
+ // 收窄进本 describe——不泄漏到 run/abort 路径)──
189
+ const FAKE_NOW = new Date("2026-01-01T00:00:30.000Z").getTime();
190
+
191
+ beforeEach(() => {
192
+ vi.useFakeTimers({ now: FAKE_NOW });
193
+ });
194
+
195
+ afterEach(() => {
196
+ vi.useRealTimers();
197
+ });
198
+
199
+ it("runs 为空 → 固定空态文案 + 空 runs details", async () => {
200
+ const tool = captureRegisteredTool<StatusToolView>(new Map());
201
+ const r = await tool.execute("id", { action: "status" }, undefined, undefined, {});
202
+ expect(r.content).toEqual([{ type: "text", text: "No workflows in current session." }]);
203
+ expect(r.details).toMatchObject({ action: "status", runs: [] });
204
+ });
205
+
206
+ it("running + done 混合 → 每 run 一行:status/reason 后缀、id 前 8 位、elapsed(done 冻结)、error 尾缀", async () => {
207
+ const running = makeRun({
208
+ runId: "wf-1719500000000-a1b2c3",
209
+ scriptName: "demo-wf",
210
+ status: "running",
211
+ startedAt: "2026-01-01T00:00:24.000Z", // fakeNow - 6s → (6s)
212
+ });
213
+ const doneFailed = makeRun({
214
+ runId: "wf-1719600000000-z9y8x7",
215
+ scriptName: "cleanup-wf",
216
+ status: "done",
217
+ reason: "failed", // ≠ completed → [failed] 后缀
218
+ error: "boom",
219
+ startedAt: "2026-01-01T00:00:00.000Z",
220
+ completedAt: "2026-01-01T00:00:05.000Z", // done 冻结 → (5s),不随墙钟
221
+ });
222
+ const doneCompleted = makeRun({
223
+ runId: "wf-1719700000000-q5r4t3",
224
+ scriptName: "ok-wf",
225
+ status: "done",
226
+ reason: "completed", // === completed → 无 reason 后缀、无 error 尾缀
227
+ startedAt: "2026-01-01T00:00:08.000Z",
228
+ completedAt: "2026-01-01T00:00:10.000Z", // (2s)
229
+ });
230
+ const runs = new Map([
231
+ [running.runId, running],
232
+ [doneFailed.runId, doneFailed],
233
+ [doneCompleted.runId, doneCompleted],
234
+ ]);
235
+ const tool = captureRegisteredTool<StatusToolView>(runs);
236
+ const r = await tool.execute("id", { action: "status" }, undefined, undefined, {});
237
+
238
+ expect(r.content).toEqual([
239
+ {
240
+ type: "text",
241
+ text:
242
+ "[running] demo-wf (wf-17195) (6s)\n" +
243
+ "[done [failed]] cleanup-wf (wf-17196) (5s) error: boom\n" +
244
+ "[done] ok-wf (wf-17197) (2s)",
245
+ },
246
+ ]);
247
+ // details.runs = core runSummary 投影 + 宿主 stateFile 扩展(GUI 消费面)
248
+ expect(r.details).toMatchObject({
249
+ action: "status",
250
+ runs: [
251
+ {
252
+ runId: "wf-1719500000000-a1b2c3",
253
+ name: "demo-wf",
254
+ status: "running",
255
+ stateFile: "/state/wf-1719500000000-a1b2c3.jsonl",
256
+ },
257
+ {
258
+ runId: "wf-1719600000000-z9y8x7",
259
+ name: "cleanup-wf",
260
+ status: "done",
261
+ reason: "failed",
262
+ error: "boom",
263
+ completedAt: "2026-01-01T00:00:05.000Z",
264
+ stateFile: "/state/wf-1719600000000-z9y8x7.jsonl",
265
+ },
266
+ {
267
+ runId: "wf-1719700000000-q5r4t3",
268
+ name: "ok-wf",
269
+ status: "done",
270
+ reason: "completed",
271
+ stateFile: "/state/wf-1719700000000-q5r4t3.jsonl",
272
+ },
273
+ ],
274
+ });
275
+ });
276
+ });
277
+
278
+ // ── details 不含 GUI 描述符(isWorkflow 块分支恒折叠单行不消费 __gui__,构造即
279
+ // 死代码——D8 裁决,execute 级不 attach)──
280
+
281
+ describe("details 形状(execute 级无 GUI attach)", () => {
282
+ it("RPC ctx → details 不含 __gui__ 键(workflow tool 不构造 GUI 描述符)", async () => {
283
+ const tool = captureRegisteredTool<Record<string, unknown>>(new Map());
284
+ const r = await tool.execute("id", { action: "status" }, undefined, undefined, {
285
+ mode: "rpc",
286
+ hasUI: true,
287
+ });
288
+ expect(Object.keys(r.details as object)).not.toContain("__gui__");
289
+ });
290
+ });
291
+
292
+ // ══════════════════════════════════════════════════════════════
293
+ // W4b 错误路径 throw 语义(原 tool-workflow-throw-paths.test.ts)
294
+ // ══════════════════════════════════════════════════════════════
295
+
296
+ /** throw-paths 专用 capture wrapper(差异面:deps 实参 + reentry guard 可注入占用态)。 */
297
+ function captureWorkflowTool(deps: unknown, reentryRef: ReentryGuardRef): CapturedTool {
298
+ return captureTool(
299
+ (pi) => registerWorkflowTool(pi as never, deps as never, reentryRef),
300
+ "workflow",
301
+ );
302
+ }
303
+
304
+ /** 平铺检测 / slug 护栏路径的 registry script stub(meta.parameters 驱动已知键集)。 */
305
+ function scriptRegistry(parameters?: object): Record<string, unknown> {
306
+ return stubRegistry({
307
+ getPath: vi.fn().mockResolvedValue(makeScript("demo-wf", "/abs/demo-wf.js", parameters)),
308
+ });
309
+ }
310
+
311
+ describe("W4b: workflow tool 错误路径 throw 语义", () => {
312
+ it("not_found:abort 目标 runId 不存在 → throw(pi catch 后置 isError:true)", async () => {
313
+ const tool = captureWorkflowTool(makeDeps(stubRegistry()), { isProcessing: false });
314
+ await expect(
315
+ tool.execute("id", { action: "abort", runId: "no-such-run" }, undefined, undefined, {}),
316
+ ).rejects.toThrow(
317
+ "Workflow 'no-such-run' not found. Use action:status to list active runs and their runIds.",
318
+ );
319
+ });
320
+
321
+ it("W4c not_found:run 目标路径不可读(getPath 返回 available:false stub 而非 undefined)→ throw,不假启动", async () => {
322
+ // config-loader.toCachedMeta 对不存在/不可读文件返回 available:false 的空壳实体
323
+ // (非 undefined)——旧判定仅 !script 绕过 not_found,空 sourceCode 的 run 假启动
324
+ // (W4b verifier 探针实测复现)。registry 层真行为见 config-loader.ts:143-151。
325
+ const deps = makeDeps(
326
+ stubRegistry({
327
+ getPath: vi.fn().mockResolvedValue({
328
+ name: "ghost-wf",
329
+ path: "/tmp/no-such-workflow.js",
330
+ available: false,
331
+ sourceCode: "",
332
+ meta: { description: "", parameters: undefined },
333
+ toExecutable: () => "",
334
+ }),
335
+ }),
336
+ );
337
+ const tool = captureWorkflowTool(deps, { isProcessing: false });
338
+ await expect(
339
+ tool.execute("id", { action: "run", name: "/tmp/no-such-workflow.js" }, undefined, undefined, {}),
340
+ ).rejects.toThrow("Workflow '/tmp/no-such-workflow.js' not found.");
341
+ // 未假启动:runs 注册表不出现该 run
342
+ expect(deps.runs).toBeInstanceOf(Map);
343
+ expect((deps.runs as Map<string, unknown>).size).toBe(0);
344
+ });
345
+
346
+ it("reentry-busy:guard 占用 → throw REENTRY_BUSY_MESSAGE,且 guard 状态不被污染", async () => {
347
+ const guard: ReentryGuardRef = { isProcessing: true };
348
+ const tool = captureWorkflowTool(makeDeps(stubRegistry()), guard);
349
+ await expect(
350
+ tool.execute("id", { action: "status" }, undefined, undefined, {}),
351
+ ).rejects.toThrow(REENTRY_BUSY_MESSAGE);
352
+ // throw 发生在 acquire 之前——占用方语义保持,不产生双重 release
353
+ expect(guard.isProcessing).toBe(true);
354
+ });
355
+
356
+ it("平铺检测:args 子字段提到顶层 → throw 'Detected ... at top level'(含 Correct 正例)", async () => {
357
+ const deps = makeDeps(
358
+ scriptRegistry({
359
+ type: "object",
360
+ properties: { task: { type: "string" }, items: { type: "array" } },
361
+ required: ["task"],
362
+ }),
363
+ );
364
+ const tool = captureWorkflowTool(deps, { isProcessing: false });
365
+ await expect(
366
+ tool.execute(
367
+ "id",
368
+ { action: "run", name: "/abs/demo-wf.js", task: "do work" },
369
+ undefined,
370
+ undefined,
371
+ {},
372
+ ),
373
+ ).rejects.toThrow(
374
+ /Detected task at top level — they belong inside 'args'\. Correct: \{"action":"run","name":"\/abs\/demo-wf\.js","args":\{"task": "<value>"\}\}/,
375
+ );
376
+ });
377
+
378
+ it("slug 护栏:slug 超 SLUG_MAX_LENGTH → throw 'slug exceeds ...'(运行时第二道)", async () => {
379
+ const deps = makeDeps(
380
+ scriptRegistry({
381
+ type: "object",
382
+ properties: { task: { type: "string" } },
383
+ required: ["task"],
384
+ }),
385
+ );
386
+ const tool = captureWorkflowTool(deps, { isProcessing: false });
387
+ const longSlug = "a".repeat(SLUG_MAX_LENGTH + 1);
388
+ await expect(
389
+ tool.execute(
390
+ "id",
391
+ { action: "run", name: "/abs/demo-wf.js", slug: longSlug, args: {} },
392
+ undefined,
393
+ undefined,
394
+ {},
395
+ ),
396
+ ).rejects.toThrow(
397
+ `slug exceeds ${SLUG_MAX_LENGTH} chars (got ${longSlug.length}). Shorten to a kebab-case label, e.g. "fix-login", "extract-urls".`,
398
+ );
399
+ });
400
+
401
+ it("OR-1 入口校验:time 超 setTimeout 上限 → throw 含上限 2147483647 与实际值,run 未启动", async () => {
402
+ const deps = makeDeps(
403
+ scriptRegistry({
404
+ type: "object",
405
+ properties: { task: { type: "string" } },
406
+ required: ["task"],
407
+ }),
408
+ );
409
+ const tool = captureWorkflowTool(deps, { isProcessing: false });
410
+ // LLM 对「跑久一点」完全可能生成 1e12——超 2^31-1 的典型形态(schema Type.Number
411
+ // 直通无上界,用户入口 fail-fast 不依赖 lifecycle 内层 assertSafeTimerDelay)
412
+ await expect(
413
+ tool.execute(
414
+ "id",
415
+ { action: "run", name: "/abs/demo-wf.js", args: { task: "do work" }, time: 1_000_000_000_000 },
416
+ undefined,
417
+ undefined,
418
+ {},
419
+ ),
420
+ ).rejects.toThrow(
421
+ "time budget 1000000000000 ms exceeds the maximum of 2147483647 ms (~24.8 days). Retry with a smaller \"time\", or omit it for unlimited.",
422
+ );
423
+ // fail-fast 于 runWorkflow 之前:runs 无条目(abortRun not found 语义保持)
424
+ expect((deps.runs as Map<string, unknown>).size).toBe(0);
425
+ });
426
+
427
+ it.each([
428
+ { field: "tokens", params: { tokens: -5 }, message: 'tokens budget -5 is negative. Retry with a positive "tokens", or omit it for unlimited.' },
429
+ { field: "time", params: { time: -1 }, message: 'time budget -1 ms is negative. Retry with a positive "time", or omit it for unlimited.' },
430
+ ] as const)(
431
+ "负值 fail-fast:$field 为负 → throw 拒绝(不再静默升格 unlimited),run 未启动",
432
+ async ({ params, message }) => {
433
+ const deps = makeDeps(
434
+ scriptRegistry({
435
+ type: "object",
436
+ properties: { task: { type: "string" } },
437
+ required: ["task"],
438
+ }),
439
+ );
440
+ const tool = captureWorkflowTool(deps, { isProcessing: false });
441
+ await expect(
442
+ tool.execute(
443
+ "id",
444
+ { action: "run", name: "/abs/demo-wf.js", args: { task: "do work" }, ...params },
445
+ undefined,
446
+ undefined,
447
+ {},
448
+ ),
449
+ ).rejects.toThrow(message);
450
+ // fail-fast 于 runWorkflow 之前:runs 无条目
451
+ expect((deps.runs as Map<string, unknown>).size).toBe(0);
452
+ },
453
+ );
454
+
455
+ it("throw 后 reentry guard 经 finally 正常释放(成功路径回归)", async () => {
456
+ // abort not_found throw 穿透 execute try/finally:guard 必须复位,否则后续命令全部 busy
457
+ const guard: ReentryGuardRef = { isProcessing: false };
458
+ const tool = captureWorkflowTool(makeDeps(stubRegistry()), guard);
459
+ await expect(
460
+ tool.execute("id", { action: "abort", runId: "no-such-run" }, undefined, undefined, {}),
461
+ ).rejects.toThrow(/not found/);
462
+ expect(guard.isProcessing).toBe(false);
463
+ });
464
+ });
465
+
466
+ // ══════════════════════════════════════════════════════════════
467
+ // D8 创建期模型拒单(原 tool-workflow-model-rejection.test.ts)
468
+ // ══════════════════════════════════════════════════════════════
469
+
470
+ /** D8 专用 deps:getPath 按 path 从给定 scripts 集解析(模型门之前的解析面)。 */
471
+ function depsWithScripts(scripts: Array<ReturnType<typeof makeScript>>): Record<string, unknown> {
472
+ return makeDeps({
473
+ get: vi.fn().mockResolvedValue(undefined),
474
+ getPath: vi.fn(async (ref: string) => scripts.find((s) => s.path === ref) ?? undefined),
475
+ loadAll: vi.fn().mockResolvedValue(scripts),
476
+ });
477
+ }
478
+
479
+ function setCatalogService(entries: Array<{ provider: string; id: string }>): void {
480
+ setModelConfigService({
481
+ initModel: vi.fn(),
482
+ reloadGlobalConfig: vi.fn(() => ({ status: "absent", config: { version: 1, maxConcurrent: 6 } })),
483
+ getModelRegistry: () => ({ getAvailable: () => entries }),
484
+ } as never);
485
+ }
486
+
487
+ describe("D8 创建期模型拒单", () => {
488
+ const SCRIPT = makeScript("chain", "/abs/chain.js");
489
+
490
+ it("目录缺席(单例未装配)→ warn 降级跳过,不误拒(runWorkflow 正常到达)", async () => {
491
+ // 本用例必须先于任何 setCatalogService 调用执行(声明序 = 执行序)——
492
+ // setModelConfigService 无复位面,单例 set 后 null 分支不可再达。
493
+ const deps = depsWithScripts([SCRIPT]);
494
+ await actionRun(
495
+ { action: "run", name: "/abs/chain.js", args: {}, model: "prov1/typo" } as never,
496
+ deps as never,
497
+ undefined,
498
+ );
499
+ expect(vi.mocked(runWorkflow)).toHaveBeenCalledTimes(1);
500
+ });
501
+
502
+ it("目录查无 → 同步 throw 分类化文案 + 可用清单,零 run 创建", async () => {
503
+ setCatalogService([
504
+ { provider: "prov1", id: "good-a" },
505
+ { provider: "prov2", id: "solo" },
506
+ ]);
507
+ const deps = depsWithScripts([SCRIPT]);
508
+ const err = await actionRun(
509
+ { action: "run", name: "/abs/chain.js", args: {}, model: "prov1/typo" } as never,
510
+ deps as never,
511
+ undefined,
512
+ ).catch((e: unknown) => e as Error);
513
+
514
+ expect(err).toBeInstanceOf(Error);
515
+ expect(err.message).toContain("not in the pi engine model catalog");
516
+ expect(err.message).toContain("prov1/good-a");
517
+ expect(err.message).toContain("prov2/solo");
518
+ expect(err.message).toContain("Recovery:");
519
+ // 零 spawn(拒单先于 runWorkflow)
520
+ expect(vi.mocked(runWorkflow)).not.toHaveBeenCalled();
521
+ });
522
+
523
+ it("provider 漂移 → 拒单文案含配置恢复指引(models.json)", async () => {
524
+ setCatalogService([{ provider: "prov1", id: "good-a" }]);
525
+ const deps = depsWithScripts([SCRIPT]);
526
+ const err = await actionRun(
527
+ { action: "run", name: "/abs/chain.js", args: {}, model: "retired/m1" } as never,
528
+ deps as never,
529
+ undefined,
530
+ ).catch((e: unknown) => e as Error);
531
+
532
+ expect(err).toBeInstanceOf(Error);
533
+ expect(err.message).toContain("provider configuration drift");
534
+ expect(err.message).toContain("models.json under the pi agent dir");
535
+ expect(vi.mocked(runWorkflow)).not.toHaveBeenCalled();
536
+ });
537
+
538
+ it("目录命中 → 模型门放行,model 透传进 RunSpec(runWorkflow 收到)", async () => {
539
+ setCatalogService([{ provider: "prov1", id: "good-a" }]);
540
+ const deps = depsWithScripts([SCRIPT]);
541
+ await actionRun(
542
+ { action: "run", name: "/abs/chain.js", args: {}, model: "prov1/good-a" } as never,
543
+ deps as never,
544
+ undefined,
545
+ );
546
+
547
+ expect(vi.mocked(runWorkflow)).toHaveBeenCalledTimes(1);
548
+ const spec = vi.mocked(runWorkflow).mock.calls[0][0] as Record<string, unknown>;
549
+ expect(spec.model).toBe("prov1/good-a");
550
+ });
551
+ });
552
+
553
+ // ══════════════════════════════════════════════════════════════
554
+ // D4-1 按名解析退役 + 文案全文锚定(原 tool-workflow-run-builtin-name.test.ts)
555
+ // ══════════════════════════════════════════════════════════════
556
+
557
+ // ── fixture:可用 workflow 脚本(@pi-meta 新格式,无参数声明) ──
558
+
559
+ const CHAIN_META = `/* @pi-meta
560
+ name: chain
561
+ description: 内置名测试用三步链
562
+ phases: [a, b]
563
+ */
564
+ const agent = require("./agent");
565
+ agent("w", { task: $ARGS.task });
566
+ `;
567
+
568
+ describe("D4-1 按名解析退役(fake registry)", () => {
569
+ it("裸名(旧内置名 chain,get 可命中)→ not_found 拒单;registry.get 零调用(机制删除)", async () => {
570
+ const chain = makeScript("chain", "/builtin/workflows/chain.js");
571
+ const registry = stubRegistry({
572
+ // get 仍能命中(registry 层机制未删——退役的是 actionRun 的按名解析通道)
573
+ get: vi.fn().mockResolvedValue(chain),
574
+ getPath: vi.fn().mockResolvedValue(undefined),
575
+ loadAll: vi.fn().mockResolvedValue([chain]),
576
+ });
577
+ const err = await actionRun(
578
+ { action: "run", name: "chain" } as never,
579
+ makeDeps(registry) as never,
580
+ undefined,
581
+ ).catch((e: unknown) => e as Error);
582
+
583
+ // 退役断言:get 零调用 = 裸名不进入按名通道(P5 修复发现面后也不会复活)
584
+ expect(registry.get).not.toHaveBeenCalled();
585
+ expect(registry.getPath).toHaveBeenCalledWith("chain");
586
+ expect(vi.mocked(runWorkflow)).not.toHaveBeenCalled();
587
+ // 拒单文案:清单逐条附 location(唯一自救路径)
588
+ expect(err.message).toContain(
589
+ "Workflow 'chain' not found. Available (name — use the absolute location path as 'name' when the bare name is rejected):",
590
+ );
591
+ expect(err.message).toContain(" location: /builtin/workflows/chain.js");
592
+ });
593
+
594
+ it("路径引用 → getPath 命中即启动(现行为零变化)", async () => {
595
+ const byPath = makeScript("demo", "/abs/demo.js");
596
+ const registry = stubRegistry({
597
+ getPath: vi.fn().mockResolvedValue(byPath),
598
+ loadAll: vi.fn().mockResolvedValue([byPath]),
599
+ });
600
+ await actionRun(
601
+ { action: "run", name: "/abs/demo.js" } as never,
602
+ makeDeps(registry) as never,
603
+ undefined,
604
+ );
605
+
606
+ expect(registry.getPath).toHaveBeenCalledWith("/abs/demo.js");
607
+ expect(vi.mocked(runWorkflow)).toHaveBeenCalledTimes(1);
608
+ const spec = vi.mocked(runWorkflow).mock.calls[0][0] as Record<string, unknown>;
609
+ expect(spec.scriptName).toBe("demo");
610
+ });
611
+
612
+ it("getPath 返回 available:false 的 stub → 不启动,走 not_found 报错(W4c 口径不回退)", async () => {
613
+ const ghost = { ...makeScript("ghost", "/builtin/ghost.js"), available: false };
614
+ const registry = stubRegistry({
615
+ getPath: vi.fn().mockResolvedValue(ghost),
616
+ });
617
+ await expect(
618
+ actionRun(
619
+ { action: "run", name: "ghost" } as never,
620
+ makeDeps(registry) as never,
621
+ undefined,
622
+ ),
623
+ ).rejects.toThrow(/Workflow 'ghost' not found\./);
624
+ expect(vi.mocked(runWorkflow)).not.toHaveBeenCalled();
625
+ });
626
+
627
+ it("未知名 → 报错含建议清单且逐条附绝对路径 location(全路径自救指引)", async () => {
628
+ const registry = stubRegistry({
629
+ loadAll: vi.fn().mockResolvedValue([makeScript("chain", "/builtin/workflows/chain.js")]),
630
+ });
631
+ const err = await actionRun(
632
+ { action: "run", name: "no-such" } as never,
633
+ makeDeps(registry) as never,
634
+ undefined,
635
+ ).catch((e: unknown) => e as Error);
636
+ // 逐条 location:按名解析已退役,location 是唯一可派发形态,失败一次即自救。
637
+ expect(err.message).toContain(
638
+ "Workflow 'no-such' not found. Available (name — use the absolute location path as 'name' when the bare name is rejected):",
639
+ );
640
+ expect(err.message).toContain(" - chain: chain workflow");
641
+ expect(err.message).toContain(" location: /builtin/workflows/chain.js");
642
+ });
643
+ });
644
+
645
+ describe("D4-1 按名解析退役(真 registry:WorkflowScriptRegistryImpl + 真实发现链)", () => {
646
+ let fixtureDir: string;
647
+
648
+ beforeEach(() => {
649
+ fixtureDir = mkdtempSync(join(tmpdir(), "d4-ref-bare-"));
650
+ // WorkflowScanConfig 布局:projectDir = <fixture>/ws/.pi/workflows(反推 workspaceRoot)
651
+ const projectDir = join(fixtureDir, "ws", ".pi", "workflows");
652
+ mkdirSync(projectDir, { recursive: true });
653
+ writeFileSync(join(projectDir, "chain.js"), CHAIN_META, "utf-8");
654
+ });
655
+
656
+ afterEach(() => {
657
+ rmSync(fixtureDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 20 });
658
+ });
659
+
660
+ it("真发现链里名字存在也拒:裸名 'chain' → not_found(拒的是解析机制本身)", async () => {
661
+ const registry = new WorkflowScriptRegistryImpl({
662
+ projectDir: join(fixtureDir, "ws", ".pi", "workflows"),
663
+ userDir: join(fixtureDir, "user", "workflows"),
664
+ tmpDir: join(fixtureDir, "ws", ".pi", "workflows", ".tmp"),
665
+ npmDirs: [],
666
+ });
667
+ // 前置自检:fixture 布局可被扫描(隔离 config 下 hostRoots 为空、仅 project 根命中)
668
+ // ——名字确实在发现面内,拒单非「不可见」而是「按名通道退役」。
669
+ const all = await registry.loadAll();
670
+ expect(all.filter((w) => w.available).map((w) => w.name)).toContain("chain");
671
+
672
+ await expect(
673
+ actionRun(
674
+ { action: "run", name: "chain" } as never,
675
+ makeDeps(registry as unknown as Record<string, unknown>) as never,
676
+ undefined,
677
+ ),
678
+ ).rejects.toThrow(/Workflow 'chain' not found\./);
679
+ expect(vi.mocked(runWorkflow)).not.toHaveBeenCalled();
680
+ });
681
+
682
+ it("location 全路径 → 正常启动(真 registry 下唯一可派发形态)", async () => {
683
+ const registry = new WorkflowScriptRegistryImpl({
684
+ projectDir: join(fixtureDir, "ws", ".pi", "workflows"),
685
+ userDir: join(fixtureDir, "user", "workflows"),
686
+ tmpDir: join(fixtureDir, "ws", ".pi", "workflows", ".tmp"),
687
+ npmDirs: [],
688
+ });
689
+ const result = await actionRun(
690
+ { action: "run", name: join(fixtureDir, "ws", ".pi", "workflows", "chain.js") } as never,
691
+ makeDeps(registry as unknown as Record<string, unknown>) as never,
692
+ undefined,
693
+ );
694
+
695
+ expect(vi.mocked(runWorkflow)).toHaveBeenCalledTimes(1);
696
+ const spec = vi.mocked(runWorkflow).mock.calls[0][0] as Record<string, unknown>;
697
+ expect(spec.scriptName).toBe("chain");
698
+ expect(String(spec.scriptPath)).toContain("chain.js");
699
+ expect(result.content[0]?.text).toContain("Started workflow 'chain'");
700
+ });
701
+
702
+ it("fixture 卫生断言:fixture 目录无其他 .js 泄漏(避免 discoverWorkflows 误扫)", () => {
703
+ const projectDir = join(fixtureDir, "ws", ".pi", "workflows");
704
+ expect(readdirSync(projectDir).filter((f) => f.endsWith(".js"))).toEqual(["chain.js"]);
705
+ });
706
+ });
707
+
708
+ // ══════════════════════════════════════════════════════════════
709
+ // LLM 直接消费文案全文锚定(第四轮架构审查 Strong 项)。
710
+ //
711
+ // run 启动文案(防轮询段)与 abort 转移文案此前零锚定——重构/顺手清理改写无红灯。
712
+ // 本节逐字锁 LLM 看到的 content[0].text(期望值从 tool-workflow.ts 实现逐字复制)。
713
+ // abort 的终态语义(done/aborted 由 lifecycle transition 落位)归 core lifecycle
714
+ // 测试;此处 mock abortRun 仅落位 state(actionAbort 从 run 对象读转移前后状态
715
+ // 拼接文案的真实行为面不变)。
716
+ // ══════════════════════════════════════════════════════════════
717
+
718
+ describe("run 启动文案全文锚定(LLM 可见文本锁)", () => {
719
+ /** fake registry:getPath 命中 demo script(无参数声明——平铺检测跳过)。 */
720
+ function demoRegistry(): Record<string, unknown> {
721
+ return stubRegistry({
722
+ getPath: vi.fn().mockResolvedValue(makeScript("demo", "/abs/demo.js")),
723
+ });
724
+ }
725
+
726
+ it("含 slug → 'name · slug (runId)' + 防轮询段(逐字 toBe)", async () => {
727
+ const result = await actionRun(
728
+ { action: "run", name: "/abs/demo.js", slug: "tri-review" } as never,
729
+ makeDeps(demoRegistry()) as never,
730
+ undefined,
731
+ );
732
+ // runId 来自顶层 beforeEach 的 runWorkflow mockResolvedValue("run-id-1")
733
+ expect(result.content[0]?.text).toBe(
734
+ "Started workflow 'demo' · tri-review (run-id-1). Running in background — DO NOT bash sleep or poll status; results are auto-delivered via notifyDone.",
735
+ );
736
+ });
737
+
738
+ it("不含 slug → 'name (runId)' 无 · 段(逐字 toBe)", async () => {
739
+ const result = await actionRun(
740
+ { action: "run", name: "/abs/demo.js" } as never,
741
+ makeDeps(demoRegistry()) as never,
742
+ undefined,
743
+ );
744
+ expect(result.content[0]?.text).toBe(
745
+ "Started workflow 'demo' (run-id-1). Running in background — DO NOT bash sleep or poll status; results are auto-delivered via notifyDone.",
746
+ );
747
+ });
748
+ });
749
+
750
+ // ── run details.stateFile 暴露(原 workflow-state-file-exposure.test.ts 第 3 条,
751
+ // grep 断言改行为级等值;前 2 条类型/来源 grep 已由上方 status details 的
752
+ // stateFile 逐字段断言三合一覆盖)──
753
+
754
+ describe("run details.stateFile = store.stateFilePath(runId)(行为级等值)", () => {
755
+ it("actionRun 返回的 details.stateFile 等于同一 store 对该 runId 的 stateFilePath 返回值", async () => {
756
+ const storeRef = { stateFilePath: (id: string) => `/tmp/state/${id}.jsonl` };
757
+ const deps = {
758
+ ...makeDeps(
759
+ stubRegistry({
760
+ getPath: vi.fn().mockResolvedValue(makeScript("demo", "/abs/demo.js")),
761
+ }),
762
+ ),
763
+ store: storeRef,
764
+ };
765
+ const result = await actionRun(
766
+ { action: "run", name: "/abs/demo.js" } as never,
767
+ deps as never,
768
+ undefined,
769
+ );
770
+
771
+ const details = result.details as Record<string, unknown> | undefined;
772
+ expect(details).toBeDefined();
773
+ expect(details?.stateFile).toBe(storeRef.stateFilePath(String(details?.runId)));
774
+ });
775
+ });
776
+
777
+ describe("abort 转移文案全文锚定(LLM 可见文本锁)", () => {
778
+ type AbortResult = {
779
+ content: Array<{ type: string; text: string }>;
780
+ details: Record<string, unknown> | undefined;
781
+ };
782
+
783
+ type AbortToolView = CapturedTool & {
784
+ execute: (
785
+ toolCallId: string,
786
+ params: Record<string, unknown>,
787
+ signal: AbortSignal | undefined,
788
+ onUpdate: unknown,
789
+ ctx: unknown,
790
+ ) => Promise<AbortResult>;
791
+ };
792
+
793
+ /** mock abortRun 落位终态(模拟 lifecycle transition 语义:status=done + 可选 reason)。 */
794
+ function stubAbortTransition(opts: { reason?: string }): void {
795
+ vi.mocked(abortRun).mockReset();
796
+ vi.mocked(abortRun).mockImplementation(async (runId, deps) => {
797
+ const run = deps.runs.get(runId);
798
+ if (run) {
799
+ run.state.status = "done";
800
+ run.state.reason = opts.reason;
801
+ }
802
+ });
803
+ }
804
+
805
+ it("running run → abort → 'running → done (aborted)'(reason 后缀拼接形态)", async () => {
806
+ stubAbortTransition({ reason: "aborted" });
807
+ const run = makeRun({
808
+ runId: "wf-1719500000000-a1b2c3",
809
+ scriptName: "demo-wf",
810
+ status: "running",
811
+ startedAt: "2026-01-01T00:00:00.000Z",
812
+ });
813
+ const tool = captureRegisteredTool<AbortToolView>(new Map([[run.runId, run]]));
814
+
815
+ const r = await tool.execute("id", { action: "abort", runId: run.runId }, undefined, undefined, {});
816
+ expect(r.content[0]?.text).toBe("Workflow 'demo-wf' (wf-1719500000000-a1b2c3): running → done (aborted)");
817
+ expect(r.details).toMatchObject({ action: "abort", runId: run.runId, status: "done", reason: "aborted" });
818
+ });
819
+
820
+ it("无 reason → 转移段无后缀(reasonSuffix 条件拼接)", async () => {
821
+ stubAbortTransition({ reason: undefined });
822
+ const run = makeRun({
823
+ runId: "wf-1719600000000-z9y8x7",
824
+ scriptName: "cleanup-wf",
825
+ status: "running",
826
+ startedAt: "2026-01-01T00:00:00.000Z",
827
+ });
828
+ const tool = captureRegisteredTool<AbortToolView>(new Map([[run.runId, run]]));
829
+
830
+ const r = await tool.execute("id", { action: "abort", runId: run.runId }, undefined, undefined, {});
831
+ expect(r.content[0]?.text).toBe("Workflow 'cleanup-wf' (wf-1719600000000-z9y8x7): running → done");
832
+ expect(r.details).toMatchObject({ action: "abort", runId: run.runId, status: "done" });
833
+ });
834
+ });
835
+
836
+ // ══════════════════════════════════════════════════════════════
837
+ // 宿主 reservedKeys 接线锚(原 detectors.test.ts TC3i,M-3 回归唯一红灯)。
838
+ //
839
+ // D9 下沉后权威实现在 core args-meta:宿主差异(TOOL_TOP_LEVEL 撞名保护)经
840
+ // reservedKeys 注入。core 侧 args-meta.test.ts 用 core 自己的常量锁行为等值,
841
+ // 本 describe 锁「tool-workflow.ts 导出的 TOOL_TOP_LEVEL 真实注入」这一宿主接线
842
+ // (TOOL_TOP_LEVEL 漏注入/漂移时只有此条会红)。
843
+ // ══════════════════════════════════════════════════════════════
844
+
845
+ /** 真实参数 meta(@pi-meta parameters——与 core args-meta.test.ts 同源防漂移)。 */
846
+ function rflParams(): object {
847
+ const src = readFileSync(
848
+ join(
849
+ __dirname,
850
+ "../../../node_modules/@zhushanwen/subagent-core/workflows/review-fix-loop.js",
851
+ ),
852
+ "utf-8",
853
+ );
854
+ const meta = parseResourceMeta(src, "workflow");
855
+ if (!meta || meta.kind !== "workflow" || !meta.parameters) throw new Error("rfl meta");
856
+ return meta.parameters;
857
+ }
858
+
859
+ describe("TC3i: 宿主 TOOL_TOP_LEVEL reservedKeys 接线锚(M-3 回归)", () => {
860
+ // 宿主 reservedKeys 注入(与 tool-workflow ARGS_META_OPTIONS 等值契约)
861
+ const OPT = { reservedKeys: TOOL_TOP_LEVEL };
862
+
863
+ it("tool 顶层键不误报——argKeysFromMeta 经宿主 OPT 注入排除撞名键", () => {
864
+ // 合成 parameters:workflow 声明参数 name(tool 键撞名)——argKeysFromMeta 必须排除
865
+ const synthetic = {
866
+ type: "object",
867
+ properties: { name: { type: "string" }, task: { type: "string" } },
868
+ required: ["name"],
869
+ };
870
+ const keys = argKeysFromMeta(synthetic, OPT);
871
+ expect(keys.exact.has("task")).toBe(true);
872
+ expect(keys.exact.has("name")).toBe(false); // TOOL_TOP_LEVEL 排除(M-3 真回归锁定)
873
+ // 合法调用不误报
874
+ expect(
875
+ findFlattenedArgKeys(
876
+ { action: "run", name: "mywf", args: { name: "x" } },
877
+ synthetic,
878
+ OPT,
879
+ ),
880
+ ).toEqual([]);
881
+ // 真实 meta 不含 name(探针实测 16 键)——附加验证
882
+ expect(argKeysFromMeta(rflParams(), OPT).exact.has("name")).toBe(false);
883
+ });
884
+ });