@zhushanwen/pi-subagent-workflow 0.2.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 (64) hide show
  1. package/README.md +56 -0
  2. package/agents/{scout.md → explorer.md} +1 -1
  3. package/agents/orchestrator.md +48 -0
  4. package/package.json +1 -1
  5. package/src/execution/__tests__/agent-registry.test.ts +3 -3
  6. package/src/execution/__tests__/ask-user-transit-e2e.test.ts +484 -0
  7. package/src/execution/__tests__/channel-registry-handshake.test.ts +233 -0
  8. package/src/execution/__tests__/crash-recovery.test.ts +5 -1
  9. package/src/execution/__tests__/dialog-queue.test.ts +299 -0
  10. package/src/execution/__tests__/execute-nesting.test.ts +1 -1
  11. package/src/execution/__tests__/execute-options-mapper.test.ts +1 -1
  12. package/src/execution/__tests__/finalize-record.test.ts +173 -0
  13. package/src/execution/__tests__/gui-mode-dispatch.test.ts +2 -3
  14. package/src/execution/__tests__/helpers/spawn-mock.ts +209 -0
  15. package/src/execution/__tests__/host-mode.test.ts +87 -0
  16. package/src/execution/__tests__/index-session-start.test.ts +342 -0
  17. package/src/execution/__tests__/list-component.test.ts +1 -1
  18. package/src/execution/__tests__/notifier-flush.test.ts +78 -0
  19. package/src/execution/__tests__/path-encoding.test.ts +30 -1
  20. package/src/execution/__tests__/record-store.test.ts +86 -2
  21. package/src/execution/__tests__/records-cwd-isolation.test.ts +91 -0
  22. package/src/execution/__tests__/rpc-mode.test.ts +89 -0
  23. package/src/execution/__tests__/run-spawn-edges.test.ts +157 -153
  24. package/src/execution/__tests__/run-spawn-integration.test.ts +85 -151
  25. package/src/execution/__tests__/run-spawn-rpc-mode.test.ts +193 -0
  26. package/src/execution/__tests__/session-file-gc.test.ts +46 -0
  27. package/src/execution/__tests__/session-start-reaper.test.ts +7 -1
  28. package/src/execution/__tests__/spawn-args.test.ts +14 -19
  29. package/src/execution/__tests__/spawn-event-adapter-rpc.test.ts +189 -0
  30. package/src/execution/__tests__/stdin-writer.test.ts +353 -0
  31. package/src/execution/__tests__/subagent-service.test.ts +73 -3
  32. package/src/execution/__tests__/tool-action.test.ts +1 -1
  33. package/src/execution/__tests__/ui-channels.test.ts +187 -0
  34. package/src/execution/__tests__/ui-interaction-model.test.ts +67 -0
  35. package/src/execution/__tests__/ui-request-handler-factory.test.ts +166 -0
  36. package/src/execution/__tests__/ui-request-handler.test.ts +204 -0
  37. package/src/execution/__tests__/ui-request-observability.test.ts +101 -0
  38. package/src/execution/__tests__/ui-request-queue.test.ts +133 -0
  39. package/src/execution/__tests__/worktree-manager.test.ts +1 -1
  40. package/src/execution/agent-registry.ts +1 -1
  41. package/src/execution/channel-registry-access.ts +138 -0
  42. package/src/execution/dialog-queue.ts +329 -0
  43. package/src/execution/finalize-record.ts +160 -0
  44. package/src/execution/get-state-handshake.ts +104 -0
  45. package/src/execution/host-mode.ts +52 -0
  46. package/src/execution/manifest-store.ts +206 -0
  47. package/src/execution/notifier.ts +5 -1
  48. package/src/execution/path-encoding.ts +18 -0
  49. package/src/execution/pi-invocation.ts +1 -1
  50. package/src/execution/record-store.ts +108 -2
  51. package/src/execution/session-file-gc.ts +25 -3
  52. package/src/execution/session-runner.ts +216 -32
  53. package/src/execution/spawn-event-adapter.ts +219 -6
  54. package/src/execution/stdin-writer.ts +106 -0
  55. package/src/execution/subagent-service.ts +167 -197
  56. package/src/execution/ui-channels.ts +216 -0
  57. package/src/execution/ui-interaction-model.ts +48 -0
  58. package/src/execution/ui-request-handler-factory.ts +175 -0
  59. package/src/execution/ui-request-observability.ts +77 -0
  60. package/src/execution/ui-request-queue.ts +168 -0
  61. package/src/index.ts +90 -6
  62. package/src/interface/format.ts +2 -0
  63. package/src/interface/subagent-actions.ts +9 -2
  64. package/src/interface/subagent-tool.ts +9 -8
