alvin-bot 4.5.1 → 4.7.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/CHANGELOG.md +278 -0
- package/README.md +25 -2
- package/bin/cli.js +325 -26
- package/dist/handlers/commands.js +505 -63
- package/dist/handlers/message.js +209 -14
- package/dist/i18n.js +470 -13
- package/dist/index.js +45 -5
- package/dist/providers/claude-sdk-provider.js +106 -14
- package/dist/providers/ollama-provider.js +32 -0
- package/dist/providers/openai-compatible.js +10 -1
- package/dist/providers/registry.js +112 -17
- package/dist/providers/types.js +25 -3
- package/dist/services/compaction.js +2 -0
- package/dist/services/cron.js +53 -42
- package/dist/services/heartbeat.js +41 -7
- package/dist/services/language-detect.js +12 -2
- package/dist/services/ollama-manager.js +339 -0
- package/dist/services/personality.js +20 -14
- package/dist/services/session.js +21 -3
- package/dist/services/subagent-delivery.js +266 -0
- package/dist/services/subagent-stats.js +123 -0
- package/dist/services/subagents.js +509 -42
- package/dist/services/telegram.js +28 -1
- package/dist/services/updater.js +158 -0
- package/dist/services/usage-tracker.js +11 -4
- package/dist/services/users.js +2 -1
- package/docs/HANDBOOK.md +856 -0
- package/package.json +7 -2
- package/test/claude-sdk-provider.test.ts +69 -0
- package/test/i18n.test.ts +108 -0
- package/test/registry.test.ts +201 -0
- package/test/subagent-delivery.test.ts +273 -0
- package/test/subagent-stats.test.ts +119 -0
- package/test/subagents-commands.test.ts +64 -0
- package/test/subagents-config.test.ts +114 -0
- package/test/subagents-depth.test.ts +58 -0
- package/test/subagents-inheritance.test.ts +67 -0
- package/test/subagents-name-resolver.test.ts +122 -0
- package/test/subagents-priority-reject.test.ts +88 -0
- package/test/subagents-queue.test.ts +127 -0
- package/test/subagents-shutdown.test.ts +126 -0
- package/test/subagents-toolset.test.ts +51 -0
- package/vitest.config.ts +17 -0
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, vi } from "vitest";
|
|
2
|
+
import fs from "fs";
|
|
3
|
+
import os from "os";
|
|
4
|
+
import { resolve } from "path";
|
|
5
|
+
import type { SubAgentInfo, SubAgentResult } from "../src/services/subagents.js";
|
|
6
|
+
|
|
7
|
+
const TEST_DATA_DIR = resolve(os.tmpdir(), `alvin-bot-stats-${process.pid}-${Date.now()}`);
|
|
8
|
+
|
|
9
|
+
beforeEach(() => {
|
|
10
|
+
if (fs.existsSync(TEST_DATA_DIR)) fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
|
11
|
+
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
|
|
12
|
+
process.env.ALVIN_DATA_DIR = TEST_DATA_DIR;
|
|
13
|
+
vi.resetModules();
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
function makeInfo(overrides: Partial<SubAgentInfo> = {}): SubAgentInfo {
|
|
17
|
+
return {
|
|
18
|
+
id: "x",
|
|
19
|
+
name: "test",
|
|
20
|
+
status: "completed",
|
|
21
|
+
startedAt: Date.now() - 1000,
|
|
22
|
+
source: "user",
|
|
23
|
+
depth: 0,
|
|
24
|
+
...overrides,
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function makeResult(overrides: Partial<SubAgentResult> = {}): SubAgentResult {
|
|
29
|
+
return {
|
|
30
|
+
id: "x",
|
|
31
|
+
name: "test",
|
|
32
|
+
status: "completed",
|
|
33
|
+
output: "ok",
|
|
34
|
+
tokensUsed: { input: 100, output: 50 },
|
|
35
|
+
duration: 1000,
|
|
36
|
+
...overrides,
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
describe("subagent-stats (H3)", () => {
|
|
41
|
+
it("getSubAgentStats returns zeros on a fresh install", async () => {
|
|
42
|
+
const mod = await import("../src/services/subagent-stats.js");
|
|
43
|
+
const stats = mod.getSubAgentStats();
|
|
44
|
+
expect(stats.total.runs).toBe(0);
|
|
45
|
+
expect(stats.bySource.user.runs).toBe(0);
|
|
46
|
+
expect(stats.byStatus.completed).toBe(0);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("recordSubAgentRun appends and updates totals", async () => {
|
|
50
|
+
const mod = await import("../src/services/subagent-stats.js");
|
|
51
|
+
mod.recordSubAgentRun(makeInfo({ source: "user" }), makeResult({ tokensUsed: { input: 100, output: 50 } }));
|
|
52
|
+
mod.recordSubAgentRun(makeInfo({ source: "cron" }), makeResult({ tokensUsed: { input: 200, output: 75 } }));
|
|
53
|
+
mod.recordSubAgentRun(makeInfo({ source: "user" }), makeResult({ tokensUsed: { input: 50, output: 25 } }));
|
|
54
|
+
|
|
55
|
+
const stats = mod.getSubAgentStats();
|
|
56
|
+
expect(stats.total.runs).toBe(3);
|
|
57
|
+
expect(stats.total.inputTokens).toBe(350);
|
|
58
|
+
expect(stats.total.outputTokens).toBe(150);
|
|
59
|
+
expect(stats.bySource.user.runs).toBe(2);
|
|
60
|
+
expect(stats.bySource.user.inputTokens).toBe(150);
|
|
61
|
+
expect(stats.bySource.cron.runs).toBe(1);
|
|
62
|
+
expect(stats.bySource.cron.inputTokens).toBe(200);
|
|
63
|
+
expect(stats.byStatus.completed).toBe(3);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("persists to disk and round-trips through reload", async () => {
|
|
67
|
+
let mod = await import("../src/services/subagent-stats.js");
|
|
68
|
+
mod.recordSubAgentRun(makeInfo({ source: "cron" }), makeResult());
|
|
69
|
+
|
|
70
|
+
// Force a reload by resetting modules
|
|
71
|
+
vi.resetModules();
|
|
72
|
+
mod = await import("../src/services/subagent-stats.js");
|
|
73
|
+
|
|
74
|
+
const stats = mod.getSubAgentStats();
|
|
75
|
+
expect(stats.total.runs).toBe(1);
|
|
76
|
+
expect(stats.bySource.cron.runs).toBe(1);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("prunes entries older than 24h", async () => {
|
|
80
|
+
const mod = await import("../src/services/subagent-stats.js");
|
|
81
|
+
// Seed the file with an entry from 25 hours ago
|
|
82
|
+
const ancient = [
|
|
83
|
+
{
|
|
84
|
+
completedAt: Date.now() - 25 * 60 * 60 * 1000,
|
|
85
|
+
name: "ancient",
|
|
86
|
+
source: "user",
|
|
87
|
+
status: "completed",
|
|
88
|
+
durationMs: 100,
|
|
89
|
+
inputTokens: 999,
|
|
90
|
+
outputTokens: 999,
|
|
91
|
+
},
|
|
92
|
+
];
|
|
93
|
+
fs.writeFileSync(
|
|
94
|
+
resolve(TEST_DATA_DIR, "subagent-stats.json"),
|
|
95
|
+
JSON.stringify(ancient),
|
|
96
|
+
);
|
|
97
|
+
mod.__resetStatsCacheForTest();
|
|
98
|
+
|
|
99
|
+
// Fresh read should exclude the ancient entry
|
|
100
|
+
const stats = mod.getSubAgentStats();
|
|
101
|
+
expect(stats.total.runs).toBe(0);
|
|
102
|
+
expect(stats.total.inputTokens).toBe(0);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it("tracks byStatus separately for cancelled/error/timeout", async () => {
|
|
106
|
+
const mod = await import("../src/services/subagent-stats.js");
|
|
107
|
+
mod.recordSubAgentRun(makeInfo(), makeResult({ status: "completed" }));
|
|
108
|
+
mod.recordSubAgentRun(makeInfo(), makeResult({ status: "cancelled" }));
|
|
109
|
+
mod.recordSubAgentRun(makeInfo(), makeResult({ status: "error" }));
|
|
110
|
+
mod.recordSubAgentRun(makeInfo(), makeResult({ status: "timeout" }));
|
|
111
|
+
mod.recordSubAgentRun(makeInfo(), makeResult({ status: "completed" }));
|
|
112
|
+
|
|
113
|
+
const stats = mod.getSubAgentStats();
|
|
114
|
+
expect(stats.byStatus.completed).toBe(2);
|
|
115
|
+
expect(stats.byStatus.cancelled).toBe(1);
|
|
116
|
+
expect(stats.byStatus.error).toBe(1);
|
|
117
|
+
expect(stats.byStatus.timeout).toBe(1);
|
|
118
|
+
});
|
|
119
|
+
});
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, vi } from "vitest";
|
|
2
|
+
import fs from "fs";
|
|
3
|
+
import os from "os";
|
|
4
|
+
import { resolve } from "path";
|
|
5
|
+
|
|
6
|
+
const TEST_DATA_DIR = resolve(os.tmpdir(), `alvin-bot-cmds-${process.pid}-${Date.now()}`);
|
|
7
|
+
|
|
8
|
+
beforeEach(() => {
|
|
9
|
+
if (fs.existsSync(TEST_DATA_DIR)) fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
|
10
|
+
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
|
|
11
|
+
process.env.ALVIN_DATA_DIR = TEST_DATA_DIR;
|
|
12
|
+
delete process.env.MAX_SUBAGENTS;
|
|
13
|
+
vi.resetModules();
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
vi.mock("../src/engine.js", () => ({
|
|
17
|
+
getRegistry: () => ({
|
|
18
|
+
queryWithFallback: async function* () {
|
|
19
|
+
await new Promise((r) => setTimeout(r, 500));
|
|
20
|
+
yield { type: "done", text: "ok", inputTokens: 0, outputTokens: 0 };
|
|
21
|
+
},
|
|
22
|
+
}),
|
|
23
|
+
}));
|
|
24
|
+
|
|
25
|
+
describe("cancelSubAgentByName / getSubAgentResultByName (B2 helpers)", () => {
|
|
26
|
+
it("cancels an agent by its exact name", async () => {
|
|
27
|
+
const mod = await import("../src/services/subagents.js");
|
|
28
|
+
const id = await mod.spawnSubAgent({ name: "foo", prompt: "a" });
|
|
29
|
+
const ok = mod.cancelSubAgentByName("foo");
|
|
30
|
+
expect(ok).toBe(true);
|
|
31
|
+
|
|
32
|
+
const info = mod.listSubAgents().find((a) => a.id === id);
|
|
33
|
+
expect(info?.status).toBe("cancelled");
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("cancels the base-name when unambiguous", async () => {
|
|
37
|
+
const mod = await import("../src/services/subagents.js");
|
|
38
|
+
await mod.spawnSubAgent({ name: "bar", prompt: "a" });
|
|
39
|
+
expect(mod.cancelSubAgentByName("bar")).toBe(true);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("returns false for unknown name", async () => {
|
|
43
|
+
const mod = await import("../src/services/subagents.js");
|
|
44
|
+
expect(mod.cancelSubAgentByName("ghost")).toBe(false);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("cancels the #N variant when addressed directly", async () => {
|
|
48
|
+
const mod = await import("../src/services/subagents.js");
|
|
49
|
+
await mod.spawnSubAgent({ name: "baz", prompt: "a" });
|
|
50
|
+
await mod.spawnSubAgent({ name: "baz", prompt: "b" });
|
|
51
|
+
const ok = mod.cancelSubAgentByName("baz#2");
|
|
52
|
+
expect(ok).toBe(true);
|
|
53
|
+
|
|
54
|
+
const agents = mod.listSubAgents();
|
|
55
|
+
const canceledNames = agents.filter((a) => a.status === "cancelled").map((a) => a.name);
|
|
56
|
+
expect(canceledNames).toEqual(["baz#2"]);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("getSubAgentResultByName returns null when still running", async () => {
|
|
60
|
+
const mod = await import("../src/services/subagents.js");
|
|
61
|
+
await mod.spawnSubAgent({ name: "running", prompt: "a" });
|
|
62
|
+
expect(mod.getSubAgentResultByName("running")).toBeNull();
|
|
63
|
+
});
|
|
64
|
+
});
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
|
2
|
+
import fs from "fs";
|
|
3
|
+
import { resolve } from "path";
|
|
4
|
+
import os from "os";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Tests for the file-backed sub-agents config.
|
|
8
|
+
*
|
|
9
|
+
* We isolate via ALVIN_DATA_DIR pointing at a temp directory, so the test
|
|
10
|
+
* never touches the real ~/.alvin-bot/sub-agents.json. vi.resetModules()
|
|
11
|
+
* clears Vitest's module cache between tests so each import() gets a
|
|
12
|
+
* fresh module with a fresh configCache.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
const TEST_DATA_DIR = resolve(os.tmpdir(), `alvin-bot-test-${process.pid}-${Date.now()}`);
|
|
16
|
+
|
|
17
|
+
beforeEach(() => {
|
|
18
|
+
if (fs.existsSync(TEST_DATA_DIR)) {
|
|
19
|
+
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
|
20
|
+
}
|
|
21
|
+
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
|
|
22
|
+
process.env.ALVIN_DATA_DIR = TEST_DATA_DIR;
|
|
23
|
+
delete process.env.MAX_SUBAGENTS;
|
|
24
|
+
vi.resetModules(); // force re-import of subagents.ts next time
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
afterEach(() => {
|
|
28
|
+
if (fs.existsSync(TEST_DATA_DIR)) {
|
|
29
|
+
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
|
30
|
+
}
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
describe("sub-agents config", () => {
|
|
34
|
+
it("returns 0 as the configured value on a fresh install", async () => {
|
|
35
|
+
const mod = await import("../src/services/subagents.js");
|
|
36
|
+
expect(mod.getConfiguredMaxParallel()).toBe(0);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("resolves 0 to min(cpuCount, 16) in getMaxParallelAgents", async () => {
|
|
40
|
+
const mod = await import("../src/services/subagents.js");
|
|
41
|
+
const effective = mod.getMaxParallelAgents();
|
|
42
|
+
const cpuCount = os.cpus().length;
|
|
43
|
+
expect(effective).toBe(Math.min(cpuCount, 16));
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("setMaxParallelAgents persists the value to disk", async () => {
|
|
47
|
+
const mod = await import("../src/services/subagents.js");
|
|
48
|
+
mod.setMaxParallelAgents(5);
|
|
49
|
+
expect(mod.getConfiguredMaxParallel()).toBe(5);
|
|
50
|
+
expect(mod.getMaxParallelAgents()).toBe(5);
|
|
51
|
+
|
|
52
|
+
// Verify file on disk
|
|
53
|
+
const configPath = resolve(TEST_DATA_DIR, "sub-agents.json");
|
|
54
|
+
expect(fs.existsSync(configPath)).toBe(true);
|
|
55
|
+
const persisted = JSON.parse(fs.readFileSync(configPath, "utf-8"));
|
|
56
|
+
expect(persisted.maxParallel).toBe(5);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("clamps values above ABSOLUTE_MAX (16) down to 16", async () => {
|
|
60
|
+
const mod = await import("../src/services/subagents.js");
|
|
61
|
+
const effective = mod.setMaxParallelAgents(500);
|
|
62
|
+
expect(effective).toBe(16);
|
|
63
|
+
expect(mod.getConfiguredMaxParallel()).toBe(16);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("clamps negative values to 0 (which then resolves to auto)", async () => {
|
|
67
|
+
const mod = await import("../src/services/subagents.js");
|
|
68
|
+
const effective = mod.setMaxParallelAgents(-5);
|
|
69
|
+
expect(mod.getConfiguredMaxParallel()).toBe(0);
|
|
70
|
+
expect(effective).toBe(Math.min(os.cpus().length, 16));
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("floors fractional values", async () => {
|
|
74
|
+
const mod = await import("../src/services/subagents.js");
|
|
75
|
+
mod.setMaxParallelAgents(7.8);
|
|
76
|
+
expect(mod.getConfiguredMaxParallel()).toBe(7);
|
|
77
|
+
});
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
describe("sub-agents visibility config (A4)", () => {
|
|
81
|
+
it("defaults visibility to 'auto' on a fresh install", async () => {
|
|
82
|
+
const mod = await import("../src/services/subagents.js");
|
|
83
|
+
expect(mod.getVisibility()).toBe("auto");
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it("setVisibility persists the value to disk", async () => {
|
|
87
|
+
const mod = await import("../src/services/subagents.js");
|
|
88
|
+
mod.setVisibility("banner");
|
|
89
|
+
expect(mod.getVisibility()).toBe("banner");
|
|
90
|
+
|
|
91
|
+
const configPath = resolve(TEST_DATA_DIR, "sub-agents.json");
|
|
92
|
+
expect(fs.existsSync(configPath)).toBe(true);
|
|
93
|
+
const persisted = JSON.parse(fs.readFileSync(configPath, "utf-8"));
|
|
94
|
+
expect(persisted.visibility).toBe("banner");
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it("rejects invalid visibility values", async () => {
|
|
98
|
+
const mod = await import("../src/services/subagents.js");
|
|
99
|
+
expect(() => mod.setVisibility("bogus" as "auto")).toThrow(/invalid/i);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it("accepts 'live' as a valid visibility mode (A4 Stufe 2)", async () => {
|
|
103
|
+
const mod = await import("../src/services/subagents.js");
|
|
104
|
+
mod.setVisibility("live");
|
|
105
|
+
expect(mod.getVisibility()).toBe("live");
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it("setVisibility('auto') round-trips through disk", async () => {
|
|
109
|
+
const mod = await import("../src/services/subagents.js");
|
|
110
|
+
mod.setVisibility("banner");
|
|
111
|
+
mod.setVisibility("auto");
|
|
112
|
+
expect(mod.getVisibility()).toBe("auto");
|
|
113
|
+
});
|
|
114
|
+
});
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, vi } from "vitest";
|
|
2
|
+
import fs from "fs";
|
|
3
|
+
import os from "os";
|
|
4
|
+
import { resolve } from "path";
|
|
5
|
+
|
|
6
|
+
const TEST_DATA_DIR = resolve(os.tmpdir(), `alvin-bot-depth-${process.pid}-${Date.now()}`);
|
|
7
|
+
|
|
8
|
+
beforeEach(() => {
|
|
9
|
+
if (fs.existsSync(TEST_DATA_DIR)) fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
|
10
|
+
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
|
|
11
|
+
process.env.ALVIN_DATA_DIR = TEST_DATA_DIR;
|
|
12
|
+
delete process.env.MAX_SUBAGENTS;
|
|
13
|
+
vi.resetModules();
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
// Stub the engine so spawnSubAgent doesn't actually invoke any LLM.
|
|
17
|
+
vi.mock("../src/engine.js", () => ({
|
|
18
|
+
getRegistry: () => ({
|
|
19
|
+
queryWithFallback: async function* () {
|
|
20
|
+
yield { type: "text", text: "ok" };
|
|
21
|
+
yield { type: "done", text: "ok", inputTokens: 1, outputTokens: 1 };
|
|
22
|
+
},
|
|
23
|
+
}),
|
|
24
|
+
}));
|
|
25
|
+
|
|
26
|
+
describe("sub-agents depth-cap (F2)", () => {
|
|
27
|
+
it("accepts depth 0 (root)", async () => {
|
|
28
|
+
const mod = await import("../src/services/subagents.js");
|
|
29
|
+
const id = await mod.spawnSubAgent({ name: "d0", prompt: "hi", depth: 0 });
|
|
30
|
+
expect(id).toMatch(/^[0-9a-f-]{36}$/);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("accepts depth 1", async () => {
|
|
34
|
+
const mod = await import("../src/services/subagents.js");
|
|
35
|
+
const id = await mod.spawnSubAgent({ name: "d1", prompt: "hi", depth: 1 });
|
|
36
|
+
expect(id).toMatch(/^[0-9a-f-]{36}$/);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("accepts depth 2 (the cap)", async () => {
|
|
40
|
+
const mod = await import("../src/services/subagents.js");
|
|
41
|
+
const id = await mod.spawnSubAgent({ name: "d2", prompt: "hi", depth: 2 });
|
|
42
|
+
expect(id).toMatch(/^[0-9a-f-]{36}$/);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("rejects depth 3 with a clear error", async () => {
|
|
46
|
+
const mod = await import("../src/services/subagents.js");
|
|
47
|
+
await expect(
|
|
48
|
+
mod.spawnSubAgent({ name: "d3", prompt: "hi", depth: 3 }),
|
|
49
|
+
).rejects.toThrow(/depth limit/i);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("defaults depth to 0 when omitted", async () => {
|
|
53
|
+
const mod = await import("../src/services/subagents.js");
|
|
54
|
+
const id = await mod.spawnSubAgent({ name: "nodepth", prompt: "hi" });
|
|
55
|
+
const info = mod.listSubAgents().find((a) => a.id === id);
|
|
56
|
+
expect(info?.depth).toBe(0);
|
|
57
|
+
});
|
|
58
|
+
});
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, vi } from "vitest";
|
|
2
|
+
import fs from "fs";
|
|
3
|
+
import os from "os";
|
|
4
|
+
import { resolve } from "path";
|
|
5
|
+
|
|
6
|
+
const TEST_DATA_DIR = resolve(os.tmpdir(), `alvin-bot-inherit-${process.pid}-${Date.now()}`);
|
|
7
|
+
|
|
8
|
+
beforeEach(() => {
|
|
9
|
+
if (fs.existsSync(TEST_DATA_DIR)) fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
|
10
|
+
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
|
|
11
|
+
process.env.ALVIN_DATA_DIR = TEST_DATA_DIR;
|
|
12
|
+
delete process.env.MAX_SUBAGENTS;
|
|
13
|
+
vi.resetModules();
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
// Capture what queryWithFallback is called with, so we can inspect workingDir.
|
|
17
|
+
let capturedOptions: Record<string, unknown> | null = null;
|
|
18
|
+
|
|
19
|
+
vi.mock("../src/engine.js", () => ({
|
|
20
|
+
getRegistry: () => ({
|
|
21
|
+
queryWithFallback: async function* (options: Record<string, unknown>) {
|
|
22
|
+
capturedOptions = options;
|
|
23
|
+
yield { type: "text", text: "ok" };
|
|
24
|
+
yield { type: "done", text: "ok", inputTokens: 0, outputTokens: 0 };
|
|
25
|
+
},
|
|
26
|
+
}),
|
|
27
|
+
}));
|
|
28
|
+
|
|
29
|
+
// Small sleep helper — spawnSubAgent is fire-and-forget, we wait for the
|
|
30
|
+
// background task to reach queryWithFallback.
|
|
31
|
+
const tick = () => new Promise((r) => setTimeout(r, 50));
|
|
32
|
+
|
|
33
|
+
describe("sub-agents inheritance (C3)", () => {
|
|
34
|
+
beforeEach(() => {
|
|
35
|
+
capturedOptions = null;
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("passes the provided workingDir to queryWithFallback", async () => {
|
|
39
|
+
const mod = await import("../src/services/subagents.js");
|
|
40
|
+
await mod.spawnSubAgent({
|
|
41
|
+
name: "cwd-test",
|
|
42
|
+
prompt: "hi",
|
|
43
|
+
workingDir: "/tmp/inherited-project",
|
|
44
|
+
});
|
|
45
|
+
await tick();
|
|
46
|
+
expect(capturedOptions?.workingDir).toBe("/tmp/inherited-project");
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("falls back to os.homedir() when workingDir is not provided", async () => {
|
|
50
|
+
const mod = await import("../src/services/subagents.js");
|
|
51
|
+
await mod.spawnSubAgent({ name: "no-cwd", prompt: "hi" });
|
|
52
|
+
await tick();
|
|
53
|
+
expect(capturedOptions?.workingDir).toBe(os.homedir());
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("respects inheritCwd=false by defaulting to homedir regardless of workingDir", async () => {
|
|
57
|
+
const mod = await import("../src/services/subagents.js");
|
|
58
|
+
await mod.spawnSubAgent({
|
|
59
|
+
name: "no-inherit",
|
|
60
|
+
prompt: "hi",
|
|
61
|
+
workingDir: "/tmp/should-be-ignored",
|
|
62
|
+
inheritCwd: false,
|
|
63
|
+
});
|
|
64
|
+
await tick();
|
|
65
|
+
expect(capturedOptions?.workingDir).toBe(os.homedir());
|
|
66
|
+
});
|
|
67
|
+
});
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, vi } from "vitest";
|
|
2
|
+
import fs from "fs";
|
|
3
|
+
import os from "os";
|
|
4
|
+
import { resolve } from "path";
|
|
5
|
+
|
|
6
|
+
const TEST_DATA_DIR = resolve(os.tmpdir(), `alvin-bot-name-${process.pid}-${Date.now()}`);
|
|
7
|
+
|
|
8
|
+
beforeEach(() => {
|
|
9
|
+
if (fs.existsSync(TEST_DATA_DIR)) fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
|
10
|
+
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
|
|
11
|
+
process.env.ALVIN_DATA_DIR = TEST_DATA_DIR;
|
|
12
|
+
delete process.env.MAX_SUBAGENTS;
|
|
13
|
+
vi.resetModules();
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
// Long-running engine stub: holds for 500ms so entries stay "running"
|
|
17
|
+
// while we interrogate the resolver.
|
|
18
|
+
vi.mock("../src/engine.js", () => ({
|
|
19
|
+
getRegistry: () => ({
|
|
20
|
+
queryWithFallback: async function* () {
|
|
21
|
+
await new Promise((r) => setTimeout(r, 500));
|
|
22
|
+
yield { type: "done", text: "ok", inputTokens: 0, outputTokens: 0 };
|
|
23
|
+
},
|
|
24
|
+
}),
|
|
25
|
+
}));
|
|
26
|
+
|
|
27
|
+
describe("sub-agents name resolver (B2)", () => {
|
|
28
|
+
it("spawning a second agent with the same name adds #2 suffix", async () => {
|
|
29
|
+
const mod = await import("../src/services/subagents.js");
|
|
30
|
+
await mod.spawnSubAgent({ name: "review", prompt: "a" });
|
|
31
|
+
await mod.spawnSubAgent({ name: "review", prompt: "b" });
|
|
32
|
+
|
|
33
|
+
const agents = mod.listSubAgents();
|
|
34
|
+
const names = agents.map((a) => a.name).sort();
|
|
35
|
+
expect(names).toEqual(["review", "review#2"]);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("spawning a third adds #3 when #2 is also running", async () => {
|
|
39
|
+
const mod = await import("../src/services/subagents.js");
|
|
40
|
+
await mod.spawnSubAgent({ name: "review", prompt: "a" });
|
|
41
|
+
await mod.spawnSubAgent({ name: "review", prompt: "b" });
|
|
42
|
+
await mod.spawnSubAgent({ name: "review", prompt: "c" });
|
|
43
|
+
|
|
44
|
+
const names = mod.listSubAgents().map((a) => a.name).sort();
|
|
45
|
+
expect(names).toEqual(["review", "review#2", "review#3"]);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("different base names do not interfere", async () => {
|
|
49
|
+
const mod = await import("../src/services/subagents.js");
|
|
50
|
+
await mod.spawnSubAgent({ name: "review", prompt: "a" });
|
|
51
|
+
await mod.spawnSubAgent({ name: "scan", prompt: "b" });
|
|
52
|
+
|
|
53
|
+
const names = mod.listSubAgents().map((a) => a.name).sort();
|
|
54
|
+
expect(names).toEqual(["review", "scan"]);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("findSubAgentByName returns exact match when unambiguous", async () => {
|
|
58
|
+
const mod = await import("../src/services/subagents.js");
|
|
59
|
+
await mod.spawnSubAgent({ name: "review", prompt: "a" });
|
|
60
|
+
const match = mod.findSubAgentByName("review");
|
|
61
|
+
expect(match).not.toBeNull();
|
|
62
|
+
if (match && !("ambiguous" in match)) {
|
|
63
|
+
expect(match.name).toBe("review");
|
|
64
|
+
}
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("findSubAgentByName returns null for unknown name", async () => {
|
|
68
|
+
const mod = await import("../src/services/subagents.js");
|
|
69
|
+
await mod.spawnSubAgent({ name: "review", prompt: "a" });
|
|
70
|
+
expect(mod.findSubAgentByName("ghost")).toBeNull();
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("findSubAgentByName returns the #N variant when addressed directly", async () => {
|
|
74
|
+
const mod = await import("../src/services/subagents.js");
|
|
75
|
+
await mod.spawnSubAgent({ name: "review", prompt: "a" });
|
|
76
|
+
await mod.spawnSubAgent({ name: "review", prompt: "b" });
|
|
77
|
+
const match = mod.findSubAgentByName("review#2");
|
|
78
|
+
expect(match).not.toBeNull();
|
|
79
|
+
if (match && !("ambiguous" in match)) {
|
|
80
|
+
expect(match.name).toBe("review#2");
|
|
81
|
+
}
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it("findSubAgentByName with ambiguousAsList still honours exact #N match", async () => {
|
|
85
|
+
// Regression test: the previous implementation checked base-name
|
|
86
|
+
// siblings BEFORE the exact match when ambiguousAsList was set, so
|
|
87
|
+
// queries like findSubAgentByName("review#2", { ambiguousAsList: true })
|
|
88
|
+
// incorrectly returned an ambiguity marker instead of the specific
|
|
89
|
+
// #2 entry. Explicit disambiguation via #N must always win.
|
|
90
|
+
const mod = await import("../src/services/subagents.js");
|
|
91
|
+
await mod.spawnSubAgent({ name: "review", prompt: "a" });
|
|
92
|
+
await mod.spawnSubAgent({ name: "review", prompt: "b" });
|
|
93
|
+
|
|
94
|
+
const match = mod.findSubAgentByName("review#2", { ambiguousAsList: true });
|
|
95
|
+
expect(match).not.toBeNull();
|
|
96
|
+
if (match && "ambiguous" in match) {
|
|
97
|
+
throw new Error(`Expected exact match for 'review#2', got ambiguous marker: ${JSON.stringify(match)}`);
|
|
98
|
+
}
|
|
99
|
+
expect(match?.name).toBe("review#2");
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it("findSubAgentByName returns an ambiguity marker when a basename has >1 variant", async () => {
|
|
103
|
+
const mod = await import("../src/services/subagents.js");
|
|
104
|
+
await mod.spawnSubAgent({ name: "review", prompt: "a" });
|
|
105
|
+
await mod.spawnSubAgent({ name: "review", prompt: "b" });
|
|
106
|
+
// Query the bare basename that also exists as exact → exact match wins.
|
|
107
|
+
// So we use a non-matching basename form to trigger ambiguity: the
|
|
108
|
+
// basename "review" matches exactly, so we need to query something
|
|
109
|
+
// that is neither exact nor unique — we use the exact query but with
|
|
110
|
+
// an ambiguousAsList opt-in, which should now return the marker
|
|
111
|
+
// because there are two siblings under the "review" base name.
|
|
112
|
+
const result = mod.findSubAgentByName("review", { ambiguousAsList: true });
|
|
113
|
+
// In ambiguous mode, return an object with { ambiguous: true, candidates: [...] }
|
|
114
|
+
expect(result).toMatchObject({
|
|
115
|
+
ambiguous: true,
|
|
116
|
+
});
|
|
117
|
+
if (result && "ambiguous" in result) {
|
|
118
|
+
const names = result.candidates.map((c) => c.name).sort();
|
|
119
|
+
expect(names).toEqual(["review", "review#2"]);
|
|
120
|
+
}
|
|
121
|
+
});
|
|
122
|
+
});
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, vi } from "vitest";
|
|
2
|
+
import fs from "fs";
|
|
3
|
+
import os from "os";
|
|
4
|
+
import { resolve } from "path";
|
|
5
|
+
|
|
6
|
+
const TEST_DATA_DIR = resolve(os.tmpdir(), `alvin-bot-reject-${process.pid}-${Date.now()}`);
|
|
7
|
+
|
|
8
|
+
beforeEach(() => {
|
|
9
|
+
if (fs.existsSync(TEST_DATA_DIR)) fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
|
10
|
+
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
|
|
11
|
+
process.env.ALVIN_DATA_DIR = TEST_DATA_DIR;
|
|
12
|
+
process.env.MAX_SUBAGENTS = "2";
|
|
13
|
+
vi.resetModules();
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
// Long-running engine stub — holds agents in 'running' state
|
|
17
|
+
vi.mock("../src/engine.js", () => ({
|
|
18
|
+
getRegistry: () => ({
|
|
19
|
+
queryWithFallback: async function* () {
|
|
20
|
+
await new Promise((r) => setTimeout(r, 5000));
|
|
21
|
+
yield { type: "done", text: "ok", inputTokens: 0, outputTokens: 0 };
|
|
22
|
+
},
|
|
23
|
+
}),
|
|
24
|
+
}));
|
|
25
|
+
|
|
26
|
+
describe("priority-aware reject (D4) — queue disabled", () => {
|
|
27
|
+
// With queueCap=0 the bounded queue is disabled, so hitting the max
|
|
28
|
+
// parallel limit triggers immediate rejection — the D4 messages.
|
|
29
|
+
it("user-spawn message points out cron/implicit jobs hold the slots", async () => {
|
|
30
|
+
const mod = await import("../src/services/subagents.js");
|
|
31
|
+
mod.setMaxParallelAgents(2);
|
|
32
|
+
mod.setQueueCap(0);
|
|
33
|
+
|
|
34
|
+
await mod.spawnSubAgent({ name: "bg-1", prompt: "x", source: "cron" });
|
|
35
|
+
await mod.spawnSubAgent({ name: "bg-2", prompt: "y", source: "implicit" });
|
|
36
|
+
|
|
37
|
+
await expect(
|
|
38
|
+
mod.spawnSubAgent({ name: "user-new", prompt: "z", source: "user" }),
|
|
39
|
+
).rejects.toThrow(/cron\/implicit|Hintergrund/i);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("user-spawn message blames user agents when they hold all slots", async () => {
|
|
43
|
+
const mod = await import("../src/services/subagents.js");
|
|
44
|
+
mod.setMaxParallelAgents(2);
|
|
45
|
+
mod.setQueueCap(0);
|
|
46
|
+
|
|
47
|
+
await mod.spawnSubAgent({ name: "u1", prompt: "x", source: "user" });
|
|
48
|
+
await mod.spawnSubAgent({ name: "u2", prompt: "y", source: "user" });
|
|
49
|
+
|
|
50
|
+
await expect(
|
|
51
|
+
mod.spawnSubAgent({ name: "u3", prompt: "z", source: "user" }),
|
|
52
|
+
).rejects.toThrow(/user-spawns|cancel/i);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("cron and implicit rejects use the generic message", async () => {
|
|
56
|
+
const mod = await import("../src/services/subagents.js");
|
|
57
|
+
mod.setMaxParallelAgents(1);
|
|
58
|
+
mod.setQueueCap(0);
|
|
59
|
+
|
|
60
|
+
await mod.spawnSubAgent({ name: "first", prompt: "x", source: "user" });
|
|
61
|
+
await expect(
|
|
62
|
+
mod.spawnSubAgent({ name: "cron-new", prompt: "y", source: "cron" }),
|
|
63
|
+
).rejects.toThrow(/limit reached/i);
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
describe("priority-aware reject (D4) — queue enabled", () => {
|
|
68
|
+
// With a queue, reject only fires when BOTH the running pool and the
|
|
69
|
+
// queue are full. Queue-enabled rejections still carry the priority-
|
|
70
|
+
// aware messages because the code path is shared.
|
|
71
|
+
it("user-spawn rejects with cron-in-queue message when pool + queue are full", async () => {
|
|
72
|
+
const mod = await import("../src/services/subagents.js");
|
|
73
|
+
mod.setMaxParallelAgents(2);
|
|
74
|
+
mod.setQueueCap(2);
|
|
75
|
+
|
|
76
|
+
// Fill the running pool with cron agents
|
|
77
|
+
await mod.spawnSubAgent({ name: "bg-1", prompt: "x", source: "cron" });
|
|
78
|
+
await mod.spawnSubAgent({ name: "bg-2", prompt: "y", source: "implicit" });
|
|
79
|
+
// Fill the queue with 2 more
|
|
80
|
+
await mod.spawnSubAgent({ name: "q-1", prompt: "z", source: "cron" });
|
|
81
|
+
await mod.spawnSubAgent({ name: "q-2", prompt: "z", source: "cron" });
|
|
82
|
+
|
|
83
|
+
// Now a user spawn has nowhere to go → reject
|
|
84
|
+
await expect(
|
|
85
|
+
mod.spawnSubAgent({ name: "user-new", prompt: "w", source: "user" }),
|
|
86
|
+
).rejects.toThrow(/Queue voll|Hintergrund/i);
|
|
87
|
+
});
|
|
88
|
+
});
|