@zhushanwen/pi-subagent-workflow 0.1.0 → 0.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.
Files changed (130) hide show
  1. package/README.md +56 -0
  2. package/agents/context-builder.md +1 -3
  3. package/agents/explorer.md +27 -0
  4. package/agents/oracle.md +2 -2
  5. package/agents/orchestrator.md +48 -0
  6. package/agents/planner.md +1 -3
  7. package/agents/researcher.md +0 -2
  8. package/agents/reviewer.md +2 -2
  9. package/agents/worker.md +0 -2
  10. package/package.json +5 -3
  11. package/skills/workflow-script-format/SKILL.md +6 -6
  12. package/src/execution/__tests__/agent-registry.test.ts +3 -3
  13. package/src/execution/__tests__/agent-result-mapper.test.ts +24 -2
  14. package/src/execution/__tests__/ask-user-transit-e2e.test.ts +484 -0
  15. package/src/execution/__tests__/channel-registry-handshake.test.ts +233 -0
  16. package/src/execution/__tests__/concurrency-pool.test.ts +33 -0
  17. package/src/execution/__tests__/crash-recovery.test.ts +5 -1
  18. package/src/execution/__tests__/dialog-queue.test.ts +299 -0
  19. package/src/execution/__tests__/execute-nesting.test.ts +1 -1
  20. package/src/execution/__tests__/execute-options-mapper.test.ts +41 -9
  21. package/src/execution/__tests__/finalize-record.test.ts +173 -0
  22. package/src/execution/__tests__/gui-mode-dispatch.test.ts +59 -0
  23. package/src/execution/__tests__/helpers/spawn-mock.ts +209 -0
  24. package/src/execution/__tests__/host-mode.test.ts +87 -0
  25. package/src/execution/__tests__/index-session-start.test.ts +342 -0
  26. package/src/execution/__tests__/list-component.test.ts +1 -1
  27. package/src/execution/__tests__/notifier-flush.test.ts +78 -0
  28. package/src/execution/__tests__/path-encoding.test.ts +30 -1
  29. package/src/execution/__tests__/record-store.test.ts +86 -2
  30. package/src/execution/__tests__/records-cwd-isolation.test.ts +91 -0
  31. package/src/execution/__tests__/rpc-mode.test.ts +89 -0
  32. package/src/execution/__tests__/run-spawn-edges.test.ts +157 -153
  33. package/src/execution/__tests__/run-spawn-integration.test.ts +85 -151
  34. package/src/execution/__tests__/run-spawn-rpc-mode.test.ts +193 -0
  35. package/src/execution/__tests__/sdk-contract.test.ts +5 -2
  36. package/src/execution/__tests__/session-file-gc.test.ts +46 -0
  37. package/src/execution/__tests__/session-reconstructor.test.ts +20 -0
  38. package/src/execution/__tests__/session-start-reaper.test.ts +7 -1
  39. package/src/execution/__tests__/spawn-args.test.ts +14 -19
  40. package/src/execution/__tests__/spawn-event-adapter-rpc.test.ts +189 -0
  41. package/src/execution/__tests__/stdin-writer.test.ts +353 -0
  42. package/src/execution/__tests__/subagent-service-abort.test.ts +60 -0
  43. package/src/execution/__tests__/subagent-service.test.ts +73 -3
  44. package/src/execution/__tests__/subprocess-agent-runner.test.ts +72 -3
  45. package/src/execution/__tests__/tool-action.test.ts +27 -5
  46. package/src/execution/__tests__/ui-channels.test.ts +187 -0
  47. package/src/execution/__tests__/ui-interaction-model.test.ts +67 -0
  48. package/src/execution/__tests__/ui-request-handler-factory.test.ts +166 -0
  49. package/src/execution/__tests__/ui-request-handler.test.ts +204 -0
  50. package/src/execution/__tests__/ui-request-observability.test.ts +101 -0
  51. package/src/execution/__tests__/ui-request-queue.test.ts +133 -0
  52. package/src/execution/__tests__/worktree-manager.test.ts +1 -1
  53. package/src/execution/agent-registry.ts +1 -1
  54. package/src/execution/agent-result-mapper.ts +4 -1
  55. package/src/execution/channel-registry-access.ts +138 -0
  56. package/src/execution/concurrency-pool.ts +38 -6
  57. package/src/execution/dialog-queue.ts +329 -0
  58. package/src/execution/execute-options-mapper.ts +21 -4
  59. package/src/execution/execution-record.ts +5 -0
  60. package/src/execution/finalize-record.ts +160 -0
  61. package/src/execution/get-state-handshake.ts +104 -0
  62. package/src/execution/host-mode.ts +52 -0
  63. package/src/execution/manifest-store.ts +206 -0
  64. package/src/execution/notifier.ts +5 -1
  65. package/src/execution/path-encoding.ts +18 -0
  66. package/src/execution/pi-invocation.ts +1 -1
  67. package/src/execution/record-store.ts +110 -2
  68. package/src/execution/session-file-gc.ts +25 -3
  69. package/src/execution/session-reconstructor.ts +11 -0
  70. package/src/execution/session-runner.ts +228 -32
  71. package/src/execution/spawn-event-adapter.ts +219 -6
  72. package/src/execution/stdin-writer.ts +106 -0
  73. package/src/execution/stream-sink.ts +83 -0
  74. package/src/execution/subagent-service.ts +230 -235
  75. package/src/execution/subprocess-agent-runner.ts +16 -4
  76. package/src/execution/types.ts +23 -3
  77. package/src/execution/ui-channels.ts +216 -0
  78. package/src/execution/ui-interaction-model.ts +48 -0
  79. package/src/execution/ui-request-handler-factory.ts +175 -0
  80. package/src/execution/ui-request-observability.ts +77 -0
  81. package/src/execution/ui-request-queue.ts +168 -0
  82. package/src/index.ts +101 -4
  83. package/src/interface/__tests__/subagent-tool-prompt.test.ts +84 -0
  84. package/src/interface/__tests__/workflow-state-file-exposure.test.ts +38 -0
  85. package/src/interface/__tests__/workflow-tool-prompt.test.ts +50 -0
  86. package/src/interface/command-actions.ts +77 -0
  87. package/src/interface/commands.ts +40 -4
  88. package/src/interface/format.ts +2 -0
  89. package/src/interface/gui-mappers.ts +83 -0
  90. package/src/interface/helpers.ts +52 -9
  91. package/src/interface/list-component.ts +3 -1
  92. package/src/interface/subagent-actions.ts +44 -24
  93. package/src/interface/subagent-tool.ts +56 -24
  94. package/src/interface/subagents.ts +45 -5
  95. package/src/interface/tool-render.ts +16 -5
  96. package/src/interface/tool-workflow-script.ts +113 -15
  97. package/src/interface/tool-workflow.ts +92 -34
  98. package/src/interface/views/WorkflowsView.ts +13 -4
  99. package/src/interface/views/__tests__/detail-content-session-file.test.ts +70 -0
  100. package/src/interface/views/detail-content.ts +20 -0
  101. package/src/orchestration/__tests__/agent-call-catch-fallback.test.ts +208 -0
  102. package/src/orchestration/__tests__/agent-call-stream.test.ts +157 -0
  103. package/src/orchestration/__tests__/error-recovery-handlers.test.ts +2 -0
  104. package/src/orchestration/__tests__/execute-agent-call.test.ts +171 -0
  105. package/src/orchestration/__tests__/jsonl-run-store-session-file.test.ts +177 -0
  106. package/src/orchestration/__tests__/worker-script-builder.test.ts +15 -0
  107. package/src/orchestration/agent-opts-resolver.ts +11 -2
  108. package/src/orchestration/error-recovery.ts +131 -23
  109. package/src/orchestration/execute-agent-call.ts +12 -3
  110. package/src/orchestration/jsonl-run-store.ts +10 -0
  111. package/src/orchestration/lifecycle.ts +1 -1
  112. package/src/orchestration/models/agent-call.ts +7 -0
  113. package/src/orchestration/models/ports.ts +15 -2
  114. package/src/orchestration/models/run-spec.ts +6 -0
  115. package/src/orchestration/models/trace.ts +1 -0
  116. package/src/orchestration/models/types.ts +19 -0
  117. package/src/orchestration/node-ops.ts +2 -0
  118. package/src/orchestration/worker-script-builder.ts +1 -0
  119. package/workflows/README.md +58 -0
  120. package/workflows/chain.js +107 -0
  121. package/workflows/map-reduce.js +142 -0
  122. package/workflows/parallel.js +131 -0
  123. package/workflows/scatter-gather.js +146 -0
  124. package/agents/scout.md +0 -17
  125. package/examples/README.md +0 -43
  126. package/examples/chain.example.js +0 -92
  127. package/examples/map-reduce.example.js +0 -99
  128. package/examples/parallel.example.js +0 -82
  129. package/examples/scatter-gather.example.js +0 -106
  130. package/src/interface/gui-adapter.ts +0 -136
