@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.
@@ -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
+ });