@zhushanwen/pi-subagent-workflow 8.2.0 → 8.4.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.4.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.",
@@ -47,17 +47,17 @@
47
47
  "dependencies": {
48
48
  "ajv": "^8.20.0",
49
49
  "yaml": "^2.9.0",
50
- "@xyz-agent/extension-protocol": "0.6.0",
51
50
  "@xyz-agent/session-delivery": "0.2.0",
52
- "@zhushanwen/pi-extension-logger": "0.2.2",
53
- "@zhushanwen/pi-file-lock": "0.1.1"
51
+ "@zhushanwen/pi-extension-logger": "0.3.0",
52
+ "@xyz-agent/extension-protocol": "0.6.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
  }
@@ -8,10 +8,15 @@
8
8
  * 设计为纯函数(无 ctx / service 依赖),便于独立单测,handler 只做薄分发。
9
9
  */
10
10
 
11
- /** /subagents RPC action 判别联合。 */
11
+ /** /subagents RPC action 判别联合。message/start 为 GUI 定向消息通道(设计 §3.3.3,
12
+ * 仅 RPC 分支消费;missing-args 携带 missing 字段供 handler 输出指明缺什么的 usage)。 */
12
13
  export type SubagentRpcAction =
13
14
  | { action: "cancel"; recordId: string }
14
15
  | { action: "cancel-missing-id" }
16
+ | { action: "message"; recordId: string; text: string }
17
+ | { action: "message-missing-args"; missing: "recordId" | "text" }
18
+ | { action: "start"; slug: string; task: string }
19
+ | { action: "start-missing-args"; missing: "slug" | "task" }
15
20
  | { action: "noop" };
16
21
 
17
22
  /** /workflows RPC action 判别联合。 */
@@ -46,25 +51,84 @@ function isRemovedLifecycleVerb(verb: string): verb is "pause" | "resume" {
46
51
  return REMOVED_LIFECYCLE_VERBS.has(verb as "pause" | "resume");
47
52
  }
48
53
 
54
+ /**
55
+ * 还原转义协议(设计 §3.3.3 / 探针 P3):字面 `\n`(反斜杠 + n 两字符)→ 真实换行、
56
+ * 字面 `\\`(两反斜杠)→ 单反斜杠。
57
+ *
58
+ * 与 runtime encodeDirectiveText(session-service.ts)互逆:composer 多行输入在
59
+ * client.prompt 传输前把真实换行编码为字面 \n、原生反斜杠编码为 \\(命令保持单行),
60
+ * extension 解析侧在此还原。反斜杠转义必须与换行转义在**单次遍历**里成对处理
61
+ * (交替分支 `\\\\|\\n`,两反斜杠优先匹配)——若只处理 \n,原文里的字面反斜杠+n
62
+ * (如路径 `C:\new`)会被误解码为换行,往返歧义。
63
+ */
64
+ function decodeNewlineEscapes(s: string): string {
65
+ return s.replace(/\\\\|\\n/g, (m) => (m === "\\\\" ? "\\" : "\n"));
66
+ }
67
+
68
+ /**
69
+ * 提取首个非空白 token 与其后剩余原文。
70
+ *
71
+ * 与 split(/\s+/) 不同:rest 保留 token 之后的全部原文(含空格/引号/换行转义),
72
+ * 供 message text / start task 的「剩余全量到字符串末尾」语义使用(设计 §3.3.3——
73
+ * pi 以首个空格拆命令名后 args 为其后全文,文本内的空格/引号必须原样保留)。
74
+ * rest 跳过 token 后的分隔空白(分隔符不属文本),但保留其后全部内容原样。
75
+ */
76
+ function splitFirstToken(s: string): { token: string; rest: string } | null {
77
+ const head = s.trimStart();
78
+ if (!head) return null;
79
+ const idx = head.search(/\s/);
80
+ if (idx === -1) return { token: head, rest: "" };
81
+ return { token: head.slice(0, idx), rest: head.slice(idx + 1).trimStart() };
82
+ }
83
+
49
84
  /**
50
85
  * 解析 /subagents RPC 命令字符串。
51
86
  *
52
87
  * 支持格式:
53
88
  * - `cancel <id>` → { action: "cancel", recordId }
54
89
  * - `cancel`(无 id)→ { action: "cancel-missing-id" }
90
+ * - `message <recordId> <text...>` → { action: "message", recordId, text }
91
+ * text 为第二 token 后的剩余全量(含空格/引号原样;字面 \n 还原为换行、字面 \\ 还原为
92
+ * 反斜杠——composer 定向消息经此协议编码,与 runtime encodeDirectiveText 互逆,设计 §3.3.3)
93
+ * - `message`(缺 recordId 或 text 为空白)→ { action: "message-missing-args", missing }
94
+ * - `start <slug> <task...>` → { action: "start", slug, task }(task 同 text 转义协议)
95
+ * - `start`(缺 slug 或 task 为空白)→ { action: "start-missing-args", missing }
55
96
  * - 其他(空 / 未知 action / 无参)→ { action: "noop" }
56
97
  *
57
- * noop 表示 GUI 端无对应程序化操作(GUI 已在 CommandPopover 屏蔽 /subagents 入口,
58
- * 此分支仅兜底手动 prompt)。
98
+ * missing-args 携带 missing 字段(缺哪个参数),handler 据此输出可操作的 usage
99
+ * 错误(全局规则:错误信息指向恢复动作)。noop 表示 GUI 端无对应程序化操作(GUI
100
+ * 已在 CommandPopover 屏蔽 /subagents 入口,此分支仅兜底手动 prompt)。
59
101
  */
