@lobu/gateway 3.0.5 → 3.0.7

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 (175) hide show
  1. package/package.json +2 -2
  2. package/src/__tests__/agent-config-routes.test.ts +254 -0
  3. package/src/__tests__/agent-history-routes.test.ts +72 -0
  4. package/src/__tests__/agent-routes.test.ts +68 -0
  5. package/src/__tests__/agent-schedules-routes.test.ts +59 -0
  6. package/src/__tests__/agent-settings-store.test.ts +323 -0
  7. package/src/__tests__/chat-instance-manager-slack.test.ts +204 -0
  8. package/src/__tests__/chat-response-bridge.test.ts +131 -0
  9. package/src/__tests__/config-memory-plugins.test.ts +92 -0
  10. package/src/__tests__/config-request-store.test.ts +127 -0
  11. package/src/__tests__/connection-routes.test.ts +144 -0
  12. package/src/__tests__/core-services-store-selection.test.ts +92 -0
  13. package/src/__tests__/docker-deployment.test.ts +1211 -0
  14. package/src/__tests__/embedded-deployment.test.ts +342 -0
  15. package/src/__tests__/grant-store.test.ts +148 -0
  16. package/src/__tests__/http-proxy.test.ts +281 -0
  17. package/src/__tests__/instruction-service.test.ts +37 -0
  18. package/src/__tests__/link-buttons.test.ts +112 -0
  19. package/src/__tests__/lobu.test.ts +32 -0
  20. package/src/__tests__/mcp-config-service.test.ts +347 -0
  21. package/src/__tests__/mcp-proxy.test.ts +696 -0
  22. package/src/__tests__/message-handler-bridge.test.ts +17 -0
  23. package/src/__tests__/model-selection.test.ts +172 -0
  24. package/src/__tests__/oauth-templates.test.ts +39 -0
  25. package/src/__tests__/platform-adapter-slack-send.test.ts +114 -0
  26. package/src/__tests__/platform-helpers-model-resolution.test.ts +253 -0
  27. package/src/__tests__/provider-inheritance.test.ts +212 -0
  28. package/src/__tests__/routes/cli-auth.test.ts +337 -0
  29. package/src/__tests__/routes/interactions.test.ts +121 -0
  30. package/src/__tests__/secret-proxy.test.ts +85 -0
  31. package/src/__tests__/session-manager.test.ts +572 -0
  32. package/src/__tests__/setup.ts +133 -0
  33. package/src/__tests__/skill-and-mcp-registry.test.ts +203 -0
  34. package/src/__tests__/slack-routes.test.ts +161 -0
  35. package/src/__tests__/system-config-resolver.test.ts +75 -0
  36. package/src/__tests__/system-message-limiter.test.ts +89 -0
  37. package/src/__tests__/system-skills-service.test.ts +362 -0
  38. package/src/__tests__/transcription-service.test.ts +222 -0
  39. package/src/__tests__/utils/rate-limiter.test.ts +102 -0
  40. package/src/__tests__/worker-connection-manager.test.ts +497 -0
  41. package/src/__tests__/worker-job-router.test.ts +722 -0
  42. package/src/api/index.ts +1 -0
  43. package/src/api/platform.ts +292 -0
  44. package/src/api/response-renderer.ts +157 -0
  45. package/src/auth/agent-metadata-store.ts +168 -0
  46. package/src/auth/api-auth-middleware.ts +69 -0
  47. package/src/auth/api-key-provider-module.ts +213 -0
  48. package/src/auth/base-provider-module.ts +201 -0
  49. package/src/auth/chatgpt/chatgpt-oauth-module.ts +185 -0
  50. package/src/auth/chatgpt/device-code-client.ts +218 -0
  51. package/src/auth/chatgpt/index.ts +1 -0
  52. package/src/auth/claude/oauth-module.ts +280 -0
  53. package/src/auth/cli/token-service.ts +249 -0
  54. package/src/auth/external/client.ts +560 -0
  55. package/src/auth/external/device-code-client.ts +225 -0
  56. package/src/auth/mcp/config-service.ts +392 -0
  57. package/src/auth/mcp/proxy.ts +1088 -0
  58. package/src/auth/mcp/string-substitution.ts +17 -0
  59. package/src/auth/mcp/tool-cache.ts +90 -0
  60. package/src/auth/oauth/base-client.ts +267 -0
  61. package/src/auth/oauth/client.ts +153 -0
  62. package/src/auth/oauth/credentials.ts +7 -0
  63. package/src/auth/oauth/providers.ts +69 -0
  64. package/src/auth/oauth/state-store.ts +150 -0
  65. package/src/auth/oauth-templates.ts +179 -0
  66. package/src/auth/provider-catalog.ts +220 -0
  67. package/src/auth/provider-model-options.ts +41 -0
  68. package/src/auth/settings/agent-settings-store.ts +565 -0
  69. package/src/auth/settings/auth-profiles-manager.ts +216 -0
  70. package/src/auth/settings/index.ts +12 -0
  71. package/src/auth/settings/model-preference-store.ts +52 -0
  72. package/src/auth/settings/model-selection.ts +135 -0
  73. package/src/auth/settings/resolved-settings-view.ts +298 -0
  74. package/src/auth/settings/template-utils.ts +44 -0
  75. package/src/auth/settings/token-service.ts +88 -0
  76. package/src/auth/system-env-store.ts +98 -0
  77. package/src/auth/user-agents-store.ts +68 -0
  78. package/src/channels/binding-service.ts +214 -0
  79. package/src/channels/index.ts +4 -0
  80. package/src/cli/gateway.ts +1304 -0
  81. package/src/cli/index.ts +74 -0
  82. package/src/commands/built-in-commands.ts +80 -0
  83. package/src/commands/command-dispatcher.ts +94 -0
  84. package/src/commands/command-reply-adapters.ts +27 -0
  85. package/src/config/file-loader.ts +618 -0
  86. package/src/config/index.ts +588 -0
  87. package/src/config/network-allowlist.ts +71 -0
  88. package/src/connections/chat-instance-manager.ts +1284 -0
  89. package/src/connections/chat-response-bridge.ts +618 -0
  90. package/src/connections/index.ts +7 -0
  91. package/src/connections/interaction-bridge.ts +831 -0
  92. package/src/connections/message-handler-bridge.ts +415 -0
  93. package/src/connections/platform-auth-methods.ts +15 -0
  94. package/src/connections/types.ts +84 -0
  95. package/src/gateway/connection-manager.ts +291 -0
  96. package/src/gateway/index.ts +700 -0
  97. package/src/gateway/job-router.ts +201 -0
  98. package/src/gateway-main.ts +200 -0
  99. package/src/index.ts +41 -0
  100. package/src/infrastructure/queue/index.ts +12 -0
  101. package/src/infrastructure/queue/queue-producer.ts +148 -0
  102. package/src/infrastructure/queue/redis-queue.ts +361 -0
  103. package/src/infrastructure/queue/types.ts +133 -0
  104. package/src/infrastructure/redis/system-message-limiter.ts +94 -0
  105. package/src/interactions/config-request-store.ts +198 -0
  106. package/src/interactions.ts +363 -0
  107. package/src/lobu.ts +311 -0
  108. package/src/metrics/prometheus.ts +159 -0
  109. package/src/modules/module-system.ts +179 -0
  110. package/src/orchestration/base-deployment-manager.ts +900 -0
  111. package/src/orchestration/deployment-utils.ts +98 -0
  112. package/src/orchestration/impl/docker-deployment.ts +620 -0
  113. package/src/orchestration/impl/embedded-deployment.ts +268 -0
  114. package/src/orchestration/impl/index.ts +8 -0
  115. package/src/orchestration/impl/k8s/deployment.ts +1061 -0
  116. package/src/orchestration/impl/k8s/helpers.ts +610 -0
  117. package/src/orchestration/impl/k8s/index.ts +1 -0
  118. package/src/orchestration/index.ts +333 -0
  119. package/src/orchestration/message-consumer.ts +584 -0
  120. package/src/orchestration/scheduled-wakeup.ts +704 -0
  121. package/src/permissions/approval-policy.ts +36 -0
  122. package/src/permissions/grant-store.ts +219 -0
  123. package/src/platform/file-handler.ts +66 -0
  124. package/src/platform/link-buttons.ts +57 -0
  125. package/src/platform/renderer-utils.ts +44 -0
  126. package/src/platform/response-renderer.ts +84 -0
  127. package/src/platform/unified-thread-consumer.ts +187 -0
  128. package/src/platform.ts +318 -0
  129. package/src/proxy/http-proxy.ts +752 -0
  130. package/src/proxy/proxy-manager.ts +81 -0
  131. package/src/proxy/secret-proxy.ts +402 -0
  132. package/src/proxy/token-refresh-job.ts +143 -0
  133. package/src/routes/internal/audio.ts +141 -0
  134. package/src/routes/internal/device-auth.ts +566 -0
  135. package/src/routes/internal/files.ts +226 -0
  136. package/src/routes/internal/history.ts +69 -0
  137. package/src/routes/internal/images.ts +127 -0
  138. package/src/routes/internal/interactions.ts +84 -0
  139. package/src/routes/internal/middleware.ts +23 -0
  140. package/src/routes/internal/schedule.ts +226 -0
  141. package/src/routes/internal/types.ts +22 -0
  142. package/src/routes/openapi-auto.ts +239 -0
  143. package/src/routes/public/agent-access.ts +23 -0
  144. package/src/routes/public/agent-config.ts +675 -0
  145. package/src/routes/public/agent-history.ts +422 -0
  146. package/src/routes/public/agent-schedules.ts +296 -0
  147. package/src/routes/public/agent.ts +1086 -0
  148. package/src/routes/public/agents.ts +373 -0
  149. package/src/routes/public/channels.ts +191 -0
  150. package/src/routes/public/cli-auth.ts +883 -0
  151. package/src/routes/public/connections.ts +574 -0
  152. package/src/routes/public/landing.ts +16 -0
  153. package/src/routes/public/oauth.ts +147 -0
  154. package/src/routes/public/settings-auth.ts +104 -0
  155. package/src/routes/public/slack.ts +173 -0
  156. package/src/routes/shared/agent-ownership.ts +101 -0
  157. package/src/routes/shared/token-verifier.ts +34 -0
  158. package/src/services/core-services.ts +1053 -0
  159. package/src/services/image-generation-service.ts +257 -0
  160. package/src/services/instruction-service.ts +318 -0
  161. package/src/services/mcp-registry.ts +94 -0
  162. package/src/services/platform-helpers.ts +287 -0
  163. package/src/services/session-manager.ts +262 -0
  164. package/src/services/settings-resolver.ts +74 -0
  165. package/src/services/system-config-resolver.ts +90 -0
  166. package/src/services/system-skills-service.ts +229 -0
  167. package/src/services/transcription-service.ts +684 -0
  168. package/src/session.ts +110 -0
  169. package/src/spaces/index.ts +1 -0
  170. package/src/spaces/space-resolver.ts +17 -0
  171. package/src/stores/in-memory-agent-store.ts +403 -0
  172. package/src/stores/redis-agent-store.ts +279 -0
  173. package/src/utils/public-url.ts +44 -0
  174. package/src/utils/rate-limiter.ts +94 -0
  175. package/tsconfig.json +33 -0
