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.
Files changed (74) hide show
  1. package/AEC-PLUGINS-SOURCES.md +53 -0
  2. package/CHANGELOG.md +37 -2
  3. package/DESIGN-SKILLS-SOURCES.md +81 -0
  4. package/bin/cli.js +1 -1
  5. package/dist/providers/claude-sdk-provider.js +24 -0
  6. package/package.json +3 -1
  7. package/test/allowed-users-gate.test.ts +0 -98
  8. package/test/alvin-dispatch.test.ts +0 -220
  9. package/test/async-agent-chunk-flow.test.ts +0 -244
  10. package/test/async-agent-parser-staleness.test.ts +0 -412
  11. package/test/async-agent-parser-streamjson.test.ts +0 -273
  12. package/test/async-agent-parser.test.ts +0 -322
  13. package/test/async-agent-watcher.test.ts +0 -229
  14. package/test/background-bypass-integration.test.ts +0 -443
  15. package/test/background-bypass-stress.test.ts +0 -417
  16. package/test/background-bypass.test.ts +0 -127
  17. package/test/browser-webfetch.test.ts +0 -121
  18. package/test/claude-sdk-provider.test.ts +0 -115
  19. package/test/claude-sdk-tool-use-id.test.ts +0 -180
  20. package/test/console-timestamps.test.ts +0 -98
  21. package/test/cron-progress-ticker.test.ts +0 -76
  22. package/test/cron-restart-resilience.test.ts +0 -191
  23. package/test/cron-run-resolver.test.ts +0 -133
  24. package/test/cron-runjobnow-throw.test.ts +0 -100
  25. package/test/debounce.test.ts +0 -60
  26. package/test/delivery-registry.test.ts +0 -71
  27. package/test/exec-guard-metachars.test.ts +0 -110
  28. package/test/file-permissions.test.ts +0 -130
  29. package/test/i18n.test.ts +0 -108
  30. package/test/list-subagents-merged.test.ts +0 -172
  31. package/test/memory-extractor.test.ts +0 -151
  32. package/test/memory-layers.test.ts +0 -169
  33. package/test/memory-sdk-injection.test.ts +0 -146
  34. package/test/memory-stress-restart.test.ts +0 -337
  35. package/test/multi-session-stress.test.ts +0 -255
  36. package/test/platform-session-key.test.ts +0 -69
  37. package/test/process-manager.test.ts +0 -186
  38. package/test/registry.test.ts +0 -201
  39. package/test/session-pending-background.test.ts +0 -59
  40. package/test/session-persistence.test.ts +0 -195
  41. package/test/slack-progress-ticker.test.ts +0 -123
  42. package/test/slack-slash-command.test.ts +0 -61
  43. package/test/slack-test-connection.test.ts +0 -176
  44. package/test/stress-scenarios.test.ts +0 -356
  45. package/test/stuck-timer.test.ts +0 -116
  46. package/test/subagent-delivery-markdown-fallback.test.ts +0 -147
  47. package/test/subagent-delivery-platform-routing.test.ts +0 -232
  48. package/test/subagent-delivery.test.ts +0 -273
  49. package/test/subagent-final-text.test.ts +0 -132
  50. package/test/subagent-stats.test.ts +0 -119
  51. package/test/subagent-toolset-allowlist.test.ts +0 -146
  52. package/test/subagents-commands.test.ts +0 -64
  53. package/test/subagents-config.test.ts +0 -114
  54. package/test/subagents-depth.test.ts +0 -58
  55. package/test/subagents-inheritance.test.ts +0 -67
  56. package/test/subagents-name-resolver.test.ts +0 -122
  57. package/test/subagents-priority-reject.test.ts +0 -88
  58. package/test/subagents-queue.test.ts +0 -127
  59. package/test/subagents-shutdown.test.ts +0 -126
  60. package/test/subagents-toolset.test.ts +0 -71
  61. package/test/sync-task-timeout.test.ts +0 -153
  62. package/test/system-prompt-background-hint.test.ts +0 -65
  63. package/test/telegram-error-filter.test.ts +0 -85
  64. package/test/telegram-workspace-command.test.ts +0 -78
  65. package/test/timing-safe-bearer.test.ts +0 -65
  66. package/test/watchdog-brake.test.ts +0 -157
  67. package/test/watcher-pending-count.test.ts +0 -228
  68. package/test/watcher-zombie-fix.test.ts +0 -252
  69. package/test/web-server-integration.test.ts +0 -189
  70. package/test/web-server-resilience.test.ts +0 -118
  71. package/test/web-server-shutdown.test.ts +0 -117
  72. package/test/whatsapp-auth-resilience.test.ts +0 -96
  73. package/test/workspaces.test.ts +0 -196
  74. package/vitest.config.ts +0 -17
