@copilotkit/channels-discord 0.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (123) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +308 -0
  3. package/dist/__tests__/ephemeral.test.d.ts +2 -0
  4. package/dist/__tests__/ephemeral.test.d.ts.map +1 -0
  5. package/dist/__tests__/ephemeral.test.js +26 -0
  6. package/dist/__tests__/intents.test.d.ts +2 -0
  7. package/dist/__tests__/intents.test.d.ts.map +1 -0
  8. package/dist/__tests__/intents.test.js +8 -0
  9. package/dist/__tests__/modal-submit.test.d.ts +2 -0
  10. package/dist/__tests__/modal-submit.test.d.ts.map +1 -0
  11. package/dist/__tests__/modal-submit.test.js +26 -0
  12. package/dist/__tests__/reactions.test.d.ts +2 -0
  13. package/dist/__tests__/reactions.test.d.ts.map +1 -0
  14. package/dist/__tests__/reactions.test.js +87 -0
  15. package/dist/adapter.d.ts +118 -0
  16. package/dist/adapter.d.ts.map +1 -0
  17. package/dist/adapter.js +567 -0
  18. package/dist/adapter.test.d.ts +2 -0
  19. package/dist/adapter.test.d.ts.map +1 -0
  20. package/dist/adapter.test.js +343 -0
  21. package/dist/auto-close-streaming.d.ts +61 -0
  22. package/dist/auto-close-streaming.d.ts.map +1 -0
  23. package/dist/auto-close-streaming.js +276 -0
  24. package/dist/auto-close-streaming.test.d.ts +2 -0
  25. package/dist/auto-close-streaming.test.d.ts.map +1 -0
  26. package/dist/auto-close-streaming.test.js +149 -0
  27. package/dist/built-in-context.d.ts +20 -0
  28. package/dist/built-in-context.d.ts.map +1 -0
  29. package/dist/built-in-context.js +30 -0
  30. package/dist/built-in-context.test.d.ts +2 -0
  31. package/dist/built-in-context.test.d.ts.map +1 -0
  32. package/dist/built-in-context.test.js +21 -0
  33. package/dist/built-in-tools.d.ts +4 -0
  34. package/dist/built-in-tools.d.ts.map +1 -0
  35. package/dist/built-in-tools.js +27 -0
  36. package/dist/built-in-tools.test.d.ts +2 -0
  37. package/dist/built-in-tools.test.d.ts.map +1 -0
  38. package/dist/built-in-tools.test.js +30 -0
  39. package/dist/chunked-message-stream.d.ts +94 -0
  40. package/dist/chunked-message-stream.d.ts.map +1 -0
  41. package/dist/chunked-message-stream.js +203 -0
  42. package/dist/chunked-message-stream.test.d.ts +2 -0
  43. package/dist/chunked-message-stream.test.d.ts.map +1 -0
  44. package/dist/chunked-message-stream.test.js +384 -0
  45. package/dist/commands.d.ts +29 -0
  46. package/dist/commands.d.ts.map +1 -0
  47. package/dist/commands.js +193 -0
  48. package/dist/commands.test.d.ts +2 -0
  49. package/dist/commands.test.d.ts.map +1 -0
  50. package/dist/commands.test.js +281 -0
  51. package/dist/conversation-store.d.ts +45 -0
  52. package/dist/conversation-store.d.ts.map +1 -0
  53. package/dist/conversation-store.js +78 -0
  54. package/dist/conversation-store.test.d.ts +2 -0
  55. package/dist/conversation-store.test.d.ts.map +1 -0
  56. package/dist/conversation-store.test.js +106 -0
  57. package/dist/discord-listener.d.ts +88 -0
  58. package/dist/discord-listener.d.ts.map +1 -0
  59. package/dist/discord-listener.js +118 -0
  60. package/dist/discord-listener.test.d.ts +2 -0
  61. package/dist/discord-listener.test.d.ts.map +1 -0
  62. package/dist/discord-listener.test.js +361 -0
  63. package/dist/download-files.d.ts +94 -0
  64. package/dist/download-files.d.ts.map +1 -0
  65. package/dist/download-files.js +145 -0
  66. package/dist/download-files.test.d.ts +2 -0
  67. package/dist/download-files.test.d.ts.map +1 -0
  68. package/dist/download-files.test.js +181 -0
  69. package/dist/event-renderer.d.ts +36 -0
  70. package/dist/event-renderer.d.ts.map +1 -0
  71. package/dist/event-renderer.js +270 -0
  72. package/dist/event-renderer.test.d.ts +2 -0
  73. package/dist/event-renderer.test.d.ts.map +1 -0
  74. package/dist/event-renderer.test.js +160 -0
  75. package/dist/index.d.ts +24 -0
  76. package/dist/index.d.ts.map +1 -0
  77. package/dist/index.js +17 -0
  78. package/dist/interaction.d.ts +11 -0
  79. package/dist/interaction.d.ts.map +1 -0
  80. package/dist/interaction.js +138 -0
  81. package/dist/interaction.test.d.ts +2 -0
  82. package/dist/interaction.test.d.ts.map +1 -0
  83. package/dist/interaction.test.js +120 -0
  84. package/dist/markdown.d.ts +7 -0
  85. package/dist/markdown.d.ts.map +1 -0
  86. package/dist/markdown.js +66 -0
  87. package/dist/markdown.test.d.ts +2 -0
  88. package/dist/markdown.test.d.ts.map +1 -0
  89. package/dist/markdown.test.js +47 -0
  90. package/dist/message-stream.d.ts +45 -0
  91. package/dist/message-stream.d.ts.map +1 -0
  92. package/dist/message-stream.js +76 -0
  93. package/dist/message-stream.test.d.ts +2 -0
  94. package/dist/message-stream.test.d.ts.map +1 -0
  95. package/dist/message-stream.test.js +162 -0
  96. package/dist/pending-interactions.d.ts +26 -0
  97. package/dist/pending-interactions.d.ts.map +1 -0
  98. package/dist/pending-interactions.js +50 -0
  99. package/dist/pending-interactions.test.d.ts +2 -0
  100. package/dist/pending-interactions.test.d.ts.map +1 -0
  101. package/dist/pending-interactions.test.js +73 -0
  102. package/dist/render/budget.d.ts +28 -0
  103. package/dist/render/budget.d.ts.map +1 -0
  104. package/dist/render/budget.js +49 -0
  105. package/dist/render/budget.test.d.ts +2 -0
  106. package/dist/render/budget.test.d.ts.map +1 -0
  107. package/dist/render/budget.test.js +59 -0
  108. package/dist/render/components-v2.d.ts +16 -0
  109. package/dist/render/components-v2.d.ts.map +1 -0
  110. package/dist/render/components-v2.js +396 -0
  111. package/dist/render/components-v2.test.d.ts +2 -0
  112. package/dist/render/components-v2.test.d.ts.map +1 -0
  113. package/dist/render/components-v2.test.js +395 -0
  114. package/dist/render/modal.d.ts +12 -0
  115. package/dist/render/modal.d.ts.map +1 -0
  116. package/dist/render/modal.js +44 -0
  117. package/dist/types.d.ts +16 -0
  118. package/dist/types.d.ts.map +1 -0
  119. package/dist/types.js +4 -0
  120. package/dist/types.test.d.ts +2 -0
  121. package/dist/types.test.d.ts.map +1 -0
  122. package/dist/types.test.js +12 -0
  123. package/package.json +56 -0
