@zhushanwen/pi-subagent-workflow 8.2.0 → 8.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zhushanwen/pi-subagent-workflow",
3
- "version": "8.2.0",
3
+ "version": "8.3.0",
4
4
  "type": "module",
5
5
  "main": "index.ts",
6
6
  "description": "Unified subagent execution and multi-agent workflow orchestration for Pi — spawned-process agent runtime with sync/background modes, stateful workflow management with persistence, state machine, and execution tracing.",
@@ -49,15 +49,15 @@
49
49
  "yaml": "^2.9.0",
50
50
  "@xyz-agent/extension-protocol": "0.6.0",
51
51
  "@xyz-agent/session-delivery": "0.2.0",
52
- "@zhushanwen/pi-extension-logger": "0.2.2",
53
- "@zhushanwen/pi-file-lock": "0.1.1"
52
+ "@zhushanwen/pi-extension-logger": "0.3.0",
53
+ "@zhushanwen/pi-file-lock": "0.1.2"
54
54
  },
55
55
  "peerDependencies": {
56
56
  "@earendil-works/pi-ai": "^0.84.1",
57
57
  "@earendil-works/pi-coding-agent": "^0.84.1",
58
58
  "@earendil-works/pi-tui": "^0.84.1",
59
59
  "typebox": "*",
60
- "@zhushanwen/pi-pending-notifications": "0.3.4",
60
+ "@zhushanwen/pi-pending-notifications": "0.3.5",
61
61
  "@zhushanwen/pi-structured-output": "5.0.2"
62
62
  },
63
63
  "peerDependenciesMeta": {
@@ -6,12 +6,24 @@
6
6
  // - headless(json/print/undefined):返回 undefined(不注入 handler)
7
7
  // - TUI:fire-and-forget 回 ack 不透传;dialog 进 dialogQueue 串行
8
8
  // - GUI(rpc):fire-and-forget 直接调 realHandler;dialog 进 dialogQueue 串行
9
- // realHandler 路由:channel 命中 → channelHandler(经 coerceUiResponse 形变);未命中 → defaultDialogForward(cancelled)。
9
+ // realHandler 路由:channel 命中 → channelHandler(经 coerceUiResponse 形变);未命中 → defaultDialogForward(dialog 转发结果,fire-and-forget 转发 ctx.ui.* 后回 ack,未知 method warn + ack)。
10
10
  // 测接口契约,不测实现细节。
11
11
 
12
12
  import type { ExtensionContext, ExtensionMode } from "@earendil-works/pi-coding-agent";
13
13
  import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
14
14
 
15
+ const { loggerMock } = vi.hoisted(() => ({
16
+ loggerMock: {
17
+ debug: vi.fn(),
18
+ warn: vi.fn(),
19
+ error: vi.fn(),
20
+ info: vi.fn(),
21
+ },
22
+ }));
23
+ vi.mock("@zhushanwen/pi-extension-logger", () => ({
24
+ getLogger: () => loggerMock,
25
+ }));
26
+
15
27
  import { DialogGlobalQueue, type UiRequest } from "../dialog-queue.ts";
16
28
  import { type ChannelHandler,createUiChannelRegistry } from "../ui-channels.ts";
17
29
  import { createUiRequestHandlerForMode } from "../ui-request-handler-factory.ts";
@@ -31,6 +43,35 @@ function makeCtx(mode: ExtensionMode): ExtensionContext {
31
43
  } as ExtensionContext;
32
44
  }
33
45
 
46
+ /** 带 mock ctx.ui 的 ExtensionContext(GUI fire-and-forget 转发测试用)。
47
+ * dialog method(select/confirm/input/editor)返回 undefined/true/"" 兜底,
48
+ * fire-and-forget method(notify/setStatus/setWidget/setTitle/setEditorText)是 void spy。 */
49
+ function makeCtxWithUi(mode: ExtensionMode = "rpc"): ExtensionContext & { ui: Record<string, ReturnType<typeof vi.fn>> } {
50
+ const ui: Record<string, ReturnType<typeof vi.fn>> = {
51
+ select: vi.fn(async () => undefined),
52
+ confirm: vi.fn(async () => true),
53
+ input: vi.fn(async () => ""),
54
+ editor: vi.fn(async () => ""),
55
+ notify: vi.fn(),
56
+ setStatus: vi.fn(),
57
+ setWidget: vi.fn(),
58
+ setTitle: vi.fn(),
59
+ setEditorText: vi.fn(),
60
+ };
61
+ return {
62
+ cwd: "/tmp/test",
63
+ mode,
64
+ sessionManager: {
65
+ getSessionId: () => "s1",
66
+ getSessionFile: () => undefined,
67
+ getSessionDir: () => "/tmp/test/sessions",
68
+ },
69
+ modelRegistry: undefined,
70
+ model: undefined,
71
+ ui,
72
+ } as unknown as ExtensionContext & { ui: Record<string, ReturnType<typeof vi.fn>> };
73
+ }
74
+
34
75
  function dialogReq(id: string, channel?: string): UiRequest {
35
76
  return { method: "select", id, title: `q-${id}`, ...(channel ? { channel } : {}) };
36
77
  }
