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,127 @@
|
|
|
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
|
+
/**
|
|
7
|
+
* Tests for the D3 bounded priority queue.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
const TEST_DATA_DIR = resolve(os.tmpdir(), `alvin-bot-queue-${process.pid}-${Date.now()}`);
|
|
11
|
+
|
|
12
|
+
beforeEach(() => {
|
|
13
|
+
if (fs.existsSync(TEST_DATA_DIR)) fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
|
14
|
+
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
|
|
15
|
+
process.env.ALVIN_DATA_DIR = TEST_DATA_DIR;
|
|
16
|
+
delete process.env.MAX_SUBAGENTS;
|
|
17
|
+
vi.resetModules();
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
// Slow generator so the first 2 agents stay "running" while we test
|
|
21
|
+
// queueing of subsequent spawns.
|
|
22
|
+
vi.mock("../src/engine.js", () => ({
|
|
23
|
+
getRegistry: () => ({
|
|
24
|
+
queryWithFallback: async function* () {
|
|
25
|
+
await new Promise((r) => setTimeout(r, 1000));
|
|
26
|
+
yield { type: "done", text: "ok", inputTokens: 0, outputTokens: 0 };
|
|
27
|
+
},
|
|
28
|
+
}),
|
|
29
|
+
}));
|
|
30
|
+
|
|
31
|
+
describe("sub-agents bounded queue (D3)", () => {
|
|
32
|
+
it("getQueueCap defaults to 20", async () => {
|
|
33
|
+
const mod = await import("../src/services/subagents.js");
|
|
34
|
+
expect(mod.getQueueCap()).toBe(20);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("setQueueCap persists to disk and round-trips through reload", async () => {
|
|
38
|
+
const mod = await import("../src/services/subagents.js");
|
|
39
|
+
mod.setQueueCap(5);
|
|
40
|
+
expect(mod.getQueueCap()).toBe(5);
|
|
41
|
+
|
|
42
|
+
const configPath = resolve(TEST_DATA_DIR, "sub-agents.json");
|
|
43
|
+
const persisted = JSON.parse(fs.readFileSync(configPath, "utf-8"));
|
|
44
|
+
expect(persisted.queueCap).toBe(5);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("clamps setQueueCap to [0, ABSOLUTE_MAX_QUEUE]", async () => {
|
|
48
|
+
const mod = await import("../src/services/subagents.js");
|
|
49
|
+
expect(mod.setQueueCap(-5)).toBe(0);
|
|
50
|
+
expect(mod.setQueueCap(500)).toBe(200); // ABSOLUTE_MAX_QUEUE
|
|
51
|
+
expect(mod.setQueueCap(7.9)).toBe(7);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("third spawn at full pool lands in the queue as status=queued", async () => {
|
|
55
|
+
const mod = await import("../src/services/subagents.js");
|
|
56
|
+
mod.setMaxParallelAgents(2);
|
|
57
|
+
mod.setQueueCap(20);
|
|
58
|
+
|
|
59
|
+
await mod.spawnSubAgent({ name: "a", prompt: "x", source: "user" });
|
|
60
|
+
await mod.spawnSubAgent({ name: "b", prompt: "y", source: "user" });
|
|
61
|
+
const id = await mod.spawnSubAgent({ name: "c", prompt: "z", source: "user" });
|
|
62
|
+
|
|
63
|
+
const info = mod.listSubAgents().find((a) => a.id === id);
|
|
64
|
+
expect(info?.status).toBe("queued");
|
|
65
|
+
expect(info?.queuePosition).toBe(1);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("queue drains automatically when a running agent finishes", async () => {
|
|
69
|
+
const mod = await import("../src/services/subagents.js");
|
|
70
|
+
mod.setMaxParallelAgents(2);
|
|
71
|
+
mod.setQueueCap(20);
|
|
72
|
+
|
|
73
|
+
await mod.spawnSubAgent({ name: "a", prompt: "x", source: "user" });
|
|
74
|
+
await mod.spawnSubAgent({ name: "b", prompt: "y", source: "user" });
|
|
75
|
+
const cId = await mod.spawnSubAgent({ name: "c", prompt: "z", source: "user" });
|
|
76
|
+
|
|
77
|
+
// c is queued
|
|
78
|
+
expect(mod.listSubAgents().find((a) => a.id === cId)?.status).toBe("queued");
|
|
79
|
+
|
|
80
|
+
// Wait for the first two to finish (1s each) + drain cycle
|
|
81
|
+
await new Promise((r) => setTimeout(r, 1400));
|
|
82
|
+
|
|
83
|
+
// c should now be running or completed
|
|
84
|
+
const cInfo = mod.listSubAgents().find((a) => a.id === cId);
|
|
85
|
+
expect(["running", "completed"]).toContain(cInfo?.status);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("priority order: user spawns drain before cron at the same moment", async () => {
|
|
89
|
+
const mod = await import("../src/services/subagents.js");
|
|
90
|
+
mod.setMaxParallelAgents(1);
|
|
91
|
+
mod.setQueueCap(20);
|
|
92
|
+
|
|
93
|
+
// One running blocker
|
|
94
|
+
await mod.spawnSubAgent({ name: "blocker", prompt: "x", source: "user" });
|
|
95
|
+
// Queue order: cron first, then user — BUT the drain should pick user first
|
|
96
|
+
const cronId = await mod.spawnSubAgent({ name: "cron-q", prompt: "y", source: "cron" });
|
|
97
|
+
const userId = await mod.spawnSubAgent({ name: "user-q", prompt: "z", source: "user" });
|
|
98
|
+
|
|
99
|
+
// Wait for blocker to finish and drain
|
|
100
|
+
await new Promise((r) => setTimeout(r, 1200));
|
|
101
|
+
|
|
102
|
+
const cronInfo = mod.listSubAgents().find((a) => a.id === cronId);
|
|
103
|
+
const userInfo = mod.listSubAgents().find((a) => a.id === userId);
|
|
104
|
+
|
|
105
|
+
// user agent should be running or done; cron should still be queued
|
|
106
|
+
// (because user has higher priority when draining)
|
|
107
|
+
expect(["running", "completed"]).toContain(userInfo?.status);
|
|
108
|
+
expect(cronInfo?.status).toBe("queued");
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it("cancelSubAgent removes a queued entry from the queue", async () => {
|
|
112
|
+
const mod = await import("../src/services/subagents.js");
|
|
113
|
+
mod.setMaxParallelAgents(1);
|
|
114
|
+
mod.setQueueCap(20);
|
|
115
|
+
|
|
116
|
+
await mod.spawnSubAgent({ name: "blocker", prompt: "x", source: "user" });
|
|
117
|
+
const qId = await mod.spawnSubAgent({ name: "victim", prompt: "y", source: "user" });
|
|
118
|
+
|
|
119
|
+
expect(mod.listSubAgents().find((a) => a.id === qId)?.status).toBe("queued");
|
|
120
|
+
|
|
121
|
+
const ok = mod.cancelSubAgent(qId);
|
|
122
|
+
expect(ok).toBe(true);
|
|
123
|
+
|
|
124
|
+
const info = mod.listSubAgents().find((a) => a.id === qId);
|
|
125
|
+
expect(info?.status).toBe("cancelled");
|
|
126
|
+
});
|
|
127
|
+
});
|
|
@@ -0,0 +1,126 @@
|
|
|
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-shutdown-${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 5s so cancelAll catches the agents
|
|
17
|
+
// as "running".
|
|
18
|
+
vi.mock("../src/engine.js", () => ({
|
|
19
|
+
getRegistry: () => ({
|
|
20
|
+
queryWithFallback: async function* () {
|
|
21
|
+
await new Promise((r) => setTimeout(r, 5000));
|
|
22
|
+
yield { type: "done", text: "ok", inputTokens: 0, outputTokens: 0 };
|
|
23
|
+
},
|
|
24
|
+
}),
|
|
25
|
+
}));
|
|
26
|
+
|
|
27
|
+
describe("cancelAllSubAgents (E2)", () => {
|
|
28
|
+
it("calls delivery for each running agent when notify=true", async () => {
|
|
29
|
+
const deliveredNames: string[] = [];
|
|
30
|
+
|
|
31
|
+
vi.doMock("../src/services/subagent-delivery.js", () => ({
|
|
32
|
+
deliverSubAgentResult: async (info: { name: string }) => {
|
|
33
|
+
deliveredNames.push(info.name);
|
|
34
|
+
},
|
|
35
|
+
attachBotApi: () => {},
|
|
36
|
+
__setBotApiForTest: () => {},
|
|
37
|
+
}));
|
|
38
|
+
|
|
39
|
+
const mod = await import("../src/services/subagents.js");
|
|
40
|
+
await mod.spawnSubAgent({
|
|
41
|
+
name: "agent-a",
|
|
42
|
+
prompt: "x",
|
|
43
|
+
source: "user",
|
|
44
|
+
parentChatId: 1,
|
|
45
|
+
});
|
|
46
|
+
await mod.spawnSubAgent({
|
|
47
|
+
name: "agent-b",
|
|
48
|
+
prompt: "y",
|
|
49
|
+
source: "cron",
|
|
50
|
+
parentChatId: 2,
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
await mod.cancelAllSubAgents(true);
|
|
54
|
+
// Give the async delivery calls a chance to run
|
|
55
|
+
await new Promise((r) => setTimeout(r, 100));
|
|
56
|
+
|
|
57
|
+
expect(deliveredNames.sort()).toEqual(["agent-a", "agent-b"]);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("skips delivery when notify=false", async () => {
|
|
61
|
+
const deliveredNames: string[] = [];
|
|
62
|
+
|
|
63
|
+
vi.doMock("../src/services/subagent-delivery.js", () => ({
|
|
64
|
+
deliverSubAgentResult: async (info: { name: string }) => {
|
|
65
|
+
deliveredNames.push(info.name);
|
|
66
|
+
},
|
|
67
|
+
attachBotApi: () => {},
|
|
68
|
+
__setBotApiForTest: () => {},
|
|
69
|
+
}));
|
|
70
|
+
|
|
71
|
+
const mod = await import("../src/services/subagents.js");
|
|
72
|
+
await mod.spawnSubAgent({
|
|
73
|
+
name: "agent-c",
|
|
74
|
+
prompt: "x",
|
|
75
|
+
source: "user",
|
|
76
|
+
parentChatId: 1,
|
|
77
|
+
});
|
|
78
|
+
await mod.cancelAllSubAgents(false);
|
|
79
|
+
await new Promise((r) => setTimeout(r, 100));
|
|
80
|
+
|
|
81
|
+
expect(deliveredNames).toEqual([]);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it("does not double-deliver when runSubAgent.finally runs after cancelAllSubAgents", async () => {
|
|
85
|
+
// Regression test for the bug caught on the 192.168.178.75 remote
|
|
86
|
+
// test: a slow-fox agent got TWO Telegram messages on shutdown —
|
|
87
|
+
// first an (empty output) 'completed' banner from runSubAgent's
|
|
88
|
+
// finally() block (because queryWithFallback exited gracefully
|
|
89
|
+
// after the abort), and second the 'cancelled · Bot-Restart' banner
|
|
90
|
+
// from cancelAllSubAgents. The delivered flag should prevent the
|
|
91
|
+
// second one firing.
|
|
92
|
+
const deliveredStatuses: string[] = [];
|
|
93
|
+
|
|
94
|
+
vi.doMock("../src/services/subagent-delivery.js", () => ({
|
|
95
|
+
deliverSubAgentResult: async (
|
|
96
|
+
info: { name: string },
|
|
97
|
+
result: { status: string },
|
|
98
|
+
) => {
|
|
99
|
+
deliveredStatuses.push(`${info.name}:${result.status}`);
|
|
100
|
+
},
|
|
101
|
+
attachBotApi: () => {},
|
|
102
|
+
__setBotApiForTest: () => {},
|
|
103
|
+
}));
|
|
104
|
+
|
|
105
|
+
const mod = await import("../src/services/subagents.js");
|
|
106
|
+
await mod.spawnSubAgent({
|
|
107
|
+
name: "slow-fox",
|
|
108
|
+
prompt: "x",
|
|
109
|
+
source: "user",
|
|
110
|
+
parentChatId: 1,
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
// Give the runSubAgent generator a chance to actually start
|
|
114
|
+
await new Promise((r) => setTimeout(r, 20));
|
|
115
|
+
|
|
116
|
+
// Now trigger shutdown — this should cancel and deliver ONCE
|
|
117
|
+
await mod.cancelAllSubAgents(true);
|
|
118
|
+
|
|
119
|
+
// Wait for any pending finally() to run
|
|
120
|
+
await new Promise((r) => setTimeout(r, 2500));
|
|
121
|
+
|
|
122
|
+
const slowFoxDeliveries = deliveredStatuses.filter((s) => s.startsWith("slow-fox:"));
|
|
123
|
+
expect(slowFoxDeliveries.length).toBe(1);
|
|
124
|
+
expect(slowFoxDeliveries[0]).toBe("slow-fox:cancelled");
|
|
125
|
+
});
|
|
126
|
+
});
|
|
@@ -0,0 +1,51 @@
|
|
|
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-toolset-${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
|
+
yield { type: "done", text: "ok", inputTokens: 0, outputTokens: 0 };
|
|
20
|
+
},
|
|
21
|
+
}),
|
|
22
|
+
}));
|
|
23
|
+
|
|
24
|
+
describe("sub-agents toolset (G1)", () => {
|
|
25
|
+
it("accepts toolset='full'", async () => {
|
|
26
|
+
const mod = await import("../src/services/subagents.js");
|
|
27
|
+
const id = await mod.spawnSubAgent({
|
|
28
|
+
name: "tool-full",
|
|
29
|
+
prompt: "hi",
|
|
30
|
+
toolset: "full",
|
|
31
|
+
});
|
|
32
|
+
expect(typeof id).toBe("string");
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("defaults to 'full' when omitted", async () => {
|
|
36
|
+
const mod = await import("../src/services/subagents.js");
|
|
37
|
+
const id = await mod.spawnSubAgent({ name: "tool-default", prompt: "hi" });
|
|
38
|
+
expect(typeof id).toBe("string");
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("rejects unknown toolset values at runtime", async () => {
|
|
42
|
+
const mod = await import("../src/services/subagents.js");
|
|
43
|
+
await expect(
|
|
44
|
+
mod.spawnSubAgent({
|
|
45
|
+
name: "tool-bogus",
|
|
46
|
+
prompt: "hi",
|
|
47
|
+
toolset: "readonly" as unknown as "full",
|
|
48
|
+
}),
|
|
49
|
+
).rejects.toThrow(/toolset/i);
|
|
50
|
+
});
|
|
51
|
+
});
|
package/vitest.config.ts
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { defineConfig } from "vitest/config";
|
|
2
|
+
|
|
3
|
+
export default defineConfig({
|
|
4
|
+
test: {
|
|
5
|
+
// Tests live alongside source as *.test.ts, plus a dedicated test/ dir
|
|
6
|
+
include: ["src/**/*.test.ts", "test/**/*.test.ts"],
|
|
7
|
+
// Node environment — no DOM
|
|
8
|
+
environment: "node",
|
|
9
|
+
// Keep individual tests fast; fail loudly if any hang
|
|
10
|
+
testTimeout: 10_000,
|
|
11
|
+
hookTimeout: 5_000,
|
|
12
|
+
// ESM-only project — vitest handles this natively
|
|
13
|
+
globals: false,
|
|
14
|
+
// Don't try to run electron or build artifacts
|
|
15
|
+
exclude: ["node_modules/**", "dist/**", "electron/**"],
|
|
16
|
+
},
|
|
17
|
+
});
|