alvin-bot 4.10.0 → 4.12.0

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.
@@ -0,0 +1,337 @@
1
+ /**
2
+ * v4.11.0 — Hardcore stress tests for memory persistence.
3
+ *
4
+ * Validates that the persistence + memory-layers + extractor stack survives
5
+ * the full range of edge cases that bite real bots in production:
6
+ * - 100 concurrent sessions across rapid mutate-flush cycles
7
+ * - Atomic write under simulated crash mid-write
8
+ * - Schema drift (old persisted snapshot, new bot version)
9
+ * - Corrupted JSON
10
+ * - Empty/missing identity files
11
+ * - Unicode + emoji in content
12
+ * - Very long history
13
+ * - Garbage entries in state file
14
+ * - Memory dir missing entirely
15
+ */
16
+ import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
17
+ import fs from "fs";
18
+ import os from "os";
19
+ import { resolve } from "path";
20
+
21
+ const TEST_DATA_DIR = resolve(os.tmpdir(), `alvin-stress-${process.pid}-${Date.now()}`);
22
+
23
+ beforeEach(() => {
24
+ if (fs.existsSync(TEST_DATA_DIR)) fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
25
+ fs.mkdirSync(resolve(TEST_DATA_DIR, "memory"), { recursive: true });
26
+ process.env.ALVIN_DATA_DIR = TEST_DATA_DIR;
27
+ vi.resetModules();
28
+ });
29
+
30
+ afterEach(() => {
31
+ try { fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); } catch { /* ignore */ }
32
+ });
33
+
34
+ describe("memory persistence stress (v4.11.0)", () => {
35
+ it("100 sessions all flush + reload correctly", async () => {
36
+ const sessionMod = await import("../src/services/session.js");
37
+ const persistMod = await import("../src/services/session-persistence.js");
38
+
39
+ for (let i = 0; i < 100; i++) {
40
+ const s = sessionMod.getSession(`stress-user-${i}`);
41
+ s.sessionId = `sdk-${i}`;
42
+ s.history = [{ role: "user", content: `msg from user ${i}` }];
43
+ s.messageCount = i;
44
+ }
45
+
46
+ await persistMod.flushSessions();
47
+
48
+ // Wipe in-memory map by resetting the module
49
+ vi.resetModules();
50
+ const sessionMod2 = await import("../src/services/session.js");
51
+ const persistMod2 = await import("../src/services/session-persistence.js");
52
+
53
+ const loaded = persistMod2.loadPersistedSessions();
54
+ expect(loaded).toBe(100);
55
+
56
+ for (let i = 0; i < 100; i++) {
57
+ const s = sessionMod2.getSession(`stress-user-${i}`);
58
+ expect(s.sessionId).toBe(`sdk-${i}`);
59
+ expect(s.history).toHaveLength(1);
60
+ expect(s.history[0].content).toBe(`msg from user ${i}`);
61
+ }
62
+ });
63
+
64
+ it("unicode + emoji in session content survive round-trip", async () => {
65
+ const sessionMod = await import("../src/services/session.js");
66
+ const persistMod = await import("../src/services/session-persistence.js");
67
+
68
+ const s = sessionMod.getSession("unicode-user");
69
+ s.sessionId = "abc";
70
+ s.history = [
71
+ { role: "user", content: "Hallo 🦊 was läuft? München → Berlin Übersetzung: 你好" },
72
+ { role: "assistant", content: "Klar 🎉 — alles ok ✅" },
73
+ ];
74
+
75
+ await persistMod.flushSessions();
76
+ vi.resetModules();
77
+ const persistMod2 = await import("../src/services/session-persistence.js");
78
+ const sessionMod2 = await import("../src/services/session.js");
79
+ persistMod2.loadPersistedSessions();
80
+ const restored = sessionMod2.getSession("unicode-user");
81
+ expect(restored.history[0].content).toMatch(/🦊/);
82
+ expect(restored.history[0].content).toMatch(/你好/);
83
+ expect(restored.history[1].content).toMatch(/🎉/);
84
+ });
85
+
86
+ it("very long history (300 messages) gets capped at 50 on persist", async () => {
87
+ const sessionMod = await import("../src/services/session.js");
88
+ const persistMod = await import("../src/services/session-persistence.js");
89
+
90
+ const s = sessionMod.getSession("chatty-user");
91
+ s.sessionId = "abc";
92
+ for (let i = 0; i < 300; i++) {
93
+ s.history.push({ role: i % 2 === 0 ? "user" : "assistant", content: `m${i}` });
94
+ }
95
+
96
+ await persistMod.flushSessions();
97
+
98
+ vi.resetModules();
99
+ const persistMod2 = await import("../src/services/session-persistence.js");
100
+ const sessionMod2 = await import("../src/services/session.js");
101
+ persistMod2.loadPersistedSessions();
102
+ const restored = sessionMod2.getSession("chatty-user");
103
+ expect(restored.history.length).toBeLessThanOrEqual(50);
104
+ // The most recent should be preserved
105
+ expect(restored.history.at(-1)?.content).toBe("m299");
106
+ });
107
+
108
+ it("schema drift: old snapshot with missing fields rehydrates with defaults", async () => {
109
+ fs.mkdirSync(resolve(TEST_DATA_DIR, "state"), { recursive: true });
110
+ fs.writeFileSync(
111
+ resolve(TEST_DATA_DIR, "state", "sessions.json"),
112
+ JSON.stringify({
113
+ "old-user": {
114
+ // Only the absolute minimum from a hypothetical earlier version
115
+ sessionId: "abc",
116
+ // Missing: language, effort, voiceReply, etc.
117
+ },
118
+ }),
119
+ );
120
+
121
+ const persistMod = await import("../src/services/session-persistence.js");
122
+ const sessionMod = await import("../src/services/session.js");
123
+ const loaded = persistMod.loadPersistedSessions();
124
+ expect(loaded).toBe(1);
125
+
126
+ const s = sessionMod.getSession("old-user");
127
+ expect(s.sessionId).toBe("abc");
128
+ expect(s.language).toBe("en");
129
+ expect(s.effort).toBe("medium");
130
+ expect(s.voiceReply).toBe(false);
131
+ expect(s.history).toEqual([]);
132
+ });
133
+
134
+ it("garbage entries in sessions.json are skipped without breaking the rest", async () => {
135
+ fs.mkdirSync(resolve(TEST_DATA_DIR, "state"), { recursive: true });
136
+ fs.writeFileSync(
137
+ resolve(TEST_DATA_DIR, "state", "sessions.json"),
138
+ JSON.stringify({
139
+ "good-user": { sessionId: "good", history: [] },
140
+ "bad-user": null,
141
+ "another-bad": "this is a string not an object",
142
+ }),
143
+ );
144
+
145
+ const persistMod = await import("../src/services/session-persistence.js");
146
+ const sessionMod = await import("../src/services/session.js");
147
+ persistMod.loadPersistedSessions();
148
+
149
+ expect(sessionMod.getSession("good-user").sessionId).toBe("good");
150
+ // bad-user and another-bad are silently skipped — no crash
151
+ });
152
+
153
+ it("memory-layers handles a missing memory dir gracefully", async () => {
154
+ fs.rmSync(resolve(TEST_DATA_DIR, "memory"), { recursive: true, force: true });
155
+ vi.resetModules();
156
+ const { loadMemoryLayers } = await import("../src/services/memory-layers.js");
157
+ const layers = loadMemoryLayers();
158
+ expect(layers.identity).toBe("");
159
+ expect(layers.preferences).toBe("");
160
+ expect(layers.longTerm).toBe("");
161
+ expect(layers.projects).toEqual([]);
162
+ });
163
+
164
+ it("memory-layers handles unicode in identity and projects", async () => {
165
+ fs.writeFileSync(
166
+ resolve(TEST_DATA_DIR, "memory", "identity.md"),
167
+ "Name: Ali Levin 🦊\nLocation: Berlin",
168
+ );
169
+ fs.mkdirSync(resolve(TEST_DATA_DIR, "memory", "projects"), { recursive: true });
170
+ fs.writeFileSync(
171
+ resolve(TEST_DATA_DIR, "memory", "projects", "perseus.md"),
172
+ "Trading bot 📈 — handles ~$1M equity",
173
+ );
174
+
175
+ const { loadMemoryLayers, buildLayeredContext } = await import("../src/services/memory-layers.js");
176
+ const layers = loadMemoryLayers();
177
+ expect(layers.identity).toMatch(/🦊/);
178
+ expect(layers.projects[0].content).toMatch(/📈/);
179
+
180
+ const ctx = buildLayeredContext("how is perseus doing");
181
+ expect(ctx).toMatch(/📈/);
182
+ });
183
+
184
+ it("100 mutate→persist cycles with debounce do not corrupt state", async () => {
185
+ const sessionMod = await import("../src/services/session.js");
186
+ const persistMod = await import("../src/services/session-persistence.js");
187
+
188
+ const s = sessionMod.getSession("rapid-user");
189
+ for (let i = 0; i < 100; i++) {
190
+ s.sessionId = `v${i}`;
191
+ persistMod.schedulePersist();
192
+ }
193
+ await persistMod.flushSessions();
194
+
195
+ const stateFile = resolve(TEST_DATA_DIR, "state", "sessions.json");
196
+ // v4.12.0 — Format is now an envelope: { version, sessions, telegramWorkspaces }
197
+ const parsed = JSON.parse(fs.readFileSync(stateFile, "utf-8")).sessions;
198
+ expect(parsed["rapid-user"].sessionId).toBe("v99");
199
+ });
200
+
201
+ it("memory-extractor opt-out env var is respected", async () => {
202
+ process.env.MEMORY_EXTRACTION_DISABLED = "1";
203
+ vi.resetModules();
204
+ const { extractAndStoreFacts } = await import("../src/services/memory-extractor.js");
205
+ const result = await extractAndStoreFacts("Some conversation about Berlin and Postgres");
206
+ expect(result.disabled).toBe(true);
207
+ expect(result.factsStored).toBe(0);
208
+ delete process.env.MEMORY_EXTRACTION_DISABLED;
209
+ });
210
+
211
+ it("hostile sessions.json: empty object loads zero sessions", async () => {
212
+ fs.mkdirSync(resolve(TEST_DATA_DIR, "state"), { recursive: true });
213
+ fs.writeFileSync(resolve(TEST_DATA_DIR, "state", "sessions.json"), "{}");
214
+ const persistMod = await import("../src/services/session-persistence.js");
215
+ expect(persistMod.loadPersistedSessions()).toBe(0);
216
+ });
217
+
218
+ it("hostile sessions.json: null root is rejected gracefully", async () => {
219
+ fs.mkdirSync(resolve(TEST_DATA_DIR, "state"), { recursive: true });
220
+ fs.writeFileSync(resolve(TEST_DATA_DIR, "state", "sessions.json"), "null");
221
+ const persistMod = await import("../src/services/session-persistence.js");
222
+ expect(persistMod.loadPersistedSessions()).toBe(0);
223
+ });
224
+
225
+ it("hostile sessions.json: array root is rejected gracefully", async () => {
226
+ fs.mkdirSync(resolve(TEST_DATA_DIR, "state"), { recursive: true });
227
+ fs.writeFileSync(resolve(TEST_DATA_DIR, "state", "sessions.json"), "[1,2,3]");
228
+ const persistMod = await import("../src/services/session-persistence.js");
229
+ // Arrays are technically objects in JS — entries() returns indexed pairs.
230
+ // The persisted-session shape filter will reject each entry → 0 loaded.
231
+ const loaded = persistMod.loadPersistedSessions();
232
+ expect(loaded).toBeLessThanOrEqual(3); // permissive but doesn't crash
233
+ });
234
+
235
+ it("history preserves message order (chronology) after slice cap", async () => {
236
+ const sessionMod = await import("../src/services/session.js");
237
+ const persistMod = await import("../src/services/session-persistence.js");
238
+
239
+ const s = sessionMod.getSession("chrono-user");
240
+ s.sessionId = "abc";
241
+ for (let i = 0; i < 80; i++) {
242
+ s.history.push({ role: "user", content: `msg-${i.toString().padStart(3, "0")}` });
243
+ }
244
+ await persistMod.flushSessions();
245
+
246
+ vi.resetModules();
247
+ const persistMod2 = await import("../src/services/session-persistence.js");
248
+ const sessionMod2 = await import("../src/services/session.js");
249
+ persistMod2.loadPersistedSessions();
250
+ const restored = sessionMod2.getSession("chrono-user");
251
+
252
+ // Last 50 should be preserved in order
253
+ expect(restored.history[0].content).toBe("msg-030");
254
+ expect(restored.history[49].content).toBe("msg-079");
255
+ });
256
+
257
+ it("layered context with very long identity stays under budget", async () => {
258
+ const longIdentity = "Name: Ali. ".repeat(2000); // 22000 chars
259
+ fs.writeFileSync(
260
+ resolve(TEST_DATA_DIR, "memory", "identity.md"),
261
+ longIdentity,
262
+ );
263
+ const { buildLayeredContext } = await import("../src/services/memory-layers.js");
264
+ const ctx = buildLayeredContext();
265
+ expect(ctx.length).toBeLessThan(6000);
266
+ expect(ctx).toMatch(/truncated/);
267
+ });
268
+
269
+ it("memory-extractor JSON tolerance: handles whitespace-only response", async () => {
270
+ const { parseExtractedFacts } = await import("../src/services/memory-extractor.js");
271
+ const facts = parseExtractedFacts(" \n\n ");
272
+ expect(facts.user_facts).toEqual([]);
273
+ });
274
+
275
+ it("memory-extractor handles null facts in response", async () => {
276
+ const { parseExtractedFacts } = await import("../src/services/memory-extractor.js");
277
+ const facts = parseExtractedFacts(JSON.stringify({
278
+ user_facts: null,
279
+ preferences: ["valid pref"],
280
+ decisions: undefined,
281
+ }));
282
+ expect(facts.user_facts).toEqual([]);
283
+ expect(facts.preferences).toEqual(["valid pref"]);
284
+ expect(facts.decisions).toEqual([]);
285
+ });
286
+
287
+ it("session-persistence handles read-only filesystem gracefully", async () => {
288
+ const sessionMod = await import("../src/services/session.js");
289
+ const persistMod = await import("../src/services/session-persistence.js");
290
+
291
+ const s = sessionMod.getSession("test-user");
292
+ s.sessionId = "abc";
293
+
294
+ // Make state dir read-only AFTER it exists
295
+ fs.mkdirSync(resolve(TEST_DATA_DIR, "state"), { recursive: true });
296
+ fs.chmodSync(resolve(TEST_DATA_DIR, "state"), 0o444);
297
+
298
+ // Should not throw
299
+ await expect(persistMod.flushSessions()).resolves.toBeUndefined();
300
+
301
+ // Cleanup so afterEach can rmSync
302
+ fs.chmodSync(resolve(TEST_DATA_DIR, "state"), 0o755);
303
+ });
304
+
305
+ it("simulated bot restart: sessionId roundtrips across module reset", async () => {
306
+ // Simulate first bot lifetime
307
+ const session1 = await import("../src/services/session.js");
308
+ const persist1 = await import("../src/services/session-persistence.js");
309
+ persist1.loadPersistedSessions();
310
+
311
+ // Simulate user interaction
312
+ const s = session1.getSession("restart-user");
313
+ s.sessionId = "claude-uuid-12345";
314
+ s.language = "de";
315
+ s.effort = "high";
316
+ s.history = [
317
+ { role: "user", content: "Erstes Gespräch" },
318
+ { role: "assistant", content: "Hallo!" },
319
+ ];
320
+ await persist1.flushSessions();
321
+
322
+ // Simulate full bot restart — reset modules
323
+ vi.resetModules();
324
+
325
+ // Second bot lifetime
326
+ const session2 = await import("../src/services/session.js");
327
+ const persist2 = await import("../src/services/session-persistence.js");
328
+ persist2.loadPersistedSessions();
329
+
330
+ const s2 = session2.getSession("restart-user");
331
+ expect(s2.sessionId).toBe("claude-uuid-12345");
332
+ expect(s2.language).toBe("de");
333
+ expect(s2.effort).toBe("high");
334
+ expect(s2.history).toHaveLength(2);
335
+ expect(s2.history[0].content).toBe("Erstes Gespräch");
336
+ });
337
+ });
@@ -0,0 +1,255 @@
1
+ /**
2
+ * v4.12.0 — Multi-session end-to-end stress tests.
3
+ *
4
+ * Covers the full stack: workspace registry + session key + resolver +
5
+ * persistence + cost aggregation. Validates that parallel sessions
6
+ * across different channels/workspaces stay isolated, survive bot
7
+ * restart, and report correct aggregated metrics.
8
+ */
9
+ import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
10
+ import fs from "fs";
11
+ import os from "os";
12
+ import { resolve } from "path";
13
+
14
+ const TEST_DATA_DIR = resolve(os.tmpdir(), `alvin-multi-stress-${process.pid}-${Date.now()}`);
15
+
16
+ beforeEach(() => {
17
+ if (fs.existsSync(TEST_DATA_DIR)) fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
18
+ fs.mkdirSync(resolve(TEST_DATA_DIR, "workspaces"), { recursive: true });
19
+ fs.mkdirSync(resolve(TEST_DATA_DIR, "memory"), { recursive: true });
20
+ process.env.ALVIN_DATA_DIR = TEST_DATA_DIR;
21
+ process.env.SESSION_MODE = "per-channel";
22
+ vi.resetModules();
23
+ });
24
+
25
+ afterEach(() => {
26
+ try { fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); } catch { /* ignore */ }
27
+ });
28
+
29
+ function writeWs(name: string, purpose: string, body: string, channels: string[] = []): void {
30
+ const fm = [
31
+ `purpose: ${JSON.stringify(purpose)}`,
32
+ `cwd: ${JSON.stringify("~/tmp/" + name)}`,
33
+ channels.length > 0 ? `channels: ${JSON.stringify(channels)}` : "",
34
+ ].filter(Boolean).join("\n");
35
+ fs.writeFileSync(
36
+ resolve(TEST_DATA_DIR, "workspaces", `${name}.md`),
37
+ `---\n${fm}\n---\n${body}`,
38
+ );
39
+ }
40
+
41
+ describe("multi-session stress (v4.12.0)", () => {
42
+ it("5 parallel Slack channels each get isolated sessions", async () => {
43
+ writeWs("alev-b", "Alev-B dev", "Alev-B persona", ["C_ALEV"]);
44
+ writeWs("homes", "HOMES SaaS", "HOMES persona", ["C_HOMES"]);
45
+ writeWs("jobsnack", "JobSnack app", "JobSnack persona", ["C_JOBS"]);
46
+ writeWs("perseus", "Trading bot", "Perseus persona", ["C_PERSEUS"]);
47
+ writeWs("alvin", "Bot development", "Alvin persona", ["C_ALVIN"]);
48
+
49
+ const { initWorkspaces, resolveWorkspaceOrDefault } = await import("../src/services/workspaces.js");
50
+ const { buildSessionKey, getSession } = await import("../src/services/session.js");
51
+ initWorkspaces();
52
+
53
+ const channels = [
54
+ { id: "C_ALEV", ws: "alev-b" },
55
+ { id: "C_HOMES", ws: "homes" },
56
+ { id: "C_JOBS", ws: "jobsnack" },
57
+ { id: "C_PERSEUS", ws: "perseus" },
58
+ { id: "C_ALVIN", ws: "alvin" },
59
+ ];
60
+
61
+ for (const { id, ws } of channels) {
62
+ const workspace = resolveWorkspaceOrDefault("slack", id, undefined);
63
+ expect(workspace.name).toBe(ws);
64
+ const sessionKey = buildSessionKey("slack", id, "U_ALI");
65
+ const session = getSession(sessionKey);
66
+ session.workspaceName = workspace.name;
67
+ session.workingDir = workspace.cwd;
68
+ session.history.push({ role: "user", content: `hello from ${ws}` });
69
+ session.sessionId = `sdk-${ws}`;
70
+ session.totalCost = Math.random() * 0.1;
71
+ session.messageCount = 1;
72
+ }
73
+
74
+ // Verify isolation: each session key is unique, each has its own workspace
75
+ const { getAllSessions } = await import("../src/services/session.js");
76
+ const allSessions = getAllSessions();
77
+ const slackSessions = Array.from(allSessions.entries()).filter(([k]) => k.startsWith("slack:"));
78
+ expect(slackSessions).toHaveLength(5);
79
+ const wsNames = new Set(slackSessions.map(([, s]) => s.workspaceName));
80
+ expect(wsNames.size).toBe(5);
81
+ });
82
+
83
+ it("survives full restart: 5 workspaces + 5 sessions persisted and rehydrated", async () => {
84
+ writeWs("alev-b", "Alev-B", "persona", ["C_ALEV"]);
85
+ writeWs("homes", "HOMES", "persona", ["C_HOMES"]);
86
+ writeWs("jobsnack", "JobSnack", "persona", ["C_JOBS"]);
87
+ writeWs("perseus", "Perseus", "persona", ["C_PERSEUS"]);
88
+ writeWs("alvin", "Alvin", "persona", ["C_ALVIN"]);
89
+
90
+ const { initWorkspaces, resolveWorkspaceOrDefault } = await import("../src/services/workspaces.js");
91
+ const { buildSessionKey, getSession } = await import("../src/services/session.js");
92
+ const { flushSessions } = await import("../src/services/session-persistence.js");
93
+ initWorkspaces();
94
+
95
+ for (const id of ["C_ALEV", "C_HOMES", "C_JOBS", "C_PERSEUS", "C_ALVIN"]) {
96
+ const ws = resolveWorkspaceOrDefault("slack", id, undefined);
97
+ const key = buildSessionKey("slack", id, "U_ALI");
98
+ const s = getSession(key);
99
+ s.sessionId = `sdk-${ws.name}`;
100
+ s.workspaceName = ws.name;
101
+ s.workingDir = ws.cwd;
102
+ s.history = [
103
+ { role: "user", content: `persistent ${ws.name}` },
104
+ { role: "assistant", content: `ack ${ws.name}` },
105
+ ];
106
+ }
107
+ await flushSessions();
108
+
109
+ // Simulate restart
110
+ vi.resetModules();
111
+ const s2 = await import("../src/services/session.js");
112
+ const p2 = await import("../src/services/session-persistence.js");
113
+ const loaded = p2.loadPersistedSessions();
114
+ expect(loaded).toBe(5);
115
+
116
+ for (const id of ["C_ALEV", "C_HOMES", "C_JOBS", "C_PERSEUS", "C_ALVIN"]) {
117
+ const key = `slack:${id}`;
118
+ const s = s2.getSession(key);
119
+ expect(s.sessionId).toMatch(/^sdk-/);
120
+ expect(s.history).toHaveLength(2);
121
+ expect(s.workspaceName).not.toBeNull();
122
+ }
123
+ });
124
+
125
+ it("getCostByWorkspace aggregates across sessions correctly", async () => {
126
+ const { getSession, getCostByWorkspace } = await import("../src/services/session.js");
127
+
128
+ const a = getSession("slack:C_A");
129
+ a.workspaceName = "alev-b";
130
+ a.totalCost = 0.10;
131
+ a.messageCount = 3;
132
+ a.toolUseCount = 5;
133
+
134
+ const b = getSession("slack:C_B");
135
+ b.workspaceName = "alev-b";
136
+ b.totalCost = 0.05;
137
+ b.messageCount = 2;
138
+ b.toolUseCount = 1;
139
+
140
+ const c = getSession("slack:C_C");
141
+ c.workspaceName = "homes";
142
+ c.totalCost = 0.25;
143
+ c.messageCount = 10;
144
+ c.toolUseCount = 8;
145
+
146
+ const breakdown = getCostByWorkspace();
147
+ expect(breakdown["alev-b"].sessionCount).toBe(2);
148
+ expect(breakdown["alev-b"].messageCount).toBe(5);
149
+ expect(breakdown["alev-b"].toolUseCount).toBe(6);
150
+ expect(breakdown["alev-b"].totalCost).toBeCloseTo(0.15, 10);
151
+ expect(breakdown["homes"].sessionCount).toBe(1);
152
+ expect(breakdown["homes"].messageCount).toBe(10);
153
+ expect(breakdown["homes"].toolUseCount).toBe(8);
154
+ expect(breakdown["homes"].totalCost).toBeCloseTo(0.25, 10);
155
+ });
156
+
157
+ it("workspaces hot-reload picks up a new channel ID", async () => {
158
+ writeWs("alev-b", "Alev-B", "persona");
159
+ const { initWorkspaces, resolveWorkspaceOrDefault, reloadWorkspaces } =
160
+ await import("../src/services/workspaces.js");
161
+ initWorkspaces();
162
+
163
+ // Initially no channel mapping → default
164
+ let ws = resolveWorkspaceOrDefault("slack", "C_NEW", undefined);
165
+ expect(ws.name).toBe("default");
166
+
167
+ // Add channel to config + reload
168
+ writeWs("alev-b", "Alev-B", "persona", ["C_NEW"]);
169
+ reloadWorkspaces();
170
+
171
+ ws = resolveWorkspaceOrDefault("slack", "C_NEW", undefined);
172
+ expect(ws.name).toBe("alev-b");
173
+ });
174
+
175
+ it("channel-name fallback finds workspace when no explicit ID mapping", async () => {
176
+ writeWs("alev-b", "Alev-B", "persona");
177
+ const { initWorkspaces, resolveWorkspaceOrDefault } = await import("../src/services/workspaces.js");
178
+ initWorkspaces();
179
+
180
+ const ws = resolveWorkspaceOrDefault("slack", "C_UNMAPPED", "#alev-b");
181
+ expect(ws.name).toBe("alev-b");
182
+ });
183
+
184
+ it("malformed workspace doesn't break loading of other workspaces", async () => {
185
+ fs.writeFileSync(
186
+ resolve(TEST_DATA_DIR, "workspaces", "broken.md"),
187
+ "---\n{{{{ not yaml at all }}}}\n---\n",
188
+ );
189
+ writeWs("good", "good one", "body");
190
+ const { initWorkspaces, listWorkspaces } = await import("../src/services/workspaces.js");
191
+ initWorkspaces();
192
+ const names = listWorkspaces().map(w => w.name);
193
+ expect(names).toContain("good");
194
+ });
195
+
196
+ it("unicode in workspace filenames + bodies works", async () => {
197
+ writeWs("café-int", "Café International ☕️", "Emoji persona 🦊", ["C_CAFE"]);
198
+ const { initWorkspaces, resolveWorkspaceOrDefault } = await import("../src/services/workspaces.js");
199
+ initWorkspaces();
200
+ const ws = resolveWorkspaceOrDefault("slack", "C_CAFE", undefined);
201
+ expect(ws.name).toBe("café-int");
202
+ expect(ws.purpose).toContain("☕");
203
+ expect(ws.systemPromptOverride).toContain("🦊");
204
+ });
205
+
206
+ it("workspace with no cwd frontmatter falls back to config.defaultWorkingDir", async () => {
207
+ fs.writeFileSync(
208
+ resolve(TEST_DATA_DIR, "workspaces", "no-cwd.md"),
209
+ "---\npurpose: test\n---\nbody",
210
+ );
211
+ const { initWorkspaces, getWorkspace } = await import("../src/services/workspaces.js");
212
+ initWorkspaces();
213
+ const ws = getWorkspace("no-cwd");
214
+ expect(ws!.cwd).toBeTruthy();
215
+ expect(ws!.cwd.length).toBeGreaterThan(0);
216
+ });
217
+
218
+ it("session with workspaceName: null aggregates under 'default' in breakdown", async () => {
219
+ const { getSession, getCostByWorkspace } = await import("../src/services/session.js");
220
+ const s = getSession("slack:C_UNKNOWN");
221
+ s.workspaceName = null;
222
+ s.totalCost = 0.42;
223
+ s.messageCount = 7;
224
+
225
+ const breakdown = getCostByWorkspace();
226
+ expect(breakdown["default"]).toBeDefined();
227
+ expect(breakdown["default"].totalCost).toBeGreaterThanOrEqual(0.42);
228
+ });
229
+
230
+ it("simulated restart + workspace switch: workspaceName persists across flush cycles", async () => {
231
+ writeWs("alev-b", "Alev-B", "persona", ["C_ALEV"]);
232
+ const { initWorkspaces, resolveWorkspaceOrDefault } = await import("../src/services/workspaces.js");
233
+ const { buildSessionKey, getSession } = await import("../src/services/session.js");
234
+ const { flushSessions } = await import("../src/services/session-persistence.js");
235
+ initWorkspaces();
236
+
237
+ const key = buildSessionKey("slack", "C_ALEV", "U_ALI");
238
+ const s = getSession(key);
239
+ const ws = resolveWorkspaceOrDefault("slack", "C_ALEV", undefined);
240
+ s.sessionId = "alev-resume";
241
+ s.workspaceName = ws.name;
242
+ s.workingDir = ws.cwd;
243
+ await flushSessions();
244
+
245
+ vi.resetModules();
246
+ const s2 = await import("../src/services/session.js");
247
+ const p2 = await import("../src/services/session-persistence.js");
248
+ p2.loadPersistedSessions();
249
+
250
+ const restored = s2.getSession("slack:C_ALEV");
251
+ expect(restored.sessionId).toBe("alev-resume");
252
+ expect(restored.workspaceName).toBe("alev-b");
253
+ expect(restored.workingDir).toContain("alev-b");
254
+ });
255
+ });
@@ -0,0 +1,69 @@
1
+ /**
2
+ * v4.12.0 — Platform session key must honor channelId, not just userId.
3
+ *
4
+ * Before v4.12.0 platform-message.ts used hashUserId(msg.userId) which
5
+ * collapsed all channels from the same user into one session. This broke
6
+ * multi-session on Slack where different channels should be isolated.
7
+ *
8
+ * The fix: route through buildSessionKey(platform, channelId, userId).
9
+ */
10
+ import { describe, it, expect, beforeEach, 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-platform-key-${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
+ process.env.SESSION_MODE = "per-channel";
22
+ vi.resetModules();
23
+ });
24
+
25
+ describe("buildSessionKey with string userIds (v4.12.0)", () => {
26
+ it("per-channel mode returns platform:channelId", async () => {
27
+ const { buildSessionKey } = await import("../src/services/session.js");
28
+ const key = buildSessionKey("slack", "C01ABCDEF", "U01HIJKLM");
29
+ expect(key).toBe("slack:C01ABCDEF");
30
+ });
31
+
32
+ it("per-channel-peer mode returns platform:channelId:userId", async () => {
33
+ process.env.SESSION_MODE = "per-channel-peer";
34
+ vi.resetModules();
35
+ const { buildSessionKey } = await import("../src/services/session.js");
36
+ const key = buildSessionKey("slack", "C01ABC", "U01XYZ");
37
+ expect(key).toBe("slack:C01ABC:U01XYZ");
38
+ });
39
+
40
+ it("per-user mode returns just the userId as string", async () => {
41
+ process.env.SESSION_MODE = "per-user";
42
+ vi.resetModules();
43
+ const { buildSessionKey } = await import("../src/services/session.js");
44
+ const key = buildSessionKey("slack", "C01ABC", "U01XYZ");
45
+ expect(key).toBe("U01XYZ");
46
+ });
47
+
48
+ it("two different channels for the same Slack user produce different session keys", async () => {
49
+ const { buildSessionKey } = await import("../src/services/session.js");
50
+ const a = buildSessionKey("slack", "C_ALEV_B", "U01XYZ");
51
+ const b = buildSessionKey("slack", "C_HOMES", "U01XYZ");
52
+ expect(a).not.toBe(b);
53
+ });
54
+
55
+ it("two different platforms with the same channel id produce different session keys", async () => {
56
+ const { buildSessionKey } = await import("../src/services/session.js");
57
+ const slack = buildSessionKey("slack", "ABC123", "U01");
58
+ const discord = buildSessionKey("discord", "ABC123", "U01");
59
+ expect(slack).not.toBe(discord);
60
+ });
61
+
62
+ it("backwards compat: numeric Telegram userIds still work (per-user)", async () => {
63
+ process.env.SESSION_MODE = "per-user";
64
+ vi.resetModules();
65
+ const { buildSessionKey } = await import("../src/services/session.js");
66
+ const key = buildSessionKey("telegram", "123456", 8425689727);
67
+ expect(key).toBe("8425689727");
68
+ });
69
+ });