@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,521 @@
1
+ import { describe, it, expect, vi } from "vitest";
2
+ import { createRunRenderer } from "../event-renderer.js";
3
+ /**
4
+ * Fake {@link SlackRenderTransport} — records every post / update call in
5
+ * order. This is exactly the seam the managed Connector Outbox will drive, so
6
+ * the renderer is verified Bolt-free (no `WebClient`).
7
+ */
8
+ function makeFakeClient() {
9
+ const posts = [];
10
+ const updates = [];
11
+ let postedTsCounter = 0;
12
+ const transport = {
13
+ setStatus: vi.fn(async () => { }),
14
+ postMessage: vi.fn(async (args) => {
15
+ postedTsCounter++;
16
+ const ts = `${postedTsCounter}.000`;
17
+ posts.push({ ...args, ts });
18
+ return { ts };
19
+ }),
20
+ updateMessage: vi.fn(async (args) => {
21
+ updates.push(args);
22
+ }),
23
+ };
24
+ return { transport, posts, updates };
25
+ }
26
+ describe("createRunRenderer", () => {
27
+ it("accumulates deltas into the final Slack message (the ECHO regression)", async () => {
28
+ // Reproduces the live bug: AG-UI's `textMessageBuffer` is the buffer
29
+ // *before* the current delta — it lags by one — so a renderer that
30
+ // forwards `textMessageBuffer` straight through ends up posting "E"
31
+ // instead of "ECHO". The renderer must accumulate deltas on its own.
32
+ const fake = makeFakeClient();
33
+ const { subscriber: sub } = createRunRenderer({
34
+ transport: fake.transport,
35
+ target: { channel: "C1", threadTs: "100.0" },
36
+ });
37
+ const id = "msg-1";
38
+ await sub.onTextMessageStartEvent({
39
+ event: { messageId: id, role: "assistant" },
40
+ });
41
+ sub.onTextMessageContentEvent({
42
+ event: { messageId: id, delta: "E" },
43
+ textMessageBuffer: "",
44
+ });
45
+ sub.onTextMessageContentEvent({
46
+ event: { messageId: id, delta: "CHO" },
47
+ textMessageBuffer: "E",
48
+ });
49
+ await sub.onTextMessageEndEvent({ event: { messageId: id } });
50
+ // (a) Text streaming posts a placeholder then updates it; the LAST
51
+ // update must be the fully-accumulated text.
52
+ expect(fake.posts).toHaveLength(1);
53
+ expect(fake.posts[0]?.text).toBe("_thinking…_");
54
+ expect(fake.updates.length).toBeGreaterThan(0);
55
+ expect(fake.updates.at(-1)?.text).toBe("ECHO");
56
+ });
57
+ it("posts placeholder with thread_ts for thread replies, none for DMs (only on first content)", async () => {
58
+ const fake = makeFakeClient();
59
+ const { subscriber: dm } = createRunRenderer({
60
+ transport: fake.transport,
61
+ target: { channel: "D1" },
62
+ });
63
+ await dm.onTextMessageStartEvent({
64
+ event: { messageId: "m1", role: "assistant" },
65
+ });
66
+ // No content yet → no Slack post (per the D20 fix).
67
+ expect(fake.posts).toHaveLength(0);
68
+ // First content event triggers the placeholder.
69
+ dm.onTextMessageContentEvent({
70
+ event: { messageId: "m1", delta: "hi" },
71
+ textMessageBuffer: "",
72
+ });
73
+ await dm.onTextMessageEndEvent({ event: { messageId: "m1" } });
74
+ expect(fake.posts[0]?.thread_ts).toBeUndefined();
75
+ const { subscriber: thread } = createRunRenderer({
76
+ transport: fake.transport,
77
+ target: { channel: "C1", threadTs: "100.0" },
78
+ });
79
+ await thread.onTextMessageStartEvent({
80
+ event: { messageId: "m2", role: "assistant" },
81
+ });
82
+ thread.onTextMessageContentEvent({
83
+ event: { messageId: "m2", delta: "hi" },
84
+ textMessageBuffer: "",
85
+ });
86
+ await thread.onTextMessageEndEvent({
87
+ event: { messageId: "m2" },
88
+ });
89
+ expect(fake.posts[1]?.thread_ts).toBe("100.0");
90
+ });
91
+ it("D20: a TEXT_MESSAGE_START + END with NO content events produces no Slack message", async () => {
92
+ const fake = makeFakeClient();
93
+ const { subscriber: sub } = createRunRenderer({
94
+ transport: fake.transport,
95
+ target: { channel: "C1", threadTs: "100.0" },
96
+ });
97
+ await sub.onTextMessageStartEvent({
98
+ event: { messageId: "m1", role: "assistant" },
99
+ });
100
+ await sub.onTextMessageEndEvent({ event: { messageId: "m1" } });
101
+ expect(fake.posts).toHaveLength(0);
102
+ expect(fake.updates).toHaveLength(0);
103
+ });
104
+ it("captures EVERY tool-call-end (getCapturedToolCalls returns it)", async () => {
105
+ const fake = makeFakeClient();
106
+ const { subscriber: sub, getCapturedToolCalls } = createRunRenderer({
107
+ transport: fake.transport,
108
+ target: { channel: "C1", threadTs: "100.0" },
109
+ showToolStatus: false,
110
+ });
111
+ await sub.onToolCallStartEvent({
112
+ event: { toolCallId: "tc1", toolCallName: "search_flights" },
113
+ });
114
+ await sub.onToolCallEndEvent({
115
+ event: { toolCallId: "tc1" },
116
+ toolCallName: "search_flights",
117
+ toolCallArgs: { from: "SFO", to: "JFK" },
118
+ });
119
+ const captured = getCapturedToolCalls();
120
+ expect(captured).toHaveLength(1);
121
+ expect(captured[0]).toEqual({
122
+ toolCallId: "tc1",
123
+ toolCallName: "search_flights",
124
+ toolCallArgs: { from: "SFO", to: "JFK" },
125
+ });
126
+ });
127
+ it("captures partial args from onToolCallArgsEvent, finalised at END", async () => {
128
+ const fake = makeFakeClient();
129
+ const { subscriber: sub, getCapturedToolCalls } = createRunRenderer({
130
+ transport: fake.transport,
131
+ target: { channel: "C1", threadTs: "100.0" },
132
+ showToolStatus: false,
133
+ });
134
+ sub.onToolCallArgsEvent({
135
+ event: { toolCallId: "tc1" },
136
+ toolCallName: "manage_todos",
137
+ partialToolCallArgs: { title: "partial" },
138
+ });
139
+ await sub.onToolCallEndEvent({
140
+ event: { toolCallId: "tc1" },
141
+ toolCallName: "manage_todos",
142
+ toolCallArgs: { title: "final" },
143
+ });
144
+ const captured = getCapturedToolCalls();
145
+ expect(captured).toHaveLength(1);
146
+ expect(captured[0]?.toolCallArgs).toEqual({ title: "final" });
147
+ });
148
+ it("tool-call status: showToolStatus default (true) → START posts 🔧, END edits to ✅", async () => {
149
+ const fake = makeFakeClient();
150
+ const { subscriber: sub } = createRunRenderer({
151
+ transport: fake.transport,
152
+ target: { channel: "C1", threadTs: "100.0" },
153
+ });
154
+ await sub.onToolCallStartEvent({
155
+ event: { toolCallId: "tc1", toolCallName: "search_flights" },
156
+ });
157
+ expect(fake.posts).toHaveLength(1);
158
+ expect(fake.posts[0]?.text).toContain(":wrench:");
159
+ await sub.onToolCallEndEvent({
160
+ event: { toolCallId: "tc1" },
161
+ toolCallName: "search_flights",
162
+ toolCallArgs: {},
163
+ });
164
+ expect(fake.updates).toHaveLength(1);
165
+ expect(fake.updates[0]?.text).toContain(":white_check_mark:");
166
+ });
167
+ it("tool-call status: showToolStatus:false → no status posts but still captures", async () => {
168
+ const fake = makeFakeClient();
169
+ const { subscriber: sub, getCapturedToolCalls } = createRunRenderer({
170
+ transport: fake.transport,
171
+ target: { channel: "C1", threadTs: "100.0" },
172
+ showToolStatus: false,
173
+ });
174
+ await sub.onToolCallStartEvent({
175
+ event: { toolCallId: "tc1", toolCallName: "search_flights" },
176
+ });
177
+ await sub.onToolCallEndEvent({
178
+ event: { toolCallId: "tc1" },
179
+ toolCallName: "search_flights",
180
+ toolCallArgs: {},
181
+ });
182
+ expect(fake.posts).toHaveLength(0);
183
+ expect(fake.updates).toHaveLength(0);
184
+ expect(getCapturedToolCalls()).toHaveLength(1);
185
+ });
186
+ it("tool-call status: dedup by toolCallId — a repeated START (e.g. on graph resume) does not double-post", async () => {
187
+ const fake = makeFakeClient();
188
+ const { subscriber: sub } = createRunRenderer({
189
+ transport: fake.transport,
190
+ target: { channel: "C1", threadTs: "100.0" },
191
+ showToolStatus: true,
192
+ });
193
+ await sub.onToolCallStartEvent({
194
+ event: { toolCallId: "tc1", toolCallName: "schedule_meeting" },
195
+ });
196
+ await sub.onToolCallStartEvent({
197
+ event: { toolCallId: "tc1", toolCallName: "schedule_meeting" },
198
+ });
199
+ expect(fake.posts).toHaveLength(1);
200
+ });
201
+ it("interrupt: an on_interrupt custom event sets getPendingInterrupt", async () => {
202
+ const fake = makeFakeClient();
203
+ const { subscriber: sub, getPendingInterrupt, clearPendingInterrupt, } = createRunRenderer({
204
+ transport: fake.transport,
205
+ target: { channel: "C1", threadTs: "100.0" },
206
+ });
207
+ sub.onCustomEvent({
208
+ event: { name: "on_interrupt", value: { question: "Confirm?" } },
209
+ });
210
+ const pending = getPendingInterrupt();
211
+ expect(pending).toBeDefined();
212
+ expect(pending?.eventName).toBe("on_interrupt");
213
+ expect(pending?.value).toEqual({ question: "Confirm?" });
214
+ clearPendingInterrupt();
215
+ expect(getPendingInterrupt()).toBeUndefined();
216
+ });
217
+ it("interrupt: a JSON-string value is parsed", async () => {
218
+ const fake = makeFakeClient();
219
+ const { subscriber: sub, getPendingInterrupt } = createRunRenderer({
220
+ transport: fake.transport,
221
+ target: { channel: "C1", threadTs: "100.0" },
222
+ });
223
+ sub.onCustomEvent({
224
+ event: { name: "on_interrupt", value: JSON.stringify({ ok: true }) },
225
+ });
226
+ expect(getPendingInterrupt()?.value).toEqual({ ok: true });
227
+ });
228
+ it("interrupt: a non-matching custom event is ignored", async () => {
229
+ const fake = makeFakeClient();
230
+ const { subscriber: sub, getPendingInterrupt } = createRunRenderer({
231
+ transport: fake.transport,
232
+ target: { channel: "C1", threadTs: "100.0" },
233
+ });
234
+ sub.onCustomEvent({
235
+ event: { name: "some_other_event", value: { x: 1 } },
236
+ });
237
+ expect(getPendingInterrupt()).toBeUndefined();
238
+ });
239
+ it("mrkdwn: bold/italic/links/lists get translated before chat.update", async () => {
240
+ const fake = makeFakeClient();
241
+ const { subscriber: sub } = createRunRenderer({
242
+ transport: fake.transport,
243
+ target: { channel: "C1", threadTs: "100.0" },
244
+ });
245
+ await sub.onTextMessageStartEvent({
246
+ event: { messageId: "m1", role: "assistant" },
247
+ });
248
+ sub.onTextMessageContentEvent({
249
+ event: {
250
+ messageId: "m1",
251
+ delta: "**hi** [docs](https://x.com)\n- a\n- b",
252
+ },
253
+ textMessageBuffer: "",
254
+ });
255
+ await sub.onTextMessageEndEvent({ event: { messageId: "m1" } });
256
+ const last = fake.updates.at(-1)?.text ?? "";
257
+ expect(last).toContain("*hi*");
258
+ expect(last).toContain("<https://x.com|docs>");
259
+ expect(last).toContain("• a");
260
+ });
261
+ it("posts a warning when RUN_ERROR fires", async () => {
262
+ const fake = makeFakeClient();
263
+ const { subscriber: sub } = createRunRenderer({
264
+ transport: fake.transport,
265
+ target: { channel: "C1", threadTs: "100.0" },
266
+ });
267
+ await sub.onRunErrorEvent({ event: { message: "boom" } });
268
+ expect(fake.posts).toHaveLength(1);
269
+ expect(fake.posts[0]?.text).toContain("boom");
270
+ expect(fake.posts[0]?.thread_ts).toBe("100.0");
271
+ });
272
+ it("markInterrupted: appends _(interrupted)_ to a partial reply and finalises", async () => {
273
+ const fake = makeFakeClient();
274
+ const { subscriber, markInterrupted } = createRunRenderer({
275
+ transport: fake.transport,
276
+ target: { channel: "C1", threadTs: "100.0" },
277
+ });
278
+ await subscriber.onTextMessageStartEvent({
279
+ event: { messageId: "m1", role: "assistant" },
280
+ });
281
+ subscriber.onTextMessageContentEvent({
282
+ event: { messageId: "m1", delta: "Once upon a time" },
283
+ textMessageBuffer: "",
284
+ });
285
+ await markInterrupted();
286
+ const last = fake.updates.at(-1)?.text ?? "";
287
+ expect(last).toContain("Once upon a time");
288
+ expect(last).toContain("_(interrupted)_");
289
+ });
290
+ it("markInterrupted: no partial reply yet → no Slack post created", async () => {
291
+ const fake = makeFakeClient();
292
+ const { subscriber, markInterrupted } = createRunRenderer({
293
+ transport: fake.transport,
294
+ target: { channel: "C1", threadTs: "100.0" },
295
+ });
296
+ await subscriber.onTextMessageStartEvent({
297
+ event: { messageId: "m1", role: "assistant" },
298
+ });
299
+ // No content events arrived yet — interrupted IMMEDIATELY.
300
+ await markInterrupted();
301
+ expect(fake.posts).toHaveLength(0);
302
+ expect(fake.updates).toHaveLength(0);
303
+ });
304
+ it("markInterrupted: a RUN_ERROR event that arrives AFTER abort is suppressed", async () => {
305
+ const fake = makeFakeClient();
306
+ const { subscriber, markInterrupted } = createRunRenderer({
307
+ transport: fake.transport,
308
+ target: { channel: "C1", threadTs: "100.0" },
309
+ });
310
+ await subscriber.onTextMessageStartEvent({
311
+ event: { messageId: "m1", role: "assistant" },
312
+ });
313
+ subscriber.onTextMessageContentEvent({
314
+ event: { messageId: "m1", delta: "partial" },
315
+ textMessageBuffer: "",
316
+ });
317
+ await markInterrupted();
318
+ // Now AG-UI fires the late RUN_ERROR.
319
+ await subscriber.onRunErrorEvent({
320
+ event: { message: "aborted" },
321
+ });
322
+ const warnings = fake.posts.filter((p) => p.text.includes(":warning:"));
323
+ expect(warnings).toHaveLength(0);
324
+ });
325
+ it("markInterrupted: ignores late content events after the interrupt", async () => {
326
+ const fake = makeFakeClient();
327
+ const { subscriber, markInterrupted } = createRunRenderer({
328
+ transport: fake.transport,
329
+ target: { channel: "C1", threadTs: "100.0" },
330
+ });
331
+ await subscriber.onTextMessageStartEvent({
332
+ event: { messageId: "m1", role: "assistant" },
333
+ });
334
+ subscriber.onTextMessageContentEvent({
335
+ event: { messageId: "m1", delta: "partial" },
336
+ textMessageBuffer: "",
337
+ });
338
+ await markInterrupted();
339
+ const postsBefore = fake.posts.length;
340
+ // Late event after interrupt — should be ignored.
341
+ subscriber.onTextMessageContentEvent({
342
+ event: { messageId: "m1", delta: " more text" },
343
+ textMessageBuffer: "partial",
344
+ });
345
+ expect(fake.posts.length).toBe(postsBefore);
346
+ });
347
+ it("does not bleed buffers between messages with different ids", async () => {
348
+ const fake = makeFakeClient();
349
+ const { subscriber: sub } = createRunRenderer({
350
+ transport: fake.transport,
351
+ target: { channel: "C1", threadTs: "100.0" },
352
+ });
353
+ await sub.onTextMessageStartEvent({
354
+ event: { messageId: "a", role: "assistant" },
355
+ });
356
+ await sub.onTextMessageStartEvent({
357
+ event: { messageId: "b", role: "assistant" },
358
+ });
359
+ sub.onTextMessageContentEvent({
360
+ event: { messageId: "a", delta: "AAA" },
361
+ textMessageBuffer: "",
362
+ });
363
+ sub.onTextMessageContentEvent({
364
+ event: { messageId: "b", delta: "BBB" },
365
+ textMessageBuffer: "",
366
+ });
367
+ await sub.onTextMessageEndEvent({ event: { messageId: "a" } });
368
+ await sub.onTextMessageEndEvent({ event: { messageId: "b" } });
369
+ const lastUpdateForFirstTs = fake.updates
370
+ .filter((u) => u.ts === "1.000")
371
+ .at(-1);
372
+ const lastUpdateForSecondTs = fake.updates
373
+ .filter((u) => u.ts === "2.000")
374
+ .at(-1);
375
+ expect(lastUpdateForFirstTs?.text).toBe("AAA");
376
+ expect(lastUpdateForSecondTs?.text).toBe("BBB");
377
+ });
378
+ });
379
+ describe("createRunRenderer — native status mode", () => {
380
+ function makePaneClient() {
381
+ const statuses = [];
382
+ const posts = [];
383
+ const updates = [];
384
+ let counter = 0;
385
+ const transport = {
386
+ setStatus: vi.fn(async (args) => {
387
+ statuses.push({
388
+ status: args.status,
389
+ loading_messages: args.loading_messages,
390
+ thread_ts: args.thread_ts,
391
+ });
392
+ }),
393
+ postMessage: vi.fn(async (args) => {
394
+ counter++;
395
+ const ts = `${counter}.000`;
396
+ posts.push({ text: args.text, ts });
397
+ return { ts };
398
+ }),
399
+ updateMessage: vi.fn(async (args) => {
400
+ updates.push(args);
401
+ }),
402
+ };
403
+ return { transport, statuses, posts };
404
+ }
405
+ it("sets native status on run start instead of posting a placeholder", async () => {
406
+ const f = makePaneClient();
407
+ const { subscriber: sub } = createRunRenderer({
408
+ transport: f.transport,
409
+ target: { channel: "D1", threadTs: "100.0" },
410
+ status: {
411
+ threadTs: "100.0",
412
+ isPane: true,
413
+ config: { thinking: "is pondering…", loadingMessages: ["a"] },
414
+ },
415
+ });
416
+ await sub.onRunStartedEvent({});
417
+ expect(f.posts).toHaveLength(0);
418
+ expect(f.statuses[0]).toEqual({
419
+ status: "is pondering…",
420
+ loading_messages: ["a"],
421
+ thread_ts: "100.0",
422
+ });
423
+ });
424
+ it("surfaces tool calls as live status, not :wrench: rows", async () => {
425
+ const f = makePaneClient();
426
+ const { subscriber: sub } = createRunRenderer({
427
+ transport: f.transport,
428
+ target: { channel: "D1", threadTs: "100.0" },
429
+ status: { threadTs: "100.0", isPane: true, config: {} },
430
+ });
431
+ await sub.onToolCallStartEvent({
432
+ event: { toolCallId: "t1", toolCallName: "search" },
433
+ });
434
+ expect(f.posts).toHaveLength(0);
435
+ expect(f.statuses.at(-1)?.status).toBe("is using `search`…");
436
+ });
437
+ it("showToolStatus:false suppresses the pane's `is using` status", async () => {
438
+ const f = makePaneClient();
439
+ const { subscriber: sub } = createRunRenderer({
440
+ transport: f.transport,
441
+ target: { channel: "D1", threadTs: "100.0" },
442
+ status: { threadTs: "100.0", isPane: true, config: {} },
443
+ showToolStatus: false,
444
+ });
445
+ await sub.onToolCallStartEvent({
446
+ event: { toolCallId: "t1", toolCallName: "search" },
447
+ });
448
+ expect(f.posts).toHaveLength(0);
449
+ expect(f.statuses.some((s) => s.status.includes("is using"))).toBe(false);
450
+ });
451
+ it("clears the status once a reply is posted", async () => {
452
+ const f = makePaneClient();
453
+ const { subscriber: sub } = createRunRenderer({
454
+ transport: f.transport,
455
+ target: { channel: "D1", threadTs: "100.0" },
456
+ status: { threadTs: "100.0", isPane: true, config: {} },
457
+ });
458
+ await sub.onRunStartedEvent({});
459
+ await sub.onTextMessageStartEvent({
460
+ event: { messageId: "m", role: "assistant" },
461
+ });
462
+ sub.onTextMessageContentEvent({
463
+ event: { messageId: "m", delta: "hi" },
464
+ textMessageBuffer: "",
465
+ });
466
+ await sub.onTextMessageEndEvent({ event: { messageId: "m" } });
467
+ expect(f.statuses.some((s) => s.status === "")).toBe(true);
468
+ });
469
+ it("clears the status on run finish when nothing was posted", async () => {
470
+ const f = makePaneClient();
471
+ const { subscriber: sub } = createRunRenderer({
472
+ transport: f.transport,
473
+ target: { channel: "D1", threadTs: "100.0" },
474
+ status: { threadTs: "100.0", isPane: true, config: { thinking: "t" } },
475
+ });
476
+ await sub.onRunStartedEvent({});
477
+ await sub.onRunFinishedEvent({});
478
+ expect(f.statuses.at(-1)?.status).toBe("");
479
+ });
480
+ // ── Non-pane: a channel @-mention / thread (isPane:false) gets the native
481
+ // "is thinking…" status instead of the old :hourglass: placeholder, and tool
482
+ // progress flows to both :wrench: rows and the composer "is using…" status
483
+ // (setStatus drives any thread anchor now, not just panes). ──────────────────
484
+ it("non-pane thread: sets native status on run start (no :hourglass: placeholder)", async () => {
485
+ const f = makePaneClient();
486
+ const { subscriber: sub } = createRunRenderer({
487
+ transport: f.transport,
488
+ target: { channel: "C1", threadTs: "100.0" },
489
+ status: { threadTs: "100.0", isPane: false, config: {} },
490
+ });
491
+ await sub.onRunStartedEvent({});
492
+ expect(f.posts).toHaveLength(0);
493
+ expect(f.statuses[0]?.status).toBe("is thinking…");
494
+ });
495
+ it("non-pane thread: tool calls post :wrench: rows and set composer status", async () => {
496
+ const f = makePaneClient();
497
+ const { subscriber: sub } = createRunRenderer({
498
+ transport: f.transport,
499
+ target: { channel: "C1", threadTs: "100.0" },
500
+ status: { threadTs: "100.0", isPane: false, config: {} },
501
+ });
502
+ await sub.onToolCallStartEvent({
503
+ event: { toolCallId: "t1", toolCallName: "search" },
504
+ });
505
+ expect(f.posts.at(-1)?.text).toContain(":wrench:");
506
+ expect(f.statuses.some((s) => s.status.includes("is using"))).toBe(true);
507
+ });
508
+ it("uses the provided threadTs anchor (e.g. a DM's inbound ts) for setStatus", async () => {
509
+ const f = makePaneClient();
510
+ const { subscriber: sub } = createRunRenderer({
511
+ transport: f.transport,
512
+ // Flat DM: the channel target has no threadTs, but the adapter passes the
513
+ // inbound message ts as the status anchor.
514
+ target: { channel: "D1" },
515
+ status: { threadTs: "999.000", isPane: false, config: {} },
516
+ });
517
+ await sub.onRunStartedEvent({});
518
+ expect(f.statuses[0]?.status).toBe("is thinking…");
519
+ expect(f.statuses[0]?.thread_ts).toBe("999.000");
520
+ });
521
+ });
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=markdown-to-mrkdwn.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"markdown-to-mrkdwn.test.d.ts","sourceRoot":"","sources":["../../src/__tests__/markdown-to-mrkdwn.test.ts"],"names":[],"mappings":""}
@@ -0,0 +1,80 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { markdownToMrkdwn } from "../markdown-to-mrkdwn.js";
3
+ describe("markdownToMrkdwn", () => {
4
+ it("**bold** → *bold*", () => {
5
+ expect(markdownToMrkdwn("hello **world**!")).toBe("hello *world*!");
6
+ });
7
+ it("__bold__ also → *bold*", () => {
8
+ expect(markdownToMrkdwn("__strong__ text")).toBe("*strong* text");
9
+ });
10
+ it("*italic* → _italic_", () => {
11
+ expect(markdownToMrkdwn("here is *emphasised* text")).toBe("here is _emphasised_ text");
12
+ });
13
+ it("~~strike~~ → ~strike~", () => {
14
+ expect(markdownToMrkdwn("~~gone~~")).toBe("~gone~");
15
+ });
16
+ it("# heading → *heading*", () => {
17
+ expect(markdownToMrkdwn("# Title")).toBe("*Title*");
18
+ expect(markdownToMrkdwn("### Sub")).toBe("*Sub*");
19
+ });
20
+ it("[text](url) → <url|text>", () => {
21
+ expect(markdownToMrkdwn("see [docs](https://x.com/d)")).toBe("see <https://x.com/d|docs>");
22
+ });
23
+ it("bullet markers (- * +) → •", () => {
24
+ const input = "- one\n* two\n+ three";
25
+ const expected = "• one\n• two\n• three";
26
+ expect(markdownToMrkdwn(input)).toBe(expected);
27
+ });
28
+ it("indented bullets keep their indent", () => {
29
+ expect(markdownToMrkdwn(" - nested")).toBe(" • nested");
30
+ });
31
+ it("does NOT touch contents of fenced code blocks", () => {
32
+ const md = "```\n**not bold here**\n- not a bullet\n```";
33
+ // body unchanged because it's inside a fence
34
+ expect(markdownToMrkdwn(md)).toBe(md);
35
+ });
36
+ it("does NOT touch contents of inline backticks", () => {
37
+ expect(markdownToMrkdwn("use `**stars**` here")).toBe("use `**stars**` here");
38
+ });
39
+ it("wraps GFM tables in a code fence so they render in monospace", () => {
40
+ const input = "| a | b |\n|---|---|\n| 1 | 2 |\n";
41
+ const out = markdownToMrkdwn(input);
42
+ expect(out.startsWith("```")).toBe(true);
43
+ expect(out).toContain("| a | b |");
44
+ expect(out).toContain("| 1 | 2 |");
45
+ expect(out.trim().endsWith("```")).toBe(true);
46
+ });
47
+ it("aligns table columns so monospace reads cleanly (no pipe-soup)", () => {
48
+ const input = "| name | role |\n|---|---|\n| LangGraph | Framework. |\n| AG-UI | Protocol. |\n";
49
+ const out = markdownToMrkdwn(input);
50
+ // Drop the separator row (no visual value in monospace).
51
+ expect(out).not.toContain("|---|");
52
+ // Each rendered data row should have the SAME line length now that
53
+ // columns are padded.
54
+ const dataLines = out
55
+ .split("\n")
56
+ .filter((l) => l.startsWith("|") && l.endsWith("|"));
57
+ expect(dataLines.length).toBe(3); // header + 2 data
58
+ const lens = dataLines.map((l) => l.length);
59
+ expect(new Set(lens).size).toBe(1); // all the same length
60
+ });
61
+ it("aligned table preserves cell contents", () => {
62
+ const input = "| col1 | col2 |\n|---|---|\n| a | b |\n";
63
+ const out = markdownToMrkdwn(input);
64
+ expect(out).toContain("col1");
65
+ expect(out).toContain("col2");
66
+ expect(out).toContain(" a "); // padded but content preserved
67
+ expect(out).toContain(" b ");
68
+ });
69
+ it("translates a paragraph with mixed formatting", () => {
70
+ const input = "**Hello** — see [docs](https://x.com) and try:\n- *fast* mode\n- **safe** mode";
71
+ const out = markdownToMrkdwn(input);
72
+ expect(out).toBe("*Hello* — see <https://x.com|docs> and try:\n• _fast_ mode\n• *safe* mode");
73
+ });
74
+ it("is a no-op for plain text", () => {
75
+ expect(markdownToMrkdwn("just plain words 123")).toBe("just plain words 123");
76
+ });
77
+ it("is a no-op for the empty string", () => {
78
+ expect(markdownToMrkdwn("")).toBe("");
79
+ });
80
+ });
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=message-stream.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"message-stream.test.d.ts","sourceRoot":"","sources":["../../src/__tests__/message-stream.test.ts"],"names":[],"mappings":""}