@zhushanwen/pi-subagent-workflow 8.7.0 → 8.8.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 (49) hide show
  1. package/README.md +5 -11
  2. package/package.json +7 -10
  3. package/skills/workflow-script-format/SKILL.md +32 -13
  4. package/src/host/__tests__/pi-host.test.ts +23 -2
  5. package/src/host/pi-host.ts +57 -3
  6. package/src/index.ts +56 -110
  7. package/src/injectors/__tests__/engine-awareness.test.ts +2 -2
  8. package/src/injectors/__tests__/engine-section-stability.test.ts +6 -4
  9. package/src/injectors/__tests__/model-list-injector.test.ts +18 -21
  10. package/src/injectors/__tests__/subagent-list-injector.test.ts +54 -14
  11. package/src/injectors/__tests__/workflow-list-injector.test.ts +26 -12
  12. package/src/injectors/engine-awareness.ts +0 -4
  13. package/src/injectors/model-list-injector.ts +15 -58
  14. package/src/injectors/subagent-list-injector.ts +55 -113
  15. package/src/injectors/workflow-list-injector.ts +33 -66
  16. package/src/interface/__tests__/detectors.test.ts +45 -34
  17. package/src/interface/__tests__/subagent-tool-prompt.test.ts +6 -3
  18. package/src/interface/__tests__/tool-workflow-run-builtin-name.test.ts +216 -0
  19. package/src/interface/__tests__/tool-workflow-script-generate.test.ts +17 -6
  20. package/src/interface/__tests__/tool-workflow-throw-paths.test.ts +37 -1
  21. package/src/interface/bg-notify-render.ts +3 -13
  22. package/src/interface/command-actions.ts +5 -15
  23. package/src/interface/commands.ts +1 -1
  24. package/src/interface/format.ts +15 -4
  25. package/src/interface/gui-mappers.ts +18 -22
  26. package/src/interface/helpers.ts +8 -129
  27. package/src/interface/list-component.ts +1 -1
  28. package/src/interface/list-shared.ts +1 -1
  29. package/src/interface/list-view.ts +1 -1
  30. package/src/interface/subagent-actions.ts +51 -676
  31. package/src/interface/subagent-tool-schema.ts +1 -4
  32. package/src/interface/subagent-tool.ts +1 -1
  33. package/src/interface/subagents.ts +1 -1
  34. package/src/interface/tool-render.ts +3 -13
  35. package/src/interface/tool-workflow-script.ts +33 -75
  36. package/src/interface/tool-workflow.ts +51 -88
  37. package/src/interface/views/WorkflowsView.ts +3 -11
  38. package/src/interface/views/format.ts +19 -58
  39. package/src/jsonl-run-store.ts +134 -222
  40. package/agents/analyst.md +0 -61
  41. package/agents/coder.md +0 -70
  42. package/agents/debugger.md +0 -67
  43. package/agents/doc-reviewer.md +0 -50
  44. package/agents/explorer.md +0 -64
  45. package/agents/general-purpose.md +0 -32
  46. package/agents/orchestrator.md +0 -63
  47. package/agents/planner.md +0 -54
  48. package/agents/researcher.md +0 -65
  49. package/agents/reviewer.md +0 -74
