@zhushanwen/pi-subagent-workflow 8.1.1 → 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 +6 -5
- package/src/execution/__tests__/__fixtures__/notifier-golden-snapshots.json +53 -0
- package/src/execution/__tests__/notifier-flush.test.ts +193 -7
- package/src/execution/__tests__/notifier-golden-snapshot.test.ts +261 -0
- package/src/execution/__tests__/status-refactor.test.ts +5 -5
- package/src/execution/__tests__/ui-request-handler-factory.test.ts +211 -7
- package/src/execution/notifier.ts +182 -222
- package/src/execution/session-pending.ts +4 -1
- package/src/execution/subagent-service.ts +11 -4
- package/src/execution/ui-request-handler-factory.ts +40 -3
- package/src/index.ts +3 -0
- package/src/injectors/__tests__/helpers/injector-test-mocks.ts +104 -0
- package/src/injectors/__tests__/model-list-injector.test.ts +139 -0
- package/src/injectors/__tests__/subagent-list-injector.test.ts +59 -69
- package/src/injectors/__tests__/workflow-list-injector.test.ts +57 -67
- package/src/injectors/model-list-injector.ts +138 -0
- package/src/injectors/subagent-list-injector.ts +33 -24
- package/src/injectors/workflow-list-injector.ts +16 -25
- package/src/orchestration/config-loader.ts +5 -1
- package/src/orchestration/models/types.ts +1 -1
- package/src/shared/__tests__/resource-discovery.test.ts +276 -5
- package/src/shared/resource-discovery.ts +93 -6
- package/src/shared/xml-injection.ts +35 -0
|
@@ -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
|
+
});
|