alvin-bot 4.6.0 → 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.
@@ -167,3 +167,107 @@ describe("subagent-delivery (I3)", () => {
167
167
  expect(sentMessages).toHaveLength(0);
168
168
  });
169
169
  });
170
+
171
+ describe("subagent-delivery LiveStream (A4)", () => {
172
+ const edits: Array<{ chatId: number; messageId: number; text: string }> = [];
173
+ let messageCounter = 100;
174
+
175
+ beforeEach(() => {
176
+ edits.length = 0;
177
+ messageCounter = 100;
178
+ });
179
+
180
+ async function wireLiveApi() {
181
+ const mod = await import("../src/services/subagent-delivery.js");
182
+ mod.__setBotApiForTest({
183
+ sendMessage: async (chatId: number, text: string) => {
184
+ sentMessages.push({ chatId, text });
185
+ return { message_id: messageCounter++ };
186
+ },
187
+ sendDocument: async (chatId: number) => {
188
+ sentDocuments.push({ chatId });
189
+ return {};
190
+ },
191
+ editMessageText: async (chatId: number, messageId: number, text: string) => {
192
+ edits.push({ chatId, messageId, text });
193
+ return {};
194
+ },
195
+ });
196
+ return mod;
197
+ }
198
+
199
+ it("start posts an initial 'thinking…' message and records messageId", async () => {
200
+ const mod = await wireLiveApi();
201
+ const stream = mod.createLiveStream(555, "code-review");
202
+ expect(stream).not.toBeNull();
203
+ await stream!.start();
204
+
205
+ expect(sentMessages).toHaveLength(1);
206
+ expect(sentMessages[0].chatId).toBe(555);
207
+ expect(sentMessages[0].text).toContain("thinking");
208
+ expect(stream!.failed).toBe(false);
209
+ });
210
+
211
+ it("update coalesces multiple rapid calls into a single throttled edit", async () => {
212
+ const mod = await wireLiveApi();
213
+ const stream = mod.createLiveStream(1, "fast");
214
+ await stream!.start();
215
+
216
+ stream!.update("hello");
217
+ stream!.update("hello world");
218
+ stream!.update("hello world and more");
219
+
220
+ // Wait for the throttle window to elapse
221
+ await new Promise((r) => setTimeout(r, 900));
222
+
223
+ // Should have produced exactly one edit with the LAST text
224
+ expect(edits.length).toBe(1);
225
+ expect(edits[0].text).toContain("hello world and more");
226
+ });
227
+
228
+ it("finalize posts a banner as a new message", async () => {
229
+ const mod = await wireLiveApi();
230
+ const stream = mod.createLiveStream(42, "done-agent");
231
+ await stream!.start();
232
+ stream!.update("final text");
233
+ await new Promise((r) => setTimeout(r, 900)); // let flush run
234
+
235
+ await stream!.finalize(
236
+ {
237
+ id: "x",
238
+ name: "done-agent",
239
+ status: "completed",
240
+ startedAt: Date.now() - 5000,
241
+ source: "user",
242
+ depth: 0,
243
+ parentChatId: 42,
244
+ },
245
+ {
246
+ id: "x",
247
+ name: "done-agent",
248
+ status: "completed",
249
+ output: "final text",
250
+ tokensUsed: { input: 100, output: 50 },
251
+ duration: 5000,
252
+ },
253
+ );
254
+
255
+ // Two sends total: initial "thinking…" + final banner
256
+ expect(sentMessages.length).toBe(2);
257
+ const banner = sentMessages[sentMessages.length - 1].text;
258
+ expect(banner).toContain("done-agent");
259
+ expect(banner).toContain("completed");
260
+ });
261
+
262
+ it("createLiveStream returns null when bot api lacks editMessageText", async () => {
263
+ const mod = await import("../src/services/subagent-delivery.js");
264
+ // Set an api that intentionally has no editMessageText
265
+ mod.__setBotApiForTest({
266
+ sendMessage: async () => ({ message_id: 1 }),
267
+ sendDocument: async () => ({}),
268
+ // no editMessageText
269
+ });
270
+ const stream = mod.createLiveStream(1, "no-edit");
271
+ expect(stream).toBeNull();
272
+ });
273
+ });
@@ -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
+ });
@@ -96,7 +96,13 @@ describe("sub-agents visibility config (A4)", () => {
96
96
 
97
97
  it("rejects invalid visibility values", async () => {
98
98
  const mod = await import("../src/services/subagents.js");
99
- expect(() => mod.setVisibility("live" as "auto")).toThrow(/invalid/i);
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");
100
106
  });
101
107
 
102
108
  it("setVisibility('auto') round-trips through disk", async () => {
@@ -23,10 +23,13 @@ vi.mock("../src/engine.js", () => ({
23
23
  }),
24
24
  }));
25
25
 
26
- describe("priority-aware reject (D4)", () => {
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.
27
29
  it("user-spawn message points out cron/implicit jobs hold the slots", async () => {
28
30
  const mod = await import("../src/services/subagents.js");
29
31
  mod.setMaxParallelAgents(2);
32
+ mod.setQueueCap(0);
30
33
 
31
34
  await mod.spawnSubAgent({ name: "bg-1", prompt: "x", source: "cron" });
32
35
  await mod.spawnSubAgent({ name: "bg-2", prompt: "y", source: "implicit" });
@@ -39,6 +42,7 @@ describe("priority-aware reject (D4)", () => {
39
42
  it("user-spawn message blames user agents when they hold all slots", async () => {
40
43
  const mod = await import("../src/services/subagents.js");
41
44
  mod.setMaxParallelAgents(2);
45
+ mod.setQueueCap(0);
42
46
 
43
47
  await mod.spawnSubAgent({ name: "u1", prompt: "x", source: "user" });
44
48
  await mod.spawnSubAgent({ name: "u2", prompt: "y", source: "user" });
@@ -51,6 +55,7 @@ describe("priority-aware reject (D4)", () => {
51
55
  it("cron and implicit rejects use the generic message", async () => {
52
56
  const mod = await import("../src/services/subagents.js");
53
57
  mod.setMaxParallelAgents(1);
58
+ mod.setQueueCap(0);
54
59
 
55
60
  await mod.spawnSubAgent({ name: "first", prompt: "x", source: "user" });
56
61
  await expect(
@@ -58,3 +63,26 @@ describe("priority-aware reject (D4)", () => {
58
63
  ).rejects.toThrow(/limit reached/i);
59
64
  });
60
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
+ });
@@ -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
+ });
Binary file