@@ -0,0 +1,216 @@
1
+ /**
2
+ * C5③:actionRun 放开内置 workflow 名(convergence D-4 pi 半边)。
3
+ *
4
+ * 解析序:registry.get(name)(内置/已保存 workflow 名,按 tmp>project>user>npm
5
+ * 优先级合并)→ 未命中 registry.getPath(name)(绝对路径 + ~/ 展开)→ 两者都 miss
6
+ * 走原 not_found 报错(文案不变)。严格超集:现有路径用法零变化。
7
+ *
8
+ * 三视角:
9
+ * - 使用者(黑盒):run {"action":"run","name":"chain"} 直接可跑(内置名新能力);
10
+ * 传路径仍可跑(现行为);两者都 miss 的报错与改造前逐字一致。
11
+ * - 构建者(白盒):解析序 get→getPath、get 未命中才穿透到 getPath。
12
+ * - 观察者(真 registry):WorkflowScriptRegistryImpl + fixture 目录经真实
13
+ * discoverWorkflows 按名命中(生产 lookup 链)。
14
+ *
15
+ * mock 策略:lifecycle 深路径 stub(runWorkflow/abortRun 为 vi.fn——不起真 Worker,
16
+ * 只验证 run 启动面的脚本解析与 spec 组装)。框架:vitest(禁 node:test)。
17
+ */
18
+ import { mkdtempSync, readdirSync, rmSync, writeFileSync, mkdirSync } from "node:fs";
19
+ import { tmpdir } from "node:os";
20
+ import { join } from "node:path";
21
+
22
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
23
+
24
+ /** 桩化 lifecycle——runWorkflow/abortRun 为 vi.fn,不起真 Worker(只测解析面)。 */
25
+ vi.mock("@zhushanwen/subagent-core/orchestration/lifecycle.ts", () => ({
26
+ runWorkflow: vi.fn(),
27
+ abortRun: vi.fn(),
28
+ }));
29
+
30
+ // 被 mock 的模块——import 路径与被测源文件(tool-workflow.ts 深路径 import)一致
31
+ import { runWorkflow } from "@zhushanwen/subagent-core/orchestration/lifecycle.ts";
32
+ import { actionRun } from "../tool-workflow.ts";
33
+ import { WorkflowScriptRegistryImpl } from "@zhushanwen/subagent-core";
34
+
35
+ // ── fixture:可用 workflow 脚本(@pi-meta 新格式,无参数声明) ──
36
+
37
+ const CHAIN_META = `/* @pi-meta
38
+ name: chain
39
+ description: 内置名测试用三步链
40
+ phases: [a, b]
41
+ */
42
+ const agent = require("./agent");
43
+ agent("w", { task: $ARGS.task });
44
+ `;
45
+
46
+ /** fake registry 的最小 WorkflowScript stub。 */
47
+ function makeScript(name: string, path: string, parameters?: object) {
48
+ return {
49
+ name,
50
+ path,
51
+ available: true,
52
+ sourceCode: `// ${name}`,
53
+ meta: { description: `${name} workflow`, parameters },
54
+ toExecutable: () => `// ${name}`,
55
+ };
56
+ }
57
+
58
+ /** 最小 deps stub(runWorkflow 已 mock;store 只消费 stateFilePath)。 */
59
+ function makeDeps(registry: Record<string, unknown>): Record<string, unknown> {
60
+ return {
61
+ runs: new Map(),
62
+ store: { stateFilePath: (id: string) => `/tmp/state/${id}.jsonl` },
63
+ registry,
64
+ };
65
+ }
66
+
67
+ beforeEach(() => {
68
+ vi.mocked(runWorkflow).mockReset();
69
+ vi.mocked(runWorkflow).mockResolvedValue("run-id-1");
70
+ });
71
+
72
+ afterEach(() => {
73
+ vi.restoreAllMocks();
74
+ });
75
+
76
+ describe("C5③ run 内置名(fake registry)", () => {
77
+ it("run 传内置名 → registry.get 命中即启动(runWorkflow 收到该脚本,getPath 不被调用)", async () => {
78
+ const chain = makeScript("chain", "/builtin/workflows/chain.js");
79
+ const registry = {
80
+ get: vi.fn().mockResolvedValue(chain),
81
+ getPath: vi.fn().mockResolvedValue(undefined),
82
+ loadAll: vi.fn().mockResolvedValue([chain]),
83
+ };
84
+ const result = await actionRun(
85
+ { action: "run", name: "chain" } as never,
86
+ makeDeps(registry) as never,
87
+ undefined,
88
+ );
89
+
90
+ expect(registry.get).toHaveBeenCalledWith("chain");
91
+ expect(registry.getPath).not.toHaveBeenCalled();
92
+ expect(vi.mocked(runWorkflow)).toHaveBeenCalledTimes(1);
93
+ // spec 组装:scriptName/scriptPath 来自按名命中的脚本
94
+ const spec = vi.mocked(runWorkflow).mock.calls[0][0] as Record<string, unknown>;
95
+ expect(spec.scriptName).toBe("chain");
96
+ expect(spec.scriptPath).toBe("/builtin/workflows/chain.js");
97
+ expect(result.content[0]?.text).toContain("Started workflow 'chain'");
98
+ expect(result.details).toMatchObject({ action: "run", status: "running", name: "chain" });
99
+ });
100
+
101
+ it("严格超集:get 未命中的路径名 → 穿透 getPath(现有路径用法零变化)", async () => {
102
+ const byPath = makeScript("demo", "/abs/demo.js");
103
+ const registry = {
104
+ get: vi.fn().mockResolvedValue(undefined),
105
+ getPath: vi.fn().mockResolvedValue(byPath),
106
+ loadAll: vi.fn().mockResolvedValue([byPath]),
107
+ };
108
+ await actionRun(
109
+ { action: "run", name: "/abs/demo.js" } as never,
110
+ makeDeps(registry) as never,
111
+ undefined,
112
+ );
113
+
114
+ expect(registry.get).toHaveBeenCalledWith("/abs/demo.js");
115
+ expect(registry.getPath).toHaveBeenCalledWith("/abs/demo.js");
116
+ expect(vi.mocked(runWorkflow)).toHaveBeenCalledTimes(1);
117
+ const spec = vi.mocked(runWorkflow).mock.calls[0][0] as Record<string, unknown>;
118
+ expect(spec.scriptName).toBe("demo");
119
+ });
120
+
121
+ it("get 命中 available:false 的 stub → 不启动,走 not_found 报错(W4c 口径不回退)", async () => {
122
+ const ghost = { ...makeScript("ghost", "/builtin/ghost.js"), available: false };
123
+ const registry = {
124
+ get: vi.fn().mockResolvedValue(ghost),
125
+ getPath: vi.fn().mockResolvedValue(undefined),
126
+ loadAll: vi.fn().mockResolvedValue([]),
127
+ };
128
+ await expect(
129
+ actionRun(
130
+ { action: "run", name: "ghost" } as never,
131
+ makeDeps(registry) as never,
132
+ undefined,
133
+ ),
134
+ ).rejects.toThrow(/Workflow 'ghost' not found\./);
135
+ expect(vi.mocked(runWorkflow)).not.toHaveBeenCalled();
136
+ });
137
+
138
+ it("两者都 miss → 报错文案与改造前一致(含建议清单与 location 指引)", async () => {
139
+ const registry = {
140
+ get: vi.fn().mockResolvedValue(undefined),
141
+ getPath: vi.fn().mockResolvedValue(undefined),
142
+ loadAll: vi.fn().mockResolvedValue([makeScript("chain", "/builtin/workflows/chain.js")]),
143
+ };
144
+ await expect(
145
+ actionRun(
146
+ { action: "run", name: "no-such" } as never,
147
+ makeDeps(registry) as never,
148
+ undefined,
149
+ ),
150
+ ).rejects.toThrow(
151
+ "Workflow 'no-such' not found. Available:\n - chain: chain workflow\nUse <location> from <available_workflows> for the absolute .js path.",
152
+ );
153
+ });
154
+ });
155
+
156
+ describe("C5③ run 内置名(真 registry:WorkflowScriptRegistryImpl + 真实发现链)", () => {
157
+ let fixtureDir: string;
158
+
159
+ beforeEach(() => {
160
+ fixtureDir = mkdtempSync(join(tmpdir(), "c5-run-builtin-"));
161
+ // WorkflowScanConfig 布局:projectDir = <fixture>/ws/.pi/workflows(反推 workspaceRoot)
162
+ const projectDir = join(fixtureDir, "ws", ".pi", "workflows");
163
+ mkdirSync(projectDir, { recursive: true });
164
+ writeFileSync(join(projectDir, "chain.js"), CHAIN_META, "utf-8");
165
+ });
166
+
167
+ afterEach(() => {
168
+ rmSync(fixtureDir, { recursive: true, force: true });
169
+ });
170
+
171
+ it("get('chain') 经真实 discoverWorkflows 命中 fixture 脚本 → actionRun 按名启动成功", async () => {
172
+ const registry = new WorkflowScriptRegistryImpl({
173
+ projectDir: join(fixtureDir, "ws", ".pi", "workflows"),
174
+ userDir: join(fixtureDir, "user", "workflows"),
175
+ tmpDir: join(fixtureDir, "ws", ".pi", "workflows", ".tmp"),
176
+ npmDirs: [],
177
+ });
178
+ // 前置自检:fixture 布局可被扫描(隔离 config 下 hostRoots 为空、仅 project 根命中)
179
+ const all = await registry.loadAll();
180
+ expect(all.filter((w) => w.available).map((w) => w.name)).toContain("chain");
181
+
182
+ const result = await actionRun(
183
+ { action: "run", name: "chain" } as never,
184
+ makeDeps(registry as unknown as Record<string, unknown>) as never,
185
+ undefined,
186
+ );
187
+
188
+ expect(vi.mocked(runWorkflow)).toHaveBeenCalledTimes(1);
189
+ const spec = vi.mocked(runWorkflow).mock.calls[0][0] as Record<string, unknown>;
190
+ expect(spec.scriptName).toBe("chain");
191
+ expect(String(spec.scriptPath)).toContain("chain.js");
192
+ expect(result.content[0]?.text).toContain("Started workflow 'chain'");
193
+ });
194
+
195
+ it("真 registry 下未知名(无路径形态)→ not_found(含 fixture 内可用清单)", async () => {
196
+ const registry = new WorkflowScriptRegistryImpl({
197
+ projectDir: join(fixtureDir, "ws", ".pi", "workflows"),
198
+ userDir: join(fixtureDir, "user", "workflows"),
199
+ tmpDir: join(fixtureDir, "ws", ".pi", "workflows", ".tmp"),
200
+ npmDirs: [],
201
+ });
202
+ await expect(
203
+ actionRun(
204
+ { action: "run", name: "not-a-workflow" } as never,
205
+ makeDeps(registry as unknown as Record<string, unknown>) as never,
206
+ undefined,
207
+ ),
208
+ ).rejects.toThrow(/Workflow 'not-a-workflow' not found\./);
209
+ expect(vi.mocked(runWorkflow)).not.toHaveBeenCalled();
210
+ });
211
+
212
+ it("fixture 卫生断言:fixture 目录无其他 .js 泄漏(避免 discoverWorkflows 误扫)", () => {
213
+ const projectDir = join(fixtureDir, "ws", ".pi", "workflows");
214
+ expect(readdirSync(projectDir).filter((f) => f.endsWith(".js"))).toEqual(["chain.js"]);
215
+ });
216
+ });
@@ -1,8 +1,13 @@
1
1
  /**
2
- * actionGenerate 行为测试(m0 wave / TC1-TC7 + [P-generate-roundtrip]
2
+ * actionGenerate 行为测试(m0 wave / TC1-TC7 + [P-generate-roundtrip];C5② 改接 core 管线)
3
3
  *
4
- * mock node:fsmkdirSync/writeFileSync)避免真实落盘 .pi/workflows/.tmp/。
5
- * parseResourceMetaDetailed 真实调用(不 mock m1)——round-trip 探针必须用真实 IF2。
4
+ * C5② 起五道闸校验 + tmp 写盘在 core generateWorkflowScriptbarrel import)——本测试
5
+ * 验证宿主契约层(结构化结果 execute-throw 转换、signal aborted、成功文案)+ 经
6
+ * 真实 core 管线的校验行为回归。
7
+ *
8
+ * mock node:fs(mkdirSync/writeFileSync)避免真实落盘 .pi/workflows/.tmp/(builtin
9
+ * 模块 mock 对 core 管线内的写盘同样生效)。save/delete 走 barrel mock(importActual
10
+ * 展开覆写,其余 barrel 面(含 generateWorkflowScript)保持真实)。
6
11
  *
7
12
  * 框架:vitest(禁 node:test)。
8
13
  */
