@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
@@ -12,18 +12,18 @@
12
12
  * - 三 handler 自管缓存生命周期(不耦合 index.ts session 逻辑):
13
13
  * session_start(含 reload)发现+覆盖缓存(刷新节奏对齐 pi skill,fail-safe 异常
14
14
  * 不阻断、缓存保持 null);before_agent_start 读缓存渲染注入、miss(session_start
15
- * 未触发/缓存被清)则 fallback 重新发现+赋值,空列表/空注入不返回 systemPrompt,
16
- * 任何异常被吞掉(记日志)不阻断 agent turn;session_shutdown 清缓存。
15
+ * 未触发/缓存被清)则 fallback 重新发现+赋值,空发现渲染空态段(D4-2:(none
16
+ * discovered; roots: ...),不再整段消失;invalids 随段具名呈现——D4-3 损坏文件
17
+ * 不静默),任何异常被吞掉(记日志)不阻断 agent turn;session_shutdown 清缓存。
17
18
  * pi 支持 async handler,同一 event 多 handler 链式(前者返回的 systemPrompt 作
18
19
  * 后者输入)。
19
20
  *
20
21
  * 两实例的真差异经 config 参数化承载:kind(发现种类 + discoveryRoots 宿主槽位)/
21
22
  * parse(单文件内容 → entry;workflow 侧 description 截断内聚于其 parse)/ format
22
23
  * (entries → XML 注入段;guide 文案差异内聚于各自 format)/ includeTmp(workflow 侧
23
- * 覆盖 .pi/workflows/.tmp/ generate 产物)/ onParseNull(agent 侧「有 frontmatter 但
24
- * 解析失败」warn 判据)/ logTag(错误日志前缀,保持既有可检索性);assemble(agent
25
- * 侧真差异:装配循环覆写——U11 下沉 core discoverAgents 后工厂不重复装配语义,
26
- * parse/onParseNull 不参与)。
24
+ * 覆盖 .pi/workflows/.tmp/ generate 产物)/ logTag(错误日志前缀,保持既有可检索性);
25
+ * assemble(agent 侧真差异:装配循环覆写——U11 下沉 core discoverAgents 后工厂不重复
26
+ * 装配语义,parse 不参与)。
27
27
  *
28
28
  * model-list-injector 不参与本工厂:数据源是 ModelRegistry 内存快照(真差异,无文件
29
29
  * 发现与缓存生命周期),见该文件头注释。
@@ -37,15 +37,19 @@ import type {
37
37
  SessionShutdownEvent,
38
38
  SessionStartEvent,
39
39
  } from "@earendil-works/pi-coding-agent";
40
+
40
41
  import { getLogger } from "@zhushanwen/pi-extension-logger";
41
42
 
42
43
  import { getHostServices } from "@zhushanwen/subagent-core";
43
44
 
44
45
  import {
46
+ conventionRootDirs,
45
47
  discoverResources,
46
48
  findWorkspaceRoot,
49
+ formatEmptyResourceList,
47
50
  getCachedParsed,
48
51
  sortByCodepoint,
52
+ type InvalidResource,
49
53
  } from "@zhushanwen/subagent-core";
50
54
  import { toErrorMessage } from "@zhushanwen/pi-ext-guards";
51
55
 
@@ -57,6 +61,13 @@ export interface ResourceListEntry {
57
61
  path: string;
58
62
  }
59
63
 
64
+ /** discover 产物(P5 D4-3):entries = 可用条目;invalids = 损坏文件具名上报
65
+ * (三处静默点收敛:available=false 占位 / meta 解析失败 / 文件读失败)。 */
66
+ export interface ResourceDiscoveryResult<TEntry extends ResourceListEntry> {
67
+ entries: TEntry[];
68
+ invalids: InvalidResource[];
69
+ }
70
+
60
71
  /** 工厂参数(真差异承载面,见文件头)。 */
