@zhushanwen/pi-subagent-workflow 0.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/agents/context-builder.md +17 -0
- package/agents/general-purpose.md +16 -0
- package/agents/oracle.md +17 -0
- package/agents/planner.md +17 -0
- package/agents/researcher.md +17 -0
- package/agents/reviewer.md +17 -0
- package/agents/scout.md +17 -0
- package/agents/worker.md +16 -0
- package/examples/README.md +43 -0
- package/examples/chain.example.js +92 -0
- package/examples/map-reduce.example.js +99 -0
- package/examples/parallel.example.js +82 -0
- package/examples/scatter-gather.example.js +106 -0
- package/index.ts +1 -0
- package/package.json +66 -0
- package/skills/workflow-script-format/SKILL.md +328 -0
- package/src/execution/__tests__/agent-registry.test.ts +164 -0
- package/src/execution/__tests__/agent-result-mapper.test.ts +128 -0
- package/src/execution/__tests__/alive-store.test.ts +147 -0
- package/src/execution/__tests__/bg-notify-render.test.ts +256 -0
- package/src/execution/__tests__/concurrency-pool.test.ts +217 -0
- package/src/execution/__tests__/config.test.ts +110 -0
- package/src/execution/__tests__/crash-recovery.test.ts +311 -0
- package/src/execution/__tests__/execute-nesting.test.ts +359 -0
- package/src/execution/__tests__/execute-options-mapper.test.ts +138 -0
- package/src/execution/__tests__/execution-record.test.ts +959 -0
- package/src/execution/__tests__/finalized-marker.test.ts +82 -0
- package/src/execution/__tests__/format-schema-instruction.test.ts +135 -0
- package/src/execution/__tests__/format.test.ts +320 -0
- package/src/execution/__tests__/helpers/mock-extension-api.ts +30 -0
- package/src/execution/__tests__/list-component.test.ts +347 -0
- package/src/execution/__tests__/model-resolver.test.ts +356 -0
- package/src/execution/__tests__/output-collector.test.ts +61 -0
- package/src/execution/__tests__/path-encoding.test.ts +75 -0
- package/src/execution/__tests__/pi-invocation.test.ts +73 -0
- package/src/execution/__tests__/record-store.test.ts +545 -0
- package/src/execution/__tests__/run-spawn-edges.test.ts +439 -0
- package/src/execution/__tests__/run-spawn-integration.test.ts +897 -0
- package/src/execution/__tests__/sdk-contract.test.ts +272 -0
- package/src/execution/__tests__/session-context-resolver.test.ts +167 -0
- package/src/execution/__tests__/session-file-gc.test.ts +247 -0
- package/src/execution/__tests__/session-reconstructor.test.ts +359 -0
- package/src/execution/__tests__/session-runner-schema-env.test.ts +314 -0
- package/src/execution/__tests__/session-start-reaper.test.ts +227 -0
- package/src/execution/__tests__/spawn-args.test.ts +244 -0
- package/src/execution/__tests__/spawn-event-adapter.test.ts +167 -0
- package/src/execution/__tests__/subagent-service.test.ts +678 -0
- package/src/execution/__tests__/subprocess-agent-runner.test.ts +389 -0
- package/src/execution/__tests__/temp-prompt.test.ts +53 -0
- package/src/execution/__tests__/timeout-integration.test.ts +381 -0
- package/src/execution/__tests__/tombstone-store.test.ts +73 -0
- package/src/execution/__tests__/tool-action.test.ts +330 -0
- package/src/execution/__tests__/turn-limiter.test.ts +65 -0
- package/src/execution/__tests__/worktree-manager.test.ts +423 -0
- package/src/execution/__tests__/worktree-registry.test.ts +161 -0
- package/src/execution/agent-registry.ts +252 -0
- package/src/execution/agent-result-mapper.ts +84 -0
- package/src/execution/alive-store.ts +92 -0
- package/src/execution/best-effort.ts +30 -0
- package/src/execution/concurrency-pool.ts +84 -0
- package/src/execution/config.ts +73 -0
- package/src/execution/execute-options-mapper.ts +86 -0
- package/src/execution/execution-record.ts +778 -0
- package/src/execution/finalized-marker.ts +51 -0
- package/src/execution/model-config-service.ts +225 -0
- package/src/execution/model-resolver.ts +247 -0
- package/src/execution/notifier.ts +168 -0
- package/src/execution/output-collector.ts +88 -0
- package/src/execution/path-encoding.ts +34 -0
- package/src/execution/pi-invocation.ts +70 -0
- package/src/execution/record-store.ts +350 -0
- package/src/execution/session-context-resolver.ts +64 -0
- package/src/execution/session-file-gc.ts +98 -0
- package/src/execution/session-reconstructor.ts +450 -0
- package/src/execution/session-runner.ts +725 -0
- package/src/execution/spawn-event-adapter.ts +150 -0
- package/src/execution/subagent-service.ts +973 -0
- package/src/execution/subprocess-agent-runner.ts +108 -0
- package/src/execution/temp-prompt.ts +57 -0
- package/src/execution/tombstone-store.ts +72 -0
- package/src/execution/turn-limiter.ts +88 -0
- package/src/execution/types.ts +634 -0
- package/src/execution/worktree-manager.ts +285 -0
- package/src/execution/worktree-registry.ts +144 -0
- package/src/index.ts +454 -0
- package/src/interface/bg-notify-render.ts +286 -0
- package/src/interface/commands.ts +157 -0
- package/src/interface/format.ts +501 -0
- package/src/interface/gui-adapter.ts +136 -0
- package/src/interface/helpers.ts +110 -0
- package/src/interface/list-component.ts +643 -0
- package/src/interface/list-shared.ts +84 -0
- package/src/interface/list-view.ts +373 -0
- package/src/interface/reentry-guard.ts +30 -0
- package/src/interface/subagent-actions.ts +294 -0
- package/src/interface/subagent-tool.ts +294 -0
- package/src/interface/subagents.ts +30 -0
- package/src/interface/tool-render.ts +333 -0
- package/src/interface/tool-workflow-script.ts +351 -0
- package/src/interface/tool-workflow.ts +485 -0
- package/src/interface/views/WorkflowsView.ts +944 -0
- package/src/interface/views/detail-content.ts +298 -0
- package/src/interface/views/format.ts +320 -0
- package/src/orchestration/__tests__/concurrency-gate.test.ts +125 -0
- package/src/orchestration/__tests__/config-loader.test.ts +381 -0
- package/src/orchestration/__tests__/error-recovery-handlers.test.ts +332 -0
- package/src/orchestration/__tests__/error-recovery-workflow-call.test.ts +166 -0
- package/src/orchestration/__tests__/launcher-nested-workflow.test.ts +248 -0
- package/src/orchestration/__tests__/lifecycle.test.ts +385 -0
- package/src/orchestration/__tests__/script-lint.test.ts +347 -0
- package/src/orchestration/__tests__/worker-script-builder.test.ts +42 -0
- package/src/orchestration/__tests__/workflow-nesting-e2e.test.ts +319 -0
- package/src/orchestration/agent-opts-resolver.ts +128 -0
- package/src/orchestration/concurrency-gate.ts +69 -0
- package/src/orchestration/config-loader.ts +313 -0
- package/src/orchestration/error-recovery.ts +578 -0
- package/src/orchestration/execute-agent-call.ts +174 -0
- package/src/orchestration/jsonl-run-store.ts +292 -0
- package/src/orchestration/launcher.ts +368 -0
- package/src/orchestration/lifecycle.ts +373 -0
- package/src/orchestration/models/__tests__/budget.test.ts +367 -0
- package/src/orchestration/models/agent-call.ts +76 -0
- package/src/orchestration/models/budget.ts +148 -0
- package/src/orchestration/models/ports.ts +165 -0
- package/src/orchestration/models/run-runtime.ts +91 -0
- package/src/orchestration/models/run-spec.ts +54 -0
- package/src/orchestration/models/run-state.ts +44 -0
- package/src/orchestration/models/trace.ts +102 -0
- package/src/orchestration/models/types.ts +242 -0
- package/src/orchestration/models/workflow-run.ts +275 -0
- package/src/orchestration/models/workflow-script-registry.ts +32 -0
- package/src/orchestration/models/workflow-script.ts +90 -0
- package/src/orchestration/node-ops.ts +192 -0
- package/src/orchestration/script-lint.ts +387 -0
- package/src/orchestration/skill-discovery.ts +60 -0
- package/src/orchestration/worker-handle.ts +115 -0
- package/src/orchestration/worker-host.ts +93 -0
- package/src/orchestration/worker-script-builder.ts +281 -0
- package/src/orchestration/workflow-files.ts +85 -0
- package/src/orchestration/workflow-script-registry-impl.ts +128 -0
- package/src/shared/__tests__/resource-discovery.test.ts +226 -0
- package/src/shared/agent-event.ts +13 -0
- package/src/shared/resource-discovery.ts +535 -0
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
// src/__tests__/alive-store.test.ts
|
|
2
|
+
|
|
3
|
+
import * as fs from "node:fs";
|
|
4
|
+
import * as os from "node:os";
|
|
5
|
+
import * as path from "node:path";
|
|
6
|
+
|
|
7
|
+
import { afterEach,describe, expect, it } from "vitest";
|
|
8
|
+
|
|
9
|
+
import {
|
|
10
|
+
isProcessAlive,
|
|
11
|
+
readAliveMarker,
|
|
12
|
+
removeAliveMarker,
|
|
13
|
+
writeAliveMarker,
|
|
14
|
+
} from "../alive-store.ts";
|
|
15
|
+
import type { AliveMarker } from "../types.ts";
|
|
16
|
+
|
|
17
|
+
function makeTmpDir(): string {
|
|
18
|
+
return fs.mkdtempSync(path.join(os.tmpdir(), "alive-store-test-"));
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
describe("alive-store", () => {
|
|
22
|
+
let tmpDir: string;
|
|
23
|
+
|
|
24
|
+
afterEach(() => {
|
|
25
|
+
if (tmpDir) {
|
|
26
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
27
|
+
}
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
// ── writeAliveMarker + readAliveMarker 往返 ──
|
|
31
|
+
|
|
32
|
+
it("write → read round-trip", () => {
|
|
33
|
+
tmpDir = makeTmpDir();
|
|
34
|
+
const sessionFile = path.join(tmpDir, "session.jsonl");
|
|
35
|
+
const marker: AliveMarker = { pid: 12345, id: "bg-1-abc", startedAt: Date.now() };
|
|
36
|
+
|
|
37
|
+
writeAliveMarker(sessionFile, marker);
|
|
38
|
+
const result = readAliveMarker(sessionFile);
|
|
39
|
+
|
|
40
|
+
expect(result).toEqual(marker);
|
|
41
|
+
// 验证 sidecar 文件是单行 JSON
|
|
42
|
+
const raw = fs.readFileSync(`${sessionFile}.alive`, "utf-8");
|
|
43
|
+
expect(raw.endsWith("\n")).toBe(true);
|
|
44
|
+
expect(JSON.parse(raw.trim())).toEqual(marker);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
// ── readAliveMarker 损坏文件 ──
|
|
48
|
+
|
|
49
|
+
it("readAliveMarker returns undefined for corrupted file", () => {
|
|
50
|
+
tmpDir = makeTmpDir();
|
|
51
|
+
const sessionFile = path.join(tmpDir, "session.jsonl");
|
|
52
|
+
fs.writeFileSync(`${sessionFile}.alive`, "not-json", "utf-8");
|
|
53
|
+
|
|
54
|
+
expect(readAliveMarker(sessionFile)).toBeUndefined();
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("readAliveMarker returns undefined for structurally invalid JSON", () => {
|
|
58
|
+
tmpDir = makeTmpDir();
|
|
59
|
+
const sessionFile = path.join(tmpDir, "session.jsonl");
|
|
60
|
+
// pid 字段缺失
|
|
61
|
+
fs.writeFileSync(`${sessionFile}.alive`, '{"id":"x","startedAt":1}\n', "utf-8");
|
|
62
|
+
|
|
63
|
+
expect(readAliveMarker(sessionFile)).toBeUndefined();
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("readAliveMarker returns undefined when file does not exist", () => {
|
|
67
|
+
tmpDir = makeTmpDir();
|
|
68
|
+
const sessionFile = path.join(tmpDir, "session.jsonl");
|
|
69
|
+
|
|
70
|
+
expect(readAliveMarker(sessionFile)).toBeUndefined();
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
// ── removeAliveMarker ──
|
|
74
|
+
|
|
75
|
+
it("removeAliveMarker removes existing sidecar", () => {
|
|
76
|
+
tmpDir = makeTmpDir();
|
|
77
|
+
const sessionFile = path.join(tmpDir, "session.jsonl");
|
|
78
|
+
const marker: AliveMarker = { pid: 1, id: "x", startedAt: 0 };
|
|
79
|
+
|
|
80
|
+
writeAliveMarker(sessionFile, marker);
|
|
81
|
+
expect(fs.existsSync(`${sessionFile}.alive`)).toBe(true);
|
|
82
|
+
|
|
83
|
+
removeAliveMarker(sessionFile);
|
|
84
|
+
expect(fs.existsSync(`${sessionFile}.alive`)).toBe(false);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("removeAliveMarker does not throw when file does not exist", () => {
|
|
88
|
+
tmpDir = makeTmpDir();
|
|
89
|
+
const sessionFile = path.join(tmpDir, "session.jsonl");
|
|
90
|
+
|
|
91
|
+
expect(() => removeAliveMarker(sessionFile)).not.toThrow();
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
// ── isProcessAlive ──
|
|
95
|
+
|
|
96
|
+
it("isProcessAlive returns true for current process", () => {
|
|
97
|
+
expect(isProcessAlive(process.pid)).toBe(true);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it("isProcessAlive returns false for non-existent pid", () => {
|
|
101
|
+
// pid 0 不存在于用户空间(kill(0,0) 在某些 OS 有特殊语义,用大数更安全)
|
|
102
|
+
expect(isProcessAlive(9999999)).toBe(false);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it("isProcessAlive returns true for EPERM (process exists but no permission)", () => {
|
|
106
|
+
// 模拟 EPERM:进程存在但无权限发信号
|
|
107
|
+
const err = new Error("EPERM") as NodeJS.ErrnoException;
|
|
108
|
+
err.code = "EPERM";
|
|
109
|
+
const originalKill = process.kill;
|
|
110
|
+
process.kill = (_pid: number, _signal?: string | number) => {
|
|
111
|
+
throw err;
|
|
112
|
+
};
|
|
113
|
+
try {
|
|
114
|
+
expect(isProcessAlive(12345)).toBe(true);
|
|
115
|
+
} finally {
|
|
116
|
+
process.kill = originalKill;
|
|
117
|
+
}
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it("isProcessAlive returns false for ESRCH (no such process)", () => {
|
|
121
|
+
// 模拟 ESRCH:进程不存在
|
|
122
|
+
const err = new Error("ESRCH") as NodeJS.ErrnoException;
|
|
123
|
+
err.code = "ESRCH";
|
|
124
|
+
const originalKill = process.kill;
|
|
125
|
+
process.kill = (_pid: number, _signal?: string | number) => {
|
|
126
|
+
throw err;
|
|
127
|
+
};
|
|
128
|
+
try {
|
|
129
|
+
expect(isProcessAlive(12345)).toBe(false);
|
|
130
|
+
} finally {
|
|
131
|
+
process.kill = originalKill;
|
|
132
|
+
}
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it("isProcessAlive returns false for unknown errors", () => {
|
|
136
|
+
// 模拟未知错误:保守判死
|
|
137
|
+
const originalKill = process.kill;
|
|
138
|
+
process.kill = (_pid: number, _signal?: string | number) => {
|
|
139
|
+
throw new Error("unknown error");
|
|
140
|
+
};
|
|
141
|
+
try {
|
|
142
|
+
expect(isProcessAlive(12345)).toBe(false);
|
|
143
|
+
} finally {
|
|
144
|
+
process.kill = originalKill;
|
|
145
|
+
}
|
|
146
|
+
});
|
|
147
|
+
});
|
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
// src/__tests__/bg-notify-render.test.ts
|
|
2
|
+
//
|
|
3
|
+
// background 完成通知的渲染测试。
|
|
4
|
+
//
|
|
5
|
+
// 核心契约:renderBgNotifyMessage 必须返回带紫色背景 + 圆角边框的组件——
|
|
6
|
+
// Pi 的 CustomMessageComponent 对 customRenderer 返回值是「裸 addChild」,
|
|
7
|
+
// 返回裸 Text 会丢失紫色背景。本测试钉死「施加 customMessageBg + 边框」这一行为。
|
|
8
|
+
//
|
|
9
|
+
// 测试用 mock theme(bg 记录调用色 token),不依赖真实 Pi Theme。
|
|
10
|
+
|
|
11
|
+
import type { Theme } from "@mariozechner/pi-coding-agent";
|
|
12
|
+
import { describe, expect, it } from "vitest";
|
|
13
|
+
|
|
14
|
+
import { renderBgNotifyMessage } from "../../interface/bg-notify-render.ts";
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* 构造 mock theme:bg 记录被调用的色 token,fg/bold 透传文本。
|
|
18
|
+
*
|
|
19
|
+
* 真实 theme.fg/bg 会包裹 ANSI 码;这里透传纯文本,让断言可读。
|
|
20
|
+
* 边框/背景 token 通过 bgColors 记录。
|
|
21
|
+
*/
|
|
22
|
+
function makeTheme(): { theme: Theme; bgColors: string[]; fgColors: string[] } {
|
|
23
|
+
const bgColors: string[] = [];
|
|
24
|
+
const fgColors: string[] = [];
|
|
25
|
+
const theme = {
|
|
26
|
+
fg: (tag: string, text: string) => {
|
|
27
|
+
fgColors.push(tag);
|
|
28
|
+
return text;
|
|
29
|
+
},
|
|
30
|
+
bold: (text: string) => text,
|
|
31
|
+
bg: (color: string, text: string) => {
|
|
32
|
+
bgColors.push(color);
|
|
33
|
+
return text;
|
|
34
|
+
},
|
|
35
|
+
};
|
|
36
|
+
return { theme: theme as Theme, bgColors, fgColors };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
describe("renderBgNotifyMessage", () => {
|
|
40
|
+
it("单条 done → 施加 customMessageBg 背景 + 边框 + 内容可见", () => {
|
|
41
|
+
const { theme, bgColors } = makeTheme();
|
|
42
|
+
const comp = renderBgNotifyMessage(
|
|
43
|
+
{ details: { status: "done", agent: "worker", id: "bg-1", result: "All green" } },
|
|
44
|
+
{ expanded: false },
|
|
45
|
+
theme,
|
|
46
|
+
);
|
|
47
|
+
|
|
48
|
+
expect(comp).toBeDefined();
|
|
49
|
+
const lines = comp!.render(80);
|
|
50
|
+
const joined = lines.join("\n");
|
|
51
|
+
// 紫色背景施加(内容行 ≥ 1 次)
|
|
52
|
+
expect(bgColors.filter((c) => c === "customMessageBg").length).toBeGreaterThanOrEqual(1);
|
|
53
|
+
// 圆角边框
|
|
54
|
+
expect(lines[0]).toContain("╭");
|
|
55
|
+
expect(lines[0]).toContain("╮");
|
|
56
|
+
expect(lines[lines.length - 1]).toContain("╰");
|
|
57
|
+
expect(lines[lines.length - 1]).toContain("╯");
|
|
58
|
+
for (let i = 1; i < lines.length - 1; i++) {
|
|
59
|
+
expect(lines[i]).toContain("│");
|
|
60
|
+
}
|
|
61
|
+
// 前景内容仍在
|
|
62
|
+
expect(joined).toContain("worker");
|
|
63
|
+
expect(joined).toContain("All green");
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("id 用 shortId 截断(bg-tag-seq-ts → bg-tag-seq)", () => {
|
|
67
|
+
const { theme } = makeTheme();
|
|
68
|
+
const comp = renderBgNotifyMessage(
|
|
69
|
+
{ details: { status: "done", agent: "w", id: "bg-f6f731-10-1719500000000", result: "ok" } },
|
|
70
|
+
{ expanded: false },
|
|
71
|
+
theme,
|
|
72
|
+
);
|
|
73
|
+
const joined = comp!.render(80).join("\n");
|
|
74
|
+
// shortId 对 background id 取前 3 段(bg/tag/seq)
|
|
75
|
+
expect(joined).toContain("bg-f6f731-10");
|
|
76
|
+
// 完整时间戳不应出现
|
|
77
|
+
expect(joined).not.toContain("1719500000000");
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("单条 failed → 内容含 Error + agent", () => {
|
|
81
|
+
const { theme } = makeTheme();
|
|
82
|
+
const comp = renderBgNotifyMessage(
|
|
83
|
+
{ details: { status: "failed", agent: "scout", id: "bg-2", error: "boom" } },
|
|
84
|
+
{ expanded: false },
|
|
85
|
+
theme,
|
|
86
|
+
);
|
|
87
|
+
expect(comp).toBeDefined();
|
|
88
|
+
const joined = comp!.render(80).join("\n");
|
|
89
|
+
expect(joined).toContain("scout");
|
|
90
|
+
expect(joined).toContain("Error");
|
|
91
|
+
expect(joined).toContain("boom");
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it("批量 → 施加 customMessageBg + 边框,每条 agent 可见", () => {
|
|
95
|
+
const { theme, bgColors } = makeTheme();
|
|
96
|
+
const comp = renderBgNotifyMessage(
|
|
97
|
+
{
|
|
98
|
+
details: {
|
|
99
|
+
batch: true,
|
|
100
|
+
items: [
|
|
101
|
+
{ status: "done", agent: "alpha", id: "1", result: "r1" },
|
|
102
|
+
{ status: "failed", agent: "beta", id: "2", error: "e2" },
|
|
103
|
+
],
|
|
104
|
+
},
|
|
105
|
+
},
|
|
106
|
+
{ expanded: false },
|
|
107
|
+
theme,
|
|
108
|
+
);
|
|
109
|
+
expect(comp).toBeDefined();
|
|
110
|
+
const lines = comp!.render(80);
|
|
111
|
+
const joined = lines.join("\n");
|
|
112
|
+
expect(bgColors).toContain("customMessageBg");
|
|
113
|
+
expect(joined).toContain("alpha");
|
|
114
|
+
expect(joined).toContain("beta");
|
|
115
|
+
// 边框
|
|
116
|
+
expect(lines[0]).toContain("╭");
|
|
117
|
+
expect(lines[lines.length - 1]).toContain("╰");
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it("cancelled → 内容含 cancelled", () => {
|
|
121
|
+
const { theme } = makeTheme();
|
|
122
|
+
const comp = renderBgNotifyMessage(
|
|
123
|
+
{ details: { status: "cancelled", agent: "w", id: "bg-3" } },
|
|
124
|
+
{ expanded: false },
|
|
125
|
+
theme,
|
|
126
|
+
);
|
|
127
|
+
expect(comp).toBeDefined();
|
|
128
|
+
expect(comp!.render(80).join("\n")).toContain("cancelled");
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it("details 缺失/结构不全 → undefined(走 Pi 默认渲染兜底)", () => {
|
|
132
|
+
const { theme } = makeTheme();
|
|
133
|
+
expect(renderBgNotifyMessage({ details: undefined }, { expanded: false }, theme)).toBeUndefined();
|
|
134
|
+
expect(renderBgNotifyMessage({ details: { foo: 1 } }, { expanded: false }, theme)).toBeUndefined();
|
|
135
|
+
// 缺 status / agent
|
|
136
|
+
expect(
|
|
137
|
+
renderBgNotifyMessage({ details: { id: "x", agent: "w" } }, { expanded: false }, theme),
|
|
138
|
+
).toBeUndefined();
|
|
139
|
+
expect(
|
|
140
|
+
renderBgNotifyMessage({ details: { status: "done", id: "x" } }, { expanded: false }, theme),
|
|
141
|
+
).toBeUndefined();
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it("details 无效时不施加任何背景(兜底路径不应泄漏背景色)", () => {
|
|
145
|
+
const { theme, bgColors } = makeTheme();
|
|
146
|
+
renderBgNotifyMessage({ details: undefined }, { expanded: false }, theme);
|
|
147
|
+
expect(bgColors).not.toContain("customMessageBg");
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
// ── model 显示 ──
|
|
151
|
+
|
|
152
|
+
it("record 带 model → 内容含 model 字符串(agent 后、状态描述前)", () => {
|
|
153
|
+
const { theme } = makeTheme();
|
|
154
|
+
const comp = renderBgNotifyMessage(
|
|
155
|
+
{ details: { status: "done", agent: "general-purpose", model: "anthropic/sonnet-4-5", id: "bg-1", result: "ok" } },
|
|
156
|
+
{ expanded: false },
|
|
157
|
+
theme,
|
|
158
|
+
);
|
|
159
|
+
const joined = comp!.render(80).join("\n");
|
|
160
|
+
expect(joined).toContain("anthropic/sonnet-4-5");
|
|
161
|
+
// agent 和状态仍在
|
|
162
|
+
expect(joined).toContain("general-purpose");
|
|
163
|
+
expect(joined).toContain("finished");
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it("record 无 model → 向后兼容,不渲染 model 段不崩", () => {
|
|
167
|
+
const { theme } = makeTheme();
|
|
168
|
+
const comp = renderBgNotifyMessage(
|
|
169
|
+
{ details: { status: "done", agent: "worker", id: "bg-2", result: "r" } },
|
|
170
|
+
{ expanded: false },
|
|
171
|
+
theme,
|
|
172
|
+
);
|
|
173
|
+
const joined = comp!.render(80).join("\n");
|
|
174
|
+
expect(joined).toContain("worker");
|
|
175
|
+
expect(joined).toContain("finished");
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
// ── ANSI 背景 safety ──
|
|
179
|
+
|
|
180
|
+
it("着色行截断的 \\x1b[0m 被替换为精确 reset(不破坏背景)", () => {
|
|
181
|
+
// mock theme 产生真实 ANSI(fg 用 italic \x1b[3m..\x1b[23m,bold 用 \x1b[1m..\x1b[22m)
|
|
182
|
+
const theme = {
|
|
183
|
+
fg: (_tag: string, text: string) => `\x1b[3m${text}\x1b[23m`,
|
|
184
|
+
bold: (text: string) => `\x1b[1m${text}\x1b[22m`,
|
|
185
|
+
bg: (_color: string, text: string) => `\x1b[48;5;95m${text}\x1b[49m`,
|
|
186
|
+
} as Theme;
|
|
187
|
+
// 关键:用窄 width + 长 agent 名,让 head 行在 truncLine 内被真正截断
|
|
188
|
+
// head 行含 ANSI(fg 包裹),截断时 activeStyles 非空 → 会产生 \x1b[0m
|
|
189
|
+
const longAgent = "a".repeat(60);
|
|
190
|
+
const comp = renderBgNotifyMessage(
|
|
191
|
+
{ details: { status: "done", agent: longAgent, id: "bg-1", result: "ok" } },
|
|
192
|
+
{ expanded: false },
|
|
193
|
+
theme,
|
|
194
|
+
);
|
|
195
|
+
const lines = comp!.render(30);
|
|
196
|
+
// 内容行(非边框)不应含 \x1b[0m(会破坏背景)
|
|
197
|
+
const contentLines = lines.slice(1, -1);
|
|
198
|
+
expect(contentLines.length).toBeGreaterThan(0);
|
|
199
|
+
for (const line of contentLines) {
|
|
200
|
+
expect(line).not.toContain("\x1b[0m");
|
|
201
|
+
}
|
|
202
|
+
// 正向验证:sanitizeAnsiForBg 把 \x1b[0m 替换成了精确 reset(\x1b[39m)
|
|
203
|
+
// 如果截断真的发生了,内容行应含 \x1b[39m(sanitize 的替换产物)
|
|
204
|
+
const hasSanitized = contentLines.some((l) => l.includes("\x1b[39m"));
|
|
205
|
+
expect(hasSanitized).toBe(true);
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
// ── 窄宽度退化 ──
|
|
209
|
+
|
|
210
|
+
it("极窄宽度(width < 5)退化:无边框,不崩溃,有背景", () => {
|
|
211
|
+
const { theme, bgColors } = makeTheme();
|
|
212
|
+
const comp = renderBgNotifyMessage(
|
|
213
|
+
{ details: { status: "done", agent: "w", id: "bg-1", result: "ok" } },
|
|
214
|
+
{ expanded: false },
|
|
215
|
+
theme,
|
|
216
|
+
);
|
|
217
|
+
// width=3 < MIN_BORDER_WIDTH(5),应退化为无边框模式
|
|
218
|
+
const lines = comp!.render(3);
|
|
219
|
+
expect(lines.length).toBeGreaterThan(0);
|
|
220
|
+
// 不应有边框字符(退化模式)
|
|
221
|
+
for (const line of lines) {
|
|
222
|
+
expect(line).not.toContain("╭");
|
|
223
|
+
expect(line).not.toContain("│");
|
|
224
|
+
}
|
|
225
|
+
// 仍施加背景
|
|
226
|
+
expect(bgColors).toContain("customMessageBg");
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
it("批量场景边框完整:所有中间行含 │", () => {
|
|
230
|
+
const { theme } = makeTheme();
|
|
231
|
+
const comp = renderBgNotifyMessage(
|
|
232
|
+
{
|
|
233
|
+
details: {
|
|
234
|
+
batch: true,
|
|
235
|
+
items: [
|
|
236
|
+
{ status: "done", agent: "alpha", id: "1", result: "r1" },
|
|
237
|
+
{ status: "failed", agent: "beta", id: "2", error: "e2" },
|
|
238
|
+
{ status: "cancelled", agent: "gamma", id: "3" },
|
|
239
|
+
],
|
|
240
|
+
},
|
|
241
|
+
},
|
|
242
|
+
{ expanded: false },
|
|
243
|
+
theme,
|
|
244
|
+
);
|
|
245
|
+
const lines = comp!.render(80);
|
|
246
|
+
// 顶底边框
|
|
247
|
+
expect(lines[0]).toContain("╭");
|
|
248
|
+
expect(lines[0]).toContain("╮");
|
|
249
|
+
expect(lines[lines.length - 1]).toContain("╰");
|
|
250
|
+
expect(lines[lines.length - 1]).toContain("╯");
|
|
251
|
+
// 所有中间行都应有左右 │
|
|
252
|
+
for (let i = 1; i < lines.length - 1; i++) {
|
|
253
|
+
expect(lines[i]).toContain("│");
|
|
254
|
+
}
|
|
255
|
+
});
|
|
256
|
+
});
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
// src/__tests__/concurrency-pool.test.ts
|
|
2
|
+
import { describe, expect, it } from "vitest";
|
|
3
|
+
|
|
4
|
+
import { DefaultConcurrencyPool } from "../concurrency-pool.ts";
|
|
5
|
+
|
|
6
|
+
describe("DefaultConcurrencyPool", () => {
|
|
7
|
+
it("allows up to maxConcurrent concurrent tasks", async () => {
|
|
8
|
+
const pool = new DefaultConcurrencyPool(2);
|
|
9
|
+
await pool.acquire(0);
|
|
10
|
+
await pool.acquire(0);
|
|
11
|
+
// 第三个 acquire 应阻塞
|
|
12
|
+
let thirdAcquired = false;
|
|
13
|
+
const third = pool.acquire(0).then(() => { thirdAcquired = true; });
|
|
14
|
+
await new Promise((r) => setTimeout(r, 5));
|
|
15
|
+
expect(thirdAcquired).toBe(false);
|
|
16
|
+
expect(pool.active).toBe(2);
|
|
17
|
+
|
|
18
|
+
pool.release();
|
|
19
|
+
await third;
|
|
20
|
+
expect(thirdAcquired).toBe(true);
|
|
21
|
+
expect(pool.active).toBe(2);
|
|
22
|
+
|
|
23
|
+
pool.release();
|
|
24
|
+
pool.release();
|
|
25
|
+
await new Promise((r) => setTimeout(r, 5));
|
|
26
|
+
expect(pool.active).toBe(0);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it("higher priority acquires the pool first", async () => {
|
|
30
|
+
const pool = new DefaultConcurrencyPool(1);
|
|
31
|
+
await pool.acquire(0); // 占满
|
|
32
|
+
|
|
33
|
+
let lowAcquired = false;
|
|
34
|
+
let highAcquired = false;
|
|
35
|
+
const low = pool.acquire(10).then(() => { lowAcquired = true; });
|
|
36
|
+
const high = pool.acquire(0).then(() => { highAcquired = true; });
|
|
37
|
+
|
|
38
|
+
await new Promise((r) => setTimeout(r, 5));
|
|
39
|
+
pool.release();
|
|
40
|
+
await Promise.race([low, high, new Promise((r) => setTimeout(r, 20))]);
|
|
41
|
+
|
|
42
|
+
expect(highAcquired).toBe(true);
|
|
43
|
+
expect(lowAcquired).toBe(false);
|
|
44
|
+
pool.release();
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("same-priority tasks wake in FIFO (seq) order", async () => {
|
|
48
|
+
const pool = new DefaultConcurrencyPool(1);
|
|
49
|
+
await pool.acquire(0); // fill slot
|
|
50
|
+
|
|
51
|
+
const order: number[] = [];
|
|
52
|
+
// Enqueue 3 tasks with same priority — they should resolve in enqueue order
|
|
53
|
+
const p1 = pool.acquire(0).then(() => { order.push(1); pool.release(); });
|
|
54
|
+
const p2 = pool.acquire(0).then(() => { order.push(2); pool.release(); });
|
|
55
|
+
const p3 = pool.acquire(0).then(() => { order.push(3); pool.release(); });
|
|
56
|
+
|
|
57
|
+
pool.release(); // kick off the chain
|
|
58
|
+
await Promise.allSettled([p1, p2, p3]);
|
|
59
|
+
|
|
60
|
+
expect(order).toEqual([1, 2, 3]);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("extra release() does not make active negative", () => {
|
|
64
|
+
const pool = new DefaultConcurrencyPool(2);
|
|
65
|
+
pool.release(); // no acquires yet
|
|
66
|
+
pool.release();
|
|
67
|
+
pool.release();
|
|
68
|
+
expect(pool.active).toBe(0);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it("active never exceeds maxConcurrent", async () => {
|
|
72
|
+
const pool = new DefaultConcurrencyPool(2);
|
|
73
|
+
const acquires: Array<Promise<void>> = [];
|
|
74
|
+
let peak = 0;
|
|
75
|
+
for (let i = 0; i < 6; i++) {
|
|
76
|
+
acquires.push(pool.acquire(0).then(() => {
|
|
77
|
+
peak = Math.max(peak, pool.active);
|
|
78
|
+
}));
|
|
79
|
+
}
|
|
80
|
+
// Drain: release one at a time so we can observe peak
|
|
81
|
+
for (let i = 0; i < 6; i++) {
|
|
82
|
+
pool.release();
|
|
83
|
+
await new Promise((r) => setTimeout(r, 2));
|
|
84
|
+
}
|
|
85
|
+
await Promise.allSettled(acquires);
|
|
86
|
+
expect(peak).toBeLessThanOrEqual(2);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("clamps maxConcurrent=0 to 1 (no deadlock, C3 fix)", async () => {
|
|
90
|
+
const pool = new DefaultConcurrencyPool(0);
|
|
91
|
+
// 不会死锁——clamp 到 1,acquire 立即返回
|
|
92
|
+
await pool.acquire(0);
|
|
93
|
+
expect(pool.active).toBe(1);
|
|
94
|
+
pool.release();
|
|
95
|
+
expect(pool.active).toBe(0);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it("clamps negative maxConcurrent to 1", async () => {
|
|
99
|
+
const pool = new DefaultConcurrencyPool(-5);
|
|
100
|
+
await pool.acquire(0);
|
|
101
|
+
expect(pool.active).toBe(1);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it("priority 0 (sync) preempts priority 1000 (background)", async () => {
|
|
105
|
+
const pool = new DefaultConcurrencyPool(1);
|
|
106
|
+
await pool.acquire(1000); // background 占满
|
|
107
|
+
|
|
108
|
+
let bgAcquired = false;
|
|
109
|
+
let syncAcquired = false;
|
|
110
|
+
const bg = pool.acquire(1000).then(() => { bgAcquired = true; });
|
|
111
|
+
const sync = pool.acquire(0).then(() => { syncAcquired = true; });
|
|
112
|
+
|
|
113
|
+
await new Promise((r) => setTimeout(r, 5));
|
|
114
|
+
pool.release();
|
|
115
|
+
await Promise.race([bg, sync, new Promise((r) => setTimeout(r, 20))]);
|
|
116
|
+
|
|
117
|
+
expect(syncAcquired).toBe(true);
|
|
118
|
+
expect(bgAcquired).toBe(false);
|
|
119
|
+
pool.release();
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
// ============================================================
|
|
123
|
+
// 分层配额:effectiveMaxConcurrent 参数控制放行上限 (T-A1~T-A4)
|
|
124
|
+
// ============================================================
|
|
125
|
+
//
|
|
126
|
+
// [背景] T2 Wave 0 给 acquire 增加可选 effectiveMaxConcurrent 参数,让调用方
|
|
127
|
+
// 传 max(1, maxConcurrent - depth) 实现分层配额(嵌套越深有效并发越小)。
|
|
128
|
+
// 该参数覆盖实例级 maxConcurrent 的本次调用上限,不改实例状态。
|
|
129
|
+
// 这组测试锁定该参数的语义。
|
|
130
|
+
|
|
131
|
+
describe("分层配额 effectiveMaxConcurrent (T-A1~T-A4)", () => {
|
|
132
|
+
it("T-A1: effectiveMaxConcurrent 限制本次调用放行数(小于实例级 maxConcurrent)", async () => {
|
|
133
|
+
// 实例配额 5,但本次调用 effective=2 → 第 3 个应阻塞
|
|
134
|
+
const pool = new DefaultConcurrencyPool(5);
|
|
135
|
+
await pool.acquire(0, 2);
|
|
136
|
+
await pool.acquire(0, 2);
|
|
137
|
+
expect(pool.active).toBe(2);
|
|
138
|
+
|
|
139
|
+
let thirdAcquired = false;
|
|
140
|
+
const third = pool.acquire(0, 2).then(() => { thirdAcquired = true; });
|
|
141
|
+
await new Promise((r) => setTimeout(r, 5));
|
|
142
|
+
|
|
143
|
+
expect(thirdAcquired).toBe(false);
|
|
144
|
+
expect(pool.active).toBe(2);
|
|
145
|
+
|
|
146
|
+
pool.release();
|
|
147
|
+
await third;
|
|
148
|
+
expect(thirdAcquired).toBe(true);
|
|
149
|
+
pool.release();
|
|
150
|
+
pool.release();
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it("T-A2: 同一 pool 实例不同 effectiveMaxConcurrent 控制不同调用放行数", async () => {
|
|
154
|
+
// 同一 pool(实例级 maxConcurrent=5)
|
|
155
|
+
const pool = new DefaultConcurrencyPool(5);
|
|
156
|
+
// 前 3 个用 effective=3,全放行(active 0→1→2→3,每次 < 3)
|
|
157
|
+
await pool.acquire(0, 3);
|
|
158
|
+
await pool.acquire(0, 3);
|
|
159
|
+
await pool.acquire(0, 3);
|
|
160
|
+
expect(pool.active).toBe(3);
|
|
161
|
+
|
|
162
|
+
// 第 4 个用 effective=3 → 阻塞(active 3 >= 3)
|
|
163
|
+
let blockedAcquired = false;
|
|
164
|
+
const blocked = pool.acquire(0, 3).then(() => { blockedAcquired = true; });
|
|
165
|
+
await new Promise((r) => setTimeout(r, 5));
|
|
166
|
+
expect(blockedAcquired).toBe(false);
|
|
167
|
+
|
|
168
|
+
// 换 effective=5 放行(active 3 < 5)
|
|
169
|
+
let wideAcquired = false;
|
|
170
|
+
const wide = pool.acquire(0, 5).then(() => { wideAcquired = true; });
|
|
171
|
+
await new Promise((r) => setTimeout(r, 5));
|
|
172
|
+
expect(wideAcquired).toBe(true);
|
|
173
|
+
expect(pool.active).toBe(4);
|
|
174
|
+
|
|
175
|
+
// wide release 后 blocked 仍等(active 3 >= 3 不变直到再 release)
|
|
176
|
+
pool.release(); // wide 释放
|
|
177
|
+
await new Promise((r) => setTimeout(r, 5));
|
|
178
|
+
expect(blockedAcquired).toBe(true);
|
|
179
|
+
|
|
180
|
+
// 清理
|
|
181
|
+
pool.release();
|
|
182
|
+
pool.release();
|
|
183
|
+
pool.release();
|
|
184
|
+
pool.release();
|
|
185
|
+
await Promise.allSettled([blocked, wide]);
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
it("T-A3: effectiveMaxConcurrent=1 只放行 1 个(保底)", async () => {
|
|
189
|
+
const pool = new DefaultConcurrencyPool(5);
|
|
190
|
+
await pool.acquire(0, 1);
|
|
191
|
+
expect(pool.active).toBe(1);
|
|
192
|
+
|
|
193
|
+
let secondAcquired = false;
|
|
194
|
+
const second = pool.acquire(0, 1).then(() => { secondAcquired = true; });
|
|
195
|
+
await new Promise((r) => setTimeout(r, 5));
|
|
196
|
+
expect(secondAcquired).toBe(false);
|
|
197
|
+
expect(pool.active).toBe(1);
|
|
198
|
+
|
|
199
|
+
pool.release();
|
|
200
|
+
await second;
|
|
201
|
+
expect(secondAcquired).toBe(true);
|
|
202
|
+
pool.release();
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
it("T-A4: maxConcurrent readonly 属性可读且等于构造值", () => {
|
|
206
|
+
const pool = new DefaultConcurrencyPool(4);
|
|
207
|
+
expect(pool.maxConcurrent).toBe(4);
|
|
208
|
+
|
|
209
|
+
// clamp 路径也通过该属性暴露
|
|
210
|
+
const clamped = new DefaultConcurrencyPool(0);
|
|
211
|
+
expect(clamped.maxConcurrent).toBe(1);
|
|
212
|
+
|
|
213
|
+
const negative = new DefaultConcurrencyPool(-3);
|
|
214
|
+
expect(negative.maxConcurrent).toBe(1);
|
|
215
|
+
});
|
|
216
|
+
});
|
|
217
|
+
});
|