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.
Files changed (43) hide show
  1. package/CHANGELOG.md +278 -0
  2. package/README.md +25 -2
  3. package/bin/cli.js +325 -26
  4. package/dist/handlers/commands.js +505 -63
  5. package/dist/handlers/message.js +209 -14
  6. package/dist/i18n.js +470 -13
  7. package/dist/index.js +45 -5
  8. package/dist/providers/claude-sdk-provider.js +106 -14
  9. package/dist/providers/ollama-provider.js +32 -0
  10. package/dist/providers/openai-compatible.js +10 -1
  11. package/dist/providers/registry.js +112 -17
  12. package/dist/providers/types.js +25 -3
  13. package/dist/services/compaction.js +2 -0
  14. package/dist/services/cron.js +53 -42
  15. package/dist/services/heartbeat.js +41 -7
  16. package/dist/services/language-detect.js +12 -2
  17. package/dist/services/ollama-manager.js +339 -0
  18. package/dist/services/personality.js +20 -14
  19. package/dist/services/session.js +21 -3
  20. package/dist/services/subagent-delivery.js +266 -0
  21. package/dist/services/subagent-stats.js +123 -0
  22. package/dist/services/subagents.js +509 -42
  23. package/dist/services/telegram.js +28 -1
  24. package/dist/services/updater.js +158 -0
  25. package/dist/services/usage-tracker.js +11 -4
  26. package/dist/services/users.js +2 -1
  27. package/docs/HANDBOOK.md +856 -0
  28. package/package.json +7 -2
  29. package/test/claude-sdk-provider.test.ts +69 -0
  30. package/test/i18n.test.ts +108 -0
  31. package/test/registry.test.ts +201 -0
  32. package/test/subagent-delivery.test.ts +273 -0
  33. package/test/subagent-stats.test.ts +119 -0
  34. package/test/subagents-commands.test.ts +64 -0
  35. package/test/subagents-config.test.ts +114 -0
  36. package/test/subagents-depth.test.ts +58 -0
  37. package/test/subagents-inheritance.test.ts +67 -0
  38. package/test/subagents-name-resolver.test.ts +122 -0
  39. package/test/subagents-priority-reject.test.ts +88 -0
  40. package/test/subagents-queue.test.ts +127 -0
  41. package/test/subagents-shutdown.test.ts +126 -0
  42. package/test/subagents-toolset.test.ts +51 -0
  43. 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
+ });
@@ -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
+ });