@@ -0,0 +1,343 @@
1
+ import { describe, it, expect, vi } from "vitest";
2
+ import { MessageFlags } from "discord.js";
3
+ import { DiscordAdapter, discord } from "./adapter.js";
4
+ function fakeClient() {
5
+ const handlers = {};
6
+ return {
7
+ on(e, cb) {
8
+ handlers[e] = cb;
9
+ },
10
+ once(e, cb) {
11
+ handlers[e] = cb;
12
+ },
13
+ login: vi.fn(async () => "ok"),
14
+ destroy: vi.fn(async () => { }),
15
+ user: { id: "bot-1" },
16
+ channels: {
17
+ fetch: vi.fn(async () => ({
18
+ id: "c1",
19
+ send: vi.fn(async () => ({ id: "m1" })),
20
+ })),
21
+ },
22
+ emit(e, a) {
23
+ handlers[e]?.(a);
24
+ },
25
+ };
26
+ }
27
+ const sink = () => ({
28
+ onTurn: vi.fn(),
29
+ onInteraction: vi.fn(),
30
+ onCommand: vi.fn(),
31
+ onModalSubmit: vi.fn(),
32
+ });
33
+ describe("DiscordAdapter", () => {
34
+ it("advertises Discord capabilities", () => {
35
+ const a = new DiscordAdapter({ botToken: "t", appId: "app" });
36
+ expect(a.platform).toBe("discord");
37
+ expect(a.capabilities.supportsModals).toBe(true);
38
+ expect(a.capabilities.supportsTyping).toBe(true);
39
+ expect(a.capabilities.supportsReactions).toBe(true);
40
+ expect(a.capabilities.supportsEphemeral).toBe(false);
41
+ expect(a.capabilities.supportsStreaming).toBe(true);
42
+ expect(a.capabilities.maxBlocksPerMessage).toBe(40);
43
+ expect(a.ackDeadlineMs).toBe(3000);
44
+ });
45
+ it("renders IR to a components-v2 container", () => {
46
+ const a = new DiscordAdapter({ botToken: "t", appId: "app" });
47
+ const out = a.render([
48
+ {
49
+ type: "message",
50
+ props: { children: { type: "text", props: { value: "hi" } } },
51
+ },
52
+ ]);
53
+ expect(out).toBeTruthy(); // ContainerBuilder
54
+ });
55
+ it("logs in and captures the bot id on start, publishing commands on ready", async () => {
56
+ const client = fakeClient();
57
+ const put = vi.fn(async () => { });
58
+ const a = new DiscordAdapter({ botToken: "t", appId: "app" }, { client: client, rest: { put } });
59
+ await a.start(sink());
60
+ expect(client.login).toHaveBeenCalledWith("t");
61
+ expect(put).not.toHaveBeenCalled();
62
+ // Stash a non-empty command list — publishCommands guards an empty list (an
63
+ // empty PUT would clear all of the bot's commands), so a command must be
64
+ // registered for the once("ready") publish to PUT anything.
65
+ a.registerCommands([{ name: "agent", description: "x" }]);
66
+ client.emit("ready", client); // discord.js passes the ready client
67
+ // ready handler is async; flush microtasks
68
+ await Promise.resolve();
69
+ await Promise.resolve();
70
+ expect(put).toHaveBeenCalledTimes(1);
71
+ });
72
+ it("discord() factory returns an adapter", () => {
73
+ expect(discord({ botToken: "t", appId: "app" })).toBeInstanceOf(DiscordAdapter);
74
+ });
75
+ it("stream() edits each posted message with ITS own chunk, not all-on-first", async () => {
76
+ // Each send() mints a distinct id and its own edit spy, so we can assert
77
+ // the per-message Map wiring (chunk N → message N), not chunk-on-#0.
78
+ const posted = [];
79
+ let n = 0;
80
+ const channel = {
81
+ id: "c1",
82
+ send: vi.fn(async () => {
83
+ const m = { id: `m${++n}`, edit: vi.fn(async () => { }) };
84
+ posted.push(m);
85
+ return m;
86
+ }),
87
+ messages: { fetch: vi.fn() },
88
+ };
89
+ const client = {
90
+ ...fakeClient(),
91
+ channels: { fetch: vi.fn(async () => channel) },
92
+ };
93
+ const a = new DiscordAdapter({ botToken: "t", appId: "app" }, { client: client, rest: { put: vi.fn() } });
94
+ // Two chunks: >2000 chars forces ChunkedMessageStream to post a second
95
+ // Discord message. Distinct markers per half let us check routing.
96
+ const first = "A".repeat(1500) + "\n";
97
+ const second = "B".repeat(1500);
98
+ async function* chunks() {
99
+ yield first;
100
+ yield second;
101
+ }
102
+ const ref = await a.stream({ channelId: "c1" }, chunks());
103
+ // Two messages posted, ref points at the first.
104
+ expect(posted.length).toBe(2);
105
+ expect(ref.id).toBe("m1");
106
+ // The first message's final edit must NOT contain the second chunk's
107
+ // marker, and the second message's final edit must contain it. If the
108
+ // Map were ignored (old bug), every edit would land on message #0.
109
+ const firstFinal = posted[0].edit.mock.calls.at(-1)?.[0];
110
+ const secondFinal = posted[1].edit.mock.calls.at(-1)?.[0];
111
+ expect(firstFinal).toContain("A");
112
+ expect(firstFinal).not.toContain("B");
113
+ expect(secondFinal).toContain("B");
114
+ });
115
+ it("resolveUser does NOT cache the bare-id fallback on transient fetch failure", async () => {
116
+ const fetch = vi
117
+ .fn()
118
+ // first call throws → bare-id fallback, must not be cached
119
+ .mockRejectedValueOnce(new Error("rate limited"))
120
+ // second call succeeds → real user
121
+ .mockResolvedValueOnce({ id: "u1", globalName: "Ada", username: "ada" });
122
+ const client = { ...fakeClient(), users: { fetch } };
123
+ const a = new DiscordAdapter({ botToken: "t", appId: "app" }, { client: client, rest: { put: vi.fn() } });
124
+ const first = await a.resolveUser("u1");
125
+ expect(first).toEqual({ id: "u1" }); // bare-id fallback
126
+ const second = await a.resolveUser("u1");
127
+ // A retry happened (not served from cache) and resolved the real user.
128
+ expect(fetch).toHaveBeenCalledTimes(2);
129
+ expect(second).toEqual({ id: "u1", name: "Ada", handle: "ada" });
130
+ });
131
+ it("getMessages excludes the bot's own streaming placeholders from history", async () => {
132
+ // Mix: a user message, a real bot reply, and a bot streaming placeholder.
133
+ // fetch() returns a Map-like with values(); getMessages reverses + filters.
134
+ const messages = [
135
+ {
136
+ id: "m1",
137
+ content: "hey bot",
138
+ author: { id: "u1", bot: false, username: "ann", globalName: "Ann" },
139
+ },
140
+ {
141
+ id: "m2",
142
+ content: "_thinking…_", // bot placeholder — must be excluded
143
+ author: { id: "bot-1", bot: true, username: "bot" },
144
+ },
145
+ {
146
+ id: "m3",
147
+ content: "here is the real answer",
148
+ author: { id: "bot-1", bot: true, username: "bot" },
149
+ },
150
+ ];
151
+ const channel = {
152
+ id: "c1",
153
+ send: vi.fn(async () => ({ id: "x" })), // fetchSendable requires `send`
154
+ messages: {
155
+ fetch: vi.fn(async () => new Map(messages.map((m) => [m.id, m]))),
156
+ },
157
+ };
158
+ const client = {
159
+ ...fakeClient(),
160
+ channels: { fetch: vi.fn(async () => channel) },
161
+ };
162
+ const a = new DiscordAdapter({ botToken: "t", appId: "app" }, { client: client, rest: { put: vi.fn() } });
163
+ const out = await a.getMessages({ channelId: "c1" });
164
+ const texts = out.map((m) => m.text);
165
+ expect(texts).toContain("hey bot");
166
+ expect(texts).toContain("here is the real answer");
167
+ expect(texts).not.toContain("_thinking…_");
168
+ expect(out).toHaveLength(2);
169
+ });
170
+ it("addReaction falls back to the target channel when the reacted ref has no channelId", async () => {
171
+ // The reacted ref the bot-ui example sends is just `{ id }` (no channelId);
172
+ // the channel must come from the conversation's reply target — parity with
173
+ // Slack/Telegram. Regression: previously Discord resolved fetchSendable("")
174
+ // and the react silently failed (acks never fired on Discord).
175
+ const message = { react: vi.fn(async () => undefined) };
176
+ const channel = {
177
+ id: "c1",
178
+ send: vi.fn(async () => ({ id: "x" })),
179
+ messages: { fetch: vi.fn(async () => message) },
180
+ };
181
+ const channelsFetch = vi.fn(async (id) => {
182
+ if (!id)
183
+ throw new Error("channel is not sendable"); // empty id is the bug
184
+ return channel;
185
+ });
186
+ const client = { ...fakeClient(), channels: { fetch: channelsFetch } };
187
+ const a = new DiscordAdapter({ botToken: "t", appId: "app" }, { client: client, rest: { put: vi.fn() } });
188
+ const res = await a.addReaction({ channelId: "c1" }, { id: "m1" }, "eyes");
189
+ expect(res).toEqual({ ok: true });
190
+ // Resolved the TARGET channel, never the empty string.
191
+ expect(channelsFetch).toHaveBeenCalledWith("c1");
192
+ expect(channelsFetch).not.toHaveBeenCalledWith("");
193
+ expect(message.react).toHaveBeenCalled();
194
+ });
195
+ it("interactionCreate dispatch failures are caught, not left unhandled", async () => {
196
+ const client = fakeClient();
197
+ const a = new DiscordAdapter({ botToken: "t", appId: "app" }, { client: client, rest: { put: vi.fn() } });
198
+ const s = sink();
199
+ // sink.onInteraction rejects — without the try/catch this becomes an
200
+ // unhandled promise rejection inside the event handler.
201
+ s.onInteraction.mockRejectedValue(new Error("dispatch boom"));
202
+ const errSpy = vi.spyOn(console, "error").mockImplementation(() => { });
203
+ await a.start(s);
204
+ // A well-formed button interaction that decodes to an event.
205
+ const deferUpdate = vi.fn(async () => { });
206
+ const interaction = {
207
+ isButton: () => true,
208
+ isStringSelectMenu: () => false,
209
+ id: "int-1",
210
+ customId: "btn-1",
211
+ channelId: "c1",
212
+ user: { id: "u1", username: "ada" },
213
+ message: { id: "m1" },
214
+ deferUpdate,
215
+ };
216
+ // emit() invokes the (async) handler synchronously; the body runs across
217
+ // several microtask turns (deferUpdate → decode → dispatch → catch). If the
218
+ // rejection escaped the handler, it would surface as an unhandled rejection
219
+ // (and crash under --unhandled-rejections=throw) rather than the logged path.
220
+ client.emit("interactionCreate", interaction);
221
+ await Promise.resolve();
222
+ await Promise.resolve();
223
+ await Promise.resolve();
224
+ expect(deferUpdate).toHaveBeenCalledTimes(1);
225
+ expect(s.onInteraction).toHaveBeenCalledTimes(1);
226
+ expect(errSpy).toHaveBeenCalledWith("[bot-discord] interaction dispatch failed:", expect.any(Error));
227
+ errSpy.mockRestore();
228
+ });
229
+ // A modal opened from a slash command yields a ModalSubmitInteraction with
230
+ // no originating message — `deferUpdate()` is invalid there and throws. The
231
+ // ack must use `deferReply` (ephemeral) for that origin, and must never throw
232
+ // uncaught.
233
+ function fakeModalSubmit(over) {
234
+ return {
235
+ isButton: () => false,
236
+ isStringSelectMenu: () => false,
237
+ isModalSubmit: () => true,
238
+ customId: "triage",
239
+ channelId: "c1",
240
+ guildId: "g1",
241
+ user: { id: "u1", username: "ada" },
242
+ fields: {
243
+ fields: new Map([["summary", { customId: "summary", value: "x" }]]),
244
+ },
245
+ replied: false,
246
+ deferred: false,
247
+ ...over,
248
+ };
249
+ }
250
+ it("modal-submit from a slash command acks with deferReply (ephemeral), not deferUpdate", async () => {
251
+ const client = fakeClient();
252
+ const a = new DiscordAdapter({ botToken: "t", appId: "app" }, { client: client, rest: { put: vi.fn() } });
253
+ const s = sink();
254
+ await a.start(s);
255
+ // Slash-command origin: isFromMessage() === false. deferUpdate would throw
256
+ // for such an interaction in discord.js, so simulate that to prove the
257
+ // correct method is chosen (and that a stray call would surface).
258
+ const deferReply = vi.fn(async (_opts) => { });
259
+ const deferUpdate = vi.fn(async () => {
260
+ throw new Error("interaction not from message");
261
+ });
262
+ const interaction = fakeModalSubmit({
263
+ isFromMessage: () => false,
264
+ deferReply,
265
+ deferUpdate,
266
+ });
267
+ client.emit("interactionCreate", interaction);
268
+ for (let i = 0; i < 5; i++)
269
+ await Promise.resolve();
270
+ expect(s.onModalSubmit).toHaveBeenCalledTimes(1);
271
+ expect(deferReply).toHaveBeenCalledTimes(1);
272
+ // ephemeral flag passed
273
+ expect(deferReply.mock.calls[0]?.[0]).toMatchObject({
274
+ flags: MessageFlags.Ephemeral,
275
+ });
276
+ expect(deferUpdate).not.toHaveBeenCalled();
277
+ });
278
+ it("modal-submit from a message component acks with deferUpdate, not deferReply", async () => {
279
+ const client = fakeClient();
280
+ const a = new DiscordAdapter({ botToken: "t", appId: "app" }, { client: client, rest: { put: vi.fn() } });
281
+ const s = sink();
282
+ await a.start(s);
283
+ const deferReply = vi.fn(async () => { });
284
+ const deferUpdate = vi.fn(async () => { });
285
+ const interaction = fakeModalSubmit({
286
+ isFromMessage: () => true,
287
+ deferReply,
288
+ deferUpdate,
289
+ });
290
+ client.emit("interactionCreate", interaction);
291
+ for (let i = 0; i < 5; i++)
292
+ await Promise.resolve();
293
+ expect(s.onModalSubmit).toHaveBeenCalledTimes(1);
294
+ expect(deferUpdate).toHaveBeenCalledTimes(1);
295
+ expect(deferReply).not.toHaveBeenCalled();
296
+ });
297
+ it("openModal opens a modal for a slash-command interaction (commandPending registry)", async () => {
298
+ // The bug: openModal only consulted the component registry (`pending`), so a
299
+ // modal opened from a slash command — whose live interaction lives in
300
+ // `commandPending` — silently failed. openModal must try BOTH registries.
301
+ const client = fakeClient();
302
+ const a = new DiscordAdapter({ botToken: "t", appId: "app" }, { client: client, rest: { put: vi.fn() } });
303
+ // `commandPending` is constructed in start(); spin it up with the fake client.
304
+ await a.start(sink());
305
+ // Register a live slash-command interaction in the COMMAND registry only
306
+ // (not the component `pending` one), then open a modal against its trigger.
307
+ const showModal = vi.fn(async () => { });
308
+ const triggerId = a.commandPending.register({ id: "cmdTrigger", showModal });
309
+ // A minimal valid modal IR: a <Modal> root with no children renders fine
310
+ // (zero text inputs is allowed; renderDiscordModal only throws on
311
+ // unsupported elements or >5 inputs).
312
+ const modalIr = [
313
+ { type: "modal", props: { callbackId: "x", title: "t", children: [] } },
314
+ ];
315
+ const res = await a.openModal({ channelId: "c1" }, triggerId, modalIr);
316
+ expect(showModal).toHaveBeenCalledTimes(1);
317
+ expect(res).toEqual({ ok: true });
318
+ });
319
+ it("registerCommands never clears on empty, and publishes when already ready", async () => {
320
+ const client = fakeClient();
321
+ const put = vi.fn(async (_route, _opts) => undefined);
322
+ const a = new DiscordAdapter({ botToken: "t", appId: "app" }, { client: client, rest: { put } });
323
+ await a.start(sink());
324
+ // (i) An empty command list must NOT PUT — an empty PUT clears all of the
325
+ // bot's registered commands. Register empty, then fire `ready`.
326
+ a.registerCommands([]);
327
+ client.emit("ready", client);
328
+ await Promise.resolve();
329
+ await Promise.resolve();
330
+ expect(put).not.toHaveBeenCalled();
331
+ // (ii) Registering after `ready` must publish immediately (the once("ready")
332
+ // publish already ran, so a later registerCommands has to PUT itself).
333
+ a.registerCommands([{ name: "agent", description: "x" }]);
334
+ await Promise.resolve();
335
+ await Promise.resolve();
336
+ expect(put).toHaveBeenCalledTimes(1);
337
+ // The published body carries the registered command.
338
+ const body = put.mock.calls[0]?.[1];
339
+ expect(body.body).toEqual([
340
+ expect.objectContaining({ name: "agent", description: "x" }),
341
+ ]);
342
+ });
343
+ });
@@ -0,0 +1,61 @@
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 Discord,
5
+ * the rest of the message renders broken (a stray ``` will turn the rest
6
+ * of the Discord 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 Discord message.
13
+ *
14
+ * Operates in **markdown space** (i.e. on the raw text from the agent).
15
+ * Discord renders a Markdown subset natively, so a balanced document
16
+ * displays correctly without further translation.
17
+ *
18
+ * What it handles, in priority order:
19
+ *
20
+ * 1. Unclosed fenced code blocks ``` … (most severe — leaks code styling
21
+ * through the rest of the Discord message)
22
+ * 2. Unclosed inline code `…`
23
+ * 3. Unclosed bold `**…` / `__…`
24
+ * 4. Unclosed italic `*…` / `_…`
25
+ * 5. Unclosed strike `~~…`
26
+ *
27
+ * Heuristics:
28
+ *
29
+ * - A marker with no content after it (e.g. trailing `**`) is **not**
30
+ * closed — the agent might be just opening the bracket; closing would
31
+ * produce a transient `****` which looks worse than `**`.
32
+ * - Markers inside code/fence regions don't count.
33
+ * - When closing, we emit closers in reverse stack order (innermost
34
+ * first) so the rendered structure is well-nested.
35
+ */
36
+ export declare function autoCloseOpenMarkdown(text: string): string;
37
+ /**
38
+ * What markdown context is *still open* at the end of `text`? Used to
39
+ * decide what opening marker(s) the NEXT chunk needs prepended when we
40
+ * split a long buffer across multiple Discord messages mid-formatting
41
+ * — without this, a chunk boundary inside a ` ```python … ``` ` block
42
+ * would have the second Discord message start with raw code that Discord
43
+ * renders as plain text.
44
+ */
45
+ export interface OpenMarkdownContext {
46
+ /** If inside an unclosed fence, the language tag (may be ""). null otherwise. */
47
+ fenceLang: string | null;
48
+ /** True if an unclosed single-backtick inline-code span is in flight. */
49
+ inlineCode: boolean;
50
+ /** Bold/italic/strike markers still open, in stack order (outermost first). */
51
+ brackets: string[];
52
+ }
53
+ export declare function detectOpenContext(text: string): OpenMarkdownContext;
54
+ /**
55
+ * Render the opener prefix for a continuation chunk so it can stand on
56
+ * its own as a self-renderable markdown fragment. E.g. for an open
57
+ * `` ```python `` fence, returns ` ```python\n `; for an open `**` bold,
58
+ * returns `**`.
59
+ */
60
+ export declare function renderContextOpener(ctx: OpenMarkdownContext): string;
61
+ //# 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;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAkCG;AAKH,wBAAgB,qBAAqB,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CA+E1D;AAqFD;;;;;;;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"}
@@ -0,0 +1,276 @@
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 Discord,
5
+ * the rest of the message renders broken (a stray ``` will turn the rest
6
+ * of the Discord 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 Discord message.
13
+ *
14
+ * Operates in **markdown space** (i.e. on the raw text from the agent).
15
+ * Discord renders a Markdown subset natively, so a balanced document
16
+ * displays correctly without further translation.
17
+ *
18
+ * What it handles, in priority order:
19
+ *
20
+ * 1. Unclosed fenced code blocks ``` … (most severe — leaks code styling
21
+ * through the rest of the Discord message)
22
+ * 2. Unclosed inline code `…`
23
+ * 3. Unclosed bold `**…` / `__…`
24
+ * 4. Unclosed italic `*…` / `_…`
25
+ * 5. Unclosed strike `~~…`
26
+ *
27
+ * Heuristics:
28
+ *
29
+ * - A marker with no content after it (e.g. trailing `**`) is **not**
30
+ * closed — the agent might be just opening the bracket; closing would
31
+ * produce a transient `****` which looks worse than `**`.
32
+ * - Markers inside code/fence regions don't count.
33
+ * - When closing, we emit closers in reverse stack order (innermost
34
+ * first) so the rendered structure is well-nested.
35
+ */
36
+ const SENTINEL_FENCE = "";
37
+ const SENTINEL_INLINE = "";
38
+ export function autoCloseOpenMarkdown(text) {
39
+ if (!text)
40
+ return text;
41
+ // ── 1. Fenced code blocks ──────────────────────────────────────────
42
+ // Pair complete fences first, then determine if there's a dangling open.
43
+ const fences = [];
44
+ let work = text.replace(/```[\s\S]*?```/g, (m) => {
45
+ fences.push(m);
46
+ return `${SENTINEL_FENCE}${fences.length - 1}${SENTINEL_FENCE}`;
47
+ });
48
+ const openFenceIdx = work.indexOf("```");
49
+ let openFenceTail = "";
50
+ if (openFenceIdx >= 0) {
51
+ openFenceTail = work.slice(openFenceIdx);
52
+ work = work.slice(0, openFenceIdx);
53
+ }
54
+ // ── 2. Inline code regions ─────────────────────────────────────────
55
+ const inlines = [];
56
+ work = work.replace(/`[^`\n]+`/g, (m) => {
57
+ inlines.push(m);
58
+ return `${SENTINEL_INLINE}${inlines.length - 1}${SENTINEL_INLINE}`;
59
+ });
60
+ // Dangling open inline backtick? Only count if there's content after it.
61
+ const openBacktickIdx = work.indexOf("`");
62
+ let openBacktickTail = "";
63
+ if (openBacktickIdx >= 0) {
64
+ openBacktickTail = work.slice(openBacktickIdx);
65
+ work = work.slice(0, openBacktickIdx);
66
+ }
67
+ // ── 3. Bold / italic / strike via stack scan ──────────────────────
68
+ const stack = scanBracketStack(work);
69
+ // Closers in pop order (innermost first) so the rendered structure
70
+ // nests correctly: e.g. `**bold _italic` → close `_` then `**`.
71
+ const closers = stack.slice().toReversed().join("");
72
+ // ── 4. Reassemble ─────────────────────────────────────────────────
73
+ // Insert closers BEFORE any trailing whitespace on the work text so a
74
+ // buffer like "**bold " produces "**bold**" rather than "**bold **".
75
+ let output = work;
76
+ if (closers) {
77
+ const trail = output.match(/\s*$/)?.[0] ?? "";
78
+ output = output.slice(0, output.length - trail.length) + closers + trail;
79
+ }
80
+ // Inline backtick: if dangling open has content after it, close it.
81
+ if (openBacktickTail) {
82
+ output += openBacktickTail;
83
+ if (hasContentAfterMarker(openBacktickTail, "`")) {
84
+ output += "`";
85
+ }
86
+ }
87
+ // Restore inline regions (paired backticks that were untouched).
88
+ output = output.replace(new RegExp(`${SENTINEL_INLINE}(\\d+)${SENTINEL_INLINE}`, "g"), (_, idx) => inlines[Number(idx)] ?? "");
89
+ // Fenced code: if there's real code content (text after the optional
90
+ // language line), close the fence. A buffer like "```py" or "```py\n"
91
+ // is "just opened" and should NOT yet be closed.
92
+ if (openFenceTail) {
93
+ output += openFenceTail;
94
+ if (hasFenceCodeContent(openFenceTail)) {
95
+ output += openFenceTail.endsWith("\n") ? "```" : "\n```";
96
+ }
97
+ }
98
+ // Restore paired fences.
99
+ output = output.replace(new RegExp(`${SENTINEL_FENCE}(\\d+)${SENTINEL_FENCE}`, "g"), (_, idx) => fences[Number(idx)] ?? "");
100
+ return output;
101
+ }
102
+ /**
103
+ * Walks `text` left-to-right and returns the unbalanced bracket stack.
104
+ * Recognised markers (longer ones first): `**`, `__`, `~~`, then `*`, `_`.
105
+ *
106
+ * Standalone single-char markers don't match when they're part of the
107
+ * doubled variant — `**` is parsed as one bold marker, not two italics.
108
+ */
109
+ function scanBracketStack(text) {
110
+ // Each entry remembers the marker AND the position where it was pushed
111
+ // during the scan. Tracking the actual push position is required for the
112
+ // trailing-strip step below: a single-char marker (`*`) is a substring of
113
+ // its doubled variant (`**`), so `text.lastIndexOf("*")` could match the
114
+ // `*` inside a later `**` and wrongly classify a marker with real content
115
+ // as empty (e.g. `"*ab**"` would lose its open italic).
116
+ const stack = [];
117
+ const tryToggle = (m, index) => {
118
+ if (stack[stack.length - 1]?.marker === m)
119
+ stack.pop();
120
+ else
121
+ stack.push({ marker: m, index });
122
+ };
123
+ let i = 0;
124
+ while (i < text.length) {
125
+ if (text.startsWith("**", i)) {
126
+ tryToggle("**", i);
127
+ i += 2;
128
+ }
129
+ else if (text.startsWith("__", i)) {
130
+ tryToggle("__", i);
131
+ i += 2;
132
+ }
133
+ else if (text.startsWith("~~", i)) {
134
+ tryToggle("~~", i);
135
+ i += 2;
136
+ }
137
+ else if (text[i] === "*") {
138
+ tryToggle("*", i);
139
+ i += 1;
140
+ }
141
+ else if (text[i] === "_") {
142
+ tryToggle("_", i);
143
+ i += 1;
144
+ }
145
+ else {
146
+ i += 1;
147
+ }
148
+ }
149
+ // Strip any marker at the top of the stack that has NO content after it
150
+ // (i.e. it appears at the very end of the buffer with only whitespace
151
+ // following). Closing those would produce a transient `****`-style
152
+ // artefact that looks worse than the open `**`. We use the stored push
153
+ // index (not lastIndexOf) so substring markers can't be mis-located.
154
+ while (stack.length > 0) {
155
+ const top = stack[stack.length - 1];
156
+ const after = text.slice(top.index + top.marker.length);
157
+ if (/^\s*$/.test(after)) {
158
+ stack.pop();
159
+ }
160
+ else {
161
+ break;
162
+ }
163
+ }
164
+ return stack.map((e) => e.marker);
165
+ }
166
+ function hasContentAfterMarker(tail, marker) {
167
+ if (!tail.startsWith(marker))
168
+ return false;
169
+ const after = tail.slice(marker.length);
170
+ return /\S/.test(after);
171
+ }
172
+ /**
173
+ * True if a `\`\`\`<lang>?\n<content>` buffer has actual code content
174
+ * past the optional language line. We treat the first newline as the
175
+ * boundary between the (optional) language tag and code body.
176
+ */
177
+ function hasFenceCodeContent(tail) {
178
+ if (!tail.startsWith("```"))
179
+ return false;
180
+ const after = tail.slice(3);
181
+ const nl = after.indexOf("\n");
182
+ if (nl < 0) {
183
+ // No newline yet — still on the language line. Nothing to close.
184
+ return false;
185
+ }
186
+ const code = after.slice(nl + 1);
187
+ return /\S/.test(code);
188
+ }
189
+ export function detectOpenContext(text) {
190
+ if (!text)
191
+ return { fenceLang: null, inlineCode: false, brackets: [] };
192
+ // ── Fence ────────────────────────────────────────────────────────
193
+ // Pair complete fences off the text; what remains tells us if a fence
194
+ // is still open and (if so) what its language tag is.
195
+ const paired = text.replace(/```[\s\S]*?```/g, "");
196
+ const openFenceIdx = paired.indexOf("```");
197
+ let fenceLang = null;
198
+ let remainder = paired;
199
+ if (openFenceIdx >= 0) {
200
+ // Everything from openFenceIdx onward is inside the open fence.
201
+ const tail = paired.slice(openFenceIdx);
202
+ const firstLine = tail.slice(3).split("\n", 1)[0] ?? "";
203
+ fenceLang = firstLine.trim();
204
+ remainder = paired.slice(0, openFenceIdx);
205
+ }
206
+ // ── Inline code (outside fences) ─────────────────────────────────
207
+ // Strip paired inline backticks; an odd remaining ` means open.
208
+ remainder = remainder.replace(/`[^`\n]+`/g, "");
209
+ const inlineCode = (remainder.match(/`/g) || []).length % 2 === 1;
210
+ // Cut at the last lone backtick — inside-inline content shouldn't
211
+ // contribute to bracket counting.
212
+ if (inlineCode) {
213
+ const lastBt = remainder.lastIndexOf("`");
214
+ if (lastBt >= 0)
215
+ remainder = remainder.slice(0, lastBt);
216
+ }
217
+ // ── Brackets (bold/italic/strike) ────────────────────────────────
218
+ const brackets = scanBracketStackForContext(remainder);
219
+ return { fenceLang, inlineCode, brackets };
220
+ }
221
+ /** Like scanBracketStack but exposed for context detection (no whitespace strip). */
222
+ function scanBracketStackForContext(text) {
223
+ const stack = [];
224
+ const tryToggle = (m) => {
225
+ if (stack[stack.length - 1] === m)
226
+ stack.pop();
227
+ else
228
+ stack.push(m);
229
+ };
230
+ let i = 0;
231
+ while (i < text.length) {
232
+ if (text.startsWith("**", i)) {
233
+ tryToggle("**");
234
+ i += 2;
235
+ }
236
+ else if (text.startsWith("__", i)) {
237
+ tryToggle("__");
238
+ i += 2;
239
+ }
240
+ else if (text.startsWith("~~", i)) {
241
+ tryToggle("~~");
242
+ i += 2;
243
+ }
244
+ else if (text[i] === "*") {
245
+ tryToggle("*");
246
+ i += 1;
247
+ }
248
+ else if (text[i] === "_") {
249
+ tryToggle("_");
250
+ i += 1;
251
+ }
252
+ else {
253
+ i += 1;
254
+ }
255
+ }
256
+ return stack;
257
+ }
258
+ /**
259
+ * Render the opener prefix for a continuation chunk so it can stand on
260
+ * its own as a self-renderable markdown fragment. E.g. for an open
261
+ * `` ```python `` fence, returns ` ```python\n `; for an open `**` bold,
262
+ * returns `**`.
263
+ */
264
+ export function renderContextOpener(ctx) {
265
+ // Fences are exclusive — if a fence is open, no other markers count
266
+ // (they're inside opaque code).
267
+ if (ctx.fenceLang !== null) {
268
+ return "```" + (ctx.fenceLang ?? "") + "\n";
269
+ }
270
+ let out = "";
271
+ for (const m of ctx.brackets)
272
+ out += m;
273
+ if (ctx.inlineCode)
274
+ out += "`";
275
+ return out;
276
+ }