@zhushanwen/pi-subagent-workflow 8.14.7 → 9.0.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 +6 -5
- package/package.json +14 -9
- package/relay/relay.mjs +45 -2
- package/skills/workflow-script-format/SKILL.md +2 -56
- package/src/host/__tests__/inflight-reporter.test.ts +26 -1
- package/src/host/__tests__/pi-host.test.ts +418 -4
- package/src/host/inflight-reporter.ts +13 -10
- package/src/host/pi-host.ts +205 -17
- package/src/index.ts +21 -72
- package/src/injectors/__tests__/engine-awareness.test.ts +236 -33
- package/src/injectors/__tests__/helpers/injector-test-mocks.ts +15 -7
- package/src/injectors/__tests__/workflow-list-injector.test.ts +447 -40
- package/src/injectors/resource-list-injector.ts +103 -32
- package/src/injectors/subagent-list-injector.ts +4 -28
- package/src/injectors/workflow-list-injector.ts +8 -4
- package/src/interface/__tests__/capture-tool.ts +56 -0
- package/src/interface/__tests__/commands-resume.test.ts +103 -0
- package/src/interface/__tests__/tool-prompt-contract.test.ts +361 -0
- package/src/interface/__tests__/tool-render.test.ts +105 -2
- package/src/interface/__tests__/tool-subagents.test.ts +24 -26
- package/src/interface/__tests__/tool-workflow-resume.test.ts +227 -0
- package/src/interface/__tests__/tool-workflow-script.test.ts +557 -0
- package/src/interface/__tests__/tool-workflow.test.ts +884 -0
- package/src/interface/bg-notify-render.ts +18 -9
- package/src/interface/command-actions.ts +22 -17
- package/src/interface/commands.ts +58 -12
- package/src/interface/format.ts +50 -16
- package/src/interface/id-preview.ts +4 -4
- package/src/interface/subagent-actions.ts +18 -50
- package/src/interface/subagent-tool.ts +26 -18
- package/src/interface/subagents.ts +5 -11
- package/src/interface/tool-render.ts +14 -30
- package/src/interface/tool-shared.ts +55 -12
- package/src/interface/tool-subagents.ts +21 -11
- package/src/interface/tool-workflow-script.ts +52 -72
- package/src/interface/tool-workflow.ts +345 -139
- package/src/interface/views/WorkflowsView.ts +10 -7
- package/src/interface/views/__tests__/{WorkflowsView-signature.test.ts → WorkflowsView.test.ts} +155 -11
- package/src/interface/views/detail-content.ts +3 -5
- package/src/jsonl-run-store.ts +656 -544
- package/src/model-events.ts +35 -0
- package/src/session-lifecycle.ts +349 -86
- package/src/subagents-events.ts +56 -0
- package/src/workflow-events.ts +285 -200
- package/src/workflow-notify.ts +456 -0
- package/src/injectors/__tests__/engine-section-stability.test.ts +0 -353
- package/src/injectors/__tests__/model-list-injector.test.ts +0 -136
- package/src/injectors/__tests__/subagent-list-injector.test.ts +0 -376
- package/src/interface/__tests__/detectors.test.ts +0 -161
- package/src/interface/__tests__/subagent-tool-prompt.test.ts +0 -183
- package/src/interface/__tests__/tool-workflow-run-builtin-name.test.ts +0 -219
- package/src/interface/__tests__/tool-workflow-script-generate.test.ts +0 -251
- package/src/interface/__tests__/tool-workflow-throw-paths.test.ts +0 -215
- package/src/interface/__tests__/workflow-state-file-exposure.test.ts +0 -38
- package/src/interface/__tests__/workflow-tool-prompt.test.ts +0 -129
- package/src/interface/helpers.ts +0 -272
- package/src/interface/views/__tests__/detail-content-session-file.test.ts +0 -73
- package/src/interface/views/__tests__/record-progress.test.ts +0 -235
|
@@ -0,0 +1,557 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* workflow-script tool 行为测试(generate / lint / list 三域合一;原三份拆分文件的
|
|
3
|
+
* 全部用例与断言原样保留)。
|
|
4
|
+
*
|
|
5
|
+
* 三个被测域与断言形态:
|
|
6
|
+
* - actionGenerate(m0 wave TC1-TC10 + [P-generate-roundtrip];C5② 改接 core 管线):
|
|
7
|
+
* 五道闸校验 + tmp 写盘在 core generateWorkflowScript(barrel import)——验证宿主
|
|
8
|
+
* 契约层(结构化结果 → execute-throw 转换、成功文案)+ 经真实 core 管线的校验行为
|
|
9
|
+
* 回归。mock node:fs(mkdirSync/writeFileSync)避免真实落盘 .pi/workflows/.tmp/
|
|
10
|
+
* (builtin 模块 mock 对 core 管线内的写盘同样生效);save/delete 走 barrel mock
|
|
11
|
+
* (importActual 展开覆写,其余 barrel 面(含 generateWorkflowScript)保持真实)。
|
|
12
|
+
* - actionLint / actionList 黑盒输出测试(LLM 直接消费文本的形态锁):findings 格式化
|
|
13
|
+
* 文案(图标/行号/Suggestion 结构、Errors vs Warnings 标题)、not-found 建议清单与
|
|
14
|
+
* list 清单 item 行是 LLM 自纠错的唯一信息源。经注册层黑盒(capture tool +
|
|
15
|
+
* registry stub)断言锁 result.content 实际文本与 details 结构,不 mock lint 规则。
|
|
16
|
+
* finding/item 文案期望值逐字来自 core orchestration/script-lint.ts、
|
|
17
|
+
* orchestration/launcher.ts 的 formatAvailableWorkflowRefs 与 interface 层格式化
|
|
18
|
+
* (图标字节:✅ U+2705、❌ U+274C、⚠️ U+26A0+U+FE0F 带 variation selector——修改
|
|
19
|
+
* 任一侧文案此测试即红,属预期锁定)。lint 规则本体走真实 core lintScript。
|
|
20
|
+
* - execute 入口 abort 前置(P1-2):aborted signal 下五个 action 全部早退,不触达
|
|
21
|
+
* registry / core barrel / 写盘(早退发生在任何副作用之前)。
|
|
22
|
+
*
|
|
23
|
+
* 共享基建:registry stub 统一为「get 按 name+available 解析、loadAll 全量返回」
|
|
24
|
+
* 形态(list 域只触 loadAll,不消费 get,统一后行为不变);每个用例经文件级
|
|
25
|
+
* beforeEach vi.clearAllMocks 拿干净 mock 计数(abort 零触达断言依赖此)。框架:
|
|
26
|
+
* vitest(禁 node:test)。
|
|
27
|
+
*/
|
|
28
|
+
import { mkdirSync, writeFileSync } from "node:fs";
|
|
29
|
+
|
|
30
|
+
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
31
|
+
|
|
32
|
+
import { actionGenerate, type ScriptParams, type WorkflowScriptExecuteResult, registerWorkflowScriptTool } from "../tool-workflow-script.ts";
|
|
33
|
+
import { captureTool, type CapturedTool, type ScriptResultToolView } from "./capture-tool.ts";
|
|
34
|
+
import { deleteWorkflow, saveWorkflow } from "@zhushanwen/subagent-core";
|
|
35
|
+
|
|
36
|
+
// node:fs 只覆写两个写盘函数、其余保持真实——C5② 后被测链经 barrel 拉起完整 core
|
|
37
|
+
// 依赖图(importActual),core 模块对 existsSync/statSync 等的消费不能被 2 函数工厂截断
|
|
38
|
+
vi.mock("node:fs", async (importOriginal) => ({
|
|
39
|
+
...(await importOriginal<typeof import("node:fs")>()),
|
|
40
|
+
mkdirSync: vi.fn(),
|
|
41
|
+
writeFileSync: vi.fn(),
|
|
42
|
+
}));
|
|
43
|
+
|
|
44
|
+
// C5②:被测模块经 barrel 消费 save/delete/generateWorkflowScript——mock 挂 barrel,
|
|
45
|
+
// importActual 展开保持 generateWorkflowScript 真实(校验管线是被测回归面)
|
|
46
|
+
vi.mock("@zhushanwen/subagent-core", async (importOriginal) => ({
|
|
47
|
+
...(await importOriginal<typeof import("@zhushanwen/subagent-core")>()),
|
|
48
|
+
saveWorkflow: vi.fn(),
|
|
49
|
+
deleteWorkflow: vi.fn(),
|
|
50
|
+
}));
|
|
51
|
+
|
|
52
|
+
const mockedWriteFileSync = vi.mocked(writeFileSync);
|
|
53
|
+
const mockedMkdirSync = vi.mocked(mkdirSync);
|
|
54
|
+
|
|
55
|
+
// ── 注册层捕获 helper(fake pi 捕获单点见 capture-tool.ts,此处只留差异面)──
|
|
56
|
+
|
|
57
|
+
interface ScriptExecuteToolView extends CapturedTool { // oe-exempt:20260927:test 测试捕获 view 类型(非 ports 契约),单「实现」即测试本体
|
|
58
|
+
execute: (
|
|
59
|
+
toolCallId: string,
|
|
60
|
+
params: Record<string, unknown>,
|
|
61
|
+
signal: AbortSignal | undefined,
|
|
62
|
+
onUpdate: unknown,
|
|
63
|
+
ctx: unknown,
|
|
64
|
+
) => Promise<WorkflowScriptExecuteResult>;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* 捕获 workflow-script tool(registry 全量 stub:get/loadAll/invalidate——
|
|
69
|
+
* abort 早退断言要求「零触达 registry」,stub 面必须覆盖全部三个读点)。
|
|
70
|
+
*/
|
|
71
|
+
function captureScriptExecuteTool(
|
|
72
|
+
registry: Record<string, unknown> = { invalidate: vi.fn() },
|
|
73
|
+
): ScriptExecuteToolView {
|
|
74
|
+
return captureTool<ScriptExecuteToolView>(
|
|
75
|
+
(pi) => registerWorkflowScriptTool(pi as never, registry as never, () => false),
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** registry stub(duck-typed WorkflowScriptRegistry——lint 只触 get/loadAll)。 */
|
|
80
|
+
function makeRegistry(scripts: Array<Record<string, unknown>>) {
|
|
81
|
+
return {
|
|
82
|
+
get: vi.fn((name: string) =>
|
|
83
|
+
Promise.resolve(scripts.find((s) => s.name === name && s.available === true)),
|
|
84
|
+
),
|
|
85
|
+
loadAll: vi.fn(() => Promise.resolve(scripts)),
|
|
86
|
+
invalidate: vi.fn(),
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function captureScriptTool(registry: ReturnType<typeof makeRegistry>): ScriptResultToolView {
|
|
91
|
+
return captureTool<ScriptResultToolView>(
|
|
92
|
+
(pi) => registerWorkflowScriptTool(pi as never, registry as never, () => false),
|
|
93
|
+
"workflow-script",
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** 可用脚本 stub(loadScriptSource 经 registry.get 取 sourceCode)。 */
|
|
98
|
+
function makeScript(name: string, sourceCode: string) {
|
|
99
|
+
return { name, source: "saved", path: `/abs/${name}.js`, sourceCode, available: true, meta: {} };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** 清单脚本 stub(source 显式覆盖以覆盖 saved/tmp 两种标签形态)。 */
|
|
103
|
+
function makeListScript(name: string, source: "saved" | "tmp", description: string) {
|
|
104
|
+
return { name, source, path: `/abs/${name}.js`, sourceCode: "// x", available: true, meta: { description } };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function lintCall(tool: ScriptResultToolView, name: string) {
|
|
108
|
+
// ctx 传 {}(mode 非 rpc)→ details 不附加 __gui__,锁的是 pi 侧可见原始输出
|
|
109
|
+
return tool.execute("id", { action: "lint", name }, undefined, undefined, {});
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function listCall(tool: ScriptResultToolView) {
|
|
113
|
+
return tool.execute("id", { action: "list" }, undefined, undefined, {});
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function gen(script: string, name = "test-wf"): ScriptParams {
|
|
117
|
+
return { action: "generate", name, script } as ScriptParams;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** WorkflowScriptExecuteResult.text 在 content[0].text。 */
|
|
121
|
+
function textOf(r: WorkflowScriptExecuteResult): string {
|
|
122
|
+
return r.content[0]?.text ?? "";
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// ── generate 脚本 fixtures(按 core 校验闸逐项构造触发形态)──
|
|
126
|
+
|
|
127
|
+
const PI_META_VALID = `/* @pi-meta
|
|
128
|
+
name: test-wf
|
|
129
|
+
description: 合法新格式
|
|
130
|
+
phases: [a, b]
|
|
131
|
+
parameters:
|
|
132
|
+
type: object
|
|
133
|
+
properties:
|
|
134
|
+
task: { type: string }
|
|
135
|
+
required: [task]
|
|
136
|
+
*/
|
|
137
|
+
agent("worker", { task: $ARGS.task });
|
|
138
|
+
`;
|
|
139
|
+
|
|
140
|
+
const PI_META_MALFORMED = `/* @pi-meta
|
|
141
|
+
name: test-wf
|
|
142
|
+
description: bad
|
|
143
|
+
broken: indent
|
|
144
|
+
phases: [a]
|
|
145
|
+
*/
|
|
146
|
+
agent("w");
|
|
147
|
+
`;
|
|
148
|
+
|
|
149
|
+
const PI_META_REGEX_SINGLE_BS = `/* @pi-meta
|
|
150
|
+
name: test-wf
|
|
151
|
+
description: regex
|
|
152
|
+
phases: [A]
|
|
153
|
+
parameters:
|
|
154
|
+
type: object
|
|
155
|
+
patternProperties:
|
|
156
|
+
"^batch\\d+$": { type: string }
|
|
157
|
+
*/
|
|
158
|
+
agent("w");
|
|
159
|
+
`;
|
|
160
|
+
|
|
161
|
+
const LEGACY_CONST_META = `const meta = {
|
|
162
|
+
name: test-wf,
|
|
163
|
+
description: legacy,
|
|
164
|
+
phases: ["a"]
|
|
165
|
+
};
|
|
166
|
+
agent("w");
|
|
167
|
+
`;
|
|
168
|
+
|
|
169
|
+
const NO_META = `agent("w");
|
|
170
|
+
`;
|
|
171
|
+
|
|
172
|
+
const ESM_IMPORT = `/* @pi-meta
|
|
173
|
+
name: x
|
|
174
|
+
description: d
|
|
175
|
+
phases: [a]
|
|
176
|
+
*/
|
|
177
|
+
import { foo } from "bar";
|
|
178
|
+
agent("w");
|
|
179
|
+
`;
|
|
180
|
+
|
|
181
|
+
const NO_AGENT = `/* @pi-meta
|
|
182
|
+
name: x
|
|
183
|
+
description: d
|
|
184
|
+
phases: [a]
|
|
185
|
+
*/
|
|
186
|
+
const x = 1;
|
|
187
|
+
`;
|
|
188
|
+
|
|
189
|
+
// ── lint 脚本 fixtures(按 core script-lint 检查项构造稳定触发形态)──
|
|
190
|
+
|
|
191
|
+
/** 合法脚本:有 agent() 入口 + 调用带 description → 0 findings。 */
|
|
192
|
+
const CLEAN_SCRIPT = [
|
|
193
|
+
"const summary = await agent({",
|
|
194
|
+
' prompt: "Do the work",',
|
|
195
|
+
' description: "work-agent",',
|
|
196
|
+
"});",
|
|
197
|
+
].join("\n");
|
|
198
|
+
|
|
199
|
+
/** 无任何编排入口 → 恰 1 个 error finding(line 0,行号稳定)。 */
|
|
200
|
+
const NO_ENTRY_SCRIPT = "const x = 1;";
|
|
201
|
+
|
|
202
|
+
/** agent() 调用缺 description/label → 恰 1 个 warning finding(line 1)。 */
|
|
203
|
+
const UNNAMED_AGENT_SCRIPT = 'await agent({ prompt: "work" });';
|
|
204
|
+
|
|
205
|
+
// 每用例干净 mock 计数:generate 写盘断言(toHaveBeenCalledTimes(1))与 abort 零触达
|
|
206
|
+
// 断言(前序用例的 save/delete/generate 调用不留痕)共同依赖。
|
|
207
|
+
beforeEach(() => {
|
|
208
|
+
vi.clearAllMocks();
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
describe("actionGenerate (m0: @pi-meta 认可 + round-trip)", () => {
|
|
212
|
+
/**
|
|
213
|
+
* W4b:generate 校验族错误路径从 return {isError:true} 改为 throw(pi 只对
|
|
214
|
+
* execute throw 置 isError:true,返回值 isError 被 agent-loop 丢弃)。
|
|
215
|
+
* actionGenerate 是同步函数——断言用同步 toThrow。
|
|
216
|
+
*/
|
|
217
|
+
|
|
218
|
+
it("TC1: 合法 @pi-meta → ready + writeFileSync 被调用", () => {
|
|
219
|
+
const r = actionGenerate(gen(PI_META_VALID));
|
|
220
|
+
expect(r.isError).toBeFalsy();
|
|
221
|
+
expect(textOf(r)).toMatch(/ready|generated|test-wf/i);
|
|
222
|
+
expect(mockedWriteFileSync).toHaveBeenCalledTimes(1);
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
it("TC2: malformed @pi-meta YAML → throw + writeFileSync 未调用 [P-generate-roundtrip]", () => {
|
|
226
|
+
expect(() => actionGenerate(gen(PI_META_MALFORMED))).toThrow(
|
|
227
|
+
/cannot be parsed/i,
|
|
228
|
+
);
|
|
229
|
+
expect(mockedWriteFileSync).not.toHaveBeenCalled();
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
it("TC3: legacy const meta(过渡期)→ ready + writeFileSync 被调用", () => {
|
|
233
|
+
const r = actionGenerate(gen(LEGACY_CONST_META));
|
|
234
|
+
expect(r.isError).toBeFalsy();
|
|
235
|
+
expect(mockedWriteFileSync).toHaveBeenCalledTimes(1);
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
it("TC4: 无 meta → throw 提及 @pi-meta 新格式", () => {
|
|
239
|
+
expect(() => actionGenerate(gen(NO_META))).toThrow(/meta declaration/i);
|
|
240
|
+
expect(mockedWriteFileSync).not.toHaveBeenCalled();
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
it("TC5: @pi-meta 单反斜杠正则 → throw(LLM 高频错)[P-generate-roundtrip]", () => {
|
|
244
|
+
expect(() => actionGenerate(gen(PI_META_REGEX_SINGLE_BS))).toThrow(
|
|
245
|
+
/escape|cannot be parsed/i,
|
|
246
|
+
);
|
|
247
|
+
expect(mockedWriteFileSync).not.toHaveBeenCalled();
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
it("TC6: ESM import → throw(保留现有行为)", () => {
|
|
251
|
+
expect(() => actionGenerate(gen(ESM_IMPORT))).toThrow(/ESM|import/i);
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
it("TC7: 无 agent() → throw(保留现有行为)", () => {
|
|
255
|
+
expect(() => actionGenerate(gen(NO_AGENT))).toThrow(/agent\(\)/i);
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
// TC8(原 signal aborted 直调用例)已删:abort 前置上移到 execute 入口后,
|
|
259
|
+
// actionGenerate 不再消费 signal——覆盖见下方「execute 入口 abort 前置」describe。
|
|
260
|
+
|
|
261
|
+
it("TC9: 缺 name/script 参数 → throw 'generate requires'(防御性,schema 先拦)", () => {
|
|
262
|
+
expect(() => actionGenerate({ action: "generate" } as ScriptParams)).toThrow(
|
|
263
|
+
"generate requires 'name' and 'script' parameters",
|
|
264
|
+
);
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
it("TC10: ESM export(非 meta)→ throw(W4b 收敛路径)", () => {
|
|
268
|
+
const script = `/* @pi-meta
|
|
269
|
+
name: x
|
|
270
|
+
description: d
|
|
271
|
+
phases: [a]
|
|
272
|
+
*/
|
|
273
|
+
export const foo = 1;
|
|
274
|
+
agent("w");
|
|
275
|
+
`;
|
|
276
|
+
expect(() => actionGenerate(gen(script))).toThrow(/ESM 'export'/i);
|
|
277
|
+
});
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
describe("actionSave/actionDelete error paths (W4: throw 范式)", () => {
|
|
281
|
+
/**
|
|
282
|
+
* W4:save/delete 失败路径从 return {isError:true} 改为 throw——pi 只对 execute
|
|
283
|
+
* throw 置 isError:true(agent-loop.js:453-483 丢弃返回值里的 isError)。
|
|
284
|
+
* 经 registerWorkflowScriptTool 注册层测(mock workflow-files 的 FS 依赖)。
|
|
285
|
+
*/
|
|
286
|
+
const ctx = { mode: "tui" as const, hasUI: true };
|
|
287
|
+
|
|
288
|
+
it("save 失败 → throw 'Save failed: <原因>'(pi catch 后置 isError:true)", async () => {
|
|
289
|
+
vi.mocked(saveWorkflow).mockRejectedValueOnce(new Error("disk full"));
|
|
290
|
+
const tool = captureScriptExecuteTool();
|
|
291
|
+
await expect(
|
|
292
|
+
tool.execute("id", { action: "save", name: "tmp-wf" }, undefined, undefined, ctx),
|
|
293
|
+
).rejects.toThrow("Save failed: disk full");
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
it("delete 失败 → throw 'Delete failed: <原因>'", async () => {
|
|
297
|
+
// deleteWorkflow 是同步函数——mock 用同步 throw(mockRejectedValue 不会被
|
|
298
|
+
// actionDelete 的同步 try/catch 捕获)
|
|
299
|
+
vi.mocked(deleteWorkflow).mockImplementationOnce(() => {
|
|
300
|
+
throw new Error("script is running");
|
|
301
|
+
});
|
|
302
|
+
const tool = captureScriptExecuteTool();
|
|
303
|
+
await expect(
|
|
304
|
+
tool.execute("id", { action: "delete", name: "tmp-wf" }, undefined, undefined, ctx),
|
|
305
|
+
).rejects.toThrow("Delete failed: script is running");
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
it("save 成功路径不受影响(ok details 正常返回)", async () => {
|
|
309
|
+
vi.mocked(saveWorkflow).mockResolvedValueOnce("saved tmp-wf");
|
|
310
|
+
const tool = captureScriptExecuteTool();
|
|
311
|
+
const r = await tool.execute("id", { action: "save", name: "tmp-wf" }, undefined, undefined, ctx);
|
|
312
|
+
expect(r.isError).toBeFalsy();
|
|
313
|
+
expect(r.details).toMatchObject({ action: "save", name: "tmp-wf", ok: true });
|
|
314
|
+
});
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
describe("execute 入口 abort 前置(P1-2:与 workflow / subagents 两 tool 对称)", () => {
|
|
318
|
+
/**
|
|
319
|
+
* abort 检查此前只在 generate(actionGenerate 内)存在,lint/save/delete/list
|
|
320
|
+
* 四路径漏拦——已统一上移到 execute 入口(tool-shared assertNotAborted 同源)。
|
|
321
|
+
* 黑盒断言:aborted signal 下五个 action 全部早退,且不触达 registry / core
|
|
322
|
+
* barrel / 写盘(早退发生在任何副作用之前)。
|
|
323
|
+
*/
|
|
324
|
+
|
|
325
|
+
const ctx = { mode: "tui" as const, hasUI: true };
|
|
326
|
+
/** 各 action 的最小合法入参(generate 给齐 name+script:守卫失效时会走完真实 core 管线并写盘,零触达断言随之红)。 */
|
|
327
|
+
const ACTION_PARAMS: Record<string, Record<string, unknown>> = {
|
|
328
|
+
generate: { action: "generate", name: "test-wf", script: "// s" },
|
|
329
|
+
lint: { action: "lint", name: "test-wf" },
|
|
330
|
+
save: { action: "save", name: "test-wf" },
|
|
331
|
+
delete: { action: "delete", name: "test-wf" },
|
|
332
|
+
list: { action: "list" },
|
|
333
|
+
};
|
|
334
|
+
|
|
335
|
+
it.each(Object.keys(ACTION_PARAMS))(
|
|
336
|
+
"action:%s → throw 'Operation aborted before start',registry/core 管线零触达",
|
|
337
|
+
async (action) => {
|
|
338
|
+
const registry = {
|
|
339
|
+
get: vi.fn(),
|
|
340
|
+
loadAll: vi.fn(),
|
|
341
|
+
invalidate: vi.fn(),
|
|
342
|
+
};
|
|
343
|
+
const tool = captureScriptExecuteTool(registry);
|
|
344
|
+
await expect(
|
|
345
|
+
tool.execute("id", ACTION_PARAMS[action], AbortSignal.abort(), undefined, ctx),
|
|
346
|
+
).rejects.toThrow("Operation aborted before start");
|
|
347
|
+
expect(registry.get).not.toHaveBeenCalled();
|
|
348
|
+
expect(registry.loadAll).not.toHaveBeenCalled();
|
|
349
|
+
expect(registry.invalidate).not.toHaveBeenCalled();
|
|
350
|
+
expect(mockedWriteFileSync).not.toHaveBeenCalled(); // generate 的 tmp 写盘未发生
|
|
351
|
+
expect(vi.mocked(saveWorkflow)).not.toHaveBeenCalled();
|
|
352
|
+
expect(vi.mocked(deleteWorkflow)).not.toHaveBeenCalled();
|
|
353
|
+
},
|
|
354
|
+
);
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
describe("actionLint 输出形态(LLM 可见文本锁)", () => {
|
|
358
|
+
it("0 findings → ✅ 单行文案,无 details、无 isError", async () => {
|
|
359
|
+
const registry = makeRegistry([makeScript("clean-wf", CLEAN_SCRIPT)]);
|
|
360
|
+
const tool = captureScriptTool(registry);
|
|
361
|
+
const r = await lintCall(tool, "clean-wf");
|
|
362
|
+
expect(registry.get).toHaveBeenCalledWith("clean-wf");
|
|
363
|
+
expect(r.content).toEqual([{ type: "text", text: "✅ No issues found in 'clean-wf'." }]);
|
|
364
|
+
expect(r.details).toBeUndefined();
|
|
365
|
+
expect(r.isError).toBeUndefined();
|
|
366
|
+
});
|
|
367
|
+
|
|
368
|
+
it("error finding → ❌ 行 + Suggestion 缩进续行 + Errors 标题,details/isError 结构化", async () => {
|
|
369
|
+
const registry = makeRegistry([makeScript("no-entry-wf", NO_ENTRY_SCRIPT)]);
|
|
370
|
+
const tool = captureScriptTool(registry);
|
|
371
|
+
const r = await lintCall(tool, "no-entry-wf");
|
|
372
|
+
expect(r.content).toEqual([
|
|
373
|
+
{
|
|
374
|
+
type: "text",
|
|
375
|
+
text:
|
|
376
|
+
"Errors found in 'no-entry-wf':\n\n" +
|
|
377
|
+
"❌ L0: Workflow script must call agent(), parallel(), or pipeline() at least once.\n" +
|
|
378
|
+
" Suggestion: Add at least one agent(), parallel(), or pipeline() invocation.",
|
|
379
|
+
},
|
|
380
|
+
]);
|
|
381
|
+
expect(r.details).toEqual({
|
|
382
|
+
action: "lint",
|
|
383
|
+
name: "no-entry-wf",
|
|
384
|
+
valid: false,
|
|
385
|
+
findingCount: 1,
|
|
386
|
+
});
|
|
387
|
+
expect(r.isError).toBe(true);
|
|
388
|
+
});
|
|
389
|
+
|
|
390
|
+
it("warning-only finding(agent 缺 description)→ ⚠️ 行 + Warnings 标题,isError=false 不拦截", async () => {
|
|
391
|
+
const registry = makeRegistry([makeScript("unnamed-wf", UNNAMED_AGENT_SCRIPT)]);
|
|
392
|
+
const tool = captureScriptTool(registry);
|
|
393
|
+
const r = await lintCall(tool, "unnamed-wf");
|
|
394
|
+
expect(r.content).toEqual([
|
|
395
|
+
{
|
|
396
|
+
type: "text",
|
|
397
|
+
text:
|
|
398
|
+
"Warnings found in 'unnamed-wf':\n\n" +
|
|
399
|
+
"⚠️ L1: agent() call without `description` (or `label`) will show as '(unnamed)' in TUI.\n" +
|
|
400
|
+
" Suggestion: Add `description: 'kebab-case-name'` to agent() opts for readable /workflows display.",
|
|
401
|
+
},
|
|
402
|
+
]);
|
|
403
|
+
expect(r.details).toEqual({ action: "lint", name: "unnamed-wf", valid: true, findingCount: 1 });
|
|
404
|
+
expect(r.isError).toBe(false);
|
|
405
|
+
});
|
|
406
|
+
|
|
407
|
+
it("name 不存在 → throw 完整 not-found 文案,清单走 core 拒单格式(含 location 行,不可用项剔除)", async () => {
|
|
408
|
+
const registry = makeRegistry([
|
|
409
|
+
{ ...makeScript("broken-wf", "// x"), available: false, meta: { description: "解析失败" } },
|
|
410
|
+
{ ...makeScript("clean-scripts", "// x"), meta: { description: "remove stale tmp scripts" } },
|
|
411
|
+
]);
|
|
412
|
+
const tool = captureScriptTool(registry);
|
|
413
|
+
// 全文精确匹配(toBe)——同时锁定 item 行 = core formatAvailableWorkflowRefs
|
|
414
|
+
// 缺省形态(与 run 拒单有意统一,带 location 行)与不可用项剔除(broken-wf 零出现)
|
|
415
|
+
const err = (await lintCall(tool, "ghost-wf").catch((e: unknown) => e)) as Error;
|
|
416
|
+
expect(err).toBeInstanceOf(Error);
|
|
417
|
+
expect(err.message).toBe(
|
|
418
|
+
"Workflow 'ghost-wf' not found or not available.\n" +
|
|
419
|
+
"Available:\n" +
|
|
420
|
+
" - clean-scripts: remove stale tmp scripts\n" +
|
|
421
|
+
" location: /abs/clean-scripts.js",
|
|
422
|
+
);
|
|
423
|
+
});
|
|
424
|
+
|
|
425
|
+
it("name 不存在且无可用脚本 → 建议清单降级为 (none)", async () => {
|
|
426
|
+
const registry = makeRegistry([]);
|
|
427
|
+
const tool = captureScriptTool(registry);
|
|
428
|
+
await expect(lintCall(tool, "ghost-wf")).rejects.toThrow(
|
|
429
|
+
"Workflow 'ghost-wf' not found or not available.\nAvailable:\n (none)",
|
|
430
|
+
);
|
|
431
|
+
});
|
|
432
|
+
});
|
|
433
|
+
|
|
434
|
+
// ── GUI attach(RPC 模式分发;attach 实现单点在 tool-shared withGuiAttach)──
|
|
435
|
+
|
|
436
|
+
describe("actionLint GUI attach(execute 级 RPC/非 RPC 分发)", () => {
|
|
437
|
+
/** details 的 __gui__ 投影形态(协议 GuiRenderResult 的断言子集)。 */
|
|
438
|
+
type GuiProjection = { __gui__?: { component?: { type?: string; props?: { items?: Array<{ label?: string; value?: string }> } } } };
|
|
439
|
+
|
|
440
|
+
it("RPC ctx → details 附带 __gui__(lint findings → stats-line warn)", async () => {
|
|
441
|
+
const registry = makeRegistry([makeScript("no-entry-wf", NO_ENTRY_SCRIPT)]);
|
|
442
|
+
const tool = captureScriptTool(registry);
|
|
443
|
+
const r = await tool.execute("id", { action: "lint", name: "no-entry-wf" }, undefined, undefined, {
|
|
444
|
+
mode: "rpc",
|
|
445
|
+
hasUI: true,
|
|
446
|
+
});
|
|
447
|
+
const gui = (r.details as GuiProjection).__gui__;
|
|
448
|
+
expect(gui).toBeDefined();
|
|
449
|
+
expect(gui?.component?.type).toBe("stats-line");
|
|
450
|
+
expect(gui?.component?.props?.items).toEqual([{ label: "lint", value: "1 findings", severity: "warn" }]);
|
|
451
|
+
});
|
|
452
|
+
|
|
453
|
+
it("RPC ctx + 纯文本结果(details undefined)→ details 保持 undefined", async () => {
|
|
454
|
+
const registry = makeRegistry([makeScript("clean-wf", CLEAN_SCRIPT)]);
|
|
455
|
+
const tool = captureScriptTool(registry);
|
|
456
|
+
const r = await tool.execute("id", { action: "lint", name: "clean-wf" }, undefined, undefined, {
|
|
457
|
+
mode: "rpc",
|
|
458
|
+
hasUI: true,
|
|
459
|
+
});
|
|
460
|
+
expect(r.details).toBeUndefined();
|
|
461
|
+
});
|
|
462
|
+
|
|
463
|
+
it("非 RPC ctx(lintCall 既有形态)→ details 无 __gui__", async () => {
|
|
464
|
+
const registry = makeRegistry([makeScript("no-entry-wf", NO_ENTRY_SCRIPT)]);
|
|
465
|
+
const tool = captureScriptTool(registry);
|
|
466
|
+
const r = await lintCall(tool, "no-entry-wf");
|
|
467
|
+
expect((r.details as GuiProjection).__gui__).toBeUndefined();
|
|
468
|
+
});
|
|
469
|
+
});
|
|
470
|
+
|
|
471
|
+
describe("actionList 输出形态(LLM 可见文本锁)", () => {
|
|
472
|
+
it("非空清单 → 标题 + core formatter item 行([source] 标签 + ':' 分隔 + 无 location 行)+ details 计数", async () => {
|
|
473
|
+
const registry = makeRegistry([
|
|
474
|
+
makeListScript("deploy-wf", "saved", "deploy the app"),
|
|
475
|
+
makeListScript("scratch-wf", "tmp", "one-off scratch task"),
|
|
476
|
+
]);
|
|
477
|
+
const tool = captureScriptTool(registry);
|
|
478
|
+
const r = await listCall(tool);
|
|
479
|
+
expect(registry.loadAll).toHaveBeenCalledOnce();
|
|
480
|
+
expect(r.content).toEqual([
|
|
481
|
+
{
|
|
482
|
+
type: "text",
|
|
483
|
+
text:
|
|
484
|
+
"Available workflows:\n" +
|
|
485
|
+
" - [saved] deploy-wf: deploy the app\n" +
|
|
486
|
+
" - [tmp] scratch-wf: one-off scratch task",
|
|
487
|
+
},
|
|
488
|
+
]);
|
|
489
|
+
expect(r.details).toEqual({ action: "list", count: 2 });
|
|
490
|
+
expect(r.isError).toBeUndefined();
|
|
491
|
+
});
|
|
492
|
+
|
|
493
|
+
it("不可用项剔除:available=false 不进清单,count 只计可用项", async () => {
|
|
494
|
+
const registry = makeRegistry([
|
|
495
|
+
makeListScript("deploy-wf", "saved", "deploy the app"),
|
|
496
|
+
{ ...makeListScript("broken-wf", "saved", "解析失败"), available: false },
|
|
497
|
+
]);
|
|
498
|
+
const tool = captureScriptTool(registry);
|
|
499
|
+
const r = await listCall(tool);
|
|
500
|
+
expect(r.content).toEqual([
|
|
501
|
+
{ type: "text", text: "Available workflows:\n - [saved] deploy-wf: deploy the app" },
|
|
502
|
+
]);
|
|
503
|
+
expect(r.details).toEqual({ action: "list", count: 1 });
|
|
504
|
+
});
|
|
505
|
+
|
|
506
|
+
it("description 缺省 → (no description) 占位(core formatter 行为透传)", async () => {
|
|
507
|
+
const registry = makeRegistry([makeListScript("bare-wf", "saved", "")]);
|
|
508
|
+
const tool = captureScriptTool(registry);
|
|
509
|
+
const r = await listCall(tool);
|
|
510
|
+
expect(r.content).toEqual([
|
|
511
|
+
{ type: "text", text: "Available workflows:\n - [saved] bare-wf: (no description)" },
|
|
512
|
+
]);
|
|
513
|
+
});
|
|
514
|
+
|
|
515
|
+
it("空清单(无任何脚本)→ 空态单行文案,无 details、无 isError", async () => {
|
|
516
|
+
const registry = makeRegistry([]);
|
|
517
|
+
const tool = captureScriptTool(registry);
|
|
518
|
+
const r = await listCall(tool);
|
|
519
|
+
expect(r.content).toEqual([{ type: "text", text: "No workflow scripts available." }]);
|
|
520
|
+
expect(r.details).toBeUndefined();
|
|
521
|
+
expect(r.isError).toBeUndefined();
|
|
522
|
+
});
|
|
523
|
+
|
|
524
|
+
it("全不可用清单 → 同空态文案(available filter 后为空)", async () => {
|
|
525
|
+
const registry = makeRegistry([{ ...makeListScript("broken-wf", "saved", "x"), available: false }]);
|
|
526
|
+
const tool = captureScriptTool(registry);
|
|
527
|
+
const r = await listCall(tool);
|
|
528
|
+
expect(r.content).toEqual([{ type: "text", text: "No workflow scripts available." }]);
|
|
529
|
+
});
|
|
530
|
+
});
|
|
531
|
+
|
|
532
|
+
// ── GUI attach(RPC 模式分发;list 的 GUI 投影 = scripts 计数 stats-line)──
|
|
533
|
+
|
|
534
|
+
describe("actionList GUI attach(execute 级 RPC/非 RPC 分发)", () => {
|
|
535
|
+
type GuiProjection = { __gui__?: { component?: { type?: string; props?: { items?: Array<{ label?: string; value?: string; severity?: string }> } } } };
|
|
536
|
+
|
|
537
|
+
it("RPC ctx → details 附带 __gui__(list → scripts 计数 stats-line)", async () => {
|
|
538
|
+
const registry = makeRegistry([makeListScript("deploy-wf", "saved", "deploy the app")]);
|
|
539
|
+
const tool = captureScriptTool(registry);
|
|
540
|
+
const r = await tool.execute("id", { action: "list" }, undefined, undefined, {
|
|
541
|
+
mode: "rpc",
|
|
542
|
+
hasUI: true,
|
|
543
|
+
});
|
|
544
|
+
const gui = (r.details as GuiProjection).__gui__;
|
|
545
|
+
expect(gui).toBeDefined();
|
|
546
|
+
expect(gui?.component?.type).toBe("stats-line");
|
|
547
|
+
expect(gui?.component?.props?.items).toEqual([{ label: "scripts", value: "1", severity: "ok" }]);
|
|
548
|
+
});
|
|
549
|
+
|
|
550
|
+
it("非 RPC ctx → details 无 __gui__(原始 details 结构保留)", async () => {
|
|
551
|
+
const registry = makeRegistry([makeListScript("deploy-wf", "saved", "deploy the app")]);
|
|
552
|
+
const tool = captureScriptTool(registry);
|
|
553
|
+
const r = await listCall(tool);
|
|
554
|
+
expect((r.details as GuiProjection).__gui__).toBeUndefined();
|
|
555
|
+
expect(r.details).toEqual({ action: "list", count: 1 });
|
|
556
|
+
});
|
|
557
|
+
});
|