60
102
  export function parseSubagentRpcCommand(argsStr: string): SubagentRpcAction {
61
- const args = argsStr.trim().split(/\s+/).filter(Boolean);
62
- if (args.length === 0) return { action: "noop" };
103
+ const first = splitFirstToken(argsStr);
104
+ if (!first) return { action: "noop" };
63
105
 
64
- const [verb, recordId] = args;
106
+ const { token: verb, rest } = first;
65
107
  if (verb === "cancel") {
66
- if (!recordId) return { action: "cancel-missing-id" };
67
- return { action: "cancel", recordId };
108
+ const idToken = splitFirstToken(rest);
109
+ if (!idToken) return { action: "cancel-missing-id" };
110
+ return { action: "cancel", recordId: idToken.token };
111
+ }
112
+ if (verb === "message" || verb === "start") {
113
+ // message 与 start 共用解析骨架,仅结果字段名不同(recordId/text vs slug/task)
114
+ const isMessage = verb === "message";
115
+ // 第二 token:message→recordId / start→slug;其后剩余全量(还原换行转义)为 text/task
116
+ const second = splitFirstToken(rest);
117
+ if (!second) {
118
+ return isMessage
119
+ ? { action: "message-missing-args", missing: "recordId" }
120
+ : { action: "start-missing-args", missing: "slug" };
121
+ }
122
+ // 先还原再判空:纯字面 \n 还原后是真实换行(whitespace),应在解析层拦截为缺参
123
+ const payload = decodeNewlineEscapes(second.rest);
124
+ if (!payload.trim()) {
125
+ return isMessage
126
+ ? { action: "message-missing-args", missing: "text" }
127
+ : { action: "start-missing-args", missing: "task" };
128
+ }
129
+ return isMessage
130
+ ? { action: "message", recordId: second.token, text: payload }
131
+ : { action: "start", slug: second.token, task: payload };
68
132
  }
69
133
  return { action: "noop" };
70
134
  }
@@ -329,10 +329,13 @@ export interface MessageHandlerInput {
329
329
  interrupt?: boolean;
330
330
  }
331
331
 
332
- /** message 领域对象(adapter 包成 messageResponse)。 */
332
+ /** message 领域对象(adapter 包成 messageResponse)。
333
+ * slug 来自 record(GUI /subagents message 通道的留痕 details 需要,设计 §3.3.3),
334
+ * 避免调用方二次 getRecordForAction 查询。 */
333
335
  export type MessageHandlerResult = {
334
336
  kind: "message";
335
337
  subagentId: string;
338
+ slug: string;
336
339
  response: MessageResponse;
337
340
  };
338
341
 
@@ -393,7 +396,7 @@ export async function messageHandler(
393
396
  `Recovery: use action:'close' to clean up, then action:'start' a new subagent.`,
394
397
  );
395
398
  }
396
- return { kind: "message", subagentId: id, response: { delivered: true } };
399
+ return { kind: "message", subagentId: id, slug: record.slug, response: { delivered: true } };
397
400
  }
398
401
 
399
402
  // ============================================================
@@ -3,16 +3,211 @@
3
3
  // /subagents 命令。薄壳——打开 list overlay(等同原 /subagents list [<id>])。
