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,118 +0,0 @@
1
- /**
2
- * Fix #16 — Web server must never crash the bot.
3
- *
4
- * Colleague feedback (WhatsApp voice note, 2026-04-13):
5
- * > The gateway binds to port 3100 like OpenClaw. When the bot
6
- * > restarts, the port is often still held → catastrophic crash.
7
- * > I ended up decoupling the gateway process completely, because
8
- * > the actual bot runs independently of the gateway — it can still
9
- * > answer Telegram even if the web endpoint isn't reachable yet.
10
- * > It's weird that the main routine crashes when the port is busy.
11
- * > It should just run in the background, watch for the port to
12
- * > become free, and connect then. Zero impact on the main routine.
13
- *
14
- * This file tests the pure decision helper that the new startWebServer
15
- * uses to choose between "try the next port immediately" and "retry
16
- * the default port in the background after a delay".
17
- *
18
- * Contract:
19
- * decideNextBindAction(err, attempt, opts)
20
- *
21
- * err.code = "EADDRINUSE", attempt < maxPortTries
22
- * → { type: "retry-port", port: opts.originalPort + attempt + 1, attempt: attempt + 1 }
23
- *
24
- * err.code = "EADDRINUSE", attempt >= maxPortTries
25
- * → { type: "retry-background", delayMs: opts.backgroundRetryMs, port: opts.originalPort }
26
- *
27
- * err.code = anything else (EACCES, ECONNRESET, "Listen method called twice"…)
28
- * → { type: "retry-background", delayMs: opts.backgroundRetryMs, port: opts.originalPort }
29
- *
30
- * Pure function, no side effects, no timers, no I/O.
31
- */
32
- import { describe, it, expect } from "vitest";
33
- import { decideNextBindAction } from "../src/web/bind-strategy.js";
34
-
35
- const defaultOpts = {
36
- originalPort: 3100,
37
- maxPortTries: 20,
38
- backgroundRetryMs: 30_000,
39
- };
40
-
41
- describe("decideNextBindAction (Fix #16)", () => {
42
- it("retries on the next port when EADDRINUSE and attempts remain", () => {
43
- const err = Object.assign(new Error("EADDRINUSE"), { code: "EADDRINUSE" });
44
- const result = decideNextBindAction(err, 0, defaultOpts);
45
- expect(result).toEqual({ type: "retry-port", port: 3101, attempt: 1 });
46
- });
47
-
48
- it("walks the port ladder across multiple attempts", () => {
49
- const err = Object.assign(new Error("EADDRINUSE"), { code: "EADDRINUSE" });
50
- expect(decideNextBindAction(err, 5, defaultOpts)).toEqual({
51
- type: "retry-port",
52
- port: 3106,
53
- attempt: 6,
54
- });
55
- expect(decideNextBindAction(err, 18, defaultOpts)).toEqual({
56
- type: "retry-port",
57
- port: 3119,
58
- attempt: 19,
59
- });
60
- });
61
-
62
- it("switches to background retry when all port attempts are exhausted", () => {
63
- const err = Object.assign(new Error("EADDRINUSE"), { code: "EADDRINUSE" });
64
- const result = decideNextBindAction(err, 19, defaultOpts); // 20th failure
65
- expect(result).toEqual({
66
- type: "retry-background",
67
- delayMs: 30_000,
68
- port: 3100,
69
- });
70
- });
71
-
72
- it("goes straight to background retry on non-EADDRINUSE errors", () => {
73
- const err = Object.assign(new Error("EACCES"), { code: "EACCES" });
74
- const result = decideNextBindAction(err, 0, defaultOpts);
75
- expect(result).toEqual({
76
- type: "retry-background",
77
- delayMs: 30_000,
78
- port: 3100,
79
- });
80
- });
81
-
82
- it("handles errors without a .code field by doing background retry", () => {
83
- const err = new Error("Listen method has been called more than once");
84
- const result = decideNextBindAction(err, 3, defaultOpts);
85
- expect(result.type).toBe("retry-background");
86
- if (result.type === "retry-background") {
87
- expect(result.port).toBe(3100);
88
- }
89
- });
90
-
91
- it("respects custom maxPortTries", () => {
92
- const err = Object.assign(new Error("EADDRINUSE"), { code: "EADDRINUSE" });
93
- const opts = { ...defaultOpts, maxPortTries: 3 };
94
- // attempts 0, 1 still retry; attempt 2 is the LAST retry; attempt 3 -> background
95
- expect(decideNextBindAction(err, 0, opts).type).toBe("retry-port");
96
- expect(decideNextBindAction(err, 1, opts).type).toBe("retry-port");
97
- expect(decideNextBindAction(err, 2, opts).type).toBe("retry-background");
98
- });
99
-
100
- it("respects custom backgroundRetryMs", () => {
101
- const err = Object.assign(new Error("EACCES"), { code: "EACCES" });
102
- const opts = { ...defaultOpts, backgroundRetryMs: 5_000 };
103
- const result = decideNextBindAction(err, 0, opts);
104
- expect(result).toEqual({
105
- type: "retry-background",
106
- delayMs: 5_000,
107
- port: 3100,
108
- });
109
- });
110
-
111
- it("is pure — same input, same output, no mutation", () => {
112
- const err = Object.assign(new Error("EADDRINUSE"), { code: "EADDRINUSE" });
113
- const snapshot = JSON.stringify({ ...defaultOpts });
114
- decideNextBindAction(err, 5, defaultOpts);
115
- decideNextBindAction(err, 5, defaultOpts);
116
- expect(JSON.stringify({ ...defaultOpts })).toBe(snapshot);
117
- });
118
- });
@@ -1,117 +0,0 @@
1
- /**
2
- * Fix #1 — Web server must release port on shutdown.
3
- *
4
- * Regression: on bot restart the previous http.Server kept listening on
5
- * :3100 (because shutdown() never called server.close()). launchd then
6
- * restarted the bot, the next boot tried server.listen(3100), hit
7
- * EADDRINUSE, and the bot crashed uncaught. Crash-loop.
8
- *
9
- * Contract we're establishing:
10
- * - src/web/server.ts must export `stopWebServer(server, opts?)`
11
- * - It must resolve once `server.close()` finishes.
12
- * - It must force-close idle/active sockets so close() can't hang
13
- * forever (otherwise shutdown would block on the 5s launchd grace).
14
- * - After stopWebServer() returns, a fresh http.Server must be able
15
- * to listen(port) on the same port without EADDRINUSE.
16
- */
17
- import { describe, it, expect } from "vitest";
18
- import http from "http";
19
- import { once } from "events";
20
- // Fix #1 shipped as stopWebServer(server) — Fix #16 (v4.9.4) promoted
21
- // that to `closeHttpServerGracefully(server)` and reserved the name
22
- // `stopWebServer()` for the module-state-aware shutdown. The underlying
23
- // contract (close an http.Server even when clients hold open sockets,
24
- // release the port, idempotent, never throw) is unchanged — these
25
- // tests now exercise the renamed helper.
26
- import { closeHttpServerGracefully as stopWebServer } from "../src/web/server.js";
27
-
28
- function getFreePort(): Promise<number> {
29
- return new Promise((resolve, reject) => {
30
- const s = http.createServer();
31
- s.listen(0, () => {
32
- const addr = s.address();
33
- if (typeof addr === "object" && addr) {
34
- const p = addr.port;
35
- s.close(() => resolve(p));
36
- } else {
37
- reject(new Error("no address"));
38
- }
39
- });
40
- });
41
- }
42
-
43
- describe("stopWebServer (Fix #1)", () => {
44
- it("closes an http.Server so the port becomes reusable", async () => {
45
- const port = await getFreePort();
46
-
47
- const server = http.createServer((_req, res) => {
48
- res.end("ok");
49
- });
50
- await new Promise<void>((r) => server.listen(port, () => r()));
51
-
52
- // Hold an open idle keep-alive socket — this is the exact state that
53
- // prevented server.close() from resolving in production. A real
54
- // stopWebServer() must break this stall.
55
- const hanger = http.get(`http://127.0.0.1:${port}/`, () => { /* body */ });
56
- await once(hanger, "response").catch(() => { /* swallow */ });
57
-
58
- const t0 = Date.now();
59
- await stopWebServer(server);
60
- const elapsed = Date.now() - t0;
61
-
62
- expect(elapsed).toBeLessThan(2000);
63
-
64
- // Prove the port is actually free: a new server must be able to bind it.
65
- const reuse = http.createServer();
66
- await new Promise<void>((resolve, reject) => {
67
- reuse.once("error", reject);
68
- reuse.listen(port, () => resolve());
69
- });
70
- await new Promise<void>((r) => reuse.close(() => r()));
71
- });
72
-
73
- it("is safe to call on an already-closed server", async () => {
74
- const port = await getFreePort();
75
- const server = http.createServer();
76
- await new Promise<void>((r) => server.listen(port, () => r()));
77
- await stopWebServer(server);
78
- // Calling twice must not throw
79
- await expect(stopWebServer(server)).resolves.toBeUndefined();
80
- });
81
-
82
- it("is safe to call on a server that never listened", async () => {
83
- const server = http.createServer();
84
- await expect(stopWebServer(server)).resolves.toBeUndefined();
85
- });
86
-
87
- it("closes even when a client is holding a long-lived connection", async () => {
88
- // Production-mirror: a long-polling /api/cron?wait=1 or similar.
89
- // Before the fix, server.close() would hang on these sockets and the
90
- // 5s launchd grace would kill the bot before the port was released.
91
- const port = await getFreePort();
92
- const server = http.createServer((_req, res) => {
93
- // Never send a full response — keep the socket open until close.
94
- res.writeHead(200, { "Content-Type": "text/plain" });
95
- res.write("chunk-1");
96
- // intentionally do NOT call res.end()
97
- });
98
- await new Promise<void>((r) => server.listen(port, () => r()));
99
-
100
- const req = http.get(`http://127.0.0.1:${port}/hang`);
101
- // Swallow the inevitable socket-close error once the server is torn down
102
- req.on("error", () => { /* expected */ });
103
- await once(req, "response").catch(() => { /* swallow */ });
104
-
105
- const t0 = Date.now();
106
- await stopWebServer(server);
107
- expect(Date.now() - t0).toBeLessThan(2000);
108
-
109
- // Port is reusable
110
- const reuse = http.createServer();
111
- await new Promise<void>((resolve, reject) => {
112
- reuse.once("error", reject);
113
- reuse.listen(port, () => resolve());
114
- });
115
- await new Promise<void>((r) => reuse.close(() => r()));
116
- });
117
- });
@@ -1,96 +0,0 @@
1
- /**
2
- * Fix #2 — WhatsApp saveCreds must survive a vanished auth directory.
3
- *
4
- * Regression: `Unhandled rejection: ENOENT creds.json` in err.log when
5
- * baileys fired a delayed `creds.update` event after the auth dir was
6
- * gone (crash mid-init, trash, manual cleanup, etc.).
7
- *
8
- * Contract: we export a helper `makeResilientSaveCreds(authDir, inner)`
9
- * from src/platforms/whatsapp-auth-helpers.ts. It wraps baileys' raw
10
- * saveCreds so that an ENOENT triggers a mkdir-p + one retry before
11
- * surfacing the error. Any other error bubbles up unchanged.
12
- */
13
- import { describe, it, expect, beforeEach, afterEach } from "vitest";
14
- import fs from "fs";
15
- import os from "os";
16
- import { resolve, join } from "path";
17
- import { makeResilientSaveCreds } from "../src/platforms/whatsapp-auth-helpers.js";
18
-
19
- let authDir: string;
20
-
21
- beforeEach(() => {
22
- authDir = resolve(os.tmpdir(), `alvin-wa-auth-${process.pid}-${Date.now()}`);
23
- fs.mkdirSync(authDir, { recursive: true });
24
- });
25
-
26
- afterEach(() => {
27
- if (fs.existsSync(authDir)) fs.rmSync(authDir, { recursive: true, force: true });
28
- });
29
-
30
- describe("makeResilientSaveCreds (Fix #2)", () => {
31
- it("calls the inner saveCreds on the happy path", async () => {
32
- let calls = 0;
33
- const inner = async () => { calls++; };
34
- const wrapped = makeResilientSaveCreds(authDir, inner);
35
- await wrapped();
36
- expect(calls).toBe(1);
37
- });
38
-
39
- it("recreates the auth dir and retries when inner throws ENOENT", async () => {
40
- let calls = 0;
41
- const inner = async () => {
42
- calls++;
43
- if (calls === 1) {
44
- // Mirror baileys fs.promises.writeFile behaviour
45
- const err = new Error(
46
- `ENOENT: no such file or directory, open '${join(authDir, "creds.json")}'`,
47
- ) as NodeJS.ErrnoException;
48
- err.code = "ENOENT";
49
- throw err;
50
- }
51
- };
52
- // Simulate the vanished dir
53
- fs.rmSync(authDir, { recursive: true, force: true });
54
- expect(fs.existsSync(authDir)).toBe(false);
55
-
56
- const wrapped = makeResilientSaveCreds(authDir, inner);
57
- await wrapped();
58
-
59
- expect(calls).toBe(2);
60
- expect(fs.existsSync(authDir)).toBe(true);
61
- });
62
-
63
- it("only retries once — a second ENOENT surfaces as error", async () => {
64
- let calls = 0;
65
- const inner = async () => {
66
- calls++;
67
- const err = new Error("ENOENT: no such file or directory") as NodeJS.ErrnoException;
68
- err.code = "ENOENT";
69
- throw err;
70
- };
71
- const wrapped = makeResilientSaveCreds(authDir, inner);
72
- await expect(wrapped()).rejects.toThrow(/ENOENT/);
73
- expect(calls).toBe(2);
74
- });
75
-
76
- it("surfaces non-ENOENT errors unchanged", async () => {
77
- const inner = async () => {
78
- const err = new Error("EACCES: permission denied") as NodeJS.ErrnoException;
79
- err.code = "EACCES";
80
- throw err;
81
- };
82
- const wrapped = makeResilientSaveCreds(authDir, inner);
83
- await expect(wrapped()).rejects.toThrow(/EACCES/);
84
- });
85
-
86
- it("is safe to call concurrently", async () => {
87
- let calls = 0;
88
- const inner = async () => {
89
- calls++;
90
- await new Promise((r) => setTimeout(r, 5));
91
- };
92
- const wrapped = makeResilientSaveCreds(authDir, inner);
93
- await Promise.all([wrapped(), wrapped(), wrapped()]);
94
- expect(calls).toBe(3);
95
- });
96
- });
@@ -1,196 +0,0 @@
1
- /**
2
- * v4.12.0 — Workspace registry tests.
3
- *
4
- * A workspace is a markdown file under ~/.alvin-bot/workspaces/<name>.md
5
- * with YAML frontmatter defining name, purpose, cwd, color, emoji, and
6
- * an optional "channels" array for explicit channel-ID mapping. The
7
- * markdown body (below the frontmatter) is the persona instruction
8
- * that gets injected into the system prompt for that workspace.
9
- */
10
- import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
11
- import fs from "fs";
12
- import os from "os";
13
- import { resolve } from "path";
14
-
15
- const TEST_DATA_DIR = resolve(os.tmpdir(), `alvin-workspaces-${process.pid}-${Date.now()}`);
16
-
17
- beforeEach(() => {
18
- if (fs.existsSync(TEST_DATA_DIR)) fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
19
- fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
20
- fs.mkdirSync(resolve(TEST_DATA_DIR, "workspaces"), { recursive: true });
21
- process.env.ALVIN_DATA_DIR = TEST_DATA_DIR;
22
- vi.resetModules();
23
- });
24
-
25
- afterEach(() => {
26
- try { fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); } catch { /* ignore */ }
27
- });
28
-
29
- function writeWorkspace(name: string, frontmatter: Record<string, unknown>, body: string): void {
30
- const fm = Object.entries(frontmatter)
31
- .map(([k, v]) => `${k}: ${Array.isArray(v) ? JSON.stringify(v) : JSON.stringify(v)}`)
32
- .join("\n");
33
- const content = `---\n${fm}\n---\n${body}`;
34
- fs.writeFileSync(resolve(TEST_DATA_DIR, "workspaces", `${name}.md`), content, "utf-8");
35
- }
36
-
37
- describe("workspace registry (v4.12.0)", () => {
38
- it("returns a default workspace when nothing is configured", async () => {
39
- const { loadWorkspaces, getDefaultWorkspace } = await import("../src/services/workspaces.js");
40
- loadWorkspaces();
41
- const def = getDefaultWorkspace();
42
- expect(def.name).toBe("default");
43
- expect(def.purpose).toBe("");
44
- expect(def.systemPromptOverride).toBe("");
45
- });
46
-
47
- it("loads a workspace from a markdown file with frontmatter", async () => {
48
- writeWorkspace(
49
- "my-project",
50
- { purpose: "my-project consulting website dev", cwd: "~/Projects/my-project-website", emoji: "🏢", color: "#6366f1" },
51
- "You are the my-project dev assistant. Stack: React + Express + Drizzle.",
52
- );
53
- const { loadWorkspaces, getWorkspace } = await import("../src/services/workspaces.js");
54
- loadWorkspaces();
55
- const ws = getWorkspace("my-project");
56
- expect(ws).not.toBeNull();
57
- expect(ws!.name).toBe("my-project");
58
- expect(ws!.purpose).toBe("my-project consulting website dev");
59
- expect(ws!.cwd).toContain("my-project-website");
60
- expect(ws!.emoji).toBe("🏢");
61
- expect(ws!.color).toBe("#6366f1");
62
- expect(ws!.systemPromptOverride).toContain("my-project dev assistant");
63
- });
64
-
65
- it("loads multiple workspaces and listWorkspaces returns all of them", async () => {
66
- writeWorkspace("my-project", { purpose: "p1" }, "body1");
67
- writeWorkspace("homes", { purpose: "p2" }, "body2");
68
- writeWorkspace("my-landing", { purpose: "p3" }, "body3");
69
- const { loadWorkspaces, listWorkspaces } = await import("../src/services/workspaces.js");
70
- loadWorkspaces();
71
- const names = listWorkspaces().map(w => w.name).sort();
72
- expect(names).toEqual(["homes", "my-landing", "my-project"]);
73
- });
74
-
75
- it("expands ~ in cwd to the user's home directory", async () => {
76
- writeWorkspace("tilde", { purpose: "p", cwd: "~/some/path" }, "");
77
- const { loadWorkspaces, getWorkspace } = await import("../src/services/workspaces.js");
78
- loadWorkspaces();
79
- const ws = getWorkspace("tilde");
80
- expect(ws!.cwd).toBe(resolve(os.homedir(), "some/path"));
81
- expect(ws!.cwd).not.toContain("~");
82
- });
83
-
84
- it("matchWorkspaceForChannel matches by explicit channel ID in frontmatter", async () => {
85
- writeWorkspace(
86
- "my-project",
87
- { purpose: "p", channels: ["C01ALEVABC", "C01ALEVXYZ"] },
88
- "",
89
- );
90
- const { loadWorkspaces, matchWorkspaceForChannel } = await import("../src/services/workspaces.js");
91
- loadWorkspaces();
92
- const ws = matchWorkspaceForChannel("slack", "C01ALEVABC", undefined);
93
- expect(ws?.name).toBe("my-project");
94
- });
95
-
96
- it("matchWorkspaceForChannel falls back to channel name match (case-insensitive, # stripped)", async () => {
97
- writeWorkspace("my-project", { purpose: "p" }, "");
98
- const { loadWorkspaces, matchWorkspaceForChannel } = await import("../src/services/workspaces.js");
99
- loadWorkspaces();
100
- const byHash = matchWorkspaceForChannel("slack", "C_UNKNOWN", "#my-project");
101
- const noHash = matchWorkspaceForChannel("slack", "C_UNKNOWN", "MY-PROJECT");
102
- expect(byHash?.name).toBe("my-project");
103
- expect(noHash?.name).toBe("my-project");
104
- });
105
-
106
- it("matchWorkspaceForChannel returns null for unknown channel with no name match", async () => {
107
- writeWorkspace("my-project", { purpose: "p" }, "");
108
- const { loadWorkspaces, matchWorkspaceForChannel } = await import("../src/services/workspaces.js");
109
- loadWorkspaces();
110
- const ws = matchWorkspaceForChannel("slack", "C_MYSTERY", "#unmapped");
111
- expect(ws).toBeNull();
112
- });
113
-
114
- it("resolveWorkspaceOrDefault returns the matched workspace when one is found", async () => {
115
- writeWorkspace("my-project", { purpose: "p" }, "persona body");
116
- const { loadWorkspaces, resolveWorkspaceOrDefault } = await import("../src/services/workspaces.js");
117
- loadWorkspaces();
118
- const ws = resolveWorkspaceOrDefault("slack", "C_UNKNOWN", "#my-project");
119
- expect(ws.name).toBe("my-project");
120
- expect(ws.systemPromptOverride).toContain("persona body");
121
- });
122
-
123
- it("resolveWorkspaceOrDefault returns the default workspace when no match", async () => {
124
- const { loadWorkspaces, resolveWorkspaceOrDefault } = await import("../src/services/workspaces.js");
125
- loadWorkspaces();
126
- const ws = resolveWorkspaceOrDefault("slack", "C_UNKNOWN", "#whatever");
127
- expect(ws.name).toBe("default");
128
- });
129
-
130
- it("reloadWorkspaces picks up a newly created file", async () => {
131
- const { loadWorkspaces, reloadWorkspaces, listWorkspaces } = await import("../src/services/workspaces.js");
132
- loadWorkspaces();
133
- expect(listWorkspaces()).toHaveLength(0);
134
- writeWorkspace("new-one", { purpose: "p" }, "body");
135
- reloadWorkspaces();
136
- expect(listWorkspaces()).toHaveLength(1);
137
- expect(listWorkspaces()[0].name).toBe("new-one");
138
- });
139
-
140
- it("skips files that aren't .md", async () => {
141
- fs.writeFileSync(resolve(TEST_DATA_DIR, "workspaces", "notes.txt"), "ignored");
142
- writeWorkspace("real", { purpose: "p" }, "body");
143
- const { loadWorkspaces, listWorkspaces } = await import("../src/services/workspaces.js");
144
- loadWorkspaces();
145
- expect(listWorkspaces()).toHaveLength(1);
146
- expect(listWorkspaces()[0].name).toBe("real");
147
- });
148
-
149
- it("malformed frontmatter: workspace is skipped or loaded with defaults, other workspaces still load", async () => {
150
- fs.writeFileSync(
151
- resolve(TEST_DATA_DIR, "workspaces", "broken.md"),
152
- "---\nthis is: not: valid: yaml:\n---\nbody",
153
- );
154
- writeWorkspace("good", { purpose: "p" }, "body");
155
- const { loadWorkspaces, listWorkspaces } = await import("../src/services/workspaces.js");
156
- loadWorkspaces();
157
- const names = listWorkspaces().map(w => w.name);
158
- expect(names).toContain("good");
159
- });
160
-
161
- it("missing workspaces directory is handled gracefully", async () => {
162
- fs.rmSync(resolve(TEST_DATA_DIR, "workspaces"), { recursive: true, force: true });
163
- const { loadWorkspaces, listWorkspaces } = await import("../src/services/workspaces.js");
164
- const count = loadWorkspaces();
165
- expect(count).toBe(0);
166
- expect(listWorkspaces()).toEqual([]);
167
- });
168
- });
169
-
170
- describe("workspace resolver integration with session (v4.12.0)", () => {
171
- it("two channels resolve to two different workspaces", async () => {
172
- fs.writeFileSync(
173
- resolve(TEST_DATA_DIR, "workspaces", "my-project.md"),
174
- `---\npurpose: project-a\ncwd: ~/tmp/project-a\nchannels: ["C_PROJECT_A"]\n---\nproject-a persona`,
175
- );
176
- fs.writeFileSync(
177
- resolve(TEST_DATA_DIR, "workspaces", "project-b.md"),
178
- `---\npurpose: project-b\ncwd: ~/tmp/project-b\nchannels: ["C_PROJECT_B"]\n---\nproject-b persona`,
179
- );
180
-
181
- vi.resetModules();
182
- const { initWorkspaces, resolveWorkspaceOrDefault } = await import("../src/services/workspaces.js");
183
- initWorkspaces();
184
-
185
- const a = resolveWorkspaceOrDefault("slack", "C_PROJECT_A", undefined);
186
- const b = resolveWorkspaceOrDefault("slack", "C_PROJECT_B", undefined);
187
- const unknown = resolveWorkspaceOrDefault("slack", "C_MYSTERY", undefined);
188
-
189
- expect(a.name).toBe("my-project");
190
- expect(a.systemPromptOverride).toBe("project-a persona");
191
- expect(b.name).toBe("project-b");
192
- expect(b.systemPromptOverride).toBe("project-b persona");
193
- expect(unknown.name).toBe("default");
194
- expect(unknown.systemPromptOverride).toBe("");
195
- });
196
- });
package/vitest.config.ts DELETED
@@ -1,17 +0,0 @@
1
- import { defineConfig } from "vitest/config";
2
-
3
- export default defineConfig({
4
- test: {
5
- // Tests live alongside source as *.test.ts, plus a dedicated test/ dir
6
- include: ["src/**/*.test.ts", "test/**/*.test.ts"],
7
- // Node environment — no DOM
8
- environment: "node",
9
- // Keep individual tests fast; fail loudly if any hang
10
- testTimeout: 10_000,
11
- hookTimeout: 5_000,
12
- // ESM-only project — vitest handles this natively
13
- globals: false,
14
- // Don't try to run electron or build artifacts
15
- exclude: ["node_modules/**", "dist/**", "electron/**"],
16
- },
17
- });