alvin-bot 4.18.0 → 4.18.2
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/AEC-PLUGINS-SOURCES.md +53 -0
- package/CHANGELOG.md +37 -2
- package/DESIGN-SKILLS-SOURCES.md +81 -0
- package/bin/cli.js +1 -1
- package/dist/providers/claude-sdk-provider.js +24 -0
- package/package.json +3 -1
- package/test/allowed-users-gate.test.ts +0 -98
- package/test/alvin-dispatch.test.ts +0 -220
- package/test/async-agent-chunk-flow.test.ts +0 -244
- package/test/async-agent-parser-staleness.test.ts +0 -412
- package/test/async-agent-parser-streamjson.test.ts +0 -273
- package/test/async-agent-parser.test.ts +0 -322
- package/test/async-agent-watcher.test.ts +0 -229
- package/test/background-bypass-integration.test.ts +0 -443
- package/test/background-bypass-stress.test.ts +0 -417
- package/test/background-bypass.test.ts +0 -127
- package/test/browser-webfetch.test.ts +0 -121
- package/test/claude-sdk-provider.test.ts +0 -115
- package/test/claude-sdk-tool-use-id.test.ts +0 -180
- package/test/console-timestamps.test.ts +0 -98
- package/test/cron-progress-ticker.test.ts +0 -76
- package/test/cron-restart-resilience.test.ts +0 -191
- package/test/cron-run-resolver.test.ts +0 -133
- package/test/cron-runjobnow-throw.test.ts +0 -100
- package/test/debounce.test.ts +0 -60
- package/test/delivery-registry.test.ts +0 -71
- package/test/exec-guard-metachars.test.ts +0 -110
- package/test/file-permissions.test.ts +0 -130
- package/test/i18n.test.ts +0 -108
- package/test/list-subagents-merged.test.ts +0 -172
- package/test/memory-extractor.test.ts +0 -151
- package/test/memory-layers.test.ts +0 -169
- package/test/memory-sdk-injection.test.ts +0 -146
- package/test/memory-stress-restart.test.ts +0 -337
- package/test/multi-session-stress.test.ts +0 -255
- package/test/platform-session-key.test.ts +0 -69
- package/test/process-manager.test.ts +0 -186
- package/test/registry.test.ts +0 -201
- package/test/session-pending-background.test.ts +0 -59
- package/test/session-persistence.test.ts +0 -195
- package/test/slack-progress-ticker.test.ts +0 -123
- package/test/slack-slash-command.test.ts +0 -61
- package/test/slack-test-connection.test.ts +0 -176
- package/test/stress-scenarios.test.ts +0 -356
- package/test/stuck-timer.test.ts +0 -116
- package/test/subagent-delivery-markdown-fallback.test.ts +0 -147
- package/test/subagent-delivery-platform-routing.test.ts +0 -232
- package/test/subagent-delivery.test.ts +0 -273
- package/test/subagent-final-text.test.ts +0 -132
- package/test/subagent-stats.test.ts +0 -119
- package/test/subagent-toolset-allowlist.test.ts +0 -146
- package/test/subagents-commands.test.ts +0 -64
- package/test/subagents-config.test.ts +0 -114
- package/test/subagents-depth.test.ts +0 -58
- package/test/subagents-inheritance.test.ts +0 -67
- package/test/subagents-name-resolver.test.ts +0 -122
- package/test/subagents-priority-reject.test.ts +0 -88
- package/test/subagents-queue.test.ts +0 -127
- package/test/subagents-shutdown.test.ts +0 -126
- package/test/subagents-toolset.test.ts +0 -71
- package/test/sync-task-timeout.test.ts +0 -153
- package/test/system-prompt-background-hint.test.ts +0 -65
- package/test/telegram-error-filter.test.ts +0 -85
- package/test/telegram-workspace-command.test.ts +0 -78
- package/test/timing-safe-bearer.test.ts +0 -65
- package/test/watchdog-brake.test.ts +0 -157
- package/test/watcher-pending-count.test.ts +0 -228
- package/test/watcher-zombie-fix.test.ts +0 -252
- package/test/web-server-integration.test.ts +0 -189
- package/test/web-server-resilience.test.ts +0 -118
- package/test/web-server-shutdown.test.ts +0 -117
- package/test/whatsapp-auth-resilience.test.ts +0 -96
- package/test/workspaces.test.ts +0 -196
- package/vitest.config.ts +0 -17
|
@@ -1,147 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Fix #15 (A) — subagent-delivery must retry without parse_mode when
|
|
3
|
-
* Telegram rejects the Markdown entities.
|
|
4
|
-
*
|
|
5
|
-
* Real regression: Daily Job Alert banners have been silently failing
|
|
6
|
-
* with "Bad Request: can't parse entities: Can't find end of the entity"
|
|
7
|
-
* every single day since the subagent-delivery module shipped. The
|
|
8
|
-
* result text contains mixed `|`, `**`, `\|`, emoji, and asterisks that
|
|
9
|
-
* Telegram's Markdown parser chokes on. The code currently logs the
|
|
10
|
-
* error and drops the delivery, so the user never sees the banner.
|
|
11
|
-
*
|
|
12
|
-
* Contract: when `sendMessage(..., parse_mode: Markdown)` throws with
|
|
13
|
-
* the "can't parse entities" pattern, retry the SAME text WITHOUT
|
|
14
|
-
* `parse_mode`. Any other error still logs + bails.
|
|
15
|
-
*
|
|
16
|
-
* This file uses a minimal bot-api stub so we can drive both the happy
|
|
17
|
-
* path and the parse-error path deterministically.
|
|
18
|
-
*/
|
|
19
|
-
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
20
|
-
import { deliverSubAgentResult, __setBotApiForTest } from "../src/services/subagent-delivery.js";
|
|
21
|
-
import type { SubAgentInfo, SubAgentResult } from "../src/services/subagents.js";
|
|
22
|
-
|
|
23
|
-
interface Sent {
|
|
24
|
-
chatId: number;
|
|
25
|
-
text: string;
|
|
26
|
-
parseMode?: string;
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
function makeInfo(overrides: Partial<SubAgentInfo> = {}): SubAgentInfo {
|
|
30
|
-
return {
|
|
31
|
-
id: "id-1",
|
|
32
|
-
name: "Daily Job Alert",
|
|
33
|
-
status: "completed",
|
|
34
|
-
startedAt: 0,
|
|
35
|
-
depth: 0,
|
|
36
|
-
source: "cron",
|
|
37
|
-
parentChatId: 42,
|
|
38
|
-
...overrides,
|
|
39
|
-
};
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
function makeResult(output: string): SubAgentResult {
|
|
43
|
-
return {
|
|
44
|
-
id: "id-1",
|
|
45
|
-
name: "Daily Job Alert",
|
|
46
|
-
status: "completed",
|
|
47
|
-
output,
|
|
48
|
-
tokensUsed: { input: 1000, output: 200 },
|
|
49
|
-
duration: 60_000,
|
|
50
|
-
};
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
beforeEach(() => {
|
|
54
|
-
__setBotApiForTest(null);
|
|
55
|
-
});
|
|
56
|
-
|
|
57
|
-
describe("deliverSubAgentResult Markdown fallback (Fix #15)", () => {
|
|
58
|
-
it("retries without parse_mode when Telegram rejects entity parsing", async () => {
|
|
59
|
-
const sent: Sent[] = [];
|
|
60
|
-
let callCount = 0;
|
|
61
|
-
|
|
62
|
-
__setBotApiForTest({
|
|
63
|
-
sendMessage: async (chatId: number, text: string, opts?: Record<string, unknown>) => {
|
|
64
|
-
callCount++;
|
|
65
|
-
const parseMode = opts?.parse_mode as string | undefined;
|
|
66
|
-
// First call (Markdown) throws the real production error
|
|
67
|
-
if (callCount === 1 && parseMode === "Markdown") {
|
|
68
|
-
const err = Object.assign(
|
|
69
|
-
new Error("Call to 'sendMessage' failed! (400: Bad Request: can't parse entities: Can't find end of the entity starting at byte offset 2636)"),
|
|
70
|
-
{
|
|
71
|
-
description: "Bad Request: can't parse entities: Can't find end of the entity starting at byte offset 2636",
|
|
72
|
-
error_code: 400,
|
|
73
|
-
},
|
|
74
|
-
);
|
|
75
|
-
throw err;
|
|
76
|
-
}
|
|
77
|
-
sent.push({ chatId, text, parseMode });
|
|
78
|
-
return { message_id: 1 };
|
|
79
|
-
},
|
|
80
|
-
sendDocument: async () => ({}),
|
|
81
|
-
});
|
|
82
|
-
|
|
83
|
-
const info = makeInfo();
|
|
84
|
-
const result = makeResult("This **has** | broken markdown \\| entities that fail Markdown parsing");
|
|
85
|
-
|
|
86
|
-
await deliverSubAgentResult(info, result);
|
|
87
|
-
|
|
88
|
-
// Must have retried at least once WITHOUT parse_mode
|
|
89
|
-
const plainAttempt = sent.find((s) => s.parseMode === undefined);
|
|
90
|
-
expect(plainAttempt).toBeDefined();
|
|
91
|
-
expect(plainAttempt?.text).toContain("Daily Job Alert");
|
|
92
|
-
expect(plainAttempt?.text).toContain("broken markdown");
|
|
93
|
-
});
|
|
94
|
-
|
|
95
|
-
it("does NOT retry for non-parse errors (e.g. chat not found)", async () => {
|
|
96
|
-
let callCount = 0;
|
|
97
|
-
__setBotApiForTest({
|
|
98
|
-
sendMessage: async () => {
|
|
99
|
-
callCount++;
|
|
100
|
-
const err = Object.assign(new Error("Forbidden: bot was blocked by the user"), {
|
|
101
|
-
description: "Forbidden: bot was blocked by the user",
|
|
102
|
-
error_code: 403,
|
|
103
|
-
});
|
|
104
|
-
throw err;
|
|
105
|
-
},
|
|
106
|
-
sendDocument: async () => ({}),
|
|
107
|
-
});
|
|
108
|
-
|
|
109
|
-
await deliverSubAgentResult(makeInfo(), makeResult("some text"));
|
|
110
|
-
|
|
111
|
-
// Should have tried once and given up — no retry
|
|
112
|
-
expect(callCount).toBe(1);
|
|
113
|
-
});
|
|
114
|
-
|
|
115
|
-
it("chunked delivery also retries without parse_mode on parse errors", async () => {
|
|
116
|
-
const sent: Sent[] = [];
|
|
117
|
-
let callCount = 0;
|
|
118
|
-
|
|
119
|
-
__setBotApiForTest({
|
|
120
|
-
sendMessage: async (chatId: number, text: string, opts?: Record<string, unknown>) => {
|
|
121
|
-
callCount++;
|
|
122
|
-
const parseMode = opts?.parse_mode as string | undefined;
|
|
123
|
-
// First banner attempt fails — should retry without parse_mode
|
|
124
|
-
if (callCount === 1 && parseMode === "Markdown") {
|
|
125
|
-
const err = Object.assign(
|
|
126
|
-
new Error("400: Bad Request: can't parse entities"),
|
|
127
|
-
{ description: "can't parse entities", error_code: 400 },
|
|
128
|
-
);
|
|
129
|
-
throw err;
|
|
130
|
-
}
|
|
131
|
-
sent.push({ chatId, text, parseMode });
|
|
132
|
-
return { message_id: callCount };
|
|
133
|
-
},
|
|
134
|
-
sendDocument: async () => ({}),
|
|
135
|
-
});
|
|
136
|
-
|
|
137
|
-
const info = makeInfo();
|
|
138
|
-
// Large body forces the chunked path
|
|
139
|
-
const result = makeResult("x".repeat(5000));
|
|
140
|
-
|
|
141
|
-
await deliverSubAgentResult(info, result);
|
|
142
|
-
|
|
143
|
-
// At least one plain-text delivery must have landed
|
|
144
|
-
expect(sent.length).toBeGreaterThan(0);
|
|
145
|
-
expect(sent.some((s) => s.parseMode === undefined)).toBe(true);
|
|
146
|
-
});
|
|
147
|
-
});
|
|
@@ -1,232 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* v4.14 — subagent-delivery platform routing tests.
|
|
3
|
-
*
|
|
4
|
-
* Covers the new v4.14 behavior: deliveries with `info.platform` other
|
|
5
|
-
* than "telegram" go through the delivery-registry adapter instead of
|
|
6
|
-
* the grammy bot API. Telegram path is unchanged and still uses the
|
|
7
|
-
* injected grammy-compatible API.
|
|
8
|
-
*/
|
|
9
|
-
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
|
10
|
-
|
|
11
|
-
interface CapturedMsg {
|
|
12
|
-
chatId: string | number;
|
|
13
|
-
text: string;
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
beforeEach(() => vi.resetModules());
|
|
17
|
-
|
|
18
|
-
async function loadModules() {
|
|
19
|
-
const delivery = await import("../src/services/subagent-delivery.js");
|
|
20
|
-
const registry = await import("../src/services/delivery-registry.js");
|
|
21
|
-
registry.__resetForTest();
|
|
22
|
-
return { delivery, registry };
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
describe("subagent-delivery platform routing (v4.14)", () => {
|
|
26
|
-
afterEach(async () => {
|
|
27
|
-
const { delivery, registry } = await loadModules();
|
|
28
|
-
delivery.__setBotApiForTest(null);
|
|
29
|
-
registry.__resetForTest();
|
|
30
|
-
});
|
|
31
|
-
|
|
32
|
-
it("info.platform='slack' routes via delivery-registry (NOT grammy api)", async () => {
|
|
33
|
-
const { delivery, registry } = await loadModules();
|
|
34
|
-
|
|
35
|
-
// Register fake Slack adapter
|
|
36
|
-
const sent: CapturedMsg[] = [];
|
|
37
|
-
registry.registerDeliveryAdapter({
|
|
38
|
-
platform: "slack",
|
|
39
|
-
sendText: async (chatId, text) => {
|
|
40
|
-
sent.push({ chatId, text });
|
|
41
|
-
},
|
|
42
|
-
});
|
|
43
|
-
|
|
44
|
-
// Set a grammy api that SHOULD NOT be called
|
|
45
|
-
const grammyCalls: CapturedMsg[] = [];
|
|
46
|
-
delivery.__setBotApiForTest({
|
|
47
|
-
sendMessage: async (chatId: number, text: string) => {
|
|
48
|
-
grammyCalls.push({ chatId, text });
|
|
49
|
-
return { message_id: 1 };
|
|
50
|
-
},
|
|
51
|
-
sendDocument: async () => ({ message_id: 1 }),
|
|
52
|
-
});
|
|
53
|
-
|
|
54
|
-
await delivery.deliverSubAgentResult(
|
|
55
|
-
{
|
|
56
|
-
id: "a1",
|
|
57
|
-
name: "Research task",
|
|
58
|
-
status: "completed",
|
|
59
|
-
startedAt: Date.now() - 5000,
|
|
60
|
-
source: "cron",
|
|
61
|
-
depth: 0,
|
|
62
|
-
parentChatId: "C012SLACKCH",
|
|
63
|
-
platform: "slack",
|
|
64
|
-
},
|
|
65
|
-
{
|
|
66
|
-
id: "a1",
|
|
67
|
-
name: "Research task",
|
|
68
|
-
status: "completed",
|
|
69
|
-
output: "Result body",
|
|
70
|
-
tokensUsed: { input: 100, output: 50 },
|
|
71
|
-
duration: 5000,
|
|
72
|
-
},
|
|
73
|
-
);
|
|
74
|
-
|
|
75
|
-
expect(sent).toHaveLength(1);
|
|
76
|
-
expect(sent[0].chatId).toBe("C012SLACKCH");
|
|
77
|
-
expect(sent[0].text).toContain("Research task");
|
|
78
|
-
expect(sent[0].text).toContain("Result body");
|
|
79
|
-
// grammy must NOT have been touched
|
|
80
|
-
expect(grammyCalls).toHaveLength(0);
|
|
81
|
-
});
|
|
82
|
-
|
|
83
|
-
it("info.platform='telegram' (default) still uses grammy api — behavior unchanged", async () => {
|
|
84
|
-
const { delivery, registry } = await loadModules();
|
|
85
|
-
|
|
86
|
-
// Register Slack adapter that SHOULD NOT be called
|
|
87
|
-
const slackCalls: CapturedMsg[] = [];
|
|
88
|
-
registry.registerDeliveryAdapter({
|
|
89
|
-
platform: "slack",
|
|
90
|
-
sendText: async (chatId, text) => slackCalls.push({ chatId, text }),
|
|
91
|
-
});
|
|
92
|
-
|
|
93
|
-
const grammyCalls: CapturedMsg[] = [];
|
|
94
|
-
delivery.__setBotApiForTest({
|
|
95
|
-
sendMessage: async (chatId: number, text: string) => {
|
|
96
|
-
grammyCalls.push({ chatId, text });
|
|
97
|
-
return { message_id: 1 };
|
|
98
|
-
},
|
|
99
|
-
sendDocument: async () => ({ message_id: 1 }),
|
|
100
|
-
});
|
|
101
|
-
|
|
102
|
-
await delivery.deliverSubAgentResult(
|
|
103
|
-
{
|
|
104
|
-
id: "a2",
|
|
105
|
-
name: "Telegram task",
|
|
106
|
-
status: "completed",
|
|
107
|
-
startedAt: Date.now() - 3000,
|
|
108
|
-
source: "cron",
|
|
109
|
-
depth: 0,
|
|
110
|
-
parentChatId: 1234567890,
|
|
111
|
-
// platform undefined → defaults to telegram
|
|
112
|
-
},
|
|
113
|
-
{
|
|
114
|
-
id: "a2",
|
|
115
|
-
name: "Telegram task",
|
|
116
|
-
status: "completed",
|
|
117
|
-
output: "Telegram body",
|
|
118
|
-
tokensUsed: { input: 10, output: 5 },
|
|
119
|
-
duration: 3000,
|
|
120
|
-
},
|
|
121
|
-
);
|
|
122
|
-
|
|
123
|
-
expect(grammyCalls).toHaveLength(1);
|
|
124
|
-
expect(grammyCalls[0].chatId).toBe(1234567890);
|
|
125
|
-
expect(grammyCalls[0].text).toContain("Telegram body");
|
|
126
|
-
// Slack adapter must NOT have been touched
|
|
127
|
-
expect(slackCalls).toHaveLength(0);
|
|
128
|
-
});
|
|
129
|
-
|
|
130
|
-
it("info.platform='discord' routes to discord adapter", async () => {
|
|
131
|
-
const { delivery, registry } = await loadModules();
|
|
132
|
-
|
|
133
|
-
const discordCalls: CapturedMsg[] = [];
|
|
134
|
-
registry.registerDeliveryAdapter({
|
|
135
|
-
platform: "discord",
|
|
136
|
-
sendText: async (chatId, text) =>
|
|
137
|
-
discordCalls.push({ chatId, text }),
|
|
138
|
-
});
|
|
139
|
-
|
|
140
|
-
await delivery.deliverSubAgentResult(
|
|
141
|
-
{
|
|
142
|
-
id: "a3",
|
|
143
|
-
name: "Discord task",
|
|
144
|
-
status: "completed",
|
|
145
|
-
startedAt: Date.now() - 1000,
|
|
146
|
-
source: "cron",
|
|
147
|
-
depth: 0,
|
|
148
|
-
parentChatId: "1234567890123456",
|
|
149
|
-
platform: "discord",
|
|
150
|
-
},
|
|
151
|
-
{
|
|
152
|
-
id: "a3",
|
|
153
|
-
name: "Discord task",
|
|
154
|
-
status: "completed",
|
|
155
|
-
output: "Discord body",
|
|
156
|
-
tokensUsed: { input: 1, output: 1 },
|
|
157
|
-
duration: 1000,
|
|
158
|
-
},
|
|
159
|
-
);
|
|
160
|
-
|
|
161
|
-
expect(discordCalls).toHaveLength(1);
|
|
162
|
-
expect(discordCalls[0].chatId).toBe("1234567890123456");
|
|
163
|
-
});
|
|
164
|
-
|
|
165
|
-
it("non-telegram platform with NO registered adapter skips delivery (no crash)", async () => {
|
|
166
|
-
const { delivery } = await loadModules();
|
|
167
|
-
|
|
168
|
-
await expect(
|
|
169
|
-
delivery.deliverSubAgentResult(
|
|
170
|
-
{
|
|
171
|
-
id: "a4",
|
|
172
|
-
name: "Orphan",
|
|
173
|
-
status: "completed",
|
|
174
|
-
startedAt: Date.now(),
|
|
175
|
-
source: "cron",
|
|
176
|
-
depth: 0,
|
|
177
|
-
parentChatId: "C999",
|
|
178
|
-
platform: "slack",
|
|
179
|
-
},
|
|
180
|
-
{
|
|
181
|
-
id: "a4",
|
|
182
|
-
name: "Orphan",
|
|
183
|
-
status: "completed",
|
|
184
|
-
output: "x",
|
|
185
|
-
tokensUsed: { input: 1, output: 1 },
|
|
186
|
-
duration: 100,
|
|
187
|
-
},
|
|
188
|
-
),
|
|
189
|
-
).resolves.not.toThrow();
|
|
190
|
-
});
|
|
191
|
-
|
|
192
|
-
it("long output triggers chunking on non-Telegram adapter", async () => {
|
|
193
|
-
const { delivery, registry } = await loadModules();
|
|
194
|
-
|
|
195
|
-
const sent: string[] = [];
|
|
196
|
-
registry.registerDeliveryAdapter({
|
|
197
|
-
platform: "slack",
|
|
198
|
-
sendText: async (_chatId, text) => {
|
|
199
|
-
sent.push(text);
|
|
200
|
-
},
|
|
201
|
-
});
|
|
202
|
-
|
|
203
|
-
// Build ~8000 chars of output (forces chunking at 3800)
|
|
204
|
-
const longBody = "x".repeat(8000);
|
|
205
|
-
|
|
206
|
-
await delivery.deliverSubAgentResult(
|
|
207
|
-
{
|
|
208
|
-
id: "a5",
|
|
209
|
-
name: "Long task",
|
|
210
|
-
status: "completed",
|
|
211
|
-
startedAt: Date.now(),
|
|
212
|
-
source: "cron",
|
|
213
|
-
depth: 0,
|
|
214
|
-
parentChatId: "C1",
|
|
215
|
-
platform: "slack",
|
|
216
|
-
},
|
|
217
|
-
{
|
|
218
|
-
id: "a5",
|
|
219
|
-
name: "Long task",
|
|
220
|
-
status: "completed",
|
|
221
|
-
output: longBody,
|
|
222
|
-
tokensUsed: { input: 1, output: 1 },
|
|
223
|
-
duration: 100,
|
|
224
|
-
},
|
|
225
|
-
);
|
|
226
|
-
|
|
227
|
-
// Expect: 1 banner + multiple body chunks
|
|
228
|
-
expect(sent.length).toBeGreaterThan(1);
|
|
229
|
-
const bodyBytes = sent.slice(1).join("").length;
|
|
230
|
-
expect(bodyBytes).toBe(longBody.length);
|
|
231
|
-
});
|
|
232
|
-
});
|
|
@@ -1,273 +0,0 @@
|
|
|
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
|
-
});
|