4
4
  //
5
5
  // 解析:args[0] 直接作可选 <id>(聚焦该 record)。
6
- // RPC 模式(xyz-agent GUI):解析 cancel action 直接执行,不打开 TUI。
6
+ // RPC 模式(xyz-agent GUI):解析 cancel/message/start action 直接执行,不打开 TUI。
7
+ // message/start 为 GUI 定向消息通道(设计 §3.3.3):GUI 经 client.prompt 短路
8
+ // extension 命令(不经主 agent LLM),TUI 分支不消费这两个 verb(行为零变化)。
7
9
 
8
10
  import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
9
11
 
10
12
  import { getSubagentService } from "../execution/subagent-service.ts";
13
+ import type { SubagentService } from "../execution/subagent-service.ts";
11
14
  import { displayAgentName } from "../shared/agent-ref.ts";
15
+ import { messageHandler, startHandler } from "./subagent-actions.ts";
12
16
  import { parseSubagentRpcCommand } from "./command-actions.ts";
17
+ import type { SubagentRpcAction } from "./command-actions.ts";
13
18
  import { LIST_LIMIT } from "./list-shared.ts";
14
19
  import { createSubagentsView } from "./list-view.ts";
15
20
 
21
+ /**
22
+ * subagent-directive custom_message 的 customType。
23
+ *
24
+ * 定向消息留痕载体(设计 §3.3.3):message/start 成功派发后落主 session 的
25
+ * custom_message entry,一 entry 双消费——
26
+ * 1. 主 agent 上下文(custom_message 进 context,主 agent 下次 turn 可见定向对话)
27
+ * 2. renderer 定向气泡渲染源(§3.3.3a live/reload 双链路,后续 wave 消费)
28
+ * 字段形状是 GUI 契约,改动需与 renderer 侧同步。
29
+ */
30
+ export const SUBAGENT_DIRECTIVE_CUSTOM_TYPE = "subagent-directive";
31
+
32
+ /** subagent-directive entry 的 details 形状(GUI 定向气泡渲染契约)。 */
33
+ export interface SubagentDirectiveDetails {
34
+ subagentId: string;
35
+ slug: string;
36
+ /** 消息方向:'user' = 用户 → subagent 定向(当前唯一方向,命名预留双向扩展)。 */
37
+ direction: "user";
38
+ }
39
+
40
+ /**
41
+ * 定向消息留痕:向主 session 落 subagent-directive custom_message entry。
42
+ *
43
+ * 按主 agent streaming 状态分流 sendMessage options。pi 0.84.1 sendCustomMessage
44
+ * 实装(agent-session.js):isStreaming 且无 deliverAs 时默认 agent.steer()——会把
45
+ * 定向消息注入正在运行的主 agent LLM turn,违反「不经主 agent LLM 直达 subagent」。
46
+ * 故按调用时刻的权威 streaming 状态(ctx.isIdle(),与 sendCustomMessage 内部
47
+ * isStreaming 判据精确互补,含 agent_end 后 retry/continuation 窗口)分流:
48
+ * - streaming(isMainAgentIdle=false):传 { deliverAs: "nextTurn" }——消息入
49
+ * pi 内存 _pendingNextTurnMessages 队列,下个 turn 注入主 agent 上下文;不打断、
50
+ * 不 steer 当前 turn。注意:该队列不落 entry,留痕延迟到下个 turn
51
+ * - 非 streaming(isMainAgentIdle=true):不传 options——立即 append entry 留痕
52
+ * + message_start/end 双发(renderer live 链路即时可见,现状行为)
53
+ * 两者都不传 triggerTurn——不产生新 turn(§3.3.8「留痕 ≠ 处理」的结构性保证);
54
+ * display:false 使 pi TUI 不渲染该 entry(GUI 侧由 §3.3.3a 定向气泡通路渲染)。
55
+ */
56
+ function emitSubagentDirective(
57
+ pi: Pick<ExtensionAPI, "sendMessage">,
58
+ details: SubagentDirectiveDetails,
59
+ text: string,
60
+ isMainAgentIdle: boolean,
61
+ ): void {
62
+ pi.sendMessage(
63
+ {
64
+ customType: SUBAGENT_DIRECTIVE_CUSTOM_TYPE,
65
+ content: text,
66
+ display: false,
67
+ details,
68
+ },
69
+ isMainAgentIdle ? undefined : { deliverAs: "nextTurn" },
70
+ );
71
+ }
72
+
73
+ /** RPC cancel 执行体(行为等价拆分自 handler,复杂度治理)。 */
74
+ async function rpcCancel(
75
+ service: SubagentService,
76
+ recordId: string,
77
+ ctx: ExtensionCommandContext,
78
+ ): Promise<void> {
79
+ try {
80
+ const ok = service.cancel(recordId);
81
+ ctx.ui.notify(
82
+ ok ? `Cancelled subagent ${recordId}` : `Subagent ${recordId} not found or already finished`,
83
+ ok ? "info" : "warning",
84
+ );
85
+ } catch (err) {
86
+ // service.cancel 内部 assertReady 在 session_shutdown 并发 dispose 时会抛
87
+ const msg = err instanceof Error ? err.message : String(err);
88
+ ctx.ui.notify(`Failed to cancel subagent ${recordId}: ${msg}`, "warning");
89
+ }
90
+ }
91
+
92
+ /** RPC message 执行体(行为等价拆分自 handler,复杂度治理)。 */
93
+ async function rpcMessage(
94
+ pi: ExtensionAPI,
95
+ service: SubagentService,
96
+ recordId: string,
97
+ text: string,
98
+ ctx: ExtensionCommandContext,
99
+ ): Promise<void> {
100
+ // GUI 定向消息(设计 §3.3.3):不经主 agent LLM 直达 subagent。
101
+ // one-shot 首条 message 自动升级 chatMode 的机制在 messageHandler 内(勿在此重复)。
102
+ try {
103
+ const result = await messageHandler(service, {
104
+ subagentId: recordId,
105
+ text,
106
+ });
107
+ // 留痕(§3.3.3):成功派发后才留痕——失败时不留痕,GUI 按 toast 错误重发。
108
+ // ctx.isIdle() 按调用时刻分流(streaming → nextTurn 队列延迟留痕,见
109
+ // emitSubagentDirective JSDoc),保证任何时刻都不 steer 主 agent 当前 turn
110
+ emitSubagentDirective(
111
+ pi,
112
+ { subagentId: result.subagentId, slug: result.slug, direction: "user" },
113
+ text,
114
+ ctx.isIdle(),
115
+ );
116
+ ctx.ui.notify(`Message delivered to subagent ${result.slug} (${result.subagentId})`, "info");
117
+ } catch (err) {
118
+ const msg = err instanceof Error ? err.message : String(err);
119
+ ctx.ui.notify(`Failed to message subagent ${recordId}: ${msg}`, "warning");
120
+ }
121
+ }
122
+
123
+ /** RPC start 执行体(行为等价拆分自 handler,复杂度治理)。 */
124
+ async function rpcStart(
125
+ pi: ExtensionAPI,
126
+ service: SubagentService,
127
+ slug: string,
128
+ task: string,
129
+ ctx: ExtensionCommandContext,
130
+ ): Promise<void> {
131
+ // GUI 定向新建(设计 §3.3.3):conversation 固定 true(GUI 定向对话场景需要可续聊)
132
+ try {
133
+ const result = await startHandler(
134
+ service,
135
+ {
136
+ slug,
137
+ task,
138
+ conversation: true,
139
+ },
140
+ // RPC 命令无外层 AbortSignal(GUI 请求生命周期不映射到 subagent 取消——
141
+ // start 是 detached 后台语义,取消走 /subagents cancel)
142
+ undefined,
143
+ );
144
+ emitSubagentDirective(
145
+ pi,
146
+ { subagentId: result.subagentId, slug: result.slug, direction: "user" },
147
+ task,
148
+ ctx.isIdle(),
149
+ );
150
+ ctx.ui.notify(`Started subagent ${result.slug} (${result.subagentId})`, "info");
151
+ } catch (err) {
152
+ const msg = err instanceof Error ? err.message : String(err);
153
+ ctx.ui.notify(`Failed to start subagent ${slug}: ${msg}`, "warning");
154
+ }
155
+ }
156
+
157
+ /**
158
+ * RPC 模式(xyz-agent GUI):解析后的 action 分发执行,不打开 TUI。
159
+ * 行为等价拆分自 handler(fallow 圈复杂度 21 > 15):三个执行体
160
+ * (cancel/message/start)各自成函数,本函数只做 switch 分发 +
161
+ * usage notify + exhaustiveness 断言。
162
+ */
163
+ async function executeRpcAction(
164
+ pi: ExtensionAPI,
165
+ service: SubagentService,
166
+ parsed: SubagentRpcAction,
167
+ ctx: ExtensionCommandContext,
168
+ ): Promise<void> {
169
+ switch (parsed.action) {
170
+ case "cancel":
171
+ await rpcCancel(service, parsed.recordId, ctx);
172
+ return;
173
+ case "cancel-missing-id":
174
+ ctx.ui.notify("Usage: /subagents cancel <id>", "warning");
175
+ return;
176
+ case "message":
177
+ await rpcMessage(pi, service, parsed.recordId, parsed.text, ctx);
178
+ return;
179
+ case "message-missing-args":
180
+ // 错误可操作:指明缺什么 + 完整 usage(全局规则 16)
181
+ ctx.ui.notify(
182
+ parsed.missing === "recordId"
183
+ ? "Usage: /subagents message <recordId> <text> — recordId is missing"
184
+ : "Usage: /subagents message <recordId> <text> — text is missing",
185
+ "warning",
186
+ );
187
+ return;
188
+ case "start":
189
+ await rpcStart(pi, service, parsed.slug, parsed.task, ctx);
190
+ return;
191
+ case "start-missing-args":
192
+ ctx.ui.notify(
193
+ parsed.missing === "slug"
194
+ ? "Usage: /subagents start <slug> <task> — slug is missing"
195
+ : "Usage: /subagents start <slug> <task> — task is missing",
196
+ "warning",
197
+ );
198
+ return;
199
+ case "noop":
200
+ // 无 action 或未知 action:GUI 端已屏蔽此 command 入口,此处兜底
201
+ ctx.ui.notify("View subagents in the sidebar Agents tab", "info");
202
+ return;
203
+ default: {
204
+ // exhaustiveness 断言:未来新增 action verb 忘加 case 时 tsc 报错
205
+ const _exhaustive: never = parsed;
206
+ throw new Error(`Unhandled subagent RPC action: ${String(_exhaustive)}`);
207
+ }
208
+ }
209
+ }
210
+
16
211
  /** 注册 /subagents 命令(= list overlay)。 */