61
72
  export interface ResourceListInjectorConfig<TEntry extends ResourceListEntry> {
62
73
  /** 发现种类(discoverResources kind + discoveryRoots 宿主槽位)。 */
@@ -67,23 +78,26 @@ export interface ResourceListInjectorConfig<TEntry extends ResourceListEntry> {
67
78
  * 装配循环覆写:提供时工厂 discover 整体委托它(发现→解析→去重→排序 +
68
79
  * warn/error 口径全归被委托方),工厂不再跑内置装配循环。agent 侧真差异:
69
80
  * U11(sink 设计)装配算法单源 core discoverAgents(第三宿主免复刻 G3/S5),
70
- * 壳侧收缩为「宿主注入根现取 + 委托」。此时 parse/onParseNull 不参与工厂装配。
81
+ * 壳侧收缩为「宿主注入根现取 + 委托」。此时 parse 不参与工厂装配,
82
+ * invalids 恒空(core discoverAgents 无 invalid 产出面)。
71
83
  */
72
84
  assemble?: (workspaceRoot: string) => Promise<TEntry[]>;
73
- /** 单文件内容 → entry;null = 跳过该文件(无有效 frontmatter/meta)。assemble 覆写时不需要。 */
85
+ /** 单文件内容 → entry;null = 损坏文件(invalid 具名上报,D4-3)。assemble 覆写时不需要。 */
74
86
  parse?(content: string): TEntry | null;
75
- /** entry 列表 → 注入段;空列表返回空串(不注入)。 */
76
- format(entries: TEntry[]): string;
87
+ /**
88
+ * entry 列表 → 注入段;返回空串 = 空发现(工厂接管渲染 D4-2 空态段,invalids
89
+ * 随空态段具名呈现)。第二参 = invalid 具名上报(P5 D4-3),段内渲染点由
90
+ * 实例的 format 决定(workflow 实例透传 core formatWorkflowList 的 invalids 选项)。
91
+ */
92
+ format(entries: TEntry[], invalids: readonly InvalidResource[]): string;
77
93
  /** workflow 侧真差异:包含 .pi/workflows/.tmp/(workflow-script generate 产物)。 */
78
94
  includeTmp?: boolean;
79
- /** parse 返 null 的旁路处理(agent 侧真差异:仅「有 frontmatter 但解析失败」warn)。 */
80
- onParseNull?(filePath: string): void;
81
95
  }
82
96
 
83
97
  /** 工厂产物:setup 注册三 handler;discover 为发现函数(薄模块再导出)。 */
84
98
  export interface ResourceListInjector<TEntry extends ResourceListEntry> {
85
99
  setup(pi: ExtensionAPI): void;
86
- discover(workspaceRoot: string): Promise<TEntry[]>;
100
+ discover(workspaceRoot: string): Promise<ResourceDiscoveryResult<TEntry>>;
87
101
  }
88
102
 
89
103
  /** 码点序排序契约见 core sortByCodepoint(KV-cache 契约:禁 localeCompare——宿主 locale 差异会破坏跨环境字节一致)。 */
@@ -93,36 +107,81 @@ function singularKind(kind: "agents" | "workflows"): string {
93
107
  return kind === "agents" ? "agent" : "workflow";
94
108
  }
95
109
 
110
+ /**
111
+ * 空态注入(D4-2)渲染的发现根清单:宿主注入根(discoveryRoots 槽,现取——
112
+ * 实例隔离语义同 discover)+ core 约定根推导(conventionRootDirs 单源,含
113
+ * includeTmp 的 .tmp 根)——本函数只做纯投影,不持有任何 join 字面。
114
+ * TAIJI_EXTENSION_PATHS 刻意不列——taiji 内部 dev-link 通道,非 agent 自救面。
115
+ * 清单是给 agent 的提示信息:顺序无优先级契约,保序去重由渲染函数保证
116
+ * (同 turn 重建字节稳定,KV-cache 契约)。
117
+ */
118
+ function discoveryRootDirs(
119
+ workspaceRoot: string,
120
+ kind: "agents" | "workflows",
121
+ includeTmp: boolean,
122
+ ): string[] {
123
+ const hostRoots = getHostServices().discoveryRoots?.()?.[kind] ?? [];
124
+ const roots = hostRoots.map((root) => root.dir);
125
+ roots.push(...conventionRootDirs({ kind, workspaceRoot, includeTmp }));
126
+ return roots;
127
+ }
128
+
96
129
  export function createResourceListInjector<TEntry extends ResourceListEntry>(
97
130
  config: ResourceListInjectorConfig<TEntry>,
98
131
  ): ResourceListInjector<TEntry> {
99
- let entriesCache: TEntry[] | null = null;
132
+ let discoveryCache: ResourceDiscoveryResult<TEntry> | null = null;
100
133
  let injectionCache: string | null = null;
101
134
 
102
- /** 缓存唯一写点:数据与渲染缓存同步更新(null 清空两者)。 */
103
- function setCache(entries: TEntry[] | null): void {
104
- entriesCache = entries;
105
- injectionCache = entries !== null ? config.format(entries) : null;
135
+ /**
136
+ * 缓存唯一写点:数据与渲染缓存同步更新(null 清空两者,此时 workspaceRoot
137
+ * 不消费)。空发现(format 返回空串——core formatXxxList 空列表的判据契约)
138
+ * 不再让注入段整段消失,改渲染空态段(D4-2):agent 可区分「功能关闭」与
139
+ * 「确实没有」,roots 清单供自救;invalids 随空态段具名呈现(D4-3——条目为零
140
+ * 与存在损坏文件两个事实同段)。非空路径 format 输出原样缓存(invalids 缺省
141
+ * 逐字节零变更)。
142
+ */
143
+ function setCache(
144
+ result: ResourceDiscoveryResult<TEntry> | null,
145
+ workspaceRoot: string,
146
+ ): void {
147
+ discoveryCache = result;
148
+ if (result === null) {
149
+ injectionCache = null;
150
+ return;
151
+ }
152
+ const rendered = config.format(result.entries, result.invalids);
153
+ injectionCache = rendered !== ""
154
+ ? rendered
155
+ : formatEmptyResourceList(
156
+ config.kind,
157
+ discoveryRootDirs(workspaceRoot, config.kind, config.includeTmp === true),
158
+ result.invalids,
159
+ );
106
160
  }
107
161
 
108
162
  /**
109
163
  * 用统一资源发现(ADR-031)发现所有可用条目。永不抛错——发现本身 fail-safe,
110
- * 单个文件读失败仅记日志。
164
+ * 单个文件读失败仅记日志(并 invalid 具名上报,不静默)。
111
165
  *
112
- * assemble 覆写时整体委托(agent 侧:core discoverAgents 单源装配,U11);
113
- * 否则跑内置装配循环——
166
+ * assemble 覆写时整体委托(agent 侧:core discoverAgents 单源装配,U11;
167
+ * invalids 恒空);否则跑内置装配循环——
114
168
  * discoverResources 返回按文件名 stem 去重、优先级合并后的 DiscoveredResource[]
115
169
  * (project > user > builtin,返回顺序低→高优先级——Map 后写覆盖依赖此序,不可在
116
170
  * 发现层重排)。此处逐个 parse 提取条目(经 getCachedParsed mtime 级缓存),再按
117
171
  * name 去重(高优先级靠后,Map.set 后者覆盖前者,故最终保留最高优先级同名条目)。
118
172
  *
173
+ * invalid 收集(P5 D4-3,三处原静默点):available=false 占位(manifest 声明
174
+ * 路径缺失,reason 由发现层具名)/ parse null(文件在场但 meta 无效或缺失)/
175
+ * 读失败(reason = 错误消息,日志照记)。损坏文件从此在注入段具名可见,
176
+ * 不再静默跳过。
177
+ *
119
178
  * 输出按 name 码点序排序(KV-cache 契约):注入段进每 turn system prompt,顺序必须
120
179
  * 与文件系统枚举序(readdir 无契约)解耦——目录内容不变时,session_start / fallback /
121
180
  * resume 任意重建的渲染结果逐字节一致;仅条目增减时文本才变化。
122
181
  */
123
- async function discover(workspaceRoot: string): Promise<TEntry[]> {
182
+ async function discover(workspaceRoot: string): Promise<ResourceDiscoveryResult<TEntry>> {
124
183
  if (config.assemble) {
125
- return config.assemble(workspaceRoot);
184
+ return { entries: await config.assemble(workspaceRoot), invalids: [] };
126
185
  }
127
186
  if (!config.parse) {
128
187
  throw new Error(
@@ -139,24 +198,32 @@ export function createResourceListInjector<TEntry extends ResourceListEntry>(
139
198
  });
140
199
 
141
200
  const map = new Map<string, TEntry>();
201
+ const invalids: InvalidResource[] = [];
142
202
  for (const resource of resources) {
143
- if (!resource.available) continue;
203
+ if (!resource.available) {
204
+ invalids.push({
205
+ path: resource.path,
206
+ reason: resource.reason ?? "unavailable",
207
+ });
208
+ continue;
209
+ }
144
210
  try {
145
211
  const entry = getCachedParsed(resource.path, config.parse);
146
212
  if (entry) {
147
213
  map.set(entry.name, { ...entry, path: resource.path });
148
214
  } else {
149
- config.onParseNull?.(resource.path);
215
+ invalids.push({ path: resource.path, reason: "no valid resource metadata" });
150
216
  }
151
217
  } catch (err) {
152
- // 单个文件读失败不阻断整条清单注入
218
+ // 单个文件读失败不阻断整条清单注入;具名上报(D4-3)+ 日志照记
219
+ invalids.push({ path: resource.path, reason: toErrorMessage(err) });
153
220
  logger.error(
154
221
  `${config.logTag} skip unreadable ${singularKind(config.kind)} file ${resource.path}`,
155
222
  { reason: toErrorMessage(err) },
156
223
  );
157
224
  }
158
225
  }
159
- return sortByCodepoint([...map.values()], (entry) => entry.name);
226
+ return { entries: sortByCodepoint([...map.values()], (entry) => entry.name), invalids };
160
227
  }
161
228
 
162
229
  function setup(pi: ExtensionAPI): void {
@@ -164,7 +231,8 @@ export function createResourceListInjector<TEntry extends ResourceListEntry>(
164
231
  "session_start",
165
232
  async (_event: SessionStartEvent, ctx: ExtensionContext): Promise<void> => {
166
233
  try {
167
- setCache(await discover(findWorkspaceRoot(ctx.cwd)));
234
+ const workspaceRoot = findWorkspaceRoot(ctx.cwd);
235
+ setCache(await discover(workspaceRoot), workspaceRoot);
168
236
  } catch (err) {
169
237
  // fail-safe:发现异常不阻断 session,缓存保持 null(before_agent_start 会 fallback)
170
238
  logger.error(`${config.logTag} session_start discover failed`, {
@@ -182,10 +250,12 @@ export function createResourceListInjector<TEntry extends ResourceListEntry>(
182
250
  ): Promise<BeforeAgentStartEventResult | void> => {
183
251
  try {
184
252
  // 读缓存;miss(session_start 未触发/缓存被清)则 fallback 重新发现+赋值
185
- if (entriesCache === null) {
186
- setCache(await discover(findWorkspaceRoot(ctx.cwd)));
253
+ if (discoveryCache === null) {
254
+ const workspaceRoot = findWorkspaceRoot(ctx.cwd);
255
+ setCache(await discover(workspaceRoot), workspaceRoot);
187
256
  }
188
- // injectionCache 与 entriesCache 不变量同步(setCache 保证),直接复用
257
+ // injectionCache 与 discoveryCache 不变量同步(setCache 保证),直接复用;
258
+ // 空串仅出现在「空发现且 roots 清单为空」的退化态(约定根恒在,实际不可达)
189
259
  const injection = injectionCache;
190
260
  if (!injection) return;
191
261
  return { systemPrompt: event.systemPrompt + injection };
@@ -200,7 +270,8 @@ export function createResourceListInjector<TEntry extends ResourceListEntry>(
200
270
  pi.on(
201
271
  "session_shutdown",
202
272
  (_event: SessionShutdownEvent, _ctx: ExtensionContext): void => {
203
- setCache(null);
273
+ // 清缓存:null 分支不消费 workspaceRoot(空态渲染仅在发现路径发生)
274
+ setCache(null, "");
204
275
  },
205
276
  );
206
277
  }
@@ -29,7 +29,6 @@ import {
29
29
  discoverAgents,
30
30
  formatAgentList,
31
31
  getHostServices,
32
- parseResourceMeta,
33
32
  } from "@zhushanwen/subagent-core";
34
33
 
35
34
  import { createResourceListInjector } from "./resource-list-injector.ts";
@@ -46,38 +45,18 @@ import { createResourceListInjector } from "./resource-list-injector.ts";
46
45
  export const SUBAGENT_LIST_GUIDE =
47
46
  "The following subagents are available. PRIORITY: when a task involves reading 3+ files, writing 100+ lines, parallel research, or specialized review, delegate to a matching subagent FIRST instead of doing it yourself — this keeps your context focused on orchestration. Do NOT call list to discover available subagents; use list only for running state. When using the subagent tool, ONLY use agents from this list — pass the <location> path (absolute .md path) as the agent param. If no agent matches your task, omit agent (a general-purpose agent is used) and put all role-specific instructions in the task text.";
48
47
 
49
- /**
50
- * 解析 markdown 文件的 YAML frontmatter(name + description)。
51
- *
52
- * m2 收敛:删本地单行 key:value parser,改调 shared/meta-parser.ts parseResourceMeta
53
- * (IF1 统一 parser,支持 block scalar)。无有效 frontmatter 或缺 name/description 返 null。
54
- * 投影 {name, description} 注入用(examples 注入留 m5)。
55
- *
56
- * U11 后装配循环(含本函数的装配内消费)单源 core discoverAgents;保留导出作为
57
- * 严格注入投影语义锚(parseResourceMeta 消费面)。
58
- */
59
- export function parseAgentFrontmatter(content: string): AgentEntry | null {
60
- const meta = parseResourceMeta(content, "agent");
61
- if (!meta || meta.kind !== "agent") return null;
62
- return {
63
- name: meta.name,
64
- description: meta.description,
65
- when: meta.when,
66
- examples: meta.examples,
67
- path: "", // 装配时由 core discoverAgents 从 DiscoveredResource.path 填充
68
- };
69
- }
70
-
71
48
  /**
72
49
  * agent 清单实例:缓存生命周期 / 三 handler 经工厂骨架;装配循环经 assemble 覆写
73
- * 委托 core discoverAgents(U11 单源,parse/onParseNull 不参与工厂装配——warn 口径
50
+ * 委托 core discoverAgents(U11 单源,parse 不参与工厂装配——warn 口径
74
51
  * 内聚 core:仅「有 frontmatter 但严格校验未通过」才 warn,README 等无 frontmatter
75
52
  * 的 .md 静默跳过)。
76
53
  */
77
54
  const injector = createResourceListInjector<AgentEntry>({
78
55
  kind: "agents",
79
56
  logTag: "[subagent-list-injector]",
80
- format: (agents) => formatAgentList(agents, { guide: SUBAGENT_LIST_GUIDE }),
57
+ // agents 段无 invalid 产出面(assemble 路径 invalids 恒空),第二参忽略——
58
+ // 签名对齐工厂 config.format(P5 D4-3 invalid 上报仅 workflow 域接线)。
59
+ format: (agents, _invalids) => formatAgentList(agents, { guide: SUBAGENT_LIST_GUIDE }),
81
60
  assemble: (workspaceRoot) =>
82
61
  discoverAgents(
83
62
  workspaceRoot,
@@ -85,8 +64,5 @@ const injector = createResourceListInjector<AgentEntry>({
85
64
  ),
86
65
  });
87
66
 
88
- /** 发现所有可用 agent(装配骨架见工厂;装配算法单源 core discoverAgents,U11)。 */
89
- export const discoverAllAgents = injector.discover;
90
-
91
67
  /** 注册 session 生命周期 handler,注入 `<available_subagents>` 段(三 handler 语义见工厂)。 */
92
68
  export const setupSubagentListInjector = injector.setup;
@@ -25,6 +25,7 @@ import {
25
25
  formatWorkflowList,
26
26
  parseResourceMeta,
27
27
  summarizeDescription,
28
+ type InvalidResource,
28
29
  type WorkflowEntry,
29
30
  } from "@zhushanwen/subagent-core";
30
31
 
@@ -61,12 +62,15 @@ const injector = createResourceListInjector<WorkflowEntry>({
61
62
  kind: "workflows",
62
63
  logTag: "[workflow-list-injector]",
63
64
  parse: parseWorkflowMeta,
64
- format: (workflows) => formatWorkflowList(workflows, { guide: WORKFLOW_LIST_GUIDE }),
65
+ // invalids 透传 core 渲染(P5 D4-3 具名上报:条目段内 invalid 行;条目为零时
66
+ // 随工厂空态段呈现)。空数组不渲染(core 侧零字节变更)。
67
+ format: (workflows, invalids: readonly InvalidResource[]) =>
68
+ formatWorkflowList(workflows, {
69
+ guide: WORKFLOW_LIST_GUIDE,
70
+ ...(invalids.length > 0 ? { invalids } : {}),
71
+ }),
65
72
  includeTmp: true,
66
73
  });
67
74
 
68
- /** 用统一资源发现发现所有可用 workflow(includeTmp 覆盖 generate 产物;骨架与排序契约见工厂)。 */
69
- export const discoverAllWorkflows = injector.discover;
70
-
71
75
  /** 注册 session 生命周期 handler,注入 `<available_workflows>` 段(与 subagent 注入 handler 链式)。 */
72
76
  export const setupWorkflowListInjector = injector.setup;
@@ -0,0 +1,56 @@
1
+ /**
2
+ * 注册层黑盒测试共享捕获 helper:fake pi 收集 registerTool 入参,断言恰好注册
3
+ * 一个 tool(可锁定期望名)并返回首个。此前 7 个测试文件各自内联同构拷贝
4
+ * (fake pi + tools 数组 + name 断言 + CapturedTool 类型),收敛至此单点;
5
+ * 差异面(注册函数、pi 之后的实参、deps 形状)留在调用方闭包。
6
+ */
7
+
8
+ /** 注册层捕获到的 tool 定义最小形态(各文件按需用泛型传窄 view 类型)。 */
9
+ export interface CapturedTool {
10
+ name: string;
11
+ execute: (
12
+ toolCallId: string,
13
+ params: Record<string, unknown>,
14
+ signal: AbortSignal | undefined,
15
+ onUpdate: unknown,
16
+ ctx: unknown,
17
+ ) => Promise<unknown>;
18
+ }
19
+
20
+ /** workflow-script tool 的窄 view(list/lint 测试共用的 execute 返回形态)。 */
21
+ export interface ScriptResultToolView extends CapturedTool { // oe-exempt:20260929:framework:测试 fixture 契约类型(mock 桩/断言视图)——dev-0.10.5 已验收代码 merge 带入
22
+ execute: (
23
+ toolCallId: string,
24
+ params: Record<string, unknown>,
25
+ signal: AbortSignal | undefined,
26
+ onUpdate: unknown,
27
+ ctx: unknown,
28
+ ) => Promise<{
29
+ content: Array<{ type: string; text: string }>;
30
+ details: unknown;
31
+ isError?: boolean;
32
+ }>;
33
+ }
34
+
35
+ /**
36
+ * 注册并捕获 tool 定义。泛型 T 供需要窄 execute 返回类型的调用方传 view
37
+ * 类型(方法参数双变 + 返回协变,窄形态天然 extends CapturedTool,零 cast)。
38
+ *
39
+ * @param register 拿 fake pi 执行注册(调用方闭包内传齐注册函数与其余实参)
40
+ * @param expectedName 期望的 tool 名(缺省不锁定——仅断言恰好注册一个)
41
+ */
42
+ export function captureTool<T extends CapturedTool = CapturedTool>(
43
+ register: (pi: { registerTool: (tool: unknown) => void }) => void,
44
+ expectedName?: string,
45
+ ): T {
46
+ const tools: unknown[] = [];
47
+ register({ registerTool: (tool) => tools.push(tool) });
48
+ const first = tools[0] as T | undefined;
49
+ if (first === undefined || tools.length !== 1) {
50
+ throw new Error(`expected exactly one registered tool, got ${tools.length}`);
51
+ }
52
+ if (expectedName !== undefined && first.name !== expectedName) {
53
+ throw new Error(`expected registered tool '${expectedName}', got '${String(first.name)}'`);
54
+ }
55
+ return first;
56
+ }
@@ -0,0 +1,103 @@
1
+ /**
2
+ * /workflows 命令的 resume 通道契约(workflow-run-resume-revision U3)。
3
+ *
4
+ * 覆盖面(TUI 模式):/workflows resume <runId> → 执行断点续跑后 notify,不打开
5
+ * 面板;缺 runId → Usage 提示;resumeRun 拒绝 → warning notify 透出恢复指引文案。
6
+ *
7
+ * RPC 模式(taiji GUI)的 resume verb 接线未随 U3 落地:解析词表在
8
+ * command-actions.ts(领地外,resume verb 现仍解析为 lifecycle-removed),其
9
+ * dispatch 断言由 src/__tests__/rpc-command-handling.test.ts 原样锁定——词表
10
+ * 适配归编排层(见 commands.ts handleRpcMode 注释)。workflow tool 通道
11
+ * (action:"resume",含 D14 args 校验)由 tool-workflow-resume.test.ts 覆盖。
12
+ *
13
+ * mock 策略:resume-run 深路径 stub(对齐 tool-workflow-resume.test.ts);命令
14
+ * 注册层经 fake api 捕获 handler 后以 fake ctx 直接调用。
15
+ */
16
+ import { describe, expect, it, vi, beforeEach } from "vitest";
17
+
18
+ import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
19
+
20
+ // 被 mock 的模块——import 路径与被测源文件的等值实例(对齐 tool-workflow-resume.test.ts)
21
+ import { resumeRun } from "@zhushanwen/subagent-core/orchestration/resume-run.ts";
22
+
23
+ import { registerWorkflowsCommand } from "../commands.ts";
24
+
25
+ vi.mock("@zhushanwen/subagent-core/orchestration/resume-run.ts", () => ({
26
+ resumeRun: vi.fn(),
27
+ }));
28
+
29
+ // ── fixture ──────────────────────────────────────────────────
30
+
31
+ interface CapturedCommand { // oe-exempt:20260929:test:test double shape for command capture (test infra)
32
+ name: string;
33
+ description: string;
34
+ handler: (args: string, ctx: ExtensionCommandContext) => Promise<void>;
35
+ }
36
+
37
+ /** 注册层捕获(对齐 capture-tool 形态:fake api 收集 registerCommand 入参)。 */
38
+ function captureCommand(): CapturedCommand {
39
+ const commands: CapturedCommand[] = [];
40
+ const api = {
41
+ registerCommand: (name: string, def: Omit<CapturedCommand, "name">) => {
42
+ commands.push({ name, ...def });
43
+ },
44
+ };
45
+ registerWorkflowsCommand(
46
+ api as never,
47
+ () => new Map(),
48
+ { runs: new Map(), store: { stateFilePath: () => "/state/x" } } as never,
49
+ );
50
+ expect(commands).toHaveLength(1);
51
+ expect(commands[0]!.name).toBe("workflows");
52
+ return commands[0]!;
53
+ }
54
+
55
+ /** fake ctx:notify 收集 + mode 注入(TUI 分支的 theme/select 不触 resume 用例)。 */
56
+ function makeCtx(mode: "rpc" | "tui"): { ctx: ExtensionCommandContext; notifies: Array<{ msg: string; level: string }> } {
57
+ const notifies: Array<{ msg: string; level: string }> = [];
58
+ const ctx = {
59
+ mode,
60
+ ui: {
61
+ notify: (msg: string, level: string) => notifies.push({ msg, level }),
62
+ theme: {},
63
+ select: vi.fn(),
64
+ },
65
+ };
66
+ return { ctx: ctx as never, notifies };
67
+ }
68
+
69
+ beforeEach(() => {
70
+ vi.mocked(resumeRun).mockReset();
71
+ vi.mocked(resumeRun).mockResolvedValue("wf-x");
72
+ });
73
+
74
+ // ── TUI 模式 ─────────────────────────────────────────────────
75
+
76
+ describe("/workflows TUI resume", () => {
77
+ it("resume <runId> → resumeRun 接线 + notify(不打开面板)", async () => {
78
+ const cmd = captureCommand();
79
+ const { ctx, notifies } = makeCtx("tui");
80
+ await cmd.handler("resume wf-x", ctx);
81
+ expect(vi.mocked(resumeRun)).toHaveBeenCalledWith("wf-x", expect.anything());
82
+ expect(notifies).toHaveLength(1);
83
+ expect(notifies[0]!.msg).toContain("resuming");
84
+ expect(notifies[0]!.level).toBe("info");
85
+ });
86
+
87
+ it("resume 缺 runId → Usage warning", async () => {
88
+ const cmd = captureCommand();
89
+ const { ctx, notifies } = makeCtx("tui");
90
+ await cmd.handler("resume", ctx);
91
+ expect(vi.mocked(resumeRun)).not.toHaveBeenCalled();
92
+ expect(notifies[0]).toMatchObject({ msg: "Usage: /workflows resume <runId>", level: "warning" });
93
+ });
94
+
95
+ it("resumeRun 拒绝 → warning notify(TUI 同款透出)", async () => {
96
+ vi.mocked(resumeRun).mockRejectedValue(new Error("Resume rejected: lock held"));
97
+ const cmd = captureCommand();
98
+ const { ctx, notifies } = makeCtx("tui");
99
+ await cmd.handler("resume wf-x", ctx);
100
+ expect(notifies[0]!.msg).toContain("Failed to resume workflow wf-x: Resume rejected: lock held");
101
+ expect(notifies[0]!.level).toBe("warning");
102
+ });
103
+ });