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,201 +0,0 @@
1
- import { describe, it, expect, vi } from "vitest";
2
- import { ProviderRegistry } from "../src/providers/registry.js";
3
- import type { Provider, QueryOptions, StreamChunk } from "../src/providers/types.js";
4
-
5
- /**
6
- * Registry tests — focused on the queryWithFallback behaviour, which is
7
- * the core of today's reliability work:
8
- * 1. Silent retry on mid-stream transient aborts
9
- * 2. No mid-stream failover after visible text has already streamed
10
- * 3. Lifecycle boot on asleep providers
11
- * 4. Fallback chain traversal when providers are unavailable
12
- */
13
-
14
- // Mock provider factory — lets each test craft the exact chunk sequence
15
- function createMockProvider(opts: {
16
- key?: string;
17
- available?: boolean;
18
- chunks: StreamChunk[];
19
- attempts?: number[]; // per-attempt chunk sets (for retry tests)
20
- }): Provider {
21
- let attemptIndex = 0;
22
- return {
23
- config: {
24
- type: "openai-compatible",
25
- name: opts.key || "mock",
26
- model: "mock-model",
27
- },
28
- isAvailable: async () => opts.available ?? true,
29
- getInfo: () => ({
30
- name: opts.key || "mock",
31
- model: "mock-model",
32
- status: "mock",
33
- }),
34
- async *query(_options: QueryOptions): AsyncGenerator<StreamChunk> {
35
- // If per-attempt sequences provided, use them in order
36
- if (opts.attempts && opts.attempts.length > 0) {
37
- const idx = Math.min(attemptIndex, opts.attempts.length - 1);
38
- const count = opts.attempts[idx];
39
- attemptIndex++;
40
- for (let i = 0; i < count; i++) {
41
- yield opts.chunks[i];
42
- }
43
- } else {
44
- for (const c of opts.chunks) yield c;
45
- }
46
- },
47
- };
48
- }
49
-
50
- describe("ProviderRegistry.queryWithFallback", () => {
51
- it("yields chunks from the active provider on a happy path", async () => {
52
- const provider = createMockProvider({
53
- chunks: [
54
- { type: "text", text: "hello world" },
55
- { type: "done", text: "hello world", inputTokens: 10, outputTokens: 5 },
56
- ],
57
- });
58
-
59
- const registry = new ProviderRegistry({
60
- primary: "mock",
61
- fallbacks: [],
62
- providers: { mock: provider.config },
63
- });
64
- // Manually wire the mock provider — createProvider dispatches by type,
65
- // which would create a real OpenAICompatibleProvider. We inject directly.
66
- (registry as unknown as { providers: Map<string, Provider> }).providers.set("mock", provider);
67
-
68
- const chunks: StreamChunk[] = [];
69
- for await (const c of registry.queryWithFallback({ prompt: "hi" })) {
70
- chunks.push(c);
71
- }
72
- expect(chunks.length).toBe(2);
73
- expect(chunks[0]?.type).toBe("text");
74
- expect(chunks[1]?.type).toBe("done");
75
- });
76
-
77
- it("falls back to the next provider when the active one has no visible text and errors", async () => {
78
- const primary = createMockProvider({
79
- chunks: [{ type: "error", error: "rate limit" }],
80
- });
81
- const fallback = createMockProvider({
82
- chunks: [
83
- { type: "text", text: "fallback answer" },
84
- { type: "done", text: "fallback answer" },
85
- ],
86
- });
87
-
88
- const registry = new ProviderRegistry({
89
- primary: "primary",
90
- fallbacks: ["fallback"],
91
- providers: {
92
- primary: primary.config,
93
- fallback: fallback.config,
94
- },
95
- });
96
- const internal = (registry as unknown as { providers: Map<string, Provider> }).providers;
97
- internal.set("primary", primary);
98
- internal.set("fallback", fallback);
99
-
100
- const chunks: StreamChunk[] = [];
101
- for await (const c of registry.queryWithFallback({ prompt: "hi" })) {
102
- chunks.push(c);
103
- }
104
-
105
- // Expect: fallback chunk (switching notification), then text + done from fallback
106
- const types = chunks.map((c) => c.type);
107
- expect(types).toContain("fallback");
108
- expect(types).toContain("text");
109
- expect(types).toContain("done");
110
- });
111
-
112
- it("surfaces a terminal error (no fallback) when the active provider fails mid-stream", async () => {
113
- const primary = createMockProvider({
114
- chunks: [
115
- { type: "text", text: "I'm starting the an" },
116
- { type: "error", error: "Request aborted" },
117
- ],
118
- });
119
- const fallback = createMockProvider({
120
- chunks: [
121
- { type: "text", text: "different answer" },
122
- { type: "done", text: "different answer" },
123
- ],
124
- });
125
-
126
- const registry = new ProviderRegistry({
127
- primary: "primary",
128
- fallbacks: ["fallback"],
129
- providers: {
130
- primary: primary.config,
131
- fallback: fallback.config,
132
- },
133
- });
134
- const internal = (registry as unknown as { providers: Map<string, Provider> }).providers;
135
- internal.set("primary", primary);
136
- internal.set("fallback", fallback);
137
-
138
- const chunks: StreamChunk[] = [];
139
- for await (const c of registry.queryWithFallback({ prompt: "hi" })) {
140
- chunks.push(c);
141
- }
142
-
143
- // We SHOULD get the first text chunk (visible text)
144
- // We SHOULD NOT get any fallback-provider chunks
145
- // We SHOULD get a final error chunk with the "mid-stream" message
146
- const texts = chunks.filter((c) => c.type === "text");
147
- expect(texts.length).toBeGreaterThanOrEqual(1);
148
- expect(texts.some((c) => c.text?.includes("different answer"))).toBe(false);
149
-
150
- const errors = chunks.filter((c) => c.type === "error");
151
- expect(errors.length).toBe(1);
152
- // Mid-stream message is localised, just check it mentions the provider name
153
- expect(errors[0]?.error).toContain("mock");
154
- });
155
-
156
- it("retries the SAME provider on mid-stream abort before giving up", async () => {
157
- // First attempt: emits text then aborts mid-stream
158
- // Second attempt: emits text and completes successfully
159
- const querySpy = vi.fn();
160
- let attemptCount = 0;
161
- const provider: Provider = {
162
- config: {
163
- type: "openai-compatible",
164
- name: "retry-test",
165
- model: "m",
166
- },
167
- isAvailable: async () => true,
168
- getInfo: () => ({ name: "retry-test", model: "m", status: "ok" }),
169
- async *query() {
170
- querySpy();
171
- attemptCount++;
172
- if (attemptCount === 1) {
173
- yield { type: "text", text: "first partial" } as StreamChunk;
174
- yield { type: "error", error: "Request aborted" } as StreamChunk;
175
- } else {
176
- yield { type: "text", text: "retry success" } as StreamChunk;
177
- yield { type: "done", text: "retry success" } as StreamChunk;
178
- }
179
- },
180
- };
181
-
182
- const registry = new ProviderRegistry({
183
- primary: "retry-test",
184
- fallbacks: [],
185
- providers: { "retry-test": provider.config },
186
- });
187
- (registry as unknown as { providers: Map<string, Provider> }).providers.set("retry-test", provider);
188
-
189
- const chunks: StreamChunk[] = [];
190
- for await (const c of registry.queryWithFallback({ prompt: "hi" })) {
191
- chunks.push(c);
192
- }
193
-
194
- // query() should have been called twice — original attempt + 1 retry
195
- expect(querySpy).toHaveBeenCalledTimes(2);
196
- // The final done chunk should reflect the retry's success
197
- const done = chunks.find((c) => c.type === "done");
198
- expect(done).toBeDefined();
199
- expect(done?.text).toBe("retry success");
200
- }, 15_000); // allow for the 2s retry delay
201
- });
@@ -1,59 +0,0 @@
1
- /**
2
- * v4.12.3 — UserSession.pendingBackgroundCount
3
- *
4
- * When Claude launches an Agent/Task tool with run_in_background: true,
5
- * the SDK's CLI subprocess stays alive until the task-notification is
6
- * ready to deliver. During that window the main Telegram session is
7
- * effectively blocked — isProcessing=true, all new user messages get
8
- * queued. For 5-minute+ background tasks that's unacceptable UX.
9
- *
10
- * v4.12.3 tracks the count of pending background agents on each session
11
- * so the handler can detect the blocked state and bypass the SDK resume
12
- * (start a fresh SDK session for the new user message while the old
13
- * session drains in the background).
14
- *
15
- * The count is incremented by the message handler on async_launched
16
- * tool_result and decremented by the async-agent-watcher when it
17
- * delivers the sub-agent's result.
18
- */
19
- import { describe, it, expect, beforeEach, vi } from "vitest";
20
-
21
- beforeEach(() => vi.resetModules());
22
-
23
- describe("UserSession.pendingBackgroundCount (v4.12.3)", () => {
24
- it("new session starts with pendingBackgroundCount=0", async () => {
25
- const { getSession } = await import("../src/services/session.js");
26
- const s = getSession("test-user-new");
27
- expect(s.pendingBackgroundCount).toBe(0);
28
- });
29
-
30
- it("incrementing on the session persists across getSession calls", async () => {
31
- const { getSession } = await import("../src/services/session.js");
32
- const s1 = getSession("test-user-inc");
33
- s1.pendingBackgroundCount = 2;
34
- const s2 = getSession("test-user-inc");
35
- expect(s2.pendingBackgroundCount).toBe(2);
36
- expect(s1).toBe(s2);
37
- });
38
-
39
- it("resetSession zeroes pendingBackgroundCount", async () => {
40
- const { getSession, resetSession } = await import("../src/services/session.js");
41
- const s = getSession("test-user-reset");
42
- s.pendingBackgroundCount = 3;
43
- resetSession("test-user-reset");
44
- expect(s.pendingBackgroundCount).toBe(0);
45
- });
46
-
47
- it("count can be decremented without going negative via explicit guard", async () => {
48
- // The handler/watcher code is responsible for not decrementing below
49
- // zero. This test just documents that the field is a plain number
50
- // with no built-in guard — decrement logic lives in the consumers.
51
- const { getSession } = await import("../src/services/session.js");
52
- const s = getSession("test-user-dec");
53
- s.pendingBackgroundCount = 1;
54
- s.pendingBackgroundCount = Math.max(0, s.pendingBackgroundCount - 1);
55
- expect(s.pendingBackgroundCount).toBe(0);
56
- s.pendingBackgroundCount = Math.max(0, s.pendingBackgroundCount - 1);
57
- expect(s.pendingBackgroundCount).toBe(0);
58
- });
59
- });
@@ -1,195 +0,0 @@
1
- /**
2
- * v4.11.0 — Session persistence across bot restarts.
3
- *
4
- * Sessions live in an in-memory Map that gets wiped on every bot restart.
5
- * This persistence layer flushes the Map to disk (debounced) and rehydrates
6
- * it on bot startup so Claude SDK's `resume: sessionId` keeps working,
7
- * conversation history survives, and user preferences (language, effort,
8
- * voiceReply) don't reset.
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-session-persist-${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
- process.env.ALVIN_DATA_DIR = TEST_DATA_DIR;
21
- vi.resetModules();
22
- });
23
-
24
- afterEach(() => {
25
- try { fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); } catch { /* ignore */ }
26
- });
27
-
28
- describe("session-persistence (v4.11.0)", () => {
29
- it("flushSessions writes a JSON file with all session fields that survive restart", async () => {
30
- const sessionMod = await import("../src/services/session.js");
31
- const persistMod = await import("../src/services/session-persistence.js");
32
-
33
- const s = sessionMod.getSession("user-1");
34
- s.sessionId = "sdk-abc-123";
35
- s.language = "de";
36
- s.effort = "high";
37
- s.voiceReply = true;
38
- s.workingDir = "/tmp/test-cwd";
39
- s.history = [
40
- { role: "user", content: "hi" },
41
- { role: "assistant", content: "hello" },
42
- ];
43
-
44
- await persistMod.flushSessions();
45
-
46
- const stateFile = resolve(TEST_DATA_DIR, "state", "sessions.json");
47
- expect(fs.existsSync(stateFile)).toBe(true);
48
-
49
- // v4.12.0 — Format is now an envelope: { version: 2, sessions: {...}, telegramWorkspaces: {...} }
50
- const envelope = JSON.parse(fs.readFileSync(stateFile, "utf-8"));
51
- expect(envelope.version).toBe(2);
52
- const parsed = envelope.sessions;
53
- expect(parsed).toHaveProperty("user-1");
54
- expect(parsed["user-1"].sessionId).toBe("sdk-abc-123");
55
- expect(parsed["user-1"].language).toBe("de");
56
- expect(parsed["user-1"].effort).toBe("high");
57
- expect(parsed["user-1"].voiceReply).toBe(true);
58
- expect(parsed["user-1"].history).toHaveLength(2);
59
- });
60
-
61
- it("loadPersistedSessions rehydrates the sessions Map from disk", async () => {
62
- fs.mkdirSync(resolve(TEST_DATA_DIR, "state"), { recursive: true });
63
- fs.writeFileSync(
64
- resolve(TEST_DATA_DIR, "state", "sessions.json"),
65
- JSON.stringify({
66
- "user-7": {
67
- sessionId: "sdk-restored",
68
- language: "en",
69
- effort: "medium",
70
- voiceReply: false,
71
- workingDir: "/home/test",
72
- lastActivity: Date.now() - 60_000,
73
- lastSdkHistoryIndex: 3,
74
- history: [{ role: "user", content: "from past life" }],
75
- messageCount: 5,
76
- toolUseCount: 2,
77
- },
78
- }),
79
- );
80
-
81
- const persistMod = await import("../src/services/session-persistence.js");
82
- const sessionMod = await import("../src/services/session.js");
83
-
84
- const loaded = persistMod.loadPersistedSessions();
85
- expect(loaded).toBe(1);
86
-
87
- const s = sessionMod.getSession("user-7");
88
- expect(s.sessionId).toBe("sdk-restored");
89
- expect(s.language).toBe("en");
90
- expect(s.history).toHaveLength(1);
91
- expect(s.history[0].content).toBe("from past life");
92
- expect(s.messageCount).toBe(5);
93
- });
94
-
95
- it("survives a corrupt sessions.json file (does not crash)", async () => {
96
- fs.mkdirSync(resolve(TEST_DATA_DIR, "state"), { recursive: true });
97
- fs.writeFileSync(
98
- resolve(TEST_DATA_DIR, "state", "sessions.json"),
99
- "{ this is not valid json",
100
- );
101
-
102
- const persistMod = await import("../src/services/session-persistence.js");
103
- const loaded = persistMod.loadPersistedSessions();
104
- expect(loaded).toBe(0);
105
- });
106
-
107
- it("survives missing sessions.json file (returns 0 loaded)", async () => {
108
- const persistMod = await import("../src/services/session-persistence.js");
109
- const loaded = persistMod.loadPersistedSessions();
110
- expect(loaded).toBe(0);
111
- });
112
-
113
- it("does NOT persist runtime-only fields (abortController, isProcessing)", async () => {
114
- const sessionMod = await import("../src/services/session.js");
115
- const persistMod = await import("../src/services/session-persistence.js");
116
-
117
- const s = sessionMod.getSession("user-2");
118
- s.isProcessing = true;
119
- s.abortController = new AbortController();
120
- s.sessionId = "abc";
121
-
122
- await persistMod.flushSessions();
123
-
124
- const stateFile = resolve(TEST_DATA_DIR, "state", "sessions.json");
125
- const parsed = JSON.parse(fs.readFileSync(stateFile, "utf-8")).sessions;
126
- expect(parsed["user-2"]).not.toHaveProperty("abortController");
127
- expect(parsed["user-2"]).not.toHaveProperty("isProcessing");
128
- expect(parsed["user-2"].sessionId).toBe("abc");
129
- });
130
-
131
- it("caps history at MAX_PERSISTED_HISTORY (50) so the file doesn't grow unbounded", async () => {
132
- const sessionMod = await import("../src/services/session.js");
133
- const persistMod = await import("../src/services/session-persistence.js");
134
-
135
- const s = sessionMod.getSession("user-3");
136
- s.sessionId = "needs-some-state-to-be-persisted";
137
- for (let i = 0; i < 200; i++) {
138
- s.history.push({ role: i % 2 === 0 ? "user" : "assistant", content: `msg ${i}` });
139
- }
140
-
141
- await persistMod.flushSessions();
142
-
143
- const parsed = JSON.parse(fs.readFileSync(resolve(TEST_DATA_DIR, "state", "sessions.json"), "utf-8")).sessions;
144
- expect(parsed["user-3"].history.length).toBeLessThanOrEqual(50);
145
- // Last message should still be there
146
- expect(parsed["user-3"].history.at(-1).content).toContain("199");
147
- });
148
-
149
- it("debounce: schedulePersist coalesces multiple rapid mutations into one flush", async () => {
150
- const persistMod = await import("../src/services/session-persistence.js");
151
- const sessionMod = await import("../src/services/session.js");
152
-
153
- sessionMod.getSession("user-4").sessionId = "v1";
154
- persistMod.schedulePersist();
155
- sessionMod.getSession("user-4").sessionId = "v2";
156
- persistMod.schedulePersist();
157
- sessionMod.getSession("user-4").sessionId = "v3";
158
- persistMod.schedulePersist();
159
-
160
- // Force the debounced flush
161
- await persistMod.flushSessions();
162
-
163
- const parsed = JSON.parse(fs.readFileSync(resolve(TEST_DATA_DIR, "state", "sessions.json"), "utf-8")).sessions;
164
- expect(parsed["user-4"].sessionId).toBe("v3");
165
- });
166
-
167
- it("atomic write: tmp+rename, never leaves a half-written file on crash", async () => {
168
- const persistMod = await import("../src/services/session-persistence.js");
169
- const sessionMod = await import("../src/services/session.js");
170
-
171
- sessionMod.getSession("user-5").sessionId = "abc";
172
- await persistMod.flushSessions();
173
-
174
- // After successful flush: no .tmp leftover
175
- const tmpFile = resolve(TEST_DATA_DIR, "state", "sessions.json.tmp");
176
- expect(fs.existsSync(tmpFile)).toBe(false);
177
- expect(fs.existsSync(resolve(TEST_DATA_DIR, "state", "sessions.json"))).toBe(true);
178
- });
179
-
180
- it("does not persist sessions that have never been activated (only defaults)", async () => {
181
- const sessionMod = await import("../src/services/session.js");
182
- const persistMod = await import("../src/services/session-persistence.js");
183
-
184
- // Touching getSession creates an empty default session — but we don't want to
185
- // persist it if it has no meaningful state (no sessionId, no history)
186
- sessionMod.getSession("noop-user");
187
- await persistMod.flushSessions();
188
-
189
- const stateFile = resolve(TEST_DATA_DIR, "state", "sessions.json");
190
- if (fs.existsSync(stateFile)) {
191
- const parsed = JSON.parse(fs.readFileSync(stateFile, "utf-8")).sessions;
192
- expect(parsed).not.toHaveProperty("noop-user");
193
- }
194
- });
195
- });
@@ -1,123 +0,0 @@
1
- /**
2
- * v4.12.0 — Slack adapter editMessage support for progress tickers.
3
- *
4
- * Slack doesn't stream text like the OpenAI API does; the idiom is to
5
- * post an initial message, capture its `ts` (timestamp), then edit it
6
- * with growing content via chat.update. This mirrors Telegram's
7
- * editMessageText approach used in the cron progress ticker.
8
- */
9
- import { describe, it, expect, vi, beforeEach } from "vitest";
10
-
11
- beforeEach(() => {
12
- vi.resetModules();
13
- });
14
-
15
- describe("SlackAdapter.editMessage (v4.12.0)", () => {
16
- it("calls chat.update with the correct channel + ts when editMessage is invoked", async () => {
17
- const updateSpy = vi.fn().mockResolvedValue({ ok: true, ts: "1234567890.123456" });
18
- const postSpy = vi.fn().mockResolvedValue({ ok: true, ts: "1234567890.123456" });
19
- const authSpy = vi.fn().mockResolvedValue({ ok: true, user_id: "U_BOT", user: "alvin", team: "Test" });
20
-
21
- vi.doMock("@slack/bolt", () => ({
22
- App: class {
23
- client = {
24
- auth: { test: authSpy },
25
- chat: { postMessage: postSpy, update: updateSpy },
26
- users: { info: vi.fn() },
27
- reactions: { add: vi.fn() },
28
- filesUploadV2: vi.fn(),
29
- conversations: { info: vi.fn() },
30
- apiCall: vi.fn(),
31
- };
32
- constructor(_opts: unknown) {}
33
- message(_h: unknown) {}
34
- event(_k: string, _h: unknown) {}
35
- async start() {}
36
- async stop() {}
37
- },
38
- }));
39
-
40
- const { SlackAdapter } = await import("../src/platforms/slack.js");
41
- const adapter = new SlackAdapter("xoxb-test", "xapp-test");
42
- await adapter.start();
43
-
44
- const returnedId = await adapter.editMessage!("C_TEST", "1234567890.123456", "updated text");
45
- expect(updateSpy).toHaveBeenCalledWith(
46
- expect.objectContaining({
47
- channel: "C_TEST",
48
- ts: "1234567890.123456",
49
- text: "updated text",
50
- }),
51
- );
52
- expect(returnedId).toBe("1234567890.123456");
53
-
54
- await adapter.stop();
55
- });
56
-
57
- it("sendText returns the message ts so it can be edited later", async () => {
58
- const postSpy = vi.fn().mockResolvedValue({ ok: true, ts: "9876543210.555555" });
59
- const authSpy = vi.fn().mockResolvedValue({ ok: true, user_id: "U_BOT", user: "alvin", team: "Test" });
60
-
61
- vi.doMock("@slack/bolt", () => ({
62
- App: class {
63
- client = {
64
- auth: { test: authSpy },
65
- chat: { postMessage: postSpy, update: vi.fn() },
66
- users: { info: vi.fn() },
67
- reactions: { add: vi.fn() },
68
- filesUploadV2: vi.fn(),
69
- conversations: { info: vi.fn() },
70
- apiCall: vi.fn(),
71
- };
72
- constructor(_opts: unknown) {}
73
- message(_h: unknown) {}
74
- event(_k: string, _h: unknown) {}
75
- async start() {}
76
- async stop() {}
77
- },
78
- }));
79
-
80
- const { SlackAdapter } = await import("../src/platforms/slack.js");
81
- const adapter = new SlackAdapter("xoxb-test", "xapp-test");
82
- await adapter.start();
83
-
84
- const id = await adapter.sendText("C_TEST", "first message");
85
- expect(id).toBe("9876543210.555555");
86
-
87
- await adapter.stop();
88
- });
89
-
90
- it("editMessage returns messageId unchanged when chat.update fails", async () => {
91
- const updateSpy = vi.fn().mockRejectedValue(new Error("slack down"));
92
- const authSpy = vi.fn().mockResolvedValue({ ok: true, user_id: "U_BOT", user: "alvin", team: "Test" });
93
-
94
- vi.doMock("@slack/bolt", () => ({
95
- App: class {
96
- client = {
97
- auth: { test: authSpy },
98
- chat: { postMessage: vi.fn().mockResolvedValue({ ok: true, ts: "x" }), update: updateSpy },
99
- users: { info: vi.fn() },
100
- reactions: { add: vi.fn() },
101
- filesUploadV2: vi.fn(),
102
- conversations: { info: vi.fn() },
103
- apiCall: vi.fn(),
104
- };
105
- constructor(_opts: unknown) {}
106
- message(_h: unknown) {}
107
- event(_k: string, _h: unknown) {}
108
- async start() {}
109
- async stop() {}
110
- },
111
- }));
112
-
113
- const { SlackAdapter } = await import("../src/platforms/slack.js");
114
- const adapter = new SlackAdapter("xoxb-test", "xapp-test");
115
- await adapter.start();
116
-
117
- // Should not throw
118
- const result = await adapter.editMessage!("C_TEST", "123.456", "new text");
119
- expect(result).toBe("123.456");
120
-
121
- await adapter.stop();
122
- });
123
- });
@@ -1,61 +0,0 @@
1
- /**
2
- * v4.13.2 — Slack slash command parser tests.
3
- *
4
- * Users on Slack type `/alvin <subcommand> [args...]` which Bolt
5
- * delivers via app.command('/alvin') with `command.text` containing
6
- * the part after `/alvin `. We parse it into a platform-agnostic
7
- * "/subcommand [args]" text that handlePlatformCommand already knows
8
- * how to route (/new, /status, /effort, /help).
9
- *
10
- * Empty text → `/help` (most helpful default).
11
- * Pass-through for everything else — unknown subcommand falls through
12
- * to normal LLM prompt handling.
13
- */
14
- import { describe, it, expect } from "vitest";
15
- import { parseSlackSlashCommand } from "../src/platforms/slack-slash-parser.js";
16
-
17
- describe("parseSlackSlashCommand (v4.13.2)", () => {
18
- it("empty text maps to /help", () => {
19
- expect(parseSlackSlashCommand("")).toBe("/help");
20
- expect(parseSlackSlashCommand(" ")).toBe("/help");
21
- });
22
-
23
- it("single-word subcommand becomes /<subcommand>", () => {
24
- expect(parseSlackSlashCommand("status")).toBe("/status");
25
- expect(parseSlackSlashCommand("new")).toBe("/new");
26
- expect(parseSlackSlashCommand("help")).toBe("/help");
27
- });
28
-
29
- it("subcommand with args preserves the args", () => {
30
- expect(parseSlackSlashCommand("effort high")).toBe("/effort high");
31
- expect(parseSlackSlashCommand("effort low")).toBe("/effort low");
32
- });
33
-
34
- it("multi-word args are preserved verbatim", () => {
35
- expect(parseSlackSlashCommand("ask what is the weather in berlin")).toBe(
36
- "/ask what is the weather in berlin",
37
- );
38
- });
39
-
40
- it("collapses extra whitespace around subcommand", () => {
41
- expect(parseSlackSlashCommand(" status ")).toBe("/status");
42
- expect(parseSlackSlashCommand(" effort max ")).toBe("/effort max");
43
- });
44
-
45
- it("lowercases the subcommand for case-insensitive matching", () => {
46
- expect(parseSlackSlashCommand("Status")).toBe("/status");
47
- expect(parseSlackSlashCommand("HELP")).toBe("/help");
48
- });
49
-
50
- it("does NOT lowercase the args (preserve user intent)", () => {
51
- expect(parseSlackSlashCommand("ask What is THIS")).toBe(
52
- "/ask What is THIS",
53
- );
54
- });
55
-
56
- it("handles leading slash defensively — strips duplicate", () => {
57
- // If a user literally types `/alvin /status`, Slack delivers text="/status"
58
- expect(parseSlackSlashCommand("/status")).toBe("/status");
59
- expect(parseSlackSlashCommand("/effort max")).toBe("/effort max");
60
- });
61
- });