@@ -44,6 +85,7 @@ beforeEach(() => {
44
85
  vi.useFakeTimers();
45
86
  vi.spyOn(console, "warn").mockImplementation(() => {});
46
87
  vi.spyOn(console, "error").mockImplementation(() => {});
88
+ loggerMock.warn.mockClear();
47
89
  });
48
90
 
49
91
  afterEach(() => {
@@ -99,12 +141,14 @@ describe("createUiRequestHandlerForMode — GUI(rpc)模式透传", () => {
99
141
  const queue = new DialogGlobalQueue();
100
142
  const enqueueSpy = vi.spyOn(queue, "enqueue");
101
143
 
102
- const handler = createUiRequestHandlerForMode(makeCtx("rpc"), registry, queue)!;
103
- // notify channel realHandler → defaultDialogForward(cancelled)
144
+ const ctx = makeCtxWithUi("rpc");
145
+ const handler = createUiRequestHandlerForMode(ctx, registry, queue)!;
146
+ // notify 无 channel → realHandler → defaultDialogForward → case "notify" → {ack:true}
104
147
  const resp = await handler(fireAndForgetReq("f1"));
105
148
 
106
149
  expect(enqueueSpy).not.toHaveBeenCalled();
107
- expect(resp).toEqual({ cancelled: true });
150
+ expect(resp).toEqual({ ack: true });
151
+ expect(ctx.ui.notify).toHaveBeenCalledWith("n-f1", "info");
108
152
  });
109
153
 
110
154
  it("dialog(select 无 channel)→ 进 dialogQueue", async () => {
@@ -135,11 +179,14 @@ describe("createUiRequestHandlerForMode — channel 业务路由", () => {
135
179
  expect(resp).toEqual({ value: "from-channel" });
136
180
  });
137
181
 
138
- it("channel 未命中 → defaultDialogForward(stub cancelled)", async () => {
182
+ it("channel 未命中 → defaultDialogForward(fire-and-forget 走 ack)", async () => {
183
+ const ctx = makeCtxWithUi("rpc");
139
184
  const handler = createUiRequestHandlerForMode(
140
- makeCtx("rpc"), createUiChannelRegistry(), new DialogGlobalQueue())!;
185
+ ctx, createUiChannelRegistry(), new DialogGlobalQueue())!;
141
186
  const resp = await handler({ method: "notify", id: "f1", message: "m", channel: "unknown" });
142
- expect(resp).toEqual({ cancelled: true });
187
+ // notify fire-and-forget,channel miss 后走 defaultDialogForward 的 notify case → ack
188
+ expect(resp).toEqual({ ack: true });
189
+ expect(ctx.ui.notify).toHaveBeenCalledWith("m", "info");
143
190
  });
144
191
  });
145
192
 
@@ -164,3 +211,160 @@ describe("createUiRequestHandlerForMode — coerceUiResponse 形变", () => {
164
211
  expect(await callWithChannel(null)).toEqual({ cancelled: true });
165
212
  });
166
213
  });
214
+
215
+ // ── P1:GUI fire-and-forget 分类转发(§3.2 映射表 + §3.7 D1/D2) ──
216
+ describe("defaultDialogForward — fire-and-forget 分类转发", () => {
217
+ function makeHandler(ctx: ExtensionContext) {
218
+ return createUiRequestHandlerForMode(
219
+ ctx, createUiChannelRegistry(), new DialogGlobalQueue())!;
220
+ }
221
+
222
+ // (a) 五个 case 转发调用与 ack 返回
223
+ it("notify → ctx.ui.notify + {ack:true}", async () => {
224
+ const ctx = makeCtxWithUi();
225
+ const handler = makeHandler(ctx);
226
+ const resp = await handler({ method: "notify", id: "n1", message: "hello" });
227
+ expect(resp).toEqual({ ack: true });
228
+ expect(ctx.ui.notify).toHaveBeenCalledTimes(1);
229
+ expect(ctx.ui.notify).toHaveBeenCalledWith("hello", "info");
230
+ });
231
+
232
+ it("setStatus → ctx.ui.setStatus + {ack:true}", async () => {
233
+ const ctx = makeCtxWithUi();
234
+ const handler = makeHandler(ctx);
235
+ const resp = await handler({ method: "setStatus", id: "s1", statusKey: "progress", statusText: "50%" });
236
+ expect(resp).toEqual({ ack: true });
237
+ expect(ctx.ui.setStatus).toHaveBeenCalledTimes(1);
238
+ expect(ctx.ui.setStatus).toHaveBeenCalledWith("progress", "50%");
239
+ });
240
+
241
+ it("setWidget(channel=undefined)→ ctx.ui.setWidget 含 placement + {ack:true}", async () => {
242
+ const ctx = makeCtxWithUi();
243
+ const handler = makeHandler(ctx);
244
+ const resp = await handler({
245
+ method: "setWidget", id: "w1",
246
+ widgetKey: "my-widget", widgetLines: ["line1", "line2"],
247
+ widgetPlacement: "belowEditor",
248
+ });
249
+ expect(resp).toEqual({ ack: true });
250
+ expect(ctx.ui.setWidget).toHaveBeenCalledTimes(1);
251
+ expect(ctx.ui.setWidget).toHaveBeenCalledWith("my-widget", ["line1", "line2"], { placement: "belowEditor" });
252
+ });
253
+
254
+ it("setTitle → ctx.ui.setTitle + {ack:true}", async () => {
255
+ const ctx = makeCtxWithUi();
256
+ const handler = makeHandler(ctx);
257
+ const resp = await handler({ method: "setTitle", id: "t1", title: "My Title" });
258
+ expect(resp).toEqual({ ack: true });
259
+ expect(ctx.ui.setTitle).toHaveBeenCalledTimes(1);
260
+ expect(ctx.ui.setTitle).toHaveBeenCalledWith("My Title");
261
+ });
262
+
263
+ it("set_editor_text → ctx.ui.setEditorText + {ack:true}", async () => {
264
+ const ctx = makeCtxWithUi();
265
+ const handler = makeHandler(ctx);
266
+ const resp = await handler({ method: "set_editor_text", id: "e1", text: "some code" });
267
+ expect(resp).toEqual({ ack: true });
268
+ expect(ctx.ui.setEditorText).toHaveBeenCalledTimes(1);
269
+ expect(ctx.ui.setEditorText).toHaveBeenCalledWith("some code");
270
+ });
271
+
272
+ // (b) notifyType 收窄三档 + 非法值 fallback info
273
+ it.each([
274
+ ["info", "info"],
275
+ ["warning", "warning"],
276
+ ["error", "error"],
277
+ ] as const)("notifyType='%s' → 透传 '%s'", async (input, expected) => {
278
+ const ctx = makeCtxWithUi();
279
+ const handler = makeHandler(ctx);
280
+ await handler({ method: "notify", id: "n1", message: "m", notifyType: input });
281
+ expect(ctx.ui.notify).toHaveBeenCalledWith("m", expected);
282
+ });
283
+
284
+ it("notifyType 非法值 → fallback 'info'", async () => {
285
+ const ctx = makeCtxWithUi();
286
+ const handler = makeHandler(ctx);
287
+ await handler({ method: "notify", id: "n1", message: "m", notifyType: "debug" });
288
+ expect(ctx.ui.notify).toHaveBeenCalledWith("m", "info");
289
+ });
290
+
291
+ it("notifyType undefined → fallback 'info'", async () => {
292
+ const ctx = makeCtxWithUi();
293
+ const handler = makeHandler(ctx);
294
+ await handler({ method: "notify", id: "n1", message: "m" });
295
+ expect(ctx.ui.notify).toHaveBeenCalledWith("m", "info");
296
+ });
297
+
298
+ // (c) setWidget 两分支
299
+ it("setWidget channel='gui_widget' → 不调 ctx.ui.setWidget,回 {ack:true}", async () => {
300
+ const ctx = makeCtxWithUi();
301
+ const handler = makeHandler(ctx);
302
+ const resp = await handler({
303
+ method: "setWidget", id: "w1",
304
+ widgetKey: "gui-w", widgetLines: ["\0XYZ_GUI_WIDGET:{...}"],
305
+ channel: "gui_widget",
306
+ });
307
+ expect(resp).toEqual({ ack: true });
308
+ expect(ctx.ui.setWidget).not.toHaveBeenCalled();
309
+ });
310
+
311
+ it("setWidget channel=undefined → 转发含 placement", async () => {
312
+ const ctx = makeCtxWithUi();
313
+ const handler = makeHandler(ctx);
314
+ await handler({
315
+ method: "setWidget", id: "w1",
316
+ widgetKey: "k", widgetLines: ["a"],
317
+ widgetPlacement: "aboveEditor",
318
+ });
319
+ expect(ctx.ui.setWidget).toHaveBeenCalledWith("k", ["a"], { placement: "aboveEditor" });
320
+ });
321
+
322
+ // (d) 未知 method warn + ack
323
+ it("未知 method → logger.warn + {ack:true}(非 cancelled)", async () => {
324
+ const ctx = makeCtxWithUi();
325
+ const handler = makeHandler(ctx);
326
+ const resp = await handler({ method: "futureMethod", id: "x1" });
327
+ expect(resp).toEqual({ ack: true });
328
+ expect(loggerMock.warn).toHaveBeenCalledWith(
329
+ expect.stringContaining("unknown method"),
330
+ expect.objectContaining({ detail: { method: "futureMethod", id: "x1" } }),
331
+ );
332
+ });
333
+
334
+ // (e) dialog 既有 case 不回归(select/confirm 代表)
335
+ it("select 无 channel → 调 ctx.ui.select + 透传 value", async () => {
336
+ const ctx = makeCtxWithUi();
337
+ ctx.ui.select.mockResolvedValueOnce("picked");
338
+ const handler = makeHandler(ctx);
339
+ const resp = await handler({ method: "select", id: "d1", title: "Choose", options: ["a", "b"] });
340
+ expect(resp).toEqual({ value: "picked" });
341
+ expect(ctx.ui.select).toHaveBeenCalledWith("Choose", ["a", "b"]);
342
+ });
343
+
344
+ it("confirm 无 channel → 调 ctx.ui.confirm + 透传 confirmed", async () => {
345
+ const ctx = makeCtxWithUi();
346
+ ctx.ui.confirm.mockResolvedValueOnce(false);
347
+ const handler = makeHandler(ctx);
348
+ const resp = await handler({ method: "confirm", id: "d2", title: "Sure?", message: "Go?" });
349
+ expect(resp).toEqual({ confirmed: false });
350
+ expect(ctx.ui.confirm).toHaveBeenCalledWith("Sure?", "Go?");
351
+ });
352
+ });
353
+
354
+ // ── 确认 createUiRequestHandlerForMode 未改动(TUI 行为零变化) ──
355
+ describe("createUiRequestHandlerForMode — TUI 零回归", () => {
356
+ it("TUI 下所有 fire-and-forget method 均回 ack 且不调 ctx.ui", async () => {
357
+ const methods = ["notify", "setStatus", "setWidget", "setTitle", "set_editor_text"];
358
+ for (const method of methods) {
359
+ const ctx = makeCtxWithUi("tui");
360
+ const handler = createUiRequestHandlerForMode(
361
+ ctx, createUiChannelRegistry(), new DialogGlobalQueue())!;
362
+ const resp = await handler({ method, id: "t1" });
363
+ expect(resp).toEqual({ ack: true });
364
+ // TUI 不透传,ctx.ui 方法不应被调用
365
+ for (const fn of Object.values(ctx.ui)) {
366
+ expect(fn).not.toHaveBeenCalled();
367
+ }
368
+ }
369
+ });
370
+ });
@@ -16,6 +16,9 @@
16
16
  import * as fs from "node:fs";
17
17
 
18
18
  import { countActiveFromEntries } from "@zhushanwen/pi-pending-notifications";
19
+ import { getLogger } from "@zhushanwen/pi-extension-logger";
20
+
21
+ const logger = getLogger("subagents");
19
22
 
20
23
  /** 后代刚完成(unregister)后,notify 唤醒父 agent 可能仍在路上(triggerTurn 的
21
24
  * steer/followUp 经 sendMessage → agent 队列排空(agent-session.js:1081-1087),
@@ -147,7 +150,7 @@ export function readActivePendingFromSessionFile(
147
150
  }
148
151
  } catch {
149
152
  // 截断行/坏行跳过——不影响其余 entry 的差集判定(罕见:append 中途崩溃)
150
- console.debug("[session-pending] skipped malformed pending line in", sessionFile);
153
+ logger.debug("skipped malformed pending line", { sessionFile });
151
154
  }
152
155
  }
153
156
 
@@ -163,13 +163,50 @@ async function defaultDialogForward(
163
163
  return { cancelled: true };
164
164
  }
165
165
  }
166
+ // ── fire-and-forget 类(§3.2 映射表:GUI 模式下由 createUiRequestHandlerForMode 直接转发)
167
+ // 子进程 rpc-mode 发出的 fire-and-forget method,channel miss 后落到此处。
168
+ // 全部回 {ack:true}(fire-and-forget 语义:子进程不等响应)。
169
+ case "notify": {
170
+ // notifyType 运行时收窄:UiRequest.notifyType 是宽 string,ctx.ui.notify 要字面量联合。
171
+ // 非法值 fallback "info"(pi 侧也会静默降级 info,此处显式 fallback 避免类型不安全)。
172
+ const rawType = req.notifyType;
173
+ const notifyType = rawType === "info" || rawType === "warning" || rawType === "error"
174
+ ? rawType
175
+ : "info";
176
+ ui.notify(req.message ?? "", notifyType);
177
+ return { ack: true };
178
+ }
179
+ case "setStatus": {
180
+ ui.setStatus(req.statusKey ?? "", req.statusText);
181
+ return { ack: true };
182
+ }
183
+ case "setWidget": {
184
+ // setWidget channel-miss 语义(§3.2 D1):
185
+ // req.channel === "gui_widget"(带 marker 但 channel 未注册)→ 不转发(marker 行无渲染意义)
186
+ // req.channel === undefined(普通 widget)→ 转发文本行到主 agent
187
+ // channel 命中 registry 时由 createRealHandler 优先走 channel handler,不进这里。
188
+ if (req.channel === "gui_widget") {
189
+ return { ack: true };
190
+ }
191
+ ui.setWidget(req.widgetKey ?? "", req.widgetLines, { placement: req.widgetPlacement });
192
+ return { ack: true };
193
+ }
194
+ case "setTitle": {
195
+ ui.setTitle(req.title ?? "");
196
+ return { ack: true };
197
+ }
198
+ case "set_editor_text": {
199
+ ui.setEditorText(req.text ?? "");
200
+ return { ack: true };
201
+ }
166
202
  default: {
167
- // 未知 dialog method(非 select/confirm/input/editor)——保守 cancelled 不阻塞子进程
203
+ // 未知 method(非 dialog fire-and-forget)——保留 warn(协议演进信号,P3 限流兜底),
204
+ // 回 ack(落到 default 的一定不是 dialog,fire-and-forget 正确应答是 ack,与 TUI 分支先例一致)。
168
205
  logger.warn(
169
- "[subagents] defaultDialogForward: unknown dialog method",
206
+ "[subagents] defaultDialogForward: unknown method",
170
207
  { detail: { method: req.method, id: req.id } },
171
208
  );
172
- return { cancelled: true };
209
+ return { ack: true };
173
210
  }
174
211
  }
175
212
  }
@@ -11,9 +11,18 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
11
11
  // 隔离真实用户全局目录:resource-discovery 用 homedir() 推导 user-agents 源
12
12
  // (~/.agents/agents/),测试环境可能存在真实 agent 文件(如 tech-design-review.md),
13
13
  // 不 mock 会导致「期望空列表/精确列表」用例被环境污染(2026-08 实测 4 个失败)。
14
+ // 用真实 tmpdir 下的子目录作 mock homedir(macOS SIP 禁止 mkdir /nonexistent-*)。
15
+ const mockHomeDir = vi.hoisted(() => {
16
+ const fs = require("node:fs");
17
+ const os = require("node:os");
18
+ const path = require("node:path");
19
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "res-disc-home-"));
20
+ return dir;
21
+ });
22
+
14
23
  vi.mock("node:os", async (importOriginal) => {
15
24
  const actual = await importOriginal<typeof import("node:os")>();
16
- return { ...actual, homedir: () => "/nonexistent-home-for-tests" };
25
+ return { ...actual, homedir: () => mockHomeDir };
17
26
  });
18
27
 
19
28
  import {
@@ -23,6 +32,9 @@ import {
23
32
  processPackageSync,
24
33
  getCachedFile,
25
34
  getCachedFileContent,
35
+ __testResetShadowDedup,
36
+ __testInjectShadowDedupKey,
37
+ isMachineSource,
26
38
  getCachedParsed,
27
39
  clearFileCache,
28
40
  } from "../resource-discovery.ts";
@@ -218,6 +230,7 @@ describe("discoverResources (async)", () => {
218
230
  });
219
231
  afterEach(() => {
220
232
  fs.rmSync(ws, { recursive: true, force: true });
233
+ __testResetShadowDedup();
221
234
  });
222
235
 
223
236
  it("discovers agents from project .pi/agents/ (async)", async () => {
@@ -262,6 +275,7 @@ describe("user-extension-paths (XYZ_EXTENSION_PATHS)", () => {
262
275
  if (savedEnv === undefined) delete process.env.XYZ_EXTENSION_PATHS;
263
276
  else process.env.XYZ_EXTENSION_PATHS = savedEnv;
264
277
  fs.rmSync(ws, { recursive: true, force: true });
278
+ __testResetShadowDedup();
265
279
  });
266
280
 
267
281
  it("discovers agents from XYZ_EXTENSION_PATHS via pi.agents manifest", () => {
@@ -392,26 +406,204 @@ describe("user-extension-paths (XYZ_EXTENSION_PATHS)", () => {
392
406
  expect(asyncResult).toEqual(discoverResourcesSync(config));
393
407
  });
394
408
 
395
- it("async: 同名遮蔽时输出 warn(D8d 有检测必有报告)", async () => {
409
+ it("async: 同名遮蔽时机器源×机器源降 debug 不产生 warn(D8d 分级)", async () => {
396
410
  const npmPkg = path.join(agentDir, "npm", "node_modules", "test-pkg");
397
411
  writePackageJson(npmPkg, { agents: ["./agents"] });
398
412
  const npmFile = writeFile(path.join(npmPkg, "agents"), "dup.md", "npm-body");
399
413
  const projFile = writeFile(path.join(ws, ".agents", "agents"), "dup.md", "project-body");
400
414
  const warnSpy = vi.spyOn(getLogger("subagents"), "warn");
415
+ const debugSpy = vi.spyOn(getLogger("subagents"), "debug");
401
416
 
402
417
  try {
403
418
  const result = await discoverResources({ kind: "agents", workspaceRoot: ws, agentDir });
404
419
 
405
420
  // 遮蔽仍生效(last-writer-wins 语义不变)
406
421
  expect(result.find((r) => path.basename(r.path) === "dup.md")?.source).toBe("project-agents");
407
- // 但不再静默:warn 报告被遮蔽方与保留方路径(D8d「有检测无报告」修复)
408
- expect(warnSpy).toHaveBeenCalledTimes(1);
409
- const [msg, data] = warnSpy.mock.calls[0];
422
+ // npm 与 project-agents 均为机器源 → 降级 debug,不产生 warn
423
+ expect(warnSpy).not.toHaveBeenCalled();
424
+ expect(debugSpy).toHaveBeenCalledTimes(1);
425
+ const [msg, data] = debugSpy.mock.calls[0];
410
426
  expect(String(msg)).toContain('duplicate agents "dup"');
411
427
  expect(String(msg)).toContain("project-agents shadows npm");
412
428
  expect(data).toMatchObject({ shadowed: npmFile, kept: projFile });
413
429
  } finally {
414
430
  warnSpy.mockRestore();
431
+ debugSpy.mockRestore();
432
+ }
433
+ });
434
+
435
+ it("(a) 机器源×用户源降 debug:npm vs user-pi 不产生 warn", async () => {
436
+ // npm 源(机器源)与 user-pi 源(用户源)——任一侧为机器源即降 debug
437
+ const npmPkg = path.join(agentDir, "npm", "node_modules", "test-pkg");
438
+ writePackageJson(npmPkg, { agents: ["./agents"] });
439
+ writeFile(path.join(npmPkg, "agents"), "dup.md", "npm-body");
440
+ // user-pi 源 = agentDir/<kind>/(agentDir 是独立于 homedir mock 的入参,
441
+ // mockHomeDir 由 vi.hoisted mkdtempSync 创建真实 tmpdir)
442
+ writeFile(path.join(agentDir, "agents"), "dup.md", "user-pi-body");
443
+ const warnSpy = vi.spyOn(getLogger("subagents"), "warn");
444
+ const debugSpy = vi.spyOn(getLogger("subagents"), "debug");
445
+
446
+ try {
447
+ await discoverResources({ kind: "agents", workspaceRoot: ws, agentDir });
448
+
449
+ expect(warnSpy).not.toHaveBeenCalled();
450
+ expect(debugSpy).toHaveBeenCalled();
451
+ const [msg] = debugSpy.mock.calls[0];
452
+ expect(String(msg)).toContain('duplicate agents "dup"');
453
+ } finally {
454
+ warnSpy.mockRestore();
455
+ debugSpy.mockRestore();
456
+ }
457
+ });
458
+
459
+ it("(a) npm vs user-extension-paths 机器源×机器源降 debug", async () => {
460
+ // 两个机器源同名(npm 包 vs XYZ_EXTENSION_PATHS 注入的 dev 包,后者 source 标签为 user-extension-paths)
461
+ const npmPkg = path.join(agentDir, "npm", "node_modules", "test-pkg");
462
+ writePackageJson(npmPkg, { agents: ["./agents"] });
463
+ writeFile(path.join(npmPkg, "agents"), "shared.md", "npm-body");
464
+ const devPkg = path.join(ws, "dev-ext");
465
+ writePackageJson(devPkg, { agents: ["./agents"] });
466
+ writeFile(path.join(devPkg, "agents"), "shared.md", "dev-body");
467
+ process.env.XYZ_EXTENSION_PATHS = devPkg;
468
+ const warnSpy = vi.spyOn(getLogger("subagents"), "warn");
469
+ const debugSpy = vi.spyOn(getLogger("subagents"), "debug");
470
+
471
+ try {
472
+ await discoverResources({ kind: "agents", workspaceRoot: ws, agentDir });
473
+
474
+ expect(warnSpy).not.toHaveBeenCalled();
475
+ expect(debugSpy).toHaveBeenCalled();
476
+ } finally {
477
+ warnSpy.mockRestore();
478
+ debugSpy.mockRestore();
479
+ }
480
+ });
481
+
482
+ it("(b) 双用户源重复产生 warn 且同进程第二次 discoverResources 不重复(去重生效)", async () => {
483
+ // user-pi 与 user-agents 都是用户源——需要构造两个源都有同名文件
484
+ // user-pi = agentDir/agents/(buildScanTargets 第一个 target)
485
+ // user-agents = mockHomeDir/.agents/agents/(vi.hoisted 创建的真实 tmpdir)
486
+ const userAgentsDir = path.join(mockHomeDir, ".agents", "agents");
487
+ fs.mkdirSync(userAgentsDir, { recursive: true });
488
+ try {
489
+ // user-pi: agentDir/agents/dup.md
490
+ writeFile(path.join(agentDir, "agents"), "dup.md", "user-pi-body");
491
+ // user-agents: mockHomeDir/.agents/agents/dup.md
492
+ fs.writeFileSync(path.join(userAgentsDir, "dup.md"), "user-agents-body", "utf-8");
493
+
494
+ const warnSpy = vi.spyOn(getLogger("subagents"), "warn");
495
+
496
+ try {
497
+ // 第一次调用——应产生 warn(双用户源)
498
+ await discoverResources({ kind: "agents", workspaceRoot: ws, agentDir });
499
+ expect(warnSpy).toHaveBeenCalledTimes(1);
500
+ const [msg] = warnSpy.mock.calls[0];
501
+ expect(String(msg)).toContain('duplicate agents "dup"');
502
+
503
+ // 第二次调用——同进程去重,不再报
504
+ warnSpy.mockClear();
505
+ await discoverResources({ kind: "agents", workspaceRoot: ws, agentDir });
506
+ expect(warnSpy).not.toHaveBeenCalled();
507
+ } finally {
508
+ warnSpy.mockRestore();
509
+ }
510
+ } finally {
511
+ fs.rmSync(userAgentsDir, { recursive: true, force: true });
512
+ }
513
+ });
514
+
515
+ it("(c) path 变化(新 key)重新报 warn", async () => {
516
+ const userAgentsDir = path.join(mockHomeDir, ".agents", "agents");
517
+ fs.mkdirSync(userAgentsDir, { recursive: true });
518
+ try {
519
+ writeFile(path.join(agentDir, "agents"), "dup.md", "user-pi-body");
520
+ fs.writeFileSync(path.join(userAgentsDir, "dup.md"), "user-agents-body", "utf-8");
521
+
522
+ const warnSpy = vi.spyOn(getLogger("subagents"), "warn");
523
+
524
+ try {
525
+ // 第一次调用——报 warn
526
+ await discoverResources({ kind: "agents", workspaceRoot: ws, agentDir });
527
+ expect(warnSpy).toHaveBeenCalledTimes(1);
528
+
529
+ // 第二次调用——同 key 去重,不报
530
+ warnSpy.mockClear();
531
+ await discoverResources({ kind: "agents", workspaceRoot: ws, agentDir });
532
+ expect(warnSpy).not.toHaveBeenCalled();
533
+
534
+ // 真实 path 变化产生新 key:双用户目录各加一个不同 stem(dup2.md)
535
+ // → 新遮蔽对(新 stem 新 path)→ 新 key → 重新报
536
+ writeFile(path.join(agentDir, "agents"), "dup2.md", "user-pi-body-2");
537
+ fs.writeFileSync(path.join(userAgentsDir, "dup2.md"), "user-agents-body-2", "utf-8");
538
+ warnSpy.mockClear();
539
+ await discoverResources({ kind: "agents", workspaceRoot: ws, agentDir });
540
+ expect(warnSpy).toHaveBeenCalledTimes(1);
541
+ const [newMsg] = warnSpy.mock.calls[0];
542
+ expect(String(newMsg)).toContain('duplicate agents "dup2"');
543
+ } finally {
544
+ warnSpy.mockRestore();
545
+ }
546
+ } finally {
547
+ fs.rmSync(userAgentsDir, { recursive: true, force: true });
548
+ }
549
+ });
550
+
551
+ it("分级穷举:全部 8 个 ResourceSource 的机器/用户归属与 D3 一致", () => {
552
+ // 封闭枚举逐值断言,防止未来新增/修改枚举值时分级边界漂移
553
+ const machine: ResourceSource[] = ["npm", "npm-dev", "user-extension-paths", "project-pi", "project-pi-tmp", "project-agents"];
554
+ const user: ResourceSource[] = ["user-pi", "user-agents"];
555
+ for (const s of machine) expect(isMachineSource(s), `${s} 应为机器源`).toBe(true);
556
+ for (const s of user) expect(isMachineSource(s), `${s} 应为用户源`).toBe(false);
557
+ });
558
+
559
+ it("(d) cap 清空行为:超限后 clear 再 add,之前报过的 key 可重新报", async () => {
560
+ const userAgentsDir = path.join(mockHomeDir, ".agents", "agents");
561
+ fs.mkdirSync(userAgentsDir, { recursive: true });
562
+ try {
563
+ writeFile(path.join(agentDir, "agents"), "dup.md", "user-pi-body");
564
+ fs.writeFileSync(path.join(userAgentsDir, "dup.md"), "user-agents-body", "utf-8");
565
+
566
+ const warnSpy = vi.spyOn(getLogger("subagents"), "warn");
567
+
568
+ try {
569
+ // 步骤 1:首次调用——报 warn,dedup key 加入 set
570
+ await discoverResources({ kind: "agents", workspaceRoot: ws, agentDir });
571
+ expect(warnSpy).toHaveBeenCalledTimes(1);
572
+ warnSpy.mockClear();
573
+
574
+ // 步骤 2:重置 set,注入 1024 个虚拟 key(不含 dedup key)
575
+ // 使 set.size = MAX,dedup key 不在 set 中
576
+ __testResetShadowDedup();
577
+ for (let i = 0; i < 1024; i++) {
578
+ __testInjectShadowDedupKey(`fake|key${i}|/a|/b`);
579
+ }
580
+
581
+ // 步骤 3:第二次调用——dedup key 不在 set → 进 else 分支 →
582
+ // size(1024) >= MAX(1024) → clear() → set 空 → add dedup key → warn
583
+ await discoverResources({ kind: "agents", workspaceRoot: ws, agentDir });
584
+ expect(warnSpy).toHaveBeenCalledTimes(1);
585
+ warnSpy.mockClear();
586
+
587
+ // 步骤 4:第三次调用——dedup key 在 set 中 → 去重跳过 warn
588
+ await discoverResources({ kind: "agents", workspaceRoot: ws, agentDir });
589
+ expect(warnSpy).not.toHaveBeenCalled();
590
+
591
+ // 步骤 5:再次填充到 cap——重置 + 注入 1024 个虚拟 key
592
+ // dedup key(步骤 3 add 的)已被 reset 清除
593
+ __testResetShadowDedup();
594
+ for (let i = 0; i < 1024; i++) {
595
+ __testInjectShadowDedupKey(`fake2|key${i}|/a|/b`);
596
+ }
597
+
598
+ // 步骤 6:第四次调用——cap 再次触发 clear → dedup key 重新报
599
+ await discoverResources({ kind: "agents", workspaceRoot: ws, agentDir });
600
+ expect(warnSpy).toHaveBeenCalledTimes(1);
601
+ } finally {
602
+ warnSpy.mockRestore();
603
+ __testResetShadowDedup();
604
+ }
605
+ } finally {
606
+ fs.rmSync(userAgentsDir, { recursive: true, force: true });
415
607
  }
416
608
  });
417
609
  });
@@ -21,6 +21,22 @@ import { getLogger } from "@zhushanwen/pi-extension-logger";
21
21
  // 模块级 logger(setPiHandle 注入后自动走 appendEntry,未注入时 console 兜底)
22
22
  const logger = getLogger("subagents");
23
23
 
24
+ // [D8d] warn 路径进程内去重集合:key=(kind, stem, shadowedPath, keptPath),
25
+ // cap 1024 超限先清空再 add(对齐 ui-request-observability 的 MAX_WARNED_SESSIONS 范式)。
26
+ // debug 路径不去重(默认 no-op,无成本)。
27
+ const shadowWarnDedup = new Set<string>();
28
+ const MAX_SHADOW_WARN_DEDUP = 1024;
29
+
30
+ /** @internal 测试辅助:重置 warn 去重集合(cap 测试用,生产代码不调用)。 */
31
+ export function __testResetShadowDedup(): void {
32
+ shadowWarnDedup.clear();
33
+ }
34
+
35
+ /** @internal 测试辅助:向 warn 去重集合注入 key(cap 测试用)。 */
36
+ export function __testInjectShadowDedupKey(key: string): void {
37
+ shadowWarnDedup.add(key);
38
+ }
39
+
24
40
  // ── 类型 ─────────────────────────────────────────────────────
25
41
 
26
42
  /** 资源种类:agent 或 workflow */
@@ -53,6 +69,23 @@ export interface ScanConfig {
53
69
 
54
70
  // ── 常量 ─────────────────────────────────────────────────────
55
71
 
72
+ /** 机器源集合:包管理/工程配置产物,其同名重复是安装拓扑常态(非用户配置错误)。
73
+ * 用户个人源(user-pi / user-agents)不在此列——双个人源同名重复保留 warn。 */
74
+ const MACHINE_SOURCES: ReadonlySet<ResourceSource> = new Set<ResourceSource>([
75
+ "npm",
76
+ "npm-dev",
77
+ "user-extension-paths",
78
+ "project-pi",
79
+ "project-pi-tmp",
80
+ "project-agents",
81
+ ]);
82
+
83
+ /** 判断 source 是否属于机器源(安装拓扑常态,同名重复降 debug)。
84
+ * 导出仅为测试穷举断言用(封闭 8 值枚举 × 分级边界)。 */
85
+ export function isMachineSource(source: ResourceSource): boolean {
86
+ return MACHINE_SOURCES.has(source);
87
+ }
88
+
56
89
  /** workspace root 向上查找的最大深度 */
57
90
  const WORKSPACE_ROOT_MAX_DEPTH = 20;
58
91
 
@@ -579,13 +612,29 @@ export async function discoverResources(config: ScanConfig): Promise<DiscoveredR
579
612
  if (!r.available && existing) {
580
613
  continue;
581
614
  }
582
- // [D8d] 同名遮蔽可观测:高优先级源覆盖低优先级同名资源时 warn——此前
583
- // 「有检测无报告」,用户自定义 agent/workflow 被静默遮蔽后排查无从下手。
615
+ // [D8d] 同名遮蔽可观测:高优先级源覆盖低优先级同名资源时分级报告——
616
+ // 机器源重复是安装拓扑常态(npm 包与用户目录结构性同名),降 debug 默认静默
617
+ // (XYZ_AGENT_DEBUG=1 文件日志可查);双用户源重复是配置错误,保留 warn 首报。
618
+ // warn 路径进程内去重(Set cap 1024,对齐 ui-request-observability 范式):
619
+ // 每 session 独立进程(process-manager.ts L142-143),进程级去重 ≈ session 级首报。
584
620
  if (existing && existing.path !== r.path) {
585
- logger.warn(
586
- `[resource-discovery] duplicate ${config.kind} "${key}" from ${r.source} shadows ${existing.source}`,
587
- { shadowed: existing.path, kept: r.path },
588
- );
621
+ const msg =
622
+ `[resource-discovery] duplicate ${config.kind} "${key}" from ${r.source} shadows ${existing.source}`;
623
+ const data = { shadowed: existing.path, kept: r.path };
624
+ if (isMachineSource(existing.source) || isMachineSource(r.source)) {
625
+ // 任一侧为机器源 → 降级 debug(安装拓扑常态,排查走 XYZ_AGENT_DEBUG=1)
626
+ logger.debug(msg, data);
627
+ } else {
628
+ // 双侧均为用户源 → 保持 warn,进程内去重(同 key 只报首次)
629
+ const dedupKey = `${config.kind}|${key}|${existing.path}|${r.path}`;
630
+ if (!shadowWarnDedup.has(dedupKey)) {
631
+ if (shadowWarnDedup.size >= MAX_SHADOW_WARN_DEDUP) {
632
+ shadowWarnDedup.clear();
633
+ }
634
+ shadowWarnDedup.add(dedupKey);
635
+ logger.warn(msg, data);
636
+ }
637
+ }
589
638
  }
590
639
  merged.set(key, r);
591
640
  }