alvin-bot 4.13.2 → 4.14.1
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 +95 -0
- package/dist/handlers/commands.js +6 -3
- package/dist/handlers/platform-message.js +12 -0
- package/dist/platforms/discord.js +14 -0
- package/dist/platforms/slack.js +15 -0
- package/dist/platforms/whatsapp.js +14 -0
- package/dist/services/alvin-dispatch.js +1 -0
- package/dist/services/alvin-mcp-tools.js +1 -0
- package/dist/services/async-agent-watcher.js +3 -0
- package/dist/services/delivery-registry.js +21 -0
- package/dist/services/subagent-delivery.js +99 -13
- package/dist/services/subagents.js +59 -0
- package/package.json +1 -1
- package/test/delivery-registry.test.ts +71 -0
- package/test/list-subagents-merged.test.ts +172 -0
- package/test/subagent-delivery-platform-routing.test.ts +232 -0
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,101 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to Alvin Bot are documented here.
|
|
4
4
|
|
|
5
|
+
## [4.14.1] — 2026-04-16
|
|
6
|
+
|
|
7
|
+
### 🐛 Patch: `/subagents list` now shows v4.13+ dispatch agents too
|
|
8
|
+
|
|
9
|
+
**Bug Ali caught:** typing `/subagents list` in Telegram while a `alvin_dispatch_agent` sub-agent was actively running returned "no agents running" — even though the user could see the agent finish and deliver a result shortly after. Cross-platform effect too: `/alvin` slash command on Slack had the same display gap.
|
|
10
|
+
|
|
11
|
+
**Root cause:** two separate registries for sub-agents:
|
|
12
|
+
- `src/services/subagents.ts` `activeAgents` Map — used since v4.0.0 for bot-level sub-agents (cron spawns, implicit Task tool children, `/sub-agents spawn` CLI)
|
|
13
|
+
- `src/services/async-agent-watcher.ts` `pending` Map — used since v4.13 for detached `alvin_dispatch_agent` subprocesses
|
|
14
|
+
|
|
15
|
+
`/subagents list` only read from the first map. The entire v4.13+ dispatch path was invisible in the listing.
|
|
16
|
+
|
|
17
|
+
**Fix:** new `listActiveSubAgents()` helper in subagents.ts that merges both registries. Pending async-agent-watcher entries get synthesized into `SubAgentInfo` shape (status="running", source="cron", depth=0, platform preserved). The `/subagents list` handler and the default-render path both switch to the merged helper. The old `listSubAgents()` function stays pure (unchanged behavior) — cancel/result paths still use it because detached subprocess PIDs aren't tracked.
|
|
18
|
+
|
|
19
|
+
### Technical details
|
|
20
|
+
|
|
21
|
+
- `listActiveSubAgents()` is async (lazy dynamic import of the watcher module to keep subagents.ts load order clean) — existing `listSubAgents()` remains sync for the v4.0.0 consumers
|
|
22
|
+
- Synthesis mapping: `PendingAsyncAgent.agentId → SubAgentInfo.id`, `description → name`, `startedAt → startedAt`, always `status="running"` (pending by definition), `source="cron"` (matches watcher's delivery banner), `depth=0`
|
|
23
|
+
- Platform field preserved so the renderer can show cross-platform context if desired later
|
|
24
|
+
|
|
25
|
+
### Testing
|
|
26
|
+
|
|
27
|
+
- **Baseline**: 492 tests (v4.14.0)
|
|
28
|
+
- **New**: `test/list-subagents-merged.test.ts` — 6 tests (empty state, single slack agent, multi-platform merge, timestamp preservation, source tag, listSubAgents purity guard)
|
|
29
|
+
- **Total**: 498 tests, all green, TSC clean
|
|
30
|
+
|
|
31
|
+
### Files changed
|
|
32
|
+
|
|
33
|
+
- **Modified**: `src/services/subagents.ts` (new listActiveSubAgents helper), `src/handlers/commands.ts` (both /subagents list paths switch to merged view)
|
|
34
|
+
- **NEW tests**: `test/list-subagents-merged.test.ts`
|
|
35
|
+
- **Version**: `package.json` 4.14.0 → 4.14.1
|
|
36
|
+
|
|
37
|
+
---
|
|
38
|
+
|
|
39
|
+
## [4.14.0] — 2026-04-16
|
|
40
|
+
|
|
41
|
+
### ✨ Sub-agent dispatch on Slack, Discord, WhatsApp (Telegram unchanged)
|
|
42
|
+
|
|
43
|
+
v4.13.0 shipped truly-detached sub-agents via the `mcp__alvin__dispatch_agent` MCP tool, but only Telegram passed the required `alvinDispatchContext` to the provider. Slack/Discord/WhatsApp users couldn't trigger background sub-agents — the tool was visible to Claude but effectively unreachable.
|
|
44
|
+
|
|
45
|
+
v4.14 wires the same dispatch path through the non-Telegram handler (`src/handlers/platform-message.ts`) and adds a platform-aware delivery router so results come back on the same platform they were dispatched from.
|
|
46
|
+
|
|
47
|
+
**Telegram is untouched.** The v4.13.0 Telegram pipeline (message.ts → Claude SDK → alvin_dispatch_agent → watcher → grammy-api delivery) is bit-for-bit identical. Only the types widened (`chatId: number | string`, `platform?: ...`), and the new code paths activate only when `platform !== "telegram"`.
|
|
48
|
+
|
|
49
|
+
### Technical details
|
|
50
|
+
|
|
51
|
+
**Type widening** (`src/services/async-agent-watcher.ts`, `src/services/alvin-dispatch.ts`, `src/services/alvin-mcp-tools.ts`, `src/providers/types.ts`, `src/services/subagents.ts`):
|
|
52
|
+
- `PendingAsyncAgent.chatId` / `userId`: `number` → `number | string`
|
|
53
|
+
- `PendingAsyncAgent.platform?: "telegram" | "slack" | "discord" | "whatsapp"` (optional, undefined = telegram)
|
|
54
|
+
- `SubAgentInfo.parentChatId`: same widening
|
|
55
|
+
- `SubAgentInfo.platform?: ...` new field
|
|
56
|
+
- `DispatchInput`, `AlvinDispatchContext`, `QueryOptions.alvinDispatchContext`: same widening + `platform` field
|
|
57
|
+
|
|
58
|
+
Pre-v4.14 persisted `async-agents.json` entries keep working — missing `platform` field defaults to `telegram`, numeric `chatId` still routes through grammy.
|
|
59
|
+
|
|
60
|
+
**New module** `src/services/delivery-registry.ts`:
|
|
61
|
+
- `registerDeliveryAdapter({ platform, sendText, sendDocument? })` — called by each platform module at startup
|
|
62
|
+
- `getDeliveryAdapter(platform)` — watcher lookup
|
|
63
|
+
- Tiny surface: sendText + optional sendDocument, string | number chatId, no Markdown or live-stream
|
|
64
|
+
|
|
65
|
+
**Delivery router** `src/services/subagent-delivery.ts` `deliverSubAgentResult()`:
|
|
66
|
+
- Branches on `info.platform ?? "telegram"`:
|
|
67
|
+
- `telegram` → existing grammy path (unchanged Markdown parsing, file uploads, 3800-char chunking)
|
|
68
|
+
- `slack`/`discord`/`whatsapp` → new `deliverViaRegistry()` path — plain text (no Markdown), 3800-char chunks, optional file upload via adapter.sendDocument
|
|
69
|
+
|
|
70
|
+
**Adapter registration** in `src/platforms/slack.ts`, `src/platforms/discord.ts`, `src/platforms/whatsapp.ts`:
|
|
71
|
+
- Each platform's `start()` now calls `registerDeliveryAdapter` at the end
|
|
72
|
+
- The adapter's `sendText` wraps the existing platform `sendText` (no duplicate code)
|
|
73
|
+
|
|
74
|
+
**Handler wiring** `src/handlers/platform-message.ts`:
|
|
75
|
+
- When the active provider is SDK, `alvinDispatchContext: { chatId, userId, sessionKey, platform }` is passed in queryOpts — mirrors the Telegram handler's v4.13.0 behavior
|
|
76
|
+
- Claude sees the same `mcp__alvin__dispatch_agent` tool and uses it the same way
|
|
77
|
+
|
|
78
|
+
### Testing
|
|
79
|
+
|
|
80
|
+
- **Baseline**: 483 tests (v4.13.2)
|
|
81
|
+
- **New**:
|
|
82
|
+
- `test/delivery-registry.test.ts` — 4 tests (register/get roundtrip, unregistered returns null, re-register replaces, per-platform isolation)
|
|
83
|
+
- `test/subagent-delivery-platform-routing.test.ts` — 5 tests (slack routes via registry not grammy, telegram defaults still use grammy, discord routes correctly, orphan platform skips gracefully, long output chunks on non-telegram adapters)
|
|
84
|
+
- **Total**: 492 tests, all green, TSC clean
|
|
85
|
+
- **Telegram regression guard**: the routing test explicitly verifies `info.platform=undefined` still hits grammy, and `info.platform='slack'` never touches grammy. That's the load-bearing invariant.
|
|
86
|
+
|
|
87
|
+
### Files changed
|
|
88
|
+
|
|
89
|
+
- **NEW**: `src/services/delivery-registry.ts`, `test/delivery-registry.test.ts`, `test/subagent-delivery-platform-routing.test.ts`
|
|
90
|
+
- **Modified**: `src/services/async-agent-watcher.ts` (chatId widening + platform field), `src/services/subagent-delivery.ts` (platform router + plain-text banner variant), `src/services/alvin-dispatch.ts` (type widening), `src/services/alvin-mcp-tools.ts` (context pass-through), `src/services/subagents.ts` (SubAgentInfo.platform + widened parentChatId), `src/providers/types.ts` (QueryOptions.alvinDispatchContext extended), `src/handlers/platform-message.ts` (dispatch context), `src/platforms/slack.ts` / `discord.ts` / `whatsapp.ts` (adapter registration)
|
|
91
|
+
- **Version**: `package.json` 4.13.2 → 4.14.0 (minor bump — new public surface: delivery-registry, platform field)
|
|
92
|
+
|
|
93
|
+
### Known limitations
|
|
94
|
+
|
|
95
|
+
- **Slack slash command context**: when a user invokes `/alvin <prompt>` in Slack, dispatch works (same codepath), but the sub-agent result delivery lands as a persistent channel message, not an ephemeral slash-command response. If you want ephemeral replies, use DM.
|
|
96
|
+
- **Discord/WhatsApp not smoke-tested**: the code paths match Slack, and the adapter registration is symmetric, but I only end-to-end tested Slack. YMMV until you run a real test.
|
|
97
|
+
|
|
98
|
+
---
|
|
99
|
+
|
|
5
100
|
## [4.13.2] — 2026-04-16
|
|
6
101
|
|
|
7
102
|
### ✨ Slack: `/alvin` slash commands + rewritten setup guide
|
|
@@ -1910,7 +1910,7 @@ export function registerCommands(bot) {
|
|
|
1910
1910
|
// type both "/sub-agents" and "/subagents" — Telegram routes both to this.
|
|
1911
1911
|
bot.command(["sub_agents", "subagents"], async (ctx) => {
|
|
1912
1912
|
const lang = getSession(ctx.from.id).language;
|
|
1913
|
-
const { listSubAgents, cancelSubAgent, getSubAgentResult, getMaxParallelAgents, getConfiguredMaxParallel, setMaxParallelAgents, findSubAgentByName, getVisibility, setVisibility, getQueueCap, setQueueCap, getDefaultTimeoutMs, setDefaultTimeoutMs, } = await import("../services/subagents.js");
|
|
1913
|
+
const { listSubAgents, listActiveSubAgents, cancelSubAgent, getSubAgentResult, getMaxParallelAgents, getConfiguredMaxParallel, setMaxParallelAgents, findSubAgentByName, getVisibility, setVisibility, getQueueCap, setQueueCap, getDefaultTimeoutMs, setDefaultTimeoutMs, } = await import("../services/subagents.js");
|
|
1914
1914
|
const arg = (ctx.match || "").trim();
|
|
1915
1915
|
const tokens = arg.split(/\s+/).filter(Boolean);
|
|
1916
1916
|
const sub = tokens[0]?.toLowerCase() || "";
|
|
@@ -2040,8 +2040,10 @@ export function registerCommands(bot) {
|
|
|
2040
2040
|
return;
|
|
2041
2041
|
}
|
|
2042
2042
|
// /sub-agents list — same rendering as the default, but forced
|
|
2043
|
+
// v4.14.1 — uses listActiveSubAgents (merged view) so v4.13+
|
|
2044
|
+
// alvin_dispatch_agent detached subprocesses also show up here.
|
|
2043
2045
|
if (sub === "list") {
|
|
2044
|
-
const agents =
|
|
2046
|
+
const agents = await listActiveSubAgents();
|
|
2045
2047
|
if (agents.length === 0) {
|
|
2046
2048
|
await ctx.reply(t("bot.subagents.noneRunning", lang));
|
|
2047
2049
|
return;
|
|
@@ -2142,7 +2144,8 @@ export function registerCommands(bot) {
|
|
|
2142
2144
|
const timeoutLabel = currentTimeout <= 0
|
|
2143
2145
|
? `⏱ Timeout: *∞ (unlimited)*`
|
|
2144
2146
|
: `⏱ Timeout: *${Math.round(currentTimeout / 1000)}s*`;
|
|
2145
|
-
|
|
2147
|
+
// v4.14.1 — merged view incl. v4.13+ alvin_dispatch_agent agents.
|
|
2148
|
+
const agents = await listActiveSubAgents();
|
|
2146
2149
|
let body = "";
|
|
2147
2150
|
if (agents.length === 0) {
|
|
2148
2151
|
body = `\n${t("bot.subagents.noneRunning", lang)}`;
|
|
@@ -171,6 +171,18 @@ export async function handlePlatformMessage(msg, adapter) {
|
|
|
171
171
|
effort: session.effort,
|
|
172
172
|
sessionId: isSDK ? session.sessionId : null,
|
|
173
173
|
history: !isSDK ? session.history : undefined,
|
|
174
|
+
// v4.14 — Expose alvin_dispatch_agent MCP tool on non-Telegram
|
|
175
|
+
// platforms too (Slack/Discord/WhatsApp). The watcher routes the
|
|
176
|
+
// eventual delivery via the platform's registered DeliveryAdapter.
|
|
177
|
+
// Only for SDK provider (where MCP tools are supported).
|
|
178
|
+
alvinDispatchContext: isSDK
|
|
179
|
+
? {
|
|
180
|
+
chatId: msg.chatId,
|
|
181
|
+
userId: msg.userId,
|
|
182
|
+
sessionKey,
|
|
183
|
+
platform: msg.platform,
|
|
184
|
+
}
|
|
185
|
+
: undefined,
|
|
174
186
|
};
|
|
175
187
|
if (!isSDK) {
|
|
176
188
|
addToHistory(sessionKey, { role: "user", content: fullText });
|
|
@@ -83,6 +83,20 @@ export class DiscordAdapter {
|
|
|
83
83
|
});
|
|
84
84
|
await this.client.login(this.token);
|
|
85
85
|
console.log(`🎮 Discord adapter started (${this.client.user?.tag})`);
|
|
86
|
+
// v4.14 — Register with the delivery registry so the async-agent
|
|
87
|
+
// watcher can deliver background sub-agent results back to Discord.
|
|
88
|
+
try {
|
|
89
|
+
const { registerDeliveryAdapter } = await import("../services/delivery-registry.js");
|
|
90
|
+
registerDeliveryAdapter({
|
|
91
|
+
platform: "discord",
|
|
92
|
+
sendText: async (chatId, text) => {
|
|
93
|
+
await this.sendText(String(chatId), text);
|
|
94
|
+
},
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
catch (err) {
|
|
98
|
+
console.warn("[discord] failed to register delivery adapter:", err);
|
|
99
|
+
}
|
|
86
100
|
}
|
|
87
101
|
catch (err) {
|
|
88
102
|
_discordState.status = "error";
|
package/dist/platforms/slack.js
CHANGED
|
@@ -110,6 +110,21 @@ export class SlackAdapter {
|
|
|
110
110
|
_slackState.status = "connected";
|
|
111
111
|
_slackState.connectedAt = Date.now();
|
|
112
112
|
console.log(`\uD83D\uDCAC Slack connected (${_slackState.botName} @ ${_slackState.teamName})`);
|
|
113
|
+
// v4.14 — Register this adapter with the delivery registry so the
|
|
114
|
+
// async-agent watcher can deliver background sub-agent results
|
|
115
|
+
// back to Slack. The registry accepts string channel IDs directly.
|
|
116
|
+
try {
|
|
117
|
+
const { registerDeliveryAdapter } = await import("../services/delivery-registry.js");
|
|
118
|
+
registerDeliveryAdapter({
|
|
119
|
+
platform: "slack",
|
|
120
|
+
sendText: async (chatId, text) => {
|
|
121
|
+
await this.sendText(String(chatId), text);
|
|
122
|
+
},
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
catch (err) {
|
|
126
|
+
console.warn("[slack] failed to register delivery adapter:", err);
|
|
127
|
+
}
|
|
113
128
|
}
|
|
114
129
|
catch (err) {
|
|
115
130
|
_slackState.status = "error";
|
|
@@ -217,6 +217,20 @@ export class WhatsAppAdapter {
|
|
|
217
217
|
connectedAt: null, error: null, info: null,
|
|
218
218
|
};
|
|
219
219
|
await this.connect();
|
|
220
|
+
// v4.14 — Register with the delivery registry so the async-agent
|
|
221
|
+
// watcher can deliver background sub-agent results back to WhatsApp.
|
|
222
|
+
try {
|
|
223
|
+
const { registerDeliveryAdapter } = await import("../services/delivery-registry.js");
|
|
224
|
+
registerDeliveryAdapter({
|
|
225
|
+
platform: "whatsapp",
|
|
226
|
+
sendText: async (chatId, text) => {
|
|
227
|
+
await this.sendText(String(chatId), text);
|
|
228
|
+
},
|
|
229
|
+
});
|
|
230
|
+
}
|
|
231
|
+
catch (err) {
|
|
232
|
+
console.warn("[whatsapp] failed to register delivery adapter:", err);
|
|
233
|
+
}
|
|
220
234
|
}
|
|
221
235
|
async connect() {
|
|
222
236
|
let baileys;
|
|
@@ -108,6 +108,7 @@ export function dispatchDetachedAgent(input) {
|
|
|
108
108
|
userId: input.userId,
|
|
109
109
|
toolUseId: null,
|
|
110
110
|
sessionKey: input.sessionKey,
|
|
111
|
+
platform: input.platform,
|
|
111
112
|
});
|
|
112
113
|
// Increment the session's pendingBackgroundCount so the main handler
|
|
113
114
|
// knows a background task is in flight (same signal path as SDK's
|
|
@@ -83,6 +83,7 @@ export function registerPendingAgent(input) {
|
|
|
83
83
|
giveUpAt: input.giveUpAt ?? now + MAX_AGENT_AGE_MS,
|
|
84
84
|
toolUseId: input.toolUseId,
|
|
85
85
|
sessionKey: input.sessionKey,
|
|
86
|
+
platform: input.platform,
|
|
86
87
|
};
|
|
87
88
|
pending.set(input.agentId, entry);
|
|
88
89
|
saveToDisk();
|
|
@@ -175,6 +176,7 @@ async function deliverAsCompleted(entry, output, tokensUsed) {
|
|
|
175
176
|
source: "cron", // Reuse cron banner format — fits async background agents.
|
|
176
177
|
depth: 0,
|
|
177
178
|
parentChatId: entry.chatId,
|
|
179
|
+
platform: entry.platform,
|
|
178
180
|
};
|
|
179
181
|
const result = {
|
|
180
182
|
id: entry.agentId,
|
|
@@ -202,6 +204,7 @@ async function deliverAsFailure(entry, status, error) {
|
|
|
202
204
|
source: "cron",
|
|
203
205
|
depth: 0,
|
|
204
206
|
parentChatId: entry.chatId,
|
|
207
|
+
platform: entry.platform,
|
|
205
208
|
};
|
|
206
209
|
const result = {
|
|
207
210
|
id: entry.agentId,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
const adapters = new Map();
|
|
2
|
+
/**
|
|
3
|
+
* Register (or replace) an adapter for a platform. Idempotent —
|
|
4
|
+
* registering the same platform twice replaces the previous entry
|
|
5
|
+
* (handles platform-module reload during dev).
|
|
6
|
+
*/
|
|
7
|
+
export function registerDeliveryAdapter(adapter) {
|
|
8
|
+
adapters.set(adapter.platform, adapter);
|
|
9
|
+
}
|
|
10
|
+
/** Look up the adapter for a platform. Returns null if not registered. */
|
|
11
|
+
export function getDeliveryAdapter(platform) {
|
|
12
|
+
return adapters.get(platform) ?? null;
|
|
13
|
+
}
|
|
14
|
+
/** List all registered adapters — used for /status and diagnostics. */
|
|
15
|
+
export function listDeliveryAdapters() {
|
|
16
|
+
return [...adapters.values()];
|
|
17
|
+
}
|
|
18
|
+
/** Test-only — reset the registry between tests. */
|
|
19
|
+
export function __resetForTest() {
|
|
20
|
+
adapters.clear();
|
|
21
|
+
}
|
|
@@ -244,7 +244,11 @@ export function createLiveStream(chatId, agentName) {
|
|
|
244
244
|
* config default), then dispatches to the source-specific renderer.
|
|
245
245
|
*
|
|
246
246
|
* Errors are logged but never thrown — delivery must not break the sub-agent
|
|
247
|
-
* lifecycle. A failed
|
|
247
|
+
* lifecycle. A failed send falls through silently.
|
|
248
|
+
*
|
|
249
|
+
* v4.14 — routes by `info.platform`:
|
|
250
|
+
* - "telegram" (default) → existing grammy pipeline (unchanged)
|
|
251
|
+
* - "slack" / "discord" / "whatsapp" → delivery-registry lookup
|
|
248
252
|
*/
|
|
249
253
|
export async function deliverSubAgentResult(info, result, opts = {}) {
|
|
250
254
|
// Implicit spawns: the Task-tool bridge in the main stream has already
|
|
@@ -254,17 +258,28 @@ export async function deliverSubAgentResult(info, result, opts = {}) {
|
|
|
254
258
|
const effective = opts.visibility ?? getVisibility();
|
|
255
259
|
if (effective === "silent")
|
|
256
260
|
return;
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
+
if (!info.parentChatId) {
|
|
262
|
+
console.warn(`[subagent-delivery] missing parentChatId for ${info.name} (source=${info.source})`);
|
|
263
|
+
return;
|
|
264
|
+
}
|
|
265
|
+
// v4.14 — Platform routing. Telegram is the default path (unchanged).
|
|
266
|
+
const platform = info.platform ?? "telegram";
|
|
267
|
+
if (platform !== "telegram") {
|
|
268
|
+
await deliverViaRegistry(platform, info, result);
|
|
269
|
+
return;
|
|
270
|
+
}
|
|
271
|
+
// ── Telegram path (v4.12.x behavior, unchanged) ──────────────────
|
|
261
272
|
const api = getBotApi();
|
|
262
273
|
if (!api) {
|
|
263
274
|
console.warn(`[subagent-delivery] no bot api available for ${info.name}`);
|
|
264
275
|
return;
|
|
265
276
|
}
|
|
266
|
-
|
|
267
|
-
|
|
277
|
+
// Telegram's chatId is always a number at runtime; defensive cast.
|
|
278
|
+
const tgChatId = typeof info.parentChatId === "number"
|
|
279
|
+
? info.parentChatId
|
|
280
|
+
: Number(info.parentChatId);
|
|
281
|
+
if (!Number.isFinite(tgChatId)) {
|
|
282
|
+
console.warn(`[subagent-delivery] invalid telegram chatId for ${info.name}`);
|
|
268
283
|
return;
|
|
269
284
|
}
|
|
270
285
|
const banner = buildBanner(info, result);
|
|
@@ -272,32 +287,103 @@ export async function deliverSubAgentResult(info, result, opts = {}) {
|
|
|
272
287
|
try {
|
|
273
288
|
// Case 1: very long output → file upload with a short banner
|
|
274
289
|
if (body.length > FILE_UPLOAD_THRESHOLD) {
|
|
275
|
-
await sendWithMarkdownFallback(api,
|
|
290
|
+
await sendWithMarkdownFallback(api, tgChatId, banner);
|
|
276
291
|
try {
|
|
277
292
|
const { InputFile } = await import("grammy");
|
|
278
293
|
const buf = Buffer.from(body, "utf-8");
|
|
279
|
-
await api.sendDocument(
|
|
294
|
+
await api.sendDocument(tgChatId, new InputFile(buf, `${info.name}.md`));
|
|
280
295
|
}
|
|
281
296
|
catch (err) {
|
|
282
297
|
console.error(`[subagent-delivery] file upload failed:`, err);
|
|
283
|
-
await api.sendMessage(
|
|
298
|
+
await api.sendMessage(tgChatId, body.slice(0, MAX_TG_CHUNK));
|
|
284
299
|
}
|
|
285
300
|
return;
|
|
286
301
|
}
|
|
287
302
|
// Case 2: fits in a single message → banner + body joined
|
|
288
303
|
if (body.length + banner.length + 2 <= MAX_TG_CHUNK) {
|
|
289
|
-
await sendWithMarkdownFallback(api,
|
|
304
|
+
await sendWithMarkdownFallback(api, tgChatId, `${banner}\n\n${body}`);
|
|
290
305
|
return;
|
|
291
306
|
}
|
|
292
307
|
// Case 3: medium output → banner as its own message, body chunked
|
|
293
|
-
await sendWithMarkdownFallback(api,
|
|
308
|
+
await sendWithMarkdownFallback(api, tgChatId, banner);
|
|
294
309
|
for (let i = 0; i < body.length; i += MAX_TG_CHUNK) {
|
|
295
310
|
// Body chunks are always sent as plain text — markdown across
|
|
296
311
|
// arbitrary chunk boundaries would be inconsistent anyway.
|
|
297
|
-
await api.sendMessage(
|
|
312
|
+
await api.sendMessage(tgChatId, body.slice(i, i + MAX_TG_CHUNK));
|
|
298
313
|
}
|
|
299
314
|
}
|
|
300
315
|
catch (err) {
|
|
301
316
|
console.error(`[subagent-delivery] send failed for ${info.name}:`, err);
|
|
302
317
|
}
|
|
303
318
|
}
|
|
319
|
+
/**
|
|
320
|
+
* v4.14 — Delivery path for non-Telegram platforms. Uses the adapter
|
|
321
|
+
* registered in delivery-registry (populated by each platform module
|
|
322
|
+
* at startup). Simpler than the Telegram path: no Markdown parsing,
|
|
323
|
+
* no live-stream mode, plain text only, chunked to a conservative
|
|
324
|
+
* 3800-char cap that all three platforms handle.
|
|
325
|
+
*/
|
|
326
|
+
async function deliverViaRegistry(platform, info, result) {
|
|
327
|
+
const { getDeliveryAdapter } = await import("./delivery-registry.js");
|
|
328
|
+
const adapter = getDeliveryAdapter(platform);
|
|
329
|
+
if (!adapter) {
|
|
330
|
+
console.warn(`[subagent-delivery] no ${platform} adapter registered for ${info.name} — skipping delivery`);
|
|
331
|
+
return;
|
|
332
|
+
}
|
|
333
|
+
if (info.parentChatId === undefined)
|
|
334
|
+
return;
|
|
335
|
+
// Registry adapters accept string | number chatId directly.
|
|
336
|
+
const chatId = info.parentChatId;
|
|
337
|
+
const banner = buildBannerPlain(info, result);
|
|
338
|
+
const body = result.output?.trim() || `(empty output)`;
|
|
339
|
+
const NON_TG_CHUNK = 3800;
|
|
340
|
+
const FILE_THRESHOLD = 20_000;
|
|
341
|
+
try {
|
|
342
|
+
// Very long output → file upload if supported, else truncated text
|
|
343
|
+
if (body.length > FILE_THRESHOLD) {
|
|
344
|
+
await adapter.sendText(chatId, banner);
|
|
345
|
+
if (adapter.sendDocument) {
|
|
346
|
+
try {
|
|
347
|
+
await adapter.sendDocument(chatId, Buffer.from(body, "utf-8"), `${info.name}.md`);
|
|
348
|
+
return;
|
|
349
|
+
}
|
|
350
|
+
catch (err) {
|
|
351
|
+
console.error(`[subagent-delivery] ${platform} file upload failed:`, err);
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
// Fallback: chunked text if no file upload or upload failed
|
|
355
|
+
for (let i = 0; i < body.length; i += NON_TG_CHUNK) {
|
|
356
|
+
await adapter.sendText(chatId, body.slice(i, i + NON_TG_CHUNK));
|
|
357
|
+
}
|
|
358
|
+
return;
|
|
359
|
+
}
|
|
360
|
+
// Fits in one message → combined
|
|
361
|
+
if (body.length + banner.length + 2 <= NON_TG_CHUNK) {
|
|
362
|
+
await adapter.sendText(chatId, `${banner}\n\n${body}`);
|
|
363
|
+
return;
|
|
364
|
+
}
|
|
365
|
+
// Medium — banner first, then chunked body
|
|
366
|
+
await adapter.sendText(chatId, banner);
|
|
367
|
+
for (let i = 0; i < body.length; i += NON_TG_CHUNK) {
|
|
368
|
+
await adapter.sendText(chatId, body.slice(i, i + NON_TG_CHUNK));
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
catch (err) {
|
|
372
|
+
console.error(`[subagent-delivery] ${platform} send failed for ${info.name}:`, err);
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
/**
|
|
376
|
+
* v4.14 — Plain-text banner variant for non-Telegram platforms.
|
|
377
|
+
* No Markdown (some platforms render it inconsistently), just emoji +
|
|
378
|
+
* clean labels. Matches the info layout of buildBanner.
|
|
379
|
+
*/
|
|
380
|
+
function buildBannerPlain(info, result) {
|
|
381
|
+
const truncated = result.status === "completed" &&
|
|
382
|
+
(!result.output || result.output.trim().length === 0);
|
|
383
|
+
const icon = truncated ? "⚠️" : statusIcon(result.status);
|
|
384
|
+
const statusLabel = truncated ? "completed · empty output" : result.status;
|
|
385
|
+
const dur = formatDuration(result.duration);
|
|
386
|
+
const ti = formatTokens(result.tokensUsed.input);
|
|
387
|
+
const to = formatTokens(result.tokensUsed.output);
|
|
388
|
+
return `${icon} ${info.name} — ${statusLabel} · ${dur} · ${ti} in / ${to} out`;
|
|
389
|
+
}
|
|
@@ -576,10 +576,69 @@ export function spawnSubAgent(agentConfig) {
|
|
|
576
576
|
}
|
|
577
577
|
/**
|
|
578
578
|
* List all agents (active + recent completed).
|
|
579
|
+
*
|
|
580
|
+
* This is the v4.0.0 API — shows only agents from the bot-level
|
|
581
|
+
* registry (activeAgents Map). Does NOT include v4.13+ detached
|
|
582
|
+
* `alvin_dispatch_agent` subprocesses which live in async-agent-
|
|
583
|
+
* watcher. For the merged view used by `/subagents list`, use
|
|
584
|
+
* `listActiveSubAgents()` instead.
|
|
579
585
|
*/
|
|
580
586
|
export function listSubAgents() {
|
|
581
587
|
return [...activeAgents.values()].map((a) => ({ ...a.info }));
|
|
582
588
|
}
|
|
589
|
+
/**
|
|
590
|
+
* v4.14.1 — Merged view of BOTH sub-agent registries:
|
|
591
|
+
* 1. Bot-level agents (subagents.ts activeAgents Map) — v4.0.0+
|
|
592
|
+
* the /sub-agents spawn CLI, cron-spawned sub-agents, implicit
|
|
593
|
+
* Task-tool children.
|
|
594
|
+
* 2. Detached `alvin_dispatch_agent` subprocesses (async-agent-
|
|
595
|
+
* watcher pending Map) — v4.13+ the MCP-tool-dispatched
|
|
596
|
+
* agents that survive parent aborts.
|
|
597
|
+
*
|
|
598
|
+
* The user doesn't care which registry an agent lives in — "is there
|
|
599
|
+
* anything running right now?" is the question `/subagents list`
|
|
600
|
+
* answers. This function unifies the view.
|
|
601
|
+
*
|
|
602
|
+
* Pending async agents are synthesized into SubAgentInfo shape:
|
|
603
|
+
* - id: PendingAsyncAgent.agentId (alvin-prefixed hex)
|
|
604
|
+
* - name: PendingAsyncAgent.description
|
|
605
|
+
* - status: "running" (we wouldn't be pending otherwise)
|
|
606
|
+
* - startedAt: PendingAsyncAgent.startedAt
|
|
607
|
+
* - source: "cron" — matches the delivery banner's source tag
|
|
608
|
+
* - depth: 0 — dispatch agents are always top-level (no nesting)
|
|
609
|
+
* - platform: preserved from the pending entry
|
|
610
|
+
* - parentChatId: from the pending entry
|
|
611
|
+
*
|
|
612
|
+
* Lazy import of the watcher keeps this function cheap for callers
|
|
613
|
+
* who only need the v4.0.0 view (importing the watcher pulls in its
|
|
614
|
+
* whole startup cost otherwise).
|
|
615
|
+
*/
|
|
616
|
+
export async function listActiveSubAgents() {
|
|
617
|
+
const botLevel = listSubAgents();
|
|
618
|
+
let pending = [];
|
|
619
|
+
try {
|
|
620
|
+
// Lazy dynamic import so this module doesn't depend on the watcher
|
|
621
|
+
// at load time (preserves test isolation + avoids a circular boot).
|
|
622
|
+
const watcher = await import("./async-agent-watcher.js");
|
|
623
|
+
if (typeof watcher.listPendingAgents === "function") {
|
|
624
|
+
const raw = watcher.listPendingAgents();
|
|
625
|
+
pending = raw.map((p) => ({
|
|
626
|
+
id: p.agentId,
|
|
627
|
+
name: p.description,
|
|
628
|
+
status: "running",
|
|
629
|
+
startedAt: p.startedAt,
|
|
630
|
+
source: "cron",
|
|
631
|
+
depth: 0,
|
|
632
|
+
platform: p.platform,
|
|
633
|
+
parentChatId: p.chatId,
|
|
634
|
+
}));
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
catch {
|
|
638
|
+
/* never break listing because of merge errors */
|
|
639
|
+
}
|
|
640
|
+
return [...botLevel, ...pending];
|
|
641
|
+
}
|
|
583
642
|
/**
|
|
584
643
|
* Cancel a running sub-agent by ID.
|
|
585
644
|
* Returns true if the agent was found and aborted.
|
package/package.json
CHANGED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* v4.14 — delivery-registry module tests.
|
|
3
|
+
*
|
|
4
|
+
* Registers platform adapters (slack/discord/whatsapp) so the sub-agent
|
|
5
|
+
* watcher can route delivery to the right one based on
|
|
6
|
+
* PendingAsyncAgent.platform. Telegram does NOT go through this registry
|
|
7
|
+
* — it continues to use the existing grammy-bot path via attachBotApi.
|
|
8
|
+
*/
|
|
9
|
+
import { describe, it, expect, beforeEach, vi } from "vitest";
|
|
10
|
+
|
|
11
|
+
beforeEach(() => vi.resetModules());
|
|
12
|
+
|
|
13
|
+
describe("delivery-registry (v4.14)", () => {
|
|
14
|
+
it("register + get roundtrip", async () => {
|
|
15
|
+
const { registerDeliveryAdapter, getDeliveryAdapter, __resetForTest } =
|
|
16
|
+
await import("../src/services/delivery-registry.js");
|
|
17
|
+
__resetForTest();
|
|
18
|
+
|
|
19
|
+
const fake = {
|
|
20
|
+
platform: "slack" as const,
|
|
21
|
+
sendText: vi.fn(async () => {}),
|
|
22
|
+
};
|
|
23
|
+
registerDeliveryAdapter(fake);
|
|
24
|
+
expect(getDeliveryAdapter("slack")).toBe(fake);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("returns null for unregistered platform", async () => {
|
|
28
|
+
const { getDeliveryAdapter, __resetForTest } = await import(
|
|
29
|
+
"../src/services/delivery-registry.js"
|
|
30
|
+
);
|
|
31
|
+
__resetForTest();
|
|
32
|
+
expect(getDeliveryAdapter("slack")).toBeNull();
|
|
33
|
+
expect(getDeliveryAdapter("discord")).toBeNull();
|
|
34
|
+
expect(getDeliveryAdapter("telegram")).toBeNull();
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("re-register replaces the existing adapter (handles platform reload)", async () => {
|
|
38
|
+
const {
|
|
39
|
+
registerDeliveryAdapter,
|
|
40
|
+
getDeliveryAdapter,
|
|
41
|
+
__resetForTest,
|
|
42
|
+
} = await import("../src/services/delivery-registry.js");
|
|
43
|
+
__resetForTest();
|
|
44
|
+
|
|
45
|
+
const first = { platform: "slack" as const, sendText: vi.fn(async () => {}) };
|
|
46
|
+
const second = { platform: "slack" as const, sendText: vi.fn(async () => {}) };
|
|
47
|
+
registerDeliveryAdapter(first);
|
|
48
|
+
registerDeliveryAdapter(second);
|
|
49
|
+
expect(getDeliveryAdapter("slack")).toBe(second);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("adapters are isolated per platform", async () => {
|
|
53
|
+
const {
|
|
54
|
+
registerDeliveryAdapter,
|
|
55
|
+
getDeliveryAdapter,
|
|
56
|
+
__resetForTest,
|
|
57
|
+
} = await import("../src/services/delivery-registry.js");
|
|
58
|
+
__resetForTest();
|
|
59
|
+
|
|
60
|
+
const slack = { platform: "slack" as const, sendText: vi.fn(async () => {}) };
|
|
61
|
+
const discord = {
|
|
62
|
+
platform: "discord" as const,
|
|
63
|
+
sendText: vi.fn(async () => {}),
|
|
64
|
+
};
|
|
65
|
+
registerDeliveryAdapter(slack);
|
|
66
|
+
registerDeliveryAdapter(discord);
|
|
67
|
+
expect(getDeliveryAdapter("slack")).toBe(slack);
|
|
68
|
+
expect(getDeliveryAdapter("discord")).toBe(discord);
|
|
69
|
+
expect(getDeliveryAdapter("whatsapp")).toBeNull();
|
|
70
|
+
});
|
|
71
|
+
});
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* v4.14.1 — `/subagents list` must show v4.13+ dispatch agents too.
|
|
3
|
+
*
|
|
4
|
+
* Root cause: `listSubAgents()` in subagents.ts only iterates the
|
|
5
|
+
* `activeAgents` Map (B1+B2 from v4.0.0). v4.13's `alvin_dispatch_agent`
|
|
6
|
+
* MCP tool writes into `async-agent-watcher.ts`'s `pending` Map instead.
|
|
7
|
+
* User-facing impact: "no subagents running" while the bot is visibly
|
|
8
|
+
* dispatching sub-agents.
|
|
9
|
+
*
|
|
10
|
+
* Fix strategy: a new `listActiveSubAgents()` helper that merges both
|
|
11
|
+
* registries into a unified SubAgentInfo-shaped list. The `/subagents
|
|
12
|
+
* list` handler uses this instead of the bare `listSubAgents()`.
|
|
13
|
+
* Cancel/result operations keep using the old registry — we can't
|
|
14
|
+
* cancel a detached `claude -p` subprocess anyway without knowing its
|
|
15
|
+
* PID, which isn't tracked.
|
|
16
|
+
*/
|
|
17
|
+
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
|
18
|
+
import fs from "fs";
|
|
19
|
+
import os from "os";
|
|
20
|
+
import { resolve } from "path";
|
|
21
|
+
|
|
22
|
+
const TEST_DATA_DIR = resolve(
|
|
23
|
+
os.tmpdir(),
|
|
24
|
+
`alvin-list-merged-${process.pid}-${Date.now()}`,
|
|
25
|
+
);
|
|
26
|
+
|
|
27
|
+
beforeEach(async () => {
|
|
28
|
+
if (fs.existsSync(TEST_DATA_DIR)) {
|
|
29
|
+
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
|
30
|
+
}
|
|
31
|
+
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
|
|
32
|
+
process.env.ALVIN_DATA_DIR = TEST_DATA_DIR;
|
|
33
|
+
vi.resetModules();
|
|
34
|
+
|
|
35
|
+
vi.doMock("../src/services/subagent-delivery.js", () => ({
|
|
36
|
+
deliverSubAgentResult: async () => {},
|
|
37
|
+
attachBotApi: () => {},
|
|
38
|
+
__setBotApiForTest: () => {},
|
|
39
|
+
}));
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
afterEach(async () => {
|
|
43
|
+
try {
|
|
44
|
+
const mod = await import("../src/services/async-agent-watcher.js");
|
|
45
|
+
mod.stopWatcher();
|
|
46
|
+
mod.__resetForTest();
|
|
47
|
+
} catch {}
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
describe("listActiveSubAgents merged view (v4.14.1)", () => {
|
|
51
|
+
it("returns empty list when neither registry has agents", async () => {
|
|
52
|
+
const mod = await import("../src/services/subagents.js");
|
|
53
|
+
expect(await mod.listActiveSubAgents()).toEqual([]);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("includes async-agent-watcher pending agents in the merged list", async () => {
|
|
57
|
+
const watcher = await import("../src/services/async-agent-watcher.js");
|
|
58
|
+
watcher.registerPendingAgent({
|
|
59
|
+
agentId: "alvin-abc123",
|
|
60
|
+
outputFile: `${TEST_DATA_DIR}/out.jsonl`,
|
|
61
|
+
description: "Research Higgsfield",
|
|
62
|
+
prompt: "...",
|
|
63
|
+
chatId: "C012SLACK",
|
|
64
|
+
userId: "U123",
|
|
65
|
+
toolUseId: null,
|
|
66
|
+
sessionKey: "slack:C012SLACK",
|
|
67
|
+
platform: "slack",
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
const mod = await import("../src/services/subagents.js");
|
|
71
|
+
const agents = await mod.listActiveSubAgents();
|
|
72
|
+
expect(agents).toHaveLength(1);
|
|
73
|
+
expect(agents[0].id).toBe("alvin-abc123");
|
|
74
|
+
expect(agents[0].name).toBe("Research Higgsfield");
|
|
75
|
+
expect(agents[0].status).toBe("running");
|
|
76
|
+
expect(agents[0].depth).toBe(0);
|
|
77
|
+
expect(agents[0].platform).toBe("slack");
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("merges multiple agents from both registries without dupes", async () => {
|
|
81
|
+
const watcher = await import("../src/services/async-agent-watcher.js");
|
|
82
|
+
watcher.registerPendingAgent({
|
|
83
|
+
agentId: "alvin-one",
|
|
84
|
+
outputFile: `${TEST_DATA_DIR}/a.jsonl`,
|
|
85
|
+
description: "Agent One",
|
|
86
|
+
prompt: "p",
|
|
87
|
+
chatId: 42,
|
|
88
|
+
userId: 42,
|
|
89
|
+
toolUseId: null,
|
|
90
|
+
sessionKey: "s",
|
|
91
|
+
platform: "telegram",
|
|
92
|
+
});
|
|
93
|
+
watcher.registerPendingAgent({
|
|
94
|
+
agentId: "alvin-two",
|
|
95
|
+
outputFile: `${TEST_DATA_DIR}/b.jsonl`,
|
|
96
|
+
description: "Agent Two",
|
|
97
|
+
prompt: "p",
|
|
98
|
+
chatId: 42,
|
|
99
|
+
userId: 42,
|
|
100
|
+
toolUseId: null,
|
|
101
|
+
sessionKey: "s",
|
|
102
|
+
platform: "telegram",
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
const mod = await import("../src/services/subagents.js");
|
|
106
|
+
const agents = await mod.listActiveSubAgents();
|
|
107
|
+
const ids = agents.map((a) => a.id).sort();
|
|
108
|
+
expect(ids).toEqual(["alvin-one", "alvin-two"]);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it("preserves startedAt timestamp for age rendering", async () => {
|
|
112
|
+
const fixedTs = Date.now() - 45_000; // 45 seconds ago
|
|
113
|
+
const watcher = await import("../src/services/async-agent-watcher.js");
|
|
114
|
+
watcher.registerPendingAgent({
|
|
115
|
+
agentId: "alvin-aged",
|
|
116
|
+
outputFile: `${TEST_DATA_DIR}/aged.jsonl`,
|
|
117
|
+
description: "Old agent",
|
|
118
|
+
prompt: "p",
|
|
119
|
+
chatId: 1,
|
|
120
|
+
userId: 1,
|
|
121
|
+
toolUseId: null,
|
|
122
|
+
sessionKey: "s",
|
|
123
|
+
platform: "slack",
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
const mod = await import("../src/services/subagents.js");
|
|
127
|
+
const agents = await mod.listActiveSubAgents();
|
|
128
|
+
expect(agents[0].startedAt).toBeGreaterThan(fixedTs - 1000);
|
|
129
|
+
expect(agents[0].startedAt).toBeLessThan(Date.now() + 1000);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it("tags async dispatch agents with source='cron' (matches v4.12 banner format)", async () => {
|
|
133
|
+
const watcher = await import("../src/services/async-agent-watcher.js");
|
|
134
|
+
watcher.registerPendingAgent({
|
|
135
|
+
agentId: "alvin-sourced",
|
|
136
|
+
outputFile: `${TEST_DATA_DIR}/s.jsonl`,
|
|
137
|
+
description: "sourced",
|
|
138
|
+
prompt: "p",
|
|
139
|
+
chatId: 1,
|
|
140
|
+
userId: 1,
|
|
141
|
+
toolUseId: null,
|
|
142
|
+
sessionKey: "s",
|
|
143
|
+
platform: "telegram",
|
|
144
|
+
});
|
|
145
|
+
const mod = await import("../src/services/subagents.js");
|
|
146
|
+
const agents = await mod.listActiveSubAgents();
|
|
147
|
+
// source='cron' = the ⏰ badge in /subagents list rendering. Matches
|
|
148
|
+
// the existing v4.12.x watcher delivery's SubAgentInfo.source value.
|
|
149
|
+
expect(agents[0].source).toBe("cron");
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it("listSubAgents() (v4.0.0 API) is unchanged and doesn't include pending dispatches", async () => {
|
|
153
|
+
const watcher = await import("../src/services/async-agent-watcher.js");
|
|
154
|
+
watcher.registerPendingAgent({
|
|
155
|
+
agentId: "alvin-isolated",
|
|
156
|
+
outputFile: `${TEST_DATA_DIR}/iso.jsonl`,
|
|
157
|
+
description: "isolated",
|
|
158
|
+
prompt: "p",
|
|
159
|
+
chatId: 1,
|
|
160
|
+
userId: 1,
|
|
161
|
+
toolUseId: null,
|
|
162
|
+
sessionKey: "s",
|
|
163
|
+
platform: "telegram",
|
|
164
|
+
});
|
|
165
|
+
const mod = await import("../src/services/subagents.js");
|
|
166
|
+
// The original listSubAgents is kept pure — only the merged helper
|
|
167
|
+
// returns combined results. Cancel/result paths still use the
|
|
168
|
+
// bot-level registry.
|
|
169
|
+
expect(mod.listSubAgents()).toHaveLength(0);
|
|
170
|
+
expect(await mod.listActiveSubAgents()).toHaveLength(1);
|
|
171
|
+
});
|
|
172
|
+
});
|
|
@@ -0,0 +1,232 @@
|
|
|
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: 8425689727,
|
|
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(8425689727);
|
|
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
|
+
});
|