@@ -0,0 +1,60 @@
1
+ // src/__tests__/subagent-service-abort.test.ts
2
+ //
3
+ // S4: subagent-service abort 终态一致性测试。
4
+ //
5
+ // 审查发现:排队中被 abort(pool.acquire reject)走 finalizeFailed(status=failed),
6
+ // 而已运行中被 abort 走 cancelled。同一用户 abort 因时机不同产生两种终态。
7
+ //
8
+ // 修复后:pool reject 的 error 带 name="AbortError",runAndFinalize catch 判断
9
+ // signal?.aborted 走 cancelled 路径。
10
+ //
11
+ // 本文件覆盖两层:
12
+ // 1. pool 层:acquire reject 的 error.name 是 "AbortError"(S1 前半)
13
+ // 2. pool 层:pre-aborted signal 也带 AbortError name(S1 另一处)
14
+
15
+ import { describe, expect, it } from "vitest";
16
+
17
+ import { DefaultConcurrencyPool } from "../concurrency-pool.ts";
18
+
19
+ describe("S1: ConcurrencyPool abort reject produces AbortError name", () => {
20
+ it("queued acquire aborted via signal rejects with error.name=AbortError", async () => {
21
+ const pool = new DefaultConcurrencyPool(1);
22
+ await pool.acquire(0); // 占满
23
+
24
+ const controller = new AbortController();
25
+ const queued = pool.acquire(0, undefined, controller.signal);
26
+
27
+ await new Promise((r) => setTimeout(r, 5));
28
+ expect(pool.active).toBe(1); // 仍在排队
29
+
30
+ controller.abort();
31
+
32
+ try {
33
+ await queued;
34
+ expect.unreachable("should have rejected");
35
+ } catch (err) {
36
+ expect(err).toBeInstanceOf(Error);
37
+ expect((err as Error).name).toBe("AbortError");
38
+ expect((err as Error).message).toContain("abort");
39
+ }
40
+ pool.release();
41
+ });
42
+
43
+ it("pre-aborted signal rejects immediately with error.name=AbortError", async () => {
44
+ const pool = new DefaultConcurrencyPool(1);
45
+ await pool.acquire(0); // 占满
46
+
47
+ const controller = new AbortController();
48
+ controller.abort(); // 先 abort 再 acquire
49
+
50
+ try {
51
+ await pool.acquire(0, undefined, controller.signal);
52
+ expect.unreachable("should have rejected");
53
+ } catch (err) {
54
+ expect(err).toBeInstanceOf(Error);
55
+ expect((err as Error).name).toBe("AbortError");
56
+ expect((err as Error).message).toContain("abort");
57
+ }
58
+ pool.release();
59
+ });
60
+ });
@@ -18,9 +18,10 @@ import * as path from "node:path";
18
18
  import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