@@ -1,176 +0,0 @@
1
- /**
2
- * v4.13.1 — `/api/platforms/test-connection` must accept `slack` as a
3
- * platformId and validate the Bot Token via Slack's auth.test endpoint.
4
- *
5
- * Before v4.13.1, the handler only knew about telegram/discord/signal/
6
- * whatsapp, so slack fell through to "Unknown platform" even when a
7
- * valid xoxb- Bot Token was set.
8
- *
9
- * These tests hit the handler directly (no HTTP server spin-up) and stub
10
- * global fetch so the Slack API is never actually contacted.
11
- */
12
- import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
13
- import { EventEmitter } from "node:events";
14
- import { Writable } from "node:stream";
15
-
16
- /**
17
- * Minimal request/response pair that the setup-api handler expects.
18
- * We capture the body written via res.end(body) so the test can assert
19
- * on the JSON payload.
20
- */
21
- interface FakeIO {
22
- req: EventEmitter & { method: string; url: string; headers: Record<string, string> };
23
- res: Writable & { statusCode: number; headers: Record<string, string>; body: string };
24
- }
25
-
26
- function makeIO(method: string, url: string, body: string): FakeIO {
27
- const req = new EventEmitter() as FakeIO["req"];
28
- req.method = method;
29
- req.url = url;
30
- req.headers = {};
31
-
32
- let captured = "";
33
- const res = new Writable({
34
- write(chunk, _enc, cb) {
35
- captured += chunk.toString();
36
- cb();
37
- },
38
- }) as FakeIO["res"];
39
- res.statusCode = 200;
40
- res.headers = {};
41
- res.setHeader = (k: string, v: string) => {
42
- res.headers[k.toLowerCase()] = v;
43
- return res as any;
44
- };
45
- res.end = (b?: unknown) => {
46
- if (b != null) captured += String(b);
47
- res.body = captured;
48
- return res as any;
49
- };
50
-
51
- return { req, res };
52
- }
53
-
54
- beforeEach(() => {
55
- vi.resetModules();
56
- // Prevent the setup-api module from crashing on BOT_ROOT etc.
57
- process.env.BOT_TOKEN = "";
58
- process.env.SLACK_BOT_TOKEN = "";
59
- process.env.SLACK_APP_TOKEN = "";
60
- });
61
-
62
- afterEach(() => {
63
- vi.unstubAllGlobals();
64
- delete process.env.BOT_TOKEN;
65
- delete process.env.SLACK_BOT_TOKEN;
66
- delete process.env.SLACK_APP_TOKEN;
67
- });
68
-
69
- describe("POST /api/platforms/test-connection — slack (v4.13.1)", () => {
70
- it("returns {ok:false, error: 'SLACK_BOT_TOKEN not set'} when no tokens configured", async () => {
71
- const { handleSetupAPI } = await import("../src/web/setup-api.js");
72
- const { req, res } = makeIO("POST", "/api/platforms/test-connection", "");
73
- const body = JSON.stringify({ platformId: "slack" });
74
-
75
- const handled = await handleSetupAPI(req as any, res as any, "/api/platforms/test-connection", body);
76
- expect(handled).toBe(true);
77
- const parsed = JSON.parse(res.body);
78
- expect(parsed.ok).toBe(false);
79
- expect(parsed.error).toMatch(/SLACK_BOT_TOKEN/);
80
- });
81
-
82
- it("returns {ok:true, info: '...'} when Slack's auth.test accepts the token", async () => {
83
- process.env.SLACK_BOT_TOKEN = "xoxb-fake-valid";
84
- process.env.SLACK_APP_TOKEN = "xapp-fake-valid";
85
-
86
- vi.stubGlobal(
87
- "fetch",
88
- vi.fn(async (url: string) => {
89
- expect(url).toContain("slack.com/api/auth.test");
90
- return {
91
- ok: true,
92
- json: async () => ({
93
- ok: true,
94
- url: "https://my-project.slack.com/",
95
- team: "my-project Workspace",
96
- user: "alvinbot",
97
- team_id: "T123",
98
- user_id: "U456",
99
- bot_id: "B789",
100
- }),
101
- };
102
- }),
103
- );
104
-
105
- const { handleSetupAPI } = await import("../src/web/setup-api.js");
106
- const { req, res } = makeIO("POST", "/api/platforms/test-connection", "");
107
- const body = JSON.stringify({ platformId: "slack" });
108
- await handleSetupAPI(req as any, res as any, "/api/platforms/test-connection", body);
109
-
110
- const parsed = JSON.parse(res.body);
111
- expect(parsed.ok).toBe(true);
112
- expect(parsed.info).toMatch(/alvinbot|my-project/i);
113
- });
114
-
115
- it("returns {ok:false} when Slack's auth.test rejects the token", async () => {
116
- process.env.SLACK_BOT_TOKEN = "xoxb-fake-invalid";
117
- process.env.SLACK_APP_TOKEN = "xapp-fake-invalid";
118
-
119
- vi.stubGlobal(
120
- "fetch",
121
- vi.fn(async () => ({
122
- ok: true,
123
- json: async () => ({ ok: false, error: "invalid_auth" }),
124
- })),
125
- );
126
-
127
- const { handleSetupAPI } = await import("../src/web/setup-api.js");
128
- const { req, res } = makeIO("POST", "/api/platforms/test-connection", "");
129
- const body = JSON.stringify({ platformId: "slack" });
130
- await handleSetupAPI(req as any, res as any, "/api/platforms/test-connection", body);
131
-
132
- const parsed = JSON.parse(res.body);
133
- expect(parsed.ok).toBe(false);
134
- expect(parsed.error).toMatch(/invalid_auth/);
135
- });
136
-
137
- it("warns about missing/invalid App Token format when Bot Token is OK", async () => {
138
- process.env.SLACK_BOT_TOKEN = "xoxb-fake-valid";
139
- process.env.SLACK_APP_TOKEN = "xoxb-not-an-app-token"; // wrong prefix
140
-
141
- vi.stubGlobal(
142
- "fetch",
143
- vi.fn(async () => ({
144
- ok: true,
145
- json: async () => ({
146
- ok: true,
147
- user: "alvinbot",
148
- team: "x",
149
- team_id: "T1",
150
- user_id: "U1",
151
- bot_id: "B1",
152
- }),
153
- })),
154
- );
155
-
156
- const { handleSetupAPI } = await import("../src/web/setup-api.js");
157
- const { req, res } = makeIO("POST", "/api/platforms/test-connection", "");
158
- const body = JSON.stringify({ platformId: "slack" });
159
- await handleSetupAPI(req as any, res as any, "/api/platforms/test-connection", body);
160
-
161
- const parsed = JSON.parse(res.body);
162
- // Bot Token was valid, but we should still note the App Token format issue
163
- expect(parsed.ok).toBe(true);
164
- expect(parsed.info).toMatch(/App.?Token|xapp-/i);
165
- });
166
-
167
- it("still rejects 'slack-workspace' or other typos as unknown (regression guard)", async () => {
168
- const { handleSetupAPI } = await import("../src/web/setup-api.js");
169
- const { req, res } = makeIO("POST", "/api/platforms/test-connection", "");
170
- const body = JSON.stringify({ platformId: "slack-workspace" });
171
- await handleSetupAPI(req as any, res as any, "/api/platforms/test-connection", body);
172
- const parsed = JSON.parse(res.body);
173
- expect(parsed.ok).toBe(false);
174
- expect(parsed.error).toMatch(/Unknown platform/);
175
- });
176
- });
@@ -1,356 +0,0 @@
1
- /**
2
- * Stress scenarios — end-to-end sanity checks that combine multiple
3
- * services under pathological inputs. These are not "happy path" tests;
4
- * they're the "what if everything goes wrong at once" layer.
5
- *
6
- * Scenarios covered:
7
- * 1. Port churn — open/close a web server 20 times with active
8
- * connections on each cycle. No EADDRINUSE ever.
9
- * 2. Scheduler catchup chain — 50 jobs, 10 of which have a
10
- * mid-execution "crash" (lastAttemptAt > lastRunAt within grace),
11
- * 30 past/future mix, 10 disabled. handleStartupCatchup must
12
- * rewind exactly the 10 interrupted ones and leave all others.
13
- * 3. Watchdog brake escalation — simulated crash burst triggers the
14
- * daily cap before the short cap.
15
- * 4. Concurrent runJobNow — 10 parallel calls to the same job
16
- * resolve to 1 "ran" + 9 "already-running", never double-fire.
17
- * 5. Telegram error filter across 50 random grammy errors — no
18
- * false positives, no false negatives on the reference patterns.
19
- */
20
- import { describe, it, expect, beforeEach, vi } from "vitest";
21
- import http from "http";
22
- import { closeHttpServerGracefully as stopWebServer } from "../src/web/server.js";
23
- import {
24
- handleStartupCatchup,
25
- prepareForExecution,
26
- } from "../src/services/cron-scheduling.js";
27
- import {
28
- decideBrakeAction,
29
- DEFAULTS,
30
- } from "../src/services/watchdog-brake.js";
31
- import { isHarmlessTelegramError } from "../src/util/telegram-error-filter.js";
32
- import { resolveJobByNameOrId } from "../src/services/cron-resolver.js";
33
- import type { CronJob } from "../src/services/cron.js";
34
-
35
- function getFreePort(): Promise<number> {
36
- return new Promise((resolve, reject) => {
37
- const s = http.createServer();
38
- s.listen(0, () => {
39
- const addr = s.address();
40
- if (typeof addr === "object" && addr) {
41
- const p = addr.port;
42
- s.close(() => resolve(p));
43
- } else {
44
- reject(new Error("no address"));
45
- }
46
- });
47
- });
48
- }
49
-
50
- function job(overrides: Partial<CronJob>): CronJob {
51
- return {
52
- id: "j",
53
- name: "n",
54
- type: "ai-query",
55
- schedule: "0 8 * * *",
56
- oneShot: false,
57
- payload: { prompt: "x" },
58
- target: { platform: "telegram", chatId: "1" },
59
- enabled: true,
60
- createdAt: 0,
61
- lastRunAt: null,
62
- lastResult: null,
63
- lastError: null,
64
- nextRunAt: null,
65
- runCount: 0,
66
- createdBy: "t",
67
- ...overrides,
68
- };
69
- }
70
-
71
- describe("Stress 1 — port churn", () => {
72
- it("survives 20 open/close cycles with active connections", async () => {
73
- const port = await getFreePort();
74
-
75
- for (let cycle = 0; cycle < 20; cycle++) {
76
- const server = http.createServer((_req, res) => {
77
- res.writeHead(200);
78
- res.write("chunk");
79
- // do NOT end — simulates a hanging client
80
- });
81
- await new Promise<void>((r) => server.listen(port, () => r()));
82
-
83
- // Open 5 simultaneous clients hanging on the response
84
- const clients: http.ClientRequest[] = [];
85
- for (let i = 0; i < 5; i++) {
86
- const req = http.get(`http://127.0.0.1:${port}/h${i}`);
87
- req.on("error", () => { /* expected on close */ });
88
- clients.push(req);
89
- }
90
- // Give them a tick to actually connect
91
- await new Promise((r) => setImmediate(r));
92
-
93
- const t0 = Date.now();
94
- await stopWebServer(server);
95
- expect(Date.now() - t0).toBeLessThan(2000);
96
- }
97
-
98
- // Final: the port must still be bindable
99
- const reuse = http.createServer();
100
- await new Promise<void>((resolve, reject) => {
101
- reuse.once("error", reject);
102
- reuse.listen(port, () => resolve());
103
- });
104
- await new Promise<void>((r) => reuse.close(() => r()));
105
- }, 30_000); // longer timeout — 20 cycles
106
- });
107
-
108
- describe("Stress 2 — scheduler catchup chain", () => {
109
- it("rewinds exactly the interrupted jobs in a mixed 50-job list", () => {
110
- const now = 1_775_900_000_000;
111
- const GRACE = 6 * 60 * 60 * 1000;
112
- const jobs: CronJob[] = [];
113
-
114
- // 10 interrupted within grace (should rewind)
115
- for (let i = 0; i < 10; i++) {
116
- jobs.push(job({
117
- id: `interrupted-${i}`,
118
- name: `Interrupted ${i}`,
119
- lastAttemptAt: now - (i + 1) * 60_000, // 1..10 min ago
120
- lastRunAt: null,
121
- nextRunAt: now + 86_400_000,
122
- }));
123
- }
124
-
125
- // 10 completed (lastRunAt >= lastAttemptAt)
126
- for (let i = 0; i < 10; i++) {
127
- jobs.push(job({
128
- id: `completed-${i}`,
129
- name: `Completed ${i}`,
130
- lastAttemptAt: now - 3 * 3600_000,
131
- lastRunAt: now - 3 * 3600_000 + 60_000,
132
- nextRunAt: now + 86_400_000,
133
- }));
134
- }
135
-
136
- // 10 past grace (too old to catch up)
137
- for (let i = 0; i < 10; i++) {
138
- jobs.push(job({
139
- id: `stale-${i}`,
140
- name: `Stale ${i}`,
141
- lastAttemptAt: now - 12 * 3600_000, // 12h ago
142
- lastRunAt: null,
143
- nextRunAt: now + 3600_000,
144
- }));
145
- }
146
-
147
- // 10 disabled
148
- for (let i = 0; i < 10; i++) {
149
- jobs.push(job({
150
- id: `disabled-${i}`,
151
- name: `Disabled ${i}`,
152
- enabled: false,
153
- lastAttemptAt: now - 60_000,
154
- lastRunAt: null,
155
- nextRunAt: now + 3600_000,
156
- }));
157
- }
158
-
159
- // 10 fresh (never attempted)
160
- for (let i = 0; i < 10; i++) {
161
- jobs.push(job({
162
- id: `fresh-${i}`,
163
- name: `Fresh ${i}`,
164
- lastAttemptAt: null,
165
- lastRunAt: null,
166
- nextRunAt: now + 3600_000,
167
- }));
168
- }
169
-
170
- const caught = handleStartupCatchup(jobs, now, GRACE);
171
-
172
- const rewound = caught.filter((j, i) => j.nextRunAt !== jobs[i].nextRunAt);
173
- expect(rewound.length).toBe(10);
174
- expect(rewound.every((j) => j.id.startsWith("interrupted-"))).toBe(true);
175
- expect(rewound.every((j) => j.nextRunAt === now)).toBe(true);
176
- });
177
- });
178
-
179
- describe("Stress 3 — watchdog daily cap escalation", () => {
180
- it("trips the daily brake on the 20th crash even when short window resets", () => {
181
- let beacon: import("../src/services/watchdog-brake.js").BeaconData = {
182
- lastBeat: 0,
183
- pid: 1,
184
- bootTime: 0,
185
- crashCount: 0,
186
- crashWindowStart: 0,
187
- dailyCrashCount: 0,
188
- dailyCrashWindowStart: 0,
189
- version: "t",
190
- };
191
-
192
- // Simulate 19 crashes over 23 hours — short window resets each
193
- // time but daily accumulates.
194
- let now = 1000;
195
- for (let i = 0; i < 19; i++) {
196
- now += 70 * 60_000; // 70 min between crashes — outside short window
197
- const result = decideBrakeAction(
198
- { ...beacon, lastBeat: now - 10_000 },
199
- now,
200
- );
201
- expect(result.action).toBe("proceed");
202
- if (result.action === "proceed") {
203
- beacon = {
204
- ...beacon,
205
- lastBeat: now,
206
- crashCount: result.crashCount,
207
- crashWindowStart: result.crashWindowStart,
208
- dailyCrashCount: result.dailyCrashCount,
209
- dailyCrashWindowStart: result.dailyCrashWindowStart,
210
- };
211
- }
212
- }
213
- expect(beacon.dailyCrashCount).toBe(19);
214
-
215
- // 20th crash — must trip the daily cap even though short window is clean
216
- now += 70 * 60_000;
217
- const last = decideBrakeAction(
218
- { ...beacon, lastBeat: now - 10_000 },
219
- now,
220
- );
221
- expect(last.action).toBe("brake");
222
- if (last.action === "brake") {
223
- expect(last.reason).toMatch(/daily|day/i);
224
- }
225
- });
226
- });
227
-
228
- describe("Stress 4 — concurrent runJobNow simulation", () => {
229
- it("only one call wins the runningJobs guard; the rest see already-running", () => {
230
- // We can't call the real runJobNow without the full cron fs tree,
231
- // so we simulate the guard protocol directly. This verifies the
232
- // invariant that the cron-resolver + runningJobs Set model gives
233
- // at-most-one concurrent execution per job.
234
- const runningJobs = new Set<string>();
235
- const jobId = "job-1";
236
-
237
- const results: Array<"ran" | "already-running"> = [];
238
- const attempt = (): "ran" | "already-running" => {
239
- if (runningJobs.has(jobId)) return "already-running";
240
- runningJobs.add(jobId);
241
- try {
242
- // Pretend executeJob runs here
243
- return "ran";
244
- } finally {
245
- runningJobs.delete(jobId);
246
- }
247
- };
248
-
249
- // Sequential but with interleaved add/delete — single-threaded JS
250
- // means we can't actually overlap, but the Set invariant has to
251
- // hold if an await is inserted between check and add (it's not).
252
- for (let i = 0; i < 10; i++) {
253
- results.push(attempt());
254
- }
255
-
256
- // All 10 synchronous calls see empty set → all "ran", all cleanup OK
257
- expect(results.every((r) => r === "ran")).toBe(true);
258
-
259
- // Now simulate the async case: inject an await between attempt() calls
260
- // while holding the guard across the await.
261
- async function guardedAsync(): Promise<"ran" | "already-running"> {
262
- if (runningJobs.has(jobId)) return "already-running";
263
- runningJobs.add(jobId);
264
- try {
265
- await new Promise((r) => setTimeout(r, 5));
266
- return "ran";
267
- } finally {
268
- runningJobs.delete(jobId);
269
- }
270
- }
271
-
272
- return Promise.all([
273
- guardedAsync(),
274
- guardedAsync(),
275
- guardedAsync(),
276
- guardedAsync(),
277
- guardedAsync(),
278
- ]).then((out) => {
279
- const ran = out.filter((r) => r === "ran").length;
280
- const already = out.filter((r) => r === "already-running").length;
281
- expect(ran).toBe(1);
282
- expect(already).toBe(4);
283
- });
284
- });
285
- });
286
-
287
- describe("Stress 5 — telegram error filter large sample", () => {
288
- const benign = [
289
- "Call to 'editMessageText' failed! (400: Bad Request: message is not modified: specified new message content and reply markup are exactly the same as a current content and reply markup of the message)",
290
- "Call to 'editMessageReplyMarkup' failed! (400: Bad Request: message is not modified)",
291
- "Bad Request: query is too old and response timeout expired",
292
- "Bad Request: MESSAGE_ID_INVALID",
293
- "Bad Request: message to edit not found",
294
- "Bad Request: message to delete not found",
295
- "specified new message content and reply markup are exactly the same",
296
- ];
297
-
298
- const real = [
299
- "Unauthorized",
300
- "Too Many Requests: retry after 5",
301
- "Forbidden: bot was blocked by the user",
302
- "chat not found",
303
- "Bad Request: chat not found",
304
- "connect ETIMEDOUT",
305
- "write ECONNRESET",
306
- "stream error: provider timeout",
307
- "Claude SDK error: maxTurns exceeded",
308
- "Bad Request: can't parse entities: Can't find end of the entity starting at byte offset 1024",
309
- ];
310
-
311
- it("silences every benign grammy race", () => {
312
- for (const msg of benign) {
313
- expect(isHarmlessTelegramError(new Error(msg))).toBe(true);
314
- }
315
- });
316
-
317
- it("never silences a real actionable error", () => {
318
- for (const msg of real) {
319
- expect(isHarmlessTelegramError(new Error(msg))).toBe(false);
320
- }
321
- });
322
-
323
- it("handles grammy's description field on GrammyError shape", () => {
324
- const err = Object.assign(new Error("generic"), {
325
- description: "Bad Request: message is not modified",
326
- });
327
- expect(isHarmlessTelegramError(err)).toBe(true);
328
- });
329
- });
330
-
331
- describe("Stress 6 — cron-resolver ambiguity edge cases", () => {
332
- const baseJobs: CronJob[] = [
333
- job({ id: "id1", name: "Daily Job Alert" }),
334
- job({ id: "id2", name: "Weekly Stock Report" }),
335
- job({ id: "id3", name: "daily job alert" }), // lowercase collision
336
- ];
337
-
338
- it("returns null on ambiguous case-insensitive query, but hits the exact-case match first", () => {
339
- // Exact case "Daily Job Alert" → wins via exact-name path
340
- expect(resolveJobByNameOrId(baseJobs, "Daily Job Alert")?.id).toBe("id1");
341
- // Exact case "daily job alert" → wins via exact-name path too
342
- expect(resolveJobByNameOrId(baseJobs, "daily job alert")?.id).toBe("id3");
343
- // Mixed case "DaIlY jOb AlErT" → no exact match, 2 CI matches → ambiguous → null
344
- expect(resolveJobByNameOrId(baseJobs, "DaIlY jOb AlErT")).toBeNull();
345
- });
346
-
347
- it("ID always wins over collision at the name layer", () => {
348
- const jobs = [
349
- job({ id: "Daily Job Alert", name: "Something Else" }),
350
- job({ id: "abc", name: "Daily Job Alert" }),
351
- ];
352
- // "Daily Job Alert" matches both: id of job[0] and name of job[1].
353
- // ID wins per contract.
354
- expect(resolveJobByNameOrId(jobs, "Daily Job Alert")?.id).toBe("Daily Job Alert");
355
- });
356
- });
@@ -1,116 +0,0 @@
1
- /**
2
- * v4.12.1 — Task-aware stuck timer state machine.
3
- *
4
- * Before v4.12.1, message.ts used a flat 10-min stuck timeout that
5
- * aborted the session when no chunks arrived for 10 minutes. This
6
- * was fatal for synchronous Task/Agent tool calls, which legitimately
7
- * produce no parent-stream chunks for their entire duration.
8
- *
9
- * The new stuck timer is task-aware: it escalates to an extended
10
- * timeout (default 120 min) as soon as a sync Task/Agent tool call
11
- * is detected (tracked by toolUseId), then reverts to the normal
12
- * timeout once all tracked sync tool calls have emitted their
13
- * tool_result.
14
- *
15
- * This module is a pure state machine — no grammy, no session,
16
- * no provider. Testable in isolation with vi.useFakeTimers().
17
- */
18
- import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
19
- import { createStuckTimer } from "../src/handlers/stuck-timer.js";
20
-
21
- describe("stuck timer — task-aware state machine (v4.12.1)", () => {
22
- beforeEach(() => vi.useFakeTimers());
23
- afterEach(() => vi.useRealTimers());
24
-
25
- it("fires after normalMs when no pending sync tasks", () => {
26
- const onTimeout = vi.fn();
27
- const t = createStuckTimer({ normalMs: 1000, extendedMs: 10_000, onTimeout });
28
- t.reset();
29
- vi.advanceTimersByTime(999);
30
- expect(onTimeout).not.toHaveBeenCalled();
31
- vi.advanceTimersByTime(1);
32
- expect(onTimeout).toHaveBeenCalledTimes(1);
33
- });
34
-
35
- it("enterSync extends the timer to extendedMs", () => {
36
- const onTimeout = vi.fn();
37
- const t = createStuckTimer({ normalMs: 1000, extendedMs: 10_000, onTimeout });
38
- t.reset();
39
- t.enterSync("tool_1");
40
- // 5 seconds in — should still be alive because we're in extended mode
41
- vi.advanceTimersByTime(5000);
42
- expect(onTimeout).not.toHaveBeenCalled();
43
- // 5 more seconds (10s total since enterSync) — extended timer should fire
44
- vi.advanceTimersByTime(5000);
45
- expect(onTimeout).toHaveBeenCalledTimes(1);
46
- });
47
-
48
- it("exitSync returns to normalMs and rearms from that point", () => {
49
- const onTimeout = vi.fn();
50
- const t = createStuckTimer({ normalMs: 1000, extendedMs: 10_000, onTimeout });
51
- t.enterSync("tool_1");
52
- vi.advanceTimersByTime(500);
53
- t.exitSync("tool_1");
54
- // New normal timer is armed from exitSync time; fires after another 1000ms.
55
- vi.advanceTimersByTime(999);
56
- expect(onTimeout).not.toHaveBeenCalled();
57
- vi.advanceTimersByTime(1);
58
- expect(onTimeout).toHaveBeenCalledTimes(1);
59
- });
60
-
61
- it("multiple pending syncs: exit one keeps extended timer", () => {
62
- const onTimeout = vi.fn();
63
- const t = createStuckTimer({ normalMs: 1000, extendedMs: 10_000, onTimeout });
64
- t.enterSync("tool_1");
65
- t.enterSync("tool_2");
66
- expect(t._pendingCount()).toBe(2);
67
- t.exitSync("tool_1");
68
- expect(t._pendingCount()).toBe(1);
69
- // Still in extended mode — 5s of silence must not fire
70
- vi.advanceTimersByTime(5000);
71
- expect(onTimeout).not.toHaveBeenCalled();
72
- });
73
-
74
- it("exitSync on unknown id is a no-op and doesn't corrupt state", () => {
75
- const onTimeout = vi.fn();
76
- const t = createStuckTimer({ normalMs: 1000, extendedMs: 10_000, onTimeout });
77
- t.exitSync("never-seen");
78
- expect(t._pendingCount()).toBe(0);
79
- // Normal timer should work as usual
80
- t.reset();
81
- vi.advanceTimersByTime(1000);
82
- expect(onTimeout).toHaveBeenCalled();
83
- });
84
-
85
- it("cancel stops the timer entirely", () => {
86
- const onTimeout = vi.fn();
87
- const t = createStuckTimer({ normalMs: 1000, extendedMs: 10_000, onTimeout });
88
- t.reset();
89
- t.cancel();
90
- vi.advanceTimersByTime(2000);
91
- expect(onTimeout).not.toHaveBeenCalled();
92
- });
93
-
94
- it("reset while extended keeps the extended timer (not shortening)", () => {
95
- const onTimeout = vi.fn();
96
- const t = createStuckTimer({ normalMs: 1000, extendedMs: 10_000, onTimeout });
97
- t.enterSync("tool_1");
98
- vi.advanceTimersByTime(500);
99
- // A chunk arrived — reset. We should STAY in extended mode.
100
- t.reset();
101
- vi.advanceTimersByTime(9000);
102
- expect(onTimeout).not.toHaveBeenCalled();
103
- vi.advanceTimersByTime(1000);
104
- expect(onTimeout).toHaveBeenCalledTimes(1);
105
- });
106
-
107
- it("idempotent enterSync: same id twice stays at count 1", () => {
108
- const onTimeout = vi.fn();
109
- const t = createStuckTimer({ normalMs: 1000, extendedMs: 10_000, onTimeout });
110
- t.enterSync("tool_1");
111
- t.enterSync("tool_1");
112
- expect(t._pendingCount()).toBe(1);
113
- t.exitSync("tool_1");
114
- expect(t._pendingCount()).toBe(0);
115
- });
116
- });