@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
@@ -1,19 +1,36 @@
1
- // engine-awareness 单测([engine-awareness U3])
1
+ // engine-awareness 单测([engine-awareness U3];engine 域合并宿主)
2
2
  //
3
- // 覆盖(设计 docs/design/subagent-engine-awareness-injection.md(已删除,git 可追溯)验收挂钩 D1/D1b/D2/D3/D5):
4
- // 1. normalizeEngineId:缺省/空白归一到 'pi'(单一权威源 registry.ts,直连导入)
5
- // 2. buildEngineChangeNotice:§3.1 文案骨架、pi/非 pi 指路段分界、不含任何模型清单(D4)
6
- // 3. runEngineAwarenessTurn 编排:
3
+ // 覆盖(设计 docs/design/subagent-engine-awareness-injection.md(已删除,git 可追溯)
4
+ // 验收挂钩 D1/D1b/D2/D3/D4/D5/D7/D8):
5
+ // 1. buildEngineChangeNotice:§3.1 文案骨架、pi/非 pi 指路段分界、不含任何模型清单(D4)
6
+ // 2. runEngineAwarenessTurn 编排:
7
7
  // - 变更触发 apply + 通知(D2 顺序硬约束:提交缓存先于通知先于记账)
8
8
  // - applyRead 收到的参数与 readConfig 返回值引用相等(构造性同源,消灭双读分叉)
9
- // - 无变更无事
10
- // - 读失败保持 lastEngine 不动、不发通知(D5 态 3,防 torn write 伪通知)
11
- // - ENOENT(absent)= 合法缺省 pi,正常触发变更(D5 态 2)
12
- // - 首 turn lastEngine === undefined 静默基线化,无伪通知(D1b)
13
- // - 通知消息形态(customType / display / details,D3+D8 对齐 notifier 约定)
14
- // - 多 session 各自独立 lastEngine(per-session diff 基准互不干扰)
9
+ // - 无变更无事 / 读失败保持 lastEngine 不动(D5 态 3)/ ENOENT=合法缺省(D5 态 2)
10
+ // - 首 turn 静默基线化(D1b)/ 通知消息形态(D3+D8)/ 多 session 独立 lastEngine
11
+ // 3. 引擎切换只变尾部(A8 前置):provider models 段前缀不变,变化只发生在链尾
12
+ // engine 段——断的是「切换前后 prompt 的稳定头部逐字节不变」,复刻 index.ts
13
+ // 装配形态(BASE + provider models 段 + engine 追加段);注册序本身由
14
+ // src/__tests__/injector-chain-order.test.ts 的源码锚点守卫。
15
+ // 4. setupModelListInjector handler(provider models 段生产渲染路径):注入 append /
16
+ // 空列表不干预链 / registry 异常 fail-safe。
17
+ // 5. formatModelList guide 全量快照:MODEL_LIST_GUIDE 是壳自有文案,core 测试用假
18
+ // guide 不锁本文案——此处是唯一字节锁(guide 改写即红灯)。
19
+ //
20
+ // normalizeEngineId 缺省归一契约(undefined/空白 → 'pi'、非 pi 透传)归 core
21
+ // registry.test.ts(单一权威源所在地直测)。
22
+
23
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
15
24
 
16
- import { describe, expect, it } from "vitest";
25
+ import {
26
+ buildEngineModelsPromptAppend,
27
+ buildSubagentEngineSection,
28
+ formatModelList,
29
+ type EnginePort,
30
+ type GlobalConfigReadResult,
31
+ type ModelEntry,
32
+ } from "@zhushanwen/subagent-core";
33
+ import { clearEngines, registerEngine } from "@zhushanwen/subagent-core/execution/engine/registry.ts";
17
34
 