@@ -0,0 +1,101 @@
1
+ // src/__tests__/ui-request-observability.test.ts
2
+ //
3
+ // M4 测试:ui-request-observability.ts — UiRequestObservability 纯逻辑类。
4
+ //
5
+ // 测试对象:extensions/subagent-workflow/src/execution/ui-request-observability.ts
6
+ // 契约来源:类注释(per-session 去重 + resetMissingHandlerWarnings 清洗 + setMode/getMode 往返)
7
+ //
8
+ // UiRequestObservability 职责:
9
+ // - setMode/getMode:sessionMode 往返存储
10
+ // - notifyMissingHandler(sessionId):per-session 去重,每 session 只 console.warn 一次
11
+ // - resetMissingHandlerWarnings():清去重集合,清洗后可重新 warn
12
+ //
13
+ // 纯逻辑无异步,测试最简单。console.warn spy 验证调用次数。
14
+
15
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
16
+
17
+ import { UiRequestObservability } from "../ui-request-observability.ts";
18
+
19
+ // ── 公共 fixture ──────────────────────────────────────────────
20
+
21
+ beforeEach(() => {
22
+ vi.spyOn(console, "warn").mockImplementation(() => {});
23
+ });
24
+
25
+ afterEach(() => {
26
+ vi.restoreAllMocks();
27
+ });
28
+
29
+ describe("UiRequestObservability — setMode/getMode 往返", () => {
30
+ it("setMode('tui') → getMode() === 'tui'", () => {
31
+ const obs = new UiRequestObservability();
32
+ obs.setMode("tui");
33
+ expect(obs.getMode()).toBe("tui");
34
+ });
35
+
36
+ it("setMode('rpc') → getMode() === 'rpc'", () => {
37
+ const obs = new UiRequestObservability();
38
+ obs.setMode("rpc");
39
+ expect(obs.getMode()).toBe("rpc");
40
+ });
41
+
42
+ it("初始 getMode() === undefined(未 set)", () => {
43
+ const obs = new UiRequestObservability();
44
+ expect(obs.getMode()).toBeUndefined();
45
+ });
46
+
47
+ it("setMode(undefined) → getMode() === undefined(可重置)", () => {
48
+ const obs = new UiRequestObservability();
49
+ obs.setMode("json");
50
+ obs.setMode(undefined);
51
+ expect(obs.getMode()).toBeUndefined();
52
+ });
53
+ });
54
+
55
+ describe("UiRequestObservability — notifyMissingHandler per-session 去重", () => {
56
+ it("同 sessionId 调两次 → 只 warn 一次", () => {
57
+ const obs = new UiRequestObservability();
58
+ obs.notifyMissingHandler("s1");
59
+ obs.notifyMissingHandler("s1");
60
+ expect(console.warn).toHaveBeenCalledTimes(1);
61
+ });
62
+
63
+ it("warn 内容含 sessionId 和 mode(可观测性)", () => {
64
+ const obs = new UiRequestObservability();
65
+ obs.setMode("tui");
66
+ obs.notifyMissingHandler("s1");
67
+ expect(console.warn).toHaveBeenCalledWith(
68
+ expect.stringContaining("session=s1"),
69
+ );
70
+ expect(console.warn).toHaveBeenCalledWith(
71
+ expect.stringContaining("mode=tui"),
72
+ );
73
+ });
74
+ });
75
+
76
+ describe("UiRequestObservability — resetMissingHandlerWarnings 后可重新 warn", () => {
77
+ it("notify(s1) → reset → notify(s1) → warn 被调 2 次", () => {
78
+ const obs = new UiRequestObservability();
79
+ obs.notifyMissingHandler("s1");
80
+ expect(console.warn).toHaveBeenCalledTimes(1);
81
+
82
+ obs.resetMissingHandlerWarnings();
83
+
84
+ obs.notifyMissingHandler("s1");
85
+ expect(console.warn).toHaveBeenCalledTimes(2);
86
+ });
87
+ });
88
+
89
+ describe("UiRequestObservability — 不同 session 各自首次 warn", () => {
90
+ it("notify(s1) + notify(s2) → warn 被调 2 次(互不干扰)", () => {
91
+ const obs = new UiRequestObservability();
92
+ obs.notifyMissingHandler("s1");
93
+ obs.notifyMissingHandler("s2");
94
+ expect(console.warn).toHaveBeenCalledTimes(2);
95
+
96
+ // 再次各自调用,已被去重,不再 warn
97
+ obs.notifyMissingHandler("s1");
98
+ obs.notifyMissingHandler("s2");
99
+ expect(console.warn).toHaveBeenCalledTimes(2);
100
+ });
101
+ });
@@ -0,0 +1,133 @@
1
+ // src/__tests__/ui-request-queue.test.ts
2
+ //
3
+ // W3 测试:UI 请求队列机制(L1 per-child createUiRequestQueue)
4
+ // 1. 多个请求按 FIFO 顺序处理
5
+ // 2. 第一个请求未完成时第二个不开始
6
+ //
7
+ // 直接测试 createUiRequestQueue 的队列逻辑(纯函数,不需要 mock runSpawn)。
8
+ // 用 fake child(PassThrough stdin)+ 手动控制 Promise resolve 时序。
9
+ //
10
+ // 协议迁移(W2):enqueue 第二参从旧 Record params(含 questions/context)改为
11
+ // ExtensionUiRequest(method 平铺);handler 签名从 (questions,context) 改为
12
+ // UiRequestHandler (req: UiRequest) => Promise<UiResponse>。
13
+
14
+ import type { ChildProcess } from "node:child_process";
15
+ import { PassThrough } from "node:stream";
16
+
17
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
18
+
19
+ import {
20
+ createUiRequestQueue,
21
+ type UiRequest,
22
+ type UiRequestHandler,
23
+ type UiResponse,
24
+ } from "../ui-request-queue.ts";
25
+
26
+ beforeEach(() => {
27
+ vi.useFakeTimers();
28
+ });
29
+
30
+ afterEach(() => {
31
+ vi.useRealTimers();
32
+ });
33
+
34
+ function makeFakeChild(): ChildProcess {
35
+ const stdin = new PassThrough();
36
+ return {
37
+ stdin,
38
+ killed: false,
39
+ pid: 10001,
40
+ on: vi.fn(),
41
+ removeListener: vi.fn(),
42
+ } as unknown as ChildProcess;
43
+ }
44
+
45
+ /** 构造 select method 的 ExtensionUiRequest(ask_user 借道 select dialog 通道)。 */
46
+ function makeSelectReq(question: string): { method: "select"; title: string; options: string[] } {
47
+ return {
48
+ method: "select",
49
+ title: question,
50
+ options: [JSON.stringify({ question, options: [{ label: "A" }] })],
51
+ };
52
+ }
53
+
54
+ describe("UI 请求队列", () => {
55
+ it("多个 extension_ui_request 按 FIFO 顺序处理", async () => {
56
+ const callOrder: string[] = [];
57
+ // 每个请求返回独立的可控 Promise
58
+ const resolvers: Array<(v: unknown) => void> = [];
59
+
60
+ const handler: UiRequestHandler = vi.fn((req: UiRequest) => {
61
+ callOrder.push(req.title ?? "");
62
+ return new Promise<UiResponse>((resolve) => {
63
+ resolvers.push(resolve);
64
+ });
65
+ }) as unknown as UiRequestHandler;
66
+
67
+ const child = makeFakeChild();
68
+ const ctx = { uiRequestHandler: handler } as Parameters<
69
+ typeof createUiRequestQueue
70
+ >[1];
71
+ const enqueue = createUiRequestQueue(child, ctx);
72
+
73
+ // 快速入队三个请求(handler 被调用但不 resolve)
74
+ enqueue("r1", makeSelectReq("Q1"));
75
+ enqueue("r2", makeSelectReq("Q2"));
76
+ enqueue("r3", makeSelectReq("Q3"));
77
+
78
+ // 第一个请求立即开始处理
79
+ expect(handler).toHaveBeenCalledTimes(1);
80
+ expect(callOrder).toEqual(["Q1"]);
81
+
82
+ // resolve 第一个 → 第二个开始处理
83
+ resolvers[0]({ value: "a1" });
84
+ await vi.advanceTimersByTimeAsync(0);
85
+ expect(handler).toHaveBeenCalledTimes(2);
86
+ expect(callOrder).toEqual(["Q1", "Q2"]);
87
+
88
+ // resolve 第二个 → 第三个开始处理
89
+ resolvers[1]({ value: "a2" });
90
+ await vi.advanceTimersByTimeAsync(0);
91
+ expect(handler).toHaveBeenCalledTimes(3);
92
+ expect(callOrder).toEqual(["Q1", "Q2", "Q3"]);
93
+ });
94
+
95
+ it("第一个请求未 resolve 时第二个不调用 uiRequestHandler", async () => {
96
+ const callOrder: string[] = [];
97
+ let firstResolve: (v: unknown) => void;
98
+
99
+ const handler: UiRequestHandler = vi.fn((req: UiRequest) => {
100
+ callOrder.push(req.title ?? "");
101
+ if (req.title === "Q1") {
102
+ return new Promise<UiResponse>((resolve) => {
103
+ firstResolve = resolve;
104
+ });
105
+ }
106
+ return Promise.resolve<UiResponse>({ value: "done" });
107
+ }) as unknown as UiRequestHandler;
108
+
109
+ const child = makeFakeChild();
110
+ const ctx = { uiRequestHandler: handler } as Parameters<
111
+ typeof createUiRequestQueue
112
+ >[1];
113
+ const enqueue = createUiRequestQueue(child, ctx);
114
+
115
+ enqueue("r1", makeSelectReq("Q1"));
116
+ enqueue("r2", makeSelectReq("Q2"));
117
+
118
+ // 只有 Q1 被调用,Q2 还在队列里
119
+ expect(handler).toHaveBeenCalledTimes(1);
120
+ expect(callOrder).toEqual(["Q1"]);
121
+
122
+ // 等一下,Q2 仍然不应该被调用
123
+ await vi.advanceTimersByTimeAsync(100);
124
+ expect(handler).toHaveBeenCalledTimes(1);
125
+ expect(callOrder).toEqual(["Q1"]);
126
+
127
+ // resolve Q1 → Q2 才开始
128
+ firstResolve!({ value: "a1" });
129
+ await vi.advanceTimersByTimeAsync(0);
130
+ expect(handler).toHaveBeenCalledTimes(2);
131
+ expect(callOrder).toEqual(["Q1", "Q2"]);
132
+ });
133
+ });
@@ -72,8 +72,8 @@ import * as fs from "node:fs";
72
72
  import * as os from "node:os";