@@ -0,0 +1,323 @@
1
+ import {
2
+ afterAll,
3
+ beforeAll,
4
+ beforeEach,
5
+ describe,
6
+ expect,
7
+ test,
8
+ } from "bun:test";
9
+ import { MockRedisClient } from "@lobu/core/testing";
10
+ import { AgentSettingsStore } from "../auth/settings/agent-settings-store";
11
+
12
+ const TEST_ENCRYPTION_KEY =
13
+ "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
14
+
15
+ let originalEncryptionKey: string | undefined;
16
+
17
+ beforeAll(() => {
18
+ originalEncryptionKey = process.env.ENCRYPTION_KEY;
19
+ process.env.ENCRYPTION_KEY = TEST_ENCRYPTION_KEY;
20
+ });
21
+
22
+ afterAll(() => {
23
+ if (originalEncryptionKey !== undefined) {
24
+ process.env.ENCRYPTION_KEY = originalEncryptionKey;
25
+ } else {
26
+ delete process.env.ENCRYPTION_KEY;
27
+ }
28
+ });
29
+
30
+ function createStore(redis?: MockRedisClient) {
31
+ const r = redis ?? new MockRedisClient();
32
+ const store = new AgentSettingsStore(r as any);
33
+ return { store, redis: r };
34
+ }
35
+
36
+ describe("AgentSettingsStore", () => {
37
+ let redis: MockRedisClient;
38
+ let store: AgentSettingsStore;
39
+
40
+ beforeEach(() => {
41
+ const created = createStore();
42
+ redis = created.redis;
43
+ store = created.store;
44
+ });
45
+
46
+ describe("CRUD basics", () => {
47
+ test("saveSettings stores and getSettings retrieves", async () => {
48
+ await store.saveSettings("agent-1", { model: "claude-sonnet-4" });
49
+ const result = await store.getSettings("agent-1");
50
+ expect(result).not.toBeNull();
51
+ expect(result!.model).toBe("claude-sonnet-4");
52
+ expect(result!.updatedAt).toBeGreaterThan(0);
53
+ });
54
+
55
+ test("getSettings returns null for non-existent agent", async () => {
56
+ const result = await store.getSettings("missing");
57
+ expect(result).toBeNull();
58
+ });
59
+
60
+ test("updateSettings merges with existing", async () => {
61
+ await store.saveSettings("agent-1", { model: "claude-sonnet-4" });
62
+ await store.updateSettings("agent-1", { soulMd: "Be helpful" });
63
+ const result = await store.getSettings("agent-1");
64
+ expect(result!.model).toBe("claude-sonnet-4");
65
+ expect(result!.soulMd).toBe("Be helpful");
66
+ });
67
+
68
+ test("deleteSettings removes settings", async () => {
69
+ await store.saveSettings("agent-1", { model: "claude-sonnet-4" });
70
+ await store.deleteSettings("agent-1");
71
+ const result = await store.getSettings("agent-1");
72
+ expect(result).toBeNull();
73
+ });
74
+
75
+ test("hasSettings returns boolean", async () => {
76
+ expect(await store.hasSettings("agent-1")).toBe(false);
77
+ await store.saveSettings("agent-1", { model: "claude-sonnet-4" });
78
+ expect(await store.hasSettings("agent-1")).toBe(true);
79
+ });
80
+ });
81
+
82
+ describe("partial update merging", () => {
83
+ test("merges new fields with existing", async () => {
84
+ await store.saveSettings("agent-1", {
85
+ model: "claude-sonnet-4",
86
+ soulMd: "Original",
87
+ });
88
+ await store.updateSettings("agent-1", { userMd: "New field" });
89
+ const result = await store.getSettings("agent-1");
90
+ expect(result!.model).toBe("claude-sonnet-4");
91
+ expect(result!.soulMd).toBe("Original");
92
+ expect(result!.userMd).toBe("New field");
93
+ });
94
+
95
+ test("overwrites overlapping fields", async () => {
96
+ await store.saveSettings("agent-1", { model: "claude-sonnet-4" });
97
+ await store.updateSettings("agent-1", { model: "claude-opus-4" });
98
+ const result = await store.getSettings("agent-1");
99
+ expect(result!.model).toBe("claude-opus-4");
100
+ });
101
+
102
+ test("creates if no existing settings", async () => {
103
+ await store.updateSettings("agent-1", { model: "claude-opus-4" });
104
+ const result = await store.getSettings("agent-1");
105
+ expect(result).not.toBeNull();
106
+ expect(result!.model).toBe("claude-opus-4");
107
+ });
108
+ });
109
+
110
+ describe("encryption of authProfiles.credential", () => {
111
+ test("credential is encrypted in Redis and decrypted on read", async () => {
112
+ const apiKey = "sk-ant-secret-key-12345";
113
+ await store.saveSettings("agent-1", {
114
+ authProfiles: [
115
+ {
116
+ id: "profile-1",
117
+ provider: "anthropic",
118
+ model: "claude-sonnet-4",
119
+ credential: apiKey,
120
+ label: "test",
121
+ authType: "api-key",
122
+ createdAt: Date.now(),
123
+ },
124
+ ],
125
+ });
126
+
127
+ // Check raw value in Redis has enc:v1: prefix
128
+ const rawData = await redis.get("agent:settings:agent-1");
129
+ expect(rawData).not.toBeNull();
130
+ const parsed = JSON.parse(rawData!);
131
+ expect(parsed.authProfiles[0].credential).toStartWith("enc:v1:");
132
+ expect(parsed.authProfiles[0].credential).not.toBe(apiKey);
133
+
134
+ // Check decrypted on read
135
+ const result = await store.getSettings("agent-1");
136
+ expect(result!.authProfiles![0].credential).toBe(apiKey);
137
+ });
138
+ });
139
+
140
+ describe("encryption of refreshToken", () => {
141
+ test("refreshToken is encrypted in Redis and decrypted on read", async () => {
142
+ const refreshToken = "rt-secret-refresh-token-xyz";
143
+ await store.saveSettings("agent-1", {
144
+ authProfiles: [
145
+ {
146
+ id: "profile-1",
147
+ provider: "anthropic",
148
+ model: "claude-sonnet-4",
149
+ credential: "sk-key",
150
+ label: "test",
151
+ authType: "oauth",
152
+ metadata: {
153
+ refreshToken,
154
+ email: "user@example.com",
155
+ },
156
+ createdAt: Date.now(),
157
+ },
158
+ ],
159
+ });
160
+
161
+ // Check raw value in Redis
162
+ const rawData = await redis.get("agent:settings:agent-1");
163
+ const parsed = JSON.parse(rawData!);
164
+ expect(parsed.authProfiles[0].metadata.refreshToken).toStartWith(
165
+ "enc:v1:"
166
+ );
167
+
168
+ // Check decrypted on read
169
+ const result = await store.getSettings("agent-1");
170
+ expect(result!.authProfiles![0].metadata!.refreshToken).toBe(
171
+ refreshToken
172
+ );
173
+ });
174
+ });
175
+
176
+ describe("no double-encryption", () => {
177
+ test("already encrypted values are not re-encrypted", async () => {
178
+ // Save once to encrypt
179
+ await store.saveSettings("agent-1", {
180
+ authProfiles: [
181
+ {
182
+ id: "profile-1",
183
+ provider: "anthropic",
184
+ model: "claude-sonnet-4",
185
+ credential: "sk-key",
186
+ label: "test",
187
+ authType: "api-key",
188
+ createdAt: Date.now(),
189
+ },
190
+ ],
191
+ });
192
+
193
+ const rawAfterFirst = await redis.get("agent:settings:agent-1");
194
+ const parsedFirst = JSON.parse(rawAfterFirst!);
195
+ const encryptedCredential = parsedFirst.authProfiles[0].credential;
196
+
197
+ // Update to re-save (simulating save with already-encrypted value)
198
+ await store.updateSettings("agent-1", { model: "claude-opus-4" });
199
+
200
+ const rawAfterSecond = await redis.get("agent:settings:agent-1");
201
+ const parsedSecond = JSON.parse(rawAfterSecond!);
202
+
203
+ // The credential should still be encrypted, not double-encrypted
204
+ // Both should decrypt to the same plaintext
205
+ const result = await store.getSettings("agent-1");
206
+ expect(result!.authProfiles![0].credential).toBe("sk-key");
207
+ });
208
+ });
209
+
210
+ describe("graceful plaintext when ENCRYPTION_KEY missing", () => {
211
+ test("values stored as plaintext without encryption key", async () => {
212
+ const savedKey = process.env.ENCRYPTION_KEY;
213
+ delete process.env.ENCRYPTION_KEY;
214
+
215
+ try {
216
+ const { store: noEncStore, redis: noEncRedis } = createStore();
217
+
218
+ const apiKey = "sk-plaintext-key";
219
+ await noEncStore.saveSettings("agent-1", {
220
+ authProfiles: [
221
+ {
222
+ id: "profile-1",
223
+ provider: "anthropic",
224
+ model: "claude-sonnet-4",
225
+ credential: apiKey,
226
+ label: "test",
227
+ authType: "api-key",
228
+ createdAt: Date.now(),
229
+ },
230
+ ],
231
+ });
232
+
233
+ // Raw value should be plaintext (no enc:v1: prefix)
234
+ const rawData = await noEncRedis.get("agent:settings:agent-1");
235
+ const parsed = JSON.parse(rawData!);
236
+ expect(parsed.authProfiles[0].credential).toBe(apiKey);
237
+ expect(parsed.authProfiles[0].credential).not.toStartWith("enc:v1:");
238
+
239
+ // Read should also return plaintext
240
+ const result = await noEncStore.getSettings("agent-1");
241
+ expect(result!.authProfiles![0].credential).toBe(apiKey);
242
+ } finally {
243
+ process.env.ENCRYPTION_KEY = savedKey;
244
+ }
245
+ });
246
+ });
247
+
248
+ describe("findTemplateAgentId", () => {
249
+ test("returns first agent with installedProviders", async () => {
250
+ // Agent without installedProviders
251
+ await store.saveSettings("agent-no-providers", {
252
+ model: "claude-sonnet-4",
253
+ });
254
+
255
+ // Agent with installedProviders
256
+ await store.saveSettings("agent-with-providers", {
257
+ model: "claude-opus-4",
258
+ installedProviders: [
259
+ {
260
+ id: "anthropic",
261
+ displayName: "Anthropic",
262
+ envVarName: "ANTHROPIC_API_KEY",
263
+ upstreamBaseUrl: "https://api.anthropic.com",
264
+ },
265
+ ],
266
+ });
267
+
268
+ const templateId = await store.findTemplateAgentId();
269
+ expect(templateId).toBe("agent-with-providers");
270
+ });
271
+
272
+ test("returns null when no agents have providers", async () => {
273
+ await store.saveSettings("agent-1", { model: "claude-sonnet-4" });
274
+ await store.saveSettings("agent-2", { model: "claude-opus-4" });
275
+
276
+ const templateId = await store.findTemplateAgentId();
277
+ expect(templateId).toBeNull();
278
+ });
279
+ });
280
+
281
+ describe("findSandboxAgentIds", () => {
282
+ test("returns agent IDs referencing template", async () => {
283
+ const templateId = "template-agent";
284
+
285
+ await store.saveSettings(templateId, {
286
+ model: "claude-opus-4",
287
+ installedProviders: [
288
+ {
289
+ id: "anthropic",
290
+ displayName: "Anthropic",
291
+ envVarName: "ANTHROPIC_API_KEY",
292
+ upstreamBaseUrl: "https://api.anthropic.com",
293
+ },
294
+ ],
295
+ });
296
+
297
+ await store.saveSettings("sandbox-1", {
298
+ model: "claude-sonnet-4",
299
+ templateAgentId: templateId,
300
+ });
301
+
302
+ await store.saveSettings("sandbox-2", {
303
+ model: "claude-sonnet-4",
304
+ templateAgentId: templateId,
305
+ });
306
+
307
+ // Unrelated agent
308
+ await store.saveSettings("other-agent", {
309
+ model: "claude-sonnet-4",
310
+ });
311
+
312
+ const sandboxIds = await store.findSandboxAgentIds(templateId);
313
+ expect(sandboxIds).toHaveLength(2);
314
+ expect(sandboxIds.sort()).toEqual(["sandbox-1", "sandbox-2"]);
315
+ });
316
+
317
+ test("returns empty array when no sandboxes exist", async () => {
318
+ await store.saveSettings("agent-1", { model: "claude-sonnet-4" });
319
+ const sandboxIds = await store.findSandboxAgentIds("non-existent");
320
+ expect(sandboxIds).toEqual([]);
321
+ });
322
+ });
323
+ });
@@ -0,0 +1,204 @@
1
+ import { describe, expect, mock, test } from "bun:test";
2
+ import { ChatInstanceManager } from "../connections/chat-instance-manager";
3
+
4
+ class PipelineRedisMock {
5
+ private readonly operations: Array<() => Promise<unknown>> = [];
6
+
7
+ constructor(private readonly redis: RedisMock) {}
8
+
9
+ set(key: string, value: string): this {
10
+ this.operations.push(() => this.redis.set(key, value));
11
+ return this;
12
+ }
13
+
14
+ sadd(key: string, member: string): this {
15
+ this.operations.push(() => this.redis.sadd(key, member));
16
+ return this;
17
+ }
18
+
19
+ async exec(): Promise<unknown[]> {
20
+ const results: unknown[] = [];
21
+ for (const operation of this.operations) {
22
+ results.push(await operation());
23
+ }
24
+ return results;
25
+ }
26
+ }
27
+
28
+ class RedisMock {
29
+ private readonly strings = new Map<string, string>();
30
+ private readonly sets = new Map<string, Set<string>>();
31
+
32
+ async get(key: string): Promise<string | null> {
33
+ return this.strings.get(key) ?? null;
34
+ }
35
+
36
+ async set(key: string, value: string): Promise<"OK"> {
37
+ this.strings.set(key, value);
38
+ return "OK";
39
+ }
40
+
41
+ async del(...keys: string[]): Promise<number> {
42
+ let removed = 0;
43
+ for (const key of keys) {
44
+ const existed = this.strings.delete(key) || this.sets.delete(key);
45
+ if (existed) {
46
+ removed++;
47
+ }
48
+ }
49
+ return removed;
50
+ }
51
+
52
+ async sadd(key: string, ...members: string[]): Promise<number> {
53
+ if (!this.sets.has(key)) {
54
+ this.sets.set(key, new Set());
55
+ }
56
+
57
+ const set = this.sets.get(key)!;
58
+ let added = 0;
59
+ for (const member of members) {
60
+ if (!set.has(member)) {
61
+ set.add(member);
62
+ added++;
63
+ }
64
+ }
65
+ return added;
66
+ }
67
+
68
+ async srem(key: string, ...members: string[]): Promise<number> {
69
+ const set = this.sets.get(key);
70
+ if (!set) {
71
+ return 0;
72
+ }
73
+
74
+ let removed = 0;
75
+ for (const member of members) {
76
+ if (set.delete(member)) {
77
+ removed++;
78
+ }
79
+ }
80
+ return removed;
81
+ }
82
+
83
+ async smembers(key: string): Promise<string[]> {
84
+ return Array.from(this.sets.get(key) || []);
85
+ }
86
+
87
+ pipeline(): PipelineRedisMock {
88
+ return new PipelineRedisMock(this);
89
+ }
90
+ }
91
+
92
+ describe("ChatInstanceManager Slack marketplace support", () => {
93
+ test("ensureSlackWorkspaceConnection is idempotent per team", async () => {
94
+ const manager = new ChatInstanceManager() as any;
95
+ const redis = new RedisMock();
96
+ const startInstance = mock(async (connection: any) => {
97
+ manager.instances.set(connection.id, {
98
+ connection,
99
+ chat: { webhooks: { slack: async () => new Response("ok") } },
100
+ cleanup: async () => undefined,
101
+ });
102
+ });
103
+
104
+ manager.redis = redis;
105
+ manager.startInstance = startInstance;
106
+ manager.resolveSlackAdapterConfig = mock(() => ({
107
+ platform: "slack",
108
+ signingSecret: "signing-secret",
109
+ clientId: "client-id",
110
+ clientSecret: "client-secret",
111
+ }));
112
+
113
+ const first = await manager.ensureSlackWorkspaceConnection("T123", {
114
+ botToken: "xoxb-first-token",
115
+ botUserId: "U123",
116
+ teamName: "Acme",
117
+ });
118
+ const second = await manager.ensureSlackWorkspaceConnection("T123", {
119
+ botToken: "xoxb-second-token",
120
+ botUserId: "U456",
121
+ teamName: "Acme Updated",
122
+ });
123
+
124
+ expect(second.id).toBe(first.id);
125
+ expect(startInstance).toHaveBeenCalledTimes(2);
126
+
127
+ const connections = await manager.listConnections({ platform: "slack" });
128
+ expect(connections).toHaveLength(1);
129
+
130
+ const stored = JSON.parse(
131
+ (await redis.get(`connection:${first.id}`)) || "{}"
132
+ );
133
+ const decryptedConfig = manager.decryptConfig(stored.config);
134
+
135
+ expect(stored.metadata).toEqual({
136
+ teamId: "T123",
137
+ teamName: "Acme Updated",
138
+ botUserId: "U456",
139
+ });
140
+ expect(decryptedConfig.botToken).toBe("xoxb-second-token");
141
+ expect(decryptedConfig.botUserId).toBe("U456");
142
+ });
143
+
144
+ test("handleSlackAppWebhook prefers an exact team match", async () => {
145
+ const manager = new ChatInstanceManager() as any;
146
+
147
+ manager.findSlackConnectionByTeamId = mock(async (teamId: string) =>
148
+ teamId === "T123" ? { id: "conn-team" } : null
149
+ );
150
+ manager.getDefaultSlackConnection = mock(async () => ({
151
+ id: "conn-default",
152
+ }));
153
+ manager.ensureConnectionRunning = mock(async () => true);
154
+ manager.handleWebhook = mock(
155
+ async (connectionId: string, request: Request) => {
156
+ const body = await request.text();
157
+ return new Response(`${connectionId}:${body}`);
158
+ }
159
+ );
160
+
161
+ const body = JSON.stringify({ team_id: "T123", type: "event_callback" });
162
+ const response = await manager.handleSlackAppWebhook(
163
+ new Request("https://gateway.example.com/slack/events", {
164
+ method: "POST",
165
+ headers: { "content-type": "application/json" },
166
+ body,
167
+ })
168
+ );
169
+
170
+ expect(response.status).toBe(200);
171
+ expect(await response.text()).toBe(`conn-team:${body}`);
172
+ expect(manager.handleWebhook).toHaveBeenCalledTimes(1);
173
+ expect(manager.handleWebhook.mock.calls[0]?.[0]).toBe("conn-team");
174
+ });
175
+
176
+ test("handleSlackAppWebhook falls back to the default Slack connection", async () => {
177
+ const manager = new ChatInstanceManager() as any;
178
+
179
+ manager.findSlackConnectionByTeamId = mock(async () => null);
180
+ manager.getDefaultSlackConnection = mock(async () => ({
181
+ id: "conn-default",
182
+ }));
183
+ manager.ensureConnectionRunning = mock(async () => true);
184
+ manager.handleWebhook = mock(
185
+ async (connectionId: string, request: Request) => {
186
+ const body = await request.text();
187
+ return new Response(`${connectionId}:${body}`);
188
+ }
189
+ );
190
+
191
+ const body = JSON.stringify({ type: "url_verification" });
192
+ const response = await manager.handleSlackAppWebhook(
193
+ new Request("https://gateway.example.com/slack/events", {
194
+ method: "POST",
195
+ headers: { "content-type": "application/json" },
196
+ body,
197
+ })
198
+ );
199
+
200
+ expect(response.status).toBe(200);
201
+ expect(await response.text()).toBe(`conn-default:${body}`);
202
+ expect(manager.handleWebhook.mock.calls[0]?.[0]).toBe("conn-default");
203
+ });
204
+ });
@@ -0,0 +1,131 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { ChatResponseBridge } from "../connections/chat-response-bridge";
3
+
4
+ function createRedisMock() {
5
+ return {
6
+ pipeline() {
7
+ return {
8
+ rpush() {
9
+ return this;
10
+ },
11
+ ltrim() {
12
+ return this;
13
+ },
14
+ expire() {
15
+ return this;
16
+ },
17
+ exec: async () => [],
18
+ };
19
+ },
20
+ };
21
+ }
22
+
23
+ describe("ChatResponseBridge.handleEphemeral", () => {
24
+ test("renders settings links as native buttons for Chat SDK targets", async () => {
25
+ const posts: unknown[] = [];
26
+ const target = {
27
+ post: async (payload: unknown) => {
28
+ posts.push(payload);
29
+ return { id: "msg-1" };
30
+ },
31
+ };
32
+ const manager = {
33
+ getInstance: () => ({
34
+ connection: { platform: "telegram" },
35
+ chat: {
36
+ channel: () => target,
37
+ },
38
+ }),
39
+ getServices: () => ({
40
+ getQueue: () => ({
41
+ getRedisClient: () => createRedisMock(),
42
+ }),
43
+ }),
44
+ };
45
+
46
+ const bridge = new ChatResponseBridge(manager as any);
47
+ await bridge.handleEphemeral({
48
+ messageId: "m1",
49
+ channelId: "123",
50
+ conversationId: "123",
51
+ userId: "u1",
52
+ teamId: "telegram",
53
+ timestamp: Date.now(),
54
+ platform: "telegram",
55
+ platformMetadata: {
56
+ connectionId: "conn-1",
57
+ chatId: "123",
58
+ },
59
+ content:
60
+ "Setup required: add OpenAI in settings before this bot can respond.\n\n[Open Agent Settings](https://example.com/connect/claim?claim=abc123)",
61
+ });
62
+
63
+ expect(posts).toHaveLength(1);
64
+ expect(posts[0]).toBeObject();
65
+ expect(posts[0]).toHaveProperty("card");
66
+ expect(posts[0]).toHaveProperty("fallbackText");
67
+ expect((posts[0] as { fallbackText: string }).fallbackText).toContain(
68
+ "Open Agent Settings: https://example.com/connect/claim?claim=abc123"
69
+ );
70
+ });
71
+
72
+ test("buffers telegram markdown until completion", async () => {
73
+ const posts: unknown[] = [];
74
+ const target = {
75
+ post: async (payload: unknown) => {
76
+ posts.push(payload);
77
+ return { id: "msg-1", threadId: "123" };
78
+ },
79
+ };
80
+ const manager = {
81
+ getInstance: () => ({
82
+ connection: { platform: "telegram" },
83
+ chat: {
84
+ channel: () => target,
85
+ },
86
+ }),
87
+ getServices: () => ({
88
+ getQueue: () => ({
89
+ getRedisClient: () => createRedisMock(),
90
+ }),
91
+ }),
92
+ };
93
+
94
+ const bridge = new ChatResponseBridge(manager as any);
95
+ const basePayload = {
96
+ messageId: "m1",
97
+ channelId: "123",
98
+ conversationId: "123",
99
+ userId: "u1",
100
+ teamId: "telegram",
101
+ timestamp: Date.now(),
102
+ platform: "telegram",
103
+ platformMetadata: {
104
+ connectionId: "conn-1",
105
+ chatId: "123",
106
+ },
107
+ };
108
+
109
+ await bridge.handleDelta(
110
+ {
111
+ ...basePayload,
112
+ delta: "*Reason",
113
+ },
114
+ "session-1"
115
+ );
116
+ await bridge.handleDelta(
117
+ {
118
+ ...basePayload,
119
+ delta: "ing:* hello",
120
+ },
121
+ "session-1"
122
+ );
123
+
124
+ expect(posts).toHaveLength(0);
125
+
126
+ await bridge.handleCompletion(basePayload, "session-1");
127
+
128
+ expect(posts).toHaveLength(1);
129
+ expect(posts[0]).toEqual({ markdown: "*Reasoning:* hello" });
130
+ });
131
+ });