19
19
 
20
20
  import { createRecord } from "../execution-record.ts";
21
+ import { ModelConfigService } from "../model-config-service.ts";
21
22
  import type { ModelInfo, ModelRegistryLike } from "../model-resolver.ts";
22
23
  import type { RecordStore } from "../record-store.ts";
23
- import { ModelConfigService } from "../model-config-service.ts";
24
+ import type { UiRequest, UiRequestHandler } from "../dialog-queue.ts";
24
25
  import type { PiLike } from "../subagent-service.ts";
25
26
  import { getSubagentService, setSubagentService,SubagentService } from "../subagent-service.ts";
26
27
  import type { ExecutionRecord } from "../types.ts";
@@ -314,6 +315,75 @@ describe("SubagentService", () => {
314
315
  });
315
316
  });
316
317
 
318
+ // ============================================================
319
+ // dispose uiRequestHandler stub(dispose-cleanup Minor 优化1)
320
+ // ============================================================
321
+ //
322
+ // [背景] Pi 单进程 session 串行接管。session A shutdown 发 SIGTERM 后、子进程彻底
323
+ // close 前(graceful shutdown 窗口几十~几百 ms),子进程的 trailing extension_ui_request
324
+ // 仍会被父进程 pump 解析,调到 A 的 handler 闭包(仍持有 A 的 ctx)。旧实现 dispose 不清
325
+ // uiRequestHandler,该闭包触发 ui-request-queue.ts catch 分支打 `[subagents] uiRequestHandler
326
+ // threw` 误导性 console.error。dispose 现注入 stub(始终返回 {cancelled:true}),让 trailing
327
+ // ui_request 干净降级,不再走旧 handler 闭包。
328
+ //
329
+ // 被测点是 SubagentService.dispose 内的 setUiRequestHandler(stub)。uiRequestHandler 是
330
+ // private 字段,用 Reflect.get 取(与 R0-D 块访问 store 同模式)。
331
+
332
+ describe("dispose uiRequestHandler stub (dispose-cleanup)", () => {
333
+ /** 从 service 取出 private uiRequestHandler(trailing ui_request 实际调用入口)。 */
334
+ function getHandler(service: SubagentService): UiRequestHandler | undefined {
335
+ return Reflect.get(service, "uiRequestHandler") as UiRequestHandler | undefined;
336
+ }
337
+
338
+ /** 最小 UiRequest(method 无关紧要——stub 不论 method 一律返回 cancelled)。 */
339
+ function makeTrailingRequest(id: string): UiRequest {
340
+ return { method: "notify", id };
341
+ }
342
+
343
+ it("dispose 后 uiRequestHandler 被 stub 替换,调用返回 {cancelled:true} 不抛错", async () => {
344
+ const service = new SubagentService({ cwd: agentDir, modelService });
345
+ service.initSession({ pi: makePi(), sessionId: "s1" });
346
+
347
+ service.dispose();
348
+
349
+ const handler = getHandler(service);
350
+ expect(handler).toBeDefined();
351
+ // 模拟 dispose 后子进程 trailing ui_request 被父进程 pump 调到 handler
352
+ await expect(handler!(makeTrailingRequest("trailing-1"))).resolves.toEqual({ cancelled: true });
353
+ });
354
+
355
+ it("dispose stub 覆盖旧 handler:trailing ui_request 不产生 [subagents] console.error 噪声", async () => {
356
+ // 模拟 session A 的真实 handler 闭包——在 ctx 已 disposed 后调用会抛错
357
+ const staleHandler: UiRequestHandler = async () => {
358
+ throw new Error("stale ctx");
359
+ };
360
+ const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
361
+
362
+ const service = new SubagentService({ cwd: agentDir, modelService });
363
+ service.initSession({ pi: makePi(), sessionId: "s1" });
364
+ service.setUiRequestHandler(staleHandler);
365
+
366
+ service.dispose();
367
+
368
+ // dispose 后 handler 应被 stub 替换,不再是 staleHandler
369
+ const handler = getHandler(service);
370
+ expect(handler).not.toBe(staleHandler);
371
+
372
+ // trailing ui_request 调到的是 stub,不抛错、返回 cancelled
373
+ await expect(handler!(makeTrailingRequest("trailing-2"))).resolves.toEqual({ cancelled: true });
374
+
375
+ // 关键价值断言:无 "[subagents] uiRequestHandler threw" 误导性噪声
376
+ // (旧实现 dispose 不清 handler 时,trailing ui_request 走 staleHandler 抛错 →
377
+ // ui-request-queue.ts catch 打该日志。stub 覆盖后该路径不再触发。)
378
+ const subagentsErrors = errorSpy.mock.calls.filter(
379
+ (args: unknown[]) => typeof args[0] === "string" && args[0].includes("[subagents]"),
380
+ );
381
+ expect(subagentsErrors).toHaveLength(0);
382
+
383
+ errorSpy.mockRestore();
384
+ });
385
+ });
386
+
317
387
  // ============================================================