73
73
  import * as path from "node:path";
74
74
 
75
- import { encodeCwd } from "../path-encoding.ts";
76
75
  import { isProcessAlive } from "../alive-store.ts";
76
+ import { encodeCwd } from "../path-encoding.ts";
77
77
  import { WorktreeManager } from "../worktree-manager.ts";
78
78
 
79
79
  const mockExec = vi.mocked(execFileSync);
@@ -31,7 +31,7 @@ export interface BuiltinAgentRegistry {
31
31
  * manifest 缺失时 fallback 扫约定目录 agents/。
32
32
  *
33
33
  * [HISTORICAL] 此前 discoverAll 从未被调用,agentRegistry 永远为空——包内
34
- * agents/*.md(worker/reviewer/scout 等)pi install 后开箱不可用。修复:构造时扫描
34
+ * agents/*.md(worker/reviewer/explorer 等)pi install 后开箱不可用。修复:构造时扫描
35
35
  * 包内 agents/ 作为 builtin(优先级最低,被用户同名文件覆盖)。
36
36
  */
37
37
  export function createPackageBuiltinRegistry(): BuiltinAgentRegistry {
@@ -0,0 +1,138 @@
1
+ // src/execution/channel-registry-access.ts
2
+ //
3
+ // Channel registry 的访问入口(本扩展内部用 + 契约文档化导出)。
4
+ //
5
+ // ask-user 等扩展通过本模块注册 channel handler,让 subagent 子进程的 UI 请求
6
+ // 能透传到主进程渲染。所有扩展各自直接读写
7
+ // globalThis[Symbol.for("@zhushanwen/pi-subagents.channelHandshake")],
8
+ // 拿到结构兼容 ChannelRegistryHandshake 的同一握手对象。
9
+ //
10
+ // ── 升级说明(决策 D 修复 M4)─────────────────────────────────
11
+ // 历史问题:原协议槽位直接存 UiChannelRegistry 实例,谁先 session_start 谁创建。
12
+ // ask-user 先到时会自建简化 registry 占位,劫持 canonical 槽位,subagent-workflow
13
+ // 后到时拿到的是 ask-user 的非 canonical 实例,行为不一致。
14
+ //
15
+ // 修复:槽位从「存 registry 实例」升级为「存握手对象 {version, registry?, pending[]}」。
16
+ // canonical registry 实例永远由 subagent-workflow 单一创建点(本模块的
17
+ // getOrCreateChannelRegistry)实例化。ask-user 先到时只往 pending 推 handler,
18
+ // 等 subagent-workflow 来 flush;subagent-workflow 先到时直接创建 registry 并
19
+ // 接收后续 ask-user 推入的 pending。
20
+ //
21
+ // ── 跨扩展协议契约(必须与 ask-user 侧严格一致)──────────────
22
+ // key 字面量: "@zhushanwen/pi-subagents.channelHandshake"
23
+ // ↑ 必须与 ask-user/src/channel-registry-register.ts 完全一致
24
+ // handshake 形状: ChannelRegistryHandshake(version=1)
25
+ // version 守卫: slot.version !== 1 时 console.warn + 丢弃重建(向前兼容未来升级)
26
+
27
+ import { createUiChannelRegistry, type UiChannelRegistry, type ChannelHandler } from "./ui-channels.ts";
28
+
29
+ /** 进程级 channel 握手的 globalThis key(Symbol.for 跨模块共享)。
30
+ *
31
+ * **协议契约**:字面量 `"@zhushanwen/pi-subagents.channelHandshake"` 必须与
32
+ * ask-user 扩展的 `ask-user/src/channel-registry-register.ts` 完全一致——
33
+ * 两边读写同一个 Symbol.for key 才能拿到同一握手对象。
34
+ *
35
+ * 改名历史:原 `CHANNEL_REGISTRY_KEY`(字面量 `...channelRegistry`)在决策 D
36
+ * 中升级为 `CHANNEL_HANDSHAKE_KEY`(字面量 `...channelHandshake`),槽位形状
37
+ * 从 registry 实例改为握手对象。 */
38
+ export const CHANNEL_HANDSHAKE_KEY = Symbol.for("@zhushanwen/pi-subagents.channelHandshake");
39
+
40
+ /** 握手版本。未来若形状不兼容升级,递增此常量并在 getOrCreateChannelRegistry
41
+ * 的 version 守卫里加迁移逻辑。 */
42
+ const HANDSHAKE_VERSION = 1 as const;
43
+
44
+ /** pending channel handler 条目。ask-user 先到、registry 未就绪时临时存放。 */
45
+ export interface ChannelRegistryHandshakePendingEntry {
46
+ channel: string;
47
+ handler: ChannelHandler;
48
+ }
49
+
50
+ /** 跨扩展 channel registry 握手对象(globalThis 槽位形状)。
51
+ *
52
+ * - `version`:协议版本,当前固定 1。读取时若 ≠1 视为不兼容,丢弃重建。
53
+ * - `registry`:canonical UiChannelRegistry 实例。仅由 subagent-workflow 的
54
+ * getOrCreateChannelRegistry 创建并填充。未就绪时为 undefined。
55
+ * - `pending`:ask-user 在 registry 就绪前推入的 handler 条目。
56
+ * subagent-workflow 创建 registry 后逐条 flush(注册),然后清空。 */
57
+ export interface ChannelRegistryHandshake {
58
+ version: typeof HANDSHAKE_VERSION;
59
+ registry?: UiChannelRegistry;
60
+ pending: ChannelRegistryHandshakePendingEntry[];
61
+ }
62
+
63
+ /** 读取握手槽位;若 version 不匹配则视为缺失(warn + 返回 undefined)。
64
+ * 类型不安全的 globalThis 反射访问集中在本函数,外层逻辑保持类型严谨。 */
65
+ function readHandshakeSlot(): ChannelRegistryHandshake | undefined {
66
+ const slot = Reflect.get(globalThis, CHANNEL_HANDSHAKE_KEY) as unknown;
67
+ if (slot === undefined) return undefined;
68
+ // 形状校验:必须是对象且 version===1,否则视为不兼容
69
+ if (typeof slot !== "object" || slot === null) {
70
+ console.warn(
71
+ "[pi-subagent-workflow] channel handshake slot is not an object; discarding and recreating.",
72
+ );
73
+ return undefined;
74
+ }
75
+ const version = (slot as { version?: unknown }).version;
76
+ if (version !== HANDSHAKE_VERSION) {
77
+ console.warn(
78
+ `[pi-subagent-workflow] channel handshake version mismatch (got ${String(
79
+ version,
80
+ )}, expected ${HANDSHAKE_VERSION}); discarding and recreating.`,
81
+ );
82
+ return undefined;
83
+ }
84
+ // version 正确,但 pending 可能被恶意/错误地塞了非数组;防御性处理
85
+ const candidate = slot as ChannelRegistryHandshake;
86
+ if (!Array.isArray(candidate.pending)) {
87
+ console.warn(
88
+ "[pi-subagent-workflow] channel handshake pending is not an array; discarding and recreating.",
89
+ );
90
+ return undefined;
91
+ }
92
+ return candidate;
93
+ }
94
+
95
+ /** 将 pending 条目逐个注册进 registry,注册完清空 pending。
96
+ * pending 内部 entry 的形状由 ask-user 侧保证,此处不再二次校验。 */
97
+ function flushPending(registry: UiChannelRegistry, pending: ChannelRegistryHandshakePendingEntry[]): void {
98
+ for (const entry of pending) {
99
+ registry.register(entry.channel, entry.handler);
100
+ }
101
+ }
102
+
103
+ /** 获取或创建进程级 channel registry(canonical 单例)。
104
+ *
105
+ * **唯一创建点**:canonical UiChannelRegistry 实例仅由本函数创建。
106
+ * ask-user 扩展绝不自建 registry——它在 registry 未就绪时只往 slot.pending 推条目。
107
+ *
108
+ * 行为分支:
109
+ * 1. 槽位不存在或 version ≠ 1 → warn(version 不匹配时)+ 建新 slot
110
+ * `{version:1, pending:[], registry:<newly created>}`
111
+ * 2. 槽位存在但 registry 未就绪(ask-user 先到过,塞过 pending)→
112
+ * 创建 canonical registry + flush pending + 清空 pending
113
+ * 3. 槽位存在且 registry 已就绪(本函数已被调用过)→
114
+ * 直接返回同一实例引用(===),不重建、不重复 flush
115
+ *
116
+ * @returns 进程级 canonical UiChannelRegistry 单例(永不返回 undefined) */
117
+ export function getOrCreateChannelRegistry(): UiChannelRegistry {
118
+ let slot = readHandshakeSlot();
119
+ if (slot === undefined) {
120
+ // 分支 1:无合规槽位,新建并立即创建 canonical registry
121
+ const registry = createUiChannelRegistry();
122
+ slot = { version: HANDSHAKE_VERSION, registry, pending: [] };
123
+ Reflect.set(globalThis, CHANNEL_HANDSHAKE_KEY, slot);
124
+ return registry;
125
+ }
126
+ if (slot.registry === undefined) {
127
+ // 分支 2:ask-user 先到过,flush pending 进 canonical registry
128
+ const registry = createUiChannelRegistry();
129
+ flushPending(registry, slot.pending);
130
+ slot.pending = [];
131
+ slot.registry = registry;
132
+ return registry;
133
+ }
134
+ // 分支 3:registry 已就绪,返回同一实例
135
+ return slot.registry;
136
+ }
137
+
138
+ export type { UiChannelRegistry, ChannelHandler };