@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.
Files changed (55) hide show
  1. package/package.json +7 -4
  2. package/scripts/rfl.mjs +308 -0
  3. package/src/execution/__tests__/finalize-record.test.ts +33 -7
  4. package/src/execution/__tests__/helpers/spawn-mock.ts +12 -3
  5. package/src/execution/__tests__/record-store.test.ts +284 -2
  6. package/src/execution/__tests__/spawn-args.test.ts +5 -5
  7. package/src/execution/__tests__/subagent-service-message-close.test.ts +31 -0
  8. package/src/execution/__tests__/worktree-manager.test.ts +9 -2
  9. package/src/execution/__tests__/worktree-pid-registration.integration.test.ts +4 -1
  10. package/src/execution/__tests__/worktree-reconcile.integration.test.ts +181 -0
  11. package/src/execution/__tests__/worktree-registry.test.ts +72 -34
  12. package/src/execution/agent-result-mapper.ts +4 -1
  13. package/src/execution/argv-mirror.ts +1 -1
  14. package/src/execution/channel-registry-access.ts +3 -1
  15. package/src/execution/finalize-record.ts +16 -7
  16. package/src/execution/idle-gc.ts +47 -0
  17. package/src/execution/lifecycle-manager.ts +9 -2
  18. package/src/execution/record-entry.ts +118 -0
  19. package/src/execution/record-store.ts +290 -1
  20. package/src/execution/session-pending.ts +5 -4
  21. package/src/execution/session-runner.ts +484 -289
  22. package/src/execution/subagent-service.ts +87 -37
  23. package/src/execution/temp-prompt.ts +8 -3
  24. package/src/execution/types.ts +20 -2
  25. package/src/execution/worktree-manager.ts +325 -14
  26. package/src/execution/worktree-registry.ts +90 -33
  27. package/src/index.ts +42 -2
  28. package/src/interface/__tests__/tool-workflow-script-generate.test.ts +103 -26
  29. package/src/interface/__tests__/tool-workflow-throw-paths.test.ts +179 -0
  30. package/src/interface/format.ts +10 -4
  31. package/src/interface/tool-render.ts +10 -3
  32. package/src/interface/tool-workflow-script.ts +29 -33
  33. package/src/interface/tool-workflow.ts +47 -65
  34. package/src/interface/views/detail-content.ts +1 -1
  35. package/src/orchestration/__tests__/__fixtures__/worker-template.snapshot.txt +9 -1
  36. package/src/orchestration/__tests__/execute-agent-call.test.ts +49 -1
  37. package/src/orchestration/__tests__/jsonl-run-store-loadall-sources.test.ts +171 -0
  38. package/src/orchestration/__tests__/jsonl-run-store-session-file.test.ts +181 -19
  39. package/src/orchestration/__tests__/lifecycle-runid-injection.test.ts +96 -0
  40. package/src/orchestration/__tests__/review-fix-loop-e2e.test.ts +1108 -19
  41. package/src/orchestration/__tests__/test-mocks.ts +9 -3
  42. package/src/orchestration/__tests__/worker-returnmeta-passthrough.test.ts +164 -0
  43. package/src/orchestration/__tests__/worker-script-template-snapshot.test.ts +12 -0
  44. package/src/orchestration/__tests__/workflows-e2e.test.ts +37 -39
  45. package/src/orchestration/error-recovery.ts +4 -1
  46. package/src/orchestration/execute-agent-call.ts +13 -3
  47. package/src/orchestration/jsonl-run-store.ts +139 -60
  48. package/src/orchestration/lifecycle.ts +10 -1
  49. package/src/orchestration/worker-script-builder.ts +9 -1
  50. package/src/shared/__tests__/resource-discovery.test.ts +24 -0
  51. package/src/shared/agent-ref.ts +6 -1
  52. package/src/shared/resource-discovery.ts +15 -1
  53. package/src/shared/schema-jsonify.ts +4 -1
  54. package/workflows/review-fix-loop-utils.cjs +542 -32
  55. 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 → error + writeFileSync 未调用 [P-generate-roundtrip]", () => {
117
- const r = actionGenerate(gen(PI_META_MALFORMED), undefined);
118
- expect(r.isError).toBe(true);
119
- expect(textOf(r)).toMatch(/cannot be parsed|无法解析|line/i);
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 → error 提及 @pi-meta 新格式", () => {
130
- const r = actionGenerate(gen(NO_META), undefined);
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 单反斜杠正则 → error(LLM 高频错)[P-generate-roundtrip]", () => {
138
- const r = actionGenerate(gen(PI_META_REGEX_SINGLE_BS), undefined);
139
- expect(r.isError).toBe(true);
140
- expect(textOf(r)).toMatch(/escape|cannot be parsed|无法解析|line/i);
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 → reject(保留现有行为)", () => {
145
- const r = actionGenerate(gen(ESM_IMPORT), undefined);
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() → reject(保留现有行为)", () => {
151
- const r = actionGenerate(gen(NO_AGENT), undefined);
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 → reject(保留现有行为)", () => {
161
+ it("TC8: signal aborted → throw(保留现有行为)", () => {
157
162
  const controller = new AbortController();
158
163
  controller.abort();
159
- const r = actionGenerate(gen(PI_META_VALID), controller.signal);
160
- expect(r.isError).toBe(true);
161
- expect(textOf(r)).toMatch(/abort/i);
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
+ });
@@ -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("sa-")) {
109
- return "sa-" + id.slice(3).split("-").slice(0, SHORT_ID_BG_SEGMENTS).join("-");
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 > 60 ? `${command.slice(0, 60)}...` : command;
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 > 50 ? `${argsStr.slice(0, 50)}...` : argsStr;
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 估算。-4 对应 Pi Box paddingX=1(左右各 1 列)+ 安全余量。
58
+ * 只能从 process.stdout 估算。
53
59
  */
54
60
  function getTermWidth(): number {
55
- return (process.stdout.columns || 120) - 4;
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?: unknown }).slug
110
+ ? (args as { slug: unknown }).slug
104
111
  : undefined;
105
112
  const slugStr = typeof slug === "string" ? slug.trim() : "";
106
113
  const parts = slugStr