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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "alvin-bot",
3
- "version": "4.5.1",
3
+ "version": "4.7.0",
4
4
  "description": "Alvin Bot — Your personal AI agent on Telegram, WhatsApp, Discord, Signal, and Web.",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -12,6 +12,9 @@
12
12
  "build": "tsc",
13
13
  "start": "node dist/index.js",
14
14
  "setup": "node bin/cli.js setup",
15
+ "test": "vitest run",
16
+ "test:watch": "vitest",
17
+ "test:ui": "vitest --ui",
15
18
  "electron:compile": "tsc -p electron/tsconfig.json",
16
19
  "electron:dev": "npm run electron:compile && electron .",
17
20
  "electron:build": "npm run build && npm run electron:compile && electron-builder --publish never",
@@ -176,9 +179,11 @@
176
179
  "ws": "^8.19.0"
177
180
  },
178
181
  "devDependencies": {
182
+ "@vitest/ui": "^4.1.4",
179
183
  "electron": "^35.7.5",
180
184
  "electron-builder": "^26.8.1",
181
- "tsx": "^4.19.0"
185
+ "tsx": "^4.19.0",
186
+ "vitest": "^4.1.4"
182
187
  },
183
188
  "homepage": "https://github.com/alvbln/Alvin-Bot",
184
189
  "engines": {
@@ -0,0 +1,69 @@
1
+ import { describe, it, expect, vi, beforeEach } from "vitest";
2
+
3
+ // Mock child_process BEFORE importing the provider, so the provider's
4
+ // top-level `promisify(execFile)` binds to our mock.
5
+ const execFileMock = vi.fn();
6
+ vi.mock("child_process", () => ({
7
+ execFile: (
8
+ path: string,
9
+ args: string[],
10
+ opts: unknown,
11
+ cb: (err: Error | null, stdout: { stdout: string; stderr: string }) => void,
12
+ ) => {
13
+ execFileMock(path, args, opts, cb);
14
+ },
15
+ }));
16
+
17
+ // Stub findClaudeBinary to return a fake path — we don't want real FS
18
+ vi.mock("../src/find-claude-binary.js", () => ({
19
+ findClaudeBinary: () => "/fake/claude",
20
+ }));
21
+
22
+ describe("ClaudeSDKProvider.isAvailable", () => {
23
+ beforeEach(() => {
24
+ execFileMock.mockReset();
25
+ vi.resetModules();
26
+ });
27
+
28
+ it("returns false when `claude -p` returns 'Not logged in'", async () => {
29
+ // First call: --version succeeds
30
+ // Second call: -p 'ping' returns "Not logged in · Please run /login"
31
+ execFileMock
32
+ .mockImplementationOnce((_p, _a, _o, cb) =>
33
+ cb(null, { stdout: "1.0.0\n", stderr: "" }),
34
+ )
35
+ .mockImplementationOnce((_p, _a, _o, cb) =>
36
+ cb(null, { stdout: "Not logged in · Please run /login", stderr: "" }),
37
+ );
38
+
39
+ const { ClaudeSDKProvider } = await import("../src/providers/claude-sdk-provider.js");
40
+ const p = new ClaudeSDKProvider();
41
+ const result = await p.isAvailable();
42
+ expect(result).toBe(false);
43
+ });
44
+
45
+ it("returns true when `claude -p` returns a normal response", async () => {
46
+ execFileMock
47
+ .mockImplementationOnce((_p, _a, _o, cb) =>
48
+ cb(null, { stdout: "1.0.0\n", stderr: "" }),
49
+ )
50
+ .mockImplementationOnce((_p, _a, _o, cb) =>
51
+ cb(null, { stdout: "pong", stderr: "" }),
52
+ );
53
+
54
+ const { ClaudeSDKProvider } = await import("../src/providers/claude-sdk-provider.js");
55
+ const p = new ClaudeSDKProvider();
56
+ const result = await p.isAvailable();
57
+ expect(result).toBe(true);
58
+ });
59
+ });
60
+
61
+ describe("ClaudeSDKProvider — isAuthErrorOutput helper", () => {
62
+ it("detects 'Not logged in' text as auth error", async () => {
63
+ const { isAuthErrorOutput } = await import("../src/providers/claude-sdk-provider.js");
64
+ expect(isAuthErrorOutput("Not logged in · Please run /login")).toBe(true);
65
+ expect(isAuthErrorOutput(" not logged in · Please run /login ")).toBe(true);
66
+ expect(isAuthErrorOutput("Hello! Here is the result")).toBe(false);
67
+ expect(isAuthErrorOutput("")).toBe(false);
68
+ });
69
+ });
@@ -0,0 +1,108 @@
1
+ import { describe, it, expect, beforeEach } from "vitest";
2
+ import { t, initI18n, setLocale, getLocale, LOCALE_NAMES, LOCALE_FLAGS } from "../src/i18n.js";
3
+
4
+ describe("i18n", () => {
5
+ beforeEach(() => {
6
+ // Reset to a known state before each test
7
+ setLocale("en");
8
+ });
9
+
10
+ describe("LOCALE_NAMES", () => {
11
+ it("exposes all four supported locales", () => {
12
+ expect(LOCALE_NAMES.en).toBe("English");
13
+ expect(LOCALE_NAMES.de).toBe("Deutsch");
14
+ expect(LOCALE_NAMES.es).toBe("Español");
15
+ expect(LOCALE_NAMES.fr).toBe("Français");
16
+ });
17
+ });
18
+
19
+ describe("LOCALE_FLAGS", () => {
20
+ it("has a flag emoji for every locale", () => {
21
+ expect(LOCALE_FLAGS.en).toBe("🇬🇧");
22
+ expect(LOCALE_FLAGS.de).toBe("🇩🇪");
23
+ expect(LOCALE_FLAGS.es).toBe("🇪🇸");
24
+ expect(LOCALE_FLAGS.fr).toBe("🇫🇷");
25
+ });
26
+ });
27
+
28
+ describe("t() — translation lookup", () => {
29
+ it("returns the English string when locale is en", () => {
30
+ const msg = t("bot.cancel.cancelling", "en");
31
+ expect(msg).toContain("Cancelling");
32
+ });
33
+
34
+ it("returns the German string when locale is de", () => {
35
+ const msg = t("bot.cancel.cancelling", "de");
36
+ expect(msg).toContain("abgebrochen");
37
+ });
38
+
39
+ it("returns the Spanish string when locale is es", () => {
40
+ const msg = t("bot.cancel.cancelling", "es");
41
+ expect(msg.toLowerCase()).toContain("cancelando");
42
+ });
43
+
44
+ it("returns the French string when locale is fr", () => {
45
+ const msg = t("bot.cancel.cancelling", "fr");
46
+ expect(msg.toLowerCase()).toContain("annulation");
47
+ });
48
+
49
+ it("falls back to English when locale is missing for a key", () => {
50
+ // Use a TUI key which only has en+de — request es, should fall through
51
+ // to en since tui.* keys aren't translated for es/fr.
52
+ const msg = t("tui.title", "es");
53
+ expect(msg).toContain("Alvin Bot TUI");
54
+ });
55
+
56
+ it("returns the key itself if no locale has it at all", () => {
57
+ const msg = t("bot.nonexistent.key.for.test", "en");
58
+ expect(msg).toBe("bot.nonexistent.key.for.test");
59
+ });
60
+
61
+ it("uses the global currentLocale when no locale is passed", () => {
62
+ setLocale("de");
63
+ const msg = t("bot.cancel.cancelling");
64
+ expect(msg).toContain("abgebrochen");
65
+ });
66
+ });
67
+
68
+ describe("t() — interpolation", () => {
69
+ it("substitutes a single {var} placeholder", () => {
70
+ const msg = t("bot.error.timeoutStuck", "en", { min: 10 });
71
+ expect(msg).toContain("10 minutes");
72
+ });
73
+
74
+ it("substitutes multiple {var} placeholders", () => {
75
+ const msg = t("bot.error.midStream", "en", {
76
+ name: "claude-sdk",
77
+ detail: "connection reset",
78
+ });
79
+ expect(msg).toContain("claude-sdk");
80
+ expect(msg).toContain("connection reset");
81
+ });
82
+
83
+ it("interpolation works in all four locales", () => {
84
+ const vars = { min: 7 };
85
+ expect(t("bot.error.timeoutStuck", "en", vars)).toContain("7");
86
+ expect(t("bot.error.timeoutStuck", "de", vars)).toContain("7");
87
+ expect(t("bot.error.timeoutStuck", "es", vars)).toContain("7");
88
+ expect(t("bot.error.timeoutStuck", "fr", vars)).toContain("7");
89
+ });
90
+
91
+ it("leaves {placeholder} visible if the var is not provided", () => {
92
+ const msg = t("bot.error.timeoutStuck", "en", {});
93
+ expect(msg).toContain("{min}");
94
+ });
95
+ });
96
+
97
+ describe("initI18n / setLocale / getLocale", () => {
98
+ it("initI18n with explicit locale sets currentLocale", () => {
99
+ initI18n("fr");
100
+ expect(getLocale()).toBe("fr");
101
+ });
102
+
103
+ it("setLocale updates the global locale", () => {
104
+ setLocale("es");
105
+ expect(getLocale()).toBe("es");
106
+ });
107
+ });
108
+ });
@@ -0,0 +1,201 @@
1
+ import { describe, it, expect, vi } from "vitest";
2
+ import { ProviderRegistry } from "../src/providers/registry.js";
3
+ import type { Provider, QueryOptions, StreamChunk } from "../src/providers/types.js";
4
+
5
+ /**
6
+ * Registry tests — focused on the queryWithFallback behaviour, which is
7
+ * the core of today's reliability work:
8
+ * 1. Silent retry on mid-stream transient aborts
9
+ * 2. No mid-stream failover after visible text has already streamed
10
+ * 3. Lifecycle boot on asleep providers
11
+ * 4. Fallback chain traversal when providers are unavailable
12
+ */
13
+
14
+ // Mock provider factory — lets each test craft the exact chunk sequence
15
+ function createMockProvider(opts: {
16
+ key?: string;
17
+ available?: boolean;
18
+ chunks: StreamChunk[];
19
+ attempts?: number[]; // per-attempt chunk sets (for retry tests)
20
+ }): Provider {
21
+ let attemptIndex = 0;
22
+ return {
23
+ config: {
24
+ type: "openai-compatible",
25
+ name: opts.key || "mock",
26
+ model: "mock-model",
27
+ },
28
+ isAvailable: async () => opts.available ?? true,
29
+ getInfo: () => ({
30
+ name: opts.key || "mock",
31
+ model: "mock-model",
32
+ status: "mock",
33
+ }),
34
+ async *query(_options: QueryOptions): AsyncGenerator<StreamChunk> {
35
+ // If per-attempt sequences provided, use them in order
36
+ if (opts.attempts && opts.attempts.length > 0) {
37
+ const idx = Math.min(attemptIndex, opts.attempts.length - 1);
38
+ const count = opts.attempts[idx];
39
+ attemptIndex++;
40
+ for (let i = 0; i < count; i++) {
41
+ yield opts.chunks[i];
42
+ }
43
+ } else {
44
+ for (const c of opts.chunks) yield c;
45
+ }
46
+ },
47
+ };
48
+ }
49
+
50
+ describe("ProviderRegistry.queryWithFallback", () => {
51
+ it("yields chunks from the active provider on a happy path", async () => {
52
+ const provider = createMockProvider({
53
+ chunks: [
54
+ { type: "text", text: "hello world" },
55
+ { type: "done", text: "hello world", inputTokens: 10, outputTokens: 5 },
56
+ ],
57
+ });
58
+
59
+ const registry = new ProviderRegistry({
60
+ primary: "mock",
61
+ fallbacks: [],
62
+ providers: { mock: provider.config },
63
+ });
64
+ // Manually wire the mock provider — createProvider dispatches by type,
65
+ // which would create a real OpenAICompatibleProvider. We inject directly.
66
+ (registry as unknown as { providers: Map<string, Provider> }).providers.set("mock", provider);
67
+
68
+ const chunks: StreamChunk[] = [];
69
+ for await (const c of registry.queryWithFallback({ prompt: "hi" })) {
70
+ chunks.push(c);
71
+ }
72
+ expect(chunks.length).toBe(2);
73
+ expect(chunks[0]?.type).toBe("text");
74
+ expect(chunks[1]?.type).toBe("done");
75
+ });
76
+
77
+ it("falls back to the next provider when the active one has no visible text and errors", async () => {
78
+ const primary = createMockProvider({
79
+ chunks: [{ type: "error", error: "rate limit" }],
80
+ });
81
+ const fallback = createMockProvider({
82
+ chunks: [
83
+ { type: "text", text: "fallback answer" },
84
+ { type: "done", text: "fallback answer" },
85
+ ],
86
+ });
87
+
88
+ const registry = new ProviderRegistry({
89
+ primary: "primary",
90
+ fallbacks: ["fallback"],
91
+ providers: {
92
+ primary: primary.config,
93
+ fallback: fallback.config,
94
+ },
95
+ });
96
+ const internal = (registry as unknown as { providers: Map<string, Provider> }).providers;
97
+ internal.set("primary", primary);
98
+ internal.set("fallback", fallback);
99
+
100
+ const chunks: StreamChunk[] = [];
101
+ for await (const c of registry.queryWithFallback({ prompt: "hi" })) {
102
+ chunks.push(c);
103
+ }
104
+
105
+ // Expect: fallback chunk (switching notification), then text + done from fallback
106
+ const types = chunks.map((c) => c.type);
107
+ expect(types).toContain("fallback");
108
+ expect(types).toContain("text");
109
+ expect(types).toContain("done");
110
+ });
111
+
112
+ it("surfaces a terminal error (no fallback) when the active provider fails mid-stream", async () => {
113
+ const primary = createMockProvider({
114
+ chunks: [
115
+ { type: "text", text: "I'm starting the an" },
116
+ { type: "error", error: "Request aborted" },
117
+ ],
118
+ });
119
+ const fallback = createMockProvider({
120
+ chunks: [
121
+ { type: "text", text: "different answer" },
122
+ { type: "done", text: "different answer" },
123
+ ],
124
+ });
125
+
126
+ const registry = new ProviderRegistry({
127
+ primary: "primary",
128
+ fallbacks: ["fallback"],
129
+ providers: {
130
+ primary: primary.config,
131
+ fallback: fallback.config,
132
+ },
133
+ });
134
+ const internal = (registry as unknown as { providers: Map<string, Provider> }).providers;
135
+ internal.set("primary", primary);
136
+ internal.set("fallback", fallback);
137
+
138
+ const chunks: StreamChunk[] = [];
139
+ for await (const c of registry.queryWithFallback({ prompt: "hi" })) {
140
+ chunks.push(c);
141
+ }
142
+
143
+ // We SHOULD get the first text chunk (visible text)
144
+ // We SHOULD NOT get any fallback-provider chunks
145
+ // We SHOULD get a final error chunk with the "mid-stream" message
146
+ const texts = chunks.filter((c) => c.type === "text");
147
+ expect(texts.length).toBeGreaterThanOrEqual(1);
148
+ expect(texts.some((c) => c.text?.includes("different answer"))).toBe(false);
149
+
150
+ const errors = chunks.filter((c) => c.type === "error");
151
+ expect(errors.length).toBe(1);
152
+ // Mid-stream message is localised, just check it mentions the provider name
153
+ expect(errors[0]?.error).toContain("mock");
154
+ });
155
+
156
+ it("retries the SAME provider on mid-stream abort before giving up", async () => {
157
+ // First attempt: emits text then aborts mid-stream
158
+ // Second attempt: emits text and completes successfully
159
+ const querySpy = vi.fn();
160
+ let attemptCount = 0;
161
+ const provider: Provider = {
162
+ config: {
163
+ type: "openai-compatible",
164
+ name: "retry-test",
165
+ model: "m",
166
+ },
167
+ isAvailable: async () => true,
168
+ getInfo: () => ({ name: "retry-test", model: "m", status: "ok" }),
169
+ async *query() {
170
+ querySpy();
171
+ attemptCount++;
172
+ if (attemptCount === 1) {
173
+ yield { type: "text", text: "first partial" } as StreamChunk;
174
+ yield { type: "error", error: "Request aborted" } as StreamChunk;
175
+ } else {
176
+ yield { type: "text", text: "retry success" } as StreamChunk;
177
+ yield { type: "done", text: "retry success" } as StreamChunk;
178
+ }
179
+ },
180
+ };
181
+
182
+ const registry = new ProviderRegistry({
183
+ primary: "retry-test",
184
+ fallbacks: [],
185
+ providers: { "retry-test": provider.config },
186
+ });
187
+ (registry as unknown as { providers: Map<string, Provider> }).providers.set("retry-test", provider);
188
+
189
+ const chunks: StreamChunk[] = [];
190
+ for await (const c of registry.queryWithFallback({ prompt: "hi" })) {
191
+ chunks.push(c);
192
+ }
193
+
194
+ // query() should have been called twice — original attempt + 1 retry
195
+ expect(querySpy).toHaveBeenCalledTimes(2);
196
+ // The final done chunk should reflect the retry's success
197
+ const done = chunks.find((c) => c.type === "done");
198
+ expect(done).toBeDefined();
199
+ expect(done?.text).toBe("retry success");
200
+ }, 15_000); // allow for the 2s retry delay
201
+ });
@@ -0,0 +1,273 @@
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-delivery-${process.pid}-${Date.now()}`);
8
+
9
+ const sentMessages: Array<{ chatId: number; text: string }> = [];
10
+ const sentDocuments: Array<{ chatId: number }> = [];
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
+ sentMessages.length = 0;
17
+ sentDocuments.length = 0;
18
+ vi.resetModules();
19
+ });
20
+
21
+ async function wireFakeApi() {
22
+ const mod = await import("../src/services/subagent-delivery.js");
23
+ mod.__setBotApiForTest({
24
+ sendMessage: async (chatId: number, text: string) => {
25
+ sentMessages.push({ chatId, text });
26
+ return {};
27
+ },
28
+ sendDocument: async (chatId: number) => {
29
+ sentDocuments.push({ chatId });
30
+ return {};
31
+ },
32
+ });
33
+ return mod;
34
+ }
35
+
36
+ describe("subagent-delivery (I3)", () => {
37
+ it("does nothing for source='implicit' (parent-stream handles it)", async () => {
38
+ const mod = await wireFakeApi();
39
+
40
+ const info: SubAgentInfo = {
41
+ id: "x",
42
+ name: "impl",
43
+ status: "completed",
44
+ startedAt: Date.now() - 1000,
45
+ source: "implicit",
46
+ depth: 0,
47
+ parentChatId: 123,
48
+ };
49
+ const result: SubAgentResult = {
50
+ id: "x",
51
+ name: "impl",
52
+ status: "completed",
53
+ output: "anything",
54
+ tokensUsed: { input: 10, output: 5 },
55
+ duration: 1000,
56
+ };
57
+
58
+ await mod.deliverSubAgentResult(info, result);
59
+ expect(sentMessages).toHaveLength(0);
60
+ expect(sentDocuments).toHaveLength(0);
61
+ });
62
+
63
+ it("sends banner+final to parentChatId for source='user'", async () => {
64
+ const mod = await wireFakeApi();
65
+
66
+ const info: SubAgentInfo = {
67
+ id: "u",
68
+ name: "code-review",
69
+ status: "completed",
70
+ startedAt: Date.now() - 192000,
71
+ source: "user",
72
+ depth: 0,
73
+ parentChatId: 555,
74
+ };
75
+ const result: SubAgentResult = {
76
+ id: "u",
77
+ name: "code-review",
78
+ status: "completed",
79
+ output: "Found 2 issues:\n1. bug\n2. nit",
80
+ tokensUsed: { input: 4200, output: 2100 },
81
+ duration: 192000,
82
+ };
83
+
84
+ await mod.deliverSubAgentResult(info, result);
85
+ expect(sentMessages.length).toBeGreaterThanOrEqual(1);
86
+ const all = sentMessages.map((m) => m.text).join("\n");
87
+ expect(sentMessages[0].chatId).toBe(555);
88
+ expect(all).toContain("code-review");
89
+ expect(all).toContain("4.2k"); // token formatting
90
+ expect(all).toContain("2.1k");
91
+ expect(all).toContain("Found 2 issues");
92
+ });
93
+
94
+ it("splits long output into chunks (>3800 chars)", async () => {
95
+ const mod = await wireFakeApi();
96
+
97
+ const info: SubAgentInfo = {
98
+ id: "c",
99
+ name: "long",
100
+ status: "completed",
101
+ startedAt: Date.now() - 1000,
102
+ source: "user",
103
+ depth: 0,
104
+ parentChatId: 1,
105
+ };
106
+ const result: SubAgentResult = {
107
+ id: "c",
108
+ name: "long",
109
+ status: "completed",
110
+ output: "x".repeat(9000),
111
+ tokensUsed: { input: 0, output: 9000 },
112
+ duration: 1000,
113
+ };
114
+
115
+ await mod.deliverSubAgentResult(info, result);
116
+ // Expect: 1 banner + 3 content chunks (9000 / 3800 = 3 chunks)
117
+ expect(sentMessages.length).toBeGreaterThanOrEqual(3);
118
+ });
119
+
120
+ it("silent visibility produces no delivery", async () => {
121
+ const mod = await wireFakeApi();
122
+
123
+ const info: SubAgentInfo = {
124
+ id: "s",
125
+ name: "silent-job",
126
+ status: "completed",
127
+ startedAt: Date.now() - 1000,
128
+ source: "user",
129
+ depth: 0,
130
+ parentChatId: 1,
131
+ };
132
+ const result: SubAgentResult = {
133
+ id: "s",
134
+ name: "silent-job",
135
+ status: "completed",
136
+ output: "hello",
137
+ tokensUsed: { input: 1, output: 1 },
138
+ duration: 1000,
139
+ };
140
+
141
+ await mod.deliverSubAgentResult(info, result, { visibility: "silent" });
142
+ expect(sentMessages).toHaveLength(0);
143
+ });
144
+
145
+ it("missing parentChatId logs but does not throw", async () => {
146
+ const mod = await wireFakeApi();
147
+
148
+ const info: SubAgentInfo = {
149
+ id: "noparent",
150
+ name: "orphan",
151
+ status: "completed",
152
+ startedAt: Date.now() - 1000,
153
+ source: "user",
154
+ depth: 0,
155
+ // no parentChatId
156
+ };
157
+ const result: SubAgentResult = {
158
+ id: "noparent",
159
+ name: "orphan",
160
+ status: "completed",
161
+ output: "hi",
162
+ tokensUsed: { input: 0, output: 0 },
163
+ duration: 1000,
164
+ };
165
+
166
+ await expect(mod.deliverSubAgentResult(info, result)).resolves.toBeUndefined();
167
+ expect(sentMessages).toHaveLength(0);
168
+ });
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
+ });