alvin-bot 4.18.0 → 4.18.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AEC-PLUGINS-SOURCES.md +53 -0
- package/CHANGELOG.md +37 -2
- package/DESIGN-SKILLS-SOURCES.md +81 -0
- package/bin/cli.js +1 -1
- package/dist/providers/claude-sdk-provider.js +24 -0
- package/package.json +3 -1
- package/test/allowed-users-gate.test.ts +0 -98
- package/test/alvin-dispatch.test.ts +0 -220
- package/test/async-agent-chunk-flow.test.ts +0 -244
- package/test/async-agent-parser-staleness.test.ts +0 -412
- package/test/async-agent-parser-streamjson.test.ts +0 -273
- package/test/async-agent-parser.test.ts +0 -322
- package/test/async-agent-watcher.test.ts +0 -229
- package/test/background-bypass-integration.test.ts +0 -443
- package/test/background-bypass-stress.test.ts +0 -417
- package/test/background-bypass.test.ts +0 -127
- package/test/browser-webfetch.test.ts +0 -121
- package/test/claude-sdk-provider.test.ts +0 -115
- package/test/claude-sdk-tool-use-id.test.ts +0 -180
- package/test/console-timestamps.test.ts +0 -98
- package/test/cron-progress-ticker.test.ts +0 -76
- package/test/cron-restart-resilience.test.ts +0 -191
- package/test/cron-run-resolver.test.ts +0 -133
- package/test/cron-runjobnow-throw.test.ts +0 -100
- package/test/debounce.test.ts +0 -60
- package/test/delivery-registry.test.ts +0 -71
- package/test/exec-guard-metachars.test.ts +0 -110
- package/test/file-permissions.test.ts +0 -130
- package/test/i18n.test.ts +0 -108
- package/test/list-subagents-merged.test.ts +0 -172
- package/test/memory-extractor.test.ts +0 -151
- package/test/memory-layers.test.ts +0 -169
- package/test/memory-sdk-injection.test.ts +0 -146
- package/test/memory-stress-restart.test.ts +0 -337
- package/test/multi-session-stress.test.ts +0 -255
- package/test/platform-session-key.test.ts +0 -69
- package/test/process-manager.test.ts +0 -186
- package/test/registry.test.ts +0 -201
- package/test/session-pending-background.test.ts +0 -59
- package/test/session-persistence.test.ts +0 -195
- package/test/slack-progress-ticker.test.ts +0 -123
- package/test/slack-slash-command.test.ts +0 -61
- package/test/slack-test-connection.test.ts +0 -176
- package/test/stress-scenarios.test.ts +0 -356
- package/test/stuck-timer.test.ts +0 -116
- package/test/subagent-delivery-markdown-fallback.test.ts +0 -147
- package/test/subagent-delivery-platform-routing.test.ts +0 -232
- package/test/subagent-delivery.test.ts +0 -273
- package/test/subagent-final-text.test.ts +0 -132
- package/test/subagent-stats.test.ts +0 -119
- package/test/subagent-toolset-allowlist.test.ts +0 -146
- package/test/subagents-commands.test.ts +0 -64
- package/test/subagents-config.test.ts +0 -114
- package/test/subagents-depth.test.ts +0 -58
- package/test/subagents-inheritance.test.ts +0 -67
- package/test/subagents-name-resolver.test.ts +0 -122
- package/test/subagents-priority-reject.test.ts +0 -88
- package/test/subagents-queue.test.ts +0 -127
- package/test/subagents-shutdown.test.ts +0 -126
- package/test/subagents-toolset.test.ts +0 -71
- package/test/sync-task-timeout.test.ts +0 -153
- package/test/system-prompt-background-hint.test.ts +0 -65
- package/test/telegram-error-filter.test.ts +0 -85
- package/test/telegram-workspace-command.test.ts +0 -78
- package/test/timing-safe-bearer.test.ts +0 -65
- package/test/watchdog-brake.test.ts +0 -157
- package/test/watcher-pending-count.test.ts +0 -228
- package/test/watcher-zombie-fix.test.ts +0 -252
- package/test/web-server-integration.test.ts +0 -189
- package/test/web-server-resilience.test.ts +0 -118
- package/test/web-server-shutdown.test.ts +0 -117
- package/test/whatsapp-auth-resilience.test.ts +0 -96
- package/test/workspaces.test.ts +0 -196
- package/vitest.config.ts +0 -17
package/test/registry.test.ts
DELETED
|
@@ -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
|
-
});
|