@zhushanwen/pi-subagent-workflow 8.0.0 → 8.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +7 -4
- package/scripts/rfl.mjs +308 -0
- package/src/execution/__tests__/finalize-record.test.ts +33 -7
- package/src/execution/__tests__/helpers/spawn-mock.ts +12 -3
- package/src/execution/__tests__/record-store.test.ts +284 -2
- package/src/execution/__tests__/spawn-args.test.ts +5 -5
- package/src/execution/__tests__/subagent-service-message-close.test.ts +31 -0
- package/src/execution/__tests__/worktree-manager.test.ts +9 -2
- package/src/execution/__tests__/worktree-pid-registration.integration.test.ts +4 -1
- package/src/execution/__tests__/worktree-reconcile.integration.test.ts +181 -0
- package/src/execution/__tests__/worktree-registry.test.ts +72 -34
- package/src/execution/agent-result-mapper.ts +4 -1
- package/src/execution/argv-mirror.ts +1 -1
- package/src/execution/channel-registry-access.ts +3 -1
- package/src/execution/finalize-record.ts +16 -7
- package/src/execution/idle-gc.ts +47 -0
- package/src/execution/lifecycle-manager.ts +9 -2
- package/src/execution/record-entry.ts +118 -0
- package/src/execution/record-store.ts +290 -1
- package/src/execution/session-pending.ts +5 -4
- package/src/execution/session-runner.ts +484 -289
- package/src/execution/subagent-service.ts +87 -37
- package/src/execution/temp-prompt.ts +8 -3
- package/src/execution/types.ts +20 -2
- package/src/execution/worktree-manager.ts +325 -14
- package/src/execution/worktree-registry.ts +90 -33
- package/src/index.ts +42 -2
- package/src/interface/__tests__/tool-workflow-script-generate.test.ts +103 -26
- package/src/interface/__tests__/tool-workflow-throw-paths.test.ts +179 -0
- package/src/interface/format.ts +10 -4
- package/src/interface/tool-render.ts +10 -3
- package/src/interface/tool-workflow-script.ts +29 -33
- package/src/interface/tool-workflow.ts +47 -65
- package/src/interface/views/detail-content.ts +1 -1
- package/src/orchestration/__tests__/__fixtures__/worker-template.snapshot.txt +9 -1
- package/src/orchestration/__tests__/execute-agent-call.test.ts +49 -1
- package/src/orchestration/__tests__/jsonl-run-store-loadall-sources.test.ts +171 -0
- package/src/orchestration/__tests__/jsonl-run-store-session-file.test.ts +181 -19
- package/src/orchestration/__tests__/lifecycle-runid-injection.test.ts +96 -0
- package/src/orchestration/__tests__/review-fix-loop-e2e.test.ts +1108 -19
- package/src/orchestration/__tests__/test-mocks.ts +9 -3
- package/src/orchestration/__tests__/worker-returnmeta-passthrough.test.ts +164 -0
- package/src/orchestration/__tests__/worker-script-template-snapshot.test.ts +12 -0
- package/src/orchestration/__tests__/workflows-e2e.test.ts +37 -39
- package/src/orchestration/error-recovery.ts +4 -1
- package/src/orchestration/execute-agent-call.ts +13 -3
- package/src/orchestration/jsonl-run-store.ts +139 -60
- package/src/orchestration/lifecycle.ts +10 -1
- package/src/orchestration/worker-script-builder.ts +9 -1
- package/src/shared/__tests__/resource-discovery.test.ts +24 -0
- package/src/shared/agent-ref.ts +6 -1
- package/src/shared/resource-discovery.ts +15 -1
- package/src/shared/schema-jsonify.ts +4 -1
- package/workflows/review-fix-loop-utils.cjs +542 -32
- package/workflows/review-fix-loop.js +462 -109
package/src/index.ts
CHANGED
|
@@ -183,6 +183,24 @@ export default function subagentsWorkflowExtension(pi: ExtensionAPI): void {
|
|
|
183
183
|
return cachedMainSessionFile;
|
|
184
184
|
}
|
|
185
185
|
|
|
186
|
+
/**
|
|
187
|
+
* 按 sessionId 解析主 session 文件路径(文件名约定 `<ISO 时间戳>_<sessionId>.jsonl`)。
|
|
188
|
+
* [E2E 实测] attach 场景下 ctx.sessionManager.getSessionFile() 会返回前一 session 的
|
|
189
|
+
* 文件(session_start(root=01a01bf5) 时仍返回刚新建 session 的路径)——恢复逻辑
|
|
190
|
+
* 读错文件会整段漏判。此处按 id 从 sessions 目录解析为准;新 session 文件未 flush
|
|
191
|
+
* 时(AGENTS.md 规则 6:首条 assistant 消息前可能不存在)返回 undefined,调用方
|
|
192
|
+
* (fork 解析 / 孤儿恢复)对该场景本就无 entry 可读。
|
|
193
|
+
*/
|
|
194
|
+
function resolveMainSessionFileById(sessionId: string): string | undefined {
|
|
195
|
+
const sessionsDir = path.join(getAgentDir(), "..", "sessions");
|
|
196
|
+
try {
|
|
197
|
+
const match = fs.readdirSync(sessionsDir).find((f) => f.endsWith(`_${sessionId}.jsonl`));
|
|
198
|
+
return match === undefined ? undefined : path.join(sessionsDir, match);
|
|
199
|
+
} catch {
|
|
200
|
+
return undefined;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
186
204
|
// resources_discover:不再注入额外 skill 目录(ADR-031 废弃 discovery.json)。
|
|
187
205
|
// pi 核心 auto-discovery 已覆盖 .agents/skills 等标准目录,子 session 的
|
|
188
206
|
// --skill 由 agent({skill}) 调用方显式传入,无需 extension 额外补充。
|
|
@@ -381,9 +399,20 @@ export default function subagentsWorkflowExtension(pi: ExtensionAPI): void {
|
|
|
381
399
|
// SR-3: 无论 new 还是 existing(/resume /fork 复用),session_start 都必须注入 handler
|
|
382
400
|
service.setUiRequestHandler(uiRequestHandler);
|
|
383
401
|
|
|
402
|
+
// 主 session 文件:按 sessionId 解析(getSessionFile() 在 attach 场景会返回前一
|
|
403
|
+
// session 的文件,E2E 实测),未 flush 的新 session 回退 getSessionFile()。
|
|
404
|
+
// 值直传 initSession——jiti 多实例分裂下闭包缓存(cachedMainSessionFile)不跨
|
|
405
|
+
// 实例共享,恢复逻辑经缓存读会拿到滞后一个事件的值(E2E 实测 ENOENT 漏判);
|
|
406
|
+
// 缓存本身保留给既有 getter 消费者(fork source 解析)。
|
|
407
|
+
cachedMainSessionFile =
|
|
408
|
+
resolveMainSessionFileById(ctx.sessionManager.getSessionId()) ??
|
|
409
|
+
ctx.sessionManager.getSessionFile() ??
|
|
410
|
+
undefined;
|
|
411
|
+
|
|
384
412
|
service.initSession({
|
|
385
413
|
pi,
|
|
386
414
|
sessionId: ctx.sessionManager.getSessionId(),
|
|
415
|
+
mainSessionFile: cachedMainSessionFile,
|
|
387
416
|
// 注入 ctx.ui.setWidget 作为 streaming sink(只绑方法,不持有整个 ctx)。
|
|
388
417
|
// background subagent 执行期间,text_delta 经 SubagentStream 合并后由此通道转发。
|
|
389
418
|
// [W1 修复] ctx.mode === 'rpc' 守卫:TUI/json/print 下 streamSink = undefined(无 widget 噪音),
|
|
@@ -413,8 +442,6 @@ export default function subagentsWorkflowExtension(pi: ExtensionAPI): void {
|
|
|
413
442
|
// S-2: 启动 idle record GC 定时器(30 天 TTL,每小时检查一次)
|
|
414
443
|
service.startGcTimer();
|
|
415
444
|
|
|
416
|
-
cachedMainSessionFile = ctx.sessionManager.getSessionFile() ?? undefined;
|
|
417
|
-
|
|
418
445
|
try {
|
|
419
446
|
maybeCleanupExpiredSessionFiles(agentDir, cwd);
|
|
420
447
|
} catch (err) {
|
|
@@ -471,6 +498,19 @@ export default function subagentsWorkflowExtension(pi: ExtensionAPI): void {
|
|
|
471
498
|
id: run.runId,
|
|
472
499
|
reason: "failed",
|
|
473
500
|
});
|
|
501
|
+
// 恢复终态必须落盘:save 走冷路径(done 绕过去抖)同步写 state 文件 +
|
|
502
|
+
// append 终态 workflow-record entry——entry_appended 事件驱动 runtime 派生
|
|
503
|
+
// 缓存失效重拉(无 triggerTurn 副作用)。不 save 则 entry/state 双双停留
|
|
504
|
+
// running,侧栏永久卡 running。失败仅记日志不阻断其余 run 的恢复(下次
|
|
505
|
+
// session_start 重开重试,恢复循环天然幂等)。
|
|
506
|
+
try {
|
|
507
|
+
await store.save(run);
|
|
508
|
+
} catch (err) {
|
|
509
|
+
logger.error("[subagent-workflow] kill-9 recovery store.save failed", {
|
|
510
|
+
runId: run.runId,
|
|
511
|
+
reason: err instanceof Error ? err.message : String(err),
|
|
512
|
+
});
|
|
513
|
+
}
|
|
474
514
|
}
|
|
475
515
|
runs.set(run.runId, run);
|
|
476
516
|
}
|
|
@@ -10,13 +10,19 @@ import { mkdirSync, writeFileSync } from "node:fs";
|
|
|
10
10
|
|
|
11
11
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
12
12
|
|
|
13
|
-
import { actionGenerate, type ScriptParams, type TextContent } from "../tool-workflow-script.ts";
|
|
13
|
+
import { actionGenerate, type ScriptParams, type TextContent, registerWorkflowScriptTool } from "../tool-workflow-script.ts";
|
|
14
|
+
import { deleteWorkflow, saveWorkflow } from "../../orchestration/workflow-files.ts";
|
|
14
15
|
|
|
15
16
|
vi.mock("node:fs", () => ({
|
|
16
17
|
mkdirSync: vi.fn(),
|
|
17
18
|
writeFileSync: vi.fn(),
|
|
18
19
|
}));
|
|
19
20
|
|
|
21
|
+
vi.mock("../../orchestration/workflow-files.ts", () => ({
|
|
22
|
+
saveWorkflow: vi.fn(),
|
|
23
|
+
deleteWorkflow: vi.fn(),
|
|
24
|
+
}));
|
|
25
|
+
|
|
20
26
|
const mockedWriteFileSync = vi.mocked(writeFileSync);
|
|
21
27
|
const mockedMkdirSync = vi.mocked(mkdirSync);
|
|
22
28
|
|
|
@@ -106,6 +112,12 @@ describe("actionGenerate (m0: @pi-meta 认可 + round-trip)", () => {
|
|
|
106
112
|
vi.clearAllMocks();
|
|
107
113
|
});
|
|
108
114
|
|
|
115
|
+
/**
|
|
116
|
+
* W4b:generate 校验族错误路径从 return {isError:true} 改为 throw(pi 只对
|
|
117
|
+
* execute throw 置 isError:true,返回值 isError 被 agent-loop 丢弃)。
|
|
118
|
+
* actionGenerate 是同步函数——断言用同步 toThrow。
|
|
119
|
+
*/
|
|
120
|
+
|
|
109
121
|
it("TC1: 合法 @pi-meta → ready + writeFileSync 被调用", () => {
|
|
110
122
|
const r = actionGenerate(gen(PI_META_VALID), undefined);
|
|
111
123
|
expect(r.isError).toBeFalsy();
|
|
@@ -113,10 +125,10 @@ describe("actionGenerate (m0: @pi-meta 认可 + round-trip)", () => {
|
|
|
113
125
|
expect(mockedWriteFileSync).toHaveBeenCalledTimes(1);
|
|
114
126
|
});
|
|
115
127
|
|
|
116
|
-
it("TC2: malformed @pi-meta YAML →
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
128
|
+
it("TC2: malformed @pi-meta YAML → throw + writeFileSync 未调用 [P-generate-roundtrip]", () => {
|
|
129
|
+
expect(() => actionGenerate(gen(PI_META_MALFORMED), undefined)).toThrow(
|
|
130
|
+
/cannot be parsed/i,
|
|
131
|
+
);
|
|
120
132
|
expect(mockedWriteFileSync).not.toHaveBeenCalled();
|
|
121
133
|
});
|
|
122
134
|
|
|
@@ -126,38 +138,103 @@ describe("actionGenerate (m0: @pi-meta 认可 + round-trip)", () => {
|
|
|
126
138
|
expect(mockedWriteFileSync).toHaveBeenCalledTimes(1);
|
|
127
139
|
});
|
|
128
140
|
|
|
129
|
-
it("TC4: 无 meta →
|
|
130
|
-
|
|
131
|
-
expect(r.isError).toBe(true);
|
|
132
|
-
expect(textOf(r)).toMatch(/meta declaration/i);
|
|
133
|
-
expect(textOf(r)).toMatch(/pi-meta/i);
|
|
141
|
+
it("TC4: 无 meta → throw 提及 @pi-meta 新格式", () => {
|
|
142
|
+
expect(() => actionGenerate(gen(NO_META), undefined)).toThrow(/meta declaration/i);
|
|
134
143
|
expect(mockedWriteFileSync).not.toHaveBeenCalled();
|
|
135
144
|
});
|
|
136
145
|
|
|
137
|
-
it("TC5: @pi-meta 单反斜杠正则 →
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
146
|
+
it("TC5: @pi-meta 单反斜杠正则 → throw(LLM 高频错)[P-generate-roundtrip]", () => {
|
|
147
|
+
expect(() => actionGenerate(gen(PI_META_REGEX_SINGLE_BS), undefined)).toThrow(
|
|
148
|
+
/escape|cannot be parsed/i,
|
|
149
|
+
);
|
|
141
150
|
expect(mockedWriteFileSync).not.toHaveBeenCalled();
|
|
142
151
|
});
|
|
143
152
|
|
|
144
|
-
it("TC6: ESM import →
|
|
145
|
-
|
|
146
|
-
expect(r.isError).toBe(true);
|
|
147
|
-
expect(textOf(r)).toMatch(/ESM|import/i);
|
|
153
|
+
it("TC6: ESM import → throw(保留现有行为)", () => {
|
|
154
|
+
expect(() => actionGenerate(gen(ESM_IMPORT), undefined)).toThrow(/ESM|import/i);
|
|
148
155
|
});
|
|
149
156
|
|
|
150
|
-
it("TC7: 无 agent() →
|
|
151
|
-
|
|
152
|
-
expect(r.isError).toBe(true);
|
|
153
|
-
expect(textOf(r)).toMatch(/agent\(\)/i);
|
|
157
|
+
it("TC7: 无 agent() → throw(保留现有行为)", () => {
|
|
158
|
+
expect(() => actionGenerate(gen(NO_AGENT), undefined)).toThrow(/agent\(\)/i);
|
|
154
159
|
});
|
|
155
160
|
|
|
156
|
-
it("TC8: signal aborted →
|
|
161
|
+
it("TC8: signal aborted → throw(保留现有行为)", () => {
|
|
157
162
|
const controller = new AbortController();
|
|
158
163
|
controller.abort();
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
164
|
+
expect(() => actionGenerate(gen(PI_META_VALID), controller.signal)).toThrow(/abort/i);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it("TC9: 缺 name/script 参数 → throw 'generate requires'(防御性,schema 先拦)", () => {
|
|
168
|
+
expect(() => actionGenerate({ action: "generate" } as ScriptParams, undefined)).toThrow(
|
|
169
|
+
"generate requires 'name' and 'script' parameters",
|
|
170
|
+
);
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it("TC10: ESM export(非 meta)→ throw(W4b 收敛路径)", () => {
|
|
174
|
+
const script = `/* @pi-meta
|
|
175
|
+
name: x
|
|
176
|
+
description: d
|
|
177
|
+
phases: [a]
|
|
178
|
+
*/
|
|
179
|
+
const agent = require("./agent");
|
|
180
|
+
export const foo = 1;
|
|
181
|
+
agent("w");
|
|
182
|
+
`;
|
|
183
|
+
expect(() => actionGenerate(gen(script), undefined)).toThrow(/ESM 'export'/i);
|
|
184
|
+
});
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
describe("actionSave/actionDelete error paths (W4: throw 范式)", () => {
|
|
188
|
+
/**
|
|
189
|
+
* W4:save/delete 失败路径从 return {isError:true} 改为 throw——pi 只对 execute
|
|
190
|
+
* throw 置 isError:true(agent-loop.js:453-483 丢弃返回值里的 isError)。
|
|
191
|
+
* 经 registerWorkflowScriptTool 注册层测(mock workflow-files 的 FS 依赖)。
|
|
192
|
+
*/
|
|
193
|
+
interface CapturedTool {
|
|
194
|
+
execute: (
|
|
195
|
+
toolCallId: string,
|
|
196
|
+
params: Record<string, unknown>,
|
|
197
|
+
signal: AbortSignal | undefined,
|
|
198
|
+
onUpdate: unknown,
|
|
199
|
+
ctx: unknown,
|
|
200
|
+
) => Promise<TextContent>;
|
|
201
|
+
}
|
|
202
|
+
function captureTool(): CapturedTool {
|
|
203
|
+
const tools: CapturedTool[] = [];
|
|
204
|
+
const pi = { registerTool: (t: unknown) => tools.push(t as CapturedTool) };
|
|
205
|
+
// registry 最小 stub:delete 成功路径会调 invalidate(失败路径 throw 前不触达)
|
|
206
|
+
const registry = { invalidate: vi.fn() };
|
|
207
|
+
registerWorkflowScriptTool(pi as never, registry as never, () => false);
|
|
208
|
+
if (!tools[0]) throw new Error("registerWorkflowScriptTool did not register");
|
|
209
|
+
return tools[0];
|
|
210
|
+
}
|
|
211
|
+
const ctx = { mode: "tui" as const, hasUI: true };
|
|
212
|
+
|
|
213
|
+
it("save 失败 → throw 'Save failed: <原因>'(pi catch 后置 isError:true)", async () => {
|
|
214
|
+
vi.mocked(saveWorkflow).mockRejectedValueOnce(new Error("disk full"));
|
|
215
|
+
const tool = captureTool();
|
|
216
|
+
await expect(
|
|
217
|
+
tool.execute("id", { action: "save", name: "tmp-wf" }, undefined, undefined, ctx),
|
|
218
|
+
).rejects.toThrow("Save failed: disk full");
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
it("delete 失败 → throw 'Delete failed: <原因>'", async () => {
|
|
222
|
+
// deleteWorkflow 是同步函数——mock 用同步 throw(mockRejectedValue 不会被
|
|
223
|
+
// actionDelete 的同步 try/catch 捕获)
|
|
224
|
+
vi.mocked(deleteWorkflow).mockImplementationOnce(() => {
|
|
225
|
+
throw new Error("script is running");
|
|
226
|
+
});
|
|
227
|
+
const tool = captureTool();
|
|
228
|
+
await expect(
|
|
229
|
+
tool.execute("id", { action: "delete", name: "tmp-wf" }, undefined, undefined, ctx),
|
|
230
|
+
).rejects.toThrow("Delete failed: script is running");
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
it("save 成功路径不受影响(ok details 正常返回)", async () => {
|
|
234
|
+
vi.mocked(saveWorkflow).mockResolvedValueOnce("saved tmp-wf");
|
|
235
|
+
const tool = captureTool();
|
|
236
|
+
const r = await tool.execute("id", { action: "save", name: "tmp-wf" }, undefined, undefined, ctx);
|
|
237
|
+
expect(r.isError).toBeFalsy();
|
|
238
|
+
expect(r.details).toMatchObject({ action: "save", name: "tmp-wf", ok: true });
|
|
162
239
|
});
|
|
163
240
|
});
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* W4b:workflow tool 错误路径 throw 语义(代表路径)。
|
|
3
|
+
*
|
|
4
|
+
* pi 只对 execute throw 置 isError:true——返回值里的 isError 被 agent-loop 丢弃
|
|
5
|
+
* (agent-loop.js:453-483),故错误路径必须 throw。本文件抽 4 个代表路径
|
|
6
|
+
* (W4b 验收交付物 3):
|
|
7
|
+
* - not_found(abort 目标 runId 不存在)
|
|
8
|
+
* - reentry-busy(guard 占用,throw 前未 acquire)
|
|
9
|
+
* - 平铺检测(args 子字段平铺到顶层,LLM 常见错误)
|
|
10
|
+
* - slug 护栏(schema maxLength 之后的运行时第二道)
|
|
11
|
+
* generate 校验族(第 5 代表路径)见 tool-workflow-script-generate.test.ts。
|
|
12
|
+
*
|
|
13
|
+
* 经 registerWorkflowTool 注册层测(capture tool + 最小 deps stub),
|
|
14
|
+
* 与 W4 save/delete 测试同范式。框架:vitest。
|
|
15
|
+
*/
|
|
16
|
+
import { describe, expect, it, vi } from "vitest";
|
|
17
|
+
|
|
18
|
+
import { SLUG_MAX_LENGTH } from "../../execution/execute-options-mapper.ts";
|
|
19
|
+
import { REENTRY_BUSY_MESSAGE, type ReentryGuardRef } from "../reentry-guard.ts";
|
|
20
|
+
import { registerWorkflowTool } from "../tool-workflow.ts";
|
|
21
|
+
|
|
22
|
+
// ── capture helper ──
|
|
23
|
+
|
|
24
|
+
interface CapturedTool {
|
|
25
|
+
name: string;
|
|
26
|
+
execute: (
|
|
27
|
+
toolCallId: string,
|
|
28
|
+
params: Record<string, unknown>,
|
|
29
|
+
signal: AbortSignal | undefined,
|
|
30
|
+
onUpdate: unknown,
|
|
31
|
+
ctx: unknown,
|
|
32
|
+
) => Promise<unknown>;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function captureTool(deps: unknown, reentryRef: ReentryGuardRef): CapturedTool {
|
|
36
|
+
const tools: CapturedTool[] = [];
|
|
37
|
+
const pi = { registerTool: (t: unknown) => tools.push(t as CapturedTool) };
|
|
38
|
+
registerWorkflowTool(pi as never, deps as never, reentryRef);
|
|
39
|
+
if (!tools[0] || tools[0].name !== "workflow") {
|
|
40
|
+
throw new Error("registerWorkflowTool did not register the workflow tool");
|
|
41
|
+
}
|
|
42
|
+
return tools[0];
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** 最小 LauncherDeps stub:被测路径只触 registry / runs。 */
|
|
46
|
+
function makeDeps(overrides: Record<string, unknown> = {}): Record<string, unknown> {
|
|
47
|
+
return {
|
|
48
|
+
runs: new Map(),
|
|
49
|
+
registry: {
|
|
50
|
+
getPath: vi.fn().mockResolvedValue(undefined),
|
|
51
|
+
loadAll: vi.fn().mockResolvedValue([]),
|
|
52
|
+
},
|
|
53
|
+
...overrides,
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** 平铺检测 / slug 护栏路径的 registry script stub(meta.parameters 驱动已知键集)。 */
|
|
58
|
+
function makeScript(parameters?: object): Record<string, unknown> {
|
|
59
|
+
return {
|
|
60
|
+
name: "demo-wf",
|
|
61
|
+
path: "/abs/demo-wf.js",
|
|
62
|
+
available: true,
|
|
63
|
+
sourceCode: "// demo",
|
|
64
|
+
meta: { description: "demo workflow", parameters },
|
|
65
|
+
toExecutable: () => "// demo",
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// ── 代表路径 ──
|
|
70
|
+
|
|
71
|
+
describe("W4b: workflow tool 错误路径 throw 语义", () => {
|
|
72
|
+
it("not_found:abort 目标 runId 不存在 → throw(pi catch 后置 isError:true)", async () => {
|
|
73
|
+
const tool = captureTool(makeDeps(), { isProcessing: false });
|
|
74
|
+
await expect(
|
|
75
|
+
tool.execute("id", { action: "abort", runId: "no-such-run" }, undefined, undefined, {}),
|
|
76
|
+
).rejects.toThrow(
|
|
77
|
+
"Workflow 'no-such-run' not found. Use action:status to list active runs and their runIds.",
|
|
78
|
+
);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("W4c not_found:run 目标路径不可读(getPath 返回 available:false stub 而非 undefined)→ throw,不假启动", async () => {
|
|
82
|
+
// config-loader.toCachedMeta 对不存在/不可读文件返回 available:false 的空壳实体
|
|
83
|
+
// (非 undefined)——旧判定仅 !script 绕过 not_found,空 sourceCode 的 run 假启动
|
|
84
|
+
// (W4b verifier 探针实测复现)。registry 层真行为见 config-loader.ts:143-151。
|
|
85
|
+
const deps = makeDeps({
|
|
86
|
+
registry: {
|
|
87
|
+
getPath: vi.fn().mockResolvedValue({
|
|
88
|
+
name: "ghost-wf",
|
|
89
|
+
path: "/tmp/no-such-workflow.js",
|
|
90
|
+
available: false,
|
|
91
|
+
sourceCode: "",
|
|
92
|
+
meta: { description: "", parameters: undefined },
|
|
93
|
+
toExecutable: () => "",
|
|
94
|
+
}),
|
|
95
|
+
loadAll: vi.fn().mockResolvedValue([]),
|
|
96
|
+
},
|
|
97
|
+
});
|
|
98
|
+
const tool = captureTool(deps, { isProcessing: false });
|
|
99
|
+
await expect(
|
|
100
|
+
tool.execute("id", { action: "run", name: "/tmp/no-such-workflow.js" }, undefined, undefined, {}),
|
|
101
|
+
).rejects.toThrow("Workflow '/tmp/no-such-workflow.js' not found.");
|
|
102
|
+
// 未假启动:runs 注册表不出现该 run
|
|
103
|
+
expect(deps.runs).toBeInstanceOf(Map);
|
|
104
|
+
expect((deps.runs as Map<string, unknown>).size).toBe(0);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it("reentry-busy:guard 占用 → throw REENTRY_BUSY_MESSAGE,且 guard 状态不被污染", async () => {
|
|
108
|
+
const guard: ReentryGuardRef = { isProcessing: true };
|
|
109
|
+
const tool = captureTool(makeDeps(), guard);
|
|
110
|
+
await expect(
|
|
111
|
+
tool.execute("id", { action: "status" }, undefined, undefined, {}),
|
|
112
|
+
).rejects.toThrow(REENTRY_BUSY_MESSAGE);
|
|
113
|
+
// throw 发生在 acquire 之前——占用方语义保持,不产生双重 release
|
|
114
|
+
expect(guard.isProcessing).toBe(true);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it("平铺检测:args 子字段提到顶层 → throw 'Detected ... at top level'(含 Correct 正例)", async () => {
|
|
118
|
+
const deps = makeDeps({
|
|
119
|
+
registry: {
|
|
120
|
+
getPath: vi.fn().mockResolvedValue(
|
|
121
|
+
makeScript({
|
|
122
|
+
type: "object",
|
|
123
|
+
properties: { task: { type: "string" }, items: { type: "array" } },
|
|
124
|
+
required: ["task"],
|
|
125
|
+
}),
|
|
126
|
+
),
|
|
127
|
+
},
|
|
128
|
+
});
|
|
129
|
+
const tool = captureTool(deps, { isProcessing: false });
|
|
130
|
+
await expect(
|
|
131
|
+
tool.execute(
|
|
132
|
+
"id",
|
|
133
|
+
{ action: "run", name: "/abs/demo-wf.js", task: "do work" },
|
|
134
|
+
undefined,
|
|
135
|
+
undefined,
|
|
136
|
+
{},
|
|
137
|
+
),
|
|
138
|
+
).rejects.toThrow(
|
|
139
|
+
/Detected task at top level — they belong inside 'args'\. Correct: \{"action":"run","name":"\/abs\/demo-wf\.js","args":\{"task": "<value>"\}\}/,
|
|
140
|
+
);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it("slug 护栏:slug 超 SLUG_MAX_LENGTH → throw 'slug exceeds ...'(运行时第二道)", async () => {
|
|
144
|
+
const deps = makeDeps({
|
|
145
|
+
registry: {
|
|
146
|
+
getPath: vi.fn().mockResolvedValue(
|
|
147
|
+
makeScript({
|
|
148
|
+
type: "object",
|
|
149
|
+
properties: { task: { type: "string" } },
|
|
150
|
+
required: ["task"],
|
|
151
|
+
}),
|
|
152
|
+
),
|
|
153
|
+
},
|
|
154
|
+
});
|
|
155
|
+
const tool = captureTool(deps, { isProcessing: false });
|
|
156
|
+
const longSlug = "a".repeat(SLUG_MAX_LENGTH + 1);
|
|
157
|
+
await expect(
|
|
158
|
+
tool.execute(
|
|
159
|
+
"id",
|
|
160
|
+
{ action: "run", name: "/abs/demo-wf.js", slug: longSlug, args: {} },
|
|
161
|
+
undefined,
|
|
162
|
+
undefined,
|
|
163
|
+
{},
|
|
164
|
+
),
|
|
165
|
+
).rejects.toThrow(
|
|
166
|
+
`slug exceeds ${SLUG_MAX_LENGTH} chars (got ${longSlug.length}). Shorten to a kebab-case label, e.g. "fix-login", "extract-urls".`,
|
|
167
|
+
);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it("throw 后 reentry guard 经 finally 正常释放(成功路径回归)", async () => {
|
|
171
|
+
// abort not_found throw 穿透 execute try/finally:guard 必须复位,否则后续命令全部 busy
|
|
172
|
+
const guard: ReentryGuardRef = { isProcessing: false };
|
|
173
|
+
const tool = captureTool(makeDeps(), guard);
|
|
174
|
+
await expect(
|
|
175
|
+
tool.execute("id", { action: "abort", runId: "no-such-run" }, undefined, undefined, {}),
|
|
176
|
+
).rejects.toThrow(/not found/);
|
|
177
|
+
expect(guard.isProcessing).toBe(false);
|
|
178
|
+
});
|
|
179
|
+
});
|
package/src/interface/format.ts
CHANGED
|
@@ -91,6 +91,12 @@ export function formatElapsedSeconds(seconds: number): string {
|
|
|
91
91
|
const SHORT_ID_SYNC_SEGMENTS = 2;
|
|
92
92
|
/** background id 取前 N 段(bg/${tag}/${seq})。 */
|
|
93
93
|
const SHORT_ID_BG_SEGMENTS = 3;
|
|
94
|
+
/** subagent record id 的 `sa-` 前缀(前缀后接 UUID)。 */
|
|
95
|
+
const SA_ID_PREFIX = "sa-";
|
|
96
|
+
/** formatToolCall bash 分支 command 预览截断长度(字符)。 */
|
|
97
|
+
const BASH_PREVIEW_MAX_CHARS = 60;
|
|
98
|
+
/** formatToolCall default 分支 args JSON 预览截断长度(字符)。 */
|
|
99
|
+
const ARGS_PREVIEW_MAX_CHARS = 50;
|
|
94
100
|
|
|
95
101
|
/**
|
|
96
102
|
* 从完整 record id 提取短编号用于列表展示.
|
|
@@ -105,8 +111,8 @@ const SHORT_ID_BG_SEGMENTS = 3;
|
|
|
105
111
|
*/
|
|
106
112
|
export function shortId(id: string): string {
|
|
107
113
|
// sa- 前缀的 subagent ID:保留前缀 + UUID 前 3 段(与纯 UUID 的 3 段信息量等价)
|
|
108
|
-
if (id.startsWith(
|
|
109
|
-
return
|
|
114
|
+
if (id.startsWith(SA_ID_PREFIX)) {
|
|
115
|
+
return SA_ID_PREFIX + id.slice(SA_ID_PREFIX.length).split("-").slice(0, SHORT_ID_BG_SEGMENTS).join("-");
|
|
110
116
|
}
|
|
111
117
|
const segments = id.split("-");
|
|
112
118
|
if (segments.length <= SHORT_ID_SYNC_SEGMENTS) return id;
|
|
@@ -310,7 +316,7 @@ export function formatToolCall(
|
|
|
310
316
|
switch (toolName) {
|
|
311
317
|
case "bash": {
|
|
312
318
|
const command = (args.command as string) || "...";
|
|
313
|
-
const preview = command.length >
|
|
319
|
+
const preview = command.length > BASH_PREVIEW_MAX_CHARS ? `${command.slice(0, BASH_PREVIEW_MAX_CHARS)}...` : command;
|
|
314
320
|
return theme.fg("muted", "$ ") + theme.fg("toolOutput", preview);
|
|
315
321
|
}
|
|
316
322
|
case "read": {
|
|
@@ -354,7 +360,7 @@ export function formatToolCall(
|
|
|
354
360
|
}
|
|
355
361
|
default: {
|
|
356
362
|
const argsStr = JSON.stringify(args);
|
|
357
|
-
const preview = argsStr.length >
|
|
363
|
+
const preview = argsStr.length > ARGS_PREVIEW_MAX_CHARS ? `${argsStr.slice(0, ARGS_PREVIEW_MAX_CHARS)}...` : argsStr;
|
|
358
364
|
return theme.fg("accent", toolName) + theme.fg("dim", ` ${preview}`);
|
|
359
365
|
}
|
|
360
366
|
}
|
|
@@ -46,13 +46,19 @@ const STREAM_PREFIX = " ⎿ ";
|
|
|
46
46
|
/** footer 用的纯空格缩进(与 STREAM_PREFIX 等宽 4 列,但不带 ⎿)。 */
|
|
47
47
|
const FOOTER_PREFIX = " ";
|
|
48
48
|
|
|
49
|
+
/** process.stdout.columns 不可用(非 TTY 等)时的终端宽度兜底(列)。 */
|
|
50
|
+
const DEFAULT_TERM_WIDTH_COLUMNS = 120;
|
|
51
|
+
|
|
52
|
+
/** 终端宽度扣减(列):Pi Box paddingX=1(左右各 1 列)+ 安全余量。 */
|
|
53
|
+
const TERM_WIDTH_BOX_INSET_COLUMNS = 4;
|
|
54
|
+
|
|
49
55
|
/**
|
|
50
56
|
* 获取终端宽度(参照 nicobailon getTermWidth)。
|
|
51
57
|
* truncLine 需要在创建 Text 之前执行——此时 Pi 的 Box.render(contentWidth) 还未调用,
|
|
52
|
-
* 只能从 process.stdout
|
|
58
|
+
* 只能从 process.stdout 估算。
|
|
53
59
|
*/
|
|
54
60
|
function getTermWidth(): number {
|
|
55
|
-
return (process.stdout.columns ||
|
|
61
|
+
return (process.stdout.columns || DEFAULT_TERM_WIDTH_COLUMNS) - TERM_WIDTH_BOX_INSET_COLUMNS;
|
|
56
62
|
}
|
|
57
63
|
|
|
58
64
|
// ============================================================
|
|
@@ -99,8 +105,9 @@ export function renderSubagentCall(
|
|
|
99
105
|
// (displayAgentName);extractAgentName 原值另被 subagent-tool 的 resolveModel 消费,不动。
|
|
100
106
|
const agent = displayAgentName(extractAgentName(args));
|
|
101
107
|
// slug:从顶层 args 提取(必填字段),非空时在 agent 后用 · 分隔展示。
|
|
108
|
+
// 断言目标含必填 slug 字段(in 守卫已确认存在),对齐 format.ts extractAgentName 先例。
|
|
102
109
|
const slug = typeof args === "object" && args !== null && "slug" in args
|
|
103
|
-
? (args as { slug
|
|
110
|
+
? (args as { slug: unknown }).slug
|
|
104
111
|
: undefined;
|
|
105
112
|
const slugStr = typeof slug === "string" ? slug.trim() : "";
|
|
106
113
|
const parts = slugStr
|