17
212
  export function registerSubagentsCommand(pi: ExtensionAPI): void {
18
213
  pi.registerCommand("subagents", {
@@ -59,35 +254,8 @@ export function registerSubagentsCommand(pi: ExtensionAPI): void {
59
254
  // ── RPC 模式(xyz-agent GUI):解析 action 直接执行,不打开 TUI ──
60
255
  // hasUI 在 TUI 和 RPC 都为 true,不能用于区分;用 ctx.mode === "rpc" 判定 GUI 通道。
61
256
  if (ctx.mode === "rpc") {
62
- const parsed = parseSubagentRpcCommand(argsStr);
63
- switch (parsed.action) {
64
- case "cancel": {
65
- try {
66
- const ok = service.cancel(parsed.recordId);
67
- ctx.ui.notify(
68
- ok ? `Cancelled subagent ${parsed.recordId}` : `Subagent ${parsed.recordId} not found or already finished`,
69
- ok ? "info" : "warning",
70
- );
71
- } catch (err) {
72
- // service.cancel 内部 assertReady 在 session_shutdown 并发 dispose 时会抛
73
- const msg = err instanceof Error ? err.message : String(err);
74
- ctx.ui.notify(`Failed to cancel subagent ${parsed.recordId}: ${msg}`, "warning");
75
- }
76
- return;
77
- }
78
- case "cancel-missing-id":
79
- ctx.ui.notify("Usage: /subagents cancel <id>", "warning");
80
- return;
81
- case "noop":
82
- // 无 action 或未知 action:GUI 端已屏蔽此 command 入口,此处兜底
83
- ctx.ui.notify("View subagents in the sidebar Agents tab", "info");
84
- return;
85
- default: {
86
- // exhaustiveness 断言:未来新增 action verb 忘加 case 时 tsc 报错
87
- const _exhaustive: never = parsed;
88
- throw new Error(`Unhandled subagent RPC action: ${String(_exhaustive)}`);
89
- }
90
- }
257
+ await executeRpcAction(pi, service, parseSubagentRpcCommand(argsStr), ctx);
258
+ return;
91
259
  }
92
260
 
93
261
  // ── print/json 模式(headless):不可交互 ──
@@ -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
  }