18
35
  import {
19
36
  buildEngineChangeNotice,
@@ -22,12 +39,9 @@ import {
22
39
  type EngineAwarenessDeps,
23
40
  type EngineAwarenessOutcome,
24
41
  } from "../engine-awareness";
25
- // dev 1b05be6f9 删壳 engine-awareness 的 normalizeEngineId 再导出面后,测试直连
26
- // core barrel(与生产 engine-awareness.ts 的 import 形态一致)
27
- import { normalizeEngineId } from "@zhushanwen/subagent-core";
28
- import type { GlobalConfigReadResult } from "@zhushanwen/subagent-core";
42
+ import { MODEL_LIST_GUIDE, setupModelListInjector } from "../model-list-injector.ts";
29
43
 
30
- // ── 测试数据 ────────────────────────────────────────────
44
+ // ── 测试数据(engine-awareness 编排面)──────────────────
31
45
 
32
46
  /** ok 态读取结果工厂(config 形状与 sanitize 输出一致)。 */
33
47
  function okRead(defaultEngine: string | undefined): GlobalConfigReadResult {
@@ -63,22 +77,6 @@ function makeDeps(overrides: Partial<EngineAwarenessDeps> = {}) {
63
77
  return { deps, calls, sent, applied, getLast: () => lastEngine };
64
78
  }
65
79
 
66
- // ── normalizeEngineId ──────────────────────────────────
67
-
68
- describe("normalizeEngineId", () => {
69
- it("undefined 缺省归一到 'pi'", () => {
70
- expect(normalizeEngineId(undefined)).toBe("pi");
71
- });
72
-
73
- it("空白字符串归一到 'pi'(sanitize 会拦非字符串,但防御空白透传)", () => {
74
- expect(normalizeEngineId(" ")).toBe("pi");
75
- });
76
-
77
- it("非 pi 引擎透传", () => {
78
- expect(normalizeEngineId("zcode")).toBe("zcode");
79
- });
80
- });
81
-
82
80
  // ── buildEngineChangeNotice(D4 不含模型清单)────────────
83
81
 
84
82
  describe("buildEngineChangeNotice", () => {
@@ -292,3 +290,208 @@ describe("runEngineAwarenessTurn 多 session 独立 lastEngine", () => {
292
290
  expect(lastEngines.get("s2")).toBe("pi");
293
291
  });
294
292
  });
293
+
294
+ // ──────────────────────────────────────────────────────────────
295
+ // 引擎切换只变尾部(A8 前置):复刻 index.ts 装配形态(BASE + provider models
296
+ // 段 + engine 追加段),断言切换前后稳定头部逐字节不变、engine 段恒居尾。
297
+ // 注册序事实由 injector-chain-order.test.ts 源码锚点守卫,本处不重复。
298
+ // ──────────────────────────────────────────────────────────────
299
+
300
+ /** pi registry 风格条目(provider models 段数据源;同时充当 ctx.modelRegistry 快照)。 */
301
+ const PROVIDER_ENTRIES: ModelEntry[] = [
302
+ {
303
+ provider: "zai-coding-cn",
304
+ id: "glm-5.3",
305
+ name: "GLM 5.3",
306
+ reasoning: true,
307
+ input: ["text"],
308
+ contextWindow: 200_000,
309
+ },
310
+ {
311
+ provider: "minimax-cn",
312
+ id: "MiniMax-M3",
313
+ name: "MiniMax M3",
314
+ reasoning: true,
315
+ input: ["text", "image"],
316
+ contextWindow: 1_000_000,
317
+ },
318
+ ];
319
+
320
+ /** zcode 引擎清单(v2 registry 风格 id)。 */
321
+ const ZCODE_MODELS: Array<{ id: string; name?: string }> = [
322
+ { id: "builtin:bigmodel-coding-plan/GLM-5.3", name: "GLM-5.3" },
323
+ { id: "builtin:bigmodel-coding-plan/GLM-5.3-Flash" },
324
+ ];
325
+
326
+ /** 模拟 pi 核心 system prompt(engine 追加段之外的全部内容)。 */
327
+ const BASE = "You are a coding agent.\n\n# System\nCore prompt body.";
328
+
329
+ /** index.ts 尾部追加模板的分隔符(`${event.systemPrompt}\n\n${append}`)。 */
330
+ const APPEND_SEPARATOR = "\n\n";
331
+
332
+ /**
333
+ * engine handler 的追加段拼装(与 engine-awareness.ts setupEngineAwarenessInjector
334
+ * 内 handler 同款:状态段 + 清单段、空段剔除、\n\n 连接)。复刻保真由
335
+ * injector-chain-order.test.ts 的源码锚点断言保证。
336
+ */
337
+ function composeEngineAppend(defaultEngine: string | undefined): string {
338
+ return [buildSubagentEngineSection(defaultEngine), buildEngineModelsPromptAppend(defaultEngine)]
339
+ .filter((part) => part !== "")
340
+ .join("\n\n");
341
+ }
342
+
343
+ /** 合成一个 turn 的 system prompt 尾部:BASE + provider models 段 + engine 追加段。 */
344
+ function composeTurn(defaultEngine: string | undefined): string {
345
+ const providerSection = formatModelList(PROVIDER_ENTRIES, { guide: MODEL_LIST_GUIDE });
346
+ const afterProvider = providerSection === "" ? BASE : BASE + providerSection;
347
+ const append = composeEngineAppend(defaultEngine);
348
+ return append === "" ? afterProvider : `${afterProvider}${APPEND_SEPARATOR}${append}`;
349
+ }
350
+
351
+ /** 最大公共前缀长度(逐码元比较;用于「分叉点不早于稳定头部」断言)。 */
352
+ function commonPrefixLength(a: string, b: string): number {
353
+ const n = Math.min(a.length, b.length);
354
+ let i = 0;
355
+ while (i < n && a[i] === b[i]) i++;
356
+ return i;
357
+ }
358
+
359
+ /** 最小 fake 引擎(engine 注入链只用 listModels;其余面走不到)。 */
360
+ function fakeEngine(id: string, models: Array<{ id: string; name?: string }>): EnginePort {
361
+ return {
362
+ id,
363
+ capabilities: () => ({ conversation: "unsupported", steer: "unsupported", sandbox: "none" }),
364
+ probe: async () => ({ ok: true, engineVersion: "test" }),
365
+ run: async () => {
366
+ throw new Error("not used in this test");
367
+ },
368
+ read: async () => ({ engineId: id, turns: [], source: "outcome-only" }),
369
+ // 每次调用返回新数组实例(模拟 registry 每 turn 现值)——渲染必须与实例无关
370
+ listModels: () => models.map((m) => ({ ...m })),
371
+ };
372
+ }
373
+
374
+ /**
375
+ * 断言「从 from 切到 to 时变化只发生在尾部 engine 段」:
376
+ * - provider models 段及其之前的头部逐字节不变(公共前缀 toBe 级断言);
377
+ * - 剥离尾部 engine 追加段后两串余部逐字节相等且恰为稳定头部;
378
+ * - 分叉点不早于稳定头部末尾(差异绝不侵入 provider models 段);
379
+ * - 切换后的引擎追加段恰居 prompt 尾部。
380
+ */
381
+ function expectOnlyTailDiffers(from: string | undefined, to: string | undefined): void {
382
+ const stableHead = BASE + formatModelList(PROVIDER_ENTRIES, { guide: MODEL_LIST_GUIDE });
383
+ const before = composeTurn(from);
384
+ const after = composeTurn(to);
385
+ const beforeAppend = composeEngineAppend(from);
386
+ const afterAppend = composeEngineAppend(to);
387
+
388
+ expect(before).not.toBe(after);
389
+ expect(before.startsWith(stableHead)).toBe(true);
390
+ expect(after.startsWith(stableHead)).toBe(true);
391
+ expect(before.slice(0, before.length - beforeAppend.length - APPEND_SEPARATOR.length)).toBe(
392
+ stableHead,
393
+ );
394
+ expect(after.slice(0, after.length - afterAppend.length - APPEND_SEPARATOR.length)).toBe(
395
+ stableHead,
396
+ );
397
+ expect(commonPrefixLength(before, after)).toBeGreaterThanOrEqual(stableHead.length);
398
+ expect(after.endsWith(afterAppend)).toBe(true);
399
+ }
400
+
401
+ describe("引擎切换只变尾部(A8 前置:变化只断尾部 cache 前缀)", () => {
402
+ beforeEach(() => {
403
+ clearEngines();
404
+ registerEngine("zcode", () => fakeEngine("zcode", ZCODE_MODELS));
405
+ });
406
+
407
+ afterEach(() => {
408
+ clearEngines();
409
+ });
410
+
411
+ it("zcode → pi:provider models 段前缀不变,变化只发生在 engine 段(尾部)", () => {
412
+ expectOnlyTailDiffers("zcode", "pi");
413
+ });
414
+
415
+ it("pi → zcode(反向切换):同款只变尾部", () => {
416
+ expectOnlyTailDiffers("pi", "zcode");
417
+ });
418
+
419
+ it("zcode → ghost(配置手误形态):G4 降级段同样只替换尾部,不破坏前缀", () => {
420
+ expectOnlyTailDiffers("zcode", "ghost");
421
+ });
422
+ });
423
+
424
+ // ──────────────────────────────────────────────────────────────
425
+ // setupModelListInjector handler:provider models 段的生产渲染路径。
426
+ // model-list-injector 无模块级状态(每次 setup 注册新 handler),静态 import 直测。
427
+ // ──────────────────────────────────────────────────────────────
428
+
429
+ describe("setupModelListInjector", () => {
430
+ type Handler = (event: unknown, ctx: unknown) => Promise<unknown>;
431
+
432
+ function setupWithRegistry(models: ModelEntry[], fail = false): Handler {
433
+ const handlers: Record<string, Handler> = {};
434
+ const pi = {
435
+ on: (name: string, fn: Handler) => {
436
+ handlers[name] = fn;
437
+ },
438
+ } as unknown as Parameters<typeof setupModelListInjector>[0];
439
+ setupModelListInjector(pi);
440
+ const registry = fail
441
+ ? { getAvailable: vi.fn(() => { throw new Error("registry boom"); }) }
442
+ : { getAvailable: vi.fn(() => models) };
443
+ const handler = handlers["before_agent_start"];
444
+ if (!handler) throw new Error("before_agent_start handler not registered");
445
+ return (event: unknown) => handler(event, { modelRegistry: registry });
446
+ }
447
+
448
+ it("注册 before_agent_start handler,注入 append 到 systemPrompt 尾部", async () => {
449
+ const handler = setupWithRegistry([entry()]);
450
+ const result = (await handler({ systemPrompt: "BASE" }, {})) as {
451
+ systemPrompt: string;
452
+ };
453
+ expect(result.systemPrompt.startsWith("BASE")).toBe(true);
454
+ expect(result.systemPrompt).toContain("<available_provider_models>");
455
+ expect(result.systemPrompt).toContain("zai-coding-cn/glm-5.2");
456
+ });
457
+
458
+ it("空模型列表返回 undefined(不返回 systemPrompt,不干预链)", async () => {
459
+ const handler = setupWithRegistry([]);
460
+ const result = await handler({ systemPrompt: "BASE" }, {});
461
+ expect(result).toBeUndefined();
462
+ });
463
+
464
+ it("registry 异常被吞掉(fail-safe,不阻断 agent turn)", async () => {
465
+ const handler = setupWithRegistry([], true);
466
+ const result = await handler({ systemPrompt: "BASE" }, {});
467
+ expect(result).toBeUndefined();
468
+ });
469
+ });
470
+
471
+ // ── formatModelList guide 全量快照(壳自有文案唯一字节锁)──
472
+
473
+ function entry(overrides: Partial<ModelEntry> = {}): ModelEntry {
474
+ return {
475
+ provider: "zai-coding-cn",
476
+ id: "glm-5.2",
477
+ name: "GLM 5.2",
478
+ reasoning: true,
479
+ input: ["text"],
480
+ contextWindow: 200_000,
481
+ ...overrides,
482
+ };
483
+ }
484
+
485
+ describe("formatModelList guide 全量快照(MODEL_LIST_GUIDE 唯一字节锁)", () => {
486
+ it("guide 文案全量锚定:骨架 + 引导语 + 条目模板测试内硬编码快照(改写 MODEL_LIST_GUIDE 即红灯)", () => {
487
+ // 渲染算法契约(排序/caps/转义/空列表)由 core injection-render byte-exact
488
+ // parity 承载;本条锁壳侧 guide 文案本体(期望值不插值生产常量——文案两侧
489
+ // 同源插值时漂移无守卫,故硬编码)
490
+ const out = formatModelList([
491
+ entry({ provider: "p", id: "m", name: "N" }),
492
+ ], { guide: MODEL_LIST_GUIDE });
493
+ expect(out).toBe(
494
+ `\n\n<available_provider_models>\nThe following models are available (auth-configured). Use these ids when delegating via the subagent/workflow \`model\` param ("provider/modelId" format) to match the task (e.g. vision models for screenshots, strong reasoners for architecture). Do NOT switch the main conversation model mid-session — per-call model override on delegates only (switching the main model is cache-hostile); use the /model command only when the user explicitly asks to change it.\n <model><id>p/m</id><name>N</name><caps>reasoning</caps><contextWindow>200000</contextWindow></model>\n</available_provider_models>`,
495
+ );
496
+ });
497
+ });
@@ -3,7 +3,9 @@
3
3
  //
4
4
  // 使用方式(测试文件内,spies 必须留在文件里——vi.hoisted 保证 resetModules 后引用不变):
5
5
  // const spies = vi.hoisted(() => ({ discoverResources: vi.fn(), getCachedFileContent: vi.fn() }));
6
- // vi.mock("../../shared/resource-discovery.ts", () => createDiscoveryModuleMock(spies));
6
+ // vi.mock("../../shared/resource-discovery.ts", async (importOriginal) =>
7
+ // createDiscoveryModuleMock(spies, importOriginal),
8
+ // );
7
9
  // vi.mock("@zhushanwen/pi-extension-logger", createLoggerModuleMock);
8
10
  //
9
11
  // vi.mock 工厂引用本 helper 的 import 绑定是安全的:工厂惰性执行(被 mock 模块首次被
@@ -19,13 +21,19 @@ export interface DiscoverySpies {
19
21
  }
20
22
 
21
23
  /**
22
- * resource-discovery 模块 mock 工厂:discoverResources/getCachedFileContent 直通
23
- * spies,findWorkspaceRoot 固定 "/ws",getCachedParsed passthrough(真实实现走
24
- * mtime 缓存需真文件,测试里委托 getCachedFileContent 的 mock 返回值——保持
25
- * 「content → parse」语义)。
24
+ * resource-discovery 模块 mock 工厂:真实模块为底(importOriginal 透传——
25
+ * conventionRootDirs 等纯推导保持真实实现,空态 roots 断言即真实验证 core
26
+ * 推导),仅覆写四成员:discoverResources/getCachedFileContent 直通 spies,
27
+ * findWorkspaceRoot 固定 "/ws",getCachedParsed 委托 getCachedFileContent 的
28
+ * mock 返回值(真实实现走 mtime 缓存需真文件——保持「content → parse」语义)。
26
29
  */
27
- export function createDiscoveryModuleMock(spies: DiscoverySpies) {
30
+ export async function createDiscoveryModuleMock(
31
+ spies: DiscoverySpies,
32
+ importOriginal: () => Promise<typeof import("@zhushanwen/subagent-core/shared/resource-discovery.ts")>,
33
+ ) {
34
+ const actual = await importOriginal();
28
35
  return {
36
+ ...actual,
29
37
  discoverResources: spies.discoverResources,
30
38
  findWorkspaceRoot: () => "/ws",
31
39
  getCachedFileContent: spies.getCachedFileContent,
@@ -87,7 +95,7 @@ export function createMockPi(handlers: CapturedHandlers): ExtensionAPI {
87
95
  /* mock */
88
96
  };
89
97
  // setup*Injector 仅调 pi.on;用最小对象 + 双重断言满足 ExtensionAPI 契约
90
- // (测试 mock 约定,见 crash-recovery.test.ts 的 Proxy 模式)
98
+ // (测试 mock 约定,见 session-lifecycle.test.ts 的 Proxy 模式)
91
99
  return {
92
100
  on,
93
101
  appendEntry: noop,