@zhushanwen/pi-subagent-workflow 8.14.3 → 8.14.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +11 -10
- package/relay/relay.mjs +1 -1
- package/skills/subagent-ext-config/SKILL.md +1 -1
- package/src/host/pi-host.ts +1 -1
- package/src/index.ts +46 -410
- package/src/injectors/workflow-list-injector.ts +6 -1
- package/src/interface/__tests__/tool-subagents.test.ts +494 -0
- package/src/interface/bg-notify-render.ts +4 -27
- package/src/interface/commands.ts +3 -5
- package/src/interface/format.ts +2 -2
- package/src/interface/helpers.ts +86 -28
- package/src/interface/id-preview.ts +12 -0
- package/src/interface/subagent-actions.ts +3 -5
- package/src/interface/subagent-tool-schema.ts +7 -17
- package/src/interface/subagent-tool.ts +5 -13
- package/src/interface/subagents.ts +2 -1
- package/src/interface/tool-result.ts +40 -0
- package/src/interface/tool-shared.ts +109 -0
- package/src/interface/tool-subagents.ts +310 -0
- package/src/interface/tool-workflow-script.ts +5 -9
- package/src/interface/tool-workflow.ts +40 -59
- package/src/jsonl-run-store.ts +65 -5
- package/src/session-lifecycle.ts +184 -40
- package/src/workflow-events.ts +576 -0
|
@@ -36,9 +36,14 @@ import { createResourceListInjector } from "./resource-list-injector.ts";
|
|
|
36
36
|
*
|
|
37
37
|
* 引导语与具体 workflow 解耦:不写死内置名(列表本身已含全部 workflow,名字/描述
|
|
38
38
|
* 每 turn 由 @pi-meta 动态注入),只给通用路由指引 + read location 参数指针。
|
|
39
|
+
*
|
|
40
|
+
* 末句 = 批量入口的 pi 壳附加句(D5③):条目侧 when/notFor 走 fan-out.js 的 @pi-meta
|
|
41
|
+
* (宿主中立、双宿主共享,注入面只渲染 name/description/location,元数据经 read
|
|
42
|
+
* <location> 取);「subagents tool 是 fan-out 的执行体」是 pi 壳专属表述,故放这里
|
|
43
|
+
* 而不进模板 meta——zsw 宿主清单注入不会照搬本句。
|
|
39
44
|
*/
|
|
40
45
|
export const WORKFLOW_LIST_GUIDE =
|
|
41
|
-
"The following workflows are available. Do NOT call list to discover available workflows — they are listed below; use list only for running state. All listed workflows run directly via action:run — do NOT use workflow-script generate for any listed workflow. For parameter details, read the <location> script file (script header has @pi-meta parameters + usage).";
|
|
46
|
+
"The following workflows are available. Do NOT call list to discover available workflows — they are listed below; use list only for running state. All listed workflows run directly via action:run — do NOT use workflow-script generate for any listed workflow. For parameter details, read the <location> script file (script header has @pi-meta parameters + usage). For 2+ independent tasks dispatched together, prefer the `subagents` tool — it drives the fan-out workflow for you.";
|
|
42
47
|
|
|
43
48
|
/**
|
|
44
49
|
* 解析 workflow .js 文件的 meta(name + description),经 IF1 parseResourceMeta。
|
|
@@ -0,0 +1,494 @@
|
|
|
1
|
+
// src/interface/__tests__/tool-subagents.test.ts
|
|
2
|
+
//
|
|
3
|
+
// u2 契约测试:`subagents` 批量 tool(schema 平铺 D2 / handler 转译 D3 / 错误规格 D9 /
|
|
4
|
+
// 集合收录 D1 / 渲染不构造 __gui__ D8)。
|
|
5
|
+
//
|
|
6
|
+
// 三视角(docs/TEST-STRATEGY.md §3):
|
|
7
|
+
// - 构建者(白盒):注册面 schema 形态(顶层键集/类型/必填形态)+ 转译 spec 组装。
|
|
8
|
+
// - 使用者(黑盒):模型看到的 tool description/guidelines 分工句;execute 的返回文案
|
|
9
|
+
// (一条通知 + 单次 status 恢复出口 + abort 指引)与错误文案(Correct 示例)。
|
|
10
|
+
// - 观察者(真值源):packages/shared/src/constants.ts 真模块(集合收录 SSOT)——
|
|
11
|
+
// 动态 import 真文件,不用文本断言。
|
|
12
|
+
//
|
|
13
|
+
// mock 策略:lifecycle 深路径 stub(runWorkflow 为 vi.fn——不起真 Worker、真 worker
|
|
14
|
+
// 线程只验证转译与 spec 组装);registry/store 用最小 stub。框架:vitest(禁 node:test)。
|
|
15
|
+
|
|
16
|
+
import { readFileSync } from "node:fs";
|
|
17
|
+
import { dirname, join } from "node:path";
|
|
18
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
19
|
+
|
|
20
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
21
|
+
|
|
22
|
+
/** 桩化 lifecycle——runWorkflow 为 vi.fn(只测入口转译面,不起 Worker)。 */
|
|
23
|
+
vi.mock("@zhushanwen/subagent-core/orchestration/lifecycle.ts", () => ({
|
|
24
|
+
runWorkflow: vi.fn(),
|
|
25
|
+
abortRun: vi.fn(),
|
|
26
|
+
}));
|
|
27
|
+
|
|
28
|
+
import { runWorkflow } from "@zhushanwen/subagent-core/orchestration/lifecycle.ts";
|
|
29
|
+
import { MAX_TIMER_DELAY_MS, SLUG_MAX_LENGTH } from "@zhushanwen/subagent-core";
|
|
30
|
+
import {
|
|
31
|
+
FAN_OUT_SCRIPT_NAME,
|
|
32
|
+
generateBatchSlug,
|
|
33
|
+
registerSubagentsTool,
|
|
34
|
+
runSubagentsBatch,
|
|
35
|
+
} from "../tool-subagents.ts";
|
|
36
|
+
import { REENTRY_BUSY_MESSAGE, type ReentryGuardRef } from "../reentry-guard.ts";
|
|
37
|
+
|
|
38
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
39
|
+
const TOOL_SRC = readFileSync(join(__dirname, "../tool-subagents.ts"), "utf-8");
|
|
40
|
+
|
|
41
|
+
// ── 注册面最小类型(单层 cast:目标类型全必填,非 no-unsafe-cast 命中的全可选结构断言)──
|
|
42
|
+
|
|
43
|
+
interface CapturedProperty {
|
|
44
|
+
type?: string;
|
|
45
|
+
optional?: boolean;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
interface CapturedTool {
|
|
49
|
+
name: string;
|
|
50
|
+
label: string;
|
|
51
|
+
description: string;
|
|
52
|
+
promptSnippet: string;
|
|
53
|
+
promptGuidelines: string[];
|
|
54
|
+
parameters: { type?: string; properties: Record<string, CapturedProperty> };
|
|
55
|
+
execute: (
|
|
56
|
+
toolCallId: string,
|
|
57
|
+
params: unknown,
|
|
58
|
+
signal: undefined,
|
|
59
|
+
onUpdate: unknown,
|
|
60
|
+
ctx: unknown,
|
|
61
|
+
) => Promise<{ content: Array<{ type: string; text: string }>; details: Record<string, unknown> }>;
|
|
62
|
+
renderCall: (args: Record<string, unknown>, theme: ThemeStub, ctx?: unknown) => { render(width: number): string[] };
|
|
63
|
+
renderResult: (result: { content?: Array<{ type: string; text?: string }> }, options: unknown, theme: ThemeStub) => { render(width: number): string[] };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
interface ThemeStub {
|
|
67
|
+
fg(token: string, text: string): string;
|
|
68
|
+
bold(text: string): string;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** 注册并取回 tool 定义(fake pi 捕获 registerTool 入参)。 */
|
|
72
|
+
function captureTool(reentryRef: ReentryGuardRef, registry: Record<string, unknown>): CapturedTool {
|
|
73
|
+
const captured: unknown[] = [];
|
|
74
|
+
const pi = {
|
|
75
|
+
registerTool: (tool: unknown): void => {
|
|
76
|
+
captured.push(tool);
|
|
77
|
+
},
|
|
78
|
+
};
|
|
79
|
+
registerSubagentsTool(pi as never, makeDeps(registry) as never, reentryRef);
|
|
80
|
+
expect(captured).toHaveLength(1);
|
|
81
|
+
return captured[0] as CapturedTool;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// ── registry / deps stubs ──
|
|
85
|
+
|
|
86
|
+
/** fan-out 执行体 stub(toExecutable 形态与真 WorkflowScript 同签名)。 */
|
|
87
|
+
function makeFanOutScript() {
|
|
88
|
+
return {
|
|
89
|
+
name: FAN_OUT_SCRIPT_NAME,
|
|
90
|
+
path: "/builtin/workflows/fan-out.js",
|
|
91
|
+
available: true,
|
|
92
|
+
sourceCode: "// fan-out source",
|
|
93
|
+
meta: {
|
|
94
|
+
description: "N 个已知独立任务并行派发并全量收集",
|
|
95
|
+
parameters: { type: "object", properties: { tasks: { type: "array" } } },
|
|
96
|
+
},
|
|
97
|
+
toExecutable: () => "// fan-out executable",
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function makeRegistry(script?: ReturnType<typeof makeFanOutScript>): Record<string, unknown> {
|
|
102
|
+
return {
|
|
103
|
+
get: vi.fn().mockResolvedValue(script ?? makeFanOutScript()),
|
|
104
|
+
getPath: vi.fn().mockResolvedValue(undefined),
|
|
105
|
+
loadAll: vi.fn().mockResolvedValue([script ?? makeFanOutScript()]),
|
|
106
|
+
invalidate: vi.fn(),
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** 最小 deps stub(runWorkflow 已 mock;store 只消费 stateFilePath)。 */
|
|
111
|
+
function makeDeps(registry: Record<string, unknown>): Record<string, unknown> {
|
|
112
|
+
return {
|
|
113
|
+
runs: new Map(),
|
|
114
|
+
store: { stateFilePath: (id: string) => `/tmp/state/${id}.jsonl` },
|
|
115
|
+
registry,
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// ── theme stub(renderCall 只用 fg/bold)──
|
|
120
|
+
|
|
121
|
+
function makeTheme(): ThemeStub {
|
|
122
|
+
return {
|
|
123
|
+
fg: (token, text) => `<${token}>${text}</${token}>`,
|
|
124
|
+
bold: (text) => `<b>${text}</b>`,
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** 取 TUI 组件构造时传入的文本(Text mock 把首参存为实例字段)。 */
|
|
129
|
+
function renderedText(component: { render(width: number): string[] }): string {
|
|
130
|
+
const obj: Record<string, unknown> = component as unknown as Record<string, unknown>;
|
|
131
|
+
for (const key of Object.keys(obj)) {
|
|
132
|
+
const value = obj[key];
|
|
133
|
+
if (typeof value === "string" && value.length > 0) return value;
|
|
134
|
+
}
|
|
135
|
+
return "";
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// ── 真值源:packages/shared/src/constants.ts(集合收录 SSOT)──
|
|
139
|
+
|
|
140
|
+
interface SharedConstantsModule {
|
|
141
|
+
WORKFLOW_TOOL_NAMES: ReadonlySet<string>;
|
|
142
|
+
SUBAGENT_TOOL_NAMES: ReadonlySet<string>;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** 动态 import 真 constants 模块(文本断言会漏「值被改」的漂移,故取真值)。 */
|
|
146
|
+
async function loadSharedConstants(): Promise<SharedConstantsModule> {
|
|
147
|
+
const url = pathToFileURL(join(__dirname, "../../../../../../packages/shared/src/constants.ts")).href;
|
|
148
|
+
const mod: Record<string, unknown> = await import(/* @vite-ignore */ url);
|
|
149
|
+
const workflows = mod.WORKFLOW_TOOL_NAMES;
|
|
150
|
+
const subagents = mod.SUBAGENT_TOOL_NAMES;
|
|
151
|
+
if (!(workflows instanceof Set) || !(subagents instanceof Set)) {
|
|
152
|
+
throw new Error("packages/shared/src/constants.ts 缺 WORKFLOW_TOOL_NAMES / SUBAGENT_TOOL_NAMES 集合导出");
|
|
153
|
+
}
|
|
154
|
+
return { WORKFLOW_TOOL_NAMES: workflows, SUBAGENT_TOOL_NAMES: subagents };
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// ── 公共夹具 ──
|
|
158
|
+
|
|
159
|
+
const TWO_TASKS = ["count .ts files under src", "count .md files under src"];
|
|
160
|
+
|
|
161
|
+
beforeEach(() => {
|
|
162
|
+
vi.mocked(runWorkflow).mockReset();
|
|
163
|
+
vi.mocked(runWorkflow).mockResolvedValue("wf-1700000000000-abcd");
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
afterEach(() => {
|
|
167
|
+
vi.restoreAllMocks();
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
/** 取最近一次 runWorkflow 的 spec(vi.fn 调用记录)。 */
|
|
171
|
+
function lastSpec(): Record<string, unknown> {
|
|
172
|
+
expect(vi.mocked(runWorkflow)).toHaveBeenCalledTimes(1);
|
|
173
|
+
const call = vi.mocked(runWorkflow).mock.calls[0];
|
|
174
|
+
return call[0] as unknown as Record<string, unknown>;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// ══════════════════════════════════════════════════════════════
|
|
178
|
+
describe("schema 契约:一跳扁平 + tasks 必填(D2)", () => {
|
|
179
|
+
it("注册名/label/description 存在,且以 batch 语义自述(one-shot 成员 + 一条通知)", () => {
|
|
180
|
+
const tool = captureTool({ isProcessing: false }, makeRegistry());
|
|
181
|
+
expect(tool.name).toBe("subagents");
|
|
182
|
+
expect(tool.label.length).toBeGreaterThan(0);
|
|
183
|
+
expect(tool.description).toContain("one-shot batch member");
|
|
184
|
+
expect(tool.description).toContain("ONE notification");
|
|
185
|
+
// 分工句(D5①):单数 tool 指路 + 依赖链场景指路 workflow
|
|
186
|
+
expect(tool.description).toContain("`subagent` tool");
|
|
187
|
+
expect(tool.description).toContain("`workflow` tool");
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it("参数全平铺:无 action 分发、无 args 嵌套、无嵌套 object 参数", () => {
|
|
191
|
+
const tool = captureTool({ isProcessing: false }, makeRegistry());
|
|
192
|
+
const keys = Object.keys(tool.parameters.properties);
|
|
193
|
+
expect(keys.sort()).toEqual(
|
|
194
|
+
["agents", "aggregate", "model", "slug", "tasks", "thinkingLevel", "time", "tokens"].sort(),
|
|
195
|
+
);
|
|
196
|
+
expect(keys).not.toContain("action");
|
|
197
|
+
expect(keys).not.toContain("args");
|
|
198
|
+
for (const key of keys) {
|
|
199
|
+
expect(["string", "number", "boolean", "array"]).toContain(tool.parameters.properties[key]?.type);
|
|
200
|
+
}
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
it("tasks 为必填形态(未经 Type.Optional 构造)且类型为数组", () => {
|
|
204
|
+
const tool = captureTool({ isProcessing: false }, makeRegistry());
|
|
205
|
+
expect(tool.parameters.properties.tasks?.type).toBe("array");
|
|
206
|
+
// mock typebox 的 Optional 标记:tasks 不得带该标记(真 typebox 下即 required)
|
|
207
|
+
expect(tool.parameters.properties.tasks?.optional).toBeUndefined();
|
|
208
|
+
// 源码级:tasks 未经 Type.Optional 包裹(防 mock 失真面丢失 required 语义)
|
|
209
|
+
expect(TOOL_SRC).toContain("tasks: Type.Array(");
|
|
210
|
+
expect(TOOL_SRC).not.toContain("Type.Optional(Type.Array(");
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
it("slug 走 SLUG_MAX_LENGTH 上界(schema 第一道关卡 + 运行时第二道)", () => {
|
|
214
|
+
expect(TOOL_SRC).toContain("maxLength: SLUG_MAX_LENGTH");
|
|
215
|
+
expect(TOOL_SRC).toContain("SLUG_MAX_LENGTH");
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
it("文件头与 interface/subagents.ts(/subagents 命令壳)互指防混淆", () => {
|
|
219
|
+
expect(TOOL_SRC).toContain("interface/subagents.ts");
|
|
220
|
+
// 指认对面是 slash 命令(同名不同命名空间)
|
|
221
|
+
expect(TOOL_SRC.toLowerCase()).toContain("slash");
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
it("不构造 details.__gui__(D8:isWorkflow 块分支恒折叠不消费,构造即死代码)", () => {
|
|
225
|
+
// 文件头注释会提到 __gui__(记录 D8 裁决),故断言构造面:无 GUI 协议依赖 +
|
|
226
|
+
// 无描述符构造(属性赋值/工厂调用)。
|
|
227
|
+
expect(TOOL_SRC).not.toContain("@zhushanwen/extension-protocol");
|
|
228
|
+
expect(TOOL_SRC).not.toContain("__gui__:");
|
|
229
|
+
expect(TOOL_SRC).not.toContain("guiResult(");
|
|
230
|
+
expect(TOOL_SRC).not.toContain("isGuiCapable");
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
it("promptGuidelines 含分工句/调用正例/预算默认(弱模型结构信号依赖面)", () => {
|
|
234
|
+
const tool = captureTool({ isProcessing: false }, makeRegistry());
|
|
235
|
+
const guidelines = tool.promptGuidelines.join("\n");
|
|
236
|
+
expect(guidelines).toContain("2+ independent tasks in one dispatch");
|
|
237
|
+
expect(guidelines).toContain('{"tasks":[');
|
|
238
|
+
expect(guidelines).toContain("Do NOT set tokens/time unless the user explicitly requests");
|
|
239
|
+
expect(guidelines).toContain("workflow tool");
|
|
240
|
+
});
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
// ══════════════════════════════════════════════════════════════
|
|
244
|
+
describe("handler 转译:runWorkflow('fan-out') 确定性映射(D3)", () => {
|
|
245
|
+
it("全参数调用 → spec 逐字段映射(scriptSource/budget/slug/model/thinkingLevel/args)", async () => {
|
|
246
|
+
const registry = makeRegistry();
|
|
247
|
+
const result = await runSubagentsBatch(
|
|
248
|
+
{
|
|
249
|
+
tasks: TWO_TASKS,
|
|
250
|
+
agents: "/abs/security.md,/abs/perf.md",
|
|
251
|
+
aggregate: true,
|
|
252
|
+
slug: "foo-tri-review",
|
|
253
|
+
model: "zai-coding-cn/glm-5.3-flash",
|
|
254
|
+
thinkingLevel: "max",
|
|
255
|
+
tokens: 100000,
|
|
256
|
+
time: 600000,
|
|
257
|
+
},
|
|
258
|
+
makeDeps(registry) as never,
|
|
259
|
+
undefined,
|
|
260
|
+
);
|
|
261
|
+
|
|
262
|
+
expect(registry.get).toHaveBeenCalledWith(FAN_OUT_SCRIPT_NAME);
|
|
263
|
+
const spec = lastSpec();
|
|
264
|
+
expect(spec.scriptSource).toBe("// fan-out executable");
|
|
265
|
+
expect(spec.scriptName).toBe(FAN_OUT_SCRIPT_NAME);
|
|
266
|
+
expect(spec.scriptPath).toBe("/builtin/workflows/fan-out.js");
|
|
267
|
+
expect(spec.parameters).toEqual({ type: "object", properties: { tasks: { type: "array" } } });
|
|
268
|
+
expect(spec.args).toEqual({
|
|
269
|
+
tasks: TWO_TASKS,
|
|
270
|
+
agents: "/abs/security.md,/abs/perf.md",
|
|
271
|
+
aggregate: true,
|
|
272
|
+
});
|
|
273
|
+
expect(spec.budgetTokens).toBe(100000);
|
|
274
|
+
expect(spec.budgetTimeMs).toBe(600000);
|
|
275
|
+
expect(spec.slug).toBe("foo-tri-review");
|
|
276
|
+
expect(spec.model).toBe("zai-coding-cn/glm-5.3-flash");
|
|
277
|
+
expect(spec.thinkingLevel).toBe("max");
|
|
278
|
+
// details:无 __gui__,携带 runId/stateFile(程序化消费面)
|
|
279
|
+
expect(Object.keys(result.details)).not.toContain("__gui__");
|
|
280
|
+
expect(result.details).toMatchObject({
|
|
281
|
+
runId: "wf-1700000000000-abcd",
|
|
282
|
+
status: "running",
|
|
283
|
+
scriptName: FAN_OUT_SCRIPT_NAME,
|
|
284
|
+
slug: "foo-tri-review",
|
|
285
|
+
taskCount: 2,
|
|
286
|
+
stateFile: "/tmp/state/wf-1700000000000-abcd.jsonl",
|
|
287
|
+
});
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
it("缺省参数不写入 args(不制造「都传/都缺」歧义形态)+ 预算缺省 undefined", async () => {
|
|
291
|
+
await runSubagentsBatch({ tasks: TWO_TASKS }, makeDeps(makeRegistry()) as never, undefined);
|
|
292
|
+
const spec = lastSpec();
|
|
293
|
+
expect(spec.args).toEqual({ tasks: TWO_TASKS });
|
|
294
|
+
expect(spec.budgetTokens).toBeUndefined();
|
|
295
|
+
expect(spec.budgetTimeMs).toBeUndefined();
|
|
296
|
+
expect(spec.model).toBeUndefined();
|
|
297
|
+
expect(spec.thinkingLevel).toBeUndefined();
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
it("返回文案:一条通知 + 单次 status 恢复出口 + abort 指引(设计 §3.1 全文)", async () => {
|
|
301
|
+
const result = await runSubagentsBatch(
|
|
302
|
+
{ tasks: TWO_TASKS, slug: "foo-tri-review" },
|
|
303
|
+
makeDeps(makeRegistry()) as never,
|
|
304
|
+
undefined,
|
|
305
|
+
);
|
|
306
|
+
const text = result.content[0].text;
|
|
307
|
+
expect(text).toContain("Started batch 'foo-tri-review' (wf-1700000000000-abcd) as workflow run 'fan-out' — 2 subagents dispatched in parallel (allSettled).");
|
|
308
|
+
expect(text).toContain("Results arrive as ONE notification when the run settles. Do NOT poll.");
|
|
309
|
+
expect(text).toContain("make a SINGLE status check: workflow tool with runId wf-1700000000000-abcd (recovery exit, not a poll loop).");
|
|
310
|
+
expect(text).toContain("To abort: workflow tool, action abort, runId wf-1700000000000-abcd");
|
|
311
|
+
// status/abort 不复制:本 tool 无 action 参数(schema 已断言),文案只指路 workflow tool
|
|
312
|
+
expect(text).not.toContain('"action":"status"');
|
|
313
|
+
expect(text).not.toContain('"action":"abort"');
|
|
314
|
+
});
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
// ══════════════════════════════════════════════════════════════
|
|
318
|
+
describe("slug 缺省生成 fan-out-<时间短码>(D8 状态面辨识)", () => {
|
|
319
|
+
it("generateBatchSlug 形态稳定且 ≤ SLUG_MAX_LENGTH(35)", () => {
|
|
320
|
+
expect(generateBatchSlug(0)).toBe("fan-out-0");
|
|
321
|
+
const slug = generateBatchSlug(1700000000000);
|
|
322
|
+
expect(slug).toMatch(/^fan-out-[0-9a-z]+$/);
|
|
323
|
+
expect(slug.length).toBeLessThanOrEqual(SLUG_MAX_LENGTH);
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
it("未提供 slug → spec.slug 为生成值(不是 undefined/空)", async () => {
|
|
327
|
+
await runSubagentsBatch({ tasks: TWO_TASKS }, makeDeps(makeRegistry()) as never, undefined);
|
|
328
|
+
expect(String(lastSpec().slug)).toMatch(/^fan-out-[0-9a-z]+$/);
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
it("空白 slug 按缺省处理;显式 slug 原样透传", async () => {
|
|
332
|
+
await runSubagentsBatch({ tasks: TWO_TASKS, slug: " " }, makeDeps(makeRegistry()) as never, undefined);
|
|
333
|
+
expect(String(lastSpec().slug)).toMatch(/^fan-out-[0-9a-z]+$/);
|
|
334
|
+
|
|
335
|
+
vi.mocked(runWorkflow).mockClear();
|
|
336
|
+
await runSubagentsBatch({ tasks: TWO_TASKS, slug: "tri-review" }, makeDeps(makeRegistry()) as never, undefined);
|
|
337
|
+
expect(lastSpec().slug).toBe("tri-review");
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
it("生成的 slug 出现在启动文案与 details(状态面/文本面可对账)", async () => {
|
|
341
|
+
const result = await runSubagentsBatch({ tasks: TWO_TASKS }, makeDeps(makeRegistry()) as never, undefined);
|
|
342
|
+
const generated = result.details.slug;
|
|
343
|
+
expect(String(generated)).toMatch(/^fan-out-[0-9a-z]+$/);
|
|
344
|
+
expect(result.content[0].text).toContain(`Started batch '${String(generated)}'`);
|
|
345
|
+
});
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
// ══════════════════════════════════════════════════════════════
|
|
349
|
+
describe("错误规格(D9)", () => {
|
|
350
|
+
it("tasks 缺失 → throw 带 Correct 示例(runWorkflow 未被调用)", async () => {
|
|
351
|
+
const err = await runSubagentsBatch({} as never, makeDeps(makeRegistry()) as never, undefined)
|
|
352
|
+
.catch((e: unknown) => e as Error);
|
|
353
|
+
expect(err.message).toBe('tasks is required (non-empty string array). Correct: {"tasks":["...","..."]}');
|
|
354
|
+
expect(vi.mocked(runWorkflow)).not.toHaveBeenCalled();
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
it("tasks 空数组 → 同一 Correct 文案", async () => {
|
|
358
|
+
const err = await runSubagentsBatch({ tasks: [] }, makeDeps(makeRegistry()) as never, undefined)
|
|
359
|
+
.catch((e: unknown) => e as Error);
|
|
360
|
+
expect(err.message).toContain("tasks is required (non-empty string array)");
|
|
361
|
+
expect(vi.mocked(runWorkflow)).not.toHaveBeenCalled();
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
it("time 超上界 → 入口 fail-fast(含上限与实际值,可自纠)", async () => {
|
|
365
|
+
const err = await runSubagentsBatch(
|
|
366
|
+
{ tasks: TWO_TASKS, time: MAX_TIMER_DELAY_MS + 1 },
|
|
367
|
+
makeDeps(makeRegistry()) as never,
|
|
368
|
+
undefined,
|
|
369
|
+
).catch((e: unknown) => e as Error);
|
|
370
|
+
expect(err.message).toContain(`time budget ${MAX_TIMER_DELAY_MS + 1} ms exceeds the maximum of ${MAX_TIMER_DELAY_MS} ms`);
|
|
371
|
+
expect(vi.mocked(runWorkflow)).not.toHaveBeenCalled();
|
|
372
|
+
});
|
|
373
|
+
|
|
374
|
+
it("slug 超长 → throw(含上限与建议形态)", async () => {
|
|
375
|
+
const err = await runSubagentsBatch(
|
|
376
|
+
{ tasks: TWO_TASKS, slug: "x".repeat(SLUG_MAX_LENGTH + 1) },
|
|
377
|
+
makeDeps(makeRegistry()) as never,
|
|
378
|
+
undefined,
|
|
379
|
+
).catch((e: unknown) => e as Error);
|
|
380
|
+
expect(err.message).toContain(`slug exceeds ${SLUG_MAX_LENGTH} chars`);
|
|
381
|
+
expect(vi.mocked(runWorkflow)).not.toHaveBeenCalled();
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
it("fan-out 执行体缺席/不可用 → throw 带恢复动作 + 可用清单(按名解析失败的自救指引)", async () => {
|
|
385
|
+
const registry = {
|
|
386
|
+
get: vi.fn().mockResolvedValue(undefined),
|
|
387
|
+
getPath: vi.fn().mockResolvedValue(undefined),
|
|
388
|
+
loadAll: vi.fn().mockResolvedValue([makeFanOutScript()]),
|
|
389
|
+
invalidate: vi.fn(),
|
|
390
|
+
};
|
|
391
|
+
const err = await runSubagentsBatch({ tasks: TWO_TASKS }, makeDeps(registry) as never, undefined)
|
|
392
|
+
.catch((e: unknown) => e as Error);
|
|
393
|
+
expect(err.message).toContain(`Built-in workflow 'fan-out' is not available`);
|
|
394
|
+
expect(err.message).toContain("Recovery:");
|
|
395
|
+
expect(err.message).toContain(" - fan-out:");
|
|
396
|
+
expect(vi.mocked(runWorkflow)).not.toHaveBeenCalled();
|
|
397
|
+
});
|
|
398
|
+
|
|
399
|
+
it("available:false stub 同样走 fail-fast(不静默空源码启动)", async () => {
|
|
400
|
+
const ghost = { ...makeFanOutScript(), available: false };
|
|
401
|
+
const registry = {
|
|
402
|
+
get: vi.fn().mockResolvedValue(ghost),
|
|
403
|
+
getPath: vi.fn().mockResolvedValue(undefined),
|
|
404
|
+
loadAll: vi.fn().mockResolvedValue([]),
|
|
405
|
+
invalidate: vi.fn(),
|
|
406
|
+
};
|
|
407
|
+
await expect(
|
|
408
|
+
runSubagentsBatch({ tasks: TWO_TASKS }, makeDeps(registry) as never, undefined),
|
|
409
|
+
).rejects.toThrow(/not available/);
|
|
410
|
+
expect(vi.mocked(runWorkflow)).not.toHaveBeenCalled();
|
|
411
|
+
});
|
|
412
|
+
});
|
|
413
|
+
|
|
414
|
+
// ══════════════════════════════════════════════════════════════
|
|
415
|
+
describe("reentry guard:与 workflow tool 共用同一实例", () => {
|
|
416
|
+
it("guard 被占用 → throw busy 文案,且不触达 runWorkflow", async () => {
|
|
417
|
+
const tool = captureTool({ isProcessing: true }, makeRegistry());
|
|
418
|
+
await expect(
|
|
419
|
+
tool.execute("call-1", { tasks: TWO_TASKS }, undefined, undefined, undefined),
|
|
420
|
+
).rejects.toThrow(REENTRY_BUSY_MESSAGE);
|
|
421
|
+
expect(vi.mocked(runWorkflow)).not.toHaveBeenCalled();
|
|
422
|
+
});
|
|
423
|
+
|
|
424
|
+
it("成功路径释放 guard(同一 guard 可再次进入)", async () => {
|
|
425
|
+
const guard: ReentryGuardRef = { isProcessing: false };
|
|
426
|
+
const tool = captureTool(guard, makeRegistry());
|
|
427
|
+
await tool.execute("call-1", { tasks: TWO_TASKS }, undefined, undefined, undefined);
|
|
428
|
+
expect(guard.isProcessing).toBe(false);
|
|
429
|
+
await tool.execute("call-2", { tasks: TWO_TASKS }, undefined, undefined, undefined);
|
|
430
|
+
expect(vi.mocked(runWorkflow)).toHaveBeenCalledTimes(2);
|
|
431
|
+
});
|
|
432
|
+
|
|
433
|
+
it("失败路径经 finally 释放 guard(错误不粘住批量入口)", async () => {
|
|
434
|
+
const guard: ReentryGuardRef = { isProcessing: false };
|
|
435
|
+
const tool = captureTool(guard, makeRegistry());
|
|
436
|
+
const err = await tool.execute("call-1", {}, undefined, undefined, undefined).catch((e: unknown) => e as Error);
|
|
437
|
+
expect(err.message).toContain("tasks is required");
|
|
438
|
+
expect(guard.isProcessing).toBe(false);
|
|
439
|
+
});
|
|
440
|
+
|
|
441
|
+
it("signal 已 abort → 起步即 throw(不占 guard)", async () => {
|
|
442
|
+
const guard: ReentryGuardRef = { isProcessing: false };
|
|
443
|
+
const tool = captureTool(guard, makeRegistry());
|
|
444
|
+
const aborted = AbortSignal.abort();
|
|
445
|
+
await expect(
|
|
446
|
+
tool.execute("call-1", { tasks: TWO_TASKS }, aborted as never, undefined, undefined),
|
|
447
|
+
).rejects.toThrow("Operation aborted before start");
|
|
448
|
+
expect(guard.isProcessing).toBe(false);
|
|
449
|
+
expect(vi.mocked(runWorkflow)).not.toHaveBeenCalled();
|
|
450
|
+
});
|
|
451
|
+
});
|
|
452
|
+
|
|
453
|
+
// ══════════════════════════════════════════════════════════════
|
|
454
|
+
describe("集合收录(D1:单收录 WORKFLOW,不进 SUBAGENT)", () => {
|
|
455
|
+
it("'subagents' ∈ WORKFLOW_TOOL_NAMES 且 ∉ SUBAGENT_TOOL_NAMES", async () => {
|
|
456
|
+
const constants = await loadSharedConstants();
|
|
457
|
+
expect(constants.WORKFLOW_TOOL_NAMES.has("subagents")).toBe(true);
|
|
458
|
+
expect(constants.SUBAGENT_TOOL_NAMES.has("subagents")).toBe(false);
|
|
459
|
+
// 既有成员不回归
|
|
460
|
+
expect(constants.WORKFLOW_TOOL_NAMES.has("workflow")).toBe(true);
|
|
461
|
+
expect(constants.SUBAGENT_TOOL_NAMES.has("subagent")).toBe(true);
|
|
462
|
+
});
|
|
463
|
+
});
|
|
464
|
+
|
|
465
|
+
// ══════════════════════════════════════════════════════════════
|
|
466
|
+
describe("TUI 渲染(renderCall/renderResult 既有惯例)", () => {
|
|
467
|
+
it("renderCall 单行:subagents + 任务数 + slug", () => {
|
|
468
|
+
const tool = captureTool({ isProcessing: false }, makeRegistry());
|
|
469
|
+
const text = renderedText(tool.renderCall(
|
|
470
|
+
{ tasks: TWO_TASKS, slug: "foo-tri-review" },
|
|
471
|
+
makeTheme(),
|
|
472
|
+
));
|
|
473
|
+
expect(text).toContain("subagents");
|
|
474
|
+
expect(text).toContain("2 tasks");
|
|
475
|
+
expect(text).toContain("foo-tri-review");
|
|
476
|
+
expect(text).not.toContain("\n");
|
|
477
|
+
});
|
|
478
|
+
|
|
479
|
+
it("renderCall 无 slug/无 tasks 时不崩(streaming partial args 形态)", () => {
|
|
480
|
+
const tool = captureTool({ isProcessing: false }, makeRegistry());
|
|
481
|
+
expect(() => tool.renderCall({}, makeTheme())).not.toThrow();
|
|
482
|
+
expect(() => tool.renderCall({ tasks: "not-an-array" }, makeTheme())).not.toThrow();
|
|
483
|
+
});
|
|
484
|
+
|
|
485
|
+
it("renderResult 透出返回文案(多行保留)", () => {
|
|
486
|
+
const tool = captureTool({ isProcessing: false }, makeRegistry());
|
|
487
|
+
const text = renderedText(tool.renderResult(
|
|
488
|
+
{ content: [{ type: "text", text: "Started batch 'x' (wf-1) as workflow run 'fan-out'" }] },
|
|
489
|
+
undefined,
|
|
490
|
+
makeTheme(),
|
|
491
|
+
));
|
|
492
|
+
expect(text).toContain("Started batch 'x'");
|
|
493
|
+
});
|
|
494
|
+
});
|
|
@@ -79,9 +79,10 @@ interface BgNotifyRecord {
|
|
|
79
79
|
* display:true 时 Pi 调本方法,返回 BorderedBgBox(紫色背景 + 圆角边框)。
|
|
80
80
|
* details 异常 → 返回 undefined 走 Pi 默认渲染(兜底)。
|
|
81
81
|
*
|
|
82
|
-
* details
|
|
83
|
-
*
|
|
84
|
-
*
|
|
82
|
+
* details 形态(单条):BgNotifyRecord(status/agent/id/result/error)。
|
|
83
|
+
* [collect 退役] 批量形态的生产方(sync 批通知 notifyBatch)已删除,但 ledger 合并
|
|
84
|
+
* 投递(同边沿多条 pending 合批)仍会产 { batch: true, items } 批载荷——details
|
|
85
|
+
* 非单条形态走 Pi 默认渲染兜底(数据不丢,TUI 外观降级)。
|
|
85
86
|
*/
|
|
86
87
|
export function renderBgNotifyMessage(
|
|
87
88
|
message: { details?: unknown },
|
|
@@ -90,14 +91,6 @@ export function renderBgNotifyMessage(
|
|
|
90
91
|
): Component | undefined {
|
|
91
92
|
const t = theme as ThemeLike;
|
|
92
93
|
|
|
93
|
-
// 批量分支:多条合并,各自渲染一行
|
|
94
|
-
const batch = extractBatch(message.details);
|
|
95
|
-
if (batch) {
|
|
96
|
-
const lines = batch.flatMap((r) => renderRecordLines(r, t));
|
|
97
|
-
return new BorderedBgBox(lines, t);
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
// 单条分支
|
|
101
94
|
const record = extractBgNotifyRecord(message.details);
|
|
102
95
|
if (!record) return undefined;
|
|
103
96
|
return new BorderedBgBox(renderRecordLines(record, t), t);
|
|
@@ -262,22 +255,6 @@ function renderRecordLines(record: BgNotifyRecord, t: ThemeLike): string[] {
|
|
|
262
255
|
}
|
|
263
256
|
}
|
|
264
257
|
|
|
265
|
-
/**
|
|
266
|
-
* 从 message.details 防御性提取批量 record。
|
|
267
|
-
* 形态:{ batch: true, items: BgNotifyRecord[] }。结构不全返回 undefined。
|
|
268
|
-
*/
|
|
269
|
-
function extractBatch(details: unknown): BgNotifyRecord[] | undefined {
|
|
270
|
-
if (typeof details !== "object" || details === null) return undefined;
|
|
271
|
-
const d = details as Record<string, unknown>;
|
|
272
|
-
if (d.batch !== true || !Array.isArray(d.items)) return undefined;
|
|
273
|
-
const records: BgNotifyRecord[] = [];
|
|
274
|
-
for (const item of d.items) {
|
|
275
|
-
const r = extractBgNotifyRecord(item);
|
|
276
|
-
if (r) records.push(r);
|
|
277
|
-
}
|
|
278
|
-
return records.length > 0 ? records : undefined;
|
|
279
|
-
}
|
|
280
|
-
|
|
281
258
|
/**
|
|
282
259
|
* details.closedReason 防御性收窄:任意字符串 → ClosedReason | undefined。
|
|
283
260
|
* 旧数据/外部构造的非法值按缺失处理,交由 deriveOutcome 兑底(消费方不崩溃)。
|
|
@@ -28,9 +28,7 @@ import { parseWorkflowRpcCommand, type WorkflowRpcAction } from "./command-actio
|
|
|
28
28
|
import { createWorkflowsView, type ViewActions } from "./views/WorkflowsView.ts";
|
|
29
29
|
import { toErrorMessage } from "@zhushanwen/pi-ext-guards";
|
|
30
30
|
import { LIST_LIMIT } from "./list-shared.ts";
|
|
31
|
-
|
|
32
|
-
/** runId 截断长度(显示用)。 */
|
|
33
|
-
const RUNID_SHORT = 8;
|
|
31
|
+
import { ID_PREVIEW_LENGTH } from "./id-preview.ts";
|
|
34
32
|
|
|
35
33
|
/** status 显示顺序:running 优先(活跃态在前),再 startedAt 倒序。 */
|
|
36
34
|
const STATUS_ORDER: Record<string, number> = {
|
|
@@ -161,7 +159,7 @@ async function handleRpcMode(
|
|
|
161
159
|
ctx.ui.notify(`Usage: /workflows ${parsed.verb} <runId>`, "warning");
|
|
162
160
|
return;
|
|
163
161
|
case "noop":
|
|
164
|
-
ctx.ui.notify("View workflows in the
|
|
162
|
+
ctx.ui.notify("View workflows in the composer task tray", "info");
|
|
165
163
|
return;
|
|
166
164
|
default: {
|
|
167
165
|
// exhaustiveness 断言:未来新增 action verb 忘加 case 时 tsc 报错
|
|
@@ -219,7 +217,7 @@ async function openFromList(
|
|
|
219
217
|
|
|
220
218
|
// 多 run——select 选择
|
|
221
219
|
const entries = all.map(
|
|
222
|
-
(r) => `${r.spec.scriptName} [${r.state.status}] (${r.runId.slice(0,
|
|
220
|
+
(r) => `${r.spec.scriptName} [${r.state.status}] (${r.runId.slice(0, ID_PREVIEW_LENGTH)})`,
|
|
223
221
|
);
|
|
224
222
|
const selected = await ctx.ui.select("Select workflow:", entries);
|
|
225
223
|
if (!selected) return;
|
package/src/interface/format.ts
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
//
|
|
3
3
|
// 纯格式化函数.零 Pi 依赖、零 runtime 依赖,可单测.
|
|
4
4
|
//
|
|
5
|
-
// 分隔符语义体系(
|
|
5
|
+
// 分隔符语义体系(impeccable 审查裁定):
|
|
6
6
|
// `·` 同级并列字段/thinking 图标;`()` 元数据分组;`›` 工具;`>` 输出;`·` thinking.
|
|
7
7
|
// 禁用 `│` 做 stats 分隔、`├─`/`└─` 做 eventLog 前缀.
|
|
8
8
|
//
|
|
@@ -234,7 +234,7 @@ export function extractAgentName(args: unknown): string {
|
|
|
234
234
|
/**
|
|
235
235
|
* 格式化单条 eventLog 条目(带类型图标 + 着色,不含 `⎿` 前缀——前缀由调用方加).
|
|
236
236
|
*
|
|
237
|
-
*
|
|
237
|
+
* 标签语义:
|
|
238
238
|
* tool: tool_start/tool_end(尾部追加 ✓/✗)
|
|
239
239
|
* ── turn ── turn_end(仅 expanded)
|
|
240
240
|
* error: tool label + ✗
|