318
388
  // execute() worktree fail-fast 校验 [MF#7](commit 8e8e75966)
319
389
  // ============================================================
@@ -488,12 +558,12 @@ describe("SubagentService", () => {
488
558
  // worktree create 抛错在 kickOffBackground 之前(execute 同步 catch),返回 background 形状
489
559
  expect(handle.mode).toBe("background");
490
560
 
491
- // register emit:background mode → id 前缀 bg-
561
+ // register emit:background mode → id UUID 格式
492
562
  expect(pi.events.emit).toHaveBeenCalledWith(
493
563
  "pending:register",
494
564
  expect.objectContaining({
495
565
  type: "subagent",
496
- id: expect.stringMatching(/^bg-/),
566
+ id: expect.any(String),
497
567
  name: "general-purpose",
498
568
  }),
499
569
  );
@@ -15,9 +15,9 @@
15
15
 
16
16
  import { describe, expect, it, vi } from "vitest";
17
17
 
18
- import { SubprocessAgentRunner } from "../subprocess-agent-runner.ts";
19
- import type { SubprocessAgentRunnerDeps } from "../subprocess-agent-runner.ts";
20
18
  import type { AgentCallOpts, AgentResult } from "../../orchestration/models/types.ts";
19
+ import type { SubprocessAgentRunnerDeps } from "../subprocess-agent-runner.ts";
20
+ import { SubprocessAgentRunner } from "../subprocess-agent-runner.ts";
21
21
 
22
22
  // ── 测试辅助 ──
23
23
 
@@ -42,6 +42,7 @@ function createMockService(impl?: typeof vi.fn) {
42
42
  opts: Record<string, unknown>,
43
43
  signal?: AbortSignal,
44
44
  onEvent?: (e: Record<string, unknown>) => void,
45
+ stream?: unknown,
45
46
  ) => Promise<AgentResult>;
46
47
  };
47
48
  }
@@ -175,7 +176,37 @@ describe("SubprocessAgentRunner (wave-4 delegate)", () => {
175
176
  const opts = { ...makeBaseOpts(), model: undefined };
176
177
  await sar.run(opts, new AbortController().signal);
177
178
 
178
- expect(capturedOpts!.model).toBe("ctx-model");
179
+ // 修复后:opts.model 不再从 ctxModel.id 填底,ctxModel 作为完整对象透传
180
+ expect(capturedOpts!.model).toBeUndefined();
181
+ expect(capturedOpts!.ctxModel).toBe(ctxModel);
182
+ });
183
+ });
184
+
185
+ // ────────────────────────────────────────────────
186
+ // H1: SAR ctxModel 刷新(model_select 后不再 stale)
187
+ // ────────────────────────────────────────────────
188
+ describe("H1 ctxModel refresh via updateCtxModel", () => {
189
+ it("updateCtxModel 后 run() 传入新的 ctxModel 而非旧值", async () => {
190
+ let capturedOpts: Record<string, unknown> | undefined;
191
+ const mockService = createMockService(
192
+ vi.fn().mockImplementation((opts: Record<string, unknown>) => {
193
+ capturedOpts = opts;
194
+ return Promise.resolve(makeMockResult());
195
+ }),
196
+ );
197
+ const oldModel = { id: "old-model", provider: "test", input: [] };
198
+ const newModel = { id: "new-model", provider: "test", input: [] };
199
+ const deps: SubprocessAgentRunnerDeps = { subagentService: mockService, ctxModel: oldModel };
200
+ const sar = new SubprocessAgentRunner(deps);
201
+
202
+ // 模拟 model_select:刷新 ctxModel
203
+ sar.updateCtxModel(newModel);
204
+
205
+ const opts = { ...makeBaseOpts(), model: undefined };
206
+ await sar.run(opts, new AbortController().signal);
207
+
208
+ expect(capturedOpts!.ctxModel).toBe(newModel);
209
+ expect(capturedOpts!.ctxModel).not.toBe(oldModel);
179
210
  });
180
211
  });
