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,417 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* v4.12.3 — Stress + edge-case tests for the bypass path.
|
|
3
|
-
*
|
|
4
|
-
* These tests exercise scenarios that aren't part of the happy path
|
|
5
|
-
* but should hold up in real-world use:
|
|
6
|
-
* - Many parallel sessions
|
|
7
|
-
* - Rapid churn (launch/deliver cycles)
|
|
8
|
-
* - Memory hygiene (no residual in-memory state after delivery)
|
|
9
|
-
* - Race conditions: delivery fires while counter is mid-update
|
|
10
|
-
* - Extreme counter drift (more deliveries than launches)
|
|
11
|
-
* - waitUntilProcessingFalse timeout paths
|
|
12
|
-
*/
|
|
13
|
-
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
|
14
|
-
import fs from "fs";
|
|
15
|
-
import os from "os";
|
|
16
|
-
import { resolve } from "path";
|
|
17
|
-
|
|
18
|
-
const TEST_DATA_DIR = resolve(
|
|
19
|
-
os.tmpdir(),
|
|
20
|
-
`alvin-bypass-stress-${process.pid}-${Date.now()}`,
|
|
21
|
-
);
|
|
22
|
-
|
|
23
|
-
beforeEach(async () => {
|
|
24
|
-
if (fs.existsSync(TEST_DATA_DIR)) {
|
|
25
|
-
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
|
26
|
-
}
|
|
27
|
-
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
|
|
28
|
-
process.env.ALVIN_DATA_DIR = TEST_DATA_DIR;
|
|
29
|
-
vi.resetModules();
|
|
30
|
-
vi.doMock("../src/services/subagent-delivery.js", () => ({
|
|
31
|
-
deliverSubAgentResult: async () => {},
|
|
32
|
-
attachBotApi: () => {},
|
|
33
|
-
__setBotApiForTest: () => {},
|
|
34
|
-
}));
|
|
35
|
-
});
|
|
36
|
-
|
|
37
|
-
afterEach(async () => {
|
|
38
|
-
try {
|
|
39
|
-
const mod = await import("../src/services/async-agent-watcher.js");
|
|
40
|
-
mod.stopWatcher();
|
|
41
|
-
mod.__resetForTest();
|
|
42
|
-
} catch {
|
|
43
|
-
/* ignore */
|
|
44
|
-
}
|
|
45
|
-
});
|
|
46
|
-
|
|
47
|
-
function writeCompletedJsonl(path: string, text: string): void {
|
|
48
|
-
const lines =
|
|
49
|
-
[
|
|
50
|
-
JSON.stringify({
|
|
51
|
-
type: "assistant",
|
|
52
|
-
isSidechain: true,
|
|
53
|
-
agentId: "x",
|
|
54
|
-
message: {
|
|
55
|
-
role: "assistant",
|
|
56
|
-
content: [{ type: "text", text }],
|
|
57
|
-
stop_reason: "end_turn",
|
|
58
|
-
usage: { input_tokens: 1, output_tokens: 1 },
|
|
59
|
-
},
|
|
60
|
-
}),
|
|
61
|
-
].join("\n") + "\n";
|
|
62
|
-
fs.mkdirSync(resolve(path, ".."), { recursive: true });
|
|
63
|
-
fs.writeFileSync(path, lines, "utf-8");
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
describe("v4.12.3 bypass — stress + edge cases", () => {
|
|
67
|
-
it("100 parallel sessions each launch and deliver one agent — counters isolated", async () => {
|
|
68
|
-
const { getSession } = await import("../src/services/session.js");
|
|
69
|
-
const { handleToolResultChunk } = await import(
|
|
70
|
-
"../src/handlers/async-agent-chunk-handler.js"
|
|
71
|
-
);
|
|
72
|
-
const watcher = await import("../src/services/async-agent-watcher.js");
|
|
73
|
-
|
|
74
|
-
const N = 100;
|
|
75
|
-
const sessionKeys: string[] = [];
|
|
76
|
-
|
|
77
|
-
// Launch phase
|
|
78
|
-
for (let i = 0; i < N; i++) {
|
|
79
|
-
const sk = `stress-parallel-${i}`;
|
|
80
|
-
sessionKeys.push(sk);
|
|
81
|
-
const s = getSession(sk);
|
|
82
|
-
s.pendingBackgroundCount = 0;
|
|
83
|
-
|
|
84
|
-
const outPath = `${TEST_DATA_DIR}/p-${i}.jsonl`;
|
|
85
|
-
handleToolResultChunk(
|
|
86
|
-
{
|
|
87
|
-
type: "tool_result",
|
|
88
|
-
toolUseId: `p_${i}`,
|
|
89
|
-
toolResultContent:
|
|
90
|
-
"Async agent launched successfully.\n" +
|
|
91
|
-
`agentId: p-${i}\n` +
|
|
92
|
-
`output_file: ${outPath}\n`,
|
|
93
|
-
},
|
|
94
|
-
{
|
|
95
|
-
chatId: i,
|
|
96
|
-
userId: i,
|
|
97
|
-
sessionKey: sk,
|
|
98
|
-
lastToolUseInput: { description: `task ${i}`, prompt: "p" },
|
|
99
|
-
},
|
|
100
|
-
);
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
// Verify all have count=1
|
|
104
|
-
for (const sk of sessionKeys) {
|
|
105
|
-
expect(getSession(sk).pendingBackgroundCount).toBe(1);
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
// Complete phase
|
|
109
|
-
for (let i = 0; i < N; i++) {
|
|
110
|
-
writeCompletedJsonl(`${TEST_DATA_DIR}/p-${i}.jsonl`, `done ${i}`);
|
|
111
|
-
}
|
|
112
|
-
await watcher.pollOnce();
|
|
113
|
-
|
|
114
|
-
// Verify all back to 0
|
|
115
|
-
for (const sk of sessionKeys) {
|
|
116
|
-
expect(getSession(sk).pendingBackgroundCount).toBe(0);
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
// Verify watcher in-memory state is empty
|
|
120
|
-
expect(watcher.listPendingAgents()).toHaveLength(0);
|
|
121
|
-
});
|
|
122
|
-
|
|
123
|
-
it("churn: 200 rapid launch/deliver cycles on one session — counter stays [0,1]", async () => {
|
|
124
|
-
const { getSession } = await import("../src/services/session.js");
|
|
125
|
-
const { handleToolResultChunk } = await import(
|
|
126
|
-
"../src/handlers/async-agent-chunk-handler.js"
|
|
127
|
-
);
|
|
128
|
-
const watcher = await import("../src/services/async-agent-watcher.js");
|
|
129
|
-
|
|
130
|
-
const sk = "churn-hot";
|
|
131
|
-
const s = getSession(sk);
|
|
132
|
-
s.pendingBackgroundCount = 0;
|
|
133
|
-
|
|
134
|
-
for (let i = 0; i < 200; i++) {
|
|
135
|
-
const outPath = `${TEST_DATA_DIR}/churn-${i}.jsonl`;
|
|
136
|
-
handleToolResultChunk(
|
|
137
|
-
{
|
|
138
|
-
type: "tool_result",
|
|
139
|
-
toolUseId: `c_${i}`,
|
|
140
|
-
toolResultContent:
|
|
141
|
-
"Async agent launched successfully.\n" +
|
|
142
|
-
`agentId: c-${i}\n` +
|
|
143
|
-
`output_file: ${outPath}\n`,
|
|
144
|
-
},
|
|
145
|
-
{
|
|
146
|
-
chatId: 1,
|
|
147
|
-
userId: 1,
|
|
148
|
-
sessionKey: sk,
|
|
149
|
-
lastToolUseInput: { description: `task ${i}`, prompt: "p" },
|
|
150
|
-
},
|
|
151
|
-
);
|
|
152
|
-
expect(s.pendingBackgroundCount).toBe(1);
|
|
153
|
-
|
|
154
|
-
writeCompletedJsonl(outPath, `done ${i}`);
|
|
155
|
-
await watcher.pollOnce();
|
|
156
|
-
expect(s.pendingBackgroundCount).toBe(0);
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
// Final sanity
|
|
160
|
-
expect(watcher.listPendingAgents()).toHaveLength(0);
|
|
161
|
-
});
|
|
162
|
-
|
|
163
|
-
it("extreme drift: 10 deliveries but only 1 launch — counter clamps at 0", async () => {
|
|
164
|
-
const { getSession } = await import("../src/services/session.js");
|
|
165
|
-
const watcher = await import("../src/services/async-agent-watcher.js");
|
|
166
|
-
|
|
167
|
-
const sk = "drift-extreme";
|
|
168
|
-
const s = getSession(sk);
|
|
169
|
-
s.pendingBackgroundCount = 1;
|
|
170
|
-
|
|
171
|
-
// Register 10 agents to the same session, but keep the counter at 1
|
|
172
|
-
// (simulating a scenario where the handler increment got lost on 9 of them)
|
|
173
|
-
for (let i = 0; i < 10; i++) {
|
|
174
|
-
const outPath = `${TEST_DATA_DIR}/drift-${i}.jsonl`;
|
|
175
|
-
watcher.registerPendingAgent({
|
|
176
|
-
agentId: `drift-${i}`,
|
|
177
|
-
outputFile: outPath,
|
|
178
|
-
description: `drift ${i}`,
|
|
179
|
-
prompt: "p",
|
|
180
|
-
chatId: 1,
|
|
181
|
-
userId: 1,
|
|
182
|
-
toolUseId: null,
|
|
183
|
-
sessionKey: sk,
|
|
184
|
-
});
|
|
185
|
-
writeCompletedJsonl(outPath, `done ${i}`);
|
|
186
|
-
}
|
|
187
|
-
|
|
188
|
-
await watcher.pollOnce();
|
|
189
|
-
|
|
190
|
-
// First delivery takes counter from 1 → 0.
|
|
191
|
-
// The next 9 deliveries try to decrement from 0 and clamp.
|
|
192
|
-
expect(s.pendingBackgroundCount).toBe(0);
|
|
193
|
-
});
|
|
194
|
-
|
|
195
|
-
it("user /new during pending — counter reset is safe", async () => {
|
|
196
|
-
const { getSession, resetSession } = await import("../src/services/session.js");
|
|
197
|
-
const { handleToolResultChunk } = await import(
|
|
198
|
-
"../src/handlers/async-agent-chunk-handler.js"
|
|
199
|
-
);
|
|
200
|
-
const watcher = await import("../src/services/async-agent-watcher.js");
|
|
201
|
-
|
|
202
|
-
const sk = "reset-during-pending";
|
|
203
|
-
const s = getSession(sk);
|
|
204
|
-
s.pendingBackgroundCount = 0;
|
|
205
|
-
|
|
206
|
-
// Launch 3 agents
|
|
207
|
-
for (let i = 0; i < 3; i++) {
|
|
208
|
-
const outPath = `${TEST_DATA_DIR}/reset-${i}.jsonl`;
|
|
209
|
-
handleToolResultChunk(
|
|
210
|
-
{
|
|
211
|
-
type: "tool_result",
|
|
212
|
-
toolUseId: `r_${i}`,
|
|
213
|
-
toolResultContent:
|
|
214
|
-
"Async agent launched successfully.\n" +
|
|
215
|
-
`agentId: reset-${i}\n` +
|
|
216
|
-
`output_file: ${outPath}\n`,
|
|
217
|
-
},
|
|
218
|
-
{
|
|
219
|
-
chatId: 1,
|
|
220
|
-
userId: 1,
|
|
221
|
-
sessionKey: sk,
|
|
222
|
-
lastToolUseInput: { description: `task ${i}`, prompt: "p" },
|
|
223
|
-
},
|
|
224
|
-
);
|
|
225
|
-
}
|
|
226
|
-
expect(s.pendingBackgroundCount).toBe(3);
|
|
227
|
-
|
|
228
|
-
// User issues /new while all 3 are running
|
|
229
|
-
resetSession(sk);
|
|
230
|
-
expect(s.pendingBackgroundCount).toBe(0);
|
|
231
|
-
|
|
232
|
-
// Watcher delivers all 3 afterwards
|
|
233
|
-
for (let i = 0; i < 3; i++) {
|
|
234
|
-
writeCompletedJsonl(`${TEST_DATA_DIR}/reset-${i}.jsonl`, `done ${i}`);
|
|
235
|
-
}
|
|
236
|
-
await watcher.pollOnce();
|
|
237
|
-
|
|
238
|
-
// Counter should remain 0 (clamped)
|
|
239
|
-
expect(s.pendingBackgroundCount).toBe(0);
|
|
240
|
-
});
|
|
241
|
-
|
|
242
|
-
it("session removed from Map before delivery — decrement is no-op, no crash", async () => {
|
|
243
|
-
const { getAllSessions } = await import("../src/services/session.js");
|
|
244
|
-
const { handleToolResultChunk } = await import(
|
|
245
|
-
"../src/handlers/async-agent-chunk-handler.js"
|
|
246
|
-
);
|
|
247
|
-
const watcher = await import("../src/services/async-agent-watcher.js");
|
|
248
|
-
|
|
249
|
-
const sk = "ephemeral-session";
|
|
250
|
-
const s = getAllSessions();
|
|
251
|
-
// Use the standard path to ensure getSession works first
|
|
252
|
-
const { getSession } = await import("../src/services/session.js");
|
|
253
|
-
const session = getSession(sk);
|
|
254
|
-
session.pendingBackgroundCount = 0;
|
|
255
|
-
|
|
256
|
-
const outPath = `${TEST_DATA_DIR}/eph.jsonl`;
|
|
257
|
-
handleToolResultChunk(
|
|
258
|
-
{
|
|
259
|
-
type: "tool_result",
|
|
260
|
-
toolUseId: "eph_1",
|
|
261
|
-
toolResultContent:
|
|
262
|
-
"Async agent launched successfully.\n" +
|
|
263
|
-
"agentId: eph-1\n" +
|
|
264
|
-
`output_file: ${outPath}\n`,
|
|
265
|
-
},
|
|
266
|
-
{
|
|
267
|
-
chatId: 1,
|
|
268
|
-
userId: 1,
|
|
269
|
-
sessionKey: sk,
|
|
270
|
-
lastToolUseInput: { description: "d", prompt: "p" },
|
|
271
|
-
},
|
|
272
|
-
);
|
|
273
|
-
expect(session.pendingBackgroundCount).toBe(1);
|
|
274
|
-
|
|
275
|
-
// Nuke the session from the map (simulates TTL cleanup)
|
|
276
|
-
s.delete(sk);
|
|
277
|
-
|
|
278
|
-
writeCompletedJsonl(outPath, "done");
|
|
279
|
-
await expect(watcher.pollOnce()).resolves.not.toThrow();
|
|
280
|
-
});
|
|
281
|
-
|
|
282
|
-
it("mixed rollout: pre-v4.12.3 persisted entries (no sessionKey) mixed with new entries", async () => {
|
|
283
|
-
const { getSession } = await import("../src/services/session.js");
|
|
284
|
-
const watcher = await import("../src/services/async-agent-watcher.js");
|
|
285
|
-
|
|
286
|
-
// v4.12.3 session with counter
|
|
287
|
-
const sk = "mixed-v412";
|
|
288
|
-
const s = getSession(sk);
|
|
289
|
-
s.pendingBackgroundCount = 1;
|
|
290
|
-
|
|
291
|
-
// New-style entry with sessionKey
|
|
292
|
-
const newPath = `${TEST_DATA_DIR}/new.jsonl`;
|
|
293
|
-
watcher.registerPendingAgent({
|
|
294
|
-
agentId: "new-agent",
|
|
295
|
-
outputFile: newPath,
|
|
296
|
-
description: "new",
|
|
297
|
-
prompt: "p",
|
|
298
|
-
chatId: 1,
|
|
299
|
-
userId: 1,
|
|
300
|
-
toolUseId: null,
|
|
301
|
-
sessionKey: sk,
|
|
302
|
-
});
|
|
303
|
-
|
|
304
|
-
// Old-style entry without sessionKey (pre-v4.12.3)
|
|
305
|
-
const oldPath = `${TEST_DATA_DIR}/old.jsonl`;
|
|
306
|
-
watcher.registerPendingAgent({
|
|
307
|
-
agentId: "old-agent",
|
|
308
|
-
outputFile: oldPath,
|
|
309
|
-
description: "old",
|
|
310
|
-
prompt: "p",
|
|
311
|
-
chatId: 2,
|
|
312
|
-
userId: 2,
|
|
313
|
-
toolUseId: null,
|
|
314
|
-
// sessionKey intentionally omitted
|
|
315
|
-
});
|
|
316
|
-
|
|
317
|
-
writeCompletedJsonl(newPath, "new done");
|
|
318
|
-
writeCompletedJsonl(oldPath, "old done");
|
|
319
|
-
await watcher.pollOnce();
|
|
320
|
-
|
|
321
|
-
// New agent decrements our counter; old agent is a no-op
|
|
322
|
-
expect(s.pendingBackgroundCount).toBe(0);
|
|
323
|
-
expect(watcher.listPendingAgents()).toHaveLength(0);
|
|
324
|
-
});
|
|
325
|
-
|
|
326
|
-
it("waitUntilProcessingFalse: flag flips right at the tick boundary", async () => {
|
|
327
|
-
const { waitUntilProcessingFalse } = await import(
|
|
328
|
-
"../src/handlers/background-bypass.js"
|
|
329
|
-
);
|
|
330
|
-
const session = { isProcessing: true };
|
|
331
|
-
// Start waiting, then flip asynchronously
|
|
332
|
-
const waitPromise = waitUntilProcessingFalse(session, 2000, 10);
|
|
333
|
-
setTimeout(() => { session.isProcessing = false; }, 15);
|
|
334
|
-
const result = await waitPromise;
|
|
335
|
-
expect(result).toBe(true);
|
|
336
|
-
});
|
|
337
|
-
|
|
338
|
-
it("waitUntilProcessingFalse: timeout respected", async () => {
|
|
339
|
-
const { waitUntilProcessingFalse } = await import(
|
|
340
|
-
"../src/handlers/background-bypass.js"
|
|
341
|
-
);
|
|
342
|
-
const session = { isProcessing: true };
|
|
343
|
-
const start = Date.now();
|
|
344
|
-
const result = await waitUntilProcessingFalse(session, 200, 25);
|
|
345
|
-
const elapsed = Date.now() - start;
|
|
346
|
-
expect(result).toBe(false);
|
|
347
|
-
expect(elapsed).toBeGreaterThanOrEqual(180); // allow small jitter
|
|
348
|
-
expect(elapsed).toBeLessThan(400);
|
|
349
|
-
});
|
|
350
|
-
|
|
351
|
-
it(
|
|
352
|
-
"high load: 50 sessions, each with 4 parallel agents (200 total) — " +
|
|
353
|
-
"all deliver, all counters return to 0",
|
|
354
|
-
async () => {
|
|
355
|
-
const { getSession } = await import("../src/services/session.js");
|
|
356
|
-
const { handleToolResultChunk } = await import(
|
|
357
|
-
"../src/handlers/async-agent-chunk-handler.js"
|
|
358
|
-
);
|
|
359
|
-
const watcher = await import("../src/services/async-agent-watcher.js");
|
|
360
|
-
|
|
361
|
-
const S = 50;
|
|
362
|
-
const A = 4;
|
|
363
|
-
const sessionKeys: string[] = [];
|
|
364
|
-
const allPaths: string[] = [];
|
|
365
|
-
|
|
366
|
-
for (let i = 0; i < S; i++) {
|
|
367
|
-
const sk = `load-s-${i}`;
|
|
368
|
-
sessionKeys.push(sk);
|
|
369
|
-
const s = getSession(sk);
|
|
370
|
-
s.pendingBackgroundCount = 0;
|
|
371
|
-
|
|
372
|
-
for (let j = 0; j < A; j++) {
|
|
373
|
-
const outPath = `${TEST_DATA_DIR}/load-${i}-${j}.jsonl`;
|
|
374
|
-
allPaths.push(outPath);
|
|
375
|
-
handleToolResultChunk(
|
|
376
|
-
{
|
|
377
|
-
type: "tool_result",
|
|
378
|
-
toolUseId: `load_${i}_${j}`,
|
|
379
|
-
toolResultContent:
|
|
380
|
-
"Async agent launched successfully.\n" +
|
|
381
|
-
`agentId: load-${i}-${j}\n` +
|
|
382
|
-
`output_file: ${outPath}\n`,
|
|
383
|
-
},
|
|
384
|
-
{
|
|
385
|
-
chatId: i,
|
|
386
|
-
userId: i,
|
|
387
|
-
sessionKey: sk,
|
|
388
|
-
lastToolUseInput: {
|
|
389
|
-
description: `task ${i}-${j}`,
|
|
390
|
-
prompt: "p",
|
|
391
|
-
},
|
|
392
|
-
},
|
|
393
|
-
);
|
|
394
|
-
}
|
|
395
|
-
}
|
|
396
|
-
|
|
397
|
-
// Every session has A agents pending
|
|
398
|
-
for (const sk of sessionKeys) {
|
|
399
|
-
expect(getSession(sk).pendingBackgroundCount).toBe(A);
|
|
400
|
-
}
|
|
401
|
-
|
|
402
|
-
// Deliver all
|
|
403
|
-
for (const p of allPaths) {
|
|
404
|
-
writeCompletedJsonl(p, "done");
|
|
405
|
-
}
|
|
406
|
-
await watcher.pollOnce();
|
|
407
|
-
|
|
408
|
-
// All counters back to 0
|
|
409
|
-
for (const sk of sessionKeys) {
|
|
410
|
-
expect(getSession(sk).pendingBackgroundCount).toBe(0);
|
|
411
|
-
}
|
|
412
|
-
|
|
413
|
-
// No residual state
|
|
414
|
-
expect(watcher.listPendingAgents()).toHaveLength(0);
|
|
415
|
-
},
|
|
416
|
-
);
|
|
417
|
-
});
|
|
@@ -1,127 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* v4.12.3 — background-bypass pure helpers.
|
|
3
|
-
*
|
|
4
|
-
* These helpers factor out the SDK-resume-bypass decision from the
|
|
5
|
-
* message handler so it can be unit tested without grammy Context
|
|
6
|
-
* mocks. The real handler composes these functions — they're only
|
|
7
|
-
* state machines over session fields + time.
|
|
8
|
-
*/
|
|
9
|
-
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
|
10
|
-
import {
|
|
11
|
-
shouldBypassQueue,
|
|
12
|
-
shouldBypassSdkResume,
|
|
13
|
-
waitUntilProcessingFalse,
|
|
14
|
-
} from "../src/handlers/background-bypass.js";
|
|
15
|
-
|
|
16
|
-
describe("shouldBypassQueue (v4.12.3)", () => {
|
|
17
|
-
it("returns false when session is not processing", () => {
|
|
18
|
-
expect(
|
|
19
|
-
shouldBypassQueue({
|
|
20
|
-
isProcessing: false,
|
|
21
|
-
pendingBackgroundCount: 5,
|
|
22
|
-
abortController: new AbortController(),
|
|
23
|
-
}),
|
|
24
|
-
).toBe(false);
|
|
25
|
-
});
|
|
26
|
-
|
|
27
|
-
it("returns false when no background agent is pending", () => {
|
|
28
|
-
expect(
|
|
29
|
-
shouldBypassQueue({
|
|
30
|
-
isProcessing: true,
|
|
31
|
-
pendingBackgroundCount: 0,
|
|
32
|
-
abortController: new AbortController(),
|
|
33
|
-
}),
|
|
34
|
-
).toBe(false);
|
|
35
|
-
});
|
|
36
|
-
|
|
37
|
-
it("returns false when no abortController exists (can't abort)", () => {
|
|
38
|
-
expect(
|
|
39
|
-
shouldBypassQueue({
|
|
40
|
-
isProcessing: true,
|
|
41
|
-
pendingBackgroundCount: 2,
|
|
42
|
-
abortController: null,
|
|
43
|
-
}),
|
|
44
|
-
).toBe(false);
|
|
45
|
-
});
|
|
46
|
-
|
|
47
|
-
it("returns true when processing, background pending, and abortable", () => {
|
|
48
|
-
expect(
|
|
49
|
-
shouldBypassQueue({
|
|
50
|
-
isProcessing: true,
|
|
51
|
-
pendingBackgroundCount: 1,
|
|
52
|
-
abortController: new AbortController(),
|
|
53
|
-
}),
|
|
54
|
-
).toBe(true);
|
|
55
|
-
});
|
|
56
|
-
|
|
57
|
-
it("returns true even with multiple pending agents", () => {
|
|
58
|
-
expect(
|
|
59
|
-
shouldBypassQueue({
|
|
60
|
-
isProcessing: true,
|
|
61
|
-
pendingBackgroundCount: 3,
|
|
62
|
-
abortController: new AbortController(),
|
|
63
|
-
}),
|
|
64
|
-
).toBe(true);
|
|
65
|
-
});
|
|
66
|
-
|
|
67
|
-
it("returns false if abortController is already aborted — nothing left to abort", () => {
|
|
68
|
-
const ac = new AbortController();
|
|
69
|
-
ac.abort();
|
|
70
|
-
expect(
|
|
71
|
-
shouldBypassQueue({
|
|
72
|
-
isProcessing: true,
|
|
73
|
-
pendingBackgroundCount: 1,
|
|
74
|
-
abortController: ac,
|
|
75
|
-
}),
|
|
76
|
-
).toBe(false);
|
|
77
|
-
});
|
|
78
|
-
});
|
|
79
|
-
|
|
80
|
-
describe("shouldBypassSdkResume (v4.12.3)", () => {
|
|
81
|
-
it("returns true when pendingBackgroundCount > 0 — old SDK session is blocked, need fresh", () => {
|
|
82
|
-
expect(shouldBypassSdkResume({ pendingBackgroundCount: 1 })).toBe(true);
|
|
83
|
-
expect(shouldBypassSdkResume({ pendingBackgroundCount: 5 })).toBe(true);
|
|
84
|
-
});
|
|
85
|
-
|
|
86
|
-
it("returns false when no background pending — safe to resume", () => {
|
|
87
|
-
expect(shouldBypassSdkResume({ pendingBackgroundCount: 0 })).toBe(false);
|
|
88
|
-
});
|
|
89
|
-
});
|
|
90
|
-
|
|
91
|
-
describe("waitUntilProcessingFalse (v4.12.3)", () => {
|
|
92
|
-
beforeEach(() => vi.useFakeTimers());
|
|
93
|
-
afterEach(() => vi.useRealTimers());
|
|
94
|
-
|
|
95
|
-
it("resolves immediately when already not processing", async () => {
|
|
96
|
-
const session = { isProcessing: false };
|
|
97
|
-
const p = waitUntilProcessingFalse(session, 5000);
|
|
98
|
-
await vi.advanceTimersByTimeAsync(0);
|
|
99
|
-
await expect(p).resolves.toBe(true);
|
|
100
|
-
});
|
|
101
|
-
|
|
102
|
-
it("waits until isProcessing flips, then resolves true", async () => {
|
|
103
|
-
const session = { isProcessing: true };
|
|
104
|
-
const p = waitUntilProcessingFalse(session, 5000);
|
|
105
|
-
await vi.advanceTimersByTimeAsync(200);
|
|
106
|
-
session.isProcessing = false;
|
|
107
|
-
await vi.advanceTimersByTimeAsync(100);
|
|
108
|
-
await expect(p).resolves.toBe(true);
|
|
109
|
-
});
|
|
110
|
-
|
|
111
|
-
it("gives up after timeout if still processing, resolves false", async () => {
|
|
112
|
-
const session = { isProcessing: true };
|
|
113
|
-
const p = waitUntilProcessingFalse(session, 1000);
|
|
114
|
-
await vi.advanceTimersByTimeAsync(1100);
|
|
115
|
-
await expect(p).resolves.toBe(false);
|
|
116
|
-
});
|
|
117
|
-
|
|
118
|
-
it("uses the provided tick interval (default 50ms)", async () => {
|
|
119
|
-
const session = { isProcessing: true };
|
|
120
|
-
const p = waitUntilProcessingFalse(session, 500, 25);
|
|
121
|
-
// Flip after 130ms of "waiting" — should detect on the next 25ms tick
|
|
122
|
-
await vi.advanceTimersByTimeAsync(130);
|
|
123
|
-
session.isProcessing = false;
|
|
124
|
-
await vi.advanceTimersByTimeAsync(30);
|
|
125
|
-
await expect(p).resolves.toBe(true);
|
|
126
|
-
});
|
|
127
|
-
});
|
|
@@ -1,121 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Fix #11 (minimal) — webfetch Tier 0 for browser-manager.
|
|
3
|
-
*
|
|
4
|
-
* Background: the current browser fallback chain is
|
|
5
|
-
* gateway → cdp → hub-stealth → cli
|
|
6
|
-
* Every tier spawns playwright (or talks to a CDP-controlled Chrome),
|
|
7
|
-
* which is slow and occasionally impossible under load. Many scraping
|
|
8
|
-
* tasks only need plain HTTP — an RSS feed, a JSON API, an OG meta-
|
|
9
|
-
* tag sniff. For those, Node's native `fetch` is 100× faster and
|
|
10
|
-
* doesn't need a browser at all.
|
|
11
|
-
*
|
|
12
|
-
* Contract: `webfetchNavigate(url)` returns `{ title, url }` for a
|
|
13
|
-
* successful GET, or throws a distinct `WebfetchFailed` error that the
|
|
14
|
-
* cascade can catch and fall through to the next tier. Title is the
|
|
15
|
-
* first `<title>` tag content; if none, the URL is returned.
|
|
16
|
-
*
|
|
17
|
-
* Keep it small — this is a Tier 0 helper, not a full scraper.
|
|
18
|
-
*/
|
|
19
|
-
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
20
|
-
import {
|
|
21
|
-
webfetchNavigate,
|
|
22
|
-
WebfetchFailed,
|
|
23
|
-
parseTitle,
|
|
24
|
-
} from "../src/services/browser-webfetch.js";
|
|
25
|
-
|
|
26
|
-
describe("parseTitle (Fix #11)", () => {
|
|
27
|
-
it("extracts a simple <title>", () => {
|
|
28
|
-
expect(parseTitle("<html><head><title>Hello World</title></head></html>")).toBe("Hello World");
|
|
29
|
-
});
|
|
30
|
-
|
|
31
|
-
it("handles whitespace and newlines", () => {
|
|
32
|
-
expect(parseTitle("<title>\n Multi line \n</title>")).toBe("Multi line");
|
|
33
|
-
});
|
|
34
|
-
|
|
35
|
-
it("returns empty string when there's no title", () => {
|
|
36
|
-
expect(parseTitle("<html><body>no title</body></html>")).toBe("");
|
|
37
|
-
});
|
|
38
|
-
|
|
39
|
-
it("decodes basic HTML entities", () => {
|
|
40
|
-
expect(parseTitle("<title>A & B</title>")).toBe("A & B");
|
|
41
|
-
expect(parseTitle("<title>"quoted"</title>")).toBe('"quoted"');
|
|
42
|
-
});
|
|
43
|
-
|
|
44
|
-
it("is case-insensitive for the tag name", () => {
|
|
45
|
-
expect(parseTitle("<HEAD><TITLE>Foo</TITLE></HEAD>")).toBe("Foo");
|
|
46
|
-
});
|
|
47
|
-
});
|
|
48
|
-
|
|
49
|
-
describe("webfetchNavigate (Fix #11)", () => {
|
|
50
|
-
let originalFetch: typeof fetch;
|
|
51
|
-
|
|
52
|
-
beforeEach(() => {
|
|
53
|
-
originalFetch = globalThis.fetch;
|
|
54
|
-
});
|
|
55
|
-
afterEach(() => {
|
|
56
|
-
globalThis.fetch = originalFetch;
|
|
57
|
-
});
|
|
58
|
-
|
|
59
|
-
it("returns title + url on a 200 response", async () => {
|
|
60
|
-
globalThis.fetch = vi.fn(async () =>
|
|
61
|
-
new Response(
|
|
62
|
-
"<html><head><title>GitHub · alvbln/alvin-bot</title></head></html>",
|
|
63
|
-
{ status: 200, headers: { "content-type": "text/html" } },
|
|
64
|
-
),
|
|
65
|
-
) as unknown as typeof fetch;
|
|
66
|
-
|
|
67
|
-
const result = await webfetchNavigate("https://github.com/alvbln/alvin-bot");
|
|
68
|
-
expect(result.title).toBe("GitHub · alvbln/alvin-bot");
|
|
69
|
-
expect(result.url).toBe("https://github.com/alvbln/alvin-bot");
|
|
70
|
-
});
|
|
71
|
-
|
|
72
|
-
it("throws WebfetchFailed with the HTTP status on 4xx/5xx", async () => {
|
|
73
|
-
globalThis.fetch = vi.fn(async () =>
|
|
74
|
-
new Response("blocked", { status: 403 }),
|
|
75
|
-
) as unknown as typeof fetch;
|
|
76
|
-
|
|
77
|
-
await expect(webfetchNavigate("https://example.com")).rejects.toThrow(WebfetchFailed);
|
|
78
|
-
try {
|
|
79
|
-
await webfetchNavigate("https://example.com");
|
|
80
|
-
} catch (err) {
|
|
81
|
-
expect((err as WebfetchFailed).status).toBe(403);
|
|
82
|
-
}
|
|
83
|
-
});
|
|
84
|
-
|
|
85
|
-
it("throws WebfetchFailed when the response is not HTML and forceHtml=true", async () => {
|
|
86
|
-
globalThis.fetch = vi.fn(async () =>
|
|
87
|
-
new Response('{"json":true}', {
|
|
88
|
-
status: 200,
|
|
89
|
-
headers: { "content-type": "application/json" },
|
|
90
|
-
}),
|
|
91
|
-
) as unknown as typeof fetch;
|
|
92
|
-
|
|
93
|
-
await expect(
|
|
94
|
-
webfetchNavigate("https://api.example.com/data", { forceHtml: true }),
|
|
95
|
-
).rejects.toThrow(WebfetchFailed);
|
|
96
|
-
});
|
|
97
|
-
|
|
98
|
-
it("accepts non-HTML responses when forceHtml is false (default)", async () => {
|
|
99
|
-
globalThis.fetch = vi.fn(async () =>
|
|
100
|
-
new Response("plain text", {
|
|
101
|
-
status: 200,
|
|
102
|
-
headers: { "content-type": "text/plain" },
|
|
103
|
-
}),
|
|
104
|
-
) as unknown as typeof fetch;
|
|
105
|
-
|
|
106
|
-
const result = await webfetchNavigate("https://example.com/raw");
|
|
107
|
-
// No <title> in plain text → falls back to URL as display title
|
|
108
|
-
expect(result.url).toBe("https://example.com/raw");
|
|
109
|
-
expect(result.title).toBe("https://example.com/raw");
|
|
110
|
-
});
|
|
111
|
-
|
|
112
|
-
it("wraps network errors in WebfetchFailed so the cascade can catch a single type", async () => {
|
|
113
|
-
globalThis.fetch = vi.fn(async () => {
|
|
114
|
-
throw new Error("getaddrinfo ENOTFOUND nonexistent.invalid");
|
|
115
|
-
}) as unknown as typeof fetch;
|
|
116
|
-
|
|
117
|
-
await expect(
|
|
118
|
-
webfetchNavigate("https://nonexistent.invalid/"),
|
|
119
|
-
).rejects.toThrow(WebfetchFailed);
|
|
120
|
-
});
|
|
121
|
-
});
|