@copilotkit/channels-slack 0.1.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.
Files changed (141) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +353 -0
  3. package/dist/__tests__/assistant.test.d.ts +2 -0
  4. package/dist/__tests__/assistant.test.d.ts.map +1 -0
  5. package/dist/__tests__/assistant.test.js +153 -0
  6. package/dist/__tests__/auto-close-streaming.test.d.ts +2 -0
  7. package/dist/__tests__/auto-close-streaming.test.d.ts.map +1 -0
  8. package/dist/__tests__/auto-close-streaming.test.js +139 -0
  9. package/dist/__tests__/built-ins.test.d.ts +2 -0
  10. package/dist/__tests__/built-ins.test.d.ts.map +1 -0
  11. package/dist/__tests__/built-ins.test.js +67 -0
  12. package/dist/__tests__/chunked-message-stream.test.d.ts +2 -0
  13. package/dist/__tests__/chunked-message-stream.test.d.ts.map +1 -0
  14. package/dist/__tests__/chunked-message-stream.test.js +226 -0
  15. package/dist/__tests__/conversation-store.test.d.ts +2 -0
  16. package/dist/__tests__/conversation-store.test.d.ts.map +1 -0
  17. package/dist/__tests__/conversation-store.test.js +209 -0
  18. package/dist/__tests__/download-files.test.d.ts +2 -0
  19. package/dist/__tests__/download-files.test.d.ts.map +1 -0
  20. package/dist/__tests__/download-files.test.js +142 -0
  21. package/dist/__tests__/ephemeral.test.d.ts +2 -0
  22. package/dist/__tests__/ephemeral.test.d.ts.map +1 -0
  23. package/dist/__tests__/ephemeral.test.js +85 -0
  24. package/dist/__tests__/event-renderer.test.d.ts +2 -0
  25. package/dist/__tests__/event-renderer.test.d.ts.map +1 -0
  26. package/dist/__tests__/event-renderer.test.js +521 -0
  27. package/dist/__tests__/markdown-to-mrkdwn.test.d.ts +2 -0
  28. package/dist/__tests__/markdown-to-mrkdwn.test.d.ts.map +1 -0
  29. package/dist/__tests__/markdown-to-mrkdwn.test.js +80 -0
  30. package/dist/__tests__/message-stream.test.d.ts +2 -0
  31. package/dist/__tests__/message-stream.test.d.ts.map +1 -0
  32. package/dist/__tests__/message-stream.test.js +116 -0
  33. package/dist/__tests__/native-renderer.test.d.ts +2 -0
  34. package/dist/__tests__/native-renderer.test.d.ts.map +1 -0
  35. package/dist/__tests__/native-renderer.test.js +236 -0
  36. package/dist/__tests__/native-stream.test.d.ts +2 -0
  37. package/dist/__tests__/native-stream.test.d.ts.map +1 -0
  38. package/dist/__tests__/native-stream.test.js +235 -0
  39. package/dist/__tests__/reactions.test.d.ts +2 -0
  40. package/dist/__tests__/reactions.test.d.ts.map +1 -0
  41. package/dist/__tests__/reactions.test.js +193 -0
  42. package/dist/__tests__/slack-listener.test.d.ts +2 -0
  43. package/dist/__tests__/slack-listener.test.d.ts.map +1 -0
  44. package/dist/__tests__/slack-listener.test.js +559 -0
  45. package/dist/__tests__/views.test.d.ts +2 -0
  46. package/dist/__tests__/views.test.d.ts.map +1 -0
  47. package/dist/__tests__/views.test.js +143 -0
  48. package/dist/adapter.d.ts +188 -0
  49. package/dist/adapter.d.ts.map +1 -0
  50. package/dist/adapter.js +859 -0
  51. package/dist/adapter.test.d.ts +2 -0
  52. package/dist/adapter.test.d.ts.map +1 -0
  53. package/dist/adapter.test.js +368 -0
  54. package/dist/assistant.d.ts +19 -0
  55. package/dist/assistant.d.ts.map +1 -0
  56. package/dist/assistant.js +124 -0
  57. package/dist/auto-close-streaming.d.ts +62 -0
  58. package/dist/auto-close-streaming.d.ts.map +1 -0
  59. package/dist/auto-close-streaming.js +273 -0
  60. package/dist/built-in-context.d.ts +22 -0
  61. package/dist/built-in-context.d.ts.map +1 -0
  62. package/dist/built-in-context.js +65 -0
  63. package/dist/built-in-tools.d.ts +23 -0
  64. package/dist/built-in-tools.d.ts.map +1 -0
  65. package/dist/built-in-tools.js +45 -0
  66. package/dist/chunked-message-stream.d.ts +67 -0
  67. package/dist/chunked-message-stream.d.ts.map +1 -0
  68. package/dist/chunked-message-stream.js +134 -0
  69. package/dist/codec.d.ts +18 -0
  70. package/dist/codec.d.ts.map +1 -0
  71. package/dist/codec.js +18 -0
  72. package/dist/codec.test.d.ts +2 -0
  73. package/dist/codec.test.d.ts.map +1 -0
  74. package/dist/codec.test.js +19 -0
  75. package/dist/conversation-store.d.ts +92 -0
  76. package/dist/conversation-store.d.ts.map +1 -0
  77. package/dist/conversation-store.js +196 -0
  78. package/dist/download-files.d.ts +59 -0
  79. package/dist/download-files.d.ts.map +1 -0
  80. package/dist/download-files.js +116 -0
  81. package/dist/event-renderer.d.ts +98 -0
  82. package/dist/event-renderer.d.ts.map +1 -0
  83. package/dist/event-renderer.js +493 -0
  84. package/dist/index.d.ts +34 -0
  85. package/dist/index.d.ts.map +1 -0
  86. package/dist/index.js +23 -0
  87. package/dist/ingress-normalize.d.ts +75 -0
  88. package/dist/ingress-normalize.d.ts.map +1 -0
  89. package/dist/ingress-normalize.js +153 -0
  90. package/dist/ingress-normalize.test.d.ts +2 -0
  91. package/dist/ingress-normalize.test.d.ts.map +1 -0
  92. package/dist/ingress-normalize.test.js +187 -0
  93. package/dist/interaction.d.ts +30 -0
  94. package/dist/interaction.d.ts.map +1 -0
  95. package/dist/interaction.js +219 -0
  96. package/dist/interaction.test.d.ts +2 -0
  97. package/dist/interaction.test.d.ts.map +1 -0
  98. package/dist/interaction.test.js +137 -0
  99. package/dist/markdown-to-mrkdwn.d.ts +21 -0
  100. package/dist/markdown-to-mrkdwn.d.ts.map +1 -0
  101. package/dist/markdown-to-mrkdwn.js +99 -0
  102. package/dist/message-stream.d.ts +44 -0
  103. package/dist/message-stream.d.ts.map +1 -0
  104. package/dist/message-stream.js +70 -0
  105. package/dist/native-stream.d.ts +131 -0
  106. package/dist/native-stream.d.ts.map +1 -0
  107. package/dist/native-stream.js +208 -0
  108. package/dist/render/block-kit.d.ts +35 -0
  109. package/dist/render/block-kit.d.ts.map +1 -0
  110. package/dist/render/block-kit.js +399 -0
  111. package/dist/render/block-kit.test.d.ts +2 -0
  112. package/dist/render/block-kit.test.d.ts.map +1 -0
  113. package/dist/render/block-kit.test.js +289 -0
  114. package/dist/render/budget.d.ts +24 -0
  115. package/dist/render/budget.d.ts.map +1 -0
  116. package/dist/render/budget.js +30 -0
  117. package/dist/render/budget.test.d.ts +2 -0
  118. package/dist/render/budget.test.d.ts.map +1 -0
  119. package/dist/render/budget.test.js +49 -0
  120. package/dist/render/modal.d.ts +13 -0
  121. package/dist/render/modal.d.ts.map +1 -0
  122. package/dist/render/modal.js +108 -0
  123. package/dist/render/transport.d.ts +42 -0
  124. package/dist/render/transport.d.ts.map +1 -0
  125. package/dist/render/transport.js +1 -0
  126. package/dist/render.d.ts +23 -0
  127. package/dist/render.d.ts.map +1 -0
  128. package/dist/render.js +20 -0
  129. package/dist/sanitizing-http-agent.d.ts +28 -0
  130. package/dist/sanitizing-http-agent.d.ts.map +1 -0
  131. package/dist/sanitizing-http-agent.js +51 -0
  132. package/dist/slack-listener.d.ts +73 -0
  133. package/dist/slack-listener.d.ts.map +1 -0
  134. package/dist/slack-listener.js +126 -0
  135. package/dist/slack-listener.test.d.ts +2 -0
  136. package/dist/slack-listener.test.d.ts.map +1 -0
  137. package/dist/slack-listener.test.js +115 -0
  138. package/dist/types.d.ts +153 -0
  139. package/dist/types.d.ts.map +1 -0
  140. package/dist/types.js +20 -0
  141. package/package.json +73 -0
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=adapter.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"adapter.test.d.ts","sourceRoot":"","sources":["../src/adapter.test.ts"],"names":[],"mappings":""}
@@ -0,0 +1,368 @@
1
+ import { describe, it, expect, vi } from "vitest";
2
+ import { SlackAdapter } from "./adapter.js";
3
+ /**
4
+ * Build an adapter with a mock Slack client injected. Constructing the real
5
+ * Bolt `App` is side-effect-free (Socket Mode doesn't connect until `start()`),
6
+ * but we never call `start()` here — every test drives the pure-ish egress and
7
+ * decode methods against a fake `client`.
8
+ */
9
+ function makeAdapter() {
10
+ const chat = {
11
+ postMessage: vi.fn(async (_arg) => ({
12
+ ts: "200.5",
13
+ channel: "C1",
14
+ })),
15
+ update: vi.fn(async (_arg) => ({})),
16
+ delete: vi.fn(async (_arg) => ({})),
17
+ };
18
+ const adapter = new SlackAdapter({ botToken: "x", appToken: "y" });
19
+ adapter.client = { chat };
20
+ adapter.botUserId = "UBOT";
21
+ return { adapter, chat };
22
+ }
23
+ const section = (text) => ({
24
+ type: "section",
25
+ props: { children: [{ type: "text", props: { value: text } }] },
26
+ });
27
+ describe("SlackAdapter.post", () => {
28
+ it("posts blocks + fallback text to the target channel/thread and returns a MessageRef", async () => {
29
+ const { adapter, chat } = makeAdapter();
30
+ const ref = await adapter.post({ channel: "C1", threadTs: "100.0" }, [
31
+ section("hi"),
32
+ ]);
33
+ expect(chat.postMessage).toHaveBeenCalledTimes(1);
34
+ const arg = chat.postMessage.mock.calls[0][0];
35
+ expect(arg.channel).toBe("C1");
36
+ expect(arg.thread_ts).toBe("100.0");
37
+ expect(arg.text).toBe("hi");
38
+ expect(arg.blocks).toHaveLength(1);
39
+ expect(arg.blocks[0].type).toBe("section");
40
+ expect(arg.blocks.length).toBeLessThanOrEqual(50); // budget-clamped
41
+ expect(ref.id).toBe("200.5");
42
+ expect(ref.channel).toBe("C1");
43
+ });
44
+ it("renders a <Message accent> as a colored attachment with a short top-level text and NO fallback on the attachment", async () => {
45
+ const { adapter, chat } = makeAdapter();
46
+ const header = (text) => ({
47
+ type: "header",
48
+ props: { children: [{ type: "text", props: { value: text } }] },
49
+ });
50
+ await adapter.post({ channel: "C1" }, [
51
+ {
52
+ type: "message",
53
+ props: {
54
+ accent: "#27AE60",
55
+ children: [header("Open issues"), section("ok")],
56
+ },
57
+ },
58
+ ]);
59
+ const arg = chat.postMessage.mock.calls[0][0];
60
+ // Short top-level text (the notification/a11y summary) AND a colored
61
+ // attachment card with { color, blocks } — never a legacy `fallback` on
62
+ // the attachment (that triggers invalid_attachments). No top-level blocks.
63
+ expect(arg.text).toBe("Open issues");
64
+ expect(arg.blocks).toBeUndefined();
65
+ expect(arg.attachments).toHaveLength(1);
66
+ expect(arg.attachments[0].color).toBe("#27AE60");
67
+ expect(arg.attachments[0].blocks[0].type).toBe("header");
68
+ expect(arg.attachments[0].fallback).toBeUndefined();
69
+ // Unfurling is suppressed on the post.
70
+ expect(arg.unfurl_links).toBe(false);
71
+ expect(arg.unfurl_media).toBe(false);
72
+ });
73
+ it("defaults fallback text to … when the IR has no text", async () => {
74
+ const { adapter, chat } = makeAdapter();
75
+ await adapter.post({ channel: "C1" }, [{ type: "divider", props: {} }]);
76
+ const arg = chat.postMessage.mock.calls[0][0];
77
+ expect(arg.text).toBe("…");
78
+ });
79
+ it("uses the header as the short fallback summary — not a dump of the whole card", async () => {
80
+ const { adapter, chat } = makeAdapter();
81
+ const header = (text) => ({
82
+ type: "header",
83
+ props: { children: [{ type: "text", props: { value: text } }] },
84
+ });
85
+ await adapter.post({ channel: "C1" }, [
86
+ {
87
+ type: "message",
88
+ props: {
89
+ accent: "#27AE60",
90
+ children: [
91
+ header("Open CPK issues"),
92
+ section("CPK-1 Checkout 500s"),
93
+ section("CPK-2 Login broken"),
94
+ ],
95
+ },
96
+ },
97
+ ]);
98
+ const arg = chat.postMessage.mock.calls[0][0];
99
+ // The short summary is the header only — it must NOT concatenate the row text.
100
+ expect(arg.text).toBe("Open CPK issues");
101
+ expect(arg.text).not.toContain("CPK-1");
102
+ });
103
+ });
104
+ describe("SlackAdapter.update / delete use the stashed channel", () => {
105
+ it("update edits the message at ref.id on its channel", async () => {
106
+ const { adapter, chat } = makeAdapter();
107
+ await adapter.update({ id: "200.5", channel: "C1" }, [section("edited")]);
108
+ const arg = chat.update.mock.calls[0][0];
109
+ expect(arg.channel).toBe("C1");
110
+ expect(arg.ts).toBe("200.5");
111
+ });
112
+ it("update of an accent card sets a short top-level text and attachments with NO fallback", async () => {
113
+ const { adapter, chat } = makeAdapter();
114
+ const header = (text) => ({
115
+ type: "header",
116
+ props: { children: [{ type: "text", props: { value: text } }] },
117
+ });
118
+ await adapter.update({ id: "200.5", channel: "C1" }, [
119
+ {
120
+ type: "message",
121
+ props: { accent: "#EB5757", children: [header("Updated")] },
122
+ },
123
+ ]);
124
+ const arg = chat.update.mock.calls[0][0];
125
+ expect(arg.text).toBe("Updated");
126
+ expect(arg.blocks).toBeUndefined();
127
+ expect(arg.attachments[0].color).toBe("#EB5757");
128
+ expect(arg.attachments[0].fallback).toBeUndefined();
129
+ });
130
+ it("delete removes the message at ref.id on its channel", async () => {
131
+ const { adapter, chat } = makeAdapter();
132
+ await adapter.delete({ id: "200.5", channel: "C1" });
133
+ const arg = chat.delete.mock.calls[0][0];
134
+ expect(arg.channel).toBe("C1");
135
+ expect(arg.ts).toBe("200.5");
136
+ });
137
+ });
138
+ describe("SlackAdapter.decodeInteraction", () => {
139
+ it("decodes a block_actions payload to an opaque-id InteractionEvent", () => {
140
+ const { adapter } = makeAdapter();
141
+ const evt = adapter.decodeInteraction({
142
+ type: "block_actions",
143
+ channel: { id: "C1" },
144
+ message: { ts: "1", thread_ts: "100.0" },
145
+ actions: [{ action_id: "ck:z", value: '{"ok":1}' }],
146
+ });
147
+ expect(evt).toBeDefined();
148
+ expect(evt.id).toBe("ck:z");
149
+ expect(evt.value).toEqual({ ok: 1 });
150
+ expect(evt.conversationKey).toBe("C1::100.0");
151
+ });
152
+ });
153
+ describe("SlackAdapter.getMessages", () => {
154
+ it("maps conversations.replies to ThreadMessage[] (text/ts/isBot/resolved user)", async () => {
155
+ const { adapter } = makeAdapter();
156
+ const replies = vi.fn(async (_arg) => ({
157
+ messages: [
158
+ { ts: "100.0", text: "hello", user: "U1" },
159
+ { ts: "100.1", text: "bot reply", bot_id: "B1" },
160
+ { ts: "100.2", text: "joined", subtype: "channel_join", user: "U9" },
161
+ ],
162
+ }));
163
+ const info = vi.fn(async (_arg) => ({
164
+ user: { id: "U1", name: "ana", real_name: "Ana Smith" },
165
+ }));
166
+ adapter.client = {
167
+ conversations: { replies },
168
+ users: { info },
169
+ };
170
+ const msgs = await adapter.getMessages({
171
+ channel: "C1",
172
+ threadTs: "100.0",
173
+ });
174
+ expect(replies).toHaveBeenCalledTimes(1);
175
+ const arg = replies.mock.calls[0][0];
176
+ expect(arg.channel).toBe("C1");
177
+ expect(arg.ts).toBe("100.0");
178
+ // System (channel_join) subtype is skipped; the two real messages remain.
179
+ expect(msgs).toHaveLength(2);
180
+ expect(msgs[0]).toEqual({
181
+ text: "hello",
182
+ ts: "100.0",
183
+ isBot: false,
184
+ user: { id: "U1", name: "Ana Smith", email: undefined },
185
+ });
186
+ expect(msgs[1].isBot).toBe(true);
187
+ expect(msgs[1].text).toBe("bot reply");
188
+ expect(msgs[1].user).toBeUndefined();
189
+ });
190
+ it("returns [] for a flat target with no threadTs (nothing to fetch)", async () => {
191
+ const { adapter } = makeAdapter();
192
+ const replies = vi.fn();
193
+ adapter.client = {
194
+ conversations: { replies },
195
+ };
196
+ const msgs = await adapter.getMessages({ channel: "C1" });
197
+ expect(msgs).toEqual([]);
198
+ expect(replies).not.toHaveBeenCalled();
199
+ });
200
+ it("returns [] when conversations.replies throws", async () => {
201
+ const { adapter } = makeAdapter();
202
+ adapter.client = {
203
+ conversations: {
204
+ replies: vi.fn(async () => {
205
+ throw new Error("rate_limited");
206
+ }),
207
+ },
208
+ };
209
+ const msgs = await adapter.getMessages({
210
+ channel: "C1",
211
+ threadTs: "100.0",
212
+ });
213
+ expect(msgs).toEqual([]);
214
+ });
215
+ });
216
+ describe("SlackAdapter.postFile", () => {
217
+ it("uploads via files.uploadV2 with channel_id/thread_ts/file and returns ok", async () => {
218
+ const { adapter } = makeAdapter();
219
+ const uploadV2 = vi.fn(async (_arg) => ({
220
+ ok: true,
221
+ }));
222
+ adapter.client = {
223
+ files: { uploadV2 },
224
+ };
225
+ const res = await adapter.postFile({ channel: "C1", threadTs: "100.0" }, {
226
+ bytes: new Uint8Array([1, 2, 3]),
227
+ filename: "chart.png",
228
+ title: "Chart",
229
+ altText: "alt",
230
+ });
231
+ expect(res).toEqual({ ok: true });
232
+ expect(uploadV2).toHaveBeenCalledTimes(1);
233
+ const arg = uploadV2.mock.calls[0][0];
234
+ expect(arg.channel_id).toBe("C1");
235
+ expect(arg.thread_ts).toBe("100.0");
236
+ expect(arg.filename).toBe("chart.png");
237
+ expect(arg.title).toBe("Chart");
238
+ expect(arg.alt_text).toBe("alt");
239
+ expect(Buffer.isBuffer(arg.file)).toBe(true);
240
+ });
241
+ it("omits thread_ts when the target has none", async () => {
242
+ const { adapter } = makeAdapter();
243
+ const uploadV2 = vi.fn(async (_arg) => ({
244
+ ok: true,
245
+ }));
246
+ adapter.client = {
247
+ files: { uploadV2 },
248
+ };
249
+ await adapter.postFile({ channel: "C1" }, { bytes: new Uint8Array([1]), filename: "x.png" });
250
+ const arg = uploadV2.mock.calls[0][0];
251
+ expect(arg.thread_ts).toBeUndefined();
252
+ });
253
+ it("returns ok:false with the error message when uploadV2 throws", async () => {
254
+ const { adapter } = makeAdapter();
255
+ const uploadV2 = vi.fn(async () => {
256
+ throw new Error("upload_failed");
257
+ });
258
+ adapter.client = {
259
+ files: { uploadV2 },
260
+ };
261
+ const res = await adapter.postFile({ channel: "C1" }, { bytes: new Uint8Array([1]), filename: "x.png" });
262
+ expect(res).toEqual({ ok: false, error: "upload_failed" });
263
+ });
264
+ });
265
+ describe("SlackAdapter.capabilities / ackDeadlineMs", () => {
266
+ it("reports the Slack surface capabilities", () => {
267
+ const { adapter } = makeAdapter();
268
+ expect(adapter.capabilities.supportsTyping).toBe(false);
269
+ expect(adapter.capabilities.supportsStreaming).toBe(true);
270
+ expect(adapter.capabilities.maxBlocksPerMessage).toBe(50);
271
+ expect(adapter.ackDeadlineMs).toBe(3000);
272
+ expect(adapter.platform).toBe("slack");
273
+ });
274
+ });
275
+ describe("SlackAdapter.resolveUser", () => {
276
+ it("resolves a sender id to a richer PlatformUser (name + email) and caches it", async () => {
277
+ const { adapter } = makeAdapter();
278
+ const info = vi.fn(async (_arg) => ({
279
+ user: {
280
+ id: "U1",
281
+ name: "ana",
282
+ real_name: "Ana Smith",
283
+ profile: { real_name: "Ana Smith", email: "ana@example.com" },
284
+ },
285
+ }));
286
+ adapter.client = {
287
+ users: { info },
288
+ };
289
+ const u = await adapter.resolveUser("U1");
290
+ expect(u).toEqual({
291
+ id: "U1",
292
+ name: "Ana Smith",
293
+ email: "ana@example.com",
294
+ });
295
+ // Second call is served from cache (no extra users.info call).
296
+ const u2 = await adapter.resolveUser("U1");
297
+ expect(u2).toEqual(u);
298
+ expect(info).toHaveBeenCalledTimes(1);
299
+ });
300
+ it("falls back to a bare { id } when users.info fails", async () => {
301
+ const { adapter } = makeAdapter();
302
+ const info = vi.fn(async () => {
303
+ throw new Error("not_found");
304
+ });
305
+ adapter.client = {
306
+ users: { info },
307
+ };
308
+ const u = await adapter.resolveUser("U2");
309
+ expect(u).toEqual({ id: "U2" });
310
+ });
311
+ });
312
+ describe("SlackAdapter action wiring", () => {
313
+ it("decodes a captured block_actions body and forwards to sink.onInteraction", async () => {
314
+ const { adapter } = makeAdapter();
315
+ // Capture the handler Bolt would register, without starting sockets.
316
+ let actionHandler;
317
+ const app = {
318
+ action: vi.fn((_matcher, handler) => {
319
+ actionHandler = handler;
320
+ }),
321
+ init: vi.fn(async () => { }),
322
+ start: vi.fn(async () => { }),
323
+ };
324
+ adapter.app = app;
325
+ // auth.test is awaited in start(); attachSlackListener reads app.event etc.
326
+ adapter.client = {
327
+ ...adapter.client,
328
+ auth: { test: vi.fn(async () => ({ user_id: "UBOT" })) },
329
+ };
330
+ // attachSlackListener calls app.command/event/message and (default-on)
331
+ // attachAssistant calls app.assistant — stub them.
332
+ Object.assign(app, {
333
+ command: vi.fn(),
334
+ event: vi.fn(),
335
+ message: vi.fn(),
336
+ assistant: vi.fn(),
337
+ view: vi.fn(),
338
+ });
339
+ const received = [];
340
+ const sink = {
341
+ onTurn: vi.fn(),
342
+ onInteraction: (evt) => {
343
+ received.push(evt);
344
+ },
345
+ onCommand: vi.fn(),
346
+ onThreadStarted: vi.fn(),
347
+ onReaction: vi.fn(),
348
+ onModalSubmit: vi.fn(async () => { }),
349
+ onModalClose: vi.fn(),
350
+ };
351
+ await adapter.start(sink);
352
+ expect(actionHandler).toBeDefined();
353
+ const ack = vi.fn(async () => { });
354
+ await actionHandler({
355
+ ack,
356
+ body: {
357
+ type: "block_actions",
358
+ channel: { id: "C1" },
359
+ message: { ts: "1", thread_ts: "100.0" },
360
+ actions: [{ action_id: "ck:z", value: '{"ok":1}' }],
361
+ },
362
+ });
363
+ expect(ack).toHaveBeenCalledTimes(1);
364
+ expect(received).toHaveLength(1);
365
+ expect(received[0].id).toBe("ck:z");
366
+ expect(received[0].conversationKey).toBe("C1::100.0");
367
+ });
368
+ });
@@ -0,0 +1,19 @@
1
+ import type { App } from "@slack/bolt";
2
+ import type { IngressSink, PlatformUser } from "@copilotkit/channels";
3
+ import type { SlackAssistantOptions } from "./types.js";
4
+ export interface AttachAssistantConfig {
5
+ app: App;
6
+ /** The engine sink — pane events are delivered straight to it. */
7
+ sink: IngressSink;
8
+ /** Static pane behavior (greeting / prompts / title). */
9
+ opts: SlackAssistantOptions;
10
+ /** Resolve a Slack user id to a richer PlatformUser (adapter-owned, cached). */
11
+ resolveUser: (userId: string) => Promise<PlatformUser>;
12
+ }
13
+ export interface AssistantHandle {
14
+ /** True if `(channel, threadTs)` is a known assistant-pane thread. */
15
+ isAssistantThread: (channel: string, threadTs: string) => boolean;
16
+ }
17
+ /** Register the Bolt `Assistant` middleware and bridge it to the engine sink. */
18
+ export declare function attachAssistant(cfg: AttachAssistantConfig): AssistantHandle;
19
+ //# sourceMappingURL=assistant.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"assistant.d.ts","sourceRoot":"","sources":["../src/assistant.ts"],"names":[],"mappings":"AAsBA,OAAO,KAAK,EAAE,GAAG,EAAE,MAAM,aAAa,CAAC;AACvC,OAAO,KAAK,EAAE,WAAW,EAAE,YAAY,EAAE,MAAM,sBAAsB,CAAC;AAEtE,OAAO,KAAK,EAAE,qBAAqB,EAAE,MAAM,YAAY,CAAC;AAExD,MAAM,WAAW,qBAAqB;IACpC,GAAG,EAAE,GAAG,CAAC;IACT,kEAAkE;IAClE,IAAI,EAAE,WAAW,CAAC;IAClB,yDAAyD;IACzD,IAAI,EAAE,qBAAqB,CAAC;IAC5B,gFAAgF;IAChF,WAAW,EAAE,CAAC,MAAM,EAAE,MAAM,KAAK,OAAO,CAAC,YAAY,CAAC,CAAC;CACxD;AAED,MAAM,WAAW,eAAe;IAC9B,sEAAsE;IACtE,iBAAiB,EAAE,CAAC,OAAO,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,KAAK,OAAO,CAAC;CACnE;AAED,iFAAiF;AACjF,wBAAgB,eAAe,CAAC,GAAG,EAAE,qBAAqB,GAAG,eAAe,CAiH3E"}
@@ -0,0 +1,124 @@
1
+ /**
2
+ * Slack assistant-pane wiring — the SOLE owner of pane (`assistant_thread_*`)
3
+ * events. Bridges Bolt's `Assistant` middleware to the engine `IngressSink`.
4
+ *
5
+ * Flow:
6
+ * - `assistant_thread_started` → apply the adapter's STATIC defaults first
7
+ * (greeting + suggested prompts), then emit `sink.onThreadStarted` so
8
+ * `bot.onThreadStarted` handlers layer on top (never race the defaults).
9
+ * Records the pane thread so the message listener's guard can skip it.
10
+ * - a user message in the pane → exactly ONE `sink.onTurn`, scoped to the
11
+ * pane thread's `ts` (`channelId::threadTs`, NOT the flat DM scope), with a
12
+ * threaded reply target carrying `recipientUserId` (native streaming needs
13
+ * it). First message + `title: "auto"` → `assistant.threads.setTitle`.
14
+ * - `assistant_thread_context_changed` → persisted via Bolt's default
15
+ * thread-context store (stored for a later release; not yet fed to the agent).
16
+ *
17
+ * Pane messages arrive as threaded `message.im` events, which the ordinary
18
+ * `app.message` listener also sees; `attachSlackListener` is given the
19
+ * `isAssistantThread` predicate returned here so each pane message becomes
20
+ * exactly one turn (see slack-listener.ts).
21
+ */
22
+ import { Assistant } from "@slack/bolt";
23
+ import { conversationKeyOf } from "./interaction.js";
24
+ /** Register the Bolt `Assistant` middleware and bridge it to the engine sink. */
25
+ export function attachAssistant(cfg) {
26
+ const { app, sink, opts, resolveUser } = cfg;
27
+ // Pane threads seen this process (channelId::threadTs). Populated on
28
+ // thread_started and (defensively, after a restart) on the first message.
29
+ const assistantThreads = new Set();
30
+ // Threads we've already auto-titled, so only the FIRST user message titles.
31
+ const titled = new Set();
32
+ const titleAuto = opts.title !== false; // default "auto"
33
+ const assistant = new Assistant({
34
+ threadStarted: async ({ event, say, setSuggestedPrompts }) => {
35
+ const at = event
36
+ .assistant_thread;
37
+ if (!at?.channel_id || !at.thread_ts)
38
+ return;
39
+ const channelId = at.channel_id;
40
+ const threadTs = at.thread_ts;
41
+ assistantThreads.add(threadKey(channelId, threadTs));
42
+ // ── Static defaults first (greeting + prompts). Degrade, never throw. ──
43
+ if (opts.greeting) {
44
+ try {
45
+ await say(opts.greeting);
46
+ }
47
+ catch (err) {
48
+ console.error("[slack-assistant] greeting failed:", err);
49
+ }
50
+ }
51
+ if (opts.suggestedPrompts && opts.suggestedPrompts.length > 0) {
52
+ try {
53
+ await setSuggestedPrompts({
54
+ prompts: opts.suggestedPrompts.map((p) => ({
55
+ title: p.title,
56
+ message: p.message,
57
+ })),
58
+ });
59
+ }
60
+ catch (err) {
61
+ console.error("[slack-assistant] setSuggestedPrompts failed:", err);
62
+ }
63
+ }
64
+ // ── Then hand off to the engine (onThreadStarted handlers layer on top). ──
65
+ const user = at.user_id ? await resolveUser(at.user_id) : undefined;
66
+ await sink.onThreadStarted({
67
+ conversationKey: conversationKeyOf({ channelId, scope: threadTs }),
68
+ replyTarget: {
69
+ channel: channelId,
70
+ threadTs,
71
+ recipientUserId: at.user_id,
72
+ },
73
+ user,
74
+ platform: "slack",
75
+ });
76
+ },
77
+ userMessage: async ({ message, setTitle }) => {
78
+ const m = message;
79
+ // Pane messages are always threaded; ignore anything without a thread.
80
+ if (!m.channel || !m.thread_ts)
81
+ return;
82
+ const channelId = m.channel;
83
+ const threadTs = m.thread_ts;
84
+ const key = conversationKeyOf({ channelId, scope: threadTs });
85
+ assistantThreads.add(threadKey(channelId, threadTs)); // defensive (post-restart)
86
+ const text = (m.text ?? "").trim();
87
+ // Auto-title from the first user message.
88
+ if (titleAuto && !titled.has(key)) {
89
+ titled.add(key);
90
+ const title = text.replace(/\s+/g, " ").slice(0, 80);
91
+ if (title) {
92
+ try {
93
+ await setTitle(title);
94
+ }
95
+ catch (err) {
96
+ console.error("[slack-assistant] setTitle failed:", err);
97
+ }
98
+ }
99
+ }
100
+ await sink.onTurn({
101
+ conversationKey: key,
102
+ replyTarget: { channel: channelId, threadTs, recipientUserId: m.user },
103
+ userText: text,
104
+ user: m.user ? await resolveUser(m.user) : undefined,
105
+ platform: "slack",
106
+ });
107
+ },
108
+ threadContextChanged: async ({ saveThreadContext }) => {
109
+ // Persist via Bolt's default thread-context store. Stored for a later
110
+ // release; not yet consumed by the agent.
111
+ try {
112
+ await saveThreadContext();
113
+ }
114
+ catch (err) {
115
+ console.error("[slack-assistant] saveThreadContext failed:", err);
116
+ }
117
+ },
118
+ });
119
+ app.assistant(assistant);
120
+ return {
121
+ isAssistantThread: (channel, threadTs) => assistantThreads.has(threadKey(channel, threadTs)),
122
+ };
123
+ }
124
+ const threadKey = (channel, threadTs) => `${channel}::${threadTs}`;
@@ -0,0 +1,62 @@
1
+ /**
2
+ * Streaming-polish layer: while an agent is mid-stream, the buffer is
3
+ * usually an *unfinished* markdown document — an open code fence, an
4
+ * unclosed `**`, etc. If we send the unbalanced text straight to Slack,
5
+ * the rest of the message renders broken (a stray ``` will turn the rest
6
+ * of the Slack message into a code block until the closer arrives).
7
+ *
8
+ * `autoCloseOpenMarkdown` returns a balanced copy of the buffer by
9
+ * appending the minimum set of closers needed so that the markdown is
10
+ * well-formed for display. When the agent later emits the real closer,
11
+ * the buffer becomes balanced on its own and this function adds nothing
12
+ * — i.e. no double-close in the committed Slack message.
13
+ *
14
+ * Operates in **markdown space** (i.e. on the raw text from the agent,
15
+ * before mrkdwn translation). The caller composes this with
16
+ * `markdownToMrkdwn` afterwards so the translator sees a balanced
17
+ * document.
18
+ *
19
+ * What it handles, in priority order:
20
+ *
21
+ * 1. Unclosed fenced code blocks ``` … (most severe — leaks code styling
22
+ * through the rest of the Slack message)
23
+ * 2. Unclosed inline code `…`
24
+ * 3. Unclosed bold `**…` / `__…`
25
+ * 4. Unclosed italic `*…` / `_…`
26
+ * 5. Unclosed strike `~~…`
27
+ *
28
+ * Heuristics:
29
+ *
30
+ * - A marker with no content after it (e.g. trailing `**`) is **not**
31
+ * closed — the agent might be just opening the bracket; closing would
32
+ * produce a transient `****` which looks worse than `**`.
33
+ * - Markers inside code/fence regions don't count.
34
+ * - When closing, we emit closers in reverse stack order (innermost
35
+ * first) so the rendered structure is well-nested.
36
+ */
37
+ export declare function autoCloseOpenMarkdown(text: string): string;
38
+ /**
39
+ * What markdown context is *still open* at the end of `text`? Used to
40
+ * decide what opening marker(s) the NEXT chunk needs prepended when we
41
+ * split a long buffer across multiple Slack messages mid-formatting
42
+ * — without this, a chunk boundary inside a ` ```python … ``` ` block
43
+ * would have the second Slack message start with raw code that Slack
44
+ * renders as plain text.
45
+ */
46
+ export interface OpenMarkdownContext {
47
+ /** If inside an unclosed fence, the language tag (may be ""). null otherwise. */
48
+ fenceLang: string | null;
49
+ /** True if an unclosed single-backtick inline-code span is in flight. */
50
+ inlineCode: boolean;
51
+ /** Bold/italic/strike markers still open, in stack order (outermost first). */
52
+ brackets: string[];
53
+ }
54
+ export declare function detectOpenContext(text: string): OpenMarkdownContext;
55
+ /**
56
+ * Render the opener prefix for a continuation chunk so it can stand on
57
+ * its own as a self-renderable markdown fragment. E.g. for an open
58
+ * `` ```python `` fence, returns ` ```python\n `; for an open `**` bold,
59
+ * returns `**`.
60
+ */
61
+ export declare function renderContextOpener(ctx: OpenMarkdownContext): string;
62
+ //# sourceMappingURL=auto-close-streaming.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"auto-close-streaming.d.ts","sourceRoot":"","sources":["../src/auto-close-streaming.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAmCG;AAKH,wBAAgB,qBAAqB,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CA+E1D;AAgFD;;;;;;;GAOG;AACH,MAAM,WAAW,mBAAmB;IAClC,iFAAiF;IACjF,SAAS,EAAE,MAAM,GAAG,IAAI,CAAC;IACzB,yEAAyE;IACzE,UAAU,EAAE,OAAO,CAAC;IACpB,+EAA+E;IAC/E,QAAQ,EAAE,MAAM,EAAE,CAAC;CACpB;AAED,wBAAgB,iBAAiB,CAAC,IAAI,EAAE,MAAM,GAAG,mBAAmB,CAiCnE;AAiCD;;;;;GAKG;AACH,wBAAgB,mBAAmB,CAAC,GAAG,EAAE,mBAAmB,GAAG,MAAM,CAUpE"}