181
212
 
@@ -386,4 +417,42 @@ describe("SubprocessAgentRunner (wave-4 delegate)", () => {
386
417
  expect(result.error).toBe("raw string error");
387
418
  });
388
419
  });
420
+
421
+ // ────────────────────────────────────────────────
422
+ // U1: stream 透传给 executeAndAwait
423
+ // ────────────────────────────────────────────────
424
+ describe("U1 stream 透传", () => {
425
+ it("SAR.run 传 stream → executeAndAwait 第 4 参收到同一 stream 对象", async () => {
426
+ let capturedStream: unknown;
427
+ const mockService = createMockService(
428
+ vi.fn().mockImplementation((_opts, _sig, _onEvt, stream) => {
429
+ capturedStream = stream;
430
+ return Promise.resolve(makeMockResult());
431
+ }),
432
+ );
433
+ const deps: SubprocessAgentRunnerDeps = { subagentService: mockService };
434
+ const sar = new SubprocessAgentRunner(deps);
435
+
436
+ const fakeStream = { onDelta: vi.fn(), dispose: vi.fn() };
437
+ await sar.run(makeBaseOpts(), new AbortController().signal, undefined, fakeStream as never);
438
+
439
+ expect(capturedStream).toBe(fakeStream);
440
+ });
441
+
442
+ it("SAR.run 不传 stream → executeAndAwait 第 4 参为 undefined", async () => {
443
+ let capturedStream: unknown = "sentinel";
444
+ const mockService = createMockService(
445
+ vi.fn().mockImplementation((_opts, _sig, _onEvt, stream) => {
446
+ capturedStream = stream;
447
+ return Promise.resolve(makeMockResult());
448
+ }),
449
+ );
450
+ const deps: SubprocessAgentRunnerDeps = { subagentService: mockService };
451
+ const sar = new SubprocessAgentRunner(deps);
452
+
453
+ await sar.run(makeBaseOpts(), new AbortController().signal);
454
+
455
+ expect(capturedStream).toBeUndefined();
456
+ });
457
+ });
389
458
  });
@@ -5,8 +5,8 @@
5
5
 
6
6
  import { describe, expect, it, vi } from "vitest";
7
7
 
8
- import type { SubagentService } from "../subagent-service.ts";
9
8
  import { adapter, cancelHandler, listHandler, startHandler } from "../../interface/subagent-actions.ts";
