@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 +4 -4
- package/src/execution/__tests__/ui-request-handler-factory.test.ts +211 -7
- package/src/execution/session-pending.ts +4 -1
- package/src/execution/ui-request-handler-factory.ts +40 -3
- package/src/shared/__tests__/resource-discovery.test.ts +197 -5
- package/src/shared/resource-discovery.ts +55 -6
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zhushanwen/pi-subagent-workflow",
|
|
3
|
-
"version": "8.
|
|
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.
|
|
53
|
-
"@zhushanwen/pi-file-lock": "0.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.
|
|
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(
|
|
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
|
|
103
|
-
|
|
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({
|
|
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(
|
|
182
|
+
it("channel 未命中 → defaultDialogForward(fire-and-forget 走 ack)", async () => {
|
|
183
|
+
const ctx = makeCtxWithUi("rpc");
|
|
139
184
|
const handler = createUiRequestHandlerForMode(
|
|
140
|
-
|
|
185
|
+
ctx, createUiChannelRegistry(), new DialogGlobalQueue())!;
|
|
141
186
|
const resp = await handler({ method: "notify", id: "f1", message: "m", channel: "unknown" });
|
|
142
|
-
|
|
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
|
-
|
|
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
|
-
// 未知
|
|
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
|
|
206
|
+
"[subagents] defaultDialogForward: unknown method",
|
|
170
207
|
{ detail: { method: req.method, id: req.id } },
|
|
171
208
|
);
|
|
172
|
-
return {
|
|
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: () =>
|
|
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:
|
|
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
|
-
//
|
|
408
|
-
expect(warnSpy).
|
|
409
|
-
|
|
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]
|
|
583
|
-
//
|
|
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
|
-
|
|
586
|
-
`[resource-discovery] duplicate ${config.kind} "${key}" from ${r.source} shadows ${existing.source}
|
|
587
|
-
|
|
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
|
}
|