@zhushanwen/pi-subagent-workflow 0.2.0 → 0.3.1
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/README.md +56 -0
- package/agents/{scout.md → explorer.md} +1 -1
- package/agents/orchestrator.md +48 -0
- package/package.json +1 -1
- package/src/execution/__tests__/agent-registry.test.ts +3 -3
- package/src/execution/__tests__/ask-user-transit-e2e.test.ts +484 -0
- package/src/execution/__tests__/channel-registry-handshake.test.ts +233 -0
- package/src/execution/__tests__/crash-recovery.test.ts +5 -1
- package/src/execution/__tests__/dialog-queue.test.ts +299 -0
- package/src/execution/__tests__/execute-nesting.test.ts +1 -1
- package/src/execution/__tests__/execute-options-mapper.test.ts +22 -3
- package/src/execution/__tests__/finalize-record.test.ts +173 -0
- package/src/execution/__tests__/gui-mode-dispatch.test.ts +2 -3
- package/src/execution/__tests__/helpers/spawn-mock.ts +209 -0
- package/src/execution/__tests__/host-mode.test.ts +87 -0
- package/src/execution/__tests__/index-session-start.test.ts +342 -0
- package/src/execution/__tests__/list-component.test.ts +1 -1
- package/src/execution/__tests__/notifier-flush.test.ts +78 -0
- package/src/execution/__tests__/path-encoding.test.ts +30 -1
- package/src/execution/__tests__/record-store.test.ts +86 -2
- package/src/execution/__tests__/records-cwd-isolation.test.ts +91 -0
- package/src/execution/__tests__/rpc-mode.test.ts +89 -0
- package/src/execution/__tests__/run-spawn-edges.test.ts +157 -153
- package/src/execution/__tests__/run-spawn-integration.test.ts +85 -151
- package/src/execution/__tests__/run-spawn-rpc-mode.test.ts +193 -0
- package/src/execution/__tests__/session-file-gc.test.ts +46 -0
- package/src/execution/__tests__/session-start-reaper.test.ts +7 -1
- package/src/execution/__tests__/spawn-args.test.ts +14 -19
- package/src/execution/__tests__/spawn-event-adapter-rpc.test.ts +189 -0
- package/src/execution/__tests__/stdin-writer.test.ts +353 -0
- package/src/execution/__tests__/subagent-service.test.ts +73 -3
- package/src/execution/__tests__/tool-action.test.ts +4 -4
- package/src/execution/__tests__/ui-channels.test.ts +187 -0
- package/src/execution/__tests__/ui-interaction-model.test.ts +67 -0
- package/src/execution/__tests__/ui-request-handler-factory.test.ts +166 -0
- package/src/execution/__tests__/ui-request-handler.test.ts +204 -0
- package/src/execution/__tests__/ui-request-observability.test.ts +101 -0
- package/src/execution/__tests__/ui-request-queue.test.ts +133 -0
- package/src/execution/__tests__/worktree-manager.test.ts +1 -1
- package/src/execution/agent-registry.ts +1 -1
- package/src/execution/channel-registry-access.ts +138 -0
- package/src/execution/dialog-queue.ts +329 -0
- package/src/execution/execute-options-mapper.ts +5 -3
- package/src/execution/finalize-record.ts +160 -0
- package/src/execution/get-state-handshake.ts +104 -0
- package/src/execution/host-mode.ts +52 -0
- package/src/execution/manifest-store.ts +206 -0
- package/src/execution/notifier.ts +5 -1
- package/src/execution/path-encoding.ts +18 -0
- package/src/execution/pi-invocation.ts +1 -1
- package/src/execution/record-store.ts +108 -2
- package/src/execution/session-file-gc.ts +25 -3
- package/src/execution/session-runner.ts +216 -32
- package/src/execution/spawn-event-adapter.ts +219 -6
- package/src/execution/stdin-writer.ts +106 -0
- package/src/execution/subagent-service.ts +167 -197
- package/src/execution/types.ts +6 -6
- package/src/execution/ui-channels.ts +216 -0
- package/src/execution/ui-interaction-model.ts +48 -0
- package/src/execution/ui-request-handler-factory.ts +175 -0
- package/src/execution/ui-request-observability.ts +77 -0
- package/src/execution/ui-request-queue.ts +168 -0
- package/src/index.ts +90 -6
- package/src/interface/__tests__/detectors.test.ts +76 -0
- package/src/interface/__tests__/subagent-tool-prompt.test.ts +27 -4
- package/src/interface/__tests__/workflow-tool-prompt.test.ts +24 -2
- package/src/interface/format.ts +2 -0
- package/src/interface/subagent-actions.ts +21 -11
- package/src/interface/subagent-tool.ts +43 -10
- package/src/interface/tool-workflow.ts +58 -16
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
// src/execution/ui-request-queue.ts
|
|
2
|
+
//
|
|
3
|
+
// W3/W2: 子进程 extension_ui_request 的 FIFO 串行队列 + 转发处理。
|
|
4
|
+
//
|
|
5
|
+
// 从 session-runner.ts 提取(保持文件 < 1000 行)。职责单一:
|
|
6
|
+
// - createUiRequestQueue:每个子进程一个队列,保证多个 UI 请求(ask_user 等)
|
|
7
|
+
// FIFO 串行处理,防止并发询问用户导致交错。
|
|
8
|
+
// - handleUiRequest:从 ExtensionUiRequest 构造 UiRequest → 调主 agent uiRequestHandler
|
|
9
|
+
// → 按 UiResponse 形状回写 stdin。
|
|
10
|
+
// - extractMethodFields:method-specific 字段类型安全复制。
|
|
11
|
+
//
|
|
12
|
+
// session-runner.runSpawn 在 stdout pump 中拿到 extension_ui_request 后调 enqueue 入队。
|
|
13
|
+
|
|
14
|
+
import type { ChildProcess } from "node:child_process";
|
|
15
|
+
|
|
16
|
+
import type { UiRequest } from "./dialog-queue.ts";
|
|
17
|
+
// 类型再导出:dialog-queue.ts 是 UiRequest/UiResponse/UiRequestHandler 的规范来源,
|
|
18
|
+
// 本模块再导出供测试 import(避免测试直接依赖 dialog-queue 内部实现)。
|
|
19
|
+
export type { UiRequest, UiRequestHandler, UiResponse } from "./dialog-queue.ts";
|
|
20
|
+
import type { SessionRunnerContext } from "./session-runner.ts";
|
|
21
|
+
import type { ExtensionUiRequest } from "./spawn-event-adapter.ts";
|
|
22
|
+
import { respond } from "./stdin-writer.ts";
|
|
23
|
+
import { parseChannel } from "./ui-channels.ts";
|
|
24
|
+
import { notifyMissingHandlerGlobal } from "./ui-request-observability.ts";
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* 创建 UI 请求队列。返回 enqueue 函数,调用方将 extension_ui_request 入队。
|
|
28
|
+
*
|
|
29
|
+
* 多个 extension_ui_request 并发到达时,队列保证 FIFO 串行处理:
|
|
30
|
+
* 前一个请求的 uiRequestHandler resolve 后,才将下一个请求发给主 agent UI。
|
|
31
|
+
* 防止并发询问用户导致交错(用户同时看到多个问题)。
|
|
32
|
+
*
|
|
33
|
+
* 设计:队列是 runSpawn 生命周期内的闭包状态(非模块级),
|
|
34
|
+
* 每个子进程实例独立队列,无跨 session 泄漏。
|
|
35
|
+
*
|
|
36
|
+
* @param child 子进程(stdin 写入 extension_ui_response)
|
|
37
|
+
* @param ctx SessionRunnerContext(含 uiRequestHandler 回调)
|
|
38
|
+
* @returns enqueue 函数:(id, request) => void,将请求入队并触发顺序处理
|
|
39
|
+
*/
|
|
40
|
+
export function createUiRequestQueue(
|
|
41
|
+
child: ChildProcess,
|
|
42
|
+
ctx: SessionRunnerContext,
|
|
43
|
+
): (id: string, request: ExtensionUiRequest) => void {
|
|
44
|
+
// [R3] AbortController 取消 pending handler——子进程退出时队列不再阻塞
|
|
45
|
+
const abortController = new AbortController();
|
|
46
|
+
const queue: Array<{ id: string; request: ExtensionUiRequest; signal: AbortSignal }> = [];
|
|
47
|
+
let processing = false;
|
|
48
|
+
let closed = false;
|
|
49
|
+
|
|
50
|
+
function processNext(): void {
|
|
51
|
+
if (processing || queue.length === 0 || closed) return;
|
|
52
|
+
processing = true;
|
|
53
|
+
const { id, request, signal } = queue.shift()!;
|
|
54
|
+
handleUiRequest(child, id, request, ctx, signal).finally(() => {
|
|
55
|
+
processing = false;
|
|
56
|
+
processNext();
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// [R3] 子进程退出时 abort 所有 pending handler,队列不再阻塞
|
|
61
|
+
// [SR-4] 同步清理 L2 队列中该 child 的 pending dialog——child 在 dialog 等 L2 时退出,
|
|
62
|
+
// L2 里该项永不 settle → processing 永远 true → 所有其他子进程 dialog 永久阻塞(全局死锁)。
|
|
63
|
+
// pid 缺省(spawn 后极短窗口 child.pid 可能为 undefined)时跳过——此时该 child 还未在 L2
|
|
64
|
+
// 注册过任何 dialog(handleUiRequest 构造 UiRequest 时用同样的 child.pid,pid undefined 时
|
|
65
|
+
// 不填 _childPid,rejectChildDialogs 也匹配不到),无清理必要。
|
|
66
|
+
const onClose = (): void => {
|
|
67
|
+
// #19/#17 幂等守卫:close + error 可能都触发,二次调用会重复 rejectChildDialogs(虽依赖 L2 的 settled 幂等,
|
|
68
|
+
// 但 close 状态本身需守卫——避免 closed=true 后 queue.length=0 + abort 重复执行,以及重复 reject 导致语义噪声)。
|
|
69
|
+
if (closed) return;
|
|
70
|
+
closed = true;
|
|
71
|
+
abortController.abort();
|
|
72
|
+
queue.length = 0;
|
|
73
|
+
if (child.pid !== undefined) {
|
|
74
|
+
ctx.dialogQueue?.rejectChildDialogs({ pid: child.pid });
|
|
75
|
+
}
|
|
76
|
+
};
|
|
77
|
+
child.on("close", onClose);
|
|
78
|
+
child.on("error", onClose);
|
|
79
|
+
|
|
80
|
+
return function enqueue(id: string, request: ExtensionUiRequest): void {
|
|
81
|
+
if (closed) return;
|
|
82
|
+
queue.push({ id, request, signal: abortController.signal });
|
|
83
|
+
processNext();
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* 处理子进程发来的 extension_ui_request(ask_user 及其他 Pi UI method)。
|
|
89
|
+
*
|
|
90
|
+
* 流程:从 ExtensionUiRequest 构造 UiRequest(含 channel/channelPayload)
|
|
91
|
+
* → 调用主 agent uiRequestHandler → 按 UiResponse 形状回写 stdin。
|
|
92
|
+
*
|
|
93
|
+
* handler 未设置时不再静默忽略——console.warn 兜底(FR-9 可观测性),
|
|
94
|
+
* W3 接入 SubagentService.notifyMissingHandler 的 appendEntry。
|
|
95
|
+
*
|
|
96
|
+
* @param child 子进程(stdin 写入响应)
|
|
97
|
+
* @param id 请求 id(子进程用它关联 response)
|
|
98
|
+
* @param request ExtensionUiRequest(method 平铺,从 enqueueUiRequest 传入)
|
|
99
|
+
* @param ctx SessionRunnerContext(含 uiRequestHandler 回调)
|
|
100
|
+
* @param signal abort signal(子进程退出时触发,取消正在等待的 handler)
|
|
101
|
+
* @returns Promise(队列等待用:resolve 表示响应已写入 stdin 或已放弃)
|
|
102
|
+
*/
|
|
103
|
+
async function handleUiRequest(
|
|
104
|
+
child: ChildProcess,
|
|
105
|
+
id: string,
|
|
106
|
+
request: ExtensionUiRequest,
|
|
107
|
+
ctx: SessionRunnerContext,
|
|
108
|
+
signal?: AbortSignal,
|
|
109
|
+
): Promise<void> {
|
|
110
|
+
const handler = ctx.uiRequestHandler;
|
|
111
|
+
if (!handler) {
|
|
112
|
+
// #9 + #11:handler 缺失时不再静默 return(会让子进程永久挂起等 response)。
|
|
113
|
+
// - notifyMissingHandlerGlobal:经 globalThis 桥接到 observability 单例做 per-session 去重告警
|
|
114
|
+
// (FR-9 可观测性;未注册时走 fallback warn 不丢日志)
|
|
115
|
+
// - respond(cancelled):让子进程收到明确取消,不再永久挂起。等价于用户主动取消的语义。
|
|
116
|
+
// 去重 key 用 child.pid(队列内拿不到真实 sessionId;pid 是子进程会话的稳定标识,
|
|
117
|
+
// 去重粒度正确——同一子进程多次 handler 缺失只告警一次)。
|
|
118
|
+
notifyMissingHandlerGlobal(child.pid?.toString() ?? id);
|
|
119
|
+
respond(child, id, { cancelled: true }, signal);
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// 从 ExtensionUiRequest 构造 UiRequest(含 channel/channelPayload)
|
|
124
|
+
const { channel, channelPayload } = parseChannel(request);
|
|
125
|
+
// [SR-4] 填入 child.pid 作为内部元数据:L2 队列的 rejectChildDialogs 据此关联 child close
|
|
126
|
+
// 清理。factory 层 enqueue 时读 req._childPid 传给 opts.child。pid undefined(spawn 后极短
|
|
127
|
+
// 窗口)时不填——rejectChildDialogs 也匹配不到(onClose 同样用 child.pid 守卫),无副作用。
|
|
128
|
+
const uiReq: UiRequest = {
|
|
129
|
+
id,
|
|
130
|
+
method: request.method,
|
|
131
|
+
...(child.pid !== undefined ? { _childPid: child.pid } : {}),
|
|
132
|
+
...(channel !== undefined ? { channel } : {}),
|
|
133
|
+
...(channelPayload !== undefined ? { channelPayload } : {}),
|
|
134
|
+
...extractMethodFields(request),
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
try {
|
|
138
|
+
const result = await handler(uiReq);
|
|
139
|
+
// [R3] 子进程已退出,跳过写入
|
|
140
|
+
if (signal?.aborted) return;
|
|
141
|
+
respond(child, id, result, signal);
|
|
142
|
+
} catch (err) {
|
|
143
|
+
// [R3] 子进程已退出,跳过写入
|
|
144
|
+
if (signal?.aborted) return;
|
|
145
|
+
console.error("[subagents] uiRequestHandler threw:", err);
|
|
146
|
+
respond(child, id, { cancelled: true }, signal);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** 从 ExtensionUiRequest 提取 method-specific 字段到 UiRequest(与 Pi rpc-types.ts 1:1)。
|
|
151
|
+
* 按 method 变体类型安全地复制对应字段;缺失字段不复制(保持 UiRequest 可选)。 */
|
|
152
|
+
function extractMethodFields(req: ExtensionUiRequest): Partial<UiRequest> {
|
|
153
|
+
const out: Partial<UiRequest> = {};
|
|
154
|
+
if ("title" in req && typeof req.title === "string") out.title = req.title;
|
|
155
|
+
if ("options" in req && Array.isArray(req.options)) out.options = req.options;
|
|
156
|
+
if ("message" in req && typeof req.message === "string") out.message = req.message;
|
|
157
|
+
if ("placeholder" in req && typeof req.placeholder === "string") out.placeholder = req.placeholder;
|
|
158
|
+
if ("prefill" in req && typeof req.prefill === "string") out.prefill = req.prefill;
|
|
159
|
+
if ("notifyType" in req && typeof req.notifyType === "string") out.notifyType = req.notifyType;
|
|
160
|
+
if ("statusKey" in req && typeof req.statusKey === "string") out.statusKey = req.statusKey;
|
|
161
|
+
if ("statusText" in req) out.statusText = req.statusText;
|
|
162
|
+
if ("widgetKey" in req && typeof req.widgetKey === "string") out.widgetKey = req.widgetKey;
|
|
163
|
+
if ("widgetLines" in req) out.widgetLines = req.widgetLines;
|
|
164
|
+
if ("widgetPlacement" in req) out.widgetPlacement = req.widgetPlacement;
|
|
165
|
+
if ("text" in req && typeof req.text === "string") out.text = req.text;
|
|
166
|
+
if ("timeout" in req && typeof req.timeout === "number") out.timeout = req.timeout;
|
|
167
|
+
return out;
|
|
168
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -17,12 +17,15 @@ import * as fs from "node:fs";
|
|
|
17
17
|
import * as os from "node:os";
|
|
18
18
|
import * as path from "node:path";
|
|
19
19
|
|
|
20
|
-
import type { ExtensionAPI, ExtensionContext, ResourcesDiscoverEvent, ResourcesDiscoverResult, SessionShutdownEvent, SessionStartEvent } from "@mariozechner/pi-coding-agent";
|
|
20
|
+
import type { ExtensionAPI, ExtensionContext, ModelSelectEvent, ResourcesDiscoverEvent, ResourcesDiscoverResult, SessionShutdownEvent, SessionStartEvent, SessionTreeEvent } from "@mariozechner/pi-coding-agent";
|
|
21
21
|
import { getAgentDir } from "@mariozechner/pi-coding-agent";
|
|
22
22
|
|
|
23
23
|
import type { AgentRegistry } from "./execution/agent-registry.ts";
|
|
24
24
|
import { bestEffort } from "./execution/best-effort.ts";
|
|
25
25
|
// ═══ execution/ 层(subagents 核心 + 运行时) ═══
|
|
26
|
+
import { getOrCreateChannelRegistry } from "./execution/channel-registry-access.ts";
|
|
27
|
+
import { DialogGlobalQueue } from "./execution/dialog-queue.ts";
|
|
28
|
+
import { createUiRequestHandlerForMode } from "./execution/ui-request-handler-factory.ts";
|
|
26
29
|
import {
|
|
27
30
|
getModelConfigService,
|
|
28
31
|
ModelConfigService,
|
|
@@ -213,14 +216,35 @@ export default function subagentsWorkflowExtension(pi: ExtensionAPI): void {
|
|
|
213
216
|
sessionId: ctx.sessionManager.getSessionId(),
|
|
214
217
|
ctxModel: ctx.model ?? undefined,
|
|
215
218
|
});
|
|
219
|
+
|
|
220
|
+
// ── W3: handler 注入链路接通 ──
|
|
221
|
+
// 进程级单例:channel registry + dialog queue 跨 session 复用
|
|
222
|
+
//(与 SubagentService 单例模式一致,globalThis Symbol 持有避免 jiti 多实例分裂)。
|
|
223
|
+
const channelRegistry = getOrCreateChannelRegistry();
|
|
224
|
+
const dialogQueue = getOrCreateDialogQueue();
|
|
225
|
+
const uiRequestHandler = createUiRequestHandlerForMode(ctx, channelRegistry, dialogQueue);
|
|
226
|
+
// SR-3: 无论 new 还是 existing(/resume /fork 复用),session_start 都必须注入 handler
|
|
227
|
+
service.setUiRequestHandler(uiRequestHandler);
|
|
228
|
+
|
|
216
229
|
service.initSession({
|
|
217
230
|
pi,
|
|
218
231
|
sessionId: ctx.sessionManager.getSessionId(),
|
|
219
232
|
// 注入 ctx.ui.setWidget 作为 streaming sink(只绑方法,不持有整个 ctx)。
|
|
220
233
|
// background subagent 执行期间,text_delta 经 SubagentStream 合并后由此通道转发。
|
|
221
|
-
streamSink
|
|
222
|
-
|
|
223
|
-
|
|
234
|
+
// [W1 修复] ctx.mode === 'rpc' 守卫:TUI/json/print 下 streamSink = undefined(无 widget 噪音),
|
|
235
|
+
// rpc mode(GUI/xyz-agent)下保持原行为(ctx.ui.setWidget → sidecar → chatStore)。
|
|
236
|
+
// streamSink API 不变(SubagentStream.onDelta 仍可调,只是 TUI 下 stream 不会被创建)。
|
|
237
|
+
streamSink: ctx.mode === "rpc"
|
|
238
|
+
? { setWidget: (key, lines) => ctx.ui.setWidget(key, lines) }
|
|
239
|
+
: undefined,
|
|
240
|
+
// [#24] uiRequestHandler 单一注入入口:上方 setUiRequestHandler 已注入(SR-3 语义,
|
|
241
|
+
// new/existing service 均覆盖)。此处不再重复传 initSession.uiRequestHandler,避免
|
|
242
|
+
// 同一 handler 双路径注入造成的语义混淆与“哪一个是 source of truth”歧义。
|
|
243
|
+
// mode 仍需 session 级注入(uiObservability.setMode 依赖它,与 handler 无关)。
|
|
244
|
+
mode: ctx.mode,
|
|
245
|
+
// SR-4:注入 L2 dialog 队列——session-runner child close 时调 rejectChildDialogs
|
|
246
|
+
// 清理该 child 在 L2 的 pending dialog,防全局死锁(C1 修复:清理路径接通)。
|
|
247
|
+
dialogQueue,
|
|
224
248
|
});
|
|
225
249
|
|
|
226
250
|
if (!existingService) {
|
|
@@ -237,6 +261,18 @@ export default function subagentsWorkflowExtension(pi: ExtensionAPI): void {
|
|
|
237
261
|
console.warn("[subagents] expired session file cleanup failed:", err);
|
|
238
262
|
}
|
|
239
263
|
|
|
264
|
+
// ADR-035 启动恢复:扫描 manifest tmp 残留(崩溃打断的 writeManifest 留下),
|
|
265
|
+
// 每次 session_start 都调(与上方 maybeCleanupExpiredSessionFiles 一致)。
|
|
266
|
+
try {
|
|
267
|
+
const recovered = await service.recoverManifestTmpFiles();
|
|
268
|
+
if (recovered.recovered > 0 || recovered.deleted > 0) {
|
|
269
|
+
console.warn(`[subagents] manifest tmp recovery: ${recovered.recovered} promoted, ${recovered.deleted} deleted`);
|
|
270
|
+
}
|
|
271
|
+
} catch (err) {
|
|
272
|
+
void err;
|
|
273
|
+
console.warn("[subagents] manifest tmp recovery failed:", err);
|
|
274
|
+
}
|
|
275
|
+
|
|
240
276
|
try {
|
|
241
277
|
const wtm = new WorktreeManager(agentDir);
|
|
242
278
|
wtm.scan();
|
|
@@ -303,7 +339,7 @@ export default function subagentsWorkflowExtension(pi: ExtensionAPI): void {
|
|
|
303
339
|
// ════════════════════════════════════════════════════════════
|
|
304
340
|
// model_select:用户切换 model 时刷新缓存
|
|
305
341
|
// ════════════════════════════════════════════════════════════
|
|
306
|
-
pi.on("model_select", (event:
|
|
342
|
+
pi.on("model_select", (event: ModelSelectEvent, ctx: ExtensionContext) => {
|
|
307
343
|
const service = getModelConfigService();
|
|
308
344
|
if (service && typeof service.setCtxModel === "function") {
|
|
309
345
|
service.setCtxModel(event.model);
|
|
@@ -319,7 +355,7 @@ export default function subagentsWorkflowExtension(pi: ExtensionAPI): void {
|
|
|
319
355
|
// ════════════════════════════════════════════════════════════
|
|
320
356
|
// session_tree:切分支前 pause 所有 running run
|
|
321
357
|
// ════════════════════════════════════════════════════════════
|
|
322
|
-
pi.on("session_tree", async (_event:
|
|
358
|
+
pi.on("session_tree", async (_event: SessionTreeEvent, ctx: ExtensionContext) => {
|
|
323
359
|
const sessionId = ctx.sessionManager.getSessionId();
|
|
324
360
|
lsRef.lastSessionId = sessionId;
|
|
325
361
|
|
|
@@ -355,6 +391,20 @@ export default function subagentsWorkflowExtension(pi: ExtensionAPI): void {
|
|
|
355
391
|
cleanupAllFiles(state.activeTempFiles);
|
|
356
392
|
sessionState.delete(sessionId);
|
|
357
393
|
}
|
|
394
|
+
|
|
395
|
+
// M2: 清理 dialog queue 运行时状态(queue/current/processing)。
|
|
396
|
+
// [#10] 先 rejectAll() settle 所有 pending dialog Promise(防闭包泄漏:未 settle 的
|
|
397
|
+
// Promise 持有 resolve/reject 闭包及 handler 上下文,session 退出后仍挂在全球队列上),
|
|
398
|
+
// 再 clear() 重置 queue/current/processing(防异常退出后 processing=true 卡死下次 session)。
|
|
399
|
+
// rejectAll() 由 dialog-queue.ts 提供(Group B 新增);若其内部已 reset 状态,此处 clear() 为幂等兜底。
|
|
400
|
+
// 单 session 假设(M-2,同 lastSessionId):rejectAll() 清空进程级单例的所有 pending,
|
|
401
|
+
// 依赖 Pi 单进程单 session 串行保证——不会误清其他 session。多 session 并发的迁移策略
|
|
402
|
+
// 见 DialogGlobalQueue 类注释(rejectAllForSession)。
|
|
403
|
+
// channel registry 不清:跨 session 持久是有意设计(ask-user 扩展注册的 channel handler
|
|
404
|
+
// 在 /new /resume /fork 时不丢失注册)。
|
|
405
|
+
const dialogQueue = getOrCreateDialogQueue();
|
|
406
|
+
dialogQueue.rejectAll();
|
|
407
|
+
dialogQueue.clear();
|
|
358
408
|
});
|
|
359
409
|
|
|
360
410
|
// ════════════════════════════════════════════════════════════
|
|
@@ -465,3 +515,37 @@ export default function subagentsWorkflowExtension(pi: ExtensionAPI): void {
|
|
|
465
515
|
lazyDeps,
|
|
466
516
|
);
|
|
467
517
|
}
|
|
518
|
+
|
|
519
|
+
// ============================================================
|
|
520
|
+
// 进程级单例(channel registry + dialog queue)
|
|
521
|
+
// ============================================================
|
|
522
|
+
|
|
523
|
+
// channel registry 经 channel-registry-access.ts 公开访问(跨扩展 API)。
|
|
524
|
+
// dialog queue 仍为本模块私有——无外部消费者。
|
|
525
|
+
const DIALOG_QUEUE_KEY = Symbol.for("@zhushanwen/pi-subagents.dialogQueue");
|
|
526
|
+
|
|
527
|
+
/** 获取或创建进程级 dialog queue 单例。
|
|
528
|
+
* L2 跨子进程串行队列——所有子进程的 dialog 类请求共享同一队列实例。 */
|
|
529
|
+
function getOrCreateDialogQueue(): DialogGlobalQueue {
|
|
530
|
+
let queue = Reflect.get(globalThis, DIALOG_QUEUE_KEY) as DialogGlobalQueue | undefined;
|
|
531
|
+
if (!queue) {
|
|
532
|
+
queue = new DialogGlobalQueue();
|
|
533
|
+
Reflect.set(globalThis, DIALOG_QUEUE_KEY, queue);
|
|
534
|
+
}
|
|
535
|
+
return queue;
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
// ============================================================
|
|
539
|
+
// Public cross-extension API(channel handler 注册入口)
|
|
540
|
+
// ============================================================
|
|
541
|
+
//
|
|
542
|
+
// 跨扩展消费者(ask-user 等)通过包根 import 注册 channel handler,
|
|
543
|
+
// 让 subagent 子进程的 UI 请求(ask_user 等)透传到主进程渲染。
|
|
544
|
+
// 重新导出 channel-registry-access 的公开 API——稳定 surface,
|
|
545
|
+
// 内部存储实现演进不影响消费者。
|
|
546
|
+
|
|
547
|
+
export {
|
|
548
|
+
getOrCreateChannelRegistry,
|
|
549
|
+
type UiChannelRegistry,
|
|
550
|
+
type ChannelHandler,
|
|
551
|
+
} from "./execution/channel-registry-access.ts";
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
// Behavioral tests for weak-model parameter-misuse detectors.
|
|
2
|
+
//
|
|
3
|
+
// Complements the source-text prompt-quality tests (subagent-tool-prompt.test.ts /
|
|
4
|
+
// workflow-tool-prompt.test.ts): those lock that the Correct examples / anti-pattern
|
|
5
|
+
// STRINGS exist in source; these lock the actual trigger/no-trigger LOGIC, so a
|
|
6
|
+
// refactor that inverts a condition or swaps keys cannot pass just by keeping the
|
|
7
|
+
// literal string alive.
|
|
8
|
+
//
|
|
9
|
+
// Covers the detectors added in the weak-model-robustness PR:
|
|
10
|
+
// - subagent hasFlattenedStartFields (startParam envelope missing)
|
|
11
|
+
// - workflow findFlattenedArgKeys (args sub-fields flattened to top level — P0)
|
|
12
|
+
|
|
13
|
+
import { describe, expect, it } from "vitest";
|
|
14
|
+
|
|
15
|
+
import { hasFlattenedStartFields } from "../subagent-tool";
|
|
16
|
+
import { findFlattenedArgKeys } from "../tool-workflow";
|
|
17
|
+
|
|
18
|
+
describe("hasFlattenedStartFields (subagent startParam flatten detector)", () => {
|
|
19
|
+
it("triggers when task/slug flattened to top level (the original failure mode)", () => {
|
|
20
|
+
expect(hasFlattenedStartFields({ action: "start", task: "x", slug: "s" })).toBe(true);
|
|
21
|
+
expect(hasFlattenedStartFields({ action: "start", task: "x" })).toBe(true);
|
|
22
|
+
expect(hasFlattenedStartFields({ action: "start", slug: "s" })).toBe(true);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it("does NOT trigger when startParam envelope is present (correct nesting)", () => {
|
|
26
|
+
expect(
|
|
27
|
+
hasFlattenedStartFields({ action: "start", startParam: { task: "x", slug: "s" } }),
|
|
28
|
+
).toBe(false);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("does NOT trigger when neither task nor slug is present", () => {
|
|
32
|
+
expect(hasFlattenedStartFields({ action: "start" })).toBe(false);
|
|
33
|
+
expect(hasFlattenedStartFields({ action: "list" })).toBe(false);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("returns false for non-object input", () => {
|
|
37
|
+
expect(hasFlattenedStartFields(null)).toBe(false);
|
|
38
|
+
expect(hasFlattenedStartFields(undefined)).toBe(false);
|
|
39
|
+
expect(hasFlattenedStartFields("start")).toBe(false);
|
|
40
|
+
expect(hasFlattenedStartFields(42)).toBe(false);
|
|
41
|
+
});
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
describe("findFlattenedArgKeys (workflow args flatten detector — P0)", () => {
|
|
45
|
+
it("triggers when args sub-fields flattened to top level", () => {
|
|
46
|
+
expect(findFlattenedArgKeys({ action: "run", name: "chain", task: "x" })).toEqual(["task"]);
|
|
47
|
+
expect(findFlattenedArgKeys({ action: "run", name: "x", items: ["a"] })).toEqual(["items"]);
|
|
48
|
+
expect(
|
|
49
|
+
findFlattenedArgKeys({ action: "run", name: "x", task: "t", perspectives: ["p"] }),
|
|
50
|
+
).toEqual(["task", "perspectives"]);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("does NOT trigger when fields correctly nested in args", () => {
|
|
54
|
+
expect(
|
|
55
|
+
findFlattenedArgKeys({ action: "run", name: "x", args: { task: "x", items: ["a"] } }),
|
|
56
|
+
).toEqual([]);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("edge: key present at BOTH top-level and inside args is NOT flagged", () => {
|
|
60
|
+
// 同时传 args.task 和顶层 task:args 已提供,顶层冗余被忽略,不算平铺。
|
|
61
|
+
// 这是 reviewer 点名的 untested edge。
|
|
62
|
+
expect(
|
|
63
|
+
findFlattenedArgKeys({ action: "run", name: "x", args: { task: "x" }, task: "y" }),
|
|
64
|
+
).toEqual([]);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("does NOT trigger when no known arg keys present", () => {
|
|
68
|
+
expect(findFlattenedArgKeys({ action: "run", name: "x", args: {} })).toEqual([]);
|
|
69
|
+
expect(findFlattenedArgKeys({ action: "status" })).toEqual([]);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("returns [] for non-object input", () => {
|
|
73
|
+
expect(findFlattenedArgKeys(null)).toEqual([]);
|
|
74
|
+
expect(findFlattenedArgKeys(undefined)).toEqual([]);
|
|
75
|
+
});
|
|
76
|
+
});
|
|
@@ -9,11 +9,12 @@
|
|
|
9
9
|
// 删掉或弱化。读源码而非 import,避免 mock 链(subagent-tool.ts 依赖 pi-ai/
|
|
10
10
|
// typebox/pi-tui/ExtensionAPI 等值导入)。
|
|
11
11
|
|
|
12
|
-
import { describe, expect, it } from "vitest";
|
|
13
12
|
import { readFileSync } from "node:fs";
|
|
14
|
-
import { join
|
|
13
|
+
import { dirname,join } from "node:path";
|
|
15
14
|
import { fileURLToPath } from "node:url";
|
|
16
15
|
|
|
16
|
+
import { describe, expect, it } from "vitest";
|
|
17
|
+
|
|
17
18
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
18
19
|
const SUBAGENT_TOOL_SRC = readFileSync(
|
|
19
20
|
join(__dirname, "../subagent-tool.ts"),
|
|
@@ -30,10 +31,12 @@ function extractDescription(src: string): string {
|
|
|
30
31
|
const DESCRIPTION = extractDescription(SUBAGENT_TOOL_SRC);
|
|
31
32
|
|
|
32
33
|
describe("subagent tool description — 行为约束器(非功能说明书)", () => {
|
|
33
|
-
it("词数 ≤
|
|
34
|
+
it("词数 ≤ 550(高风险 description 密度上限)", () => {
|
|
34
35
|
// 高风险 tool 的 description 应聚焦约束而非功能铺陈;过长会稀释信号。
|
|
36
|
+
// 上限从 400 放宽到 550:补了 JSON 调用正例段(start/list/cancel 三 action 完整 JSON),
|
|
37
|
+
// 正例对弱模型首次调用用对参数的价值 > 节省这点 description 预算。
|
|
35
38
|
const words = DESCRIPTION.trim().split(/\s+/).filter(Boolean).length;
|
|
36
|
-
expect(words).toBeLessThanOrEqual(
|
|
39
|
+
expect(words).toBeLessThanOrEqual(550);
|
|
37
40
|
});
|
|
38
41
|
|
|
39
42
|
it("含 'When to delegate' 调用条件段(何时委派 vs 自己做)", () => {
|
|
@@ -81,4 +84,24 @@ describe("subagent tool description — 行为约束器(非功能说明书)"
|
|
|
81
84
|
expect(DESCRIPTION).toMatch(/sequential/);
|
|
82
85
|
expect(DESCRIPTION).toMatch(/SAME message/i);
|
|
83
86
|
});
|
|
87
|
+
|
|
88
|
+
it("Examples 段含完整 JSON 正例(含 startParam 嵌套结构)", () => {
|
|
89
|
+
// 弱模型信任 schema 结构信号 > 文本信号,容易把 task/slug 平铺到顶层。
|
|
90
|
+
// description 必须有完整 JSON 正例,让模型能直接照抄 startParam 嵌套结构。
|
|
91
|
+
expect(DESCRIPTION).toContain('{"action":"start","startParam"');
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it("Anti-patterns 段含参数结构反例(top level 平铺 task/slug)", () => {
|
|
95
|
+
// 显式说明 task/slug 不能平铺到顶层,必须嵌在 startParam 里。
|
|
96
|
+
expect(DESCRIPTION).toContain("top level");
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
describe("subagent tool runtime handler — 错误文案含纠正正例", () => {
|
|
101
|
+
// 读源码文本断言 executeSubagent 的平铺检测 throw 含 Correct 正例,
|
|
102
|
+
// 让弱模型撞错后第二次能直接照抄正确形态。
|
|
103
|
+
it("subagent-tool.ts 含 runtime 平铺检测 throw + Correct 纠正正例", () => {
|
|
104
|
+
expect(SUBAGENT_TOOL_SRC).toContain("Correct:");
|
|
105
|
+
expect(SUBAGENT_TOOL_SRC).toContain("params.action === \"start\" && !params.startParam");
|
|
106
|
+
});
|
|
84
107
|
});
|
|
@@ -7,11 +7,12 @@
|
|
|
7
7
|
// 本测试用源码断言(读 .ts 文件文本)验证提示词内容,避免 import 重 mock 链
|
|
8
8
|
// (tool-workflow.ts 依赖 pi-ai/typebox/pi-tui/lifecycle 等值导入)。
|
|
9
9
|
|
|
10
|
-
import { describe, expect, it } from "vitest";
|
|
11
10
|
import { readFileSync } from "node:fs";
|
|
12
|
-
import { join
|
|
11
|
+
import { dirname,join } from "node:path";
|
|
13
12
|
import { fileURLToPath } from "node:url";
|
|
14
13
|
|
|
14
|
+
import { describe, expect, it } from "vitest";
|
|
15
|
+
|
|
15
16
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
16
17
|
const TOOL_WORKFLOW_SRC = readFileSync(
|
|
17
18
|
join(__dirname, "../tool-workflow.ts"),
|
|
@@ -43,6 +44,27 @@ describe("U1: workflow tool prompt mentions built-in workflows", () => {
|
|
|
43
44
|
expect(TOOL_WORKFLOW_SRC).toMatch(/workflow run .+--args/i);
|
|
44
45
|
});
|
|
45
46
|
|
|
47
|
+
it("promptGuidelines 含 JSON 调用正例(run/status/lifecycle/retry-node)", () => {
|
|
48
|
+
// 弱模型信任 schema 结构信号 > 文本信号,容易把 args 子字段平铺到顶层。
|
|
49
|
+
// promptGuidelines 必须有完整 JSON 调用正例,让模型能直接照抄 {"action":"run",...} 嵌套结构。
|
|
50
|
+
expect(TOOL_WORKFLOW_SRC).toContain('{"action":"run"');
|
|
51
|
+
expect(TOOL_WORKFLOW_SRC).toContain("Call shapes (JSON)");
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("promptGuidelines 含参数结构反例(args 平铺到顶层)", () => {
|
|
55
|
+
// 显式说明 args 子字段不能平铺到顶层,必须嵌在 args 里。
|
|
56
|
+
expect(TOOL_WORKFLOW_SRC).toContain("args");
|
|
57
|
+
expect(TOOL_WORKFLOW_SRC).toContain("Anti-patterns");
|
|
58
|
+
expect(TOOL_WORKFLOW_SRC).toContain("top level");
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("runtime handler 错误文案含 Correct 纠正正例 + 平铺检测", () => {
|
|
62
|
+
// 读源码文本断言 actionRun/必填校验的错误文案含 Correct 正例,
|
|
63
|
+
// 让弱模型撞错后第二次能直接照抄正确形态。KNOWN_ARG_KEYS 证明平铺检测存在。
|
|
64
|
+
expect(TOOL_WORKFLOW_SRC).toContain("Correct:");
|
|
65
|
+
expect(TOOL_WORKFLOW_SRC).toContain("KNOWN_ARG_KEYS");
|
|
66
|
+
});
|
|
67
|
+
|
|
46
68
|
it("tool-workflow-script.ts list action 的 promptGuidelines 含 workflow run 交叉引用", () => {
|
|
47
69
|
// 反向交叉引用:list 的指引里要提到用 workflow tool 的 run action 启动脚本。
|
|
48
70
|
expect(TOOL_WORKFLOW_SCRIPT_SRC).toMatch(/workflow.*tool.*run|run.*workflow.*tool/i);
|
package/src/interface/format.ts
CHANGED
|
@@ -176,6 +176,8 @@ export function statusGlyph(status: ExecutionStatus): { icon: string | undefined
|
|
|
176
176
|
return { icon: "✗", color: "error" };
|
|
177
177
|
case "cancelled":
|
|
178
178
|
return { icon: "■", color: "muted" };
|
|
179
|
+
case "crashed":
|
|
180
|
+
return { icon: "✝", color: "error" };
|
|
179
181
|
default:
|
|
180
182
|
// 防御:运行时 status 可能是意外值(SDK 投影异常/未来新增状态),兜底为 running 语义
|
|
181
183
|
return { icon: undefined, color: "accent" };
|
|
@@ -9,6 +9,12 @@
|
|
|
9
9
|
// content(JSON 字符串)给 LLM,details(SubagentToolResult)给 renderResult,同源同处生成。
|
|
10
10
|
|
|
11
11
|
import type { AgentToolResult } from "@mariozechner/pi-coding-agent";
|
|
12
|
+
import {
|
|
13
|
+
guiComponent,
|
|
14
|
+
type GuiContext,
|
|
15
|
+
guiResult,
|
|
16
|
+
isGuiCapable,
|
|
17
|
+
} from "@xyz-agent/extension-protocol";
|
|
12
18
|
|
|
13
19
|
import { SLUG_MAX_LENGTH } from "../execution/execute-options-mapper.ts";
|
|
14
20
|
import { computeElapsedSeconds } from "../execution/execution-record.ts";
|
|
@@ -22,12 +28,6 @@ import type {
|
|
|
22
28
|
SubagentRecord,
|
|
23
29
|
SubagentToolResult,
|
|
24
30
|
} from "../execution/types.ts";
|
|
25
|
-
import {
|
|
26
|
-
guiComponent,
|
|
27
|
-
type GuiContext,
|
|
28
|
-
guiResult,
|
|
29
|
-
isGuiCapable,
|
|
30
|
-
} from "@xyz-agent/extension-protocol";
|
|
31
31
|
import { mapRunIcon, mapRunStatus } from "./gui-mappers.ts";
|
|
32
32
|
|
|
33
33
|
// ============================================================
|
|
@@ -40,7 +40,10 @@ const DEFAULT_LIST_LIMIT = 20;
|
|
|
40
40
|
const MAX_LIST_LIMIT = 100;
|
|
41
41
|
|
|
42
42
|
/** background 启动提示文案(spec FR-3 bgResponse.message)。 */
|
|
43
|
-
const BG_MESSAGE = "detached, will notify on completion";
|
|
43
|
+
const BG_MESSAGE = "detached, will notify on completion (auto-injected message, do not poll)";
|
|
44
|
+
|
|
45
|
+
/** subagentId(UUID)在 GUI header 的截断显示长度。 */
|
|
46
|
+
const SUBAGENT_ID_PREVIEW = 8;
|
|
44
47
|
|
|
45
48
|
// ============================================================
|
|
46
49
|
// 入参 / 出参类型
|
|
@@ -49,7 +52,7 @@ const BG_MESSAGE = "detached, will notify on completion";
|
|
|
49
52
|
/** start 入参(从 tool params.startParam 来,task + slug 必填)。 */
|
|
50
53
|
export interface StartHandlerInput {
|
|
51
54
|
task?: string;
|
|
52
|
-
/** 短标签(≤
|
|
55
|
+
/** 短标签(≤35 字符,kebab-case),必填。 */
|
|
53
56
|
slug?: string;
|
|
54
57
|
agent?: string;
|
|
55
58
|
model?: string;
|
|
@@ -141,7 +144,7 @@ export async function startHandler(
|
|
|
141
144
|
// slug 必填 + 空白校验 + 长度校验(≤ SLUG_MAX_LENGTH 字符)
|
|
142
145
|
const slug = input.slug?.trim();
|
|
143
146
|
if (!slug) throw new Error("startParam.slug is required (and must not be whitespace-only)");
|
|
144
|
-
if (slug.length > SLUG_MAX_LENGTH) throw new Error(`startParam.slug must be ≤${SLUG_MAX_LENGTH} chars (got ${slug.length})
|
|
147
|
+
if (slug.length > SLUG_MAX_LENGTH) throw new Error(`startParam.slug must be ≤${SLUG_MAX_LENGTH} chars (got ${slug.length}). Shorten to a kebab-case label, e.g. "fix-login", "extract-urls".`);
|
|
145
148
|
|
|
146
149
|
const handle = await service.execute({
|
|
147
150
|
task,
|
|
@@ -267,8 +270,15 @@ export function adapter(
|
|
|
267
270
|
? { ...result, __gui__: guiResult(buildGuiComponent(action, input, result)) }
|
|
268
271
|
: result;
|
|
269
272
|
|
|
273
|
+
// [W3 修复] list action 追加 reminder text block:LLM 调 list 时提醒不要轮询。
|
|
274
|
+
// reminder 作为第二个 text block(独立追加,不污染 details/JSON schema)。
|
|
275
|
+
// 只有 list 触发——start 的 reminder 已在 BG_MESSAGE 里;cancel 无需。
|
|
276
|
+
const reminder = action === "list"
|
|
277
|
+
? "\n\nReminder: Subagent completion is auto-notified via injected message (deliverAs: steer). Do NOT poll in a loop — there is no poll action. Use action:'list' only when you concretely need state, then continue working or stop."
|
|
278
|
+
: "";
|
|
279
|
+
|
|
270
280
|
return {
|
|
271
|
-
content: [{ type: "text", text }],
|
|
281
|
+
content: [{ type: "text", text }, { type: "text", text: reminder }],
|
|
272
282
|
details,
|
|
273
283
|
};
|
|
274
284
|
}
|
|
@@ -284,7 +294,7 @@ export function buildGuiComponent(
|
|
|
284
294
|
// 利用 input.domain 的身份信息,让并发 subagent 可区分。
|
|
285
295
|
const d = input.domain as StartHandlerResult;
|
|
286
296
|
return guiComponent("card", {
|
|
287
|
-
header: d.slug ? `${d.slug}` : d.subagentId.slice(0,
|
|
297
|
+
header: d.slug ? `${d.slug}` : d.subagentId.slice(0, SUBAGENT_ID_PREVIEW),
|
|
288
298
|
body: [guiComponent("stats-line", {
|
|
289
299
|
items: [{ value: "running", severity: "ok" }],
|
|
290
300
|
})],
|