@@ -11,14 +16,20 @@ import { mkdirSync, writeFileSync } from "node:fs";
11
16
  import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
12
17
 
13
18
  import { actionGenerate, type ScriptParams, type TextContent, registerWorkflowScriptTool } from "../tool-workflow-script.ts";
14
- import { deleteWorkflow, saveWorkflow } from "@zhushanwen/subagent-core/orchestration/workflow-files.ts";
19
+ import { deleteWorkflow, saveWorkflow } from "@zhushanwen/subagent-core";
15
20
 
16
- vi.mock("node:fs", () => ({
21
+ // node:fs 只覆写两个写盘函数、其余保持真实——C5② 后被测链经 barrel 拉起完整 core
22
+ // 依赖图(importActual),core 模块对 existsSync/statSync 等的消费不能被 2 函数工厂截断
23
+ vi.mock("node:fs", async (importOriginal) => ({
24
+ ...(await importOriginal<typeof import("node:fs")>()),
17
25
  mkdirSync: vi.fn(),
18
26
  writeFileSync: vi.fn(),
19
27
  }));
20
28
 
21
- vi.mock("@zhushanwen/subagent-core/orchestration/workflow-files.ts", () => ({
29
+ // C5②:被测模块经 barrel 消费 save/delete/generateWorkflowScript——mock barrel,
30
+ // importActual 展开保持 generateWorkflowScript 真实(校验管线是被测回归面)
31
+ vi.mock("@zhushanwen/subagent-core", async (importOriginal) => ({
32
+ ...(await importOriginal<typeof import("@zhushanwen/subagent-core")>()),
22
33
  saveWorkflow: vi.fn(),
23
34
  deleteWorkflow: vi.fn(),
24
35
  }));
@@ -42,11 +42,13 @@ function captureTool(deps: unknown, reentryRef: ReentryGuardRef): CapturedTool {
42
42
  return tools[0];
43
43
  }
44
44
 
45
- /** 最小 LauncherDeps stub:被测路径只触 registry / runs */
45
+ /** 最小 LauncherDeps stub:被测路径只触 registry / runs(C5③ 起 actionRun 先查
46
+ * registry.get 再 registry.getPath——两查都须在 stub 面)。 */
46
47
  function makeDeps(overrides: Record<string, unknown> = {}): Record<string, unknown> {
47
48
  return {
48
49
  runs: new Map(),
49
50
  registry: {
51
+ get: vi.fn().mockResolvedValue(undefined),
50
52
  getPath: vi.fn().mockResolvedValue(undefined),
51
53
  loadAll: vi.fn().mockResolvedValue([]),
52
54
  },
@@ -84,6 +86,7 @@ describe("W4b: workflow tool 错误路径 throw 语义", () => {
84
86
  // (W4b verifier 探针实测复现)。registry 层真行为见 config-loader.ts:143-151。
85
87
  const deps = makeDeps({
86
88
  registry: {
89
+ get: vi.fn().mockResolvedValue(undefined),
87
90
  getPath: vi.fn().mockResolvedValue({
88
91
  name: "ghost-wf",
89
92
  path: "/tmp/no-such-workflow.js",
@@ -117,6 +120,7 @@ describe("W4b: workflow tool 错误路径 throw 语义", () => {
117
120
  it("平铺检测:args 子字段提到顶层 → throw 'Detected ... at top level'(含 Correct 正例)", async () => {
118
121
  const deps = makeDeps({
119
122
  registry: {
123
+ get: vi.fn().mockResolvedValue(undefined),
120
124
  getPath: vi.fn().mockResolvedValue(
121
125
  makeScript({
122
126
  type: "object",
@@ -143,6 +147,7 @@ describe("W4b: workflow tool 错误路径 throw 语义", () => {
143
147
  it("slug 护栏:slug 超 SLUG_MAX_LENGTH → throw 'slug exceeds ...'(运行时第二道)", async () => {
144
148
  const deps = makeDeps({
145
149
  registry: {
150
+ get: vi.fn().mockResolvedValue(undefined),
146
151
  getPath: vi.fn().mockResolvedValue(
147
152
  makeScript({
148
153
  type: "object",
@@ -167,6 +172,37 @@ describe("W4b: workflow tool 错误路径 throw 语义", () => {
167
172
  );
168
173
  });
169
174
 
175
+ it("OR-1 入口校验:time 超 setTimeout 上限 → throw 含上限 2147483647 与实际值,run 未启动", async () => {
176
+ const deps = makeDeps({
177
+ registry: {
178
+ get: vi.fn().mockResolvedValue(undefined),
179
+ getPath: vi.fn().mockResolvedValue(
180
+ makeScript({
181
+ type: "object",
182
+ properties: { task: { type: "string" } },
183
+ required: ["task"],
184
+ }),
185
+ ),
186
+ },
187
+ });
188
+ const tool = captureTool(deps, { isProcessing: false });
189
+ // LLM 对「跑久一点」完全可能生成 1e12——超 2^31-1 的典型形态(schema Type.Number
190
+ // 直通无上界,用户入口 fail-fast 不依赖 lifecycle 内层 assertSafeTimerDelay)
191
+ await expect(
192
+ tool.execute(
193
+ "id",
194
+ { action: "run", name: "/abs/demo-wf.js", args: { task: "do work" }, time: 1_000_000_000_000 },
195
+ undefined,
196
+ undefined,
197
+ {},
198
+ ),
199
+ ).rejects.toThrow(
200
+ "time budget 1000000000000 ms exceeds the maximum of 2147483647 ms (~24.8 days). Retry with a smaller \"time\", or omit it for unlimited.",
201
+ );
202
+ // fail-fast 于 runWorkflow 之前:runs 无条目(abortRun not found 语义保持)
203
+ expect((deps.runs as Map<string, unknown>).size).toBe(0);
204
+ });
205
+
170
206
  it("throw 后 reentry guard 经 finally 正常释放(成功路径回归)", async () => {
171
207
  // abort not_found throw 穿透 execute try/finally:guard 必须复位,否则后续命令全部 busy
172
208
  const guard: ReentryGuardRef = { isProcessing: false };
@@ -1,4 +1,4 @@
1
- // src/tui/bg-notify-render.ts
1
+ // src/interface/bg-notify-render.ts
2
2
  //
3
3
  // background 完成通知的对话流渲染器。
4
4
  // pi.registerMessageRenderer("subagent-bg-notify", ...) 注册。
@@ -27,7 +27,7 @@ import { deriveOutcome } from "@zhushanwen/subagent-core/execution/execution-rec
27
27
  import { CLOSED_REASONS } from "@zhushanwen/subagent-core/execution/types.ts";
28
28
  import type { ClosedReason, ExecutionOutcome } from "@zhushanwen/subagent-core/execution/types.ts";
29
29
  import {
30
- firstLine,
30
+ firstLineSanitized,
31
31
  padToVisible,
32
32
  shortId,
33
33
  statusGlyph,
@@ -66,8 +66,6 @@ interface BgNotifyRecord {
66
66
  model?: string;
67
67
  result?: string;
68
68
  error?: string;
69
- /** 对话轮次计数(仅 idle 有意义)。 */
70
- round?: number;
71
69
  /** [MF#1] worktree background 完成通知携带的 patch 文件路径。 */
72
70
  patchFile?: string;
73
71
  }
@@ -119,10 +117,8 @@ export function renderBgNotifyMessage(
119
117
  * 它是全局重置会清除背景色(背景框内省略号后失去背景的根因)。
120
118
  * 本组件在施加背景前,把行内全局 reset 替换为只重置前景/粗体/斜体/下划线
121
119
  * 的精确 reset(不含背景 `\x1b[49m`),确保背景不断裂。
122
- *
123
- * 导出以便其他 message renderer 复用边框样式。
124
120
  */
125
- export class BorderedBgBox implements Component {
121
+ class BorderedBgBox implements Component {
126
122
  private lines: string[];
127
123
  private t: ThemeLike;
128
124
  private cache: { width: number; lines: string[] } | undefined;
@@ -319,13 +315,7 @@ function extractBgNotifyRecord(details: unknown): BgNotifyRecord | undefined {
319
315
  error: typeof d.error === "string" ? d.error : undefined,
320
316
  closedReason: toClosedReason(d.closedReason),
321
317
  outcome: toOutcome(d.outcome),
322
- round: typeof d.round === "number" ? d.round : undefined,
323
318
  // [MF#1] 提取 patchFile(worktree background 完成通知携带)。
324
319
  patchFile: typeof d.patchFile === "string" ? d.patchFile : undefined,
325
320
  };
326
321
  }
327
-
328
- // firstLine 取首非空行(共享自 ./format.ts);本文件额外压 \r\t 防多行展开。
329
- function firstLineSanitized(text: string): string {
330
- return firstLine(text).replace(/[\r\t]+/g, " ");
331
- }
@@ -22,21 +22,10 @@ export type SubagentRpcAction =
22
22
  /** /workflows RPC action 判别联合。 */
23
23
  export type WorkflowRpcAction =
24
24
  | { action: "abort"; runId: string }
25
- | { action: "lifecycle-missing-id"; verb: LifecycleVerb }
25
+ | { action: "lifecycle-missing-id"; verb: "abort" }
26
26
  | { action: "lifecycle-removed"; verb: "pause" | "resume" }
27
27
  | { action: "noop" };
28
28
 
29
- /** workflow lifecycle verb 类型。 */
30
- type LifecycleVerb = "abort";
31
-
32
- /** workflow lifecycle verb 集合。 */
33
- const LIFECYCLE_VERBS: ReadonlySet<LifecycleVerb> = new Set(["abort"]);
34
-
35
- /** verb 是否为 lifecycle action(类型守卫,收窄到 LifecycleVerb)。 */
36
- function isLifecycleVerb(verb: string): verb is LifecycleVerb {
37
- return LIFECYCLE_VERBS.has(verb as LifecycleVerb);
38
- }
39
-
40
29
  /**
41
30
  * 已移除的 lifecycle verb 集合(pause/resume——run 一次性生命周期化后删除,
42
31
  * 解析为 lifecycle-removed 提示,而非 unknown noop)。
@@ -152,9 +141,10 @@ export function parseWorkflowRpcCommand(argsStr: string): WorkflowRpcAction {
152
141
  if (isRemovedLifecycleVerb(verb)) {
153
142
  return { action: "lifecycle-removed", verb };
154
143
  }
155
- if (isLifecycleVerb(verb)) {
156
- if (!runId) return { action: "lifecycle-missing-id", verb };
157
- return { action: verb, runId };
144
+ // abort 是 lifecycle verb 单成员(pause/resume 已移除,见上),直判即可,无需集合机件
145
+ if (verb === "abort") {
146
+ if (!runId) return { action: "lifecycle-missing-id", verb: "abort" };
147
+ return { action: "abort", runId };
158
148
  }
159
149
  return { action: "noop" };
160
150
  }
@@ -207,7 +207,7 @@ function sortedRuns(runs: Map<string, WorkflowRun>): WorkflowRun[] {
207
207
  * 打开 WorkflowsView(三级导航 TUI),注入 lifecycle ViewActions。
208
208
  *
209
209
  * ViewActions 通过 deps 调 lifecycle(abort),与 view 解耦——
210
- * view 单测可注入 mock actions(见 workflows-view.test.ts)。
210
+ * view 单测可注入 mock actions(见 views/__tests__/WorkflowsView-signature.test.ts)。
211
211
  */
212
212
  async function openView(
213
213
  run: WorkflowRun,
@@ -1,4 +1,4 @@
1
- // src/tui/format.ts
1
+ // src/interface/format.ts
2
2
  //
3
3
  // 纯格式化函数.零 Pi 依赖、零 runtime 依赖,可单测.
4
4
  //
@@ -217,9 +217,8 @@ export function sanitizeLabel(label: string): string {
217
217
  /**
218
218
  * 取文本首个非空行(多行压成首行).
219
219
  *
220
- * 仅做"取首行"——不 sanitize.三处调用方的 sanitize 末步不同
221
- * (tool-render sanitizeLabel、bg-notify-render 压 \r\t、list-view 不处理),
222
- * 故共享此基础函数,各自按需 wrap.
220
+ * 仅做"取首行"——不 sanitize.需要压平残余 \r/\t 的调用方用 firstLineSanitized
221
+ * (下方);list-component 直接消费本函数(标题/错误行由外层 truncLine 再压平).
223
222
  *
224
223
  * firstLine("a\nb\nc") → "a"
225
224
  * firstLine("\n\nb") → "b"
@@ -230,6 +229,18 @@ export function firstLine(text?: string): string {
230
229
  return text.split("\n").find((l) => l.trim())?.trim() ?? "";
231
230
  }
232
231
 
232
+ /**
233
+ * firstLine + sanitize 组合:取首个非空行后压平残余 \r/\t.
234
+ *
235
+ * tool-render(content 错误回显)与 bg-notify-render(结果/错误正文)共用,
236
+ * 取代两侧各自维护的 wrapper.tool-render 旧版走 sanitizeLabel(\t→两空格)、
237
+ * bg-notify-render 旧版压 \r\t 为单空格——差异无测试钉住,统一取
238
+ * sanitizeLabel 口径(与 eventLog 行处理一致).
239
+ */
240
+ export function firstLineSanitized(text?: string): string {
241
+ return sanitizeLabel(firstLine(text));
242
+ }
243
+
233
244
  /**
234
245
  * 从 renderCall/execute 的 unknown args 安全提取 agent 名.
235
246
  * 类型守卫窄化(替代 `as { agent?: string }` 全可选断言).
@@ -28,6 +28,22 @@ export function toGuiCtx(ctx: { mode: GuiContext["mode"]; hasUI: boolean } | und
28
28
  /** TreeItem.status 枚举(协议三态)。 */
29
29
  type TreeStatus = NonNullable<TreeItem["status"]>;
30
30
 
31
+ /**
32
+ * 状态字符串是否为失败态(mapRunStatus/mapRunIcon 共享谓词——两映射仅返回值形态不同,
33
+ * 关键词表必须同步演化,抽单点防双写漂移)。入参须已 toLowerCase。
34
+ */
35
+ function isFailedStatus(s: string): boolean {
36
+ return (
37
+ s.includes("failed") ||
38
+ s.includes("abort") ||
39
+ s.includes("cancel") ||
40
+ s.includes("crash") ||
41
+ s.includes("error") ||
42
+ s.includes("budget") ||
43
+ s.includes("time_limited")
44
+ );
45
+ }
46
+
31
47
  /**
32
48
  * 把 workflow/subagent 状态字符串映射到 list-tree 的三态 status。
33
49
  *
@@ -42,17 +58,7 @@ type TreeStatus = NonNullable<TreeItem["status"]>;
42
58
  export function mapRunStatus(status: string): TreeStatus {
43
59
  const s = status.toLowerCase();
44
60
  if (s.includes("running")) return "running";
45
- if (
46
- s.includes("failed") ||
47
- s.includes("abort") ||
48
- s.includes("cancel") ||
49
- s.includes("crash") ||
50
- s.includes("error") ||
51
- s.includes("budget") ||
52
- s.includes("time_limited")
53
- ) {
54
- return "failed";
55
- }
61
+ if (isFailedStatus(s)) return "failed";
56
62
  return "done";
57
63
  }
58
64
 
@@ -66,16 +72,6 @@ export function mapRunStatus(status: string): TreeStatus {
66
72
  export function mapRunIcon(status: string): TreeItemIcon {
67
73
  const s = status.toLowerCase();
68
74
  if (s.includes("running")) return "circle";
69
- if (
70
- s.includes("failed") ||
71
- s.includes("abort") ||
72
- s.includes("cancel") ||
73
- s.includes("crash") ||
74
- s.includes("error") ||
75
- s.includes("budget") ||
76
- s.includes("time_limited")
77
- ) {
78
- return "cross";
79
- }
75
+ if (isFailedStatus(s)) return "cross";
80
76
  return "check";
81
77
  }