9
+ import type { SubagentService } from "../subagent-service.ts";
10
10
  import type {
11
11
  ExecutionHandle,
12
12
  RecordSnapshot,
@@ -23,6 +23,7 @@ function makeDetails(over: Partial<SubagentToolDetails> = {}): SubagentToolDetai
23
23
  agent: "worker",
24
24
  model: "test/model",
25
25
  thinkingLevel: undefined,
26
+ slug: "test-slug",
26
27
  turns: 1,
27
28
  totalTokens: 10,
28
29
  elapsedSeconds: 1,
@@ -40,6 +41,7 @@ function makeSnapshot(over: Partial<RecordSnapshot> = {}): RecordSnapshot {
40
41
  thinkingLevel: undefined,
41
42
  mode: "background",
42
43
  task: "t",
44
+ slug: "test-slug",
43
45
  status: "done",
44
46
  eventLog: [],
45
47
  turns: 1,
@@ -74,7 +76,24 @@ describe("startHandler", () => {
74
76
 
75
77
  it("task 空白 → throw", async () => {
76
78
  const svc = makeService();
77
- await expect(startHandler(svc, { task: " " }, undefined)).rejects.toThrow(/task is required/);
79
+ await expect(startHandler(svc, { task: " ", slug: "x" }, undefined)).rejects.toThrow(/task is required/);
80
+ });
81
+
82
+ it("slug 缺失 → throw", async () => {
83
+ const svc = makeService();
84
+ await expect(startHandler(svc, { task: "ok" }, undefined)).rejects.toThrow(/slug is required/);
85
+ });
86
+
87
+ it("slug 空白 → throw", async () => {
88
+ const svc = makeService();
89
+ await expect(startHandler(svc, { task: "ok", slug: " " }, undefined)).rejects.toThrow(/slug is required/);
90
+ });
91
+
92
+ it("slug 超 20 字符 → throw", async () => {
93
+ const svc = makeService();
94
+ await expect(
95
+ startHandler(svc, { task: "ok", slug: "a".repeat(21) }, undefined),
96
+ ).rejects.toThrow(/≤20 chars/);
78
97
  });
79
98
 
80
99
  it("background 启动 → kind=bg + bgResponse.message 含 detached", async () => {
@@ -86,7 +105,7 @@ describe("startHandler", () => {
86
105
  details: makeDetails({ status: "running", mode: "background" }),
87
106
  })),
88
107
  });
89
- const r = await startHandler(svc, { task: "long" }, undefined);
108
+ const r = await startHandler(svc, { task: "long", slug: "long-running" }, undefined);
90
109
  expect(r.kind).toBe("bg");
91
110
  if (r.kind !== "bg") return;
92
111
  expect(r.subagentId).toBe("bg-1-123");
@@ -297,11 +316,11 @@ describe("cancelHandler", () => {
297
316
  // adapter
298
317
  // ============================================================
299
318
  describe("adapter", () => {
300
- it("start bg → SubagentToolResult.bgResponse + content 合法 JSON(C3 回归)", () => {
319
+ it("start bg → SubagentToolResult.bgResponse + slug 透传 + content 合法 JSON(C3 回归)", () => {
301
320
  const r = adapter({
302
321
  action: "start",
303
322
  domain: {
304
- kind: "bg", subagentId: "bg-1", sessionFile: undefined,
323
+ kind: "bg", subagentId: "bg-1", sessionFile: undefined, slug: "extract-urls",
305
324
  response: { status: "running", mode: "background", message: "detached, will notify on completion" },
306
325
  },
307
326
  });
@@ -309,9 +328,12 @@ describe("adapter", () => {
309
328
  expect(r.details.subagentId).toBe("bg-1");
310
329
  expect(r.details.bgResponse).toBeDefined();
311
330
  expect(r.details.sessionFile).toBeNull();
331
+ // slug 透传到 result(renderResult 的 background 行展示用)
332
+ expect(r.details.slug).toBe("extract-urls");
312
333
  // content JSON round-trip(bg 序列化回归)
313
334
  const parsed = JSON.parse(r.content[0]!.text);
314
335
  expect(parsed.bgResponse.message).toMatch(/detached/);
336
+ expect(parsed.slug).toBe("extract-urls");
315
337
  });
316
338
 
317
339
  it("list → 最外层 subagentId/sessionFile 为 null", () => {
@@ -0,0 +1,187 @@
1
+ // src/__tests__/ui-channels.test.ts
2
+ //
3
+ // W1 红灯测试:ui-channels.ts — channel 提取(marker 解析)+ channel 注册表。
4
+ //
5
+ // 测试对象:extensions/subagent-workflow/src/execution/ui-channels.ts(新建)
6
+ // 契约来源:.fix-plans/00-master-summary.md §一 冲突 2「维度 2:channel 注册表」
7
+ //
8
+ // parseChannel(req): ExtensionUiRequest → { channel?, channelPayload? }
9
+ // - select → 从 title 解析 NUL 前缀(parseFromMarkerString)
10
+ // - setWidget → 从 widgetLines[0] 解析 NUL 前缀(parseFromMarkerArray)
11
+ // - 其他 method → {}(无 channel)
12
+ //
13
+ // marker 格式:\0<UPPER_CASE_ID>[:]<JSON-payload?>
14
+ // - ASK_USER_MARKER = "\0XYZ_ASK_USER",payload 在 options[0]
15
+ // - GUI_WIDGET_MARKER = "\0XYZ_GUI_WIDGET:" + JSON,payload 在同行
16
+ // channel 名规范化:去 "XYZ_" 前缀,小写化(XYZ_ASK_USER → ask_user)
17
+ //
18
+ // UiChannelRegistry:
19
+ // - register(channel, handler) / resolve(channel) / list()
20
+ //
21
+ // 边界:无 NUL 前缀 → {};JSON parse 失败 → {}(不抛);字段缺失 → {}
22
+ //
23
+ // 红灯原因:ui-channels.ts 尚未创建,import 失败。
24
+
25
+ import { describe, expect, it, vi } from "vitest";
26
+
27
+ import {
28
+ createUiChannelRegistry,
29
+ type ExtensionUiRequestLike,
30
+ parseChannel,
31
+ } from "../ui-channels.ts";
32
+
33
+ // ── 测试 fixture 构造助手 ────────────────────────────────────
34
+ // ExtensionUiRequestLike 是 parseChannel 入参的最小形状(method + 对应字段)。
35
+
36
+ function selectReq(title: string, options?: string[]): ExtensionUiRequestLike {
37
+ return { method: "select", title, options };
38
+ }
39
+
40
+ function setWidgetReq(widgetKey: string, widgetLines: string[] | undefined): ExtensionUiRequestLike {
41
+ return { method: "setWidget", widgetKey, widgetLines };
42
+ }
43
+
44
+ // ── ASK_USER_MARKER 样本(真实 Pi 协议格式) ─────────────────
45
+ // title = "\0XYZ_ASK_USER",options[0] = JSON.stringify({questions, allowCancel})
46
+ const ASK_USER_MARKER = "\0XYZ_ASK_USER";
47
+ const askUserPayload = {
48
+ questions: [{ question: "Pick one", options: [{ label: "A" }, { label: "B" }] }],
49
+ allowCancel: true,
50
+ };
51
+
52
+ // ── GUI_WIDGET_MARKER 样本(真实 Pi 协议格式) ───────────────
53
+ // widgetLines[0] = "\0XYZ_GUI_WIDGET:" + JSON.stringify({component:{...}})
54
+ const GUI_WIDGET_MARKER_PREFIX = "\0XYZ_GUI_WIDGET:";
55
+ const guiWidgetPayload = { component: { name: "StatusCard", props: { ok: true } } };
56
+
57
+ describe("parseChannel — select method(从 title 解析 NUL 前缀)", () => {
58
+ it("title 含 ASK_USER_MARKER → channel='ask_user' + payload 从 options[0] 解析", () => {
59
+ const req = selectReq(ASK_USER_MARKER, [JSON.stringify(askUserPayload)]);
60
+ const result = parseChannel(req);
61
+ expect(result.channel).toBe("ask_user");
62
+ expect(result.channelPayload).toEqual(askUserPayload);
63
+ });
64
+
65
+ it("title 无 NUL 前缀 → {}(普通 select,无 channel)", () => {
66
+ const req = selectReq("Choose a plan", ["basic", "pro"]);
67
+ const result = parseChannel(req);
68
+ expect(result.channel).toBeUndefined();
69
+ expect(result.channelPayload).toBeUndefined();
70
+ });
71
+
72
+ it("title 含 marker 但 options 缺失 → channel 解析但 payload undefined", () => {
73
+ // marker 在 title,但 options 为空 → channel 名仍可提取,payload 无来源
74
+ const req = selectReq(ASK_USER_MARKER);
75
+ const result = parseChannel(req);
76
+ expect(result.channel).toBe("ask_user");
77
+ });
78
+ });
79
+
80
+ describe("parseChannel — setWidget method(从 widgetLines[0] 解析 NUL 前缀)", () => {
81
+ it("widgetLines[0] 含 GUI_WIDGET_MARKER → channel='gui_widget' + payload", () => {
82
+ const req = setWidgetReq("w1", [GUI_WIDGET_MARKER_PREFIX + JSON.stringify(guiWidgetPayload)]);
83
+ const result = parseChannel(req);
84
+ expect(result.channel).toBe("gui_widget");
85
+ expect(result.channelPayload).toEqual(guiWidgetPayload);
86
+ });
87
+
88
+ it("widgetLines 无 NUL 前缀 → {}(普通 setWidget)", () => {
89
+ const req = setWidgetReq("w1", ["plain content line"]);
90
+ const result = parseChannel(req);
91
+ expect(result.channel).toBeUndefined();
92
+ });
93
+
94
+ it("widgetLines 为 undefined → {}", () => {
95
+ const req = setWidgetReq("w1", undefined);
96
+ const result = parseChannel(req);
97
+ expect(result.channel).toBeUndefined();
98
+ });
99
+
100
+ it("widgetLines 为空数组 → {}", () => {
101
+ const req = setWidgetReq("w1", []);
102
+ const result = parseChannel(req);
103
+ expect(result.channel).toBeUndefined();
104
+ });
105
+ });
106
+
107
+ describe("parseChannel — 其他 method(无 channel 提取位置)", () => {
108
+ it("confirm → {}", () => {
109
+ const result = parseChannel({ method: "confirm", title: "t", message: "m" });
110
+ expect(result.channel).toBeUndefined();
111
+ });
112
+
113
+ it("input → {}", () => {
114
+ const result = parseChannel({ method: "input", title: "t" });
115
+ expect(result.channel).toBeUndefined();
116
+ });
117
+
118
+ it("notify → {}", () => {
119
+ const result = parseChannel({ method: "notify", message: "hi" });
120
+ expect(result.channel).toBeUndefined();
121
+ });
122
+ });
123
+
124
+ describe("parseChannel — channel 名规范化规则", () => {
125
+ it("去 XYZ_ 命名空间前缀 + 小写化(XYZ_ASK_USER → ask_user)", () => {
126
+ const req = selectReq("\0XYZ_ASK_USER", [JSON.stringify({ x: 1 })]);
127
+ expect(parseChannel(req).channel).toBe("ask_user");
128
+ });
129
+
130
+ it("XYZ_GUI_WIDGET: 带冒号后缀 → gui_widget(去前缀 + 去冒号 + 小写)", () => {
131
+ const req = setWidgetReq("k", ["\0XYZ_GUI_WIDGET:" + JSON.stringify({ y: 2 })]);
132
+ expect(parseChannel(req).channel).toBe("gui_widget");
133
+ });
134
+ });
135
+
136
+ describe("parseChannel — 边界(不抛错)", () => {
137
+ it("marker 后 payload 非法 JSON → 不抛错,channel 仍解析(payload undefined)", () => {
138
+ const req = selectReq(ASK_USER_MARKER, ["not-valid-json{"]);
139
+ expect(() => parseChannel(req)).not.toThrow();
140
+ const result = parseChannel(req);
141
+ expect(result.channel).toBe("ask_user");
142
+ });
143
+
144
+ it("GUI_WIDGET_MARKER 后非法 JSON → 不抛错", () => {
145
+ const req = setWidgetReq("k", [GUI_WIDGET_MARKER_PREFIX + "broken{json"]);
146
+ expect(() => parseChannel(req)).not.toThrow();
147
+ });
148
+ });
149
+
150
+ // ── UiChannelRegistry ────────────────────────────────────────
151
+
152
+ describe("UiChannelRegistry — 注册 + 解析 + 列举", () => {
153
+ it("register 后 resolve 返回注册的 handler", () => {
154
+ const registry = createUiChannelRegistry();
155
+ const handler = vi.fn();
156
+ registry.register("ask_user", handler);
157
+ expect(registry.resolve("ask_user")).toBe(handler);
158
+ });
159
+
160
+ it("未注册的 channel resolve 返回 undefined", () => {
161
+ const registry = createUiChannelRegistry();
162
+ expect(registry.resolve("unknown")).toBeUndefined();
163
+ });
164
+
165
+ it("list 返回所有已注册 channel 名", () => {
166
+ const registry = createUiChannelRegistry();
167
+ registry.register("ask_user", vi.fn());
168
+ registry.register("gui_widget", vi.fn());
169
+ const names = registry.list().sort();
170
+ expect(names).toEqual(["ask_user", "gui_widget"]);
171
+ });
172
+
173
+ it("list 空注册表返回 []", () => {
174
+ const registry = createUiChannelRegistry();
175
+ expect(registry.list()).toEqual([]);
176
+ });
177
+
178
+ it("重复 register 同名 channel 覆盖旧 handler", () => {
179
+ const registry = createUiChannelRegistry();
180
+ const old = vi.fn();
181
+ const fresh = vi.fn();
182
+ registry.register("ask_user", old);
183
+ registry.register("ask_user", fresh);
184
+ expect(registry.resolve("ask_user")).toBe(fresh);
185
+ expect(registry.list()).toEqual(["ask_user"]);
186
+ });
187
+ });
@@ -0,0 +1,67 @@
1
+ // src/__tests__/ui-interaction-model.test.ts
2
+ //
3
+ // W1 红灯测试:ui-interaction-model.ts — method 交互模型分类。
4
+ //
5
+ // 测试对象:extensions/subagent-workflow/src/execution/ui-interaction-model.ts(新建)
6
+ // 契约来源:.fix-plans/00-master-summary.md §一 冲突 2「维度 1:透传判定规则」
7
+ //
8
+ // isDialogMethod(method):
9
+ // - dialog(占输入焦点,等响应,需透传+排队):select / confirm / input / editor → true
10
+ // - fire-and-forget(纯展示/写入,不等响应):notify / setStatus / setWidget /
11
+ // setTitle / set_editor_text / 未知 method → false
12
+ //
13
+ // 红灯原因:ui-interaction-model.ts 尚未创建,import 失败。
14
+
15
+ import { describe, expect, it } from "vitest";
16
+
17
+ import { isDialogMethod } from "../ui-interaction-model.ts";
18
+
19
+ describe("isDialogMethod — dialog 类 method(占输入焦点)", () => {
20
+ it("select → true(含 ask_user channel 借道 select)", () => {
21
+ expect(isDialogMethod("select")).toBe(true);
22
+ });
23
+
24
+ it("confirm → true", () => {
25
+ expect(isDialogMethod("confirm")).toBe(true);
26
+ });
27
+
28
+ it("input → true", () => {
29
+ expect(isDialogMethod("input")).toBe(true);
30
+ });
31
+
32
+ it("editor → true", () => {
33
+ expect(isDialogMethod("editor")).toBe(true);
34
+ });
35
+ });
36
+
37
+ describe("isDialogMethod — fire-and-forget 类 method(纯展示/写入)", () => {
38
+ it("notify → false", () => {
39
+ expect(isDialogMethod("notify")).toBe(false);
40
+ });
41
+
42
+ it("setStatus → false", () => {
43
+ expect(isDialogMethod("setStatus")).toBe(false);
44
+ });
45
+
46
+ it("setWidget → false(含 gui_widget channel 借道 setWidget)", () => {
47
+ expect(isDialogMethod("setWidget")).toBe(false);
48
+ });
49
+
50
+ it("setTitle → false", () => {
51
+ expect(isDialogMethod("setTitle")).toBe(false);
52
+ });
53
+
54
+ it("set_editor_text → false", () => {
55
+ expect(isDialogMethod("set_editor_text")).toBe(false);
56
+ });
57
+ });
58
+
59
+ describe("isDialogMethod — 未知 method 默认 fire-and-forget", () => {
60
+ it("未知 method 名 → false(未来 Pi 新增 method 不误判为 dialog)", () => {
61
+ expect(isDialogMethod("some_future_method")).toBe(false);
62
+ });
63
+
64
+ it("空字符串 → false", () => {
65
+ expect(isDialogMethod("")).toBe(false